Compare commits

..

7 Commits

27 changed files with 527 additions and 1302 deletions

View File

@@ -171,6 +171,24 @@ To add a new component to the JSON UI system:
4. Update `json-components-registry.json` with metadata
5. Test the component in a JSON schema
### JSON Compatibility Checklist
Before migrating a component, confirm all required conditions are met:
- [ ] **Hooks/state are registry-safe**: hooks and internal state are acceptable when the component registry can control or expose them through JSON bindings.
- [ ] **Bindings are defined**: any required actions, event handlers, or state bindings are already supported by the JSON UI binding system.
- [ ] **Refactoring covered by PR**: JSON compatibility gaps should be resolved via refactoring as part of the same pull request.
### Step-by-Step Migration Path
Use this repeatable migration flow for planned components:
1. **Update the type union** in `src/types/json-ui.ts` to include the new component type name.
2. **Register the component** in `src/lib/json-ui/component-registry.tsx` so JSON schemas can resolve it at runtime.
3. **Define component metadata** in `src/lib/component-definitions.ts` (defaults, prop schema, and any JSON-driven constraints).
4. **Validate JSON schema usage** by rendering a sample schema that uses the new type.
5. **Update registry metadata** in `json-components-registry.json` so the CLI/listing reflects the new status.
## Migration Strategy
Components marked as "planned" are:

View File

