Merge pull request #109 from johndoe6345789/codex/fix-json-ui-framework-rendering-issues

Stabilize JSON UI: bindings, conditionals, loops, and types
This commit is contained in:
2026-01-18 11:07:44 +00:00
committed by GitHub
6 changed files with 479 additions and 200 deletions

View File

@@ -109,7 +109,8 @@ Render lists from arrays:
{
"loop": {
"source": "items",
"itemVar": "item"
"itemVar": "item",
"indexVar": "index"
},
"children": [...]
}
@@ -129,6 +130,107 @@ Show/hide based on conditions:
}
```
## 🧭 Schema Patterns & Examples
### Conditional Branches
Conditionals can return a single component, an array of components, or a string payload:
```json
{
"id": "admin-greeting",
"type": "div",
"conditional": {
"if": "user.isAdmin",
"then": [
{ "id": "admin-title", "type": "h2", "children": "Welcome, Admin!" },
{ "id": "admin-subtitle", "type": "p", "children": "You have full access." }
],
"else": "You do not have access."
}
}
```
### Loop Templates (itemVar/indexVar)
Loop containers render once and repeat their children as the template. The `itemVar` and
`indexVar` values are available in bindings and expressions inside the loop:
```json
{
"id": "activity-list",
"type": "div",
"className": "space-y-2",
"loop": {
"source": "activities",
"itemVar": "activity",
"indexVar": "idx"
},
"children": [
{
"id": "activity-row",
"type": "div",
"children": [
{ "id": "activity-index", "type": "span", "dataBinding": "idx" },
{ "id": "activity-text", "type": "span", "dataBinding": "activity.text" }
]
}
]
}
```
### Dot-Path Bindings
Bindings support `foo.bar` access for both `dataBinding` and `bindings`:
```json
{
"id": "profile-name",
"type": "p",
"dataBinding": "user.profile.fullName"
}
```
```json
{
"id": "profile-avatar",
"type": "Avatar",
"bindings": {
"src": { "source": "user", "path": "profile.avatarUrl" },
"alt": { "source": "user.profile.fullName" }
}
}
```
### Transforms
Transforms can be applied to bindings for light formatting in JSON:
```json
{
"id": "user-score",
"type": "span",
"dataBinding": {
"source": "user",
"path": "score",
"transform": "data ?? 0"
}
}
```
```json
{
"id": "user-initials",
"type": "Badge",
"bindings": {
"children": {
"source": "user.profile.fullName",
"transform": "data.split(' ').map(part => part[0]).join('')"
}
}
}
```
## 🧩 Available Components
### Layout

View File

@@ -1,25 +1,10 @@
import { createElement, useMemo } from 'react'
import { createElement, useMemo, Fragment } from 'react'
import { UIComponent, Binding, ComponentRendererProps } from '@/types/json-ui'
import { getUIComponent } from './component-registry'
import { transformData } from './utils'
import { resolveDataBinding, evaluateCondition } from './utils'
function resolveBinding(binding: Binding, data: Record<string, unknown>): unknown {
const sourceValue = binding.source.includes('.')
? getNestedValue(data, binding.source)
: data[binding.source]
let value: unknown = sourceValue
if (binding.path) {
value = getNestedValue(value, binding.path)
}
if (binding.transform) {
value = typeof binding.transform === 'string'
? transformData(value, binding.transform)
: binding.transform(value)
}
return value
return resolveDataBinding(binding, data)
}
export function ComponentRenderer({ component, data, context = {}, onEvent }: ComponentRendererProps) {
@@ -32,16 +17,38 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
resolved[propName] = resolveBinding(binding, mergedData)
})
}
if (component.dataBinding) {
const boundData = resolveDataBinding(component.dataBinding, mergedData)
if (boundData !== undefined) {
resolved.value = boundData
resolved.data = boundData
}
}
if (component.events && onEvent) {
component.events.forEach(handler => {
resolved[`on${handler.event.charAt(0).toUpperCase()}${handler.event.slice(1)}`] = (e: unknown) => {
if (!handler.condition || handler.condition(mergedData as Record<string, any>)) {
onEvent(component.id, handler.event, e)
const conditionMet = !handler.condition
|| (typeof handler.condition === 'function'
? handler.condition(mergedData as Record<string, any>)
: evaluateCondition(handler.condition, mergedData as Record<string, any>))
if (conditionMet) {
onEvent(component.id, handler, e)
}
}
})
}
if (component.className) {
resolved.className = resolved.className
? `${resolved.className} ${component.className}`
: component.className
}
if (component.style) {
resolved.style = { ...(resolved.style as Record<string, unknown>), ...component.style }
}
return resolved
}, [component, mergedData, onEvent])
@@ -53,22 +60,128 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
return null
}
const renderChildren = (
children: UIComponent[] | string | undefined,
renderContext: Record<string, unknown>
) => {
if (!children) return null
if (typeof children === 'string') {
return children
}
return children.map((child, index) => (
<Fragment key={typeof child === 'string' ? `text-${index}` : child.id || index}>
{typeof child === 'string'
? child
: (
<ComponentRenderer
component={child}
data={data}
context={renderContext}
onEvent={onEvent}
/>
)}
</Fragment>
))
}
const renderBranch = (
branch: UIComponent | (UIComponent | string)[] | string | undefined,
renderContext: Record<string, unknown>
) => {
if (branch === undefined) return null
if (typeof branch === 'string') {
return branch
}
if (Array.isArray(branch)) {
return branch.map((child, index) => (
<Fragment key={typeof child === 'string' ? `text-${index}` : child.id || index}>
{typeof child === 'string'
? child
: (
<ComponentRenderer
component={child}
data={data}
context={renderContext}
onEvent={onEvent}
/>
)}
</Fragment>
))
}
return (
<ComponentRenderer
component={branch}
data={data}
context={renderContext}
onEvent={onEvent}
/>
)
}
const renderConditionalContent = (renderContext: Record<string, unknown>) => {
if (!component.conditional) return undefined
const conditionMet = evaluateCondition(component.conditional.if, { ...data, ...renderContext } as Record<string, any>)
if (conditionMet) {
if (component.conditional.then !== undefined) {
return renderBranch(component.conditional.then as UIComponent | (UIComponent | string)[] | string, renderContext)
}
return undefined
}
if (component.conditional.else !== undefined) {
return renderBranch(component.conditional.else as UIComponent | (UIComponent | string)[] | string, renderContext)
}
return null
}
if (component.loop) {
const items = resolveDataBinding(component.loop.source, mergedData) || []
const loopChildren = items.map((item: unknown, index: number) => {
const loopContext = {
...context,
[component.loop!.itemVar]: item,
...(component.loop!.indexVar ? { [component.loop!.indexVar]: index } : {}),
}
if (component.conditional) {
const conditionalContent = renderConditionalContent(loopContext)
if (conditionalContent !== undefined) {
return (
<Fragment key={`${component.id}-${index}`}>{conditionalContent}</Fragment>
)
}
}
if (component.condition) {
const conditionValue = resolveBinding(component.condition, { ...data, ...loopContext })
if (!conditionValue) {
return null
}
}
return (
<Fragment key={`${component.id}-${index}`}>
{renderChildren(component.children, loopContext)}
</Fragment>
)
})
return createElement(Component, resolvedProps, loopChildren)
}
if (component.conditional) {
const conditionalContent = renderConditionalContent(mergedData)
if (conditionalContent !== undefined) {
return conditionalContent
}
}
if (component.condition) {
const conditionValue = resolveBinding(component.condition, mergedData)
if (!conditionValue) {
return null
}
}
const children = component.children?.map((child, index) => (
<ComponentRenderer
key={child.id || index}
component={child}
data={data}
context={context}
onEvent={onEvent}
/>
))
return createElement(Component, resolvedProps, children)
return createElement(Component, resolvedProps, renderChildren(component.children, context))
}

View File

@@ -1,7 +1,7 @@
import React, { useCallback } from 'react'
import type { Action, EventHandler, JSONFormRendererProps, JSONUIRendererProps, UIComponent } from './types'
import { getUIComponent } from './component-registry'
import { resolveDataBinding, evaluateCondition, mergeClassNames } from './utils'
import { resolveDataBinding, evaluateCondition } from './utils'
import { cn } from '@/lib/utils'
export function JSONUIRenderer({
@@ -15,146 +15,58 @@ export function JSONUIRenderer({
renderContext: Record<string, unknown>
) => {
if (!children) return null
if (typeof children === 'string') {
return children
}
return children.map((child, index) => (
<React.Fragment key={child.id || `child-${index}`}>
{renderNode(child, renderContext)}
</React.Fragment>
))
}
const renderNode = (
node: UIComponent | string,
renderContext: Record<string, unknown>
) => {
if (typeof node === 'string') {
return node
}
return (
<JSONUIRenderer
key={child.id || `child-${index}`}
component={child}
component={node}
dataMap={dataMap}
onAction={onAction}
context={renderContext}
/>
))
)
}
const renderConditionalBranch = (
branch: UIComponent | UIComponent[] | string | undefined,
const renderBranch = (
branch: UIComponent | (UIComponent | string)[] | string | undefined,
renderContext: Record<string, unknown>
) => {
if (branch === undefined) return null
if (typeof branch === 'string' || Array.isArray(branch)) {
return renderChildren(branch, renderContext)
if (Array.isArray(branch)) {
return branch.map((item, index) => (
<React.Fragment key={typeof item === 'string' ? `text-${index}` : item.id || `branch-${index}`}>
{renderNode(item, renderContext)}
</React.Fragment>
))
}
return (
<JSONUIRenderer
component={branch as UIComponent}
dataMap={dataMap}
onAction={onAction}
context={renderContext}
/>
)
return renderNode(branch, renderContext)
}
if (component.conditional) {
const conditionMet = evaluateCondition(component.conditional.if, { ...dataMap, ...context })
if (conditionMet) {
if (component.conditional.then !== undefined) {
return renderConditionalBranch(
component.conditional.then as UIComponent | UIComponent[] | string,
context
)
}
} else {
if (component.conditional.else !== undefined) {
return renderConditionalBranch(
component.conditional.else as UIComponent | UIComponent[] | string,
context
)
}
return null
}
}
if (component.loop) {
const items = resolveDataBinding(component.loop.source, dataMap, context) || []
const Component = getUIComponent(component.type)
if (!Component) {
console.warn(`Component type "${component.type}" not found in registry`)
return null
}
return (
<>
{items.map((item: any, index: number) => {
const loopContext = {
...context,
[component.loop!.itemVar]: item,
...(component.loop!.indexVar ? { [component.loop!.indexVar]: index } : {}),
}
const props: Record<string, any> = { ...component.props }
if (component.dataBinding) {
const boundData = resolveDataBinding(component.dataBinding, dataMap, loopContext)
if (boundData !== undefined) {
props.value = boundData
props.data = boundData
}
}
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)
}
}
})
}
if (component.className) {
props.className = cn(props.className, component.className)
}
if (component.style) {
props.style = { ...props.style, ...component.style }
}
return (
<React.Fragment key={`${component.id}-${index}`}>
{typeof Component === 'string'
? React.createElement(Component, props, renderChildren(component.children, loopContext))
: (
<Component {...props}>
{renderChildren(component.children, loopContext)}
</Component>
)}
</React.Fragment>
)
})}
</>
)
}
const Component = getUIComponent(component.type)
if (!Component) {
console.warn(`Component type "${component.type}" not found in registry`)
return null
}
const props: Record<string, any> = { ...component.props }
if (component.bindings) {
Object.entries(component.bindings).forEach(([propName, binding]) => {
props[propName] = resolveDataBinding(binding, dataMap, context)
})
}
if (component.dataBinding) {
const boundData = resolveDataBinding(component.dataBinding, dataMap, context)
if (boundData !== undefined) {
props.value = boundData
props.data = boundData
}
const renderConditionalBranch = (
branch: UIComponent | (UIComponent | string)[] | string | undefined,
renderContext: Record<string, unknown>
) => {
return renderBranch(branch, renderContext)
}
const normalizeEventName = (eventName: string) =>
@@ -202,44 +114,165 @@ export function JSONUIRenderer({
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[]
: []
const applyEventHandlers = (
props: Record<string, any>,
renderContext: Record<string, unknown>
) => {
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
if (eventHandlers.length > 0) {
eventHandlers.forEach((handler) => {
const propName = getEventPropName(handler.event)
props[propName] = (event?: any) => {
if (handler.condition) {
const conditionMet = typeof handler.condition === 'function'
? handler.condition({ ...dataMap, ...renderContext })
: evaluateCondition(handler.condition, { ...dataMap, ...renderContext })
if (!conditionMet) return
}
onAction?.(handler.actions, event)
}
onAction?.(handler.actions, event)
})
}
}
const resolveProps = (renderContext: Record<string, unknown>) => {
const props: Record<string, any> = { ...component.props }
if (component.bindings) {
Object.entries(component.bindings).forEach(([propName, binding]) => {
props[propName] = resolveDataBinding(binding, dataMap, renderContext)
})
}
if (component.dataBinding) {
const boundData = resolveDataBinding(component.dataBinding, dataMap, renderContext)
if (boundData !== undefined) {
props.value = boundData
props.data = boundData
}
}
if (component.className) {
props.className = cn(props.className, component.className)
}
if (component.style) {
props.style = { ...props.style, ...component.style }
}
return props
}
const renderWithContext = (renderContext: Record<string, unknown>) => {
if (component.conditional) {
const conditionMet = evaluateCondition(component.conditional.if, { ...dataMap, ...renderContext })
if (conditionMet) {
if (component.conditional.then !== undefined) {
return renderConditionalBranch(
component.conditional.then as UIComponent | (UIComponent | string)[] | string,
renderContext
)
}
} else {
if (component.conditional.else !== undefined) {
return renderConditionalBranch(
component.conditional.else as UIComponent | (UIComponent | string)[] | string,
renderContext
)
}
return null
}
}
const Component = getUIComponent(component.type)
if (!Component) {
console.warn(`Component type "${component.type}" not found in registry`)
return null
}
const props = resolveProps(renderContext)
applyEventHandlers(props, renderContext)
if (typeof Component === 'string') {
return React.createElement(Component, props, renderChildren(component.children, renderContext))
}
return (
<Component {...props}>
{renderChildren(component.children, renderContext)}
</Component>
)
}
if (component.loop) {
const items = resolveDataBinding(component.loop.source, dataMap, context) || []
const Component = getUIComponent(component.type)
if (!Component) {
console.warn(`Component type "${component.type}" not found in registry`)
return null
}
const containerProps = resolveProps(context)
applyEventHandlers(containerProps, context)
const loopChildren = items.map((item: any, index: number) => {
const loopContext = {
...context,
[component.loop!.itemVar]: item,
...(component.loop!.indexVar ? { [component.loop!.indexVar]: index } : {}),
}
let content = renderChildren(component.children, loopContext)
if (component.conditional) {
const conditionMet = evaluateCondition(component.conditional.if, { ...dataMap, ...loopContext })
if (conditionMet) {
if (component.conditional.then !== undefined) {
content = renderConditionalBranch(
component.conditional.then as UIComponent | (UIComponent | string)[] | string,
loopContext
)
}
} else {
if (component.conditional.else !== undefined) {
content = renderConditionalBranch(
component.conditional.else as UIComponent | (UIComponent | string)[] | string,
loopContext
)
} else {
content = null
}
}
}
return (
<React.Fragment key={`${component.id}-${index}`}>
{content}
</React.Fragment>
)
})
if (typeof Component === 'string') {
return React.createElement(Component, containerProps, loopChildren)
}
return (
<Component {...containerProps}>
{loopChildren}
</Component>
)
}
if (component.className) {
props.className = cn(props.className, component.className)
}
if (component.style) {
props.style = { ...props.style, ...component.style }
}
if (typeof Component === 'string') {
return React.createElement(Component, props, renderChildren(component.children, context))
}
return (
<Component {...props}>
{renderChildren(component.children, context)}
</Component>
)
return renderWithContext(context)
}
export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONFormRendererProps) {

View File

@@ -1,5 +1,7 @@
type BindingTransform = string | ((data: unknown) => unknown)
export function resolveDataBinding(
binding: string | { source: string; path?: string; transform?: string },
binding: string | { source: string; path?: string; transform?: BindingTransform },
dataMap: Record<string, any>,
context: Record<string, any> = {},
): any {
@@ -13,10 +15,24 @@ export function resolveDataBinding(
}
const { source, path, transform } = binding
const data = mergedContext[source]
const resolvedValue = path ? getNestedValue(data, path) : data
const sourceValue = source.includes('.')
? getNestedValue(mergedContext, source)
: mergedContext[source]
const resolvedValue = path ? getNestedValue(sourceValue, path) : sourceValue
return transform ? transformData(resolvedValue, transform) : resolvedValue
return applyTransform(resolvedValue, transform)
}
function applyTransform(value: unknown, transform?: BindingTransform) {
if (!transform) {
return value
}
if (typeof transform === 'function') {
return transform(value)
}
return transformData(value, transform)
}
export function getNestedValue(obj: any, path: string): any {

View File

@@ -83,11 +83,11 @@ function checkComponentTree(
seenIds.add(component.id)
}
if (component.dataBinding && !component.dataBinding.source) {
if (component.dataBinding) {
const bindingPath = typeof component.dataBinding === 'string'
? component.dataBinding.split('.')[0]
: ''
: component.dataBinding.source?.split('.')[0]
if (bindingPath) {
result.warnings.push({
path: `${path}.${component.id}`,

View File

@@ -1,5 +1,3 @@
import { ReactNode } from 'react'
export type ComponentType =
| 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'
| 'Button' | 'Card' | 'CardHeader' | 'CardTitle' | 'CardDescription' | 'CardContent' | 'CardFooter'
@@ -37,7 +35,7 @@ export interface Action {
value?: any
params?: Record<string, any>
// Legacy: function-based compute
compute?: (data: Record<string, any>, event?: any) => any
compute?: ((data: Record<string, any>, event?: any) => any) | string
// New: JSON-friendly expression (e.g., "event.target.value", "data.fieldName")
expression?: string
// New: JSON template with dynamic values
@@ -55,17 +53,34 @@ export interface Binding {
export interface EventHandler {
event: string
actions: Action[]
condition?: (data: Record<string, any>) => boolean
condition?: string | ((data: Record<string, any>) => boolean)
}
export interface Conditional {
if: string
then?: UIComponent | (UIComponent | string)[] | string
else?: UIComponent | (UIComponent | string)[] | string
}
export interface Loop {
source: string
itemVar: string
indexVar?: string
}
export interface UIComponent {
id: string
type: ComponentType
props?: Record<string, any>
className?: string
style?: Record<string, any>
bindings?: Record<string, Binding>
dataBinding?: string | Binding
events?: EventHandler[]
children?: UIComponent[]
children?: UIComponent[] | string
condition?: Binding
conditional?: Conditional
loop?: Loop
}
export interface Layout {
@@ -100,7 +115,7 @@ export interface ComponentRendererProps {
component: UIComponent
data: Record<string, unknown>
context?: Record<string, unknown>
onEvent?: (componentId: string, event: string, eventData: unknown) => void
onEvent?: (componentId: string, handler: EventHandler, eventData: unknown) => void
}
export type ComponentSchema = UIComponent