Compare commits

...

1 Commits

Author SHA1 Message Date
b57c4014ca Remove computed data sources 2026-01-18 17:54:28 +00:00
40 changed files with 326 additions and 486 deletions

View File

@@ -7,30 +7,9 @@ import { ComponentBindingsCard } from '@/components/data-binding-designer/Compon
import { HowItWorksCard } from '@/components/data-binding-designer/HowItWorksCard' import { HowItWorksCard } from '@/components/data-binding-designer/HowItWorksCard'
import dataBindingCopy from '@/data/data-binding-designer.json' 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() { export function DataBindingDesigner() {
const [dataSources, setDataSources] = useState<DataSource[]>( const [dataSources, setDataSources] = useState<DataSource[]>(
buildSeedDataSources(dataBindingCopy.seed.dataSources as SeedDataSource[]), dataBindingCopy.seed.dataSources as DataSource[],
) )
const [mockComponents] = useState<UIComponent[]>(dataBindingCopy.seed.components) const [mockComponents] = useState<UIComponent[]>(dataBindingCopy.seed.components)

View File

@@ -45,11 +45,12 @@ function getCompletionMessage(score: number): string {
} }
export function ProjectDashboard(props: ProjectDashboardProps) { export function ProjectDashboard(props: ProjectDashboardProps) {
const completionSummary = calculateCompletionScore(props)
return ( return (
<JSONPageRenderer <JSONPageRenderer
schema={dashboardSchema as any} schema={dashboardSchema as any}
data={props} data={{ ...props, completionSummary }}
functions={{ calculateCompletionScore }}
/> />
) )
} }

View File

@@ -1,6 +1,6 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { DataSourceType } from '@/types/json-ui' import { DataSourceType } from '@/types/json-ui'
import { Database, Function, File } from '@phosphor-icons/react' import { Database, File } from '@phosphor-icons/react'
interface DataSourceBadgeProps { interface DataSourceBadgeProps {
type: DataSourceType type: DataSourceType
@@ -13,11 +13,6 @@ const dataSourceConfig = {
label: 'KV Storage', label: 'KV Storage',
className: 'bg-accent/20 text-accent border-accent/30' className: 'bg-accent/20 text-accent border-accent/30'
}, },
computed: {
icon: Function,
label: 'Computed',
className: 'bg-primary/20 text-primary border-primary/30'
},
static: { static: {
icon: File, icon: File,
label: 'Static', label: 'Static',

View File

@@ -5,6 +5,8 @@ import { Progress } from '@/components/ui/progress'
import { StatCard } from '@/components/atoms' import { StatCard } from '@/components/atoms'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getIcon, resolveBinding } from './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' import { LegacyPageSchema, PageSectionConfig } from './types'
interface PageSectionRendererProps { interface PageSectionRendererProps {
@@ -107,8 +109,21 @@ function PageCard({ card, data, functions }: PageCardProps) {
const icon = card.icon ? getIcon(card.icon) : null const icon = card.icon ? getIcon(card.icon) : null
if (card.type === 'gradient-card') { if (card.type === 'gradient-card') {
const computeFn = functions[card.dataSource?.compute] const dataSource = card.dataSource
const computedData = computeFn ? computeFn(data) : {} 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 ( return (
<Card className={cn('bg-gradient-to-br border-primary/20', card.gradient)}> <Card className={cn('bg-gradient-to-br border-primary/20', card.gradient)}>

View File

@@ -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 { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
import { DataSource } from '@/types/json-ui' import { DataSource } from '@/types/json-ui'
import { Pencil, Trash, ArrowsDownUp } from '@phosphor-icons/react' import { Pencil, Trash } from '@phosphor-icons/react'
interface DataSourceCardProps { interface DataSourceCardProps {
dataSource: DataSource dataSource: DataSource
dependents?: DataSource[]
onEdit: (id: string) => void onEdit: (id: string) => void
onDelete: (id: string) => void onDelete: (id: string) => void
} }
export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }: DataSourceCardProps) { export function DataSourceCard({ dataSource, onEdit, onDelete }: DataSourceCardProps) {
const getDependencyCount = () => {
if (dataSource.type === 'computed') {
return dataSource.dependencies?.length || 0
}
return 0
}
const renderTypeSpecificInfo = () => { const renderTypeSpecificInfo = () => {
if (dataSource.type === 'kv') { if (dataSource.type === 'kv') {
return ( return (
@@ -26,19 +18,7 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
</Text> </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 return null
} }
@@ -56,13 +36,6 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
{renderTypeSpecificInfo()} {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> </Stack>
<Flex align="center" gap="xs"> <Flex align="center" gap="xs">
@@ -78,7 +51,6 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
size="sm" size="sm"
onClick={() => onDelete(dataSource.id)} onClick={() => onDelete(dataSource.id)}
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive"
disabled={dependents.length > 0}
/> />
</Flex> </Flex>
</Flex> </Flex>

View File

@@ -5,14 +5,12 @@ import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
import { DataSourceIdField } from '@/components/molecules/data-source-editor/DataSourceIdField' import { DataSourceIdField } from '@/components/molecules/data-source-editor/DataSourceIdField'
import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields' import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields'
import { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields' 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 dataSourceEditorCopy from '@/data/data-source-editor-dialog.json'
import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor' import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor'
interface DataSourceEditorDialogProps { interface DataSourceEditorDialogProps {
open: boolean open: boolean
dataSource: DataSource | null dataSource: DataSource | null
allDataSources: DataSource[]
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onSave: (dataSource: DataSource) => void onSave: (dataSource: DataSource) => void
} }
@@ -20,19 +18,13 @@ interface DataSourceEditorDialogProps {
export function DataSourceEditorDialog({ export function DataSourceEditorDialog({
open, open,
dataSource, dataSource,
allDataSources,
onOpenChange, onOpenChange,
onSave, onSave,
}: DataSourceEditorDialogProps) { }: DataSourceEditorDialogProps) {
const { const {
editingSource, editingSource,
updateField, updateField,
addDependency, } = useDataSourceEditor(dataSource)
removeDependency,
availableDeps,
selectedDeps,
unselectedDeps,
} = useDataSourceEditor(dataSource, allDataSources)
const handleSave = () => { const handleSave = () => {
if (!editingSource) return 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> </div>
<DialogFooter> <DialogFooter>

View File

@@ -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>
</>
)
}

View File

@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DataSourceEditorDialog } from '@/components/molecules/DataSourceEditorDialog' import { DataSourceEditorDialog } from '@/components/molecules/DataSourceEditorDialog'
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager' import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
import { DataSource, DataSourceType } from '@/types/json-ui' 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 { toast } from 'sonner'
import { EmptyState, Stack } from '@/components/atoms' import { EmptyState, Stack } from '@/components/atoms'
import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader' import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader'
@@ -21,7 +21,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
addDataSource, addDataSource,
updateDataSource, updateDataSource,
deleteDataSource, deleteDataSource,
getDependents,
} = useDataSourceManager(dataSources) } = useDataSourceManager(dataSources)
const [editingSource, setEditingSource] = useState<DataSource | null>(null) const [editingSource, setEditingSource] = useState<DataSource | null>(null)
@@ -42,17 +41,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
} }
const handleDeleteSource = (id: string) => { 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) deleteDataSource(id)
onChange(localSources.filter(ds => ds.id !== id)) onChange(localSources.filter(ds => ds.id !== id))
toast.success(dataSourceManagerCopy.toasts.deleted) toast.success(dataSourceManagerCopy.toasts.deleted)
@@ -66,7 +54,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
const groupedSources = { const groupedSources = {
kv: localSources.filter(ds => ds.type === 'kv'), kv: localSources.filter(ds => ds.type === 'kv'),
computed: localSources.filter(ds => ds.type === 'computed'),
static: localSources.filter(ds => ds.type === 'static'), static: localSources.filter(ds => ds.type === 'static'),
} }
@@ -97,7 +84,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
icon={<Database size={16} />} icon={<Database size={16} />}
label={dataSourceManagerCopy.groups.kv} label={dataSourceManagerCopy.groups.kv}
dataSources={groupedSources.kv} dataSources={groupedSources.kv}
getDependents={getDependents}
onEdit={handleEditSource} onEdit={handleEditSource}
onDelete={handleDeleteSource} onDelete={handleDeleteSource}
/> />
@@ -106,16 +92,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
icon={<FileText size={16} />} icon={<FileText size={16} />}
label={dataSourceManagerCopy.groups.static} label={dataSourceManagerCopy.groups.static}
dataSources={groupedSources.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} onEdit={handleEditSource}
onDelete={handleDeleteSource} onDelete={handleDeleteSource}
/> />
@@ -127,7 +103,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
<DataSourceEditorDialog <DataSourceEditorDialog
open={dialogOpen} open={dialogOpen}
dataSource={editingSource} dataSource={editingSource}
allDataSources={localSources}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
onSave={handleSaveSource} onSave={handleSaveSource}
/> />

View File

@@ -7,7 +7,6 @@ interface DataSourceGroupSectionProps {
icon: ReactNode icon: ReactNode
label: string label: string
dataSources: DataSource[] dataSources: DataSource[]
getDependents: (id: string) => string[]
onEdit: (id: string) => void onEdit: (id: string) => void
onDelete: (id: string) => void onDelete: (id: string) => void
} }
@@ -16,7 +15,6 @@ export function DataSourceGroupSection({
icon, icon,
label, label,
dataSources, dataSources,
getDependents,
onEdit, onEdit,
onDelete, onDelete,
}: DataSourceGroupSectionProps) { }: DataSourceGroupSectionProps) {
@@ -37,7 +35,6 @@ export function DataSourceGroupSection({
<DataSourceCard <DataSourceCard
key={ds.id} key={ds.id}
dataSource={ds} dataSource={ds}
dependents={getDependents(ds.id)}
onEdit={onEdit} onEdit={onEdit}
onDelete={onDelete} onDelete={onDelete}
/> />

View File

@@ -5,7 +5,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { ActionButton, Heading, Stack, Text } from '@/components/atoms' 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' import { DataSourceType } from '@/types/json-ui'
interface DataSourceManagerHeaderCopy { interface DataSourceManagerHeaderCopy {
@@ -14,7 +14,6 @@ interface DataSourceManagerHeaderCopy {
addLabel: string addLabel: string
menu: { menu: {
kv: string kv: string
computed: string
static: string static: string
} }
} }
@@ -49,10 +48,6 @@ export function DataSourceManagerHeader({ copy, onAdd }: DataSourceManagerHeader
<Database className="w-4 h-4 mr-2" /> <Database className="w-4 h-4 mr-2" />
{copy.menu.kv} {copy.menu.kv}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => onAdd('computed')}>
<Function className="w-4 h-4 mr-2" />
{copy.menu.computed}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAdd('static')}> <DropdownMenuItem onClick={() => onAdd('static')}>
<FileText className="w-4 h-4 mr-2" /> <FileText className="w-4 h-4 mr-2" />
{copy.menu.static} {copy.menu.static}

View File

@@ -37,13 +37,6 @@ export function useDataSource(source: DataSource) {
loading: false, loading: false,
error: null, error: null,
} }
case 'computed':
return {
data: source.defaultValue,
setData: () => {},
loading: false,
error: null,
}
default: default:
return { return {
data: null, data: null,
@@ -67,7 +60,7 @@ export function useDataSources(sources: DataSource[]) {
useEffect(() => { useEffect(() => {
sources.forEach((source) => { sources.forEach((source) => {
if (source.type === 'static' || source.type === 'computed') { if (source.type === 'static') {
updateData(source.id, source.defaultValue) updateData(source.id, source.defaultValue)
} }
}) })

View File

@@ -10,7 +10,7 @@ export const ActionSchema = z.object({
export const DataSourceSchema = z.object({ export const DataSourceSchema = z.object({
id: z.string(), 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(), key: z.string().optional(),
endpoint: z.string().optional(), endpoint: z.string().optional(),
transform: z.string().optional(), transform: z.string().optional(),

View File

@@ -33,15 +33,15 @@
}, },
{ {
"id": "selectedTree", "id": "selectedTree",
"type": "computed", "type": "static",
"compute": "(data) => data.trees?.find(t => t.id === data.selectedTreeId) || null", "expression": "findById(data.trees, data.selectedTreeId)",
"dependencies": ["trees", "selectedTreeId"] "defaultValue": null
}, },
{ {
"id": "treeCount", "id": "treeCount",
"type": "computed", "type": "static",
"compute": "(data) => (data.trees || []).length", "expression": "length(data.trees)",
"dependencies": ["trees"] "defaultValue": 0
} }
], ],
"components": [ "components": [

View File

@@ -7,8 +7,8 @@
"icon": "CheckCircle", "icon": "CheckCircle",
"gradient": "from-primary/10 to-accent/10", "gradient": "from-primary/10 to-accent/10",
"dataSource": { "dataSource": {
"type": "computed", "type": "expression",
"compute": "calculateCompletionScore" "expression": "data.completionSummary"
}, },
"components": [ "components": [
{ {

View File

@@ -133,9 +133,9 @@
"data": [ "data": [
{ {
"id": "activeFile", "id": "activeFile",
"type": "computed", "type": "static",
"dependencies": ["files", "activeFileId"], "expression": "findById(data.files, data.activeFileId)",
"compute": "context.files.find(f => f.id === context.activeFileId)" "defaultValue": null
} }
], ],
"actions": [ "actions": [

View File

@@ -35,27 +35,27 @@
}, },
{ {
"id": "selectedBlueprint", "id": "selectedBlueprint",
"type": "computed", "type": "static",
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.find(b => b.id === data.selectedBlueprintId) || null; }", "expression": "findById(data.flaskConfig.blueprints, data.selectedBlueprintId)",
"dependencies": ["flaskConfig", "selectedBlueprintId"] "defaultValue": null
}, },
{ {
"id": "blueprintCount", "id": "blueprintCount",
"type": "computed", "type": "static",
"compute": "(data) => ((data.flaskConfig || {}).blueprints || []).length", "expression": "length(data.flaskConfig.blueprints)",
"dependencies": ["flaskConfig"] "defaultValue": 0
}, },
{ {
"id": "endpointCount", "id": "endpointCount",
"type": "computed", "type": "static",
"compute": "(data) => { const bp = data.selectedBlueprint; return bp ? (bp.endpoints || []).length : 0; }", "expression": "length(data.selectedBlueprint.endpoints)",
"dependencies": ["selectedBlueprint"] "defaultValue": 0
}, },
{ {
"id": "totalEndpoints", "id": "totalEndpoints",
"type": "computed", "type": "static",
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.reduce((sum, bp) => sum + (bp.endpoints || []).length, 0); }", "expression": "sumByLength(data.flaskConfig.blueprints, \"endpoints\")",
"dependencies": ["flaskConfig"] "defaultValue": 0
} }
], ],
"components": [ "components": [

View File

@@ -27,15 +27,15 @@
}, },
{ {
"id": "selectedLambda", "id": "selectedLambda",
"type": "computed", "type": "static",
"compute": "(data) => data.lambdas?.find(l => l.id === data.selectedLambdaId) || null", "expression": "findById(data.lambdas, data.selectedLambdaId)",
"dependencies": ["lambdas", "selectedLambdaId"] "defaultValue": null
}, },
{ {
"id": "lambdaCount", "id": "lambdaCount",
"type": "computed", "type": "static",
"compute": "(data) => (data.lambdas || []).length", "expression": "length(data.lambdas)",
"dependencies": ["lambdas"] "defaultValue": 0
} }
], ],
"components": [ "components": [

View File

@@ -28,15 +28,15 @@
}, },
{ {
"id": "selectedModel", "id": "selectedModel",
"type": "computed", "type": "static",
"compute": "(data) => data.models?.find(m => m.id === data.selectedModelId) || null", "expression": "findById(data.models, data.selectedModelId)",
"dependencies": ["models", "selectedModelId"] "defaultValue": null
}, },
{ {
"id": "modelCount", "id": "modelCount",
"type": "computed", "type": "static",
"compute": "(data) => (data.models || []).length", "expression": "length(data.models)",
"dependencies": ["models"] "defaultValue": 0
} }
], ],
"components": [ "components": [

View File

@@ -17,15 +17,15 @@
}, },
{ {
"id": "isRecent", "id": "isRecent",
"type": "computed", "type": "static",
"compute": "(data) => { if (!data.lastSaved) return false; return Date.now() - data.lastSaved < 3000; }", "expression": "isRecentTimestamp(data.lastSaved, data.currentTime, 3000)",
"dependencies": ["lastSaved", "currentTime"] "defaultValue": false
}, },
{ {
"id": "timeAgo", "id": "timeAgo",
"type": "computed", "type": "static",
"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`; }", "expression": "timeAgo(data.lastSaved, data.currentTime)",
"dependencies": ["lastSaved", "currentTime"] "defaultValue": ""
} }
], ],
"type": "div", "type": "div",

View File

@@ -54,21 +54,21 @@
}, },
{ {
"id": "activeVariant", "id": "activeVariant",
"type": "computed", "type": "static",
"compute": "(data) => { const theme = data.theme || {}; const variants = theme.variants || []; return variants.find(v => v.id === theme.activeVariantId) || variants[0] || null; }", "expression": "findByIdOrFirst(data.theme.variants, data.theme.activeVariantId)",
"dependencies": ["theme"] "defaultValue": null
}, },
{ {
"id": "variantCount", "id": "variantCount",
"type": "computed", "type": "static",
"compute": "(data) => ((data.theme || {}).variants || []).length", "expression": "length(data.theme.variants)",
"dependencies": ["theme"] "defaultValue": 0
}, },
{ {
"id": "customColorCount", "id": "customColorCount",
"type": "computed", "type": "static",
"compute": "(data) => { const variant = data.activeVariant; if (!variant || !variant.colors) return 0; return Object.keys(variant.colors.customColors || {}).length; }", "expression": "keyCount(data.activeVariant.colors.customColors)",
"dependencies": ["activeVariant"] "defaultValue": 0
} }
], ],
"components": [ "components": [

View File

@@ -32,15 +32,15 @@
}, },
{ {
"id": "selectedWorkflow", "id": "selectedWorkflow",
"type": "computed", "type": "static",
"compute": "(data) => data.workflows?.find(w => w.id === data.selectedWorkflowId) || null", "expression": "findById(data.workflows, data.selectedWorkflowId)",
"dependencies": ["workflows", "selectedWorkflowId"] "defaultValue": null
}, },
{ {
"id": "workflowCount", "id": "workflowCount",
"type": "computed", "type": "static",
"compute": "(data) => (data.workflows || []).length", "expression": "length(data.workflows)",
"dependencies": ["workflows"] "defaultValue": 0
} }
], ],
"components": [ "components": [

View File

@@ -1,7 +1,7 @@
{ {
"header": { "header": {
"title": "Data Binding Designer", "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": { "bindingsCard": {
"title": "Component Bindings", "title": "Component Bindings",
@@ -13,7 +13,6 @@
"title": "How It Works", "title": "How It Works",
"steps": [ "steps": [
"Create data sources (KV store for persistence, static for constants)", "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" "Bind component properties to data sources for reactive updates"
] ]
}, },
@@ -33,12 +32,6 @@
"type": "kv", "type": "kv",
"key": "app-counter", "key": "app-counter",
"defaultValue": 0 "defaultValue": 0
},
{
"id": "displayName",
"type": "computed",
"dependencies": ["userProfile"],
"computeId": "displayName"
} }
], ],
"components": [ "components": [
@@ -50,7 +43,8 @@
}, },
"bindings": { "bindings": {
"children": { "children": {
"source": "displayName" "source": "userProfile",
"path": "name"
} }
} }
}, },

View File

@@ -1,6 +1,6 @@
{ {
"title": "Edit Data Source", "title": "Edit Data Source",
"description": "Configure the data source settings and dependencies", "description": "Configure the data source settings",
"fields": { "fields": {
"id": { "id": {
"label": "ID", "label": "ID",
@@ -18,14 +18,6 @@
"valueLabel": "Value (JSON)", "valueLabel": "Value (JSON)",
"valuePlaceholder": "{\"key\": \"value\"}" "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": { "actions": {
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save Changes" "save": "Save Changes"

View File

@@ -1,14 +1,13 @@
{ {
"header": { "header": {
"title": "Data Sources", "title": "Data Sources",
"description": "Manage KV storage, computed values, and static data" "description": "Manage KV storage and static data"
}, },
"actions": { "actions": {
"add": "Add Data Source" "add": "Add Data Source"
}, },
"menu": { "menu": {
"kv": "KV Store", "kv": "KV Store",
"computed": "Computed Value",
"static": "Static Data" "static": "Static Data"
}, },
"emptyState": { "emptyState": {
@@ -17,12 +16,9 @@
}, },
"groups": { "groups": {
"kv": "KV Store", "kv": "KV Store",
"static": "Static Data", "static": "Static Data"
"computed": "Computed Values"
}, },
"toasts": { "toasts": {
"deleteBlockedTitle": "Cannot delete",
"deleteBlockedDescription": "This source is used by {count} computed {noun}",
"deleted": "Data source deleted", "deleted": "Data source deleted",
"updated": "Data source updated" "updated": "Data source updated"
} }

View File

@@ -1,9 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { DataSource } from '@/types/json-ui' import { DataSource } from '@/types/json-ui'
export function useDataSourceEditor( export function useDataSourceEditor(
dataSource: DataSource | null, dataSource: DataSource | null,
allDataSources: DataSource[],
) { ) {
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource) const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
@@ -15,44 +14,8 @@ export function useDataSourceEditor(
setEditingSource(prev => (prev ? { ...prev, [field]: value } : prev)) 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 { return {
editingSource, editingSource,
updateField, updateField,
addDependency,
removeDependency,
availableDeps,
selectedDeps,
unselectedDeps,
} }
} }

View File

@@ -9,7 +9,6 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
id: `ds-${Date.now()}`, id: `ds-${Date.now()}`,
type, type,
...(type === 'kv' && { key: '', defaultValue: null }), ...(type === 'kv' && { key: '', defaultValue: null }),
...(type === 'computed' && { compute: () => null, dependencies: [] }),
...(type === 'static' && { defaultValue: null }), ...(type === 'static' && { defaultValue: null }),
} }
@@ -31,19 +30,11 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
return dataSources.find(ds => ds.id === id) return dataSources.find(ds => ds.id === id)
}, [dataSources]) }, [dataSources])
const getDependents = useCallback((sourceId: string) => {
return dataSources.filter(ds =>
ds.type === 'computed' &&
ds.dependencies?.includes(sourceId)
)
}, [dataSources])
return { return {
dataSources, dataSources,
addDataSource, addDataSource,
updateDataSource, updateDataSource,
deleteDataSource, deleteDataSource,
getDataSource, getDataSource,
getDependents,
} }
} }

View File

@@ -1,13 +1,11 @@
import { useKV } from '@/hooks/use-kv' import { useKV } from '@/hooks/use-kv'
export type DataSourceType = 'kv' | 'static' | 'computed' export type DataSourceType = 'kv' | 'static'
export interface DataSourceConfig<T = any> { export interface DataSourceConfig<T = any> {
type: DataSourceType type: DataSourceType
key?: string key?: string
defaultValue?: T defaultValue?: T
compute?: (allData: Record<string, any>) => T
dependencies?: string[]
} }
export function useKVDataSource<T = any>(key: string, defaultValue?: T) { export function useKVDataSource<T = any>(key: string, defaultValue?: T) {
@@ -18,13 +16,6 @@ export function useStaticDataSource<T = any>(defaultValue: T) {
return [defaultValue, () => {}, () => {}] as const 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[]) { export function useMultipleDataSources(_sources: DataSourceConfig[]) {
return {} return {}
} }

View File

@@ -1,7 +1,9 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect, useMemo } from 'react'
import { useKV } from '@/hooks/use-kv' import { useKV } from '@/hooks/use-kv'
import { DataSource } from '@/types/json-ui' import { DataSource } from '@/types/json-ui'
import { setNestedValue } from '@/lib/json-ui/utils' 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[]) { export function useDataSources(dataSources: DataSource[]) {
const [data, setData] = useState<Record<string, any>>({}) const [data, setData] = useState<Record<string, any>>({})
@@ -27,7 +29,7 @@ export function useDataSources(dataSources: DataSource[]) {
if (kvIndex !== -1 && kvStates[kvIndex]) { if (kvIndex !== -1 && kvStates[kvIndex]) {
newData[source.id] = kvStates[kvIndex][0] 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 newData[source.id] = source.defaultValue
} }
}) })
@@ -39,20 +41,32 @@ export function useDataSources(dataSources: DataSource[]) {
initializeData() initializeData()
}, []) }, [])
useEffect(() => { const derivedData = useMemo(() => {
const computedSources = dataSources.filter(ds => ds.type === 'computed') const result: Record<string, any> = {}
const context = { ...data }
computedSources.forEach(source => {
if (source.compute) { dataSources.forEach((source) => {
const deps = source.dependencies || [] if (!source.expression && !source.valueTemplate) return
const hasAllDeps = deps.every(dep => dep in data)
let computedValue: any
if (hasAllDeps) { if (source.expression) {
const computedValue = source.compute(data) computedValue = evaluateBindingExpression(source.expression, context, {
setData(prev => ({ ...prev, [source.id]: computedValue })) 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]) }, [data, dataSources])
const updateData = useCallback((sourceId: string, value: any) => { const updateData = useCallback((sourceId: string, value: any) => {
@@ -101,8 +115,10 @@ export function useDataSources(dataSources: DataSource[]) {
}) })
}, [dataSources, kvSources, kvStates]) }, [dataSources, kvSources, kvStates])
const mergedData = useMemo(() => ({ ...data, ...derivedData }), [data, derivedData])
return { return {
data, data: mergedData,
updateData, updateData,
updatePath, updatePath,
loading, loading,

View File

@@ -2,6 +2,8 @@
import { useState, useEffect, useMemo, useCallback } from 'react' import { useState, useEffect, useMemo, useCallback } from 'react'
import { DataSource } from '@/types/json-ui' 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[]) { export function useDataSources(dataSources: DataSource[]) {
const [data, setData] = useState<Record<string, any>>({}) const [data, setData] = useState<Record<string, any>>({})
@@ -12,8 +14,8 @@ export function useDataSources(dataSources: DataSource[]) {
[dataSources] [dataSources]
) )
const computedSources = useMemo( const derivedSources = useMemo(
() => dataSources.filter((ds) => ds.type === 'computed'), () => dataSources.filter((ds) => ds.expression || ds.valueTemplate),
[dataSources] [dataSources]
) )
@@ -29,7 +31,7 @@ export function useDataSources(dataSources: DataSource[]) {
} catch { } catch {
initialData[ds.id] = ds.defaultValue initialData[ds.id] = ds.defaultValue
} }
} else if (ds.type === 'static') { } else if (ds.type === 'static' && !ds.expression && !ds.valueTemplate) {
initialData[ds.id] = ds.defaultValue initialData[ds.id] = ds.defaultValue
} }
} }
@@ -52,15 +54,29 @@ export function useDataSources(dataSources: DataSource[]) {
const computedData = useMemo(() => { const computedData = useMemo(() => {
const result: Record<string, any> = {} const result: Record<string, any> = {}
const context = { ...data }
computedSources.forEach((ds) => {
if (ds.compute && typeof ds.compute === 'function') { derivedSources.forEach((ds) => {
result[ds.id] = ds.compute(data) 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 return result
}, [computedSources, data]) }, [derivedSources, data])
const allData = useMemo( const allData = useMemo(
() => ({ ...data, ...computedData }), () => ({ ...data, ...computedData }),

View File

@@ -7,6 +7,7 @@ import { useWorkflows } from '../data/use-workflows'
import { useLambdas } from '../data/use-lambdas' import { useLambdas } from '../data/use-lambdas'
import { useActions } from './use-actions' import { useActions } from './use-actions'
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers' import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function usePage(schema: PageSchema) { export function usePage(schema: PageSchema) {
const files = useFiles() const files = useFiles()
@@ -44,15 +45,24 @@ export function usePage(schema: PageSchema) {
useEffect(() => { useEffect(() => {
if (schema.data) { if (schema.data) {
const computed: Record<string, any> = {} const computed: Record<string, any> = {}
const context = { ...dataContext }
schema.data.forEach(source => { schema.data.forEach(source => {
if (source.type === 'computed' && source.compute) { if (source.type === 'static') {
computed[source.id] = evaluateBindingExpression(source.compute, dataContext, { if (source.expression) {
fallback: undefined, computed[source.id] = evaluateBindingExpression(source.expression, context, {
label: `computed data (${source.id})`, fallback: source.defaultValue,
}) label: `data source (${source.id})`,
} else if (source.type === 'static' && source.defaultValue !== undefined) { })
computed[source.id] = source.defaultValue } 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]
} }
}) })

View File

@@ -1,16 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { DataSource } from '@/types/json-ui' import { DataSource } from '@/types/json-ui'
interface UseDataSourceEditorParams { interface UseDataSourceEditorParams {
dataSource: DataSource | null dataSource: DataSource | null
allDataSources: DataSource[]
onSave: (dataSource: DataSource) => void onSave: (dataSource: DataSource) => void
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
} }
export function useDataSourceEditor({ export function useDataSourceEditor({
dataSource, dataSource,
allDataSources,
onSave, onSave,
onOpenChange, onOpenChange,
}: UseDataSourceEditorParams) { }: 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(() => { const handleSave = useCallback(() => {
if (!editingSource) return if (!editingSource) return
onSave(editingSource) onSave(editingSource)
onOpenChange(false) onOpenChange(false)
}, [editingSource, onOpenChange, onSave]) }, [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 { return {
editingSource, editingSource,
updateField, updateField,
addDependency,
removeDependency,
handleSave, handleSave,
availableDeps,
selectedDeps,
unselectedDeps,
} }
} }

View File

@@ -8,6 +8,121 @@ interface EvaluationContext {
event?: any 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 * Safely evaluate a JSON expression
* Supports: * Supports:
@@ -26,6 +141,17 @@ export function evaluateExpression(
const { data, event } = context const { data, event } = context
try { 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') { if (expression === 'event') {
return event return event
} }

View File

@@ -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 IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
const NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/ const NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/
@@ -10,6 +10,8 @@ interface EvaluationOptions {
event?: any event?: any
} }
const FUNCTION_PATTERN = /^([A-Za-z_$][A-Za-z0-9_$]*)\((.*)\)$/
const isSupportedExpression = (expression: string) => { const isSupportedExpression = (expression: string) => {
if (expression === 'event' || expression === 'data') return true if (expression === 'event' || expression === 'data') return true
if (expression.startsWith('data.') || expression.startsWith('event.')) 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 (STRING_PATTERN.test(expression)) return true
if (NUMBER_PATTERN.test(expression)) return true if (NUMBER_PATTERN.test(expression)) return true
if (['true', 'false', 'null', 'undefined'].includes(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 return false
} }

View File

@@ -8,7 +8,6 @@ export function useJSONDataSource<T = unknown>(
) { ) {
const kvConfig = config.type === 'kv' ? config.config : undefined const kvConfig = config.type === 'kv' ? config.config : undefined
const apiConfig = config.type === 'api' ? config.config : undefined const apiConfig = config.type === 'api' ? config.config : undefined
const computedConfig = config.type === 'computed' ? config.config : undefined
const defaultValue = const defaultValue =
config.type === 'static' ? config.config : config.config?.defaultValue config.type === 'static' ? config.config : config.config?.defaultValue
@@ -57,8 +56,6 @@ export function useJSONDataSource<T = unknown>(
return apiValue return apiValue
case 'static': case 'static':
return config.config return config.config
case 'computed':
return computedConfig?.defaultValue
default: default:
return null return null
} }

View File

@@ -221,7 +221,7 @@ export const PageUISchema = z.object({
tables: z.array(TableSchema).optional(), tables: z.array(TableSchema).optional(),
menus: z.array(MenuSchema).optional(), menus: z.array(MenuSchema).optional(),
dataSources: z.record(z.string(), z.object({ dataSources: z.record(z.string(), z.object({
type: z.enum(['kv', 'api', 'computed', 'static']), type: z.enum(['kv', 'api', 'static']),
config: z.any(), config: z.any(),
})).optional(), })).optional(),
}) })
@@ -242,13 +242,6 @@ export type DataSourceConfig<T = unknown> =
transform?: (data: unknown) => T transform?: (data: unknown) => T
} }
} }
| {
type: 'computed'
config: {
defaultValue?: T
transform?: (data: unknown) => T
}
}
| { | {
type: 'static' type: 'static'
config: T config: T

View File

@@ -22,15 +22,19 @@
}, },
{ {
"id": "filteredUsers", "id": "filteredUsers",
"type": "computed", "type": "static",
"compute": "computeFilteredUsers", "expression": "filterUsersByQuery(data.users, data.filterQuery)",
"dependencies": ["users", "filterQuery"] "defaultValue": []
}, },
{ {
"id": "stats", "id": "stats",
"type": "computed", "type": "static",
"compute": "computeStats", "expression": "userStats(data.users)",
"dependencies": ["users"] "defaultValue": {
"total": 0,
"active": 0,
"inactive": 0
}
} }
], ],
"components": [ "components": [

View File

@@ -16,20 +16,7 @@ export function hydrateSchema(jsonSchema: any): PageSchema {
const schema = { ...jsonSchema } const schema = { ...jsonSchema }
if (schema.dataSources) { if (schema.dataSources) {
schema.dataSources = schema.dataSources.map((ds: any) => { schema.dataSources = schema.dataSources.map((ds: any) => ds)
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
})
} }
if (schema.components) { if (schema.components) {

View File

@@ -22,9 +22,13 @@
}, },
{ {
"id": "stats", "id": "stats",
"type": "computed", "type": "static",
"compute": "computeTodoStats", "expression": "todoStats(data.todos)",
"dependencies": ["todos"] "defaultValue": {
"total": 0,
"completed": 0,
"remaining": 0
}
} }
], ],
"components": [ "components": [

View File

@@ -42,7 +42,7 @@ export type ActionType =
| 'custom' | 'custom'
export type DataSourceType = export type DataSourceType =
| 'kv' | 'computed' | 'static' | 'kv' | 'static'
export type BindingSourceType = export type BindingSourceType =
| 'data' | 'bindings' | 'state' | 'data' | 'bindings' | 'state'
@@ -52,8 +52,8 @@ export interface DataSource {
type: DataSourceType type: DataSourceType
key?: string key?: string
defaultValue?: any defaultValue?: any
compute?: (data: Record<string, any>) => any expression?: string
dependencies?: string[] valueTemplate?: Record<string, any>
} }
export interface Action { export interface Action {

View File

@@ -26,11 +26,11 @@ export const ComponentSchema: z.ZodType<any> = z.lazy(() =>
export const DataSourceSchema = z.object({ export const DataSourceSchema = z.object({
id: z.string(), 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(), key: z.string().optional(),
defaultValue: z.any().optional(), defaultValue: z.any().optional(),
dependencies: z.array(z.string()).optional(), expression: z.string().optional(),
compute: z.string().optional(), valueTemplate: z.record(z.string(), z.any()).optional(),
}) })
export const ActionConfigSchema = z.object({ export const ActionConfigSchema = z.object({