From 39c57e9967dcefdd1cbf06f42a823d890dcec200 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sun, 18 Jan 2026 11:17:37 +0000 Subject: [PATCH] Add JSON event definitions and renderer support --- src/lib/json-ui/README.md | 37 +++++++++++++- src/lib/json-ui/component-renderer.tsx | 69 ++++++++++++++++++++++++-- src/lib/json-ui/renderer.tsx | 69 +++++++++++++++++--------- src/lib/json-ui/schema.ts | 20 +++++++- src/lib/json-ui/types.ts | 4 +- src/types/json-ui.ts | 11 +++- 6 files changed, 177 insertions(+), 33 deletions(-) diff --git a/src/lib/json-ui/README.md b/src/lib/json-ui/README.md index 2099817..d59038c 100644 --- a/src/lib/json-ui/README.md +++ b/src/lib/json-ui/README.md @@ -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: diff --git a/src/lib/json-ui/component-renderer.tsx b/src/lib/json-ui/component-renderer.tsx index c627e96..d56b23d 100644 --- a/src/lib/json-ui/component-renderer.tsx +++ b/src/lib/json-ui/component-renderer.tsx @@ -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): 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 = { ...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' diff --git a/src/lib/json-ui/renderer.tsx b/src/lib/json-ui/renderer.tsx index 36252b0..f3e4098 100644 --- a/src/lib/json-ui/renderer.tsx +++ b/src/lib/json-ui/renderer.tsx @@ -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 ) => { 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) { diff --git a/src/lib/json-ui/schema.ts b/src/lib/json-ui/schema.ts index 77b17ca..64fc26a 100644 --- a/src/lib/json-ui/schema.ts +++ b/src/lib/json-ui/schema.ts @@ -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 export type DataBinding = z.infer export type Action = z.infer export type EventHandler = z.infer +export type JSONEventDefinition = z.infer +export type JSONEventMap = z.infer export type Conditional = z.infer export type UIComponent = z.infer export type FormField = z.infer diff --git a/src/lib/json-ui/types.ts b/src/lib/json-ui/types.ts index 7dd69ba..55466d6 100644 --- a/src/lib/json-ui/types.ts +++ b/src/lib/json-ui/types.ts @@ -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 diff --git a/src/types/json-ui.ts b/src/types/json-ui.ts index 05c14f2..8f0ec72 100644 --- a/src/types/json-ui.ts +++ b/src/types/json-ui.ts @@ -56,6 +56,15 @@ export interface EventHandler { condition?: string | ((data: Record) => boolean) } +export interface JSONEventDefinition { + action?: string + actions?: Action[] + payload?: Record + condition?: string | ((data: Record) => boolean) +} + +export type JSONEventMap = Record + export interface Conditional { if: string then?: UIComponent | (UIComponent | string)[] | string @@ -76,7 +85,7 @@ export interface UIComponent { style?: Record bindings?: Record dataBinding?: string | Binding - events?: EventHandler[] + events?: EventHandler[] | JSONEventMap children?: UIComponent[] | string condition?: Binding conditional?: Conditional