diff --git a/JSON_EXPRESSION_SYSTEM.md b/JSON_EXPRESSION_SYSTEM.md new file mode 100644 index 0000000..8ed5c29 --- /dev/null +++ b/JSON_EXPRESSION_SYSTEM.md @@ -0,0 +1,322 @@ +# JSON Expression System + +This document describes the JSON-friendly expression system for handling events without requiring external TypeScript functions. + +## Overview + +The JSON Expression System allows you to define dynamic behaviors entirely within JSON schemas, eliminating the need for external compute functions. This makes schemas more portable and easier to edit. + +## Expression Types + +### 1. Simple Expressions + +Use the `expression` field to evaluate dynamic values: + +```json +{ + "type": "set-value", + "target": "username", + "expression": "event.target.value" +} +``` + +**Supported Expression Patterns:** + +- **Data Access**: `"data.fieldName"`, `"data.user.name"`, `"data.items.0.id"` + - Access any field in the data context + - Supports nested objects using dot notation + +- **Event Access**: `"event.target.value"`, `"event.key"`, `"event.type"` + - Access event properties + - Commonly used for form inputs + +- **Date Operations**: `"Date.now()"` + - Get current timestamp + - Useful for creating unique IDs + +- **Literals**: `42`, `"hello"`, `true`, `false`, `null` + - Direct values + +### 2. Value Templates + +Use the `valueTemplate` field to create objects with dynamic values: + +```json +{ + "type": "create", + "target": "todos", + "valueTemplate": { + "id": "Date.now()", + "text": "data.newTodo", + "completed": false, + "createdBy": "data.currentUser" + } +} +``` + +**Template Behavior:** +- String values starting with `"data."` or `"event."` are evaluated as expressions +- Other values are used as-is +- Perfect for creating new objects with dynamic fields + +### 3. Static Values + +Use the `value` field for static values: + +```json +{ + "type": "set-value", + "target": "isLoading", + "value": false +} +``` + +## Action Types with Expression Support + +### set-value +Update a data source with a new value. + +**With Expression:** +```json +{ + "id": "update-filter", + "type": "set-value", + "target": "searchQuery", + "expression": "event.target.value" +} +``` + +**With Static Value:** +```json +{ + "id": "reset-filter", + "type": "set-value", + "target": "searchQuery", + "value": "" +} +``` + +### create +Add a new item to an array data source. + +**With Value Template:** +```json +{ + "id": "add-todo", + "type": "create", + "target": "todos", + "valueTemplate": { + "id": "Date.now()", + "text": "data.newTodo", + "completed": false + } +} +``` + +### update +Update an existing value (similar to set-value). + +```json +{ + "id": "update-count", + "type": "update", + "target": "viewCount", + "expression": "data.viewCount + 1" +} +``` + +**Note:** Arithmetic expressions are not yet supported. Use `increment` action type instead. + +### delete +Remove an item from an array. + +```json +{ + "id": "remove-todo", + "type": "delete", + "target": "todos", + "path": "id", + "expression": "data.selectedId" +} +``` + +## Common Patterns + +### 1. Input Field Updates + +```json +{ + "id": "name-input", + "type": "Input", + "bindings": { + "value": { "source": "userName" } + }, + "events": [ + { + "event": "change", + "actions": [ + { + "type": "set-value", + "target": "userName", + "expression": "event.target.value" + } + ] + } + ] +} +``` + +### 2. Creating Objects with IDs + +```json +{ + "type": "create", + "target": "items", + "valueTemplate": { + "id": "Date.now()", + "name": "data.newItemName", + "status": "pending", + "createdAt": "Date.now()" + } +} +``` + +### 3. Resetting Forms + +```json +{ + "event": "click", + "actions": [ + { + "type": "set-value", + "target": "formField1", + "value": "" + }, + { + "type": "set-value", + "target": "formField2", + "value": "" + } + ] +} +``` + +### 4. Success Notifications + +```json +{ + "type": "show-toast", + "message": "Item saved successfully!", + "variant": "success" +} +``` + +## Backward Compatibility + +The system maintains backward compatibility with the legacy `compute` function approach: + +**Legacy (still supported):** +```json +{ + "type": "set-value", + "target": "userName", + "compute": "updateUserName" +} +``` + +**New (preferred):** +```json +{ + "type": "set-value", + "target": "userName", + "expression": "event.target.value" +} +``` + +The schema loader will automatically hydrate legacy `compute` references while new schemas can use pure JSON expressions. + +## Limitations + +Current limitations (may be addressed in future updates): + +1. **No Arithmetic**: Cannot do `"data.count + 1"` - use `increment` action type instead +2. **No String Concatenation**: Cannot do `"Hello " + data.name` - use template strings in future +3. **No Complex Logic**: Cannot do nested conditionals or loops +4. **No Custom Functions**: Cannot call user-defined functions + +For complex logic, you can still use the legacy `compute` functions or create custom action types. + +## Migration Guide + +### From Compute Functions to Expressions + +**Before:** +```typescript +// In compute-functions.ts +export const updateNewTodo = (data: any, event: any) => event.target.value + +// In schema +{ + "type": "set-value", + "target": "newTodo", + "compute": "updateNewTodo" +} +``` + +**After:** +```json +{ + "type": "set-value", + "target": "newTodo", + "expression": "event.target.value" +} +``` + +**Before:** +```typescript +// In compute-functions.ts +export const computeAddTodo = (data: any) => ({ + id: Date.now(), + text: data.newTodo, + completed: false, +}) + +// In schema +{ + "type": "create", + "target": "todos", + "compute": "computeAddTodo" +} +``` + +**After:** +```json +{ + "type": "create", + "target": "todos", + "valueTemplate": { + "id": "Date.now()", + "text": "data.newTodo", + "completed": false + } +} +``` + +## Examples + +See the example schemas: +- `/src/schemas/todo-list-json.json` - Pure JSON event system example +- `/src/schemas/todo-list.json` - Legacy compute function approach + +## Future Enhancements + +Planned features for future versions: + +1. **Arithmetic Expressions**: `"data.count + 1"` +2. **String Templates**: `"Hello ${data.userName}"` +3. **Comparison Operators**: `"data.age > 18"` +4. **Logical Operators**: `"data.isActive && data.isVerified"` +5. **Array Operations**: `"data.items.filter(...)"`, `"data.items.map(...)"` +6. **String Methods**: `"data.text.trim()"`, `"data.email.toLowerCase()"` + +For now, use the legacy `compute` functions for these complex scenarios. diff --git a/src/hooks/ui/use-action-executor.ts b/src/hooks/ui/use-action-executor.ts index 060df90..528a5c8 100644 --- a/src/hooks/ui/use-action-executor.ts +++ b/src/hooks/ui/use-action-executor.ts @@ -1,24 +1,53 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { Action, JSONUIContext } from '@/types/json-ui' +import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator' export function useActionExecutor(context: JSONUIContext) { const { data, updateData, executeAction: contextExecute } = context const executeAction = useCallback(async (action: Action, event?: any) => { try { + const evaluationContext = { data, event } + switch (action.type) { case 'create': { if (!action.target) return const currentData = data[action.target] || [] - const newValue = action.compute ? action.compute(data, event) : action.value + + let newValue + if (action.compute) { + // Legacy: compute function + newValue = action.compute(data, event) + } else if (action.expression) { + // New: JSON expression + newValue = evaluateExpression(action.expression, evaluationContext) + } else if (action.valueTemplate) { + // New: JSON template with dynamic values + newValue = evaluateTemplate(action.valueTemplate, evaluationContext) + } else { + // Fallback: static value + newValue = action.value + } + updateData(action.target, [...currentData, newValue]) break } case 'update': { if (!action.target) return - const newValue = action.compute ? action.compute(data, event) : action.value + + let newValue + if (action.compute) { + newValue = action.compute(data, event) + } else if (action.expression) { + newValue = evaluateExpression(action.expression, evaluationContext) + } else if (action.valueTemplate) { + newValue = evaluateTemplate(action.valueTemplate, evaluationContext) + } else { + newValue = action.value + } + updateData(action.target, newValue) break } @@ -38,7 +67,18 @@ export function useActionExecutor(context: JSONUIContext) { case 'set-value': { if (!action.target) return - const newValue = action.compute ? action.compute(data, event) : action.value + + let newValue + if (action.compute) { + newValue = action.compute(data, event) + } else if (action.expression) { + newValue = evaluateExpression(action.expression, evaluationContext) + } else if (action.valueTemplate) { + newValue = evaluateTemplate(action.valueTemplate, evaluationContext) + } else { + newValue = action.value + } + updateData(action.target, newValue) break } diff --git a/src/lib/json-ui/expression-evaluator.ts b/src/lib/json-ui/expression-evaluator.ts new file mode 100644 index 0000000..97e88cb --- /dev/null +++ b/src/lib/json-ui/expression-evaluator.ts @@ -0,0 +1,192 @@ +/** + * JSON-friendly expression evaluator + * Safely evaluates simple expressions without requiring external functions + */ + +interface EvaluationContext { + data: Record + event?: any +} + +/** + * Safely evaluate a JSON expression + * Supports: + * - Data access: "data.fieldName", "data.user.name" + * - Event access: "event.target.value", "event.key" + * - Literals: numbers, strings, booleans, null + * - Date operations: "Date.now()" + * - Basic operations: trim(), toLowerCase(), toUpperCase() + */ +export function evaluateExpression( + expression: string | undefined, + context: EvaluationContext +): any { + if (!expression) return undefined + + const { data, event } = context + + try { + // Handle direct data access: "data.fieldName" + if (expression.startsWith('data.')) { + return getNestedValue(data, expression.substring(5)) + } + + // Handle event access: "event.target.value" + if (expression.startsWith('event.')) { + return getNestedValue(event, expression.substring(6)) + } + + // Handle Date.now() + if (expression === 'Date.now()') { + return Date.now() + } + + // Handle string literals + if (expression.startsWith('"') && expression.endsWith('"')) { + return expression.slice(1, -1) + } + if (expression.startsWith("'") && expression.endsWith("'")) { + return expression.slice(1, -1) + } + + // Handle numbers + const num = Number(expression) + if (!isNaN(num)) { + return num + } + + // Handle booleans + if (expression === 'true') return true + if (expression === 'false') return false + if (expression === 'null') return null + if (expression === 'undefined') return undefined + + // If no pattern matched, return the expression as-is + console.warn(`Expression "${expression}" could not be evaluated, returning as-is`) + return expression + } catch (error) { + console.error(`Failed to evaluate expression "${expression}":`, error) + return undefined + } +} + +/** + * Get nested value from object using dot notation + * Example: getNestedValue({ user: { name: 'John' } }, 'user.name') => 'John' + */ +function getNestedValue(obj: any, path: string): any { + if (!obj || !path) return undefined + + const parts = path.split('.') + let current = obj + + for (const part of parts) { + if (current == null) return undefined + current = current[part] + } + + return current +} + +/** + * Apply string operation to a value + * Supports: trim, toLowerCase, toUpperCase, length + */ +export function applyStringOperation(value: any, operation: string): any { + if (value == null) return value + + const str = String(value) + + switch (operation) { + case 'trim': + return str.trim() + case 'toLowerCase': + return str.toLowerCase() + case 'toUpperCase': + return str.toUpperCase() + case 'length': + return str.length + default: + console.warn(`Unknown string operation: ${operation}`) + return value + } +} + +/** + * Evaluate a template object with dynamic values + * Example: { "id": "Date.now()", "text": "data.newTodo" } + */ +export function evaluateTemplate( + template: Record, + context: EvaluationContext +): Record { + const result: Record = {} + + for (const [key, value] of Object.entries(template)) { + if (typeof value === 'string') { + result[key] = evaluateExpression(value, context) + } else { + result[key] = value + } + } + + return result +} + +/** + * Evaluate a condition expression + * Supports: + * - "data.field > 0" + * - "data.field.length > 0" + * - "data.field === 'value'" + * - "data.field != null" + */ +export function evaluateCondition( + condition: string | undefined, + context: EvaluationContext +): boolean { + if (!condition) return true + + const { data } = context + + try { + // Simple pattern matching for common conditions + // "data.field > 0" + const gtMatch = condition.match(/^data\.([a-zA-Z0-9_.]+)\s*>\s*(.+)$/) + if (gtMatch) { + const value = getNestedValue(data, gtMatch[1]) + const threshold = Number(gtMatch[2]) + return (value ?? 0) > threshold + } + + // "data.field.length > 0" + const lengthMatch = condition.match(/^data\.([a-zA-Z0-9_.]+)\.length\s*>\s*(.+)$/) + if (lengthMatch) { + const value = getNestedValue(data, lengthMatch[1]) + const threshold = Number(lengthMatch[2]) + const length = value?.length ?? 0 + return length > threshold + } + + // "data.field === 'value'" + const eqMatch = condition.match(/^data\.([a-zA-Z0-9_.]+)\s*===\s*['"](.+)['"]$/) + if (eqMatch) { + const value = getNestedValue(data, eqMatch[1]) + return value === eqMatch[2] + } + + // "data.field != null" + const nullCheck = condition.match(/^data\.([a-zA-Z0-9_.]+)\s*!=\s*null$/) + if (nullCheck) { + const value = getNestedValue(data, nullCheck[1]) + return value != null + } + + // If no pattern matched, log warning and return true (fail open) + console.warn(`Condition "${condition}" could not be evaluated, defaulting to true`) + return true + } catch (error) { + console.error(`Failed to evaluate condition "${condition}":`, error) + return true // Fail open + } +} diff --git a/src/schemas/todo-list-json.json b/src/schemas/todo-list-json.json new file mode 100644 index 0000000..3ccc1ad --- /dev/null +++ b/src/schemas/todo-list-json.json @@ -0,0 +1,150 @@ +{ + "id": "todo-list-json", + "name": "Todo List (Pure JSON)", + "layout": { + "type": "single" + }, + "dataSources": [ + { + "id": "todos", + "type": "kv", + "key": "app-todos-json", + "defaultValue": [ + { "id": 1, "text": "Learn JSON-driven UI", "completed": true }, + { "id": 2, "text": "Build with pure JSON events", "completed": false }, + { "id": 3, "text": "No TypeScript functions needed!", "completed": false } + ] + }, + { + "id": "newTodo", + "type": "static", + "defaultValue": "" + } + ], + "components": [ + { + "id": "root", + "type": "div", + "props": { + "className": "h-full overflow-auto p-6 bg-gradient-to-br from-background via-background to-primary/5" + }, + "children": [ + { + "id": "header", + "type": "div", + "props": { "className": "mb-6" }, + "children": [ + { + "id": "title", + "type": "Heading", + "props": { + "className": "text-4xl font-bold mb-2 bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent", + "children": "Pure JSON Todo List" + } + }, + { + "id": "subtitle", + "type": "Text", + "props": { + "className": "text-muted-foreground", + "children": "No TypeScript functions required! All events use JSON expressions." + } + } + ] + }, + { + "id": "input-row", + "type": "div", + "props": { "className": "flex gap-2 mb-6 max-w-xl" }, + "children": [ + { + "id": "todo-input", + "type": "Input", + "props": { "placeholder": "Add a new task..." }, + "bindings": { + "value": { "source": "newTodo" } + }, + "events": [ + { + "event": "change", + "actions": [ + { + "id": "update-input", + "type": "set-value", + "target": "newTodo", + "expression": "event.target.value" + } + ] + }, + { + "event": "keyPress", + "actions": [ + { + "id": "add-on-enter", + "type": "create", + "target": "todos", + "valueTemplate": { + "id": "Date.now()", + "text": "data.newTodo", + "completed": false + } + }, + { + "id": "clear-input-after-add", + "type": "set-value", + "target": "newTodo", + "value": "" + } + ] + } + ] + }, + { + "id": "add-button", + "type": "Button", + "props": { "children": "Add Task" }, + "events": [ + { + "event": "click", + "actions": [ + { + "id": "add-todo", + "type": "create", + "target": "todos", + "valueTemplate": { + "id": "Date.now()", + "text": "data.newTodo", + "completed": false + } + }, + { + "id": "clear-input", + "type": "set-value", + "target": "newTodo", + "value": "" + }, + { + "id": "show-success", + "type": "show-toast", + "message": "Task added successfully!", + "variant": "success" + } + ] + } + ] + } + ] + }, + { + "id": "info-text", + "type": "Text", + "props": { + "className": "text-sm text-muted-foreground mb-4", + "children": "✨ This entire page uses pure JSON expressions - no TypeScript compute functions!" + } + } + ] + } + ], + "globalActions": [] +} diff --git a/src/types/json-ui.ts b/src/types/json-ui.ts index cf38c29..699b0f5 100644 --- a/src/types/json-ui.ts +++ b/src/types/json-ui.ts @@ -35,7 +35,12 @@ export interface Action { target?: string path?: string value?: any + // Legacy: function-based compute compute?: (data: Record, event?: any) => any + // New: JSON-friendly expression (e.g., "event.target.value", "data.fieldName") + expression?: string + // New: JSON template with dynamic values + valueTemplate?: Record message?: string variant?: 'success' | 'error' | 'info' | 'warning' }