Remove legacy compute schema support

This commit is contained in:
2026-01-18 17:41:57 +00:00
parent a718aca6f5
commit 966b6f2aa6
20 changed files with 260 additions and 281 deletions

62
JSON_EXPRESSION_SYSTEM.md Normal file
View File

@@ -0,0 +1,62 @@
# JSON Expression System
This document describes the supported JSON expression patterns used across JSON UI schemas.
Legacy compute functions have been removed in favor of expression strings and value templates.
## Core Concepts
### Expressions
Expressions are string values that resolve against a data + event context:
```json
{
"expression": "event.target.value"
}
```
Supported expression patterns:
- `data` or `event`
- Dot access: `data.user.name`, `event.target.value`
- Literals: numbers, booleans, `null`, `undefined`, quoted strings
- Time: `Date.now()`
- Array filtering:
- `data.todos.filter(completed === true)`
- `data.users.filter(status === 'active').length`
### Value Templates
Value templates are JSON objects whose string values are evaluated as expressions:
```json
{
"valueTemplate": {
"id": "Date.now()",
"text": "data.newTodo",
"completed": false
}
}
```
### Conditions
Conditions use expression strings that are evaluated against the data context:
```json
{
"condition": "data.newTodo.length > 0"
}
```
Supported condition patterns:
- `data.field > 0`
- `data.field.length > 0`
- `data.field === 'value'`
- `data.field != null`
## Legacy Compute Functions (Removed)
Schemas should no longer reference function names in `compute`, `transform`, or string-based
condition fields. Use `expression` and `valueTemplate` instead.

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

