diff --git a/src/components/JSONDemoPage.tsx b/src/components/JSONDemoPage.tsx index 5a87a5c..38ec0c3 100644 --- a/src/components/JSONDemoPage.tsx +++ b/src/components/JSONDemoPage.tsx @@ -3,45 +3,50 @@ import { toast } from 'sonner' import { useKV } from '@/hooks/use-kv' import { useState } from 'react' import { buildDemoPageSchema, demoCopy, demoInitialTodos } from '@/components/json-demo/schema' +import { Action } from '@/lib/json-ui/schema' export function JSONDemoPage() { const [todos, setTodos] = useKV('json-demo-todos', demoInitialTodos) const [newTodo, setNewTodo] = useState('') - const handleAction = (handler: any, event?: any) => { - switch (handler.action) { - case 'add-todo': - if (newTodo.trim()) { - setTodos((current: any) => [ - ...current, - { id: Date.now(), text: newTodo, completed: false }, - ]) - setNewTodo('') - toast.success(demoCopy.toastAdded) - } - break + const handleAction = (actions: Action[], event?: any) => { + actions.forEach((action) => { + const actionKey = action.type === 'custom' ? action.id : action.type - case 'toggle-todo': - setTodos((current: any) => - current.map((todo: any) => - todo.id === handler.params?.id - ? { ...todo, completed: !todo.completed } - : todo + switch (actionKey) { + case 'add-todo': + if (newTodo.trim()) { + setTodos((current: any) => [ + ...current, + { id: Date.now(), text: newTodo, completed: false }, + ]) + setNewTodo('') + toast.success(demoCopy.toastAdded) + } + break + + case 'toggle-todo': + setTodos((current: any) => + current.map((todo: any) => + todo.id === action.params?.id + ? { ...todo, completed: !todo.completed } + : todo + ) ) - ) - break + break - case 'delete-todo': - setTodos((current: any) => - current.filter((todo: any) => todo.id !== handler.params?.id) - ) - toast.success(demoCopy.toastDeleted) - break + case 'delete-todo': + setTodos((current: any) => + current.filter((todo: any) => todo.id !== action.params?.id) + ) + toast.success(demoCopy.toastDeleted) + break - case 'update-input': - setNewTodo(event.target.value) - break - } + case 'update-input': + setNewTodo(event.target.value) + break + } + }) } const pageSchema = buildDemoPageSchema(todos, newTodo) diff --git a/src/components/JSONUIPage.tsx b/src/components/JSONUIPage.tsx index 0e87aeb..55f189c 100644 --- a/src/components/JSONUIPage.tsx +++ b/src/components/JSONUIPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react' import { JSONUIRenderer } from '@/lib/json-ui/renderer' -import { UIComponent, EventHandler, Layout } from '@/lib/json-ui/schema' +import { Action, UIComponent, Layout } from '@/lib/json-ui/schema' import { toast } from 'sonner' interface JSONUIPageProps { @@ -34,88 +34,91 @@ export function JSONUIPage({ jsonConfig }: JSONUIPageProps) { })) } - const handleAction = (handler: EventHandler, event?: any) => { - console.log('Action triggered:', handler.action, handler.params, event) - - switch (handler.action) { - case 'refresh-data': - toast.success('Data refreshed') - break - case 'create-project': - toast.info('Create project clicked') - break - case 'deploy': - toast.info('Deploy clicked') - break - case 'view-logs': - toast.info('View logs clicked') - break - case 'settings': - toast.info('Settings clicked') - break - case 'add-project': - toast.info('Add project clicked') - break - case 'view-project': - toast.info(`View project: ${handler.params?.projectId}`) - break - case 'edit-project': - toast.info(`Edit project: ${handler.params?.projectId}`) - break - case 'delete-project': - toast.error(`Delete project: ${handler.params?.projectId}`) - break - case 'update-field': - if (event?.target) { - const { name, value } = event.target - updateDataField('formData', name, value) - } - break - case 'update-checkbox': - if (handler.params?.field) { - updateDataField('formData', handler.params.field, event) - } - break - case 'submit-form': - toast.success('Form submitted!') - console.log('Form data:', dataMap.formData) - break - case 'cancel-form': - toast.info('Form cancelled') - break - case 'toggle-dark-mode': - updateDataField('settings', 'darkMode', event) - toast.success(`Dark mode ${event ? 'enabled' : 'disabled'}`) - break - case 'toggle-auto-save': - updateDataField('settings', 'autoSave', event) - toast.success(`Auto-save ${event ? 'enabled' : 'disabled'}`) - break - case 'toggle-email-notifications': - updateDataField('notifications', 'email', event) - toast.success(`Email notifications ${event ? 'enabled' : 'disabled'}`) - break - case 'toggle-push-notifications': - updateDataField('notifications', 'push', event) - toast.success(`Push notifications ${event ? 'enabled' : 'disabled'}`) - break - case 'toggle-2fa': - updateDataField('security', 'twoFactor', event) - toast.success(`Two-factor auth ${event ? 'enabled' : 'disabled'}`) - break - case 'logout-all-sessions': - toast.success('All other sessions logged out') - break - case 'save-settings': - toast.success('Settings saved successfully') - console.log('Settings:', dataMap) - break - case 'reset-settings': - toast.info('Settings reset to defaults') - break - default: - console.log('Unhandled action:', handler.action) - } + const handleAction = (actions: Action[], event?: any) => { + actions.forEach((action) => { + const actionKey = action.type === 'custom' ? action.id : action.type + console.log('Action triggered:', actionKey, action.params, event) + + switch (actionKey) { + case 'refresh-data': + toast.success('Data refreshed') + break + case 'create-project': + toast.info('Create project clicked') + break + case 'deploy': + toast.info('Deploy clicked') + break + case 'view-logs': + toast.info('View logs clicked') + break + case 'settings': + toast.info('Settings clicked') + break + case 'add-project': + toast.info('Add project clicked') + break + case 'view-project': + toast.info(`View project: ${action.params?.projectId}`) + break + case 'edit-project': + toast.info(`Edit project: ${action.params?.projectId}`) + break + case 'delete-project': + toast.error(`Delete project: ${action.params?.projectId}`) + break + case 'update-field': + if (event?.target) { + const { name, value } = event.target + updateDataField('formData', name, value) + } + break + case 'update-checkbox': + if (action.params?.field) { + updateDataField('formData', action.params.field, event) + } + break + case 'submit-form': + toast.success('Form submitted!') + console.log('Form data:', dataMap.formData) + break + case 'cancel-form': + toast.info('Form cancelled') + break + case 'toggle-dark-mode': + updateDataField('settings', 'darkMode', event) + toast.success(`Dark mode ${event ? 'enabled' : 'disabled'}`) + break + case 'toggle-auto-save': + updateDataField('settings', 'autoSave', event) + toast.success(`Auto-save ${event ? 'enabled' : 'disabled'}`) + break + case 'toggle-email-notifications': + updateDataField('notifications', 'email', event) + toast.success(`Email notifications ${event ? 'enabled' : 'disabled'}`) + break + case 'toggle-push-notifications': + updateDataField('notifications', 'push', event) + toast.success(`Push notifications ${event ? 'enabled' : 'disabled'}`) + break + case 'toggle-2fa': + updateDataField('security', 'twoFactor', event) + toast.success(`Two-factor auth ${event ? 'enabled' : 'disabled'}`) + break + case 'logout-all-sessions': + toast.success('All other sessions logged out') + break + case 'save-settings': + toast.success('Settings saved successfully') + console.log('Settings:', dataMap) + break + case 'reset-settings': + toast.info('Settings reset to defaults') + break + default: + console.log('Unhandled action:', actionKey) + } + }) } if (!jsonConfig.layout) { diff --git a/src/components/json-demo/schema.ts b/src/components/json-demo/schema.ts index 0a6628e..66c95fb 100644 --- a/src/components/json-demo/schema.ts +++ b/src/components/json-demo/schema.ts @@ -49,12 +49,18 @@ const buildTodoItem = (todo: TodoItem, copy: DemoCopy): UIComponent => ({ props: { checked: todo.completed, }, - events: { - onCheckedChange: { - action: 'toggle-todo', - params: { id: todo.id }, + events: [ + { + event: 'checkedChange', + actions: [ + { + id: 'toggle-todo', + type: 'custom', + params: { id: todo.id }, + }, + ], }, - }, + ], }, { id: `text-${todo.id}`, @@ -72,12 +78,18 @@ const buildTodoItem = (todo: TodoItem, copy: DemoCopy): UIComponent => ({ size: 'sm', children: copy.deleteButtonLabel, }, - events: { - onClick: { - action: 'delete-todo', - params: { id: todo.id }, + events: [ + { + event: 'click', + actions: [ + { + id: 'delete-todo', + type: 'custom', + params: { id: todo.id }, + }, + ], }, - }, + ], }, ], }) diff --git a/src/lib/json-ui/page-renderer.tsx b/src/lib/json-ui/page-renderer.tsx index e998798..17b613c 100644 --- a/src/lib/json-ui/page-renderer.tsx +++ b/src/lib/json-ui/page-renderer.tsx @@ -20,17 +20,10 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) { const { executeActions } = useActionExecutor(context) - const handleEvent = useCallback((componentId: string, event: string, eventData: any) => { - const component = findComponentById(schema.components, componentId) - if (!component) return - - const handler = component.events?.find(h => h.event === event) - if (!handler) return - - if (handler.condition && !handler.condition(data)) return - + const handleEvent = useCallback((_componentId: string, handler: { actions: any[] }, eventData: any) => { + if (!handler?.actions?.length) return executeActions(handler.actions, eventData) - }, [schema.components, data, executeActions]) + }, [executeActions]) return (
@@ -45,14 +38,3 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
) } - -function findComponentById(components: any[], id: string): any { - for (const component of components) { - if (component.id === id) return component - if (component.children) { - const found = findComponentById(component.children, id) - if (found) return found - } - } - return null -} diff --git a/src/lib/json-ui/renderer.tsx b/src/lib/json-ui/renderer.tsx index 0013572..c02ca39 100644 --- a/src/lib/json-ui/renderer.tsx +++ b/src/lib/json-ui/renderer.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react' -import type { EventHandler, JSONFormRendererProps, JSONUIRendererProps, UIComponent } from './types' +import type { Action, EventHandler, JSONFormRendererProps, JSONUIRendererProps, UIComponent } from './types' import { getUIComponent } from './component-registry' import { resolveDataBinding, evaluateCondition, mergeClassNames } from './utils' import { cn } from '@/lib/utils' @@ -157,15 +157,68 @@ export function JSONUIRenderer({ } } - if (component.events) { - Object.entries(component.events).forEach(([eventName, handler]) => { - props[eventName] = (event?: any) => { - if (onAction) { - const eventHandler = typeof handler === 'string' - ? { action: handler } as EventHandler - : handler as EventHandler - onAction(eventHandler, event) + const normalizeEventName = (eventName: string) => + eventName.startsWith('on') && eventName.length > 2 + ? `${eventName.charAt(2).toLowerCase()}${eventName.slice(3)}` + : eventName + + const getEventPropName = (eventName: string) => + eventName.startsWith('on') + ? eventName + : `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}` + + const normalizeLegacyHandler = (eventName: string, handler: any): EventHandler | null => { + if (!handler) return null + + if (typeof handler === 'string') { + return { + event: normalizeEventName(eventName), + actions: [{ id: handler, type: 'custom' }], + } + } + + if (handler.actions && Array.isArray(handler.actions)) { + return { + event: normalizeEventName(eventName), + actions: handler.actions as Action[], + condition: handler.condition, + } + } + + if (typeof handler === 'object' && handler.action) { + return { + event: normalizeEventName(eventName), + actions: [ + { + id: handler.action, + type: 'custom', + target: handler.target, + params: handler.params, + }, + ], + } + } + + return null + } + + const eventHandlers: EventHandler[] = Array.isArray(component.events) + ? component.events + : component.events + ? Object.entries(component.events).map(([eventName, handler]) => + normalizeLegacyHandler(eventName, handler) + ).filter(Boolean) as EventHandler[] + : [] + + if (eventHandlers.length > 0) { + eventHandlers.forEach((handler) => { + const propName = getEventPropName(handler.event) + props[propName] = (event?: any) => { + if (handler.condition && typeof handler.condition === 'function') { + const conditionMet = handler.condition({ ...dataMap, ...context }) + if (!conditionMet) return } + onAction?.(handler.actions, event) } }) } @@ -213,12 +266,18 @@ export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONF type: field.type, value: formData[field.name] || field.defaultValue || '', }, - events: { - onChange: { - action: 'field-change', - params: { field: field.name }, + events: [ + { + event: 'change', + actions: [ + { + id: `field-change-${field.name}`, + type: 'set-value', + target: field.name, + }, + ], }, - }, + ], } return ( @@ -232,11 +291,13 @@ export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONF { - if (handler.action === 'field-change') { - const targetValue = (event as { target?: { value?: unknown } } | undefined)?.target?.value - handleFieldChange(field.name, targetValue) - } + onAction={(actions, event) => { + actions.forEach((action) => { + if (action.type === 'set-value' && action.target === field.name) { + const targetValue = (event as { target?: { value?: unknown } } | undefined)?.target?.value + handleFieldChange(field.name, targetValue) + } + }) }} /> diff --git a/src/lib/json-ui/schema.ts b/src/lib/json-ui/schema.ts index 862dd8f..77b17ca 100644 --- a/src/lib/json-ui/schema.ts +++ b/src/lib/json-ui/schema.ts @@ -15,10 +15,37 @@ export const DataBindingSchema = z.object({ transform: z.string().optional(), }) -export const EventHandlerSchema = z.object({ - action: z.string(), +export const ActionSchema = z.object({ + id: z.string(), + type: z.enum([ + 'create', + 'update', + 'delete', + 'navigate', + 'show-toast', + 'open-dialog', + 'close-dialog', + 'set-value', + 'toggle-value', + 'increment', + 'decrement', + 'custom', + ]), target: z.string().optional(), + 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(), + variant: z.enum(['success', 'error', 'info', 'warning']).optional(), +}) + +export const EventHandlerSchema = z.object({ + event: z.string(), + actions: z.array(ActionSchema), + condition: z.any().optional(), }) export const ConditionalSchema = z.object({ @@ -42,10 +69,7 @@ export const UIComponentSchema: any = z.object({ z.string(), DataBindingSchema, ]).optional(), - events: z.record(z.string(), z.union([ - z.string(), - EventHandlerSchema, - ])).optional(), + events: z.array(EventHandlerSchema).optional(), conditional: ConditionalSchema.optional(), loop: z.object({ source: z.string(), @@ -216,6 +240,7 @@ export type DataSourceConfig = export type UIValue = z.infer export type DataBinding = z.infer +export type Action = z.infer export type EventHandler = z.infer export type Conditional = z.infer export type UIComponent = z.infer diff --git a/src/lib/json-ui/types.ts b/src/lib/json-ui/types.ts index d3f4b01..7dd69ba 100644 --- a/src/lib/json-ui/types.ts +++ b/src/lib/json-ui/types.ts @@ -1,11 +1,11 @@ -import type { EventHandler, FormField, UIComponent } from './schema' +import type { Action, EventHandler, FormField, UIComponent } from './schema' -export type { EventHandler, FormField, UIComponent } +export type { Action, EventHandler, FormField, UIComponent } export interface JSONUIRendererProps { component: UIComponent dataMap?: Record - onAction?: (handler: EventHandler, event?: unknown) => void + onAction?: (actions: Action[], event?: unknown) => void context?: Record } diff --git a/src/types/json-ui.ts b/src/types/json-ui.ts index 5884b36..c954574 100644 --- a/src/types/json-ui.ts +++ b/src/types/json-ui.ts @@ -35,6 +35,7 @@ export interface Action { target?: string path?: string value?: any + params?: Record // Legacy: function-based compute compute?: (data: Record, event?: any) => any // New: JSON-friendly expression (e.g., "event.target.value", "data.fieldName")