Merge pull request #108 from johndoe6345789/codex/align-event/action-shape-in-renderer

Align JSON UI event handling with action arrays
This commit is contained in:
2026-01-18 02:38:12 +00:00
committed by GitHub
8 changed files with 261 additions and 172 deletions

View File

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

View File

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

View File

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

View File

@@ -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 (
<div className="h-full w-full">
@@ -45,14 +38,3 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
</div>
)
}
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
}

View File

@@ -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
<JSONUIRenderer
component={fieldComponent}
dataMap={{}}
onAction={(handler, event) => {
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)
}
})
}}
/>
</div>

View File

@@ -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<T = unknown> =
export type UIValue = z.infer<typeof UIValueSchema>
export type DataBinding = z.infer<typeof DataBindingSchema>
export type Action = z.infer<typeof ActionSchema>
export type EventHandler = z.infer<typeof EventHandlerSchema>
export type Conditional = z.infer<typeof ConditionalSchema>
export type UIComponent = z.infer<typeof UIComponentSchema>

View File

@@ -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<string, unknown>
onAction?: (handler: EventHandler, event?: unknown) => void
onAction?: (actions: Action[], event?: unknown) => void
context?: Record<string, unknown>
}

View File

@@ -35,6 +35,7 @@ export interface Action {
target?: string
path?: string
value?: any
params?: Record<string, any>
// Legacy: function-based compute
compute?: (data: Record<string, any>, event?: any) => any
// New: JSON-friendly expression (e.g., "event.target.value", "data.fieldName")