mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 21:54:56 +00:00
Compare commits
50 Commits
codex/iden
...
codex/crea
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a989b38db | |||
| 9675e1f071 | |||
| 813c36b953 | |||
| 377a95da80 | |||
| b232c220df | |||
| 01921d4b80 | |||
| 19a26156ad | |||
| 5652d0222d | |||
| e2f128a947 | |||
| 717871c673 | |||
| 53a40aa808 | |||
| f7d7b9f478 | |||
| 17ff0eaaea | |||
| 17c91ee91b | |||
| 94018c0e3c | |||
| 24b0498aa2 | |||
| c6dc49488d | |||
| 88e028b54e | |||
| f19df2822d | |||
| 535f712383 | |||
| 1bb40e399b | |||
| 63db390cf7 | |||
| 55c81cedfa | |||
| 3fac67c907 | |||
| 1be31c49f9 | |||
| cc0cab04dc | |||
| 8864436425 | |||
| 4a07737c6a | |||
| 463201d758 | |||
| 7cd15ca7ba | |||
| 560a75cd69 | |||
| 17f4d567c9 | |||
| 320e8a4c2c | |||
| 1065d62d65 | |||
| 671a99d30b | |||
| e033b032a1 | |||
| 81c6ada8c1 | |||
| 9a28805791 | |||
| f3cf2883d3 | |||
| 925adc9712 | |||
| 5d5968d87b | |||
| 9cd0ed818a | |||
| 68f0dcbfbd | |||
| 6700705842 | |||
| 530d7d96da | |||
| 4ee346c69d | |||
| 902253a9be | |||
| 1e0cdf034c | |||
| 5c64833a3f | |||
| 104f9461bc |
@@ -46,7 +46,7 @@ Replaced all imports of `@github/spark/hooks` with the local `@/hooks/use-kv` im
|
||||
|
||||
### Config Files
|
||||
- `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
|
||||
All instances of:
|
||||
|
||||
@@ -27,7 +27,7 @@ Successfully implemented a comprehensive JSON-driven UI system that allows build
|
||||
- Array looping for lists
|
||||
- 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)
|
||||
- `useJSONDataSources`: Multiple data sources orchestration
|
||||
- `useJSONActions`: Action registration and execution
|
||||
|
||||
@@ -275,6 +275,12 @@ registerComponent('MyCustom', MyCustomComponent)
|
||||
|
||||
### 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.
|
||||
|
||||
### Add Custom Actions
|
||||
@@ -296,7 +302,7 @@ const handleAction = (handler: EventHandler, event?: any) => {
|
||||
- **Schema Definitions**: `/src/lib/json-ui/schema.ts`
|
||||
- **Component Registry**: `/src/lib/json-ui/component-registry.ts`
|
||||
- **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`
|
||||
- **Examples**: `/src/config/ui-examples/`
|
||||
- **Demo Page**: `/src/components/JSONUIShowcase.tsx`
|
||||
|
||||
@@ -645,6 +645,19 @@ CodeForge is a comprehensive low-code development platform for building producti
|
||||
- [ ] Migration guides
|
||||
- [ ] 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
|
||||
- [ ] Regular dependency updates
|
||||
- [ ] Security audit with npm audit
|
||||
|
||||
@@ -847,7 +847,7 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Navigation breadcrumb trail",
|
||||
"status": "json-compatible",
|
||||
"source": "molecules",
|
||||
"source": "json-ui-wrappers",
|
||||
"jsonCompatible": true
|
||||
},
|
||||
{
|
||||
@@ -1257,7 +1257,7 @@
|
||||
"canHaveChildren": true,
|
||||
"description": "LazyBarChart component",
|
||||
"status": "json-compatible",
|
||||
"source": "molecules",
|
||||
"source": "json-ui-wrappers",
|
||||
"jsonCompatible": true
|
||||
},
|
||||
{
|
||||
@@ -1267,7 +1267,7 @@
|
||||
"canHaveChildren": true,
|
||||
"description": "LazyD3BarChart component",
|
||||
"status": "json-compatible",
|
||||
"source": "molecules",
|
||||
"source": "json-ui-wrappers",
|
||||
"jsonCompatible": true
|
||||
},
|
||||
{
|
||||
@@ -1277,7 +1277,7 @@
|
||||
"canHaveChildren": true,
|
||||
"description": "LazyLineChart component",
|
||||
"status": "json-compatible",
|
||||
"source": "molecules",
|
||||
"source": "json-ui-wrappers",
|
||||
"jsonCompatible": true
|
||||
},
|
||||
{
|
||||
@@ -1319,11 +1319,11 @@
|
||||
{
|
||||
"type": "SeedDataManager",
|
||||
"name": "SeedDataManager",
|
||||
"category": "data",
|
||||
"category": "custom",
|
||||
"canHaveChildren": true,
|
||||
"description": "SeedDataManager component",
|
||||
"status": "json-compatible",
|
||||
"source": "molecules",
|
||||
"source": "json-ui-wrappers",
|
||||
"jsonCompatible": true
|
||||
},
|
||||
{
|
||||
@@ -1826,11 +1826,11 @@
|
||||
{
|
||||
"type": "SaveIndicator",
|
||||
"name": "SaveIndicator",
|
||||
"category": "custom",
|
||||
"category": "feedback",
|
||||
"canHaveChildren": true,
|
||||
"description": "SaveIndicator component",
|
||||
"status": "json-compatible",
|
||||
"source": "molecules",
|
||||
"source": "json-ui-wrappers",
|
||||
"jsonCompatible": true
|
||||
},
|
||||
{
|
||||
@@ -2000,7 +2000,7 @@
|
||||
"canHaveChildren": true,
|
||||
"description": "StorageSettings component",
|
||||
"status": "json-compatible",
|
||||
"source": "molecules",
|
||||
"source": "json-ui-wrappers",
|
||||
"jsonCompatible": true
|
||||
},
|
||||
{
|
||||
|
||||
82
scripts/validate-json-ui-registry.cjs
Normal file
82
scripts/validate-json-ui-registry.cjs
Normal 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.')
|
||||
@@ -3,45 +3,50 @@ import { toast } from 'sonner'
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
import { useState } from 'react'
|
||||
import { buildDemoPageSchema, demoCopy, demoInitialTodos } from '@/components/json-demo/schema'
|
||||
import { Action } from '@/lib/json-ui/schema'
|
||||
|
||||
export function JSONDemoPage() {
|
||||
const [todos, setTodos] = useKV('json-demo-todos', demoInitialTodos)
|
||||
const [newTodo, setNewTodo] = useState('')
|
||||
|
||||
const handleAction = (handler: any, event?: any) => {
|
||||
switch (handler.action) {
|
||||
case 'add-todo':
|
||||
if (newTodo.trim()) {
|
||||
setTodos((current: any) => [
|
||||
...current,
|
||||
{ id: Date.now(), text: newTodo, completed: false },
|
||||
])
|
||||
setNewTodo('')
|
||||
toast.success(demoCopy.toastAdded)
|
||||
}
|
||||
break
|
||||
const handleAction = (actions: Action[], event?: any) => {
|
||||
actions.forEach((action) => {
|
||||
const actionKey = action.type === 'custom' ? action.id : action.type
|
||||
|
||||
case 'toggle-todo':
|
||||
setTodos((current: any) =>
|
||||
current.map((todo: any) =>
|
||||
todo.id === handler.params?.id
|
||||
? { ...todo, completed: !todo.completed }
|
||||
: todo
|
||||
switch (actionKey) {
|
||||
case 'add-todo':
|
||||
if (newTodo.trim()) {
|
||||
setTodos((current: any) => [
|
||||
...current,
|
||||
{ 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':
|
||||
setTodos((current: any) =>
|
||||
current.filter((todo: any) => todo.id !== handler.params?.id)
|
||||
)
|
||||
toast.success(demoCopy.toastDeleted)
|
||||
break
|
||||
case 'delete-todo':
|
||||
setTodos((current: any) =>
|
||||
current.filter((todo: any) => todo.id !== action.params?.id)
|
||||
)
|
||||
toast.success(demoCopy.toastDeleted)
|
||||
break
|
||||
|
||||
case 'update-input':
|
||||
setNewTodo(event.target.value)
|
||||
break
|
||||
}
|
||||
case 'update-input':
|
||||
setNewTodo(event.target.value)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const pageSchema = buildDemoPageSchema(todos, newTodo)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
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'
|
||||
|
||||
interface JSONUIPageProps {
|
||||
@@ -34,88 +34,91 @@ export function JSONUIPage({ jsonConfig }: JSONUIPageProps) {
|
||||
}))
|
||||
}
|
||||
|
||||
const handleAction = (handler: EventHandler, event?: any) => {
|
||||
console.log('Action triggered:', handler.action, handler.params, event)
|
||||
|
||||
switch (handler.action) {
|
||||
case 'refresh-data':
|
||||
toast.success('Data refreshed')
|
||||
break
|
||||
case 'create-project':
|
||||
toast.info('Create project clicked')
|
||||
break
|
||||
case 'deploy':
|
||||
toast.info('Deploy clicked')
|
||||
break
|
||||
case 'view-logs':
|
||||
toast.info('View logs clicked')
|
||||
break
|
||||
case 'settings':
|
||||
toast.info('Settings clicked')
|
||||
break
|
||||
case 'add-project':
|
||||
toast.info('Add project clicked')
|
||||
break
|
||||
case 'view-project':
|
||||
toast.info(`View project: ${handler.params?.projectId}`)
|
||||
break
|
||||
case 'edit-project':
|
||||
toast.info(`Edit project: ${handler.params?.projectId}`)
|
||||
break
|
||||
case 'delete-project':
|
||||
toast.error(`Delete project: ${handler.params?.projectId}`)
|
||||
break
|
||||
case 'update-field':
|
||||
if (event?.target) {
|
||||
const { name, value } = event.target
|
||||
updateDataField('formData', name, value)
|
||||
}
|
||||
break
|
||||
case 'update-checkbox':
|
||||
if (handler.params?.field) {
|
||||
updateDataField('formData', handler.params.field, event)
|
||||
}
|
||||
break
|
||||
case 'submit-form':
|
||||
toast.success('Form submitted!')
|
||||
console.log('Form data:', dataMap.formData)
|
||||
break
|
||||
case 'cancel-form':
|
||||
toast.info('Form cancelled')
|
||||
break
|
||||
case 'toggle-dark-mode':
|
||||
updateDataField('settings', 'darkMode', event)
|
||||
toast.success(`Dark mode ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'toggle-auto-save':
|
||||
updateDataField('settings', 'autoSave', event)
|
||||
toast.success(`Auto-save ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'toggle-email-notifications':
|
||||
updateDataField('notifications', 'email', event)
|
||||
toast.success(`Email notifications ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'toggle-push-notifications':
|
||||
updateDataField('notifications', 'push', event)
|
||||
toast.success(`Push notifications ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'toggle-2fa':
|
||||
updateDataField('security', 'twoFactor', event)
|
||||
toast.success(`Two-factor auth ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'logout-all-sessions':
|
||||
toast.success('All other sessions logged out')
|
||||
break
|
||||
case 'save-settings':
|
||||
toast.success('Settings saved successfully')
|
||||
console.log('Settings:', dataMap)
|
||||
break
|
||||
case 'reset-settings':
|
||||
toast.info('Settings reset to defaults')
|
||||
break
|
||||
default:
|
||||
console.log('Unhandled action:', handler.action)
|
||||
}
|
||||
const handleAction = (actions: Action[], event?: any) => {
|
||||
actions.forEach((action) => {
|
||||
const actionKey = action.type === 'custom' ? action.id : action.type
|
||||
console.log('Action triggered:', actionKey, action.params, event)
|
||||
|
||||
switch (actionKey) {
|
||||
case 'refresh-data':
|
||||
toast.success('Data refreshed')
|
||||
break
|
||||
case 'create-project':
|
||||
toast.info('Create project clicked')
|
||||
break
|
||||
case 'deploy':
|
||||
toast.info('Deploy clicked')
|
||||
break
|
||||
case 'view-logs':
|
||||
toast.info('View logs clicked')
|
||||
break
|
||||
case 'settings':
|
||||
toast.info('Settings clicked')
|
||||
break
|
||||
case 'add-project':
|
||||
toast.info('Add project clicked')
|
||||
break
|
||||
case 'view-project':
|
||||
toast.info(`View project: ${action.params?.projectId}`)
|
||||
break
|
||||
case 'edit-project':
|
||||
toast.info(`Edit project: ${action.params?.projectId}`)
|
||||
break
|
||||
case 'delete-project':
|
||||
toast.error(`Delete project: ${action.params?.projectId}`)
|
||||
break
|
||||
case 'update-field':
|
||||
if (event?.target) {
|
||||
const { name, value } = event.target
|
||||
updateDataField('formData', name, value)
|
||||
}
|
||||
break
|
||||
case 'update-checkbox':
|
||||
if (action.params?.field) {
|
||||
updateDataField('formData', action.params.field, event)
|
||||
}
|
||||
break
|
||||
case 'submit-form':
|
||||
toast.success('Form submitted!')
|
||||
console.log('Form data:', dataMap.formData)
|
||||
break
|
||||
case 'cancel-form':
|
||||
toast.info('Form cancelled')
|
||||
break
|
||||
case 'toggle-dark-mode':
|
||||
updateDataField('settings', 'darkMode', event)
|
||||
toast.success(`Dark mode ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'toggle-auto-save':
|
||||
updateDataField('settings', 'autoSave', event)
|
||||
toast.success(`Auto-save ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'toggle-email-notifications':
|
||||
updateDataField('notifications', 'email', event)
|
||||
toast.success(`Email notifications ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'toggle-push-notifications':
|
||||
updateDataField('notifications', 'push', event)
|
||||
toast.success(`Push notifications ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'toggle-2fa':
|
||||
updateDataField('security', 'twoFactor', event)
|
||||
toast.success(`Two-factor auth ${event ? 'enabled' : 'disabled'}`)
|
||||
break
|
||||
case 'logout-all-sessions':
|
||||
toast.success('All other sessions logged out')
|
||||
break
|
||||
case 'save-settings':
|
||||
toast.success('Settings saved successfully')
|
||||
console.log('Settings:', dataMap)
|
||||
break
|
||||
case 'reset-settings':
|
||||
toast.info('Settings reset to defaults')
|
||||
break
|
||||
default:
|
||||
console.log('Unhandled action:', actionKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (!jsonConfig.layout) {
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useSeedTemplates } from '@/hooks/data/use-seed-templates'
|
||||
import { Copy, Download } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import templateUi from '@/config/template-ui.json'
|
||||
import { useTemplateExplorerActions } from '@/hooks/use-template-explorer-actions'
|
||||
|
||||
const ui = templateUi.explorer
|
||||
|
||||
@@ -185,49 +185,11 @@ export function TemplateExplorer() {
|
||||
|
||||
const currentTemplate = templates.find(t => t.id === selectedTemplate)
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
toast.success(ui.toasts.copySuccess)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
const {
|
||||
copyToClipboard,
|
||||
downloadJSON,
|
||||
exportCurrentData
|
||||
} = useTemplateExplorerActions(currentTemplate)
|
||||
|
||||
if (!currentTemplate) return null
|
||||
|
||||
|
||||
@@ -2,25 +2,7 @@ import { ClockCounterClockwise, X } from '@phosphor-icons/react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CommandGroup, CommandItem, CommandSeparator } from '@/components/ui/command'
|
||||
|
||||
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[]
|
||||
}
|
||||
import type { SearchHistoryItem, SearchResult } from './types'
|
||||
|
||||
interface RecentSearchesProps {
|
||||
recentSearches: Array<{ historyItem: SearchHistoryItem; result?: SearchResult }>
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { CommandGroup, CommandItem, CommandSeparator } from '@/components/ui/command'
|
||||
|
||||
interface SearchResult {
|
||||
id: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
category: string
|
||||
icon: React.ReactNode
|
||||
action: () => void
|
||||
tags?: string[]
|
||||
}
|
||||
import type { SearchResult } from './types'
|
||||
|
||||
interface SearchResultsProps {
|
||||
groupedResults: Record<string, SearchResult[]>
|
||||
|
||||
18
src/components/global-search/types.ts
Normal file
18
src/components/global-search/types.ts
Normal 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
|
||||
}
|
||||
@@ -33,25 +33,7 @@ import {
|
||||
Workflow,
|
||||
} from '@/types/project'
|
||||
import navigationData from '@/data/global-search.json'
|
||||
|
||||
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
|
||||
}
|
||||
import type { SearchHistoryItem, SearchResult } from './types'
|
||||
|
||||
const navigationIconMap = {
|
||||
BookOpen,
|
||||
|
||||
@@ -49,12 +49,18 @@ const buildTodoItem = (todo: TodoItem, copy: DemoCopy): UIComponent => ({
|
||||
props: {
|
||||
checked: todo.completed,
|
||||
},
|
||||
events: {
|
||||
onCheckedChange: {
|
||||
action: 'toggle-todo',
|
||||
params: { id: todo.id },
|
||||
events: [
|
||||
{
|
||||
event: 'checkedChange',
|
||||
actions: [
|
||||
{
|
||||
id: 'toggle-todo',
|
||||
type: 'custom',
|
||||
params: { id: todo.id },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: `text-${todo.id}`,
|
||||
@@ -72,12 +78,18 @@ const buildTodoItem = (todo: TodoItem, copy: DemoCopy): UIComponent => ({
|
||||
size: 'sm',
|
||||
children: copy.deleteButtonLabel,
|
||||
},
|
||||
events: {
|
||||
onClick: {
|
||||
action: 'delete-todo',
|
||||
params: { id: todo.id },
|
||||
events: [
|
||||
{
|
||||
event: 'click',
|
||||
actions: [
|
||||
{
|
||||
id: 'delete-todo',
|
||||
type: 'custom',
|
||||
params: { id: todo.id },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { BindingEditor } from '@/components/molecules/BindingEditor'
|
||||
import { DataSource, UIComponent } from '@/types/json-ui'
|
||||
import { Link } from '@phosphor-icons/react'
|
||||
import { useComponentBindingDialog } from '@/hooks/use-component-binding-dialog'
|
||||
|
||||
interface ComponentBindingDialogProps {
|
||||
open: boolean
|
||||
@@ -21,18 +21,12 @@ export function ComponentBindingDialog({
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: ComponentBindingDialogProps) {
|
||||
const [editingComponent, setEditingComponent] = useState<UIComponent | null>(component)
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editingComponent) return
|
||||
onSave(editingComponent)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const updateBindings = (bindings: Record<string, any>) => {
|
||||
if (!editingComponent) return
|
||||
setEditingComponent({ ...editingComponent, bindings })
|
||||
}
|
||||
const { editingComponent, handleSave, updateBindings } = useComponentBindingDialog({
|
||||
component,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
})
|
||||
|
||||
if (!editingComponent) return null
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { UIComponent } from '@/types/json-ui'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { ComponentTreeHeader } from '@/components/molecules/component-tree/ComponentTreeHeader'
|
||||
import { ComponentTreeEmptyState } from '@/components/molecules/component-tree/ComponentTreeEmptyState'
|
||||
import { ComponentTreeNodes } from '@/components/molecules/component-tree/ComponentTreeNodes'
|
||||
import { useComponentTreeExpansion } from '@/hooks/use-component-tree-expansion'
|
||||
|
||||
interface ComponentTreeProps {
|
||||
components: UIComponent[]
|
||||
@@ -34,42 +34,8 @@ export function ComponentTree({
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
}: ComponentTreeProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
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
|
||||
})
|
||||
}, [])
|
||||
const { expandedIds, handleExpandAll, handleCollapseAll, toggleExpand } =
|
||||
useComponentTreeExpansion(components)
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields'
|
||||
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 { useDataSourceEditor } from '@/hooks/use-data-source-editor'
|
||||
|
||||
interface DataSourceEditorDialogProps {
|
||||
open: boolean
|
||||
@@ -24,11 +25,15 @@ export function DataSourceEditorDialog({
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: DataSourceEditorDialogProps) {
|
||||
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
|
||||
|
||||
useEffect(() => {
|
||||
setEditingSource(dataSource)
|
||||
}, [dataSource])
|
||||
const {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
} = useDataSourceEditor(dataSource, allDataSources)
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editingSource) return
|
||||
@@ -36,34 +41,8 @@ export function DataSourceEditorDialog({
|
||||
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
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { ComponentSchema as ComponentSchemaType } from '@/types/page-schema'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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',
|
||||
}
|
||||
import { getUIComponent } from '@/lib/json-ui/component-registry'
|
||||
|
||||
interface ComponentRendererProps {
|
||||
schema: ComponentSchemaType
|
||||
@@ -27,7 +9,7 @@ interface ComponentRendererProps {
|
||||
}
|
||||
|
||||
export function ComponentRenderer({ schema, context, onEvent }: ComponentRendererProps) {
|
||||
const Component = COMPONENT_MAP[schema.type]
|
||||
const Component = getUIComponent(schema.type)
|
||||
|
||||
if (!Component) {
|
||||
console.warn(`Component type "${schema.type}" not found`)
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { NextJsConfig } from '@/types/project'
|
||||
import projectSettingsCopy from '@/data/project-settings.json'
|
||||
|
||||
interface NextJsApplicationCardProps {
|
||||
nextjsConfig: NextJsConfig
|
||||
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
|
||||
}
|
||||
import { NextJsConfigSectionProps } from '@/components/project-settings/types'
|
||||
|
||||
export function NextJsApplicationCard({
|
||||
nextjsConfig,
|
||||
onNextjsConfigChange,
|
||||
}: NextJsApplicationCardProps) {
|
||||
}: NextJsConfigSectionProps) {
|
||||
const { application } = projectSettingsCopy.nextjs
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import { NextJsConfig } from '@/types/project'
|
||||
import { NextJsApplicationCard } from '@/components/project-settings/NextJsApplicationCard'
|
||||
import { NextJsFeaturesCard } from '@/components/project-settings/NextJsFeaturesCard'
|
||||
|
||||
interface NextJsConfigTabProps {
|
||||
nextjsConfig: NextJsConfig
|
||||
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
|
||||
}
|
||||
import { NextJsConfigSectionProps } from '@/components/project-settings/types'
|
||||
|
||||
export function NextJsConfigTab({
|
||||
nextjsConfig,
|
||||
onNextjsConfigChange,
|
||||
}: NextJsConfigTabProps) {
|
||||
}: NextJsConfigSectionProps) {
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<NextJsApplicationCard
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { NextJsConfig } from '@/types/project'
|
||||
import projectSettingsCopy from '@/data/project-settings.json'
|
||||
|
||||
interface NextJsFeaturesCardProps {
|
||||
nextjsConfig: NextJsConfig
|
||||
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
|
||||
}
|
||||
import { NextJsConfigSectionProps } from '@/components/project-settings/types'
|
||||
|
||||
export function NextJsFeaturesCard({
|
||||
nextjsConfig,
|
||||
onNextjsConfigChange,
|
||||
}: NextJsFeaturesCardProps) {
|
||||
}: NextJsConfigSectionProps) {
|
||||
const { features } = projectSettingsCopy.nextjs
|
||||
|
||||
return (
|
||||
|
||||
6
src/components/project-settings/types.ts
Normal file
6
src/components/project-settings/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { NextJsConfig } from '@/types/project'
|
||||
|
||||
export type NextJsConfigSectionProps = {
|
||||
nextjsConfig: NextJsConfig
|
||||
onNextjsConfigChange: (config: NextJsConfig | ((current: NextJsConfig) => NextJsConfig)) => void
|
||||
}
|
||||
58
src/hooks/data/use-data-source-editor.ts
Normal file
58
src/hooks/data/use-data-source-editor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
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 { createElement, useMemo } from 'react'
|
||||
import { uiComponentRegistry, iconComponents } from '@/lib/json-ui/component-registry'
|
||||
import * as Icons from '@phosphor-icons/react'
|
||||
|
||||
interface ComponentRegistryOptions {
|
||||
@@ -15,17 +9,7 @@ interface ComponentRegistryOptions {
|
||||
export function useComponentRegistry({ customComponents = {} }: ComponentRegistryOptions = {}) {
|
||||
const registry = useMemo(
|
||||
() => ({
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
Button,
|
||||
Badge,
|
||||
Input,
|
||||
Label,
|
||||
Separator,
|
||||
Progress,
|
||||
...uiComponentRegistry,
|
||||
...customComponents,
|
||||
}),
|
||||
[customComponents]
|
||||
@@ -36,9 +20,13 @@ export function useComponentRegistry({ customComponents = {} }: ComponentRegistr
|
||||
}
|
||||
|
||||
const getIcon = (iconName: string, props?: any): React.ReactElement | null => {
|
||||
const IconComponent = (Icons as any)[iconName]
|
||||
if (!IconComponent) return null
|
||||
return IconComponent({ size: 24, weight: "duotone", ...props })
|
||||
const registryIcon = registry[iconName as keyof typeof registry]
|
||||
const IconComponent =
|
||||
(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 {
|
||||
|
||||
40
src/hooks/use-component-binding-dialog.ts
Normal file
40
src/hooks/use-component-binding-dialog.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
58
src/hooks/use-component-tree-expansion.ts
Normal file
58
src/hooks/use-component-tree-expansion.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
77
src/hooks/use-data-source-editor.ts
Normal file
77
src/hooks/use-data-source-editor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
55
src/hooks/use-template-explorer-actions.ts
Normal file
55
src/hooks/use-template-explorer-actions.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,19 @@ export const componentDefinitions: ComponentDefinition[] = [
|
||||
canHaveChildren: true,
|
||||
defaultProps: { href: '#', children: 'Link' }
|
||||
},
|
||||
{
|
||||
type: 'Breadcrumb',
|
||||
label: 'Breadcrumb',
|
||||
category: 'navigation',
|
||||
icon: 'Path',
|
||||
defaultProps: {
|
||||
items: [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Section', href: '/section' },
|
||||
{ label: 'Current Page' },
|
||||
],
|
||||
},
|
||||
},
|
||||
// Feedback Components
|
||||
{
|
||||
type: 'Alert',
|
||||
@@ -256,6 +269,13 @@ export const componentDefinitions: ComponentDefinition[] = [
|
||||
icon: 'Circle',
|
||||
defaultProps: { status: 'active', children: 'Active' }
|
||||
},
|
||||
{
|
||||
type: 'SaveIndicator',
|
||||
label: 'Save Indicator',
|
||||
category: 'feedback',
|
||||
icon: 'FloppyDisk',
|
||||
defaultProps: { status: 'saved', label: 'Saved' }
|
||||
},
|
||||
// Data Components
|
||||
{
|
||||
type: 'List',
|
||||
@@ -285,6 +305,46 @@ export const componentDefinitions: ComponentDefinition[] = [
|
||||
icon: 'ChartBar',
|
||||
defaultProps: { title: 'Metric', value: '0' }
|
||||
},
|
||||
{
|
||||
type: 'LazyBarChart',
|
||||
label: 'Bar Chart',
|
||||
category: 'data',
|
||||
icon: 'ChartBar',
|
||||
defaultProps: {
|
||||
data: [
|
||||
{ label: 'Jan', value: 30 },
|
||||
{ label: 'Feb', value: 45 },
|
||||
],
|
||||
xKey: 'label',
|
||||
yKey: 'value',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'LazyLineChart',
|
||||
label: 'Line Chart',
|
||||
category: 'data',
|
||||
icon: 'ChartLine',
|
||||
defaultProps: {
|
||||
data: [
|
||||
{ label: 'Jan', value: 10 },
|
||||
{ label: 'Feb', value: 25 },
|
||||
],
|
||||
xKey: 'label',
|
||||
yKey: 'value',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'LazyD3BarChart',
|
||||
label: 'D3 Bar Chart',
|
||||
category: 'data',
|
||||
icon: 'ChartBar',
|
||||
defaultProps: {
|
||||
data: [
|
||||
{ label: 'A', value: 12 },
|
||||
{ label: 'B', value: 18 },
|
||||
],
|
||||
},
|
||||
},
|
||||
// Custom Components
|
||||
{
|
||||
type: 'DataCard',
|
||||
@@ -308,6 +368,23 @@ export const componentDefinitions: ComponentDefinition[] = [
|
||||
canHaveChildren: true,
|
||||
defaultProps: { actions: [] }
|
||||
},
|
||||
{
|
||||
type: 'SeedDataManager',
|
||||
label: 'Seed Data Manager',
|
||||
category: 'custom',
|
||||
icon: 'Database',
|
||||
defaultProps: { isLoaded: false, isLoading: false }
|
||||
},
|
||||
{
|
||||
type: 'StorageSettings',
|
||||
label: 'Storage Settings',
|
||||
category: 'custom',
|
||||
icon: 'Gear',
|
||||
defaultProps: {
|
||||
backend: 'indexeddb',
|
||||
flaskUrl: 'http://localhost:5001',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function getCategoryComponents(category: string): ComponentDefinition[] {
|
||||
|
||||
@@ -109,7 +109,8 @@ Render lists from arrays:
|
||||
{
|
||||
"loop": {
|
||||
"source": "items",
|
||||
"itemVar": "item"
|
||||
"itemVar": "item",
|
||||
"indexVar": "index"
|
||||
},
|
||||
"children": [...]
|
||||
}
|
||||
@@ -129,6 +130,107 @@ Show/hide based on conditions:
|
||||
}
|
||||
```
|
||||
|
||||
## 🧭 Schema Patterns & Examples
|
||||
|
||||
### Conditional Branches
|
||||
|
||||
Conditionals can return a single component, an array of components, or a string payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "admin-greeting",
|
||||
"type": "div",
|
||||
"conditional": {
|
||||
"if": "user.isAdmin",
|
||||
"then": [
|
||||
{ "id": "admin-title", "type": "h2", "children": "Welcome, Admin!" },
|
||||
{ "id": "admin-subtitle", "type": "p", "children": "You have full access." }
|
||||
],
|
||||
"else": "You do not have access."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Loop Templates (itemVar/indexVar)
|
||||
|
||||
Loop containers render once and repeat their children as the template. The `itemVar` and
|
||||
`indexVar` values are available in bindings and expressions inside the loop:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "activity-list",
|
||||
"type": "div",
|
||||
"className": "space-y-2",
|
||||
"loop": {
|
||||
"source": "activities",
|
||||
"itemVar": "activity",
|
||||
"indexVar": "idx"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "activity-row",
|
||||
"type": "div",
|
||||
"children": [
|
||||
{ "id": "activity-index", "type": "span", "dataBinding": "idx" },
|
||||
{ "id": "activity-text", "type": "span", "dataBinding": "activity.text" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Dot-Path Bindings
|
||||
|
||||
Bindings support `foo.bar` access for both `dataBinding` and `bindings`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "profile-name",
|
||||
"type": "p",
|
||||
"dataBinding": "user.profile.fullName"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "profile-avatar",
|
||||
"type": "Avatar",
|
||||
"bindings": {
|
||||
"src": { "source": "user", "path": "profile.avatarUrl" },
|
||||
"alt": { "source": "user.profile.fullName" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Transforms
|
||||
|
||||
Transforms can be applied to bindings for light formatting in JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "user-score",
|
||||
"type": "span",
|
||||
"dataBinding": {
|
||||
"source": "user",
|
||||
"path": "score",
|
||||
"transform": "data ?? 0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "user-initials",
|
||||
"type": "Badge",
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "user.profile.fullName",
|
||||
"transform": "data.split(' ').map(part => part[0]).join('')"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🧩 Available Components
|
||||
|
||||
### Layout
|
||||
@@ -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
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -6,25 +6,27 @@ import { Label } from '@/components/ui/label'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
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 { Checkbox } from '@/components/ui/checkbox'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
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 { 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 { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Heading } from '@/components/atoms/Heading'
|
||||
import { Text } from '@/components/atoms/Text'
|
||||
import { List as ListComponent } from '@/components/atoms/List'
|
||||
import { Grid } from '@/components/atoms/Grid'
|
||||
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 { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import * as AtomComponents from '@/components/atoms'
|
||||
import * as MoleculeComponents from '@/components/molecules'
|
||||
import { Breadcrumb } from './wrappers/Breadcrumb'
|
||||
import { SaveIndicator } from './wrappers/SaveIndicator'
|
||||
import { LazyBarChart } from './wrappers/LazyBarChart'
|
||||
import { LazyLineChart } from './wrappers/LazyLineChart'
|
||||
import { LazyD3BarChart } from './wrappers/LazyD3BarChart'
|
||||
import { SeedDataManager } from './wrappers/SeedDataManager'
|
||||
import { StorageSettings } from './wrappers/StorageSettings'
|
||||
import jsonComponentsRegistry from '../../../json-components-registry.json'
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass,
|
||||
Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed,
|
||||
@@ -38,6 +40,46 @@ export interface UIComponentRegistry {
|
||||
[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 = {
|
||||
div: 'div' as any,
|
||||
span: 'span' as any,
|
||||
@@ -70,7 +112,7 @@ export const shadcnComponents: UIComponentRegistry = {
|
||||
CardFooter,
|
||||
Badge,
|
||||
Separator,
|
||||
Alert,
|
||||
Alert: ShadcnAlert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Switch,
|
||||
@@ -82,7 +124,7 @@ export const shadcnComponents: UIComponentRegistry = {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Table,
|
||||
Table: ShadcnTable,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
@@ -98,23 +140,35 @@ export const shadcnComponents: UIComponentRegistry = {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Skeleton,
|
||||
Skeleton: ShadcnSkeleton,
|
||||
Progress,
|
||||
Avatar,
|
||||
Avatar: ShadcnAvatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
}
|
||||
|
||||
export const customComponents: UIComponentRegistry = {
|
||||
Heading,
|
||||
Text,
|
||||
List: ListComponent,
|
||||
Grid,
|
||||
StatusBadge,
|
||||
DataCard,
|
||||
SearchInput,
|
||||
ActionBar,
|
||||
}
|
||||
export const atomComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
atomRegistryNames,
|
||||
AtomComponents as Record<string, ComponentType<any>>
|
||||
)
|
||||
|
||||
export const moleculeComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
moleculeRegistryNames,
|
||||
MoleculeComponents as Record<string, ComponentType<any>>
|
||||
)
|
||||
|
||||
export const wrapperComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
wrapperRegistryNames,
|
||||
{
|
||||
Breadcrumb,
|
||||
SaveIndicator,
|
||||
LazyBarChart,
|
||||
LazyLineChart,
|
||||
LazyD3BarChart,
|
||||
SeedDataManager,
|
||||
StorageSettings,
|
||||
} as Record<string, ComponentType<any>>
|
||||
)
|
||||
|
||||
export const iconComponents: UIComponentRegistry = {
|
||||
ArrowLeft,
|
||||
@@ -160,7 +214,9 @@ export const iconComponents: UIComponentRegistry = {
|
||||
export const uiComponentRegistry: UIComponentRegistry = {
|
||||
...primitiveComponents,
|
||||
...shadcnComponents,
|
||||
...customComponents,
|
||||
...atomComponents,
|
||||
...moleculeComponents,
|
||||
...wrapperComponents,
|
||||
...iconComponents,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,51 +1,57 @@
|
||||
import { createElement, useMemo } from 'react'
|
||||
import { createElement, useMemo, Fragment } from 'react'
|
||||
import { UIComponent, Binding, ComponentRendererProps } from '@/types/json-ui'
|
||||
import { getUIComponent } from './component-registry'
|
||||
import { resolveDataBinding, evaluateCondition } from './utils'
|
||||
|
||||
function resolveBinding(binding: Binding, data: Record<string, unknown>): unknown {
|
||||
let value: unknown = data[binding.source]
|
||||
|
||||
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
|
||||
return resolveDataBinding(binding, data)
|
||||
}
|
||||
|
||||
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 resolved: Record<string, unknown> = { ...component.props }
|
||||
|
||||
if (component.bindings) {
|
||||
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) {
|
||||
component.events.forEach(handler => {
|
||||
resolved[`on${handler.event.charAt(0).toUpperCase()}${handler.event.slice(1)}`] = (e: unknown) => {
|
||||
if (!handler.condition || handler.condition(data)) {
|
||||
onEvent(component.id, handler.event, e)
|
||||
const conditionMet = !handler.condition
|
||||
|| (typeof handler.condition === 'function'
|
||||
? handler.condition(mergedData as Record<string, any>)
|
||||
: evaluateCondition(handler.condition, mergedData as Record<string, any>))
|
||||
if (conditionMet) {
|
||||
onEvent(component.id, handler, e)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (component.className) {
|
||||
resolved.className = resolved.className
|
||||
? `${resolved.className} ${component.className}`
|
||||
: component.className
|
||||
}
|
||||
|
||||
if (component.style) {
|
||||
resolved.style = { ...(resolved.style as Record<string, unknown>), ...component.style }
|
||||
}
|
||||
|
||||
return resolved
|
||||
}, [component, data, onEvent])
|
||||
}, [component, mergedData, onEvent])
|
||||
|
||||
const Component = getUIComponent(component.type)
|
||||
|
||||
@@ -54,21 +60,128 @@ export function ComponentRenderer({ component, data, onEvent }: ComponentRendere
|
||||
return null
|
||||
}
|
||||
|
||||
const renderChildren = (
|
||||
children: UIComponent[] | string | undefined,
|
||||
renderContext: Record<string, unknown>
|
||||
) => {
|
||||
if (!children) return null
|
||||
if (typeof children === 'string') {
|
||||
return children
|
||||
}
|
||||
|
||||
return children.map((child, index) => (
|
||||
<Fragment key={typeof child === 'string' ? `text-${index}` : child.id || index}>
|
||||
{typeof child === 'string'
|
||||
? child
|
||||
: (
|
||||
<ComponentRenderer
|
||||
component={child}
|
||||
data={data}
|
||||
context={renderContext}
|
||||
onEvent={onEvent}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
|
||||
const renderBranch = (
|
||||
branch: UIComponent | (UIComponent | string)[] | string | undefined,
|
||||
renderContext: Record<string, unknown>
|
||||
) => {
|
||||
if (branch === undefined) return null
|
||||
if (typeof branch === 'string') {
|
||||
return branch
|
||||
}
|
||||
if (Array.isArray(branch)) {
|
||||
return branch.map((child, index) => (
|
||||
<Fragment key={typeof child === 'string' ? `text-${index}` : child.id || index}>
|
||||
{typeof child === 'string'
|
||||
? child
|
||||
: (
|
||||
<ComponentRenderer
|
||||
component={child}
|
||||
data={data}
|
||||
context={renderContext}
|
||||
onEvent={onEvent}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
}
|
||||
return (
|
||||
<ComponentRenderer
|
||||
component={branch}
|
||||
data={data}
|
||||
context={renderContext}
|
||||
onEvent={onEvent}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderConditionalContent = (renderContext: Record<string, unknown>) => {
|
||||
if (!component.conditional) return undefined
|
||||
const conditionMet = evaluateCondition(component.conditional.if, { ...data, ...renderContext } as Record<string, any>)
|
||||
if (conditionMet) {
|
||||
if (component.conditional.then !== undefined) {
|
||||
return renderBranch(component.conditional.then as UIComponent | (UIComponent | string)[] | string, renderContext)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (component.conditional.else !== undefined) {
|
||||
return renderBranch(component.conditional.else as UIComponent | (UIComponent | string)[] | string, renderContext)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (component.loop) {
|
||||
const items = resolveDataBinding(component.loop.source, mergedData) || []
|
||||
const loopChildren = items.map((item: unknown, index: number) => {
|
||||
const loopContext = {
|
||||
...context,
|
||||
[component.loop!.itemVar]: item,
|
||||
...(component.loop!.indexVar ? { [component.loop!.indexVar]: index } : {}),
|
||||
}
|
||||
|
||||
if (component.conditional) {
|
||||
const conditionalContent = renderConditionalContent(loopContext)
|
||||
if (conditionalContent !== undefined) {
|
||||
return (
|
||||
<Fragment key={`${component.id}-${index}`}>{conditionalContent}</Fragment>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (component.condition) {
|
||||
const conditionValue = resolveBinding(component.condition, { ...data, ...loopContext })
|
||||
if (!conditionValue) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment key={`${component.id}-${index}`}>
|
||||
{renderChildren(component.children, loopContext)}
|
||||
</Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
return createElement(Component, resolvedProps, loopChildren)
|
||||
}
|
||||
|
||||
if (component.conditional) {
|
||||
const conditionalContent = renderConditionalContent(mergedData)
|
||||
if (conditionalContent !== undefined) {
|
||||
return conditionalContent
|
||||
}
|
||||
}
|
||||
|
||||
if (component.condition) {
|
||||
const conditionValue = resolveBinding(component.condition, data)
|
||||
const conditionValue = resolveBinding(component.condition, mergedData)
|
||||
if (!conditionValue) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const children = component.children?.map((child, index) => (
|
||||
<ComponentRenderer
|
||||
key={child.id || index}
|
||||
component={child}
|
||||
data={data}
|
||||
onEvent={onEvent}
|
||||
/>
|
||||
))
|
||||
|
||||
return createElement(Component, resolvedProps, children)
|
||||
|
||||
return createElement(Component, resolvedProps, renderChildren(component.children, context))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { JSONUIRenderer, JSONFormRenderer } from './renderer'
|
||||
export { getUIComponent, uiComponentRegistry, registerComponent } from './component-registry'
|
||||
export * from './hooks'
|
||||
export * from './schema'
|
||||
export * from './utils'
|
||||
export * from './validator'
|
||||
|
||||
@@ -20,17 +20,10 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
|
||||
|
||||
const { executeActions } = useActionExecutor(context)
|
||||
|
||||
const handleEvent = useCallback((componentId: string, event: string, eventData: any) => {
|
||||
const component = findComponentById(schema.components, componentId)
|
||||
if (!component) return
|
||||
|
||||
const handler = component.events?.find(h => h.event === event)
|
||||
if (!handler) return
|
||||
|
||||
if (handler.condition && !handler.condition(data)) return
|
||||
|
||||
const handleEvent = useCallback((_componentId: string, handler: { actions: any[] }, eventData: any) => {
|
||||
if (!handler?.actions?.length) return
|
||||
executeActions(handler.actions, eventData)
|
||||
}, [schema.components, data, executeActions])
|
||||
}, [executeActions])
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
@@ -45,14 +38,3 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
|
||||
</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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 { resolveDataBinding, evaluateCondition, mergeClassNames } from './utils'
|
||||
import { resolveDataBinding, evaluateCondition } from './utils'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function JSONUIRenderer({
|
||||
@@ -10,110 +10,278 @@ export function JSONUIRenderer({
|
||||
onAction,
|
||||
context = {}
|
||||
}: JSONUIRendererProps) {
|
||||
|
||||
if (component.conditional) {
|
||||
const conditionMet = evaluateCondition(component.conditional.if, { ...dataMap, ...context })
|
||||
if (!conditionMet) {
|
||||
return component.conditional.else ? (
|
||||
<JSONUIRenderer
|
||||
component={component.conditional.else as UIComponent}
|
||||
dataMap={dataMap}
|
||||
onAction={onAction}
|
||||
context={context}
|
||||
/>
|
||||
) : null
|
||||
const renderChildren = (
|
||||
children: UIComponent[] | string | undefined,
|
||||
renderContext: Record<string, unknown>
|
||||
) => {
|
||||
if (!children) return null
|
||||
|
||||
if (typeof children === 'string') {
|
||||
return children
|
||||
}
|
||||
}
|
||||
|
||||
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) => (
|
||||
<JSONUIRenderer
|
||||
key={child.id || `child-${index}`}
|
||||
component={child}
|
||||
dataMap={dataMap}
|
||||
onAction={onAction}
|
||||
context={context}
|
||||
/>
|
||||
return children.map((child, index) => (
|
||||
<React.Fragment key={child.id || `child-${index}`}>
|
||||
{renderNode(child, renderContext)}
|
||||
</React.Fragment>
|
||||
))
|
||||
}
|
||||
|
||||
if (typeof Component === 'string') {
|
||||
return React.createElement(Component, props, renderChildren())
|
||||
const renderNode = (
|
||||
node: UIComponent | string,
|
||||
renderContext: Record<string, unknown>
|
||||
) => {
|
||||
if (typeof node === 'string') {
|
||||
return node
|
||||
}
|
||||
|
||||
return (
|
||||
<JSONUIRenderer
|
||||
component={node}
|
||||
dataMap={dataMap}
|
||||
onAction={onAction}
|
||||
context={renderContext}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Component {...props}>
|
||||
{renderChildren()}
|
||||
</Component>
|
||||
)
|
||||
const renderBranch = (
|
||||
branch: UIComponent | (UIComponent | string)[] | string | undefined,
|
||||
renderContext: Record<string, unknown>
|
||||
) => {
|
||||
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) {
|
||||
@@ -140,12 +308,18 @@ export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONF
|
||||
type: field.type,
|
||||
value: formData[field.name] || field.defaultValue || '',
|
||||
},
|
||||
events: {
|
||||
onChange: {
|
||||
action: 'field-change',
|
||||
params: { field: field.name },
|
||||
events: [
|
||||
{
|
||||
event: 'change',
|
||||
actions: [
|
||||
{
|
||||
id: `field-change-${field.name}`,
|
||||
type: 'set-value',
|
||||
target: field.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -159,11 +333,13 @@ export function JSONFormRenderer({ formData, fields, onSubmit, onChange }: JSONF
|
||||
<JSONUIRenderer
|
||||
component={fieldComponent}
|
||||
dataMap={{}}
|
||||
onAction={(handler, event) => {
|
||||
if (handler.action === 'field-change') {
|
||||
const targetValue = (event as { target?: { value?: unknown } } | undefined)?.target?.value
|
||||
handleFieldChange(field.name, targetValue)
|
||||
}
|
||||
onAction={(actions, event) => {
|
||||
actions.forEach((action) => {
|
||||
if (action.type === 'set-value' && action.target === field.name) {
|
||||
const targetValue = (event as { target?: { value?: unknown } } | undefined)?.target?.value
|
||||
handleFieldChange(field.name, targetValue)
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -15,10 +15,37 @@ export const DataBindingSchema = z.object({
|
||||
transform: z.string().optional(),
|
||||
})
|
||||
|
||||
export const EventHandlerSchema = z.object({
|
||||
action: z.string(),
|
||||
export const ActionSchema = z.object({
|
||||
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(),
|
||||
path: z.string().optional(),
|
||||
value: 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({
|
||||
@@ -33,6 +60,7 @@ export const UIComponentSchema: any = z.object({
|
||||
props: z.record(z.string(), z.any()).optional(),
|
||||
className: z.string().optional(),
|
||||
style: z.record(z.string(), z.any()).optional(),
|
||||
bindings: z.record(z.string(), DataBindingSchema).optional(),
|
||||
children: z.union([
|
||||
z.string(),
|
||||
z.array(z.lazy(() => UIComponentSchema)),
|
||||
@@ -41,10 +69,7 @@ export const UIComponentSchema: any = z.object({
|
||||
z.string(),
|
||||
DataBindingSchema,
|
||||
]).optional(),
|
||||
events: z.record(z.string(), z.union([
|
||||
z.string(),
|
||||
EventHandlerSchema,
|
||||
])).optional(),
|
||||
events: z.array(EventHandlerSchema).optional(),
|
||||
conditional: ConditionalSchema.optional(),
|
||||
loop: z.object({
|
||||
source: z.string(),
|
||||
@@ -215,6 +240,7 @@ export type DataSourceConfig<T = unknown> =
|
||||
|
||||
export type UIValue = z.infer<typeof UIValueSchema>
|
||||
export type DataBinding = z.infer<typeof DataBindingSchema>
|
||||
export type Action = z.infer<typeof ActionSchema>
|
||||
export type EventHandler = z.infer<typeof EventHandlerSchema>
|
||||
export type Conditional = z.infer<typeof ConditionalSchema>
|
||||
export type UIComponent = z.infer<typeof UIComponentSchema>
|
||||
|
||||
@@ -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 {
|
||||
component: UIComponent
|
||||
dataMap?: Record<string, unknown>
|
||||
onAction?: (handler: EventHandler, event?: unknown) => void
|
||||
onAction?: (actions: Action[], event?: unknown) => void
|
||||
context?: Record<string, unknown>
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
return dataMap[binding]
|
||||
if (binding.includes('.')) {
|
||||
return getNestedValue(mergedContext, binding)
|
||||
}
|
||||
return mergedContext[binding]
|
||||
}
|
||||
|
||||
const { source, path } = binding
|
||||
const data = dataMap[source]
|
||||
|
||||
if (!path) return data
|
||||
|
||||
return getNestedValue(data, path)
|
||||
const { source, path, transform } = binding
|
||||
const sourceValue = source.includes('.')
|
||||
? getNestedValue(mergedContext, source)
|
||||
: mergedContext[source]
|
||||
const resolvedValue = path ? getNestedValue(sourceValue, path) : sourceValue
|
||||
|
||||
return applyTransform(resolvedValue, transform)
|
||||
}
|
||||
|
||||
function applyTransform(value: unknown, transform?: BindingTransform) {
|
||||
if (!transform) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof transform === 'function') {
|
||||
return transform(value)
|
||||
}
|
||||
|
||||
return transformData(value, transform)
|
||||
}
|
||||
|
||||
export function getNestedValue(obj: any, path: string): any {
|
||||
|
||||
@@ -83,11 +83,11 @@ function checkComponentTree(
|
||||
seenIds.add(component.id)
|
||||
}
|
||||
|
||||
if (component.dataBinding && !component.dataBinding.source) {
|
||||
if (component.dataBinding) {
|
||||
const bindingPath = typeof component.dataBinding === 'string'
|
||||
? component.dataBinding.split('.')[0]
|
||||
: ''
|
||||
|
||||
: component.dataBinding.source?.split('.')[0]
|
||||
|
||||
if (bindingPath) {
|
||||
result.warnings.push({
|
||||
path: `${path}.${component.id}`,
|
||||
|
||||
29
src/lib/json-ui/wrappers/Breadcrumb.tsx
Normal file
29
src/lib/json-ui/wrappers/Breadcrumb.tsx
Normal 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 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
49
src/lib/json-ui/wrappers/LazyBarChart.tsx
Normal file
49
src/lib/json-ui/wrappers/LazyBarChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
src/lib/json-ui/wrappers/LazyD3BarChart.tsx
Normal file
96
src/lib/json-ui/wrappers/LazyD3BarChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
src/lib/json-ui/wrappers/LazyLineChart.tsx
Normal file
49
src/lib/json-ui/wrappers/LazyLineChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
src/lib/json-ui/wrappers/SaveIndicator.tsx
Normal file
41
src/lib/json-ui/wrappers/SaveIndicator.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
108
src/lib/json-ui/wrappers/SeedDataManager.tsx
Normal file
108
src/lib/json-ui/wrappers/SeedDataManager.tsx
Normal 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}`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
117
src/lib/json-ui/wrappers/StorageSettings.tsx
Normal file
117
src/lib/json-ui/wrappers/StorageSettings.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
106
src/lib/json-ui/wrappers/definitions/breadcrumb.json
Normal file
106
src/lib/json-ui/wrappers/definitions/breadcrumb.json
Normal 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": "/"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
36
src/lib/json-ui/wrappers/definitions/save-indicator.json
Normal file
36
src/lib/json-ui/wrappers/definitions/save-indicator.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
157
src/lib/json-ui/wrappers/definitions/seed-data-manager.json
Normal file
157
src/lib/json-ui/wrappers/definitions/seed-data-manager.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
331
src/lib/json-ui/wrappers/definitions/storage-settings.json
Normal file
331
src/lib/json-ui/wrappers/definitions/storage-settings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { createElement, type ComponentType, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Component as ComponentSchema, Layout } from '@/schemas/ui-schema'
|
||||
import { useDataBinding, useEventHandlers, useComponentRegistry } from '@/hooks/ui'
|
||||
@@ -7,6 +7,7 @@ interface SchemaRendererProps {
|
||||
schema: ComponentSchema
|
||||
data: Record<string, any>
|
||||
functions?: Record<string, (...args: any[]) => any>
|
||||
componentRegistry?: Record<string, ComponentType<any>>
|
||||
}
|
||||
|
||||
interface LayoutRendererProps {
|
||||
@@ -51,10 +52,10 @@ function LayoutRenderer({ layout, children }: LayoutRendererProps) {
|
||||
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 { resolveEvents } = useEventHandlers({ functions })
|
||||
const { getComponent, getIcon } = useComponentRegistry()
|
||||
const { getComponent, getIcon } = useComponentRegistry({ customComponents: componentRegistry })
|
||||
|
||||
if (schema.condition && !resolveCondition(schema.condition)) {
|
||||
return null
|
||||
@@ -76,6 +77,7 @@ export function SchemaRenderer({ schema, data, functions = {} }: SchemaRendererP
|
||||
schema={{ ...schema, repeat: undefined }}
|
||||
data={itemData}
|
||||
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)
|
||||
|
||||
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) => (
|
||||
<SchemaRenderer
|
||||
key={child.id || index}
|
||||
schema={child}
|
||||
data={data}
|
||||
functions={functions}
|
||||
componentRegistry={componentRegistry}
|
||||
/>
|
||||
))
|
||||
|
||||
@@ -129,9 +136,10 @@ interface PageRendererProps {
|
||||
}
|
||||
data: Record<string, 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 (
|
||||
<LayoutRenderer layout={schema.layout}>
|
||||
{schema.components.map((component) => (
|
||||
@@ -140,6 +148,7 @@ export function PageRenderer({ schema, data, functions = {} }: PageRendererProps
|
||||
schema={component}
|
||||
data={data}
|
||||
functions={functions}
|
||||
componentRegistry={componentRegistry}
|
||||
/>
|
||||
))}
|
||||
</LayoutRenderer>
|
||||
|
||||
74
src/schemas/registry-validation.json
Normal file
74
src/schemas/registry-validation.json
Normal 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": []
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
export type ComponentType =
|
||||
| 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'
|
||||
| 'Button' | 'Card' | 'CardHeader' | 'CardTitle' | 'CardDescription' | 'CardContent' | 'CardFooter'
|
||||
| 'Input' | 'TextArea' | 'Select' | 'Checkbox' | 'Radio' | 'Switch' | 'Slider' | 'NumberInput'
|
||||
| 'Badge' | 'Progress' | 'Separator' | 'Tabs' | 'Dialog'
|
||||
| 'Input' | 'TextArea' | 'Textarea' | 'Select' | 'Checkbox' | 'Radio' | 'Switch' | 'Slider' | 'NumberInput'
|
||||
| 'Badge' | 'Progress' | 'Separator' | 'Tabs' | 'TabsContent' | 'TabsList' | 'TabsTrigger' | 'Dialog'
|
||||
| 'Text' | 'Heading' | 'Label' | 'List' | 'Grid' | 'Stack' | 'Flex' | 'Container'
|
||||
| 'Link' | 'Image' | 'Avatar' | 'Code' | 'Tag' | 'Spinner' | 'Skeleton'
|
||||
| 'Alert' | 'InfoBox' | 'EmptyState' | 'StatusBadge'
|
||||
| 'Table' | 'KeyValue' | 'StatCard' | 'DataCard' | 'SearchInput' | 'ActionBar'
|
||||
| 'AppBranding' | 'LabelWithBadge' | 'EmptyEditorState' | 'LoadingFallback' | 'LoadingState' | 'NavigationGroupHeader'
|
||||
| 'Breadcrumb' | 'SaveIndicator' | 'LazyBarChart' | 'LazyD3BarChart' | 'LazyLineChart' | 'SeedDataManager' | 'StorageSettings'
|
||||
|
||||
export type ActionType =
|
||||
| 'create' | 'update' | 'delete' | 'navigate'
|
||||
@@ -35,8 +34,9 @@ export interface Action {
|
||||
target?: string
|
||||
path?: string
|
||||
value?: any
|
||||
params?: Record<string, any>
|
||||
// Legacy: function-based compute
|
||||
compute?: (data: Record<string, any>, event?: any) => any
|
||||
compute?: ((data: Record<string, any>, event?: any) => any) | string
|
||||
// New: JSON-friendly expression (e.g., "event.target.value", "data.fieldName")
|
||||
expression?: string
|
||||
// New: JSON template with dynamic values
|
||||
@@ -48,23 +48,40 @@ export interface Action {
|
||||
export interface Binding {
|
||||
source: string
|
||||
path?: string
|
||||
transform?: (value: any) => any
|
||||
transform?: string | ((value: any) => any)
|
||||
}
|
||||
|
||||
export interface EventHandler {
|
||||
event: string
|
||||
actions: Action[]
|
||||
condition?: (data: Record<string, any>) => boolean
|
||||
condition?: string | ((data: Record<string, any>) => boolean)
|
||||
}
|
||||
|
||||
export interface Conditional {
|
||||
if: string
|
||||
then?: UIComponent | (UIComponent | string)[] | string
|
||||
else?: UIComponent | (UIComponent | string)[] | string
|
||||
}
|
||||
|
||||
export interface Loop {
|
||||
source: string
|
||||
itemVar: string
|
||||
indexVar?: string
|
||||
}
|
||||
|
||||
export interface UIComponent {
|
||||
id: string
|
||||
type: ComponentType
|
||||
props?: Record<string, any>
|
||||
className?: string
|
||||
style?: Record<string, any>
|
||||
bindings?: Record<string, Binding>
|
||||
dataBinding?: string | Binding
|
||||
events?: EventHandler[]
|
||||
children?: UIComponent[]
|
||||
children?: UIComponent[] | string
|
||||
condition?: Binding
|
||||
conditional?: Conditional
|
||||
loop?: Loop
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
@@ -98,7 +115,8 @@ export interface JSONUIContext {
|
||||
export interface ComponentRendererProps {
|
||||
component: UIComponent
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user