Merge pull request #110 from johndoe6345789/codex/define-json-event-schema-and-implement-dispatcher

Add JSON event map handling in JSON UI renderer
This commit is contained in:
2026-01-18 11:18:03 +00:00
committed by GitHub
6 changed files with 177 additions and 33 deletions

View File

@@ -91,16 +91,49 @@ Connect UI to data sources:
### Event Handling
Respond to user interactions:
Respond to user interactions using a JSON event map. Each entry maps an event name to an action definition:
```json
{
"events": {
"onClick": "save-data"
"onClick": {
"action": "save-data",
"payload": {
"source": "profile"
}
}
}
}
```
You can also pass full action arrays when needed:
```json
{
"events": {
"change": {
"actions": [
{ "id": "set-name", "type": "set-value", "target": "userName" }
]
}
}
}
```
#### Supported events
Events map directly to React handler props, so common values include:
- `click` / `onClick`
- `change` / `onChange`
- `submit` / `onSubmit`
- `focus` / `onFocus`
- `blur` / `onBlur`
- `keyDown` / `onKeyDown`
- `keyUp` / `onKeyUp`
- `mouseEnter` / `onMouseEnter`
- `mouseLeave` / `onMouseLeave`
### Looping
Render lists from arrays:

View File

@@ -1,5 +1,5 @@
import { createElement, useMemo, Fragment } from 'react'
import { UIComponent, Binding, ComponentRendererProps } from '@/types/json-ui'
import { UIComponent, Binding, ComponentRendererProps, EventHandler, JSONEventDefinition, JSONEventMap } from '@/types/json-ui'
import { getUIComponent } from './component-registry'
import { resolveDataBinding, evaluateCondition } from './utils'
@@ -9,6 +9,69 @@ function resolveBinding(binding: Binding, data: Record<string, unknown>): unknow
export function ComponentRenderer({ component, data, context = {}, onEvent }: ComponentRendererProps) {
const mergedData = useMemo(() => ({ ...data, ...context }), [data, context])
const resolvedEventHandlers = useMemo(() => {
const normalizeEventName = (eventName: string) =>
eventName.startsWith('on') && eventName.length > 2
? `${eventName.charAt(2).toLowerCase()}${eventName.slice(3)}`
: eventName
const normalizeDefinition = (eventName: string, definition: JSONEventDefinition | string): EventHandler | null => {
if (!definition) return null
const normalizedEventName = normalizeEventName(eventName)
if (typeof definition === 'string') {
return {
event: normalizedEventName,
actions: [{ id: definition, type: 'custom' }],
}
}
if (definition.actions?.length) {
const actions = definition.payload
? definition.actions.map((action) => ({
...action,
params: action.params ?? definition.payload,
}))
: definition.actions
return {
event: normalizedEventName,
actions,
condition: definition.condition,
}
}
if (definition.action) {
return {
event: normalizedEventName,
actions: [{ id: definition.action, type: 'custom', params: definition.payload }],
condition: definition.condition,
}
}
return null
}
if (!component.events) {
return [] as EventHandler[]
}
if (Array.isArray(component.events)) {
return component.events.map((handler) => ({
...handler,
event: normalizeEventName(handler.event),
}))
}
const eventMap = component.events as JSONEventMap
return Object.entries(eventMap).flatMap(([eventName, definition]) => {
if (Array.isArray(definition)) {
return definition
.map((entry) => normalizeDefinition(eventName, entry))
.filter(Boolean) as EventHandler[]
}
const normalized = normalizeDefinition(eventName, definition)
return normalized ? [normalized] : []
})
}, [component.events])
const resolvedProps = useMemo(() => {
const resolved: Record<string, unknown> = { ...component.props }
@@ -26,8 +89,8 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
}
}
if (component.events && onEvent) {
component.events.forEach(handler => {
if (resolvedEventHandlers.length > 0 && onEvent) {
resolvedEventHandlers.forEach(handler => {
resolved[`on${handler.event.charAt(0).toUpperCase()}${handler.event.slice(1)}`] = (e: unknown) => {
const conditionMet = !handler.condition
|| (typeof handler.condition === 'function'

View File

@@ -1,5 +1,13 @@
import React, { useCallback } from 'react'
import type { Action, EventHandler, JSONFormRendererProps, JSONUIRendererProps, UIComponent } from './types'
import type {
Action,
EventHandler,
JSONEventDefinition,
JSONEventMap,
JSONFormRendererProps,
JSONUIRendererProps,
UIComponent,
} from './types'
import { getUIComponent } from './component-registry'
import { resolveDataBinding, evaluateCondition } from './utils'
import { cn } from '@/lib/utils'
@@ -79,35 +87,39 @@ export function JSONUIRenderer({
? eventName
: `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`
const normalizeLegacyHandler = (eventName: string, handler: any): EventHandler | null => {
if (!handler) return null
const normalizeEventDefinition = (
eventName: string,
definition: JSONEventDefinition | string
): EventHandler | null => {
if (!definition) return null
const normalizedEvent = normalizeEventName(eventName)
if (typeof handler === 'string') {
if (typeof definition === 'string') {
return {
event: normalizeEventName(eventName),
actions: [{ id: handler, type: 'custom' }],
event: normalizedEvent,
actions: [{ id: definition, type: 'custom' }],
}
}
if (handler.actions && Array.isArray(handler.actions)) {
if (definition.actions && Array.isArray(definition.actions)) {
const actions = definition.payload
? definition.actions.map((action) => ({
...action,
params: action.params ?? definition.payload,
}))
: definition.actions
return {
event: normalizeEventName(eventName),
actions: handler.actions as Action[],
condition: handler.condition,
event: normalizedEvent,
actions,
condition: definition.condition,
}
}
if (typeof handler === 'object' && handler.action) {
if (definition.action) {
return {
event: normalizeEventName(eventName),
actions: [
{
id: handler.action,
type: 'custom',
target: handler.target,
params: handler.params,
},
],
event: normalizedEvent,
actions: [{ id: definition.action, type: 'custom', params: definition.payload }],
condition: definition.condition,
}
}
@@ -119,11 +131,20 @@ export function JSONUIRenderer({
renderContext: Record<string, unknown>
) => {
const eventHandlers: EventHandler[] = Array.isArray(component.events)
? component.events
? component.events.map((handler) => ({
...handler,
event: normalizeEventName(handler.event),
}))
: component.events
? Object.entries(component.events).map(([eventName, handler]) =>
normalizeLegacyHandler(eventName, handler)
).filter(Boolean) as EventHandler[]
? Object.entries(component.events as JSONEventMap).flatMap(([eventName, handler]) => {
if (Array.isArray(handler)) {
return handler
.map((entry) => normalizeEventDefinition(eventName, entry))
.filter(Boolean) as EventHandler[]
}
const normalized = normalizeEventDefinition(eventName, handler)
return normalized ? [normalized] : []
})
: []
if (eventHandlers.length > 0) {

View File

@@ -48,6 +48,22 @@ export const EventHandlerSchema = z.object({
condition: z.any().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(),
})
export const JSONEventMapSchema = z.record(
z.string(),
z.union([
z.string(),
JSONEventDefinitionSchema,
z.array(JSONEventDefinitionSchema),
])
)
export const ConditionalSchema = z.object({
if: z.string(),
then: z.any().optional(),
@@ -69,7 +85,7 @@ export const UIComponentSchema: any = z.object({
z.string(),
DataBindingSchema,
]).optional(),
events: z.array(EventHandlerSchema).optional(),
events: z.union([z.array(EventHandlerSchema), JSONEventMapSchema]).optional(),
conditional: ConditionalSchema.optional(),
loop: z.object({
source: z.string(),
@@ -242,6 +258,8 @@ 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 JSONEventDefinition = z.infer<typeof JSONEventDefinitionSchema>
export type JSONEventMap = z.infer<typeof JSONEventMapSchema>
export type Conditional = z.infer<typeof ConditionalSchema>
export type UIComponent = z.infer<typeof UIComponentSchema>
export type FormField = z.infer<typeof FormFieldSchema>

View File

@@ -1,6 +1,6 @@
import type { Action, EventHandler, FormField, UIComponent } from './schema'
import type { Action, EventHandler, FormField, JSONEventDefinition, JSONEventMap, UIComponent } from './schema'
export type { Action, EventHandler, FormField, UIComponent }
export type { Action, EventHandler, FormField, JSONEventDefinition, JSONEventMap, UIComponent }
export interface JSONUIRendererProps {
component: UIComponent

View File

@@ -56,6 +56,15 @@ export interface EventHandler {
condition?: string | ((data: Record<string, any>) => boolean)
}
export interface JSONEventDefinition {
action?: string
actions?: Action[]
payload?: Record<string, any>
condition?: string | ((data: Record<string, any>) => boolean)
}
export type JSONEventMap = Record<string, JSONEventDefinition | JSONEventDefinition[] | string>
export interface Conditional {
if: string
then?: UIComponent | (UIComponent | string)[] | string
@@ -76,7 +85,7 @@ export interface UIComponent {
style?: Record<string, any>
bindings?: Record<string, Binding>
dataBinding?: string | Binding
events?: EventHandler[]
events?: EventHandler[] | JSONEventMap
children?: UIComponent[] | string
condition?: Binding
conditional?: Conditional