Compare commits

..

50 Commits

Author SHA1 Message Date
5a989b38db Import JSON UI wrappers directly 2026-01-18 12:03:05 +00:00
9675e1f071 Merge pull request #109 from johndoe6345789/codex/fix-json-ui-framework-rendering-issues
Stabilize JSON UI: bindings, conditionals, loops, and types
2026-01-18 11:07:44 +00:00
813c36b953 Stabilize JSON UI bindings and loop rendering 2026-01-18 11:07:32 +00:00
377a95da80 Merge pull request #108 from johndoe6345789/codex/align-event/action-shape-in-renderer
Align JSON UI event handling with action arrays
2026-01-18 02:38:12 +00:00
b232c220df Merge branch 'main' into codex/align-event/action-shape-in-renderer 2026-01-18 02:38:04 +00:00
01921d4b80 Align json-ui event action handlers 2026-01-18 02:36:16 +00:00
19a26156ad Merge pull request #107 from johndoe6345789/codex/update-loop-rendering-in-renderer.tsx
Render loop container elements per item with merged props
2026-01-18 02:35:43 +00:00
5652d0222d Render loop containers with props 2026-01-18 02:35:04 +00:00
e2f128a947 Merge pull request #106 from johndoe6345789/codex/expand-conditional-rendering-in-renderer.tsx
Expand conditional rendering branches for JSON UI renderer
2026-01-18 02:34:36 +00:00
717871c673 Expand conditional rendering branches 2026-01-18 02:34:10 +00:00
53a40aa808 Merge pull request #105 from johndoe6345789/codex/refactor-data-binding-resolution-in-componentrenderer
Enhance binding resolution in json-ui component renderer
2026-01-18 02:33:00 +00:00
f7d7b9f478 Merge branch 'main' into codex/refactor-data-binding-resolution-in-componentrenderer 2026-01-18 02:32:30 +00:00
17ff0eaaea Enhance binding resolution in component renderer 2026-01-18 02:31:49 +00:00
17c91ee91b Merge pull request #104 from johndoe6345789/codex/align-binding.transform-with-string-expression
Align JSON UI binding transforms
2026-01-18 02:31:31 +00:00
94018c0e3c Align JSON UI binding transforms 2026-01-18 02:31:19 +00:00
24b0498aa2 Merge pull request #103 from johndoe6345789/codex/fix-json-rendering-library-issues
Add roadmap tasks for molecule/organism refactors and JSON UI fixes
2026-01-18 02:14:24 +00:00
c6dc49488d Add roadmap tasks for JSON UI refactors 2026-01-18 02:14:12 +00:00
88e028b54e Merge pull request #102 from johndoe6345789/codex/update-conditional-component-handling
Render `conditional.then` when condition is true in JSONUIRenderer
2026-01-18 02:06:27 +00:00
f19df2822d Adjust conditional rendering for JSON UI 2026-01-18 02:06:17 +00:00
535f712383 Merge pull request #101 from johndoe6345789/codex/update-loop-handling-in-renderer
Fix loop rendering to use children with loop context
2026-01-18 02:05:54 +00:00
1bb40e399b Fix loop rendering to use children context 2026-01-18 02:05:42 +00:00
63db390cf7 Merge pull request #100 from johndoe6345789/codex/update-resolvedatabinding-for-context-support
Resolve data bindings with context, nested paths, and transforms
2026-01-18 02:05:20 +00:00
55c81cedfa Update data binding resolution context 2026-01-18 02:05:10 +00:00
3fac67c907 Merge pull request #99 from johndoe6345789/codex/consolidate-.tsx-and-.ts-json-ui-registries
Unify orchestration component registry with JSON UI
2026-01-18 01:55:05 +00:00
1be31c49f9 Unify orchestration component registry 2026-01-18 01:54:55 +00:00
cc0cab04dc Merge pull request #98 from johndoe6345789/codex/extend-usecomponentregistry-for-ui-primitives
Align JSON UI registry with canonical atom/molecule sources
2026-01-18 01:54:03 +00:00
8864436425 Align JSON UI registry with canonical components 2026-01-18 01:53:48 +00:00
4a07737c6a Merge pull request #97 from johndoe6345789/codex/extend-usecomponentregistry-for-json-ui-icons
Unify icon registry access for schema renderer
2026-01-18 01:52:07 +00:00
463201d758 Unify icon registry access 2026-01-18 01:51:56 +00:00
7cd15ca7ba Merge pull request #96 from johndoe6345789/codex/merge-json-ui-registry-into-component-registry
Merge JSON UI registry into component hook and add registry overrides to SchemaRenderer
2026-01-18 01:43:41 +00:00
560a75cd69 Expand schema component registry 2026-01-18 01:43:31 +00:00
17f4d567c9 Merge pull request #95 from johndoe6345789/codex/merge-atom/molecule-entries-into-component-registry
Consolidate JSON UI component registry and sync ComponentType union
2026-01-18 01:42:52 +00:00
320e8a4c2c Consolidate JSON UI component registry 2026-01-18 01:42:41 +00:00
1065d62d65 Merge pull request #94 from johndoe6345789/codex/update-exports-and-documentation-for-json-ui-hooks
Re-export JSON UI hooks from public entrypoint and update docs
2026-01-18 01:41:55 +00:00
671a99d30b Re-export JSON UI hooks 2026-01-18 01:41:27 +00:00
e033b032a1 Merge pull request #93 from johndoe6345789/codex/extract-logic-into-custom-hooks
Extract data source & component binding dialog state into hooks
2026-01-18 01:27:36 +00:00
81c6ada8c1 Merge branch 'main' into codex/extract-logic-into-custom-hooks 2026-01-18 01:27:30 +00:00
9a28805791 Extract dialog state hooks 2026-01-18 01:26:34 +00:00
f3cf2883d3 Merge pull request #92 from johndoe6345789/codex/create-shared-types-module-for-global-search
Add shared global search types module
2026-01-18 01:26:18 +00:00
925adc9712 Add shared global search types 2026-01-18 01:26:08 +00:00
5d5968d87b Merge pull request #91 from johndoe6345789/codex/add-shared-types-for-nextjs-components
Add shared Next.js config props type
2026-01-18 01:25:48 +00:00
9cd0ed818a Add shared Next.js config props type 2026-01-18 01:25:37 +00:00
68f0dcbfbd Merge pull request #90 from johndoe6345789/codex/add-use-component-binding-dialog-hook
Add useComponentBindingDialog hook and refactor ComponentBindingDialog to use it
2026-01-18 01:25:16 +00:00
6700705842 Add component binding dialog hook 2026-01-18 01:25:04 +00:00
530d7d96da Merge pull request #89 from johndoe6345789/codex/create-use-data-source-editor-hook
Add useDataSourceEditor hook and refactor DataSourceEditorDialog to use it
2026-01-18 01:24:46 +00:00
4ee346c69d Add data source editor hook 2026-01-18 01:24:36 +00:00
902253a9be Merge pull request #87 from johndoe6345789/codex/create-hook-for-template-explorer-actions
Add useTemplateExplorerActions hook and refactor TemplateExplorer
2026-01-18 01:22:55 +00:00
1e0cdf034c Add template explorer actions hook 2026-01-18 01:22:45 +00:00
5c64833a3f Merge pull request #86 from johndoe6345789/codex/add-expansion-hook-for-componenttree
Add component tree expansion hook
2026-01-18 01:22:27 +00:00
104f9461bc Add component tree expansion hook 2026-01-18 01:22:18 +00:00
55 changed files with 2640 additions and 788 deletions

View File

@@ -46,7 +46,7 @@ Replaced all imports of `@github/spark/hooks` with the local `@/hooks/use-kv` im
### Config Files ### Config Files
- `src/config/orchestration/data-source-manager.ts` - `src/config/orchestration/data-source-manager.ts`
- `src/lib/json-ui/hooks.ts` - `src/lib/json-ui/hooks.ts` (re-exported via `@/lib/json-ui`)
## Change Pattern ## Change Pattern
All instances of: All instances of:

View File

@@ -27,7 +27,7 @@ Successfully implemented a comprehensive JSON-driven UI system that allows build
- Array looping for lists - Array looping for lists
- Form rendering with validation - Form rendering with validation
- **hooks.ts**: React hooks for data management - **hooks.ts**: React hooks for data management (import from `@/lib/json-ui`)
- `useJSONDataSource`: Single data source management (KV, API, static, computed) - `useJSONDataSource`: Single data source management (KV, API, static, computed)
- `useJSONDataSources`: Multiple data sources orchestration - `useJSONDataSources`: Multiple data sources orchestration
- `useJSONActions`: Action registration and execution - `useJSONActions`: Action registration and execution

View File

