mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 21:54:56 +00:00
Align json-ui event action handlers
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -36,9 +36,13 @@ export function ComponentRenderer({ component, data, onEvent }: ComponentRendere
|
||||
|
||||
if (component.events && onEvent) {
|
||||
component.events.forEach(handler => {
|
||||
resolved[`on${handler.event.charAt(0).toUpperCase()}${handler.event.slice(1)}`] = (e: unknown) => {
|
||||
const eventName = handler.event
|
||||
const propName = eventName.startsWith('on')
|
||||
? eventName
|
||||
: `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`
|
||||
resolved[propName] = (e: unknown) => {
|
||||
if (!handler.condition || handler.condition(data)) {
|
||||
onEvent(component.id, handler.event, e)
|
||||
onEvent(component.id, handler, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -94,15 +94,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -150,12 +203,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 (
|
||||
@@ -169,11 +228,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>
|
||||
|
||||
@@ -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({
|
||||
@@ -41,10 +68,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(),
|
||||
@@ -215,6 +239,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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -98,7 +99,7 @@ export interface JSONUIContext {
|
||||
export interface ComponentRendererProps {
|
||||
component: UIComponent
|
||||
data: Record<string, unknown>
|
||||
onEvent?: (componentId: string, event: string, eventData: unknown) => void
|
||||
onEvent?: (componentId: string, handler: EventHandler, eventData: unknown) => void
|
||||
}
|
||||
|
||||
export type ComponentSchema = UIComponent
|
||||
|
||||
Reference in New Issue
Block a user