@@ -6,9 +6,12 @@ import { DataSource } from '@/types/json-ui'
import { X } from '@phosphor-icons/react'
interface ComputedSourceFieldsCopy {
computeLabel: string
computePlaceholder: string
computeHelp: string
expressionLabel: string
expressionPlaceholder: string
expressionHelp: string
valueTemplateLabel: string
valueTemplatePlaceholder: string
valueTemplateHelp: string
dependenciesLabel: string
availableSourcesLabel: string
emptyDependencies: string
@@ -38,22 +41,37 @@ export function ComputedSourceFields({
return (
<>
<div className="space-y-2">
<Label>{copy.computeLabel}</Label>
<Label>{copy.expressionLabel}</Label>
<Textarea
value={editingSource.compute?.toString() || ''}
value={editingSource.expression || ''}
onChange={(e) => {
try {
const fn = new Function('data', `return (${e.target.value})`)()
onUpdateField('compute', fn)
} catch (err) {
// Invalid function
}
onUpdateField('expression', e.target.value)
}}
placeholder={copy.computePlaceholder}
placeholder={copy.expressionPlaceholder}
className="font-mono text-sm h-24"
/>
<p className="text-xs text-muted-foreground">
{copy.computeHelp}
{copy.expressionHelp}
</p>
</div>
<div className="space-y-2">
<Label>{copy.valueTemplateLabel}</Label>
<Textarea
value={editingSource.valueTemplate ? JSON.stringify(editingSource.valueTemplate, null, 2) : ''}
onChange={(e) => {
try {
const template = JSON.parse(e.target.value)
onUpdateField('valueTemplate', template)
} catch (err) {
// Invalid JSON
}
}}
placeholder={copy.valueTemplatePlaceholder}
className="font-mono text-sm h-24"
/>
<p className="text-xs text-muted-foreground">
{copy.valueTemplateHelp}
</p>
</div>

View File

@@ -38,7 +38,7 @@
"id": "displayName",
"type": "computed",
"dependencies": ["userProfile"],
"computeId": "displayName"
"expression": "data.userProfile.name"
}
],
"components": [

View File

@@ -19,9 +19,12 @@
"valuePlaceholder": "{\"key\": \"value\"}"
},
"computed": {
"computeLabel": "Compute Function",
"computePlaceholder": "(data) => data.source1 + data.source2",
"computeHelp": "Function that computes the value from other data sources",
"expressionLabel": "Expression",
"expressionPlaceholder": "data.source1",
"expressionHelp": "Expression that computes the value from other data sources",
"valueTemplateLabel": "Value Template (JSON)",
"valueTemplatePlaceholder": "{\n \"total\": \"data.items.length\"\n}",
"valueTemplateHelp": "Template object with expressions for computed fields",
"dependenciesLabel": "Dependencies",
"availableSourcesLabel": "Available Sources",
"emptyDependencies": "No data sources available. Create KV or static sources first."

View File

@@ -9,7 +9,7 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
id: `ds-${Date.now()}`,
type,
...(type === 'kv' && { key: '', defaultValue: null }),
...(type === 'computed' && { compute: () => null, dependencies: [] }),
...(type === 'computed' && { expression: '', dependencies: [] }),
...(type === 'static' && { defaultValue: null }),
}

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'
import { useKV } from '@/hooks/use-kv'
import { DataSource } from '@/types/json-ui'
import { setNestedValue } from '@/lib/json-ui/utils'
import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function useDataSources(dataSources: DataSource[]) {
const [data, setData] = useState<Record<string, any>>({})
@@ -43,14 +44,17 @@ export function useDataSources(dataSources: DataSource[]) {
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 deps = source.dependencies || []
const hasAllDeps = deps.every(dep => dep in data)
if (hasAllDeps) {
const evaluationContext = { data }
const computedValue = source.expression
? evaluateExpression(source.expression, evaluationContext)
: source.valueTemplate
? evaluateTemplate(source.valueTemplate, evaluationContext)
: source.defaultValue
setData(prev => ({ ...prev, [source.id]: computedValue }))
}
})
}, [data, dataSources])

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { DataSource } from '@/types/json-ui'
import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function useDataSources(dataSources: DataSource[]) {
const [data, setData] = useState<Record<string, any>>({})
@@ -54,8 +55,17 @@ export function useDataSources(dataSources: DataSource[]) {
const result: Record<string, any> = {}
computedSources.forEach((ds) => {
if (ds.compute && typeof ds.compute === 'function') {
result[ds.id] = ds.compute(data)
const evaluationContext = { data }
if (ds.expression) {
result[ds.id] = evaluateExpression(ds.expression, evaluationContext)
return
}
if (ds.valueTemplate) {
result[ds.id] = evaluateTemplate(ds.valueTemplate, evaluationContext)
return
}
if (ds.defaultValue !== undefined) {
result[ds.id] = ds.defaultValue
}
})

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()
@@ -46,11 +47,15 @@ export function usePage(schema: PageSchema) {
const computed: Record<string, any> = {}
schema.data.forEach(source => {
if (source.type === 'computed' && source.compute) {
computed[source.id] = evaluateBindingExpression(source.compute, dataContext, {
fallback: undefined,
label: `computed data (${source.id})`,
})
if (source.type === 'computed') {
if (source.expression) {
computed[source.id] = evaluateBindingExpression(source.expression, dataContext, {
fallback: undefined,
label: `computed data (${source.id})`,
})
} else if (source.valueTemplate) {
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: dataContext })
}
} else if (source.type === 'static' && source.defaultValue !== undefined) {
computed[source.id] = source.defaultValue
}

View File

@@ -56,10 +56,7 @@ export function useActionExecutor(context: JSONUIContext) {
const currentData = data[action.target] || []
let newValue
if (action.compute) {
// Legacy: compute function
newValue = action.compute(data, event)
} else if (action.expression) {
if (action.expression) {
// New: JSON expression
newValue = evaluateExpression(action.expression, evaluationContext)
} else if (action.valueTemplate) {
@@ -79,9 +76,7 @@ export function useActionExecutor(context: JSONUIContext) {
if (!targetParts) return
let newValue
if (action.compute) {
newValue = action.compute(data, event)
} else if (action.expression) {
if (action.expression) {
newValue = evaluateExpression(action.expression, evaluationContext)
} else if (action.valueTemplate) {
newValue = evaluateTemplate(action.valueTemplate, evaluationContext)
@@ -102,9 +97,7 @@ export function useActionExecutor(context: JSONUIContext) {
const currentData = data[action.target] || []
let selectorValue
if (action.compute) {
selectorValue = action.compute(data, event)
} else if (action.expression) {
if (action.expression) {
selectorValue = evaluateExpression(action.expression, evaluationContext)
} else if (action.valueTemplate) {
selectorValue = evaluateTemplate(action.valueTemplate, evaluationContext)
@@ -129,9 +122,7 @@ export function useActionExecutor(context: JSONUIContext) {
if (!targetParts) return
let newValue
if (action.compute) {
newValue = action.compute(data, event)
} else if (action.expression) {
if (action.expression) {
newValue = evaluateExpression(action.expression, evaluationContext)
} else if (action.valueTemplate) {
newValue = evaluateTemplate(action.valueTemplate, evaluationContext)

View File

@@ -99,9 +99,7 @@ export function ComponentRenderer({ component, data, context = {}, state, onEven
resolvedEventHandlers.forEach(handler => {
resolved[`on${handler.event.charAt(0).toUpperCase()}${handler.event.slice(1)}`] = (e: unknown) => {
const conditionMet = !handler.condition
|| (typeof handler.condition === 'function'
? handler.condition(mergedData as Record<string, any>)
: evaluateConditionExpression(handler.condition, mergedData as Record<string, any>, { label: 'event handler condition' }))
|| evaluateConditionExpression(handler.condition, mergedData as Record<string, any>, { label: 'event handler condition' })
if (conditionMet) {
const eventPayload = typeof e === 'object' && e !== null
? Object.assign(e as Record<string, unknown>, context)

View File

@@ -34,6 +34,26 @@ export function evaluateExpression(
return data
}
const filterMatch = expression.match(
/^data\.([a-zA-Z0-9_.]+)\.filter\(\s*([a-zA-Z0-9_.]+)\s*(===|==|!==|!=)\s*(.+?)\s*\)(?:\.(length))?$/
)
if (filterMatch) {
const [, collectionPath, fieldPath, operator, rawValue, lengthSuffix] = filterMatch
const collection = getNestedValue(data, collectionPath)
if (!Array.isArray(collection)) {
return lengthSuffix ? 0 : []
}
const expectedValue = evaluateExpression(rawValue.trim(), { data, event })
const isNegated = operator === '!=' || operator === '!=='
const filtered = collection.filter((item) => {
const fieldValue = getNestedValue(item, fieldPath)
return isNegated ? fieldValue !== expectedValue : fieldValue === expectedValue
})
return lengthSuffix ? filtered.length : filtered
}
// Handle direct data access: "data.fieldName"
if (expression.startsWith('data.')) {
return getNestedValue(data, expression.substring(5))

View File

@@ -35,7 +35,6 @@ export const ActionSchema = z.object({
path: z.string().optional(),
value: z.any().optional(),
params: z.record(z.string(), z.any()).optional(),
compute: z.any().optional(),
expression: z.string().optional(),
valueTemplate: z.record(z.string(), z.any()).optional(),
message: z.string().optional(),
@@ -45,14 +44,14 @@ export const ActionSchema = z.object({
export const EventHandlerSchema = z.object({
event: z.string(),
actions: z.array(ActionSchema),
condition: z.any().optional(),
condition: z.string().optional(),
})
export const JSONEventDefinitionSchema = z.object({
action: z.string().optional(),
actions: z.array(ActionSchema).optional(),
payload: z.record(z.string(), z.any()).optional(),
condition: z.any().optional(),
condition: z.string().optional(),
})
export const JSONEventMapSchema = z.record(

View File

@@ -1,6 +1,6 @@
import { evaluateTransformExpression } from './expression-helpers'
type BindingTransform = string | ((data: unknown) => unknown)
type BindingTransform = string
interface BindingSourceOptions {
state?: Record<string, any>
@@ -50,10 +50,6 @@ function applyTransform(value: unknown, transform?: BindingTransform) {
return value
}
if (typeof transform === 'function') {
return transform(value)
}
return evaluateTransformExpression(transform, value, {}, { label: 'data binding transform' })
}

View File

@@ -23,13 +23,17 @@
{
"id": "filteredUsers",
"type": "computed",
"compute": "computeFilteredUsers",
"expression": "data.users",
"dependencies": ["users", "filterQuery"]
},
{
"id": "stats",
"type": "computed",
"compute": "computeStats",
"valueTemplate": {
"total": "data.users.length",
"active": "data.users.filter(status === 'active').length",
"inactive": "data.users.filter(status === 'inactive').length"
},
"dependencies": ["users"]
}
],
@@ -193,7 +197,7 @@
"bindings": {
"children": {
"source": "filteredUsers",
"transform": "transformFilteredUsers"
"path": "length"
}
}
}
@@ -227,7 +231,7 @@
"id": "update-filter",
"type": "set-value",
"target": "filterQuery",
"compute": "updateFilterQuery"
"expression": "event.target.value"
}
]
}
@@ -239,12 +243,75 @@
"id": "users-list",
"type": "div",
"props": { "className": "space-y-4" },
"bindings": {
"children": {
"source": "filteredUsers",
"transform": "transformUserList"
"loop": {
"source": "filteredUsers",
"itemVar": "user",
"indexVar": "userIndex"
},
"children": [
{
"id": "user-card",
"type": "Card",
"props": {
"className": "bg-background/50 hover:bg-background/80 transition-colors border-l-4 border-l-primary"
},
"children": [
{
"id": "user-card-content",
"type": "CardContent",
"props": { "className": "pt-6" },
"children": [
{
"id": "user-card-row",
"type": "div",
"props": { "className": "flex items-start justify-between" },
"children": [
{
"id": "user-card-info",
"type": "div",
"props": { "className": "flex-1" },
"children": [
{
"id": "user-card-name",
"type": "div",
"props": { "className": "font-semibold text-lg mb-1" },
"bindings": {
"children": { "source": "user", "path": "name" }
}
},
{
"id": "user-card-email",
"type": "div",
"props": { "className": "text-sm text-muted-foreground" },
"bindings": {
"children": { "source": "user", "path": "email" }
}
},
{
"id": "user-card-joined",
"type": "div",
"props": { "className": "text-xs text-muted-foreground mt-2" },
"bindings": {
"children": { "source": "user", "path": "joined" }
}
}
]
},
{
"id": "user-card-status",
"type": "Badge",
"props": { "variant": "secondary" },
"bindings": {
"children": { "source": "user", "path": "status" }
}
}
]
}
]
}
]
}
}
]
}
]
}

View File

@@ -1,88 +0,0 @@
export const computeFilteredUsers = (data: any) => {
const query = (data.filterQuery || '').toLowerCase()
if (!query) return data.users || []
return (data.users || []).filter((user: any) =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
)
}
export const computeStats = (data: any) => ({
total: data.users?.length || 0,
active: data.users?.filter((u: any) => u.status === 'active').length || 0,
inactive: data.users?.filter((u: any) => u.status === 'inactive').length || 0,
})
export const computeTodoStats = (data: any) => ({
total: data.todos?.length || 0,
completed: data.todos?.filter((t: any) => t.completed).length || 0,
remaining: data.todos?.filter((t: any) => !t.completed).length || 0,
})
export const computeAddTodo = (data: any) => ({
id: Date.now(),
text: data.newTodo,
completed: false,
})
export const updateFilterQuery = (_: any, event: any) => event?.target?.value || ''
export const updateNewTodo = (data: any, event: any) => event?.target?.value || ''
export const checkCanAddTodo = (data: any) => data.newTodo?.trim().length > 0
export const transformFilteredUsers = (users: any[]) => `${users?.length || 0} users`
export const transformUserList = (users: any[]) => (users || []).map((user: any) => ({
type: 'Card',
id: `user-${user.id}`,
props: {
className: 'bg-background/50 hover:bg-background/80 transition-colors border-l-4 border-l-primary',
},
children: [
{
type: 'CardContent',
id: `user-content-${user.id}`,
props: { className: 'pt-6' },
children: [
{
type: 'div',
id: `user-row-${user.id}`,
props: { className: 'flex items-start justify-between' },
children: [
{
type: 'div',
id: `user-info-${user.id}`,
props: { className: 'flex-1' },
children: [
{
type: 'div',
id: `user-name-${user.id}`,
props: { className: 'font-semibold text-lg mb-1', children: user.name },
},
{
type: 'div',
id: `user-email-${user.id}`,
props: { className: 'text-sm text-muted-foreground', children: user.email },
},
{
type: 'div',
id: `user-joined-${user.id}`,
props: { className: 'text-xs text-muted-foreground mt-2', children: `Joined ${user.joined}` },
},
],
},
{
type: 'Badge',
id: `user-status-${user.id}`,
props: {
variant: user.status === 'active' ? 'default' : 'secondary',
children: user.status,
},
},
],
},
],
},
],
}))

View File

@@ -1,7 +1,4 @@
import { PageSchema } from '@/types/json-ui'
import * as computeFunctions from './compute-functions'
type ComputeFunctionMap = typeof computeFunctions
export function hydrateSchema(jsonSchema: any): PageSchema {
// Validate basic schema structure
@@ -13,95 +10,5 @@ export function hydrateSchema(jsonSchema: any): PageSchema {
console.warn('Schema missing required fields: id and name')
}
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
})
}
if (schema.components) {
schema.components = hydrateComponents(schema.components)
}
return schema as PageSchema
}
function hydrateComponents(components: any[]): any[] {
return components.map(component => {
const hydratedComponent = { ...component }
if (component.events) {
hydratedComponent.events = component.events.map((event: any) => {
const hydratedEvent = { ...event }
if (event.condition && typeof event.condition === 'string') {
const functionName = event.condition as keyof ComputeFunctionMap
const conditionFunction = computeFunctions[functionName]
if (!conditionFunction) {
console.warn(`Condition function "${functionName}" not found`)
}
hydratedEvent.condition = conditionFunction || (() => false)
}
if (event.actions) {
hydratedEvent.actions = event.actions.map((action: any) => {
if (action.compute && typeof action.compute === 'string') {
const functionName = action.compute as keyof ComputeFunctionMap
const computeFunction = computeFunctions[functionName]
if (!computeFunction) {
console.warn(`Action compute function "${functionName}" not found`)
}
return {
...action,
compute: computeFunction || (() => null)
}
}
return action
})
}
return hydratedEvent
})
}
if (component.bindings) {
const hydratedBindings: Record<string, any> = {}
for (const [key, binding] of Object.entries(component.bindings)) {
const b = binding as any
if (b.transform && typeof b.transform === 'string') {
const functionName = b.transform as keyof ComputeFunctionMap
const transformFunction = computeFunctions[functionName]
if (!transformFunction) {
console.warn(`Transform function "${functionName}" not found`)
}
hydratedBindings[key] = {
...b,
transform: transformFunction || ((x: any) => x)
}
} else {
hydratedBindings[key] = b
}
}
hydratedComponent.bindings = hydratedBindings
}
if (component.children) {
hydratedComponent.children = hydrateComponents(component.children)
}
return hydratedComponent
})
return jsonSchema as PageSchema
}

View File

@@ -23,7 +23,11 @@
{
"id": "stats",
"type": "computed",
"compute": "computeTodoStats",
"valueTemplate": {
"total": "data.todos.length",
"completed": "data.todos.filter(completed === true).length",
"remaining": "data.todos.filter(completed === false).length"
},
"dependencies": ["todos"]
}
],
@@ -194,7 +198,7 @@
"id": "update-input",
"type": "set-value",
"target": "newTodo",
"compute": "updateNewTodo"
"expression": "event.target.value"
}
]
}
@@ -212,7 +216,11 @@
"id": "add-todo",
"type": "create",
"target": "todos",
"compute": "computeAddTodo"
"valueTemplate": {
"id": "Date.now()",
"text": "data.newTodo",
"completed": false
}
},
{
"id": "clear-input",
@@ -227,7 +235,7 @@
"variant": "success"
}
],
"condition": "checkCanAddTodo"
"condition": "data.newTodo.length > 0"
}
]
}

View File

@@ -52,7 +52,8 @@ export interface DataSource {
type: DataSourceType
key?: string
defaultValue?: any
compute?: (data: Record<string, any>) => any
expression?: string
valueTemplate?: Record<string, any>
dependencies?: string[]
}
@@ -63,8 +64,6 @@ export interface Action {
path?: string
value?: any
params?: Record<string, any>
// Legacy: function-based compute
compute?: ((data: Record<string, any>, event?: any) => any) | string
// New: JSON-friendly expression (e.g., "event.target.value", "data.fieldName")
expression?: string
// New: JSON template with dynamic values
@@ -77,20 +76,20 @@ export interface Binding {
source: string
sourceType?: BindingSourceType
path?: string
transform?: string | ((value: any) => any)
transform?: string
}
export interface EventHandler {
event: string
actions: Action[]
condition?: string | ((data: Record<string, any>) => boolean)
condition?: string
}
export interface JSONEventDefinition {
action?: string
actions?: Action[]
payload?: Record<string, any>
condition?: string | ((data: Record<string, any>) => boolean)
condition?: string
}
export type JSONEventMap = Record<string, JSONEventDefinition | JSONEventDefinition[] | string>

View File

@@ -30,7 +30,8 @@ export const DataSourceSchema = z.object({
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({