@@ -275,6 +275,12 @@ registerComponent('MyCustom', MyCustomComponent)
### Add Custom Data Source Types ### Add Custom Data Source Types
Use the public entrypoint when consuming JSON UI hooks:
```typescript
import { useJSONDataSource, useJSONDataSources, useJSONActions } from '@/lib/json-ui'
```
Edit `/src/lib/json-ui/hooks.ts` to add new data source handlers. Edit `/src/lib/json-ui/hooks.ts` to add new data source handlers.
### Add Custom Actions ### Add Custom Actions
@@ -296,7 +302,7 @@ const handleAction = (handler: EventHandler, event?: any) => {
- **Schema Definitions**: `/src/lib/json-ui/schema.ts` - **Schema Definitions**: `/src/lib/json-ui/schema.ts`
- **Component Registry**: `/src/lib/json-ui/component-registry.ts` - **Component Registry**: `/src/lib/json-ui/component-registry.ts`
- **Renderer**: `/src/lib/json-ui/renderer.tsx` - **Renderer**: `/src/lib/json-ui/renderer.tsx`
- **Hooks**: `/src/lib/json-ui/hooks.ts` - **Hooks**: Import from `@/lib/json-ui` (source: `/src/lib/json-ui/hooks.ts`)
- **Utils**: `/src/lib/json-ui/utils.ts` - **Utils**: `/src/lib/json-ui/utils.ts`
- **Examples**: `/src/config/ui-examples/` - **Examples**: `/src/config/ui-examples/`
- **Demo Page**: `/src/components/JSONUIShowcase.tsx` - **Demo Page**: `/src/components/JSONUIShowcase.tsx`

View File

@@ -645,6 +645,19 @@ CodeForge is a comprehensive low-code development platform for building producti
- [ ] Migration guides - [ ] Migration guides
- [ ] Best practices guide - [ ] Best practices guide
#### Molecule/Organism Refactors
- [ ] Identify 3-5 molecule components that can be expressed as JSON schemas with hooks, types, and actions (target: ComponentTree, PropertyEditor, DataSourceCard, SchemaEditorCanvas, NavigationMenu).
- [ ] Create JSON schemas for selected molecules and wire them through `JSONUIRenderer` while keeping existing props contracts stable.
- [ ] Convert at least one organism (e.g., DataSourceManager) into JSON-driven layout with nested molecule schemas.
- [ ] Document the JSON schema patterns for molecule/organism composition (bindings, events, actions) with real examples.
#### JSON UI Framework Improvements
- [ ] Fix conditional rendering to honor `conditional.then` when conditions pass.
- [ ] Fix loop rendering to avoid self-recursion and render loop children templates.
- [ ] Add data binding support for dot-path string bindings and loop context variables.
- [ ] Apply data binding transforms consistently (support `transform` in JSON schemas).
- [ ] Align JSON UI event/action typings across `src/lib/json-ui` and `src/types/json-ui.ts`.
#### Security #### Security
- [ ] Regular dependency updates - [ ] Regular dependency updates
- [ ] Security audit with npm audit - [ ] Security audit with npm audit

View File

@@ -1,20 +0,0 @@
# React Hook Extraction Tasks
## Component candidates
- [ ] **Extract data source editor state logic into a hook.**
- **Component:** `src/components/molecules/DataSourceEditorDialog.tsx`
- **Why:** Manages editing state, sync with props, and dependency add/remove logic in the component body.
- **Proposed hook:** `useDataSourceEditor` (or similar) to own `editingSource`, `updateField`, and dependency helpers, plus derived dependency lists.
- [ ] **Extract component binding dialog state into a hook.**
- **Component:** `src/components/molecules/ComponentBindingDialog.tsx`
- **Why:** Holds editable component state and binding update handlers inline.
- **Proposed hook:** `useComponentBindingDialog` to sync `editingComponent` with props and expose `updateBindings`/`handleSave`.
- [ ] **Extract template export/copy/download actions into a hook.**
- **Component:** `src/components/TemplateExplorer.tsx`
- **Why:** Clipboard interactions, blob creation, and KV export are embedded in the view component.
- **Proposed hook:** `useTemplateExplorerActions` to return `copyToClipboard`, `downloadJSON`, and `exportCurrentData` handlers.
- [ ] **Extract component tree expansion state into a hook.**
- **Component:** `src/components/molecules/ComponentTree.tsx`
- **Why:** Expansion state, “expand all,” and “collapse all” logic are in the component, but reusable across tree UIs.
- **Proposed hook:** `useComponentTreeExpansion` to compute expandable IDs and manage `expandedIds` with expand/collapse/toggle functions.

View File

@@ -847,7 +847,7 @@
"canHaveChildren": false, "canHaveChildren": false,
"description": "Navigation breadcrumb trail", "description": "Navigation breadcrumb trail",
"status": "json-compatible", "status": "json-compatible",
"source": "molecules", "source": "json-ui-wrappers",
"jsonCompatible": true "jsonCompatible": true
}, },
{ {
@@ -1257,7 +1257,7 @@
"canHaveChildren": true, "canHaveChildren": true,
"description": "LazyBarChart component", "description": "LazyBarChart component",
"status": "json-compatible", "status": "json-compatible",
"source": "molecules", "source": "json-ui-wrappers",
"jsonCompatible": true "jsonCompatible": true
}, },
{ {
@@ -1267,7 +1267,7 @@
"canHaveChildren": true, "canHaveChildren": true,
"description": "LazyD3BarChart component", "description": "LazyD3BarChart component",
"status": "json-compatible", "status": "json-compatible",
"source": "molecules", "source": "json-ui-wrappers",
"jsonCompatible": true "jsonCompatible": true
}, },
{ {
@@ -1277,7 +1277,7 @@
"canHaveChildren": true, "canHaveChildren": true,
"description": "LazyLineChart component", "description": "LazyLineChart component",
"status": "json-compatible", "status": "json-compatible",
"source": "molecules", "source": "json-ui-wrappers",
"jsonCompatible": true "jsonCompatible": true
}, },
{ {
@@ -1319,11 +1319,11 @@
{ {
"type": "SeedDataManager", "type": "SeedDataManager",
"name": "SeedDataManager", "name": "SeedDataManager",
"category": "data", "category": "custom",
"canHaveChildren": true, "canHaveChildren": true,
"description": "SeedDataManager component", "description": "SeedDataManager component",
"status": "json-compatible", "status": "json-compatible",
"source": "molecules", "source": "json-ui-wrappers",
"jsonCompatible": true "jsonCompatible": true
}, },
{ {
@@ -1826,11 +1826,11 @@
{ {
"type": "SaveIndicator", "type": "SaveIndicator",
"name": "SaveIndicator", "name": "SaveIndicator",
"category": "custom", "category": "feedback",
"canHaveChildren": true, "canHaveChildren": true,
"description": "SaveIndicator component", "description": "SaveIndicator component",
"status": "json-compatible", "status": "json-compatible",
"source": "molecules", "source": "json-ui-wrappers",
"jsonCompatible": true "jsonCompatible": true
}, },
{ {
@@ -2000,7 +2000,7 @@
"canHaveChildren": true, "canHaveChildren": true,
"description": "StorageSettings component", "description": "StorageSettings component",
"status": "json-compatible", "status": "json-compatible",
"source": "molecules", "source": "json-ui-wrappers",
"jsonCompatible": true "jsonCompatible": true
}, },
{ {

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env node
const fs = require('fs')
const path = require('path')
const registryPath = path.join(process.cwd(), 'json-components-registry.json')
const schemaPath = path.join(process.cwd(), 'src', 'schemas', 'registry-validation.json')
if (!fs.existsSync(registryPath)) {
console.error('❌ Could not find json-components-registry.json')
process.exit(1)
}
if (!fs.existsSync(schemaPath)) {
console.error('❌ Could not find src/schemas/registry-validation.json')
process.exit(1)
}
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'))
const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8'))
const primitiveTypes = new Set([
'div',
'span',
'p',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'section',
'article',
'header',
'footer',
'main',
'aside',
'nav',
])
const registryTypes = new Set()
for (const entry of registry.components || []) {
if (entry.source === 'atoms' || entry.source === 'molecules') {
const name = entry.export || entry.name || entry.type
if (name) {
registryTypes.add(name)
}
}
}
const schemaTypes = new Set()
const collectTypes = (components) => {
if (!components) return
if (Array.isArray(components)) {
components.forEach(collectTypes)
return
}
if (components.type) {
schemaTypes.add(components.type)
}
if (components.children) {
collectTypes(components.children)
}
}
collectTypes(schema.components || [])
const missing = []
for (const type of schemaTypes) {
if (!primitiveTypes.has(type) && !registryTypes.has(type)) {
missing.push(type)
}
}
if (missing.length) {
console.error(`❌ Missing registry entries for: ${missing.join(', ')}`)
process.exit(1)
}
console.log('✅ JSON UI registry validation passed for primitives and atom/molecule components.')

View File

@@ -3,45 +3,50 @@ import { toast } from 'sonner'
import { useKV } from '@/hooks/use-kv' import { useKV } from '@/hooks/use-kv'
import { useState } from 'react' import { useState } from 'react'
import { buildDemoPageSchema, demoCopy, demoInitialTodos } from '@/components/json-demo/schema' import { buildDemoPageSchema, demoCopy, demoInitialTodos } from '@/components/json-demo/schema'
import { Action } from '@/lib/json-ui/schema'
export function JSONDemoPage() { export function JSONDemoPage() {
const [todos, setTodos] = useKV('json-demo-todos', demoInitialTodos) const [todos, setTodos] = useKV('json-demo-todos', demoInitialTodos)
const [newTodo, setNewTodo] = useState('') const [newTodo, setNewTodo] = useState('')
const handleAction = (handler: any, event?: any) => { const handleAction = (actions: Action[], event?: any) => {
switch (handler.action) { actions.forEach((action) => {
case 'add-todo': const actionKey = action.type === 'custom' ? action.id : action.type
if (newTodo.trim()) {
setTodos((current: any) => [
...current,
{ id: Date.now(), text: newTodo, completed: false },
])
setNewTodo('')
toast.success(demoCopy.toastAdded)
}
break
case 'toggle-todo': switch (actionKey) {
setTodos((current: any) => case 'add-todo':
current.map((todo: any) => if (newTodo.trim()) {
todo.id === handler.params?.id setTodos((current: any) => [
? { ...todo, completed: !todo.completed } ...current,
: todo { id: Date.now(), text: newTodo, completed: false },
])
setNewTodo('')
toast.success(demoCopy.toastAdded)
}
break
case 'toggle-todo':
setTodos((current: any) =>
current.map((todo: any) =>
todo.id === action.params?.id
? { ...todo, completed: !todo.completed }
: todo
)
) )
) break
break
case 'delete-todo': case 'delete-todo':
setTodos((current: any) => setTodos((current: any) =>
current.filter((todo: any) => todo.id !== handler.params?.id) current.filter((todo: any) => todo.id !== action.params?.id)
) )
toast.success(demoCopy.toastDeleted) toast.success(demoCopy.toastDeleted)
break break
case 'update-input': case 'update-input':
setNewTodo(event.target.value) setNewTodo(event.target.value)
break break
} }
})
} }
const pageSchema = buildDemoPageSchema(todos, newTodo) const pageSchema = buildDemoPageSchema(todos, newTodo)

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { JSONUIRenderer } from '@/lib/json-ui/renderer' import { JSONUIRenderer } from '@/lib/json-ui/renderer'
import { UIComponent, EventHandler, Layout } from '@/lib/json-ui/schema' import { Action, UIComponent, Layout } from '@/lib/json-ui/schema'
import { toast } from 'sonner' import { toast } from 'sonner'
interface JSONUIPageProps { interface JSONUIPageProps {
@@ -34,88 +34,91 @@ export function JSONUIPage({ jsonConfig }: JSONUIPageProps) {
})) }))
} }
const handleAction = (handler: EventHandler, event?: any) => { const handleAction = (actions: Action[], event?: any) => {
console.log('Action triggered:', handler.action, handler.params, event) actions.forEach((action) => {
const actionKey = action.type === 'custom' ? action.id : action.type
console.log('Action triggered:', actionKey, action.params, event)
switch (handler.action) { switch (actionKey) {
case 'refresh-data': case 'refresh-data':
toast.success('Data refreshed') toast.success('Data refreshed')
break break
case 'create-project': case 'create-project':
toast.info('Create project clicked') toast.info('Create project clicked')
break break
case 'deploy': case 'deploy':
toast.info('Deploy clicked') toast.info('Deploy clicked')
break break
case 'view-logs': case 'view-logs':
toast.info('View logs clicked') toast.info('View logs clicked')
break break
case 'settings': case 'settings':
toast.info('Settings clicked') toast.info('Settings clicked')
break break
case 'add-project': case 'add-project':
toast.info('Add project clicked') toast.info('Add project clicked')
break break
case 'view-project': case 'view-project':
toast.info(`View project: ${handler.params?.projectId}`) toast.info(`View project: ${action.params?.projectId}`)
break break
case 'edit-project': case 'edit-project':
toast.info(`Edit project: ${handler.params?.projectId}`) toast.info(`Edit project: ${action.params?.projectId}`)
break break
case 'delete-project': case 'delete-project':
toast.error(`Delete project: ${handler.params?.projectId}`) toast.error(`Delete project: ${action.params?.projectId}`)
break break
case 'update-field': case 'update-field':
if (event?.target) { if (event?.target) {
const { name, value } = event.target const { name, value } = event.target
updateDataField('formData', name, value) updateDataField('formData', name, value)
} }
break break
case 'update-checkbox': case 'update-checkbox':
if (handler.params?.field) { if (action.params?.field) {
updateDataField('formData', handler.params.field, event) updateDataField('formData', action.params.field, event)
} }
break break
case 'submit-form': case 'submit-form':
toast.success('Form submitted!') toast.success('Form submitted!')
console.log('Form data:', dataMap.formData) console.log('Form data:', dataMap.formData)
break break
case 'cancel-form': case 'cancel-form':
toast.info('Form cancelled') toast.info('Form cancelled')
break break
case 'toggle-dark-mode': case 'toggle-dark-mode':
updateDataField('settings', 'darkMode', event) updateDataField('settings', 'darkMode', event)
toast.success(`Dark mode ${event ? 'enabled' : 'disabled'}`) toast.success(`Dark mode ${event ? 'enabled' : 'disabled'}`)
break break
case 'toggle-auto-save': case 'toggle-auto-save':
updateDataField('settings', 'autoSave', event) updateDataField('settings', 'autoSave', event)
toast.success(`Auto-save ${event ? 'enabled' : 'disabled'}`) toast.success(`Auto-save ${event ? 'enabled' : 'disabled'}`)
break break
case 'toggle-email-notifications': case 'toggle-email-notifications':
updateDataField('notifications', 'email', event) updateDataField('notifications', 'email', event)
toast.success(`Email notifications ${event ? 'enabled' : 'disabled'}`) toast.success(`Email notifications ${event ? 'enabled' : 'disabled'}`)
break break
case 'toggle-push-notifications': case 'toggle-push-notifications':
updateDataField('notifications', 'push', event) updateDataField('notifications', 'push', event)
toast.success(`Push notifications ${event ? 'enabled' : 'disabled'}`) toast.success(`Push notifications ${event ? 'enabled' : 'disabled'}`)
break break
case 'toggle-2fa': case 'toggle-2fa':
updateDataField('security', 'twoFactor', event) updateDataField('security', 'twoFactor', event)
toast.success(`Two-factor auth ${event ? 'enabled' : 'disabled'}`) toast.success(`Two-factor auth ${event ? 'enabled' : 'disabled'}`)
break break
case 'logout-all-sessions': case 'logout-all-sessions':
toast.success('All other sessions logged out') toast.success('All other sessions logged out')
break break
case 'save-settings': case 'save-settings':
toast.success('Settings saved successfully') toast.success('Settings saved successfully')
console.log('Settings:', dataMap) console.log('Settings:', dataMap)
break break
case 'reset-settings': case 'reset-settings':
toast.info('Settings reset to defaults') toast.info('Settings reset to defaults')
break break
default: default:
console.log('Unhandled action:', handler.action) console.log('Unhandled action:', actionKey)
} }
})
} }
if (!jsonConfig.layout) { if (!jsonConfig.layout) {

View File

@@ -8,8 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { useSeedTemplates } from '@/hooks/data/use-seed-templates' import { useSeedTemplates } from '@/hooks/data/use-seed-templates'
import { Copy, Download } from '@phosphor-icons/react' import { Copy, Download } from '@phosphor-icons/react'
import { toast } from 'sonner'
import templateUi from '@/config/template-ui.json' import templateUi from '@/config/template-ui.json'
import { useTemplateExplorerActions } from '@/hooks/use-template-explorer-actions'
const ui = templateUi.explorer const ui = templateUi.explorer
@@ -185,49 +185,11 @@ export function TemplateExplorer() {
const currentTemplate = templates.find(t => t.id === selectedTemplate) const currentTemplate = templates.find(t => t.id === selectedTemplate)
const copyToClipboard = (text: string) => { const {
navigator.clipboard.writeText(text) copyToClipboard,
toast.success(ui.toasts.copySuccess) downloadJSON,
} exportCurrentData
} = useTemplateExplorerActions(currentTemplate)
const downloadJSON = () => {
if (!currentTemplate) return
const dataStr = JSON.stringify(currentTemplate.data, null, 2)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${currentTemplate.id}-template.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
toast.success(ui.toasts.downloadSuccess)
}
const exportCurrentData = async () => {
const keys = await window.spark.kv.keys()
const data: Record<string, any> = {}
for (const key of keys) {
data[key] = await window.spark.kv.get(key)
}
const dataStr = JSON.stringify(data, null, 2)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = 'current-project-data.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
toast.success(ui.toasts.exportSuccess)
}
if (!currentTemplate) return null if (!currentTemplate) return null

View File

@@ -2,25 +2,7 @@ import { ClockCounterClockwise, X } from '@phosphor-icons/react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { CommandGroup, CommandItem, CommandSeparator } from '@/components/ui/command' import { CommandGroup, CommandItem, CommandSeparator } from '@/components/ui/command'
import type { SearchHistoryItem, SearchResult } from './types'
interface SearchHistoryItem {
id: string
query: string
timestamp: number
resultId?: string
resultTitle?: string
resultCategory?: string
}
interface SearchResult {
id: string
title: string
subtitle?: string
category: string
icon: React.ReactNode
action: () => void
tags?: string[]
}
interface RecentSearchesProps { interface RecentSearchesProps {
recentSearches: Array<{ historyItem: SearchHistoryItem; result?: SearchResult }> recentSearches: Array<{ historyItem: SearchHistoryItem; result?: SearchResult }>

View File

@@ -1,15 +1,6 @@
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { CommandGroup, CommandItem, CommandSeparator } from '@/components/ui/command' import { CommandGroup, CommandItem, CommandSeparator } from '@/components/ui/command'
import type { SearchResult } from './types'
interface SearchResult {
id: string
title: string
subtitle?: string
category: string
icon: React.ReactNode
action: () => void
tags?: string[]
}
interface SearchResultsProps { interface SearchResultsProps {
groupedResults: Record<string, SearchResult[]> groupedResults: Record<string, SearchResult[]>

View File

@@ -0,0 +1,18 @@
export interface SearchResult {
id: string
title: string
subtitle?: string
category: string
icon: React.ReactNode
action: () => void
tags?: string[]
}
export interface SearchHistoryItem {
id: string
query: string
timestamp: number
resultId?: string
resultTitle?: string
resultCategory?: string
}

View File

@@ -33,25 +33,7 @@ import {
Workflow, Workflow,
} from '@/types/project' } from '@/types/project'
import navigationData from '@/data/global-search.json' import navigationData from '@/data/global-search.json'
import type { SearchHistoryItem, SearchResult } from './types'
export interface SearchResult {
id: string
title: string
subtitle?: string
category: string
icon: React.ReactNode
action: () => void
tags?: string[]
}
export interface SearchHistoryItem {
id: string
query: string
timestamp: number
resultId?: string
resultTitle?: string
resultCategory?: string
}
const navigationIconMap = { const navigationIconMap = {
BookOpen, BookOpen,

View File

@@ -49,12 +49,18 @@ const buildTodoItem = (todo: TodoItem, copy: DemoCopy): UIComponent => ({
props: { props: {
checked: todo.completed, checked: todo.completed,
}, },
events: { events: [
onCheckedChange: { {
action: 'toggle-todo', event: 'checkedChange',
params: { id: todo.id }, actions: [
{
id: 'toggle-todo',
type: 'custom',
params: { id: todo.id },
},
],
}, },
}, ],
}, },
{ {
id: `text-${todo.id}`, id: `text-${todo.id}`,
@@ -72,12 +78,18 @@ const buildTodoItem = (todo: TodoItem, copy: DemoCopy): UIComponent => ({
size: 'sm', size: 'sm',
children: copy.deleteButtonLabel, children: copy.deleteButtonLabel,
}, },
events: { events: [
onClick: { {
action: 'delete-todo', event: 'click',
params: { id: todo.id }, actions: [
{
id: 'delete-todo',
type: 'custom',
params: { id: todo.id },
},
],
}, },
}, ],
}, },
], ],
}) })

View File

@@ -1,10 +1,10 @@
import { useState } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { BindingEditor } from '@/components/molecules/BindingEditor' import { BindingEditor } from '@/components/molecules/BindingEditor'
import { DataSource, UIComponent } from '@/types/json-ui' import { DataSource, UIComponent } from '@/types/json-ui'
import { Link } from '@phosphor-icons/react' import { Link } from '@phosphor-icons/react'
import { useComponentBindingDialog } from '@/hooks/use-component-binding-dialog'
interface ComponentBindingDialogProps { interface ComponentBindingDialogProps {
open: boolean open: boolean
@@ -21,18 +21,12 @@ export function ComponentBindingDialog({
onOpenChange, onOpenChange,
onSave, onSave,
}: ComponentBindingDialogProps) { }: ComponentBindingDialogProps) {
const [editingComponent, setEditingComponent] = useState<UIComponent | null>(component) const { editingComponent, handleSave, updateBindings } = useComponentBindingDialog({
component,
const handleSave = () => { open,
if (!editingComponent) return onOpenChange,
onSave(editingComponent) onSave,
onOpenChange(false) })
}
const updateBindings = (bindings: Record<string, any>) => {
if (!editingComponent) return
setEditingComponent({ ...editingComponent, bindings })
}
if (!editingComponent) return null if (!editingComponent) return null

View File

@@ -1,9 +1,9 @@
import { useState, useCallback } from 'react'
import { UIComponent } from '@/types/json-ui' import { UIComponent } from '@/types/json-ui'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { ComponentTreeHeader } from '@/components/molecules/component-tree/ComponentTreeHeader' import { ComponentTreeHeader } from '@/components/molecules/component-tree/ComponentTreeHeader'
import { ComponentTreeEmptyState } from '@/components/molecules/component-tree/ComponentTreeEmptyState' import { ComponentTreeEmptyState } from '@/components/molecules/component-tree/ComponentTreeEmptyState'
import { ComponentTreeNodes } from '@/components/molecules/component-tree/ComponentTreeNodes' import { ComponentTreeNodes } from '@/components/molecules/component-tree/ComponentTreeNodes'
import { useComponentTreeExpansion } from '@/hooks/use-component-tree-expansion'
interface ComponentTreeProps { interface ComponentTreeProps {
components: UIComponent[] components: UIComponent[]
@@ -34,42 +34,8 @@ export function ComponentTree({
onDragLeave, onDragLeave,
onDrop, onDrop,
}: ComponentTreeProps) { }: ComponentTreeProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set()) const { expandedIds, handleExpandAll, handleCollapseAll, toggleExpand } =
useComponentTreeExpansion(components)
const getAllComponentIds = useCallback((comps: UIComponent[]): string[] => {
const ids: string[] = []
const traverse = (nodes: UIComponent[]) => {
nodes.forEach((comp) => {
if (Array.isArray(comp.children) && comp.children.length > 0) {
ids.push(comp.id)
traverse(comp.children)
}
})
}
traverse(comps)
return ids
}, [])
const handleExpandAll = useCallback(() => {
const allIds = getAllComponentIds(components)
setExpandedIds(new Set(allIds))
}, [components, getAllComponentIds])
const handleCollapseAll = useCallback(() => {
setExpandedIds(new Set())
}, [])
const toggleExpand = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">

View File

@@ -1,4 +1,3 @@
import { useEffect, useState } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DataSource } from '@/types/json-ui' import { DataSource } from '@/types/json-ui'
@@ -7,7 +6,9 @@ import { DataSourceIdField } from '@/components/molecules/data-source-editor/Dat
import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields' import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields'
import { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields' import { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields'
import { ComputedSourceFields } from '@/components/molecules/data-source-editor/ComputedSourceFields' import { ComputedSourceFields } from '@/components/molecules/data-source-editor/ComputedSourceFields'
import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor'
import dataSourceEditorCopy from '@/data/data-source-editor-dialog.json' import dataSourceEditorCopy from '@/data/data-source-editor-dialog.json'
import { useDataSourceEditor } from '@/hooks/use-data-source-editor'
interface DataSourceEditorDialogProps { interface DataSourceEditorDialogProps {
open: boolean open: boolean
@@ -24,11 +25,15 @@ export function DataSourceEditorDialog({
onOpenChange, onOpenChange,
onSave, onSave,
}: DataSourceEditorDialogProps) { }: DataSourceEditorDialogProps) {
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource) const {
editingSource,
useEffect(() => { updateField,
setEditingSource(dataSource) addDependency,
}, [dataSource]) removeDependency,
availableDeps,
selectedDeps,
unselectedDeps,
} = useDataSourceEditor(dataSource, allDataSources)
const handleSave = () => { const handleSave = () => {
if (!editingSource) return if (!editingSource) return
@@ -36,34 +41,8 @@ export function DataSourceEditorDialog({
onOpenChange(false) onOpenChange(false)
} }
const updateField = <K extends keyof DataSource>(field: K, value: DataSource[K]) => {
if (!editingSource) return
setEditingSource({ ...editingSource, [field]: value })
}
const addDependency = (depId: string) => {
if (!editingSource || editingSource.type !== 'computed') return
const deps = editingSource.dependencies || []
if (!deps.includes(depId)) {
updateField('dependencies', [...deps, depId])
}
}
const removeDependency = (depId: string) => {
if (!editingSource || editingSource.type !== 'computed') return
const deps = editingSource.dependencies || []
updateField('dependencies', deps.filter(d => d !== depId))
}
if (!editingSource) return null if (!editingSource) return null
const availableDeps = allDataSources.filter(
ds => ds.id !== editingSource.id && ds.type !== 'computed',
)
const selectedDeps = editingSource.dependencies || []
const unselectedDeps = availableDeps.filter(ds => !selectedDeps.includes(ds.id))
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">

View File

@@ -1,24 +1,6 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { ComponentSchema as ComponentSchemaType } from '@/types/page-schema' import { ComponentSchema as ComponentSchemaType } from '@/types/page-schema'
import { Button } from '@/components/ui/button' import { getUIComponent } from '@/lib/json-ui/component-registry'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
const COMPONENT_MAP: Record<string, any> = {
Button,
Card,
Input,
Badge,
Textarea,
div: 'div',
span: 'span',
h1: 'h1',
h2: 'h2',
h3: 'h3',
p: 'p',
}
interface ComponentRendererProps { interface ComponentRendererProps {
schema: ComponentSchemaType schema: ComponentSchemaType
@@ -27,7 +9,7 @@ interface ComponentRendererProps {
} }
export function ComponentRenderer({ schema, context, onEvent }: ComponentRendererProps) { export function ComponentRenderer({ schema, context, onEvent }: ComponentRendererProps) {
const Component = COMPONENT_MAP[schema.type] const Component = getUIComponent(schema.type)
if (!Component) { if (!Component) {
console.warn(`Component type "${schema.type}" not found`) console.warn(`Component type "${schema.type}" not found`)

View File

@@ -1,18 +1,13 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { NextJsConfig } from '@/types/project'
import projectSettingsCopy from '@/data/project-settings.json' import projectSettingsCopy from '@/data/project-settings.json'
import { NextJsConfigSectionProps } from '@/components/project-settings/types'
interface NextJsApplicationCardProps {
nextjsConfig: NextJsConfig
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
}
export function NextJsApplicationCard({ export function NextJsApplicationCard({
nextjsConfig, nextjsConfig,
onNextjsConfigChange, onNextjsConfigChange,
}: NextJsApplicationCardProps) { }: NextJsConfigSectionProps) {
const { application } = projectSettingsCopy.nextjs const { application } = projectSettingsCopy.nextjs
return ( return (

View File

@@ -1,16 +1,11 @@
import { NextJsConfig } from '@/types/project'
import { NextJsApplicationCard } from '@/components/project-settings/NextJsApplicationCard' import { NextJsApplicationCard } from '@/components/project-settings/NextJsApplicationCard'
import { NextJsFeaturesCard } from '@/components/project-settings/NextJsFeaturesCard' import { NextJsFeaturesCard } from '@/components/project-settings/NextJsFeaturesCard'
import { NextJsConfigSectionProps } from '@/components/project-settings/types'
interface NextJsConfigTabProps {
nextjsConfig: NextJsConfig
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
}
export function NextJsConfigTab({ export function NextJsConfigTab({
nextjsConfig, nextjsConfig,
onNextjsConfigChange, onNextjsConfigChange,
}: NextJsConfigTabProps) { }: NextJsConfigSectionProps) {
return ( return (
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl space-y-6">
<NextJsApplicationCard <NextJsApplicationCard

View File

@@ -1,18 +1,13 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { NextJsConfig } from '@/types/project'
import projectSettingsCopy from '@/data/project-settings.json' import projectSettingsCopy from '@/data/project-settings.json'
import { NextJsConfigSectionProps } from '@/components/project-settings/types'
interface NextJsFeaturesCardProps {
nextjsConfig: NextJsConfig
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
}
export function NextJsFeaturesCard({ export function NextJsFeaturesCard({
nextjsConfig, nextjsConfig,
onNextjsConfigChange, onNextjsConfigChange,
}: NextJsFeaturesCardProps) { }: NextJsConfigSectionProps) {
const { features } = projectSettingsCopy.nextjs const { features } = projectSettingsCopy.nextjs
return ( return (

View File

@@ -0,0 +1,6 @@
import { NextJsConfig } from '@/types/project'
export type NextJsConfigSectionProps = {
nextjsConfig: NextJsConfig
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
}

View File

@@ -0,0 +1,58 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { DataSource } from '@/types/json-ui'
export function useDataSourceEditor(
dataSource: DataSource | null,
allDataSources: DataSource[],
) {
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
useEffect(() => {
setEditingSource(dataSource)
}, [dataSource])
const updateField = useCallback(<K extends keyof DataSource>(field: K, value: DataSource[K]) => {
setEditingSource(prev => (prev ? { ...prev, [field]: value } : prev))
}, [])
const addDependency = useCallback((depId: string) => {
setEditingSource(prev => {
if (!prev || prev.type !== 'computed') return prev
const deps = prev.dependencies || []
if (deps.includes(depId)) return prev
return { ...prev, dependencies: [...deps, depId] }
})
}, [])
const removeDependency = useCallback((depId: string) => {
setEditingSource(prev => {
if (!prev || prev.type !== 'computed') return prev
const deps = prev.dependencies || []
return { ...prev, dependencies: deps.filter(dep => dep !== depId) }
})
}, [])
const availableDeps = useMemo(() => {
if (!editingSource) return []
return allDataSources.filter(
ds => ds.id !== editingSource.id && ds.type !== 'computed',
)
}, [allDataSources, editingSource])
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
const unselectedDeps = useMemo(
() => availableDeps.filter(ds => !selectedDeps.includes(ds.id)),
[availableDeps, selectedDeps],
)
return {
editingSource,
updateField,
addDependency,
removeDependency,
availableDeps,
selectedDeps,
unselectedDeps,
}
}

View File

@@ -1,11 +1,5 @@
import { useMemo } from 'react' import { createElement, useMemo } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { uiComponentRegistry, iconComponents } from '@/lib/json-ui/component-registry'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Progress } from '@/components/ui/progress'
import * as Icons from '@phosphor-icons/react' import * as Icons from '@phosphor-icons/react'
interface ComponentRegistryOptions { interface ComponentRegistryOptions {
@@ -15,17 +9,7 @@ interface ComponentRegistryOptions {
export function useComponentRegistry({ customComponents = {} }: ComponentRegistryOptions = {}) { export function useComponentRegistry({ customComponents = {} }: ComponentRegistryOptions = {}) {
const registry = useMemo( const registry = useMemo(
() => ({ () => ({
Card, ...uiComponentRegistry,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Button,
Badge,
Input,
Label,
Separator,
Progress,
...customComponents, ...customComponents,
}), }),
[customComponents] [customComponents]
@@ -36,9 +20,13 @@ export function useComponentRegistry({ customComponents = {} }: ComponentRegistr
} }
const getIcon = (iconName: string, props?: any): React.ReactElement | null => { const getIcon = (iconName: string, props?: any): React.ReactElement | null => {
const IconComponent = (Icons as any)[iconName] const registryIcon = registry[iconName as keyof typeof registry]
if (!IconComponent) return null const IconComponent =
return IconComponent({ size: 24, weight: "duotone", ...props }) (registryIcon && typeof registryIcon !== 'string' ? registryIcon : null) ||
iconComponents[iconName as keyof typeof iconComponents] ||
(Icons as any)[iconName]
if (!IconComponent || typeof IconComponent === 'string') return null
return createElement(IconComponent, { size: 24, weight: "duotone", ...props })
} }
return { return {

View File

@@ -0,0 +1,40 @@
import { useCallback, useEffect, useState } from 'react'
import { UIComponent } from '@/types/json-ui'
interface UseComponentBindingDialogOptions {
component: UIComponent | null
open: boolean
onOpenChange: (open: boolean) => void
onSave: (component: UIComponent) => void
}
export function useComponentBindingDialog({
component,
open,
onOpenChange,
onSave,
}: UseComponentBindingDialogOptions) {
const [editingComponent, setEditingComponent] = useState<UIComponent | null>(component)
useEffect(() => {
if (open) {
setEditingComponent(component)
}
}, [component, open])
const updateBindings = useCallback((bindings: Record<string, any>) => {
setEditingComponent(prev => (prev ? { ...prev, bindings } : prev))
}, [])
const handleSave = useCallback(() => {
if (!editingComponent) return
onSave(editingComponent)
onOpenChange(false)
}, [editingComponent, onOpenChange, onSave])
return {
editingComponent,
handleSave,
updateBindings,
}
}

View File

@@ -0,0 +1,58 @@
import { useCallback, useMemo, useState } from 'react'
import { UIComponent } from '@/types/json-ui'
interface ComponentTreeExpansionState {
expandedIds: Set<string>
handleExpandAll: () => void
handleCollapseAll: () => void
toggleExpand: (id: string) => void
}
const getExpandableIds = (components: UIComponent[]): string[] => {
const ids: string[] = []
const traverse = (nodes: UIComponent[]) => {
nodes.forEach((component) => {
if (Array.isArray(component.children) && component.children.length > 0) {
ids.push(component.id)
traverse(component.children)
}
})
}
traverse(components)
return ids
}
export function useComponentTreeExpansion(
components: UIComponent[],
): ComponentTreeExpansionState {
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
const expandableIds = useMemo(() => getExpandableIds(components), [components])
const handleExpandAll = useCallback(() => {
setExpandedIds(new Set(expandableIds))
}, [expandableIds])
const handleCollapseAll = useCallback(() => {
setExpandedIds(new Set())
}, [])
const toggleExpand = useCallback((id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
return {
expandedIds,
handleExpandAll,
handleCollapseAll,
toggleExpand,
}
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { DataSource } from '@/types/json-ui'
interface UseDataSourceEditorParams {
dataSource: DataSource | null
allDataSources: DataSource[]
onSave: (dataSource: DataSource) => void
onOpenChange: (open: boolean) => void
}
export function useDataSourceEditor({
dataSource,
allDataSources,
onSave,
onOpenChange,
}: UseDataSourceEditorParams) {
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
useEffect(() => {
setEditingSource(dataSource)
}, [dataSource])
const updateField = useCallback(<K extends keyof DataSource>(field: K, value: DataSource[K]) => {
setEditingSource((prev) => {
if (!prev) return prev
return { ...prev, [field]: value }
})
}, [])
const addDependency = useCallback((depId: string) => {
setEditingSource((prev) => {
if (!prev || prev.type !== 'computed') return prev
const deps = prev.dependencies || []
if (deps.includes(depId)) return prev
return { ...prev, dependencies: [...deps, depId] }
})
}, [])
const removeDependency = useCallback((depId: string) => {
setEditingSource((prev) => {
if (!prev || prev.type !== 'computed') return prev
const deps = prev.dependencies || []
return { ...prev, dependencies: deps.filter((id) => id !== depId) }
})
}, [])
const handleSave = useCallback(() => {
if (!editingSource) return
onSave(editingSource)
onOpenChange(false)
}, [editingSource, onOpenChange, onSave])
const availableDeps = useMemo(() => {
if (!editingSource) return []
return allDataSources.filter(
(ds) => ds.id !== editingSource.id && ds.type !== 'computed',
)
}, [allDataSources, editingSource])
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
const unselectedDeps = useMemo(() => {
if (!editingSource) return []
return availableDeps.filter((ds) => !selectedDeps.includes(ds.id))
}, [availableDeps, editingSource, selectedDeps])
return {
editingSource,
updateField,
addDependency,
removeDependency,
handleSave,
availableDeps,
selectedDeps,
unselectedDeps,
}
}

View File

@@ -0,0 +1,55 @@
/// <reference path="../global.d.ts" />
import { useCallback } from 'react'
import { toast } from 'sonner'
import templateUi from '@/config/template-ui.json'
import type { Template } from '@/hooks/data/use-seed-templates'
const ui = templateUi.explorer
type TemplateData = Record<string, any>
const triggerJsonDownload = (data: TemplateData, filename: string) => {
const dataStr = JSON.stringify(data, null, 2)
const blob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
export function useTemplateExplorerActions(selectedTemplate?: Template) {
const copyToClipboard = useCallback((text: string) => {
navigator.clipboard.writeText(text)
toast.success(ui.toasts.copySuccess)
}, [])
const downloadJSON = useCallback(() => {
if (!selectedTemplate) return
triggerJsonDownload(selectedTemplate.data, `${selectedTemplate.id}-template.json`)
toast.success(ui.toasts.downloadSuccess)
}, [selectedTemplate])
const exportCurrentData = useCallback(async () => {
const keys = await window.spark.kv.keys()
const data: Record<string, any> = {}
for (const key of keys) {
data[key] = await window.spark.kv.get(key)
}
triggerJsonDownload(data, 'current-project-data.json')
toast.success(ui.toasts.exportSuccess)
}, [])
return {
copyToClipboard,
downloadJSON,
exportCurrentData
}
}

View File

@@ -224,6 +224,19 @@ export const componentDefinitions: ComponentDefinition[] = [
canHaveChildren: true, canHaveChildren: true,
defaultProps: { href: '#', children: 'Link' } 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 // Feedback Components
{ {
type: 'Alert', type: 'Alert',
@@ -256,6 +269,13 @@ export const componentDefinitions: ComponentDefinition[] = [
icon: 'Circle', icon: 'Circle',
defaultProps: { status: 'active', children: 'Active' } defaultProps: { status: 'active', children: 'Active' }
}, },
{
type: 'SaveIndicator',
label: 'Save Indicator',
category: 'feedback',
icon: 'FloppyDisk',
defaultProps: { status: 'saved', label: 'Saved' }
},
// Data Components // Data Components
{ {
type: 'List', type: 'List',
@@ -285,6 +305,46 @@ export const componentDefinitions: ComponentDefinition[] = [
icon: 'ChartBar', icon: 'ChartBar',
defaultProps: { title: 'Metric', value: '0' } 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 // Custom Components
{ {
type: 'DataCard', type: 'DataCard',
@@ -308,6 +368,23 @@ export const componentDefinitions: ComponentDefinition[] = [
canHaveChildren: true, canHaveChildren: true,
defaultProps: { actions: [] } 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[] { export function getCategoryComponents(category: string): ComponentDefinition[] {

View File

@@ -109,7 +109,8 @@ Render lists from arrays:
{ {
"loop": { "loop": {
"source": "items", "source": "items",
"itemVar": "item" "itemVar": "item",
"indexVar": "index"
}, },
"children": [...] "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 ## 🧩 Available Components
### Layout ### Layout
@@ -226,6 +328,14 @@ const component: UIComponent = {
} }
``` ```
### Hooks
Consume JSON UI data hooks from the public entrypoint:
```typescript
import { useJSONDataSource, useJSONDataSources, useJSONActions } from '@/lib/json-ui'
```
## 📦 Exports ## 📦 Exports
```typescript ```typescript

View File

@@ -6,25 +6,27 @@ import { Label } from '@/components/ui/label'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card' import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group' import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress' import { Progress } from '@/components/ui/progress'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Heading } from '@/components/atoms/Heading' import * as AtomComponents from '@/components/atoms'
import { Text } from '@/components/atoms/Text' import * as MoleculeComponents from '@/components/molecules'
import { List as ListComponent } from '@/components/atoms/List' import { Breadcrumb } from './wrappers/Breadcrumb'
import { Grid } from '@/components/atoms/Grid' import { SaveIndicator } from './wrappers/SaveIndicator'
import { StatusBadge } from '@/components/atoms/StatusBadge' import { LazyBarChart } from './wrappers/LazyBarChart'
import { DataCard } from '@/components/molecules/DataCard' import { LazyLineChart } from './wrappers/LazyLineChart'
import { SearchInput } from '@/components/molecules/SearchInput' import { LazyD3BarChart } from './wrappers/LazyD3BarChart'
import { ActionBar } from '@/components/molecules/ActionBar' import { SeedDataManager } from './wrappers/SeedDataManager'
import { StorageSettings } from './wrappers/StorageSettings'
import jsonComponentsRegistry from '../../../json-components-registry.json'
import { import {
ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass, ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass,
Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed, Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed,
@@ -38,6 +40,46 @@ export interface UIComponentRegistry {
[key: string]: ComponentType<any> [key: string]: ComponentType<any>
} }
interface JsonRegistryEntry {
name?: string
type?: string
export?: string
source?: string
}
interface JsonComponentRegistry {
components?: JsonRegistryEntry[]
}
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
const buildRegistryFromNames = (
names: string[],
components: Record<string, ComponentType<any>>
): UIComponentRegistry => {
return names.reduce<UIComponentRegistry>((registry, name) => {
const component = components[name]
if (component) {
registry[name] = component
}
return registry
}, {})
}
const jsonRegistryEntries = jsonRegistry.components ?? []
const atomRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'atoms')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.filter((name): name is string => Boolean(name))
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 = { export const primitiveComponents: UIComponentRegistry = {
div: 'div' as any, div: 'div' as any,
span: 'span' as any, span: 'span' as any,
@@ -70,7 +112,7 @@ export const shadcnComponents: UIComponentRegistry = {
CardFooter, CardFooter,
Badge, Badge,
Separator, Separator,
Alert, Alert: ShadcnAlert,
AlertDescription, AlertDescription,
AlertTitle, AlertTitle,
Switch, Switch,
@@ -82,7 +124,7 @@ export const shadcnComponents: UIComponentRegistry = {
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
Table, Table: ShadcnTable,
TableBody, TableBody,
TableCell, TableCell,
TableHead, TableHead,
@@ -98,23 +140,35 @@ export const shadcnComponents: UIComponentRegistry = {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
Skeleton, Skeleton: ShadcnSkeleton,
Progress, Progress,
Avatar, Avatar: ShadcnAvatar,
AvatarFallback, AvatarFallback,
AvatarImage, AvatarImage,
} }
export const customComponents: UIComponentRegistry = { export const atomComponents: UIComponentRegistry = buildRegistryFromNames(
Heading, atomRegistryNames,
Text, AtomComponents as Record<string, ComponentType<any>>
List: ListComponent, )
Grid,
StatusBadge, export const moleculeComponents: UIComponentRegistry = buildRegistryFromNames(
DataCard, moleculeRegistryNames,
SearchInput, MoleculeComponents as Record<string, ComponentType<any>>
ActionBar, )
}
export const wrapperComponents: UIComponentRegistry = buildRegistryFromNames(
wrapperRegistryNames,
{
Breadcrumb,
SaveIndicator,
LazyBarChart,
LazyLineChart,
LazyD3BarChart,
SeedDataManager,
StorageSettings,
} as Record<string, ComponentType<any>>
)
export const iconComponents: UIComponentRegistry = { export const iconComponents: UIComponentRegistry = {
ArrowLeft, ArrowLeft,
@@ -160,7 +214,9 @@ export const iconComponents: UIComponentRegistry = {
export const uiComponentRegistry: UIComponentRegistry = { export const uiComponentRegistry: UIComponentRegistry = {
...primitiveComponents, ...primitiveComponents,
...shadcnComponents, ...shadcnComponents,
...customComponents, ...atomComponents,
...moleculeComponents,
...wrapperComponents,
...iconComponents, ...iconComponents,
} }

View File

@@ -1,157 +0,0 @@
import { ComponentType } from '@/types/json-ui'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Heading } from '@/components/atoms/Heading'
import { Text } from '@/components/atoms/Text'
import { TextArea } from '@/components/atoms/TextArea'
import { List } from '@/components/atoms/List'
import { Grid } from '@/components/atoms/Grid'
import { Stack } from '@/components/atoms/Stack'
import { Flex } from '@/components/atoms/Flex'
import { Container } from '@/components/atoms/Container'
import { Link } from '@/components/atoms/Link'
import { Image } from '@/components/atoms/Image'
import { Avatar } from '@/components/atoms/Avatar'
import { Code } from '@/components/atoms/Code'
import { Tag } from '@/components/atoms/Tag'
import { Spinner } from '@/components/atoms/Spinner'
import { Skeleton } from '@/components/atoms/Skeleton'
import { Slider } from '@/components/atoms/Slider'
import { NumberInput } from '@/components/atoms/NumberInput'
import { Radio } from '@/components/atoms/Radio'
import { Alert } from '@/components/atoms/Alert'
import { InfoBox } from '@/components/atoms/InfoBox'
import { EmptyState } from '@/components/atoms/EmptyState'
import { Table } from '@/components/atoms/Table'
import { KeyValue } from '@/components/atoms/KeyValue'
import { StatCard } from '@/components/atoms/StatCard'
import { StatusBadge } from '@/components/atoms/StatusBadge'
import { DataCard } from '@/components/molecules/DataCard'
import { SearchInput } from '@/components/molecules/SearchInput'
import { ActionBar } from '@/components/molecules/ActionBar'
import { AppBranding } from '@/components/molecules/AppBranding'
import { LabelWithBadge } from '@/components/molecules/LabelWithBadge'
import { EmptyEditorState } from '@/components/molecules/EmptyEditorState'
import { LoadingFallback } from '@/components/molecules/LoadingFallback'
import { LoadingState } from '@/components/molecules/LoadingState'
import { NavigationGroupHeader } from '@/components/molecules/NavigationGroupHeader'
export const componentRegistry: Record<ComponentType, any> = {
'div': 'div',
'section': 'section',
'article': 'article',
'header': 'header',
'footer': 'footer',
'main': 'main',
'Button': Button,
'Card': Card,
'Input': Input,
'TextArea': TextArea,
'Select': Select,
'Checkbox': Checkbox,
'Radio': Radio,
'Switch': Switch,
'Slider': Slider,
'NumberInput': NumberInput,
'Badge': Badge,
'Tag': Tag,
'Progress': Progress,
'Separator': Separator,
'Tabs': Tabs,
'Dialog': 'div',
'Text': Text,
'Heading': Heading,
'Label': Label,
'Link': Link,
'Image': Image,
'Avatar': Avatar,
'Code': Code,
'Spinner': Spinner,
'Skeleton': Skeleton,
'List': List,
'Grid': Grid,
'Stack': Stack,
'Flex': Flex,
'Container': Container,
'Alert': Alert,
'InfoBox': InfoBox,
'EmptyState': EmptyState,
'StatusBadge': StatusBadge,
'Table': Table,
'KeyValue': KeyValue,
'StatCard': StatCard,
'DataCard': DataCard,
'SearchInput': SearchInput,
'ActionBar': ActionBar,
'AppBranding': AppBranding,
'LabelWithBadge': LabelWithBadge,
'EmptyEditorState': EmptyEditorState,
'LoadingFallback': LoadingFallback,
'LoadingState': LoadingState,
'NavigationGroupHeader': NavigationGroupHeader,
}
export const cardSubComponents = {
'CardHeader': CardHeader,
'CardTitle': CardTitle,
'CardDescription': CardDescription,
'CardContent': CardContent,
'CardFooter': CardFooter,
}
export const tabsSubComponents = {
'TabsContent': TabsContent,
'TabsList': TabsList,
'TabsTrigger': TabsTrigger,
}
export const customComponents = {
'StatusBadge': StatusBadge,
'DataCard': DataCard,
'SearchInput': SearchInput,
'ActionBar': ActionBar,
'StatCard': StatCard,
'KeyValue': KeyValue,
'Table': Table,
'Alert': Alert,
'InfoBox': InfoBox,
'EmptyState': EmptyState,
'AppBranding': AppBranding,
'LabelWithBadge': LabelWithBadge,
'EmptyEditorState': EmptyEditorState,
'LoadingFallback': LoadingFallback,
'LoadingState': LoadingState,
'NavigationGroupHeader': NavigationGroupHeader,
}
export function getComponent(type: ComponentType | string): any {
if (type in componentRegistry) {
return componentRegistry[type as ComponentType]
}
if (type in cardSubComponents) {
return cardSubComponents[type as keyof typeof cardSubComponents]
}
if (type in tabsSubComponents) {
return tabsSubComponents[type as keyof typeof tabsSubComponents]
}
if (type in customComponents) {
return customComponents[type as keyof typeof customComponents]
}
return 'div'
}
export const getUIComponent = getComponent

View File

@@ -1,51 +1,57 @@
import { createElement, useMemo } from 'react' import { createElement, useMemo, Fragment } from 'react'
import { UIComponent, Binding, ComponentRendererProps } from '@/types/json-ui' import { UIComponent, Binding, ComponentRendererProps } from '@/types/json-ui'
import { getUIComponent } from './component-registry' import { getUIComponent } from './component-registry'
import { resolveDataBinding, evaluateCondition } from './utils'
function resolveBinding(binding: Binding, data: Record<string, unknown>): unknown { function resolveBinding(binding: Binding, data: Record<string, unknown>): unknown {
let value: unknown = data[binding.source] return resolveDataBinding(binding, data)
if (binding.path) {
const keys = binding.path.split('.')
for (const key of keys) {
if (value && typeof value === 'object') {
value = (value as Record<string, unknown>)[key]
} else {
value = undefined
break
}
}
}
if (binding.transform) {
value = binding.transform(value)
}
return value
} }
export function ComponentRenderer({ component, data, onEvent }: ComponentRendererProps) { export function ComponentRenderer({ component, data, context = {}, onEvent }: ComponentRendererProps) {
const mergedData = useMemo(() => ({ ...data, ...context }), [data, context])
const resolvedProps = useMemo(() => { const resolvedProps = useMemo(() => {
const resolved: Record<string, unknown> = { ...component.props } const resolved: Record<string, unknown> = { ...component.props }
if (component.bindings) { if (component.bindings) {
Object.entries(component.bindings).forEach(([propName, binding]) => { Object.entries(component.bindings).forEach(([propName, binding]) => {
resolved[propName] = resolveBinding(binding, data) 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) { if (component.events && onEvent) {
component.events.forEach(handler => { component.events.forEach(handler => {
resolved[`on${handler.event.charAt(0).toUpperCase()}${handler.event.slice(1)}`] = (e: unknown) => { resolved[`on${handler.event.charAt(0).toUpperCase()}${handler.event.slice(1)}`] = (e: unknown) => {
if (!handler.condition || handler.condition(data)) { const conditionMet = !handler.condition
onEvent(component.id, handler.event, e) || (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 return resolved
}, [component, data, onEvent]) }, [component, mergedData, onEvent])
const Component = getUIComponent(component.type) const Component = getUIComponent(component.type)
@@ -54,21 +60,128 @@ export function ComponentRenderer({ component, data, onEvent }: ComponentRendere
return null 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) { if (component.condition) {
const conditionValue = resolveBinding(component.condition, data) const conditionValue = resolveBinding(component.condition, mergedData)
if (!conditionValue) { if (!conditionValue) {
return null return null
} }
} }
const children = component.children?.map((child, index) => ( return createElement(Component, resolvedProps, renderChildren(component.children, context))
<ComponentRenderer
key={child.id || index}
component={child}
data={data}
onEvent={onEvent}
/>
))
return createElement(Component, resolvedProps, children)
} }

View File

@@ -1,5 +1,6 @@
export { JSONUIRenderer, JSONFormRenderer } from './renderer' export { JSONUIRenderer, JSONFormRenderer } from './renderer'
export { getUIComponent, uiComponentRegistry, registerComponent } from './component-registry' export { getUIComponent, uiComponentRegistry, registerComponent } from './component-registry'
export * from './hooks'
export * from './schema' export * from './schema'
export * from './utils' export * from './utils'
export * from './validator' export * from './validator'

View File

@@ -20,17 +20,10 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
const { executeActions } = useActionExecutor(context) const { executeActions } = useActionExecutor(context)
const handleEvent = useCallback((componentId: string, event: string, eventData: any) => { const handleEvent = useCallback((_componentId: string, handler: { actions: any[] }, eventData: any) => {
const component = findComponentById(schema.components, componentId) if (!handler?.actions?.length) return
if (!component) return
const handler = component.events?.find(h => h.event === event)
if (!handler) return
if (handler.condition && !handler.condition(data)) return
executeActions(handler.actions, eventData) executeActions(handler.actions, eventData)
}, [schema.components, data, executeActions]) }, [executeActions])
return ( return (
<div className="h-full w-full"> <div className="h-full w-full">
@@ -45,14 +38,3 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
</div> </div>
) )
} }
function findComponentById(components: any[], id: string): any {
for (const component of components) {
if (component.id === id) return component
if (component.children) {
const found = findComponentById(component.children, id)
if (found) return found
}
}
return null
}

View File

@@ -1,7 +1,7 @@
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import type { EventHandler, JSONFormRendererProps, JSONUIRendererProps, UIComponent } from './types' import type { Action, EventHandler, JSONFormRendererProps, JSONUIRendererProps, UIComponent } from './types'
import { getUIComponent } from './component-registry' import { getUIComponent } from './component-registry'
import { resolveDataBinding, evaluateCondition, mergeClassNames } from './utils' import { resolveDataBinding, evaluateCondition } from './utils'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export function JSONUIRenderer({ export function JSONUIRenderer({
@@ -10,110 +10,278 @@ export function JSONUIRenderer({
onAction, onAction,
context = {} context = {}
}: JSONUIRendererProps) { }: JSONUIRendererProps) {
const renderChildren = (
children: UIComponent[] | string | undefined,
renderContext: Record<string, unknown>
) => {
if (!children) return null
if (component.conditional) { if (typeof children === 'string') {
const conditionMet = evaluateCondition(component.conditional.if, { ...dataMap, ...context }) return children
if (!conditionMet) {
return component.conditional.else ? (
<JSONUIRenderer
component={component.conditional.else as UIComponent}
dataMap={dataMap}
onAction={onAction}
context={context}
/>
) : null
}
}
if (component.loop) {
const items = resolveDataBinding(component.loop.source, dataMap) || []
return (
<>
{items.map((item: any, index: number) => {
const loopContext = {
...context,
[component.loop!.itemVar]: item,
...(component.loop!.indexVar ? { [component.loop!.indexVar]: index } : {}),
}
return (
<JSONUIRenderer
key={`${component.id}-${index}`}
component={component}
dataMap={dataMap}
onAction={onAction}
context={loopContext}
/>
)}
})}
</>
)
}
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.dataBinding) {
const boundData = resolveDataBinding(component.dataBinding, dataMap)
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 }
}
const renderChildren = () => {
if (!component.children) return null
if (typeof component.children === 'string') {
return component.children
} }
return component.children.map((child, index) => ( return children.map((child, index) => (
<JSONUIRenderer <React.Fragment key={child.id || `child-${index}`}>
key={child.id || `child-${index}`} {renderNode(child, renderContext)}
component={child} </React.Fragment>
dataMap={dataMap}
onAction={onAction}
context={context}
/>
)) ))
} }
if (typeof Component === 'string') { const renderNode = (
return React.createElement(Component, props, renderChildren()) node: UIComponent | string,
renderContext: Record<string, unknown>
) => {
if (typeof node === 'string') {
return node
}
return (
<JSONUIRenderer
component={node}
dataMap={dataMap}
onAction={onAction}
context={renderContext}
/>
)
} }
return ( const renderBranch = (
<Component {...props}> branch: UIComponent | (UIComponent | string)[] | string | undefined,
{renderChildren()} renderContext: Record<string, unknown>
</Component> ) => {
) if (branch === undefined) return null
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 renderNode(branch, renderContext)
}
const renderConditionalBranch = (
branch: UIComponent | (UIComponent | string)[] | string | undefined,
renderContext: Record<string, unknown>
) => {
return renderBranch(branch, renderContext)
}
const normalizeEventName = (eventName: string) =>
eventName.startsWith('on') && eventName.length > 2
? `${eventName.charAt(2).toLowerCase()}${eventName.slice(3)}`
: eventName
const getEventPropName = (eventName: string) =>
eventName.startsWith('on')
? eventName
: `on${eventName.charAt(0).toUpperCase()}${eventName.slice(1)}`
const normalizeLegacyHandler = (eventName: string, handler: any): EventHandler | null => {
if (!handler) return null
if (typeof handler === 'string') {
return {
event: normalizeEventName(eventName),
actions: [{ id: handler, type: 'custom' }],
}
}
if (handler.actions && Array.isArray(handler.actions)) {
return {
event: normalizeEventName(eventName),
actions: handler.actions as Action[],
condition: handler.condition,
}
}
if (typeof handler === 'object' && handler.action) {
return {
event: normalizeEventName(eventName),
actions: [
{
id: handler.action,
type: 'custom',
target: handler.target,
params: handler.params,
},
],
}
}
return null
}
const 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) {
const conditionMet = typeof handler.condition === 'function'
? handler.condition({ ...dataMap, ...renderContext })
: evaluateCondition(handler.condition, { ...dataMap, ...renderContext })
if (!conditionMet) return
}
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]) => {
if (propName === 'children') {
return
}
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)
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 (
<Component {...props}>
{renderChildren(boundChildren, 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 } : {}),
}
const loopChildrenBinding = component.bindings?.children
? resolveDataBinding(component.bindings.children, dataMap, loopContext)
: component.children
let content = renderChildren(loopChildrenBinding, 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>
)
}
return renderWithContext(context)
} }
export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONFormRendererProps) { export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONFormRendererProps) {
@@ -140,12 +308,18 @@ export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONF
type: field.type, type: field.type,
value: formData[field.name] || field.defaultValue || '', value: formData[field.name] || field.defaultValue || '',
}, },
events: { events: [
onChange: { {
action: 'field-change', event: 'change',
params: { field: field.name }, actions: [
{
id: `field-change-${field.name}`,
type: 'set-value',
target: field.name,
},
],
}, },
}, ],
} }
return ( return (
@@ -159,11 +333,13 @@ export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONF
<JSONUIRenderer <JSONUIRenderer
component={fieldComponent} component={fieldComponent}
dataMap={{}} dataMap={{}}
onAction={(handler, event) => { onAction={(actions, event) => {
if (handler.action === 'field-change') { actions.forEach((action) => {
const targetValue = (event as { target?: { value?: unknown } } | undefined)?.target?.value if (action.type === 'set-value' && action.target === field.name) {
handleFieldChange(field.name, targetValue) const targetValue = (event as { target?: { value?: unknown } } | undefined)?.target?.value
} handleFieldChange(field.name, targetValue)
}
})
}} }}
/> />
</div> </div>

View File

@@ -15,10 +15,37 @@ export const DataBindingSchema = z.object({
transform: z.string().optional(), transform: z.string().optional(),
}) })
export const EventHandlerSchema = z.object({ export const ActionSchema = z.object({
action: z.string(), id: z.string(),
type: z.enum([
'create',
'update',
'delete',
'navigate',
'show-toast',
'open-dialog',
'close-dialog',
'set-value',
'toggle-value',
'increment',
'decrement',
'custom',
]),
target: z.string().optional(), target: z.string().optional(),
path: z.string().optional(),
value: z.any().optional(),
params: z.record(z.string(), z.any()).optional(), params: z.record(z.string(), z.any()).optional(),
compute: z.any().optional(),
expression: z.string().optional(),
valueTemplate: z.record(z.string(), z.any()).optional(),
message: z.string().optional(),
variant: z.enum(['success', 'error', 'info', 'warning']).optional(),
})
export const EventHandlerSchema = z.object({
event: z.string(),
actions: z.array(ActionSchema),
condition: z.any().optional(),
}) })
export const ConditionalSchema = z.object({ export const ConditionalSchema = z.object({
@@ -33,6 +60,7 @@ export const UIComponentSchema: any = z.object({
props: z.record(z.string(), z.any()).optional(), props: z.record(z.string(), z.any()).optional(),
className: z.string().optional(), className: z.string().optional(),
style: z.record(z.string(), z.any()).optional(), style: z.record(z.string(), z.any()).optional(),
bindings: z.record(z.string(), DataBindingSchema).optional(),
children: z.union([ children: z.union([
z.string(), z.string(),
z.array(z.lazy(() => UIComponentSchema)), z.array(z.lazy(() => UIComponentSchema)),
@@ -41,10 +69,7 @@ export const UIComponentSchema: any = z.object({
z.string(), z.string(),
DataBindingSchema, DataBindingSchema,
]).optional(), ]).optional(),
events: z.record(z.string(), z.union([ events: z.array(EventHandlerSchema).optional(),
z.string(),
EventHandlerSchema,
])).optional(),
conditional: ConditionalSchema.optional(), conditional: ConditionalSchema.optional(),
loop: z.object({ loop: z.object({
source: z.string(), source: z.string(),
@@ -215,6 +240,7 @@ export type DataSourceConfig<T = unknown> =
export type UIValue = z.infer<typeof UIValueSchema> export type UIValue = z.infer<typeof UIValueSchema>
export type DataBinding = z.infer<typeof DataBindingSchema> export type DataBinding = z.infer<typeof DataBindingSchema>
export type Action = z.infer<typeof ActionSchema>
export type EventHandler = z.infer<typeof EventHandlerSchema> export type EventHandler = z.infer<typeof EventHandlerSchema>
export type Conditional = z.infer<typeof ConditionalSchema> export type Conditional = z.infer<typeof ConditionalSchema>
export type UIComponent = z.infer<typeof UIComponentSchema> export type UIComponent = z.infer<typeof UIComponentSchema>

View File

@@ -1,11 +1,11 @@
import type { EventHandler, FormField, UIComponent } from './schema' import type { Action, EventHandler, FormField, UIComponent } from './schema'
export type { EventHandler, FormField, UIComponent } export type { Action, EventHandler, FormField, UIComponent }
export interface JSONUIRendererProps { export interface JSONUIRendererProps {
component: UIComponent component: UIComponent
dataMap?: Record<string, unknown> dataMap?: Record<string, unknown>
onAction?: (handler: EventHandler, event?: unknown) => void onAction?: (actions: Action[], event?: unknown) => void
context?: Record<string, unknown> context?: Record<string, unknown>
} }

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,96 @@
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

@@ -0,0 +1,49 @@
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

@@ -0,0 +1,41 @@
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

@@ -0,0 +1,108 @@
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

@@ -0,0 +1,117 @@
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

@@ -0,0 +1,106 @@
{
"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

@@ -0,0 +1,36 @@
{
"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

@@ -0,0 +1,157 @@
{
"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

@@ -0,0 +1,331 @@
{
"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"
}
}
}
]
}
]
}
]
}

View File

@@ -1,4 +1,4 @@
import { ReactNode } from 'react' import { createElement, type ComponentType, type ReactNode } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Component as ComponentSchema, Layout } from '@/schemas/ui-schema' import { Component as ComponentSchema, Layout } from '@/schemas/ui-schema'
import { useDataBinding, useEventHandlers, useComponentRegistry } from '@/hooks/ui' import { useDataBinding, useEventHandlers, useComponentRegistry } from '@/hooks/ui'
@@ -7,6 +7,7 @@ interface SchemaRendererProps {
schema: ComponentSchema schema: ComponentSchema
data: Record<string, any> data: Record<string, any>
functions?: Record<string, (...args: any[]) => any> functions?: Record<string, (...args: any[]) => any>
componentRegistry?: Record<string, ComponentType<any>>
} }
interface LayoutRendererProps { interface LayoutRendererProps {
@@ -51,10 +52,10 @@ function LayoutRenderer({ layout, children }: LayoutRendererProps) {
return <div className={getLayoutClasses()}>{children}</div> return <div className={getLayoutClasses()}>{children}</div>
} }
export function SchemaRenderer({ schema, data, functions = {} }: SchemaRendererProps) { export function SchemaRenderer({ schema, data, functions = {}, componentRegistry }: SchemaRendererProps) {
const { resolveCondition, resolveProps, resolveBinding } = useDataBinding({ data }) const { resolveCondition, resolveProps, resolveBinding } = useDataBinding({ data })
const { resolveEvents } = useEventHandlers({ functions }) const { resolveEvents } = useEventHandlers({ functions })
const { getComponent, getIcon } = useComponentRegistry() const { getComponent, getIcon } = useComponentRegistry({ customComponents: componentRegistry })
if (schema.condition && !resolveCondition(schema.condition)) { if (schema.condition && !resolveCondition(schema.condition)) {
return null return null
@@ -76,6 +77,7 @@ export function SchemaRenderer({ schema, data, functions = {} }: SchemaRendererP
schema={{ ...schema, repeat: undefined }} schema={{ ...schema, repeat: undefined }}
data={itemData} data={itemData}
functions={functions} functions={functions}
componentRegistry={componentRegistry}
/> />
) )
})} })}
@@ -83,6 +85,21 @@ export function SchemaRenderer({ schema, data, functions = {} }: SchemaRendererP
) )
} }
const props = resolveProps(schema.props || {})
const events = resolveEvents(schema.events)
const combinedProps = { ...props, ...events }
if (schema.binding) {
const iconName = resolveBinding(schema.binding)
if (typeof iconName === 'string' && schema.type === 'Icon') {
const IconComponent = getComponent(iconName)
if (IconComponent) {
return createElement(IconComponent, combinedProps)
}
return getIcon(iconName, combinedProps)
}
}
const Component = getComponent(schema.type) const Component = getComponent(schema.type)
if (!Component) { if (!Component) {
@@ -96,23 +113,13 @@ export function SchemaRenderer({ schema, data, functions = {} }: SchemaRendererP
) )
} }
const props = resolveProps(schema.props || {})
const events = resolveEvents(schema.events)
const combinedProps = { ...props, ...events }
if (schema.binding) {
const iconName = resolveBinding(schema.binding)
if (iconName && schema.type === 'Icon') {
return getIcon(iconName, props)
}
}
const children = schema.children?.map((child, index) => ( const children = schema.children?.map((child, index) => (
<SchemaRenderer <SchemaRenderer
key={child.id || index} key={child.id || index}
schema={child} schema={child}
data={data} data={data}
functions={functions} functions={functions}
componentRegistry={componentRegistry}
/> />
)) ))
@@ -129,9 +136,10 @@ interface PageRendererProps {
} }
data: Record<string, any> data: Record<string, any>
functions?: Record<string, (...args: any[]) => any> functions?: Record<string, (...args: any[]) => any>
componentRegistry?: Record<string, ComponentType<any>>
} }
export function PageRenderer({ schema, data, functions = {} }: PageRendererProps) { export function PageRenderer({ schema, data, functions = {}, componentRegistry }: PageRendererProps) {
return ( return (
<LayoutRenderer layout={schema.layout}> <LayoutRenderer layout={schema.layout}>
{schema.components.map((component) => ( {schema.components.map((component) => (
@@ -140,6 +148,7 @@ export function PageRenderer({ schema, data, functions = {} }: PageRendererProps
schema={component} schema={component}
data={data} data={data}
functions={functions} functions={functions}
componentRegistry={componentRegistry}
/> />
))} ))}
</LayoutRenderer> </LayoutRenderer>

View File

@@ -0,0 +1,74 @@
{
"id": "registry-validation",
"name": "Registry Validation",
"layout": {
"type": "single"
},
"dataSources": [],
"components": [
{
"id": "root",
"type": "div",
"props": {
"className": "p-6 space-y-6"
},
"children": [
{
"id": "branding",
"type": "AppBranding",
"props": {
"title": "Registry Validation",
"subtitle": "Atoms, molecules, and primitives"
}
},
{
"id": "stack",
"type": "Stack",
"props": {
"direction": "horizontal",
"spacing": "lg",
"className": "items-stretch"
},
"children": [
{
"id": "stat-card",
"type": "StatCard",
"props": {
"title": "Active Users",
"value": "128",
"description": "Past 24 hours"
}
},
{
"id": "flex",
"type": "Flex",
"props": {
"direction": "col",
"gap": "sm",
"className": "rounded-md border p-4"
},
"children": [
{
"id": "flex-title",
"type": "Heading",
"props": {
"level": 4,
"children": "Flex Container"
}
},
{
"id": "flex-text",
"type": "Text",
"props": {
"children": "Ensures primitives and atom components resolve."
}
}
]
}
]
}
]
}
],
"globalActions": []
}

View File

@@ -1,15 +1,14 @@
import { ReactNode } from 'react'
export type ComponentType = export type ComponentType =
| 'div' | 'section' | 'article' | 'header' | 'footer' | 'main' | 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'
| 'Button' | 'Card' | 'CardHeader' | 'CardTitle' | 'CardDescription' | 'CardContent' | 'CardFooter' | 'Button' | 'Card' | 'CardHeader' | 'CardTitle' | 'CardDescription' | 'CardContent' | 'CardFooter'
| 'Input' | 'TextArea' | 'Select' | 'Checkbox' | 'Radio' | 'Switch' | 'Slider' | 'NumberInput' | 'Input' | 'TextArea' | 'Textarea' | 'Select' | 'Checkbox' | 'Radio' | 'Switch' | 'Slider' | 'NumberInput'
| 'Badge' | 'Progress' | 'Separator' | 'Tabs' | 'Dialog' | 'Badge' | 'Progress' | 'Separator' | 'Tabs' | 'TabsContent' | 'TabsList' | 'TabsTrigger' | 'Dialog'
| 'Text' | 'Heading' | 'Label' | 'List' | 'Grid' | 'Stack' | 'Flex' | 'Container' | 'Text' | 'Heading' | 'Label' | 'List' | 'Grid' | 'Stack' | 'Flex' | 'Container'
| 'Link' | 'Image' | 'Avatar' | 'Code' | 'Tag' | 'Spinner' | 'Skeleton' | 'Link' | 'Image' | 'Avatar' | 'Code' | 'Tag' | 'Spinner' | 'Skeleton'
| 'Alert' | 'InfoBox' | 'EmptyState' | 'StatusBadge' | 'Alert' | 'InfoBox' | 'EmptyState' | 'StatusBadge'
| 'Table' | 'KeyValue' | 'StatCard' | 'DataCard' | 'SearchInput' | 'ActionBar' | 'Table' | 'KeyValue' | 'StatCard' | 'DataCard' | 'SearchInput' | 'ActionBar'
| 'AppBranding' | 'LabelWithBadge' | 'EmptyEditorState' | 'LoadingFallback' | 'LoadingState' | 'NavigationGroupHeader' | 'AppBranding' | 'LabelWithBadge' | 'EmptyEditorState' | 'LoadingFallback' | 'LoadingState' | 'NavigationGroupHeader'
| 'Breadcrumb' | 'SaveIndicator' | 'LazyBarChart' | 'LazyD3BarChart' | 'LazyLineChart' | 'SeedDataManager' | 'StorageSettings'
export type ActionType = export type ActionType =
| 'create' | 'update' | 'delete' | 'navigate' | 'create' | 'update' | 'delete' | 'navigate'
@@ -35,8 +34,9 @@ export interface Action {
target?: string target?: string
path?: string path?: string
value?: any value?: any
params?: Record<string, any>
// Legacy: function-based compute // 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") // New: JSON-friendly expression (e.g., "event.target.value", "data.fieldName")
expression?: string expression?: string
// New: JSON template with dynamic values // New: JSON template with dynamic values
@@ -48,23 +48,40 @@ export interface Action {
export interface Binding { export interface Binding {
source: string source: string
path?: string path?: string
transform?: (value: any) => any transform?: string | ((value: any) => any)
} }
export interface EventHandler { export interface EventHandler {
event: string event: string
actions: Action[] 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 { export interface UIComponent {
id: string id: string
type: ComponentType type: ComponentType
props?: Record<string, any> props?: Record<string, any>
className?: string
style?: Record<string, any>
bindings?: Record<string, Binding> bindings?: Record<string, Binding>
dataBinding?: string | Binding
events?: EventHandler[] events?: EventHandler[]
children?: UIComponent[] children?: UIComponent[] | string
condition?: Binding condition?: Binding
conditional?: Conditional
loop?: Loop
} }
export interface Layout { export interface Layout {
@@ -98,7 +115,8 @@ export interface JSONUIContext {
export interface ComponentRendererProps { export interface ComponentRendererProps {
component: UIComponent component: UIComponent
data: Record<string, unknown> data: Record<string, unknown>
onEvent?: (componentId: string, event: string, eventData: unknown) => void context?: Record<string, unknown>
onEvent?: (componentId: string, handler: EventHandler, eventData: unknown) => void
} }
export type ComponentSchema = UIComponent export type ComponentSchema = UIComponent