@@ -2,7 +2,7 @@
"$schema": "./schemas/json-components-registry-schema.json",
"version": "2.0.0",
"description": "Registry of all components in the application",
"lastUpdated": "2026-01-17T22:10:22.582Z",
"lastUpdated": "2026-01-18T11:30:41.908Z",
"categories": {
"layout": "Layout and container components",
"input": "Form inputs and interactive controls",
@@ -322,7 +322,7 @@
"category": "input",
"canHaveChildren": false,
"description": "Date selection input",
"status": "planned",
"status": "supported",
"source": "atoms"
},
{
@@ -331,7 +331,7 @@
"category": "input",
"canHaveChildren": false,
"description": "File upload control",
"status": "planned",
"status": "supported",
"source": "atoms"
},
{
@@ -847,7 +847,7 @@
"canHaveChildren": false,
"description": "Navigation breadcrumb trail",
"status": "json-compatible",
"source": "json-ui-wrappers",
"source": "molecules",
"jsonCompatible": true
},
{
@@ -1257,7 +1257,7 @@
"canHaveChildren": true,
"description": "LazyBarChart component",
"status": "json-compatible",
"source": "json-ui-wrappers",
"source": "molecules",
"jsonCompatible": true
},
{
@@ -1267,7 +1267,7 @@
"canHaveChildren": true,
"description": "LazyD3BarChart component",
"status": "json-compatible",
"source": "json-ui-wrappers",
"source": "molecules",
"jsonCompatible": true
},
{
@@ -1277,7 +1277,7 @@
"canHaveChildren": true,
"description": "LazyLineChart component",
"status": "json-compatible",
"source": "json-ui-wrappers",
"source": "molecules",
"jsonCompatible": true
},
{
@@ -1319,11 +1319,11 @@
{
"type": "SeedDataManager",
"name": "SeedDataManager",
"category": "custom",
"category": "data",
"canHaveChildren": true,
"description": "SeedDataManager component",
"status": "json-compatible",
"source": "json-ui-wrappers",
"source": "molecules",
"jsonCompatible": true
},
{
@@ -1826,11 +1826,11 @@
{
"type": "SaveIndicator",
"name": "SaveIndicator",
"category": "feedback",
"category": "custom",
"canHaveChildren": true,
"description": "SaveIndicator component",
"status": "json-compatible",
"source": "json-ui-wrappers",
"source": "molecules",
"jsonCompatible": true
},
{
@@ -2000,7 +2000,7 @@
"canHaveChildren": true,
"description": "StorageSettings component",
"status": "json-compatible",
"source": "json-ui-wrappers",
"source": "molecules",
"jsonCompatible": true
},
{
@@ -2043,8 +2043,8 @@
],
"statistics": {
"total": 219,
"supported": 150,
"planned": 14,
"supported": 152,
"planned": 12,
"jsonCompatible": 14,
"maybeJsonCompatible": 41,
"byCategory": {

View File

@@ -5,6 +5,7 @@ import { PageRenderer } from '@/lib/json-ui/page-renderer'
import { hydrateSchema } from '@/schemas/schema-loader'
import todoListJson from '@/schemas/todo-list.json'
import newMoleculesShowcaseJson from '@/schemas/new-molecules-showcase.json'
import { inputComponentsShowcaseSchema } from '@/schemas/page-schemas'
const todoListSchema = hydrateSchema(todoListJson)
const newMoleculesShowcaseSchema = hydrateSchema(newMoleculesShowcaseJson)
@@ -24,6 +25,7 @@ export function JSONUIShowcasePage() {
</div>
<TabsList className="w-full justify-start">
<TabsTrigger value="atomic">Atomic Components</TabsTrigger>
<TabsTrigger value="inputs">JSON Inputs</TabsTrigger>
<TabsTrigger value="molecules">New Molecules</TabsTrigger>
<TabsTrigger value="dashboard">JSON Dashboard</TabsTrigger>
<TabsTrigger value="todos">JSON Todo List</TabsTrigger>
@@ -34,6 +36,10 @@ export function JSONUIShowcasePage() {
<TabsContent value="atomic" className="h-full m-0 data-[state=active]:block">
<AtomicComponentDemo />
</TabsContent>
<TabsContent value="inputs" className="h-full m-0 data-[state=active]:block">
<PageRenderer schema={inputComponentsShowcaseSchema} />
</TabsContent>
<TabsContent value="molecules" className="h-full m-0 data-[state=active]:block">
<PageRenderer schema={newMoleculesShowcaseSchema} />

View File

@@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
interface DatePickerProps {
value?: Date
onChange: (date: Date | undefined) => void
onChange?: (date: Date | undefined) => void
placeholder?: string
disabled?: boolean
className?: string

View File

@@ -6,7 +6,7 @@ interface FileUploadProps {
accept?: string
multiple?: boolean
maxSize?: number
onFilesSelected: (files: File[]) => void
onFilesSelected?: (files: File[]) => void
disabled?: boolean
className?: string
}
@@ -34,7 +34,7 @@ export function FileUpload({
})
setSelectedFiles(validFiles)
onFilesSelected(validFiles)
onFilesSelected?.(validFiles)
}
const handleDrop = (e: React.DragEvent) => {
@@ -59,7 +59,7 @@ export function FileUpload({
const removeFile = (index: number) => {
const newFiles = selectedFiles.filter((_, i) => i !== index)
setSelectedFiles(newFiles)
onFilesSelected(newFiles)
onFilesSelected?.(newFiles)
}
return (

View File

@@ -6,6 +6,7 @@ export interface ComponentDefinition {
category: 'layout' | 'input' | 'display' | 'navigation' | 'feedback' | 'data' | 'custom'
icon: string
defaultProps?: Record<string, any>
propSchema?: Record<string, { type: string; description?: string; required?: boolean }>
canHaveChildren?: boolean
}
@@ -97,6 +98,33 @@ export const componentDefinitions: ComponentDefinition[] = [
icon: 'CaretDown',
defaultProps: { placeholder: 'Choose option...' }
},
{
type: 'DatePicker',
label: 'Date Picker',
category: 'input',
icon: 'Calendar',
defaultProps: { placeholder: 'Pick a date' },
propSchema: {
value: { type: 'date', description: 'Selected date value' },
placeholder: { type: 'string', description: 'Placeholder when no date is selected' },
disabled: { type: 'boolean', description: 'Disable the date picker' },
onChange: { type: 'event', description: 'Fires when the date selection changes' }
}
},
{
type: 'FileUpload',
label: 'File Upload',
category: 'input',
icon: 'Upload',
defaultProps: { accept: '', multiple: false },
propSchema: {
accept: { type: 'string', description: 'Accepted file types (comma-separated)' },
multiple: { type: 'boolean', description: 'Allow multiple file selections' },
maxSize: { type: 'number', description: 'Maximum file size in bytes' },
disabled: { type: 'boolean', description: 'Disable file uploads' },
onFilesSelected: { type: 'event', description: 'Fires when files are selected' }
}
},
{
type: 'Checkbox',
label: 'Checkbox',
@@ -224,19 +252,6 @@ export const componentDefinitions: ComponentDefinition[] = [
canHaveChildren: true,
defaultProps: { href: '#', children: 'Link' }
},
{
type: 'Breadcrumb',
label: 'Breadcrumb',
category: 'navigation',
icon: 'Path',
defaultProps: {
items: [
{ label: 'Home', href: '/' },
{ label: 'Section', href: '/section' },
{ label: 'Current Page' },
],
},
},
// Feedback Components
{
type: 'Alert',
@@ -269,13 +284,6 @@ export const componentDefinitions: ComponentDefinition[] = [
icon: 'Circle',
defaultProps: { status: 'active', children: 'Active' }
},
{
type: 'SaveIndicator',
label: 'Save Indicator',
category: 'feedback',
icon: 'FloppyDisk',
defaultProps: { status: 'saved', label: 'Saved' }
},
// Data Components
{
type: 'List',
@@ -305,46 +313,6 @@ export const componentDefinitions: ComponentDefinition[] = [
icon: 'ChartBar',
defaultProps: { title: 'Metric', value: '0' }
},
{
type: 'LazyBarChart',
label: 'Bar Chart',
category: 'data',
icon: 'ChartBar',
defaultProps: {
data: [
{ label: 'Jan', value: 30 },
{ label: 'Feb', value: 45 },
],
xKey: 'label',
yKey: 'value',
},
},
{
type: 'LazyLineChart',
label: 'Line Chart',
category: 'data',
icon: 'ChartLine',
defaultProps: {
data: [
{ label: 'Jan', value: 10 },
{ label: 'Feb', value: 25 },
],
xKey: 'label',
yKey: 'value',
},
},
{
type: 'LazyD3BarChart',
label: 'D3 Bar Chart',
category: 'data',
icon: 'ChartBar',
defaultProps: {
data: [
{ label: 'A', value: 12 },
{ label: 'B', value: 18 },
],
},
},
// Custom Components
{
type: 'DataCard',
@@ -368,23 +336,6 @@ export const componentDefinitions: ComponentDefinition[] = [
canHaveChildren: true,
defaultProps: { actions: [] }
},
{
type: 'SeedDataManager',
label: 'Seed Data Manager',
category: 'custom',
icon: 'Database',
defaultProps: { isLoaded: false, isLoading: false }
},
{
type: 'StorageSettings',
label: 'Storage Settings',
category: 'custom',
icon: 'Gear',
defaultProps: {
backend: 'indexeddb',
flaskUrl: 'http://localhost:5001',
},
},
]
export function getCategoryComponents(category: string): ComponentDefinition[] {

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

@@ -19,13 +19,6 @@ import { Progress } from '@/components/ui/progress'
import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import * as AtomComponents from '@/components/atoms'
import * as MoleculeComponents from '@/components/molecules'
import { Breadcrumb } from './wrappers/Breadcrumb'
import { SaveIndicator } from './wrappers/SaveIndicator'
import { LazyBarChart } from './wrappers/LazyBarChart'
import { LazyLineChart } from './wrappers/LazyLineChart'
import { LazyD3BarChart } from './wrappers/LazyD3BarChart'
import { SeedDataManager } from './wrappers/SeedDataManager'
import { StorageSettings } from './wrappers/StorageSettings'
import jsonComponentsRegistry from '../../../json-components-registry.json'
import {
ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass,
@@ -75,10 +68,6 @@ const moleculeRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'molecules')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.filter((name): name is string => Boolean(name))
const wrapperRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'json-ui-wrappers')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.filter((name): name is string => Boolean(name))
export const primitiveComponents: UIComponentRegistry = {
div: 'div' as any,
@@ -147,29 +136,20 @@ export const shadcnComponents: UIComponentRegistry = {
AvatarImage,
}
export const atomComponents: UIComponentRegistry = buildRegistryFromNames(
atomRegistryNames,
AtomComponents as Record<string, ComponentType<any>>
)
export const atomComponents: UIComponentRegistry = {
...buildRegistryFromNames(
atomRegistryNames,
AtomComponents as Record<string, ComponentType<any>>
),
DatePicker: AtomComponents.DatePicker,
FileUpload: AtomComponents.FileUpload,
}
export const moleculeComponents: UIComponentRegistry = buildRegistryFromNames(
moleculeRegistryNames,
MoleculeComponents as Record<string, ComponentType<any>>
)
export const wrapperComponents: UIComponentRegistry = buildRegistryFromNames(
wrapperRegistryNames,
{
Breadcrumb,
SaveIndicator,
LazyBarChart,
LazyLineChart,
LazyD3BarChart,
SeedDataManager,
StorageSettings,
} as Record<string, ComponentType<any>>
)
export const iconComponents: UIComponentRegistry = {
ArrowLeft,
ArrowRight,
@@ -216,7 +196,6 @@ export const uiComponentRegistry: UIComponentRegistry = {
...shadcnComponents,
...atomComponents,
...moleculeComponents,
...wrapperComponents,
...iconComponents,
}

View File

@@ -1,33 +1,101 @@
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'
function resolveBinding(binding: Binding, data: Record<string, unknown>): unknown {
return resolveDataBinding(binding, data)
function resolveBinding(
binding: Binding,
data: Record<string, unknown>,
context: Record<string, unknown>,
state?: Record<string, unknown>
): unknown {
return resolveDataBinding(binding, data, context, { state, bindings: context })
}
export function ComponentRenderer({ component, data, context = {}, onEvent }: ComponentRendererProps) {
export function ComponentRenderer({ component, data, context = {}, state, 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 }
if (component.bindings) {
Object.entries(component.bindings).forEach(([propName, binding]) => {
resolved[propName] = resolveBinding(binding, mergedData)
resolved[propName] = resolveBinding(binding, data, context, state)
})
}
if (component.dataBinding) {
const boundData = resolveDataBinding(component.dataBinding, mergedData)
const boundData = resolveDataBinding(component.dataBinding, data, context, { state, bindings: context })
if (boundData !== undefined) {
resolved.value = boundData
resolved.data = boundData
}
}
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'
@@ -51,7 +119,7 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
}
return resolved
}, [component, mergedData, onEvent])
}, [component, data, context, state, mergedData, onEvent])
const Component = getUIComponent(component.type)
@@ -78,6 +146,7 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
component={child}
data={data}
context={renderContext}
state={state}
onEvent={onEvent}
/>
)}
@@ -103,6 +172,7 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
component={child}
data={data}
context={renderContext}
state={state}
onEvent={onEvent}
/>
)}
@@ -114,6 +184,7 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
component={branch}
data={data}
context={renderContext}
state={state}
onEvent={onEvent}
/>
)
@@ -135,7 +206,7 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
}
if (component.loop) {
const items = resolveDataBinding(component.loop.source, mergedData) || []
const items = resolveDataBinding(component.loop.source, data, context, { state, bindings: context }) || []
const loopChildren = items.map((item: unknown, index: number) => {
const loopContext = {
...context,
@@ -153,7 +224,7 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
}
if (component.condition) {
const conditionValue = resolveBinding(component.condition, { ...data, ...loopContext })
const conditionValue = resolveBinding(component.condition, data, loopContext, state)
if (!conditionValue) {
return null
}
@@ -177,7 +248,7 @@ export function ComponentRenderer({ component, data, context = {}, onEvent }: Co
}
if (component.condition) {
const conditionValue = resolveBinding(component.condition, mergedData)
const conditionValue = resolveBinding(component.condition, data, context, state)
if (!conditionValue) {
return null
}

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react'
import { PageSchema } from '@/types/json-ui'
import { useDataSources } from '@/hooks/data/use-data-sources'
import { useActionExecutor } from '@/hooks/ui/use-action-executor'
import { useAppSelector } from '@/store'
import { ComponentRenderer } from './component-renderer'
interface PageRendererProps {
@@ -11,6 +12,7 @@ interface PageRendererProps {
export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
const { data, updateData, updatePath } = useDataSources(schema.dataSources)
const state = useAppSelector((rootState) => rootState)
const context = {
data,
@@ -32,6 +34,7 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
key={component.id || index}
component={component}
data={data}
state={state}
onEvent={handleEvent}
/>
))}

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) {
@@ -147,9 +168,6 @@ export function JSONUIRenderer({
if (component.bindings) {
Object.entries(component.bindings).forEach(([propName, binding]) => {
if (propName === 'children') {
return
}
props[propName] = resolveDataBinding(binding, dataMap, renderContext)
})
}
@@ -203,17 +221,14 @@ export function JSONUIRenderer({
const props = resolveProps(renderContext)
applyEventHandlers(props, renderContext)
const boundChildren = component.bindings?.children
? resolveDataBinding(component.bindings.children, dataMap, renderContext)
: component.children
if (typeof Component === 'string') {
return React.createElement(Component, props, renderChildren(boundChildren, renderContext))
return React.createElement(Component, props, renderChildren(component.children, renderContext))
}
return (
<Component {...props}>
{renderChildren(boundChildren, renderContext)}
{renderChildren(component.children, renderContext)}
</Component>
)
}
@@ -237,10 +252,7 @@ export function JSONUIRenderer({
...(component.loop!.indexVar ? { [component.loop!.indexVar]: index } : {}),
}
const loopChildrenBinding = component.bindings?.children
? resolveDataBinding(component.bindings.children, dataMap, loopContext)
: component.children
let content = renderChildren(loopChildrenBinding, loopContext)
let content = renderChildren(component.children, loopContext)
if (component.conditional) {
const conditionMet = evaluateCondition(component.conditional.if, { ...dataMap, ...loopContext })

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

@@ -1,23 +1,43 @@
type BindingTransform = string | ((data: unknown) => unknown)
interface BindingSourceOptions {
state?: Record<string, any>
bindings?: Record<string, any>
}
export function resolveDataBinding(
binding: string | { source: string; path?: string; transform?: BindingTransform },
binding: string | { source: string; sourceType?: 'data' | 'bindings' | 'state'; path?: string; transform?: BindingTransform },
dataMap: Record<string, any>,
context: Record<string, any> = {},
options: BindingSourceOptions = {},
): any {
const mergedContext = { ...dataMap, ...context }
const stateSource = options.state ?? {}
const bindingsSource = options.bindings ?? context
if (typeof binding === 'string') {
if (binding.startsWith('state.')) {
return getNestedValue(stateSource, binding.slice('state.'.length))
}
if (binding.startsWith('bindings.')) {
return getNestedValue(bindingsSource, binding.slice('bindings.'.length))
}
if (binding.includes('.')) {
return getNestedValue(mergedContext, binding)
}
return mergedContext[binding]
}
const { source, path, transform } = binding
const { source, sourceType, path, transform } = binding
const sourceContext =
sourceType === 'state'
? stateSource
: sourceType === 'bindings'
? bindingsSource
: mergedContext
const sourceValue = source.includes('.')
? getNestedValue(mergedContext, source)
: mergedContext[source]
? getNestedValue(sourceContext, source)
: sourceContext[source]
const resolvedValue = path ? getNestedValue(sourceValue, path) : sourceValue
return applyTransform(resolvedValue, transform)

View File

@@ -1,29 +0,0 @@
import { JSONUIRenderer } from '@/lib/json-ui'
import type { UIComponent } from '@/lib/json-ui/types'
import breadcrumbDefinition from './definitions/breadcrumb.json'
export interface BreadcrumbItem {
label: string
href?: string
onClick?: () => void
}
export interface BreadcrumbProps {
items: BreadcrumbItem[]
className?: string
}
const breadcrumbComponent = breadcrumbDefinition as UIComponent
export function Breadcrumb({ items, className }: BreadcrumbProps) {
if (!items?.length) {
return null
}
return (
<JSONUIRenderer
component={breadcrumbComponent}
dataMap={{ items, className }}
/>
)
}

View File

@@ -1,49 +0,0 @@
import {
Bar,
BarChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
export interface LazyBarChartProps {
data: Array<Record<string, any>>
xKey: string
yKey: string
width?: number | string
height?: number
color?: string
showLegend?: boolean
showGrid?: boolean
}
export function LazyBarChart({
data,
xKey,
yKey,
width = 600,
height = 300,
color = '#8884d8',
showLegend = true,
showGrid = true,
}: LazyBarChartProps) {
if (!data?.length) {
return null
}
return (
<ResponsiveContainer width={width} height={height}>
<BarChart data={data}>
{showGrid && <CartesianGrid strokeDasharray="3 3" />}
<XAxis dataKey={xKey} />
<YAxis />
<Tooltip />
{showLegend && <Legend />}
<Bar dataKey={yKey} fill={color} />
</BarChart>
</ResponsiveContainer>
)
}

View File

@@ -1,96 +0,0 @@
import { max, scaleBand, scaleLinear } from 'd3'
export interface LazyD3BarChartProps {
data: Array<{ label: string; value: number }>
width?: number
height?: number
color?: string
showAxes?: boolean
showGrid?: boolean
}
export function LazyD3BarChart({
data,
width = 600,
height = 300,
color = '#8884d8',
showAxes = true,
showGrid = true,
}: LazyD3BarChartProps) {
if (!data?.length) {
return null
}
const margin = { top: 20, right: 20, bottom: 30, left: 40 }
const chartWidth = Math.max(width - margin.left - margin.right, 0)
const chartHeight = Math.max(height - margin.top - margin.bottom, 0)
const maxValue = max(data, (d) => d.value) ?? 0
const xScale = scaleBand()
.domain(data.map((d) => d.label))
.range([0, chartWidth])
.padding(0.1)
const yScale = scaleLinear()
.domain([0, maxValue])
.nice()
.range([chartHeight, 0])
const yTicks = yScale.ticks(4)
return (
<svg width={width} height={height}>
<g transform={`translate(${margin.left},${margin.top})`}>
{showGrid &&
yTicks.map((tick) => (
<line
key={`grid-${tick}`}
x1={0}
x2={chartWidth}
y1={yScale(tick)}
y2={yScale(tick)}
stroke="currentColor"
opacity={0.1}
/>
))}
{data.map((entry) => (
<rect
key={entry.label}
x={xScale(entry.label) ?? 0}
y={yScale(entry.value)}
width={xScale.bandwidth()}
height={chartHeight - yScale(entry.value)}
fill={color}
/>
))}
{showAxes && (
<>
<line x1={0} x2={chartWidth} y1={chartHeight} y2={chartHeight} stroke="currentColor" />
<line x1={0} x2={0} y1={0} y2={chartHeight} stroke="currentColor" />
{yTicks.map((tick) => (
<g key={`tick-${tick}`} transform={`translate(0,${yScale(tick)})`}>
<line x1={-4} x2={0} y1={0} y2={0} stroke="currentColor" />
<text x={-8} y={4} textAnchor="end" className="text-[10px] fill-muted-foreground">
{tick}
</text>
</g>
))}
{data.map((entry) => (
<text
key={`label-${entry.label}`}
x={(xScale(entry.label) ?? 0) + xScale.bandwidth() / 2}
y={chartHeight + 16}
textAnchor="middle"
className="text-[10px] fill-muted-foreground"
>
{entry.label}
</text>
))}
</>
)}
</g>
</svg>
)
}

View File

@@ -1,49 +0,0 @@
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
export interface LazyLineChartProps {
data: Array<Record<string, any>>
xKey: string
yKey: string
width?: number | string
height?: number
color?: string
showLegend?: boolean
showGrid?: boolean
}
export function LazyLineChart({
data,
xKey,
yKey,
width = 600,
height = 300,
color = '#8884d8',
showLegend = true,
showGrid = true,
}: LazyLineChartProps) {
if (!data?.length) {
return null
}
return (
<ResponsiveContainer width={width} height={height}>
<LineChart data={data}>
{showGrid && <CartesianGrid strokeDasharray="3 3" />}
<XAxis dataKey={xKey} />
<YAxis />
<Tooltip />
{showLegend && <Legend />}
<Line type="monotone" dataKey={yKey} stroke={color} />
</LineChart>
</ResponsiveContainer>
)
}

View File

@@ -1,41 +0,0 @@
import { JSONUIRenderer } from '@/lib/json-ui'
import type { UIComponent } from '@/lib/json-ui/types'
import saveIndicatorDefinition from './definitions/save-indicator.json'
export interface SaveIndicatorProps {
status?: 'saved' | 'synced'
label?: string
showLabel?: boolean
animate?: boolean
className?: string
}
const saveIndicatorComponent = saveIndicatorDefinition as UIComponent
export function SaveIndicator({
status = 'saved',
label,
showLabel = true,
animate,
className,
}: SaveIndicatorProps) {
if (!status) {
return null
}
const resolvedLabel = label ?? (status === 'saved' ? 'Saved' : 'Synced')
const shouldAnimate = animate ?? status === 'saved'
return (
<JSONUIRenderer
component={saveIndicatorComponent}
dataMap={{
status,
label: resolvedLabel,
showLabel,
animate: shouldAnimate,
className,
}}
/>
)
}

View File

@@ -1,108 +0,0 @@
import { JSONUIRenderer } from '@/lib/json-ui'
import type { UIComponent } from '@/lib/json-ui/types'
import seedDataManagerDefinition from './definitions/seed-data-manager.json'
interface SeedDataManagerCopy {
title: string
description: string
statusLoaded: string
buttons: {
load: string
reset: string
clear: string
loadingLoad: string
loadingReset: string
loadingClear: string
}
help: {
load: string
reset: string
clear: string
}
}
const defaultCopy: SeedDataManagerCopy = {
title: 'Seed Data Management',
description: 'Load, reset, or clear application seed data from the database',
statusLoaded: 'Seed data is loaded and available',
buttons: {
load: 'Load Seed Data',
reset: 'Reset to Defaults',
clear: 'Clear All Data',
loadingLoad: 'Loading...',
loadingReset: 'Resetting...',
loadingClear: 'Clearing...',
},
help: {
load: 'Populates database with initial data if not already loaded',
reset: 'Overwrites all data with fresh seed data',
clear: 'Removes all data from the database (destructive action)',
},
}
export interface SeedDataManagerProps {
isLoaded?: boolean
isLoading?: boolean
onLoadSeedData?: () => void
onResetSeedData?: () => void
onClearAllData?: () => void
copy?: Partial<SeedDataManagerCopy>
}
const seedDataManagerComponent = seedDataManagerDefinition as UIComponent
export function SeedDataManager({
isLoaded = false,
isLoading = false,
onLoadSeedData,
onResetSeedData,
onClearAllData,
copy,
}: SeedDataManagerProps) {
const resolvedCopy: SeedDataManagerCopy = {
...defaultCopy,
...copy,
buttons: {
...defaultCopy.buttons,
...copy?.buttons,
},
help: {
...defaultCopy.help,
...copy?.help,
},
}
const loadDisabled = !onLoadSeedData || isLoading || isLoaded
const resetDisabled = !onResetSeedData || isLoading
const clearDisabled = !onClearAllData || isLoading
return (
<JSONUIRenderer
component={seedDataManagerComponent}
dataMap={{
title: resolvedCopy.title,
description: resolvedCopy.description,
statusLoaded: resolvedCopy.statusLoaded,
onLoadSeedData,
onResetSeedData,
onClearAllData,
isLoaded,
loadDisabled,
resetDisabled,
clearDisabled,
loadButtonText: isLoading
? resolvedCopy.buttons.loadingLoad
: resolvedCopy.buttons.load,
resetButtonText: isLoading
? resolvedCopy.buttons.loadingReset
: resolvedCopy.buttons.reset,
clearButtonText: isLoading
? resolvedCopy.buttons.loadingClear
: resolvedCopy.buttons.clear,
helpLoad: `Load Seed Data: ${resolvedCopy.help.load}`,
helpReset: `Reset to Defaults: ${resolvedCopy.help.reset}`,
helpClear: `Clear All Data: ${resolvedCopy.help.clear}`,
}}
/>
)
}

View File

@@ -1,117 +0,0 @@
import type { ChangeEvent } from 'react'
import { JSONUIRenderer } from '@/lib/json-ui'
import type { UIComponent } from '@/lib/json-ui/types'
import {
storageSettingsCopy,
getBackendCopy,
type StorageBackendKey,
} from '@/components/storage/storageSettingsConfig'
import storageSettingsDefinition from './definitions/storage-settings.json'
const defaultCopy = storageSettingsCopy.molecule
type StorageSettingsCopy = typeof defaultCopy
export interface StorageSettingsProps {
backend: StorageBackendKey | null
isLoading?: boolean
flaskUrl?: string
isSwitching?: boolean
onFlaskUrlChange?: (value: string) => void
onSwitchToFlask?: () => void
onSwitchToIndexedDB?: () => void
onSwitchToSQLite?: () => void
isExporting?: boolean
isImporting?: boolean
onExport?: () => void
onImport?: () => void
copy?: Partial<StorageSettingsCopy>
}
const storageSettingsComponent = storageSettingsDefinition as UIComponent
export function StorageSettings({
backend,
isLoading = false,
flaskUrl = defaultCopy.flaskUrlPlaceholder,
isSwitching = false,
onFlaskUrlChange,
onSwitchToFlask,
onSwitchToIndexedDB,
onSwitchToSQLite,
isExporting = false,
isImporting = false,
onExport,
onImport,
copy,
}: StorageSettingsProps) {
const resolvedCopy: StorageSettingsCopy = {
...defaultCopy,
...copy,
buttons: {
...defaultCopy.buttons,
...copy?.buttons,
},
backendDetails: {
...defaultCopy.backendDetails,
...copy?.backendDetails,
},
}
const backendCopy = getBackendCopy(backend)
const handleFlaskUrlChange = onFlaskUrlChange
? (event: ChangeEvent<HTMLInputElement>) => onFlaskUrlChange(event.target.value)
: undefined
return (
<JSONUIRenderer
component={storageSettingsComponent}
dataMap={{
title: resolvedCopy.title,
description: resolvedCopy.description,
currentBackendLabel: resolvedCopy.currentBackendLabel,
backendBadge: backendCopy.moleculeLabel,
flaskUrlLabel: resolvedCopy.flaskUrlLabel,
flaskUrlPlaceholder: resolvedCopy.flaskUrlPlaceholder,
flaskHelp: resolvedCopy.flaskHelp,
flaskDetails: resolvedCopy.backendDetails.flask,
indexedDbDetails: resolvedCopy.backendDetails.indexeddb,
sqliteDetails: resolvedCopy.backendDetails.sqlite,
flaskUrl,
onFlaskUrlChange: handleFlaskUrlChange,
flaskUrlDisabled: isSwitching || isLoading,
onSwitchToFlask,
flaskButtonDisabled: !onSwitchToFlask || isSwitching || isLoading || backend === 'flask',
flaskButtonVariant: backend === 'flask' ? 'secondary' : 'default',
flaskButtonLabel:
backend === 'flask'
? resolvedCopy.buttons.flaskActive
: resolvedCopy.buttons.flaskUse,
onSwitchToIndexedDB,
indexedDbDisabled: !onSwitchToIndexedDB || isSwitching || isLoading || backend === 'indexeddb',
indexedDbVariant: backend === 'indexeddb' ? 'secondary' : 'outline',
indexedDbLabel:
backend === 'indexeddb'
? resolvedCopy.buttons.indexeddbActive
: resolvedCopy.buttons.indexeddbUse,
onSwitchToSQLite,
sqliteDisabled: !onSwitchToSQLite || isSwitching || isLoading || backend === 'sqlite',
sqliteVariant: backend === 'sqlite' ? 'secondary' : 'outline',
sqliteLabel:
backend === 'sqlite'
? resolvedCopy.buttons.sqliteActive
: resolvedCopy.buttons.sqliteUse,
dataTitle: resolvedCopy.dataTitle,
dataDescription: resolvedCopy.dataDescription,
dataHelp: resolvedCopy.dataHelp,
onExport,
exportDisabled: !onExport || isExporting,
exportLabel: resolvedCopy.buttons.export,
onImport,
importDisabled: !onImport || isImporting,
importLabel: resolvedCopy.buttons.import,
}}
/>
)
}

View File

@@ -1,106 +0,0 @@
{
"id": "breadcrumb-root",
"type": "nav",
"className": "overflow-x-auto",
"props": {
"aria-label": "Breadcrumb"
},
"bindings": {
"className": {
"source": "className"
}
},
"children": [
{
"id": "breadcrumb-row",
"type": "div",
"className": "flex items-center gap-2 text-sm",
"loop": {
"source": "items",
"itemVar": "item",
"indexVar": "itemIndex"
},
"children": [
{
"id": "breadcrumb-link-last",
"type": "Link",
"conditional": {
"if": "(item.href || item.onClick) && itemIndex === items.length - 1"
},
"props": {
"variant": "default",
"className": "text-foreground font-medium"
},
"bindings": {
"href": {
"source": "item.href"
},
"onClick": {
"source": "item.onClick"
},
"children": {
"source": "item.label"
}
}
},
{
"id": "breadcrumb-link",
"type": "Link",
"conditional": {
"if": "(item.href || item.onClick) && itemIndex < items.length - 1"
},
"props": {
"variant": "muted",
"className": "text-muted-foreground hover:text-foreground"
},
"bindings": {
"href": {
"source": "item.href"
},
"onClick": {
"source": "item.onClick"
},
"children": {
"source": "item.label"
}
}
},
{
"id": "breadcrumb-text-last",
"type": "span",
"conditional": {
"if": "!item.href && !item.onClick && itemIndex === items.length - 1"
},
"className": "text-foreground font-medium",
"bindings": {
"children": {
"source": "item.label"
}
}
},
{
"id": "breadcrumb-text",
"type": "span",
"conditional": {
"if": "!item.href && !item.onClick && itemIndex < items.length - 1"
},
"className": "text-muted-foreground",
"bindings": {
"children": {
"source": "item.label"
}
}
},
{
"id": "breadcrumb-separator",
"type": "span",
"conditional": {
"if": "itemIndex < items.length - 1"
},
"className": "text-muted-foreground",
"children": "/"
}
]
}
]
}

View File

@@ -1,36 +0,0 @@
{
"id": "save-indicator-root",
"type": "div",
"className": "flex items-center gap-1.5 text-xs text-muted-foreground",
"bindings": {
"className": {
"source": "className"
}
},
"children": [
{
"id": "save-indicator-icon",
"type": "StatusIcon",
"bindings": {
"type": {
"source": "status"
},
"animate": {
"source": "animate"
}
}
},
{
"id": "save-indicator-label",
"type": "span",
"conditional": {
"if": "showLabel"
},
"bindings": {
"children": {
"source": "label"
}
}
}
]
}

View File

@@ -1,157 +0,0 @@
{
"id": "seed-data-manager",
"type": "Card",
"children": [
{
"id": "seed-data-header",
"type": "CardHeader",
"children": [
{
"id": "seed-data-title",
"type": "CardTitle",
"bindings": {
"children": {
"source": "title"
}
}
},
{
"id": "seed-data-description",
"type": "CardDescription",
"bindings": {
"children": {
"source": "description"
}
}
}
]
},
{
"id": "seed-data-content",
"type": "CardContent",
"className": "flex flex-col gap-4",
"children": [
{
"id": "seed-data-alert",
"type": "Alert",
"conditional": {
"if": "isLoaded"
},
"children": [
{
"id": "seed-data-alert-description",
"type": "AlertDescription",
"bindings": {
"children": {
"source": "statusLoaded"
}
}
}
]
},
{
"id": "seed-data-actions-block",
"type": "div",
"className": "flex flex-col gap-3",
"children": [
{
"id": "seed-data-actions",
"type": "div",
"className": "flex gap-2 flex-wrap",
"children": [
{
"id": "seed-data-load",
"type": "Button",
"props": {
"variant": "default"
},
"bindings": {
"onClick": {
"source": "onLoadSeedData"
},
"disabled": {
"source": "loadDisabled"
},
"children": {
"source": "loadButtonText"
}
}
},
{
"id": "seed-data-reset",
"type": "Button",
"props": {
"variant": "outline"
},
"bindings": {
"onClick": {
"source": "onResetSeedData"
},
"disabled": {
"source": "resetDisabled"
},
"children": {
"source": "resetButtonText"
}
}
},
{
"id": "seed-data-clear",
"type": "Button",
"props": {
"variant": "destructive"
},
"bindings": {
"onClick": {
"source": "onClearAllData"
},
"disabled": {
"source": "clearDisabled"
},
"children": {
"source": "clearButtonText"
}
}
}
]
},
{
"id": "seed-data-help",
"type": "div",
"className": "text-sm text-muted-foreground space-y-1",
"children": [
{
"id": "seed-data-help-load",
"type": "p",
"bindings": {
"children": {
"source": "helpLoad"
}
}
},
{
"id": "seed-data-help-reset",
"type": "p",
"bindings": {
"children": {
"source": "helpReset"
}
}
},
{
"id": "seed-data-help-clear",
"type": "p",
"bindings": {
"children": {
"source": "helpClear"
}
}
}
]
}
]
}
]
}
]
}

View File

@@ -1,331 +0,0 @@
{
"id": "storage-settings-root",
"type": "div",
"className": "space-y-6",
"children": [
{
"id": "storage-backend-card",
"type": "Card",
"children": [
{
"id": "storage-backend-header",
"type": "CardHeader",
"children": [
{
"id": "storage-backend-title",
"type": "CardTitle",
"bindings": {
"children": {
"source": "title"
}
}
},
{
"id": "storage-backend-description",
"type": "CardDescription",
"bindings": {
"children": {
"source": "description"
}
}
}
]
},
{
"id": "storage-backend-content",
"type": "CardContent",
"className": "space-y-4",
"children": [
{
"id": "storage-backend-current",
"type": "div",
"className": "flex items-center gap-2",
"children": [
{
"id": "storage-backend-current-label",
"type": "span",
"className": "text-sm text-muted-foreground",
"bindings": {
"children": {
"source": "currentBackendLabel"
}
}
},
{
"id": "storage-backend-current-badge",
"type": "Badge",
"props": {
"variant": "secondary",
"className": "flex items-center gap-1"
},
"bindings": {
"children": {
"source": "backendBadge"
}
}
}
]
},
{
"id": "storage-backend-form",
"type": "div",
"className": "grid gap-4",
"children": [
{
"id": "storage-backend-flask",
"type": "div",
"className": "space-y-2",
"children": [
{
"id": "storage-backend-flask-label",
"type": "Label",
"props": {
"htmlFor": "flask-url"
},
"bindings": {
"children": {
"source": "flaskUrlLabel"
}
}
},
{
"id": "storage-backend-flask-row",
"type": "div",
"className": "flex gap-2",
"children": [
{
"id": "storage-backend-flask-input",
"type": "Input",
"bindings": {
"value": {
"source": "flaskUrl"
},
"onChange": {
"source": "onFlaskUrlChange"
},
"disabled": {
"source": "flaskUrlDisabled"
},
"placeholder": {
"source": "flaskUrlPlaceholder"
}
},
"props": {
"id": "flask-url"
}
},
{
"id": "storage-backend-flask-button",
"type": "Button",
"bindings": {
"onClick": {
"source": "onSwitchToFlask"
},
"disabled": {
"source": "flaskButtonDisabled"
},
"variant": {
"source": "flaskButtonVariant"
},
"children": {
"source": "flaskButtonLabel"
}
}
}
]
},
{
"id": "storage-backend-flask-help",
"type": "p",
"className": "text-xs text-muted-foreground",
"bindings": {
"children": {
"source": "flaskHelp"
}
}
}
]
},
{
"id": "storage-backend-options",
"type": "div",
"className": "flex gap-2",
"children": [
{
"id": "storage-backend-indexeddb",
"type": "Button",
"props": {
"className": "flex-1"
},
"bindings": {
"onClick": {
"source": "onSwitchToIndexedDB"
},
"disabled": {
"source": "indexedDbDisabled"
},
"variant": {
"source": "indexedDbVariant"
},
"children": {
"source": "indexedDbLabel"
}
}
},
{
"id": "storage-backend-sqlite",
"type": "Button",
"props": {
"className": "flex-1"
},
"bindings": {
"onClick": {
"source": "onSwitchToSQLite"
},
"disabled": {
"source": "sqliteDisabled"
},
"variant": {
"source": "sqliteVariant"
},
"children": {
"source": "sqliteLabel"
}
}
}
]
},
{
"id": "storage-backend-details",
"type": "div",
"className": "text-xs text-muted-foreground space-y-1",
"children": [
{
"id": "storage-backend-details-indexeddb",
"type": "p",
"bindings": {
"children": {
"source": "indexedDbDetails"
}
}
},
{
"id": "storage-backend-details-sqlite",
"type": "p",
"bindings": {
"children": {
"source": "sqliteDetails"
}
}
},
{
"id": "storage-backend-details-flask",
"type": "p",
"bindings": {
"children": {
"source": "flaskDetails"
}
}
}
]
}
]
}
]
}
]
},
{
"id": "storage-data-card",
"type": "Card",
"children": [
{
"id": "storage-data-header",
"type": "CardHeader",
"children": [
{
"id": "storage-data-title",
"type": "CardTitle",
"bindings": {
"children": {
"source": "dataTitle"
}
}
},
{
"id": "storage-data-description",
"type": "CardDescription",
"bindings": {
"children": {
"source": "dataDescription"
}
}
}
]
},
{
"id": "storage-data-content",
"type": "CardContent",
"className": "space-y-4",
"children": [
{
"id": "storage-data-actions",
"type": "div",
"className": "flex gap-2",
"children": [
{
"id": "storage-data-export",
"type": "Button",
"props": {
"variant": "outline",
"className": "flex-1"
},
"bindings": {
"onClick": {
"source": "onExport"
},
"disabled": {
"source": "exportDisabled"
},
"children": {
"source": "exportLabel"
}
}
},
{
"id": "storage-data-import",
"type": "Button",
"props": {
"variant": "outline",
"className": "flex-1"
},
"bindings": {
"onClick": {
"source": "onImport"
},
"disabled": {
"source": "importDisabled"
},
"children": {
"source": "importLabel"
}
}
}
]
},
{
"id": "storage-data-help",
"type": "p",
"className": "text-xs text-muted-foreground",
"bindings": {
"children": {
"source": "dataHelp"
}
}
}
]
}
]
}
]
}

219
src/schemas/page-schemas.ts Normal file
View File

@@ -0,0 +1,219 @@
import type { PageSchema } from '@/types/json-ui'
export const stateBindingsDemoSchema: PageSchema = {
id: 'state-bindings-demo',
name: 'State & Bindings Demo',
layout: {
type: 'single',
},
dataSources: [
{
id: 'statusItems',
type: 'static',
defaultValue: ['KV Ready', 'Components Loaded', 'Sync Enabled'],
},
],
components: [
{
id: 'state-demo-root',
type: 'div',
props: {
className: 'space-y-4 rounded-lg border border-border bg-card p-6',
},
children: [
{
id: 'state-demo-title',
type: 'Heading',
props: {
className: 'text-xl font-semibold',
children: 'Renderer State Binding Demo',
},
},
{
id: 'state-demo-theme',
type: 'Text',
props: {
className: 'text-sm text-muted-foreground',
},
bindings: {
children: {
sourceType: 'state',
source: 'settings',
path: 'settings.theme',
},
},
},
{
id: 'state-demo-list',
type: 'div',
props: {
className: 'space-y-2',
},
loop: {
source: 'statusItems',
itemVar: 'statusItem',
},
children: [
{
id: 'state-demo-list-item',
type: 'Text',
props: {
className: 'text-sm',
},
bindings: {
children: {
sourceType: 'bindings',
source: 'statusItem',
},
},
},
],
},
],
},
],
}
export const inputComponentsShowcaseSchema: PageSchema = {
id: 'input-components-showcase',
name: 'Input Components Showcase',
layout: {
type: 'single',
},
dataSources: [
{
id: 'selectedDate',
type: 'static',
defaultValue: new Date(),
},
{
id: 'uploadedFiles',
type: 'static',
defaultValue: [],
},
],
components: [
{
id: 'input-showcase-root',
type: 'div',
props: {
className: 'space-y-6 rounded-lg border border-border bg-card p-6',
},
children: [
{
id: 'input-showcase-title',
type: 'Heading',
props: {
className: 'text-xl font-semibold',
children: 'Date Picker & File Upload',
},
},
{
id: 'input-showcase-date-section',
type: 'div',
props: {
className: 'space-y-3',
},
children: [
{
id: 'input-showcase-date-label',
type: 'Text',
props: {
className: 'text-sm font-medium text-muted-foreground',
children: 'Pick a date',
},
},
{
id: 'input-showcase-date-picker',
type: 'DatePicker',
props: {
placeholder: 'Select a date',
},
bindings: {
value: {
source: 'selectedDate',
},
},
events: {
onChange: {
actions: [
{
id: 'update-selected-date',
type: 'set-value',
target: 'selectedDate',
expression: 'event',
},
],
},
},
},
{
id: 'input-showcase-date-value',
type: 'Text',
props: {
className: 'text-sm text-muted-foreground',
},
bindings: {
children: {
source: 'selectedDate',
transform: 'data ? `Selected: ${new Date(data).toLocaleDateString()}` : "Selected: none"',
},
},
},
],
},
{
id: 'input-showcase-file-section',
type: 'div',
props: {
className: 'space-y-3',
},
children: [
{
id: 'input-showcase-file-label',
type: 'Text',
props: {
className: 'text-sm font-medium text-muted-foreground',
children: 'Upload files',
},
},
{
id: 'input-showcase-file-upload',
type: 'FileUpload',
props: {
accept: '.pdf,.png,.jpg,.jpeg',
multiple: true,
maxSize: 5000000,
},
events: {
onFilesSelected: {
actions: [
{
id: 'update-uploaded-files',
type: 'set-value',
target: 'uploadedFiles',
expression: 'event',
},
],
},
},
},
{
id: 'input-showcase-file-value',
type: 'Text',
props: {
className: 'text-sm text-muted-foreground',
},
bindings: {
children: {
source: 'uploadedFiles',
transform: 'Array.isArray(data) && data.length ? `Selected: ${data.map((file) => file.name).join(", ")}` : "Selected: none"',
},
},
},
],
},
],
},
],
}

View File

@@ -2,13 +2,13 @@ export type ComponentType =
| 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'
| 'Button' | 'Card' | 'CardHeader' | 'CardTitle' | 'CardDescription' | 'CardContent' | 'CardFooter'
| 'Input' | 'TextArea' | 'Textarea' | 'Select' | 'Checkbox' | 'Radio' | 'Switch' | 'Slider' | 'NumberInput'
| 'DatePicker' | 'FileUpload'
| 'Badge' | 'Progress' | 'Separator' | 'Tabs' | 'TabsContent' | 'TabsList' | 'TabsTrigger' | 'Dialog'
| 'Text' | 'Heading' | 'Label' | 'List' | 'Grid' | 'Stack' | 'Flex' | 'Container'
| 'Link' | 'Image' | 'Avatar' | 'Code' | 'Tag' | 'Spinner' | 'Skeleton'
| 'Alert' | 'InfoBox' | 'EmptyState' | 'StatusBadge'
| 'Table' | 'KeyValue' | 'StatCard' | 'DataCard' | 'SearchInput' | 'ActionBar'
| 'AppBranding' | 'LabelWithBadge' | 'EmptyEditorState' | 'LoadingFallback' | 'LoadingState' | 'NavigationGroupHeader'
| 'Breadcrumb' | 'SaveIndicator' | 'LazyBarChart' | 'LazyD3BarChart' | 'LazyLineChart' | 'SeedDataManager' | 'StorageSettings'
export type ActionType =
| 'create' | 'update' | 'delete' | 'navigate'
@@ -19,6 +19,9 @@ export type ActionType =
export type DataSourceType =
| 'kv' | 'computed' | 'static'
export type BindingSourceType =
| 'data' | 'bindings' | 'state'
export interface DataSource {
id: string
type: DataSourceType
@@ -47,6 +50,7 @@ export interface Action {
export interface Binding {
source: string
sourceType?: BindingSourceType
path?: string
transform?: string | ((value: any) => any)
}
@@ -57,6 +61,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
@@ -77,7 +90,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
@@ -116,6 +129,7 @@ export interface ComponentRendererProps {
component: UIComponent
data: Record<string, unknown>
context?: Record<string, unknown>
state?: Record<string, unknown>
onEvent?: (componentId: string, handler: EventHandler, eventData: unknown) => void
}