mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-25 06:04:54 +00:00
Compare commits
1 Commits
tier-1-boo
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
| b57c4014ca |
@@ -7,30 +7,9 @@ import { ComponentBindingsCard } from '@/components/data-binding-designer/Compon
|
||||
import { HowItWorksCard } from '@/components/data-binding-designer/HowItWorksCard'
|
||||
import dataBindingCopy from '@/data/data-binding-designer.json'
|
||||
|
||||
interface SeedDataSource extends Omit<DataSource, 'compute'> {
|
||||
computeId?: string
|
||||
}
|
||||
|
||||
const computeRegistry: Record<string, (data: Record<string, any>) => any> = {
|
||||
displayName: (data) => `Welcome, ${data.userProfile?.name || 'Guest'}!`,
|
||||
}
|
||||
|
||||
const buildSeedDataSources = (sources: SeedDataSource[]): DataSource[] => {
|
||||
return sources.map((source) => {
|
||||
if (source.type === 'computed' && source.computeId) {
|
||||
return {
|
||||
...source,
|
||||
compute: computeRegistry[source.computeId],
|
||||
}
|
||||
}
|
||||
|
||||
return source
|
||||
})
|
||||
}
|
||||
|
||||
export function DataBindingDesigner() {
|
||||
const [dataSources, setDataSources] = useState<DataSource[]>(
|
||||
buildSeedDataSources(dataBindingCopy.seed.dataSources as SeedDataSource[]),
|
||||
dataBindingCopy.seed.dataSources as DataSource[],
|
||||
)
|
||||
|
||||
const [mockComponents] = useState<UIComponent[]>(dataBindingCopy.seed.components)
|
||||
|
||||
@@ -45,11 +45,12 @@ function getCompletionMessage(score: number): string {
|
||||
}
|
||||
|
||||
export function ProjectDashboard(props: ProjectDashboardProps) {
|
||||
const completionSummary = calculateCompletionScore(props)
|
||||
|
||||
return (
|
||||
<JSONPageRenderer
|
||||
schema={dashboardSchema as any}
|
||||
data={props}
|
||||
functions={{ calculateCompletionScore }}
|
||||
data={{ ...props, completionSummary }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DataSourceType } from '@/types/json-ui'
|
||||
import { Database, Function, File } from '@phosphor-icons/react'
|
||||
import { Database, File } from '@phosphor-icons/react'
|
||||
|
||||
interface DataSourceBadgeProps {
|
||||
type: DataSourceType
|
||||
@@ -13,11 +13,6 @@ const dataSourceConfig = {
|
||||
label: 'KV Storage',
|
||||
className: 'bg-accent/20 text-accent border-accent/30'
|
||||
},
|
||||
computed: {
|
||||
icon: Function,
|
||||
label: 'Computed',
|
||||
className: 'bg-primary/20 text-primary border-primary/30'
|
||||
},
|
||||
static: {
|
||||
icon: File,
|
||||
label: 'Static',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { StatCard } from '@/components/atoms'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getIcon, resolveBinding } from './utils'
|
||||
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
|
||||
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
|
||||
import { LegacyPageSchema, PageSectionConfig } from './types'
|
||||
|
||||
interface PageSectionRendererProps {
|
||||
@@ -107,8 +109,21 @@ function PageCard({ card, data, functions }: PageCardProps) {
|
||||
const icon = card.icon ? getIcon(card.icon) : null
|
||||
|
||||
if (card.type === 'gradient-card') {
|
||||
const computeFn = functions[card.dataSource?.compute]
|
||||
const computedData = computeFn ? computeFn(data) : {}
|
||||
const dataSource = card.dataSource
|
||||
let computedData: Record<string, any> = {}
|
||||
|
||||
if (dataSource?.expression) {
|
||||
const resolved = evaluateBindingExpression(dataSource.expression, data, {
|
||||
fallback: {},
|
||||
label: `dashboard card (${card.id})`,
|
||||
})
|
||||
computedData = resolved || {}
|
||||
} else if (dataSource?.valueTemplate) {
|
||||
computedData = evaluateTemplate(dataSource.valueTemplate, { data })
|
||||
} else if (dataSource?.compute) {
|
||||
const computeFn = functions[dataSource.compute]
|
||||
computedData = computeFn ? computeFn(data) : {}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('bg-gradient-to-br border-primary/20', card.gradient)}>
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { Card, Badge, IconButton, Stack, Flex, Text } from '@/components/atoms'
|
||||
import { Card, IconButton, Stack, Flex, Text } from '@/components/atoms'
|
||||
import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
import { Pencil, Trash, ArrowsDownUp } from '@phosphor-icons/react'
|
||||
import { Pencil, Trash } from '@phosphor-icons/react'
|
||||
|
||||
interface DataSourceCardProps {
|
||||
dataSource: DataSource
|
||||
dependents?: DataSource[]
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }: DataSourceCardProps) {
|
||||
const getDependencyCount = () => {
|
||||
if (dataSource.type === 'computed') {
|
||||
return dataSource.dependencies?.length || 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function DataSourceCard({ dataSource, onEdit, onDelete }: DataSourceCardProps) {
|
||||
const renderTypeSpecificInfo = () => {
|
||||
if (dataSource.type === 'kv') {
|
||||
return (
|
||||
@@ -26,19 +18,7 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (dataSource.type === 'computed') {
|
||||
const depCount = getDependencyCount()
|
||||
return (
|
||||
<Flex align="center" gap="sm">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<ArrowsDownUp className="w-3 h-3 mr-1" />
|
||||
{depCount} {depCount === 1 ? 'dependency' : 'dependencies'}
|
||||
</Badge>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -56,13 +36,6 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
|
||||
|
||||
{renderTypeSpecificInfo()}
|
||||
|
||||
{dependents.length > 0 && (
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<Text variant="caption">
|
||||
Used by {dependents.length} computed {dependents.length === 1 ? 'source' : 'sources'}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Flex align="center" gap="xs">
|
||||
@@ -78,7 +51,6 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
|
||||
size="sm"
|
||||
onClick={() => onDelete(dataSource.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={dependents.length > 0}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -5,14 +5,12 @@ import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
|
||||
import { DataSourceIdField } from '@/components/molecules/data-source-editor/DataSourceIdField'
|
||||
import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields'
|
||||
import { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields'
|
||||
import { ComputedSourceFields } from '@/components/molecules/data-source-editor/ComputedSourceFields'
|
||||
import dataSourceEditorCopy from '@/data/data-source-editor-dialog.json'
|
||||
import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor'
|
||||
|
||||
interface DataSourceEditorDialogProps {
|
||||
open: boolean
|
||||
dataSource: DataSource | null
|
||||
allDataSources: DataSource[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (dataSource: DataSource) => void
|
||||
}
|
||||
@@ -20,19 +18,13 @@ interface DataSourceEditorDialogProps {
|
||||
export function DataSourceEditorDialog({
|
||||
open,
|
||||
dataSource,
|
||||
allDataSources,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: DataSourceEditorDialogProps) {
|
||||
const {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
} = useDataSourceEditor(dataSource, allDataSources)
|
||||
} = useDataSourceEditor(dataSource)
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editingSource) return
|
||||
@@ -80,18 +72,6 @@ export function DataSourceEditorDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSource.type === 'computed' && (
|
||||
<ComputedSourceFields
|
||||
editingSource={editingSource}
|
||||
availableDeps={availableDeps}
|
||||
selectedDeps={selectedDeps}
|
||||
unselectedDeps={unselectedDeps}
|
||||
copy={dataSourceEditorCopy.computed}
|
||||
onUpdateField={updateField}
|
||||
onAddDependency={addDependency}
|
||||
onRemoveDependency={removeDependency}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
|
||||
interface ComputedSourceFieldsCopy {
|
||||
computeLabel: string
|
||||
computePlaceholder: string
|
||||
computeHelp: string
|
||||
dependenciesLabel: string
|
||||
availableSourcesLabel: string
|
||||
emptyDependencies: string
|
||||
}
|
||||
|
||||
interface ComputedSourceFieldsProps {
|
||||
editingSource: DataSource
|
||||
availableDeps: DataSource[]
|
||||
selectedDeps: string[]
|
||||
unselectedDeps: DataSource[]
|
||||
copy: ComputedSourceFieldsCopy
|
||||
onUpdateField: <K extends keyof DataSource>(field: K, value: DataSource[K]) => void
|
||||
onAddDependency: (depId: string) => void
|
||||
onRemoveDependency: (depId: string) => void
|
||||
}
|
||||
|
||||
export function ComputedSourceFields({
|
||||
editingSource,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
copy,
|
||||
onUpdateField,
|
||||
onAddDependency,
|
||||
onRemoveDependency,
|
||||
}: ComputedSourceFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{copy.computeLabel}</Label>
|
||||
<Textarea
|
||||
value={editingSource.compute?.toString() || ''}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const fn = new Function('data', `return (${e.target.value})`)()
|
||||
onUpdateField('compute', fn)
|
||||
} catch (err) {
|
||||
// Invalid function
|
||||
}
|
||||
}}
|
||||
placeholder={copy.computePlaceholder}
|
||||
className="font-mono text-sm h-24"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{copy.computeHelp}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{copy.dependenciesLabel}</Label>
|
||||
|
||||
{selectedDeps.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded border border-border">
|
||||
{selectedDeps.map(depId => (
|
||||
<Badge
|
||||
key={depId}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{depId}
|
||||
<button
|
||||
onClick={() => onRemoveDependency(depId)}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unselectedDeps.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{copy.availableSourcesLabel}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unselectedDeps.map(ds => (
|
||||
<Button
|
||||
key={ds.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAddDependency(ds.id)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
+ {ds.id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableDeps.length === 0 && selectedDeps.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{copy.emptyDependencies}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { DataSourceEditorDialog } from '@/components/molecules/DataSourceEditorDialog'
|
||||
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
|
||||
import { DataSource, DataSourceType } from '@/types/json-ui'
|
||||
import { Database, Function, FileText } from '@phosphor-icons/react'
|
||||
import { Database, FileText } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { EmptyState, Stack } from '@/components/atoms'
|
||||
import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader'
|
||||
@@ -21,7 +21,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
addDataSource,
|
||||
updateDataSource,
|
||||
deleteDataSource,
|
||||
getDependents,
|
||||
} = useDataSourceManager(dataSources)
|
||||
|
||||
const [editingSource, setEditingSource] = useState<DataSource | null>(null)
|
||||
@@ -42,17 +41,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
}
|
||||
|
||||
const handleDeleteSource = (id: string) => {
|
||||
const dependents = getDependents(id)
|
||||
if (dependents.length > 0) {
|
||||
const noun = dependents.length === 1 ? 'source' : 'sources'
|
||||
toast.error(dataSourceManagerCopy.toasts.deleteBlockedTitle, {
|
||||
description: dataSourceManagerCopy.toasts.deleteBlockedDescription
|
||||
.replace('{count}', String(dependents.length))
|
||||
.replace('{noun}', noun),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
deleteDataSource(id)
|
||||
onChange(localSources.filter(ds => ds.id !== id))
|
||||
toast.success(dataSourceManagerCopy.toasts.deleted)
|
||||
@@ -66,7 +54,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
|
||||
const groupedSources = {
|
||||
kv: localSources.filter(ds => ds.type === 'kv'),
|
||||
computed: localSources.filter(ds => ds.type === 'computed'),
|
||||
static: localSources.filter(ds => ds.type === 'static'),
|
||||
}
|
||||
|
||||
@@ -97,7 +84,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
icon={<Database size={16} />}
|
||||
label={dataSourceManagerCopy.groups.kv}
|
||||
dataSources={groupedSources.kv}
|
||||
getDependents={getDependents}
|
||||
onEdit={handleEditSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
@@ -106,16 +92,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
icon={<FileText size={16} />}
|
||||
label={dataSourceManagerCopy.groups.static}
|
||||
dataSources={groupedSources.static}
|
||||
getDependents={getDependents}
|
||||
onEdit={handleEditSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
|
||||
<DataSourceGroupSection
|
||||
icon={<Function size={16} />}
|
||||
label={dataSourceManagerCopy.groups.computed}
|
||||
dataSources={groupedSources.computed}
|
||||
getDependents={getDependents}
|
||||
onEdit={handleEditSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
@@ -127,7 +103,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
<DataSourceEditorDialog
|
||||
open={dialogOpen}
|
||||
dataSource={editingSource}
|
||||
allDataSources={localSources}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSaveSource}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,6 @@ interface DataSourceGroupSectionProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
dataSources: DataSource[]
|
||||
getDependents: (id: string) => string[]
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
@@ -16,7 +15,6 @@ export function DataSourceGroupSection({
|
||||
icon,
|
||||
label,
|
||||
dataSources,
|
||||
getDependents,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: DataSourceGroupSectionProps) {
|
||||
@@ -37,7 +35,6 @@ export function DataSourceGroupSection({
|
||||
<DataSourceCard
|
||||
key={ds.id}
|
||||
dataSource={ds}
|
||||
dependents={getDependents(ds.id)}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ActionButton, Heading, Stack, Text } from '@/components/atoms'
|
||||
import { Plus, Database, Function, FileText } from '@phosphor-icons/react'
|
||||
import { Plus, Database, FileText } from '@phosphor-icons/react'
|
||||
import { DataSourceType } from '@/types/json-ui'
|
||||
|
||||
interface DataSourceManagerHeaderCopy {
|
||||
@@ -14,7 +14,6 @@ interface DataSourceManagerHeaderCopy {
|
||||
addLabel: string
|
||||
menu: {
|
||||
kv: string
|
||||
computed: string
|
||||
static: string
|
||||
}
|
||||
}
|
||||
@@ -49,10 +48,6 @@ export function DataSourceManagerHeader({ copy, onAdd }: DataSourceManagerHeader
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
{copy.menu.kv}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAdd('computed')}>
|
||||
<Function className="w-4 h-4 mr-2" />
|
||||
{copy.menu.computed}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAdd('static')}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{copy.menu.static}
|
||||
|
||||
@@ -37,13 +37,6 @@ export function useDataSource(source: DataSource) {
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
case 'computed':
|
||||
return {
|
||||
data: source.defaultValue,
|
||||
setData: () => {},
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
data: null,
|
||||
@@ -67,7 +60,7 @@ export function useDataSources(sources: DataSource[]) {
|
||||
|
||||
useEffect(() => {
|
||||
sources.forEach((source) => {
|
||||
if (source.type === 'static' || source.type === 'computed') {
|
||||
if (source.type === 'static') {
|
||||
updateData(source.id, source.defaultValue)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ActionSchema = z.object({
|
||||
|
||||
export const DataSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['kv', 'api', 'computed', 'static'], { message: 'Invalid data source type' }),
|
||||
type: z.enum(['kv', 'api', 'static'], { message: 'Invalid data source type' }),
|
||||
key: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
transform: z.string().optional(),
|
||||
|
||||
@@ -33,15 +33,15 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedTree",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.trees?.find(t => t.id === data.selectedTreeId) || null",
|
||||
"dependencies": ["trees", "selectedTreeId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.trees, data.selectedTreeId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "treeCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.trees || []).length",
|
||||
"dependencies": ["trees"]
|
||||
"type": "static",
|
||||
"expression": "length(data.trees)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"icon": "CheckCircle",
|
||||
"gradient": "from-primary/10 to-accent/10",
|
||||
"dataSource": {
|
||||
"type": "computed",
|
||||
"compute": "calculateCompletionScore"
|
||||
"type": "expression",
|
||||
"expression": "data.completionSummary"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
|
||||
@@ -133,9 +133,9 @@
|
||||
"data": [
|
||||
{
|
||||
"id": "activeFile",
|
||||
"type": "computed",
|
||||
"dependencies": ["files", "activeFileId"],
|
||||
"compute": "context.files.find(f => f.id === context.activeFileId)"
|
||||
"type": "static",
|
||||
"expression": "findById(data.files, data.activeFileId)",
|
||||
"defaultValue": null
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
|
||||
@@ -35,27 +35,27 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedBlueprint",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.find(b => b.id === data.selectedBlueprintId) || null; }",
|
||||
"dependencies": ["flaskConfig", "selectedBlueprintId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.flaskConfig.blueprints, data.selectedBlueprintId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "blueprintCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => ((data.flaskConfig || {}).blueprints || []).length",
|
||||
"dependencies": ["flaskConfig"]
|
||||
"type": "static",
|
||||
"expression": "length(data.flaskConfig.blueprints)",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "endpointCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const bp = data.selectedBlueprint; return bp ? (bp.endpoints || []).length : 0; }",
|
||||
"dependencies": ["selectedBlueprint"]
|
||||
"type": "static",
|
||||
"expression": "length(data.selectedBlueprint.endpoints)",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "totalEndpoints",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.reduce((sum, bp) => sum + (bp.endpoints || []).length, 0); }",
|
||||
"dependencies": ["flaskConfig"]
|
||||
"type": "static",
|
||||
"expression": "sumByLength(data.flaskConfig.blueprints, \"endpoints\")",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedLambda",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.lambdas?.find(l => l.id === data.selectedLambdaId) || null",
|
||||
"dependencies": ["lambdas", "selectedLambdaId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.lambdas, data.selectedLambdaId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "lambdaCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.lambdas || []).length",
|
||||
"dependencies": ["lambdas"]
|
||||
"type": "static",
|
||||
"expression": "length(data.lambdas)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -28,15 +28,15 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedModel",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.models?.find(m => m.id === data.selectedModelId) || null",
|
||||
"dependencies": ["models", "selectedModelId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.models, data.selectedModelId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "modelCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.models || []).length",
|
||||
"dependencies": ["models"]
|
||||
"type": "static",
|
||||
"expression": "length(data.models)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
},
|
||||
{
|
||||
"id": "isRecent",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { if (!data.lastSaved) return false; return Date.now() - data.lastSaved < 3000; }",
|
||||
"dependencies": ["lastSaved", "currentTime"]
|
||||
"type": "static",
|
||||
"expression": "isRecentTimestamp(data.lastSaved, data.currentTime, 3000)",
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"id": "timeAgo",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { if (!data.lastSaved) return ''; const seconds = Math.floor((Date.now() - data.lastSaved) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; }",
|
||||
"dependencies": ["lastSaved", "currentTime"]
|
||||
"type": "static",
|
||||
"expression": "timeAgo(data.lastSaved, data.currentTime)",
|
||||
"defaultValue": ""
|
||||
}
|
||||
],
|
||||
"type": "div",
|
||||
|
||||
@@ -54,21 +54,21 @@
|
||||
},
|
||||
{
|
||||
"id": "activeVariant",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const theme = data.theme || {}; const variants = theme.variants || []; return variants.find(v => v.id === theme.activeVariantId) || variants[0] || null; }",
|
||||
"dependencies": ["theme"]
|
||||
"type": "static",
|
||||
"expression": "findByIdOrFirst(data.theme.variants, data.theme.activeVariantId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "variantCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => ((data.theme || {}).variants || []).length",
|
||||
"dependencies": ["theme"]
|
||||
"type": "static",
|
||||
"expression": "length(data.theme.variants)",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "customColorCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const variant = data.activeVariant; if (!variant || !variant.colors) return 0; return Object.keys(variant.colors.customColors || {}).length; }",
|
||||
"dependencies": ["activeVariant"]
|
||||
"type": "static",
|
||||
"expression": "keyCount(data.activeVariant.colors.customColors)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -32,15 +32,15 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedWorkflow",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.workflows?.find(w => w.id === data.selectedWorkflowId) || null",
|
||||
"dependencies": ["workflows", "selectedWorkflowId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.workflows, data.selectedWorkflowId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "workflowCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.workflows || []).length",
|
||||
"dependencies": ["workflows"]
|
||||
"type": "static",
|
||||
"expression": "length(data.workflows)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "Data Binding Designer",
|
||||
"description": "Connect UI components to KV storage and computed values"
|
||||
"description": "Connect UI components to KV storage and static values"
|
||||
},
|
||||
"bindingsCard": {
|
||||
"title": "Component Bindings",
|
||||
@@ -13,7 +13,6 @@
|
||||
"title": "How It Works",
|
||||
"steps": [
|
||||
"Create data sources (KV store for persistence, static for constants)",
|
||||
"Add computed sources to derive values from other sources",
|
||||
"Bind component properties to data sources for reactive updates"
|
||||
]
|
||||
},
|
||||
@@ -33,12 +32,6 @@
|
||||
"type": "kv",
|
||||
"key": "app-counter",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "displayName",
|
||||
"type": "computed",
|
||||
"dependencies": ["userProfile"],
|
||||
"computeId": "displayName"
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -50,7 +43,8 @@
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "displayName"
|
||||
"source": "userProfile",
|
||||
"path": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Edit Data Source",
|
||||
"description": "Configure the data source settings and dependencies",
|
||||
"description": "Configure the data source settings",
|
||||
"fields": {
|
||||
"id": {
|
||||
"label": "ID",
|
||||
@@ -18,14 +18,6 @@
|
||||
"valueLabel": "Value (JSON)",
|
||||
"valuePlaceholder": "{\"key\": \"value\"}"
|
||||
},
|
||||
"computed": {
|
||||
"computeLabel": "Compute Function",
|
||||
"computePlaceholder": "(data) => data.source1 + data.source2",
|
||||
"computeHelp": "Function that computes the value from other data sources",
|
||||
"dependenciesLabel": "Dependencies",
|
||||
"availableSourcesLabel": "Available Sources",
|
||||
"emptyDependencies": "No data sources available. Create KV or static sources first."
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Changes"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "Data Sources",
|
||||
"description": "Manage KV storage, computed values, and static data"
|
||||
"description": "Manage KV storage and static data"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add Data Source"
|
||||
},
|
||||
"menu": {
|
||||
"kv": "KV Store",
|
||||
"computed": "Computed Value",
|
||||
"static": "Static Data"
|
||||
},
|
||||
"emptyState": {
|
||||
@@ -17,12 +16,9 @@
|
||||
},
|
||||
"groups": {
|
||||
"kv": "KV Store",
|
||||
"static": "Static Data",
|
||||
"computed": "Computed Values"
|
||||
"static": "Static Data"
|
||||
},
|
||||
"toasts": {
|
||||
"deleteBlockedTitle": "Cannot delete",
|
||||
"deleteBlockedDescription": "This source is used by {count} computed {noun}",
|
||||
"deleted": "Data source deleted",
|
||||
"updated": "Data source updated"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
|
||||
export function useDataSourceEditor(
|
||||
dataSource: DataSource | null,
|
||||
allDataSources: DataSource[],
|
||||
) {
|
||||
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
|
||||
|
||||
@@ -15,44 +14,8 @@ export function useDataSourceEditor(
|
||||
setEditingSource(prev => (prev ? { ...prev, [field]: value } : prev))
|
||||
}, [])
|
||||
|
||||
const addDependency = useCallback((depId: string) => {
|
||||
setEditingSource(prev => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
if (deps.includes(depId)) return prev
|
||||
return { ...prev, dependencies: [...deps, depId] }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeDependency = useCallback((depId: string) => {
|
||||
setEditingSource(prev => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
return { ...prev, dependencies: deps.filter(dep => dep !== depId) }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const availableDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return allDataSources.filter(
|
||||
ds => ds.id !== editingSource.id && ds.type !== 'computed',
|
||||
)
|
||||
}, [allDataSources, editingSource])
|
||||
|
||||
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
|
||||
|
||||
const unselectedDeps = useMemo(
|
||||
() => availableDeps.filter(ds => !selectedDeps.includes(ds.id)),
|
||||
[availableDeps, selectedDeps],
|
||||
)
|
||||
|
||||
return {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
|
||||
id: `ds-${Date.now()}`,
|
||||
type,
|
||||
...(type === 'kv' && { key: '', defaultValue: null }),
|
||||
...(type === 'computed' && { compute: () => null, dependencies: [] }),
|
||||
...(type === 'static' && { defaultValue: null }),
|
||||
}
|
||||
|
||||
@@ -31,19 +30,11 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
|
||||
return dataSources.find(ds => ds.id === id)
|
||||
}, [dataSources])
|
||||
|
||||
const getDependents = useCallback((sourceId: string) => {
|
||||
return dataSources.filter(ds =>
|
||||
ds.type === 'computed' &&
|
||||
ds.dependencies?.includes(sourceId)
|
||||
)
|
||||
}, [dataSources])
|
||||
|
||||
return {
|
||||
dataSources,
|
||||
addDataSource,
|
||||
updateDataSource,
|
||||
deleteDataSource,
|
||||
getDataSource,
|
||||
getDependents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
|
||||
export type DataSourceType = 'kv' | 'static' | 'computed'
|
||||
export type DataSourceType = 'kv' | 'static'
|
||||
|
||||
export interface DataSourceConfig<T = any> {
|
||||
type: DataSourceType
|
||||
key?: string
|
||||
defaultValue?: T
|
||||
compute?: (allData: Record<string, any>) => T
|
||||
dependencies?: string[]
|
||||
}
|
||||
|
||||
export function useKVDataSource<T = any>(key: string, defaultValue?: T) {
|
||||
@@ -18,13 +16,6 @@ export function useStaticDataSource<T = any>(defaultValue: T) {
|
||||
return [defaultValue, () => {}, () => {}] as const
|
||||
}
|
||||
|
||||
export function useComputedDataSource<T = any>(
|
||||
compute: (allData: Record<string, any>) => T,
|
||||
dependencies: Record<string, any>
|
||||
) {
|
||||
return compute(dependencies)
|
||||
}
|
||||
|
||||
export function useMultipleDataSources(_sources: DataSourceConfig[]) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
import { setNestedValue } from '@/lib/json-ui/utils'
|
||||
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
|
||||
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
|
||||
|
||||
export function useDataSources(dataSources: DataSource[]) {
|
||||
const [data, setData] = useState<Record<string, any>>({})
|
||||
@@ -27,7 +29,7 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
if (kvIndex !== -1 && kvStates[kvIndex]) {
|
||||
newData[source.id] = kvStates[kvIndex][0]
|
||||
}
|
||||
} else if (source.type === 'static') {
|
||||
} else if (source.type === 'static' && !source.expression && !source.valueTemplate) {
|
||||
newData[source.id] = source.defaultValue
|
||||
}
|
||||
})
|
||||
@@ -39,20 +41,32 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
initializeData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const computedSources = dataSources.filter(ds => ds.type === 'computed')
|
||||
|
||||
computedSources.forEach(source => {
|
||||
if (source.compute) {
|
||||
const deps = source.dependencies || []
|
||||
const hasAllDeps = deps.every(dep => dep in data)
|
||||
|
||||
if (hasAllDeps) {
|
||||
const computedValue = source.compute(data)
|
||||
setData(prev => ({ ...prev, [source.id]: computedValue }))
|
||||
}
|
||||
const derivedData = useMemo(() => {
|
||||
const result: Record<string, any> = {}
|
||||
const context = { ...data }
|
||||
|
||||
dataSources.forEach((source) => {
|
||||
if (!source.expression && !source.valueTemplate) return
|
||||
|
||||
let computedValue: any
|
||||
if (source.expression) {
|
||||
computedValue = evaluateBindingExpression(source.expression, context, {
|
||||
fallback: undefined,
|
||||
label: `data source (${source.id})`,
|
||||
})
|
||||
} else if (source.valueTemplate) {
|
||||
computedValue = evaluateTemplate(source.valueTemplate, { data: context })
|
||||
}
|
||||
|
||||
if (computedValue === undefined && source.defaultValue !== undefined) {
|
||||
computedValue = source.defaultValue
|
||||
}
|
||||
|
||||
result[source.id] = computedValue
|
||||
context[source.id] = computedValue
|
||||
})
|
||||
|
||||
return result
|
||||
}, [data, dataSources])
|
||||
|
||||
const updateData = useCallback((sourceId: string, value: any) => {
|
||||
@@ -101,8 +115,10 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
})
|
||||
}, [dataSources, kvSources, kvStates])
|
||||
|
||||
const mergedData = useMemo(() => ({ ...data, ...derivedData }), [data, derivedData])
|
||||
|
||||
return {
|
||||
data,
|
||||
data: mergedData,
|
||||
updateData,
|
||||
updatePath,
|
||||
loading,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
|
||||
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
|
||||
|
||||
export function useDataSources(dataSources: DataSource[]) {
|
||||
const [data, setData] = useState<Record<string, any>>({})
|
||||
@@ -12,8 +14,8 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
[dataSources]
|
||||
)
|
||||
|
||||
const computedSources = useMemo(
|
||||
() => dataSources.filter((ds) => ds.type === 'computed'),
|
||||
const derivedSources = useMemo(
|
||||
() => dataSources.filter((ds) => ds.expression || ds.valueTemplate),
|
||||
[dataSources]
|
||||
)
|
||||
|
||||
@@ -29,7 +31,7 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
} catch {
|
||||
initialData[ds.id] = ds.defaultValue
|
||||
}
|
||||
} else if (ds.type === 'static') {
|
||||
} else if (ds.type === 'static' && !ds.expression && !ds.valueTemplate) {
|
||||
initialData[ds.id] = ds.defaultValue
|
||||
}
|
||||
}
|
||||
@@ -52,15 +54,29 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
|
||||
const computedData = useMemo(() => {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
computedSources.forEach((ds) => {
|
||||
if (ds.compute && typeof ds.compute === 'function') {
|
||||
result[ds.id] = ds.compute(data)
|
||||
const context = { ...data }
|
||||
|
||||
derivedSources.forEach((ds) => {
|
||||
let value: any
|
||||
if (ds.expression) {
|
||||
value = evaluateBindingExpression(ds.expression, context, {
|
||||
fallback: undefined,
|
||||
label: `data source (${ds.id})`,
|
||||
})
|
||||
} else if (ds.valueTemplate) {
|
||||
value = evaluateTemplate(ds.valueTemplate, { data: context })
|
||||
}
|
||||
|
||||
if (value === undefined && ds.defaultValue !== undefined) {
|
||||
value = ds.defaultValue
|
||||
}
|
||||
|
||||
result[ds.id] = value
|
||||
context[ds.id] = value
|
||||
})
|
||||
|
||||
return result
|
||||
}, [computedSources, data])
|
||||
}, [derivedSources, data])
|
||||
|
||||
const allData = useMemo(
|
||||
() => ({ ...data, ...computedData }),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useWorkflows } from '../data/use-workflows'
|
||||
import { useLambdas } from '../data/use-lambdas'
|
||||
import { useActions } from './use-actions'
|
||||
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
|
||||
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
|
||||
|
||||
export function usePage(schema: PageSchema) {
|
||||
const files = useFiles()
|
||||
@@ -44,15 +45,24 @@ export function usePage(schema: PageSchema) {
|
||||
useEffect(() => {
|
||||
if (schema.data) {
|
||||
const computed: Record<string, any> = {}
|
||||
const context = { ...dataContext }
|
||||
|
||||
schema.data.forEach(source => {
|
||||
if (source.type === 'computed' && source.compute) {
|
||||
computed[source.id] = evaluateBindingExpression(source.compute, dataContext, {
|
||||
fallback: undefined,
|
||||
label: `computed data (${source.id})`,
|
||||
})
|
||||
} else if (source.type === 'static' && source.defaultValue !== undefined) {
|
||||
computed[source.id] = source.defaultValue
|
||||
if (source.type === 'static') {
|
||||
if (source.expression) {
|
||||
computed[source.id] = evaluateBindingExpression(source.expression, context, {
|
||||
fallback: source.defaultValue,
|
||||
label: `data source (${source.id})`,
|
||||
})
|
||||
} else if (source.valueTemplate) {
|
||||
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: context })
|
||||
} else if (source.defaultValue !== undefined) {
|
||||
computed[source.id] = source.defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
if (computed[source.id] !== undefined) {
|
||||
context[source.id] = computed[source.id]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
|
||||
interface UseDataSourceEditorParams {
|
||||
dataSource: DataSource | null
|
||||
allDataSources: DataSource[]
|
||||
onSave: (dataSource: DataSource) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function useDataSourceEditor({
|
||||
dataSource,
|
||||
allDataSources,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
}: UseDataSourceEditorParams) {
|
||||
@@ -27,51 +25,15 @@ export function useDataSourceEditor({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addDependency = useCallback((depId: string) => {
|
||||
setEditingSource((prev) => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
if (deps.includes(depId)) return prev
|
||||
return { ...prev, dependencies: [...deps, depId] }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeDependency = useCallback((depId: string) => {
|
||||
setEditingSource((prev) => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
return { ...prev, dependencies: deps.filter((id) => id !== depId) }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!editingSource) return
|
||||
onSave(editingSource)
|
||||
onOpenChange(false)
|
||||
}, [editingSource, onOpenChange, onSave])
|
||||
|
||||
const availableDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return allDataSources.filter(
|
||||
(ds) => ds.id !== editingSource.id && ds.type !== 'computed',
|
||||
)
|
||||
}, [allDataSources, editingSource])
|
||||
|
||||
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
|
||||
|
||||
const unselectedDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return availableDeps.filter((ds) => !selectedDeps.includes(ds.id))
|
||||
}, [availableDeps, editingSource, selectedDeps])
|
||||
|
||||
return {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
handleSave,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,121 @@ interface EvaluationContext {
|
||||
event?: any
|
||||
}
|
||||
|
||||
type ExpressionFunction = (args: any[], context: EvaluationContext) => any
|
||||
|
||||
const parseArguments = (argsString: string): string[] => {
|
||||
if (!argsString.trim()) return []
|
||||
const args: string[] = []
|
||||
let current = ''
|
||||
let depth = 0
|
||||
let inSingleQuote = false
|
||||
let inDoubleQuote = false
|
||||
|
||||
for (let i = 0; i < argsString.length; i++) {
|
||||
const char = argsString[i]
|
||||
const prevChar = argsString[i - 1]
|
||||
|
||||
if (char === "'" && !inDoubleQuote && prevChar !== '\\') {
|
||||
inSingleQuote = !inSingleQuote
|
||||
} else if (char === '"' && !inSingleQuote && prevChar !== '\\') {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
} else if (!inSingleQuote && !inDoubleQuote) {
|
||||
if (char === '(') depth += 1
|
||||
if (char === ')') depth = Math.max(0, depth - 1)
|
||||
if (char === ',' && depth === 0) {
|
||||
args.push(current.trim())
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
args.push(current.trim())
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
const expressionFunctions: Record<string, ExpressionFunction> = {
|
||||
findById: ([list, id]) => {
|
||||
if (!Array.isArray(list)) return null
|
||||
return list.find((item) => item?.id === id) ?? null
|
||||
},
|
||||
findByIdOrFirst: ([list, id]) => {
|
||||
if (!Array.isArray(list)) return null
|
||||
return list.find((item) => item?.id === id) ?? list[0] ?? null
|
||||
},
|
||||
length: ([value]) => {
|
||||
if (Array.isArray(value) || typeof value === 'string') {
|
||||
return value.length
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value).length
|
||||
}
|
||||
return 0
|
||||
},
|
||||
keyCount: ([value]) => {
|
||||
if (!value || typeof value !== 'object') return 0
|
||||
return Object.keys(value).length
|
||||
},
|
||||
sumByLength: ([list, path], context) => {
|
||||
if (!Array.isArray(list)) return 0
|
||||
const resolvedPath = typeof path === 'string' ? path : evaluateExpression(String(path), context)
|
||||
if (!resolvedPath || typeof resolvedPath !== 'string') return 0
|
||||
return list.reduce((sum, item) => {
|
||||
const value = getNestedValue(item, resolvedPath)
|
||||
const length = Array.isArray(value) || typeof value === 'string' ? value.length : 0
|
||||
return sum + length
|
||||
}, 0)
|
||||
},
|
||||
isRecentTimestamp: ([lastSaved, nowValue, thresholdValue]) => {
|
||||
if (!lastSaved) return false
|
||||
const now = typeof nowValue === 'number' ? nowValue : Date.now()
|
||||
const threshold = typeof thresholdValue === 'number' ? thresholdValue : 3000
|
||||
return now - lastSaved < threshold
|
||||
},
|
||||
timeAgo: ([lastSaved, nowValue]) => {
|
||||
if (!lastSaved) return ''
|
||||
const now = typeof nowValue === 'number' ? nowValue : Date.now()
|
||||
const seconds = Math.floor((now - lastSaved) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
||||
return `${Math.floor(seconds / 86400)}d ago`
|
||||
},
|
||||
filterUsersByQuery: ([users, query]) => {
|
||||
const list = Array.isArray(users) ? users : []
|
||||
const normalizedQuery = typeof query === 'string' ? query.toLowerCase() : ''
|
||||
if (!normalizedQuery) return list
|
||||
return list.filter((user) => {
|
||||
const name = String(user?.name ?? '').toLowerCase()
|
||||
const email = String(user?.email ?? '').toLowerCase()
|
||||
return name.includes(normalizedQuery) || email.includes(normalizedQuery)
|
||||
})
|
||||
},
|
||||
userStats: ([users]) => {
|
||||
const list = Array.isArray(users) ? users : []
|
||||
return {
|
||||
total: list.length,
|
||||
active: list.filter((user) => user?.status === 'active').length,
|
||||
inactive: list.filter((user) => user?.status === 'inactive').length,
|
||||
}
|
||||
},
|
||||
todoStats: ([todos]) => {
|
||||
const list = Array.isArray(todos) ? todos : []
|
||||
return {
|
||||
total: list.length,
|
||||
completed: list.filter((todo) => Boolean(todo?.completed)).length,
|
||||
remaining: list.filter((todo) => !todo?.completed).length,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const supportedExpressionFunctions = new Set(Object.keys(expressionFunctions))
|
||||
|
||||
/**
|
||||
* Safely evaluate a JSON expression
|
||||
* Supports:
|
||||
@@ -26,6 +141,17 @@ export function evaluateExpression(
|
||||
const { data, event } = context
|
||||
|
||||
try {
|
||||
const functionMatch = expression.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\((.*)\)$/)
|
||||
if (functionMatch) {
|
||||
const functionName = functionMatch[1]
|
||||
const argString = functionMatch[2]
|
||||
const handler = expressionFunctions[functionName]
|
||||
if (handler) {
|
||||
const args = parseArguments(argString).map((arg) => evaluateExpression(arg, context))
|
||||
return handler(args, context)
|
||||
}
|
||||
}
|
||||
|
||||
if (expression === 'event') {
|
||||
return event
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateCondition, evaluateExpression } from './expression-evaluator'
|
||||
import { evaluateCondition, evaluateExpression, supportedExpressionFunctions } from './expression-evaluator'
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
const NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/
|
||||
@@ -10,6 +10,8 @@ interface EvaluationOptions {
|
||||
event?: any
|
||||
}
|
||||
|
||||
const FUNCTION_PATTERN = /^([A-Za-z_$][A-Za-z0-9_$]*)\((.*)\)$/
|
||||
|
||||
const isSupportedExpression = (expression: string) => {
|
||||
if (expression === 'event' || expression === 'data') return true
|
||||
if (expression.startsWith('data.') || expression.startsWith('event.')) return true
|
||||
@@ -17,6 +19,10 @@ const isSupportedExpression = (expression: string) => {
|
||||
if (STRING_PATTERN.test(expression)) return true
|
||||
if (NUMBER_PATTERN.test(expression)) return true
|
||||
if (['true', 'false', 'null', 'undefined'].includes(expression)) return true
|
||||
const functionMatch = expression.match(FUNCTION_PATTERN)
|
||||
if (functionMatch) {
|
||||
return supportedExpressionFunctions.has(functionMatch[1])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export function useJSONDataSource<T = unknown>(
|
||||
) {
|
||||
const kvConfig = config.type === 'kv' ? config.config : undefined
|
||||
const apiConfig = config.type === 'api' ? config.config : undefined
|
||||
const computedConfig = config.type === 'computed' ? config.config : undefined
|
||||
const defaultValue =
|
||||
config.type === 'static' ? config.config : config.config?.defaultValue
|
||||
|
||||
@@ -57,8 +56,6 @@ export function useJSONDataSource<T = unknown>(
|
||||
return apiValue
|
||||
case 'static':
|
||||
return config.config
|
||||
case 'computed':
|
||||
return computedConfig?.defaultValue
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ export const PageUISchema = z.object({
|
||||
tables: z.array(TableSchema).optional(),
|
||||
menus: z.array(MenuSchema).optional(),
|
||||
dataSources: z.record(z.string(), z.object({
|
||||
type: z.enum(['kv', 'api', 'computed', 'static']),
|
||||
type: z.enum(['kv', 'api', 'static']),
|
||||
config: z.any(),
|
||||
})).optional(),
|
||||
})
|
||||
@@ -242,13 +242,6 @@ export type DataSourceConfig<T = unknown> =
|
||||
transform?: (data: unknown) => T
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'computed'
|
||||
config: {
|
||||
defaultValue?: T
|
||||
transform?: (data: unknown) => T
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'static'
|
||||
config: T
|
||||
|
||||
@@ -22,15 +22,19 @@
|
||||
},
|
||||
{
|
||||
"id": "filteredUsers",
|
||||
"type": "computed",
|
||||
"compute": "computeFilteredUsers",
|
||||
"dependencies": ["users", "filterQuery"]
|
||||
"type": "static",
|
||||
"expression": "filterUsersByQuery(data.users, data.filterQuery)",
|
||||
"defaultValue": []
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"type": "computed",
|
||||
"compute": "computeStats",
|
||||
"dependencies": ["users"]
|
||||
"type": "static",
|
||||
"expression": "userStats(data.users)",
|
||||
"defaultValue": {
|
||||
"total": 0,
|
||||
"active": 0,
|
||||
"inactive": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -16,20 +16,7 @@ export function hydrateSchema(jsonSchema: any): PageSchema {
|
||||
const schema = { ...jsonSchema }
|
||||
|
||||
if (schema.dataSources) {
|
||||
schema.dataSources = schema.dataSources.map((ds: any) => {
|
||||
if (ds.type === 'computed' && typeof ds.compute === 'string') {
|
||||
const functionName = ds.compute as keyof ComputeFunctionMap
|
||||
const computeFunction = computeFunctions[functionName]
|
||||
if (!computeFunction) {
|
||||
console.warn(`Compute function "${functionName}" not found`)
|
||||
}
|
||||
return {
|
||||
...ds,
|
||||
compute: computeFunction || (() => null)
|
||||
}
|
||||
}
|
||||
return ds
|
||||
})
|
||||
schema.dataSources = schema.dataSources.map((ds: any) => ds)
|
||||
}
|
||||
|
||||
if (schema.components) {
|
||||
|
||||
@@ -22,9 +22,13 @@
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"type": "computed",
|
||||
"compute": "computeTodoStats",
|
||||
"dependencies": ["todos"]
|
||||
"type": "static",
|
||||
"expression": "todoStats(data.todos)",
|
||||
"defaultValue": {
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"remaining": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ActionType =
|
||||
| 'custom'
|
||||
|
||||
export type DataSourceType =
|
||||
| 'kv' | 'computed' | 'static'
|
||||
| 'kv' | 'static'
|
||||
|
||||
export type BindingSourceType =
|
||||
| 'data' | 'bindings' | 'state'
|
||||
@@ -52,8 +52,8 @@ export interface DataSource {
|
||||
type: DataSourceType
|
||||
key?: string
|
||||
defaultValue?: any
|
||||
compute?: (data: Record<string, any>) => any
|
||||
dependencies?: string[]
|
||||
expression?: string
|
||||
valueTemplate?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
|
||||
@@ -26,11 +26,11 @@ export const ComponentSchema: z.ZodType<any> = z.lazy(() =>
|
||||
|
||||
export const DataSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['kv', 'computed', 'static', 'ai'], { message: 'Invalid data source type' }),
|
||||
type: z.enum(['kv', 'static', 'ai'], { message: 'Invalid data source type' }),
|
||||
key: z.string().optional(),
|
||||
defaultValue: z.any().optional(),
|
||||
dependencies: z.array(z.string()).optional(),
|
||||
compute: z.string().optional(),
|
||||
expression: z.string().optional(),
|
||||
valueTemplate: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
|
||||
export const ActionConfigSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user