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 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)

View File

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

View File

@@ -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',

View File

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

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 { 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>

View File

@@ -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>

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 { 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}
/>

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

@@ -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": [

View File

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

View File

@@ -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": [

View File

@@ -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": [

View File

@@ -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": [

View File

@@ -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": [

View File

@@ -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",

View File

@@ -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": [

View File

@@ -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": [

View File

@@ -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"
}
}
},

View File

@@ -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"

View File

@@ -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"
}

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'
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,
}
}

View File

@@ -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,
}
}

View File

@@ -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 {}
}

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 { 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,

View File

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

View File

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

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'
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,
}
}

View File

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

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 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
}

View File

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

View File

@@ -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

View File

@@ -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": [

View File

@@ -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) {

View File

@@ -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": [

View File

@@ -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 {

View File

@@ -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({