Compare commits

..

2 Commits

66 changed files with 1515 additions and 2834 deletions

View File

@@ -12,69 +12,7 @@
"data": "Data display and visualization components",
"custom": "Custom domain-specific components"
},
"sourceRoots": {
"atoms": ["@/components/atoms/*.tsx"],
"molecules": ["@/components/molecules/*.tsx"],
"organisms": ["@/components/organisms/*.tsx"],
"ui": ["@/components/ui/**/*.{ts,tsx}"],
"wrappers": ["@/lib/json-ui/wrappers/*.tsx"],
"icons": []
},
"components": [
{
"type": "div",
"name": "div",
"category": "layout",
"canHaveChildren": true,
"description": "Generic block container",
"status": "supported",
"source": "primitive"
},
{
"type": "section",
"name": "section",
"category": "layout",
"canHaveChildren": true,
"description": "Semantic section container",
"status": "supported",
"source": "primitive"
},
{
"type": "article",
"name": "article",
"category": "layout",
"canHaveChildren": true,
"description": "Semantic article container",
"status": "supported",
"source": "primitive"
},
{
"type": "header",
"name": "header",
"category": "layout",
"canHaveChildren": true,
"description": "Semantic header container",
"status": "supported",
"source": "primitive"
},
{
"type": "footer",
"name": "footer",
"category": "layout",
"canHaveChildren": true,
"description": "Semantic footer container",
"status": "supported",
"source": "primitive"
},
{
"type": "main",
"name": "main",
"category": "layout",
"canHaveChildren": true,
"description": "Semantic main container",
"status": "supported",
"source": "primitive"
},
{
"type": "ActionCard",
"name": "ActionCard",
@@ -142,10 +80,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "ComponentBindingDialog",
"load": {
"export": "ComponentBindingDialogWrapper"
}
"wrapperFor": "ComponentBindingDialog"
},
{
"type": "Container",
@@ -187,10 +122,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "DataSourceEditorDialog",
"load": {
"export": "DataSourceEditorDialogWrapper"
}
"wrapperFor": "DataSourceEditorDialog"
},
{
"type": "Dialog",
@@ -792,10 +724,7 @@
"canHaveChildren": false,
"description": "ArrowLeft icon",
"status": "supported",
"source": "icons",
"load": {
"export": "ArrowLeft"
}
"source": "icons"
},
{
"type": "ArrowRight",
@@ -804,10 +733,7 @@
"canHaveChildren": false,
"description": "ArrowRight icon",
"status": "supported",
"source": "icons",
"load": {
"export": "ArrowRight"
}
"source": "icons"
},
{
"type": "Check",
@@ -816,10 +742,7 @@
"canHaveChildren": false,
"description": "Check icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Check"
}
"source": "icons"
},
{
"type": "X",
@@ -828,10 +751,7 @@
"canHaveChildren": false,
"description": "X icon",
"status": "supported",
"source": "icons",
"load": {
"export": "X"
}
"source": "icons"
},
{
"type": "Plus",
@@ -840,10 +760,7 @@
"canHaveChildren": false,
"description": "Plus icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Plus"
}
"source": "icons"
},
{
"type": "Minus",
@@ -852,10 +769,7 @@
"canHaveChildren": false,
"description": "Minus icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Minus"
}
"source": "icons"
},
{
"type": "Search",
@@ -864,10 +778,7 @@
"canHaveChildren": false,
"description": "Search icon",
"status": "supported",
"source": "icons",
"load": {
"export": "MagnifyingGlass"
}
"source": "icons"
},
{
"type": "Filter",
@@ -876,10 +787,7 @@
"canHaveChildren": false,
"description": "Filter icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Funnel"
}
"source": "icons"
},
{
"type": "Download",
@@ -888,10 +796,7 @@
"canHaveChildren": false,
"description": "Download icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Download"
}
"source": "icons"
},
{
"type": "Upload",
@@ -900,10 +805,7 @@
"canHaveChildren": false,
"description": "Upload icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Upload"
}
"source": "icons"
},
{
"type": "Edit",
@@ -912,10 +814,7 @@
"canHaveChildren": false,
"description": "Edit icon",
"status": "supported",
"source": "icons",
"load": {
"export": "PencilSimple"
}
"source": "icons"
},
{
"type": "Trash",
@@ -924,10 +823,7 @@
"canHaveChildren": false,
"description": "Trash icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Trash"
}
"source": "icons"
},
{
"type": "Eye",
@@ -936,10 +832,7 @@
"canHaveChildren": false,
"description": "Eye icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Eye"
}
"source": "icons"
},
{
"type": "EyeOff",
@@ -948,10 +841,7 @@
"canHaveChildren": false,
"description": "EyeOff icon",
"status": "supported",
"source": "icons",
"load": {
"export": "EyeClosed"
}
"source": "icons"
},
{
"type": "ChevronUp",
@@ -960,10 +850,7 @@
"canHaveChildren": false,
"description": "ChevronUp icon",
"status": "supported",
"source": "icons",
"load": {
"export": "CaretUp"
}
"source": "icons"
},
{
"type": "ChevronDown",
@@ -972,10 +859,7 @@
"canHaveChildren": false,
"description": "ChevronDown icon",
"status": "supported",
"source": "icons",
"load": {
"export": "CaretDown"
}
"source": "icons"
},
{
"type": "ChevronLeft",
@@ -984,10 +868,7 @@
"canHaveChildren": false,
"description": "ChevronLeft icon",
"status": "supported",
"source": "icons",
"load": {
"export": "CaretLeft"
}
"source": "icons"
},
{
"type": "ChevronRight",
@@ -996,10 +877,7 @@
"canHaveChildren": false,
"description": "ChevronRight icon",
"status": "supported",
"source": "icons",
"load": {
"export": "CaretRight"
}
"source": "icons"
},
{
"type": "Settings",
@@ -1008,10 +886,7 @@
"canHaveChildren": false,
"description": "Settings icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Gear"
}
"source": "icons"
},
{
"type": "User",
@@ -1020,10 +895,7 @@
"canHaveChildren": false,
"description": "User icon",
"status": "supported",
"source": "icons",
"load": {
"export": "User"
}
"source": "icons"
},
{
"type": "Bell",
@@ -1032,10 +904,7 @@
"canHaveChildren": false,
"description": "Bell icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Bell"
}
"source": "icons"
},
{
"type": "Mail",
@@ -1044,10 +913,7 @@
"canHaveChildren": false,
"description": "Mail icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Envelope"
}
"source": "icons"
},
{
"type": "Calendar",
@@ -1056,10 +922,7 @@
"canHaveChildren": false,
"description": "Calendar icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Calendar"
}
"source": "icons"
},
{
"type": "Clock",
@@ -1068,10 +931,7 @@
"canHaveChildren": false,
"description": "Clock icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Clock"
}
"source": "icons"
},
{
"type": "Star",
@@ -1080,10 +940,7 @@
"canHaveChildren": false,
"description": "Star icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Star"
}
"source": "icons"
},
{
"type": "Heart",
@@ -1092,10 +949,7 @@
"canHaveChildren": false,
"description": "Heart icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Heart"
}
"source": "icons"
},
{
"type": "Share",
@@ -1104,10 +958,7 @@
"canHaveChildren": false,
"description": "Share icon",
"status": "supported",
"source": "icons",
"load": {
"export": "ShareNetwork"
}
"source": "icons"
},
{
"type": "Link",
@@ -1116,10 +967,7 @@
"canHaveChildren": false,
"description": "Link icon",
"status": "supported",
"source": "icons",
"load": {
"export": "LinkSimple"
}
"source": "icons"
},
{
"type": "Copy",
@@ -1128,10 +976,7 @@
"canHaveChildren": false,
"description": "Copy icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Copy"
}
"source": "icons"
},
{
"type": "Save",
@@ -1140,10 +985,7 @@
"canHaveChildren": false,
"description": "Save icon",
"status": "supported",
"source": "icons",
"load": {
"export": "FloppyDisk"
}
"source": "icons"
},
{
"type": "RefreshCw",
@@ -1152,10 +994,7 @@
"canHaveChildren": false,
"description": "RefreshCw icon",
"status": "supported",
"source": "icons",
"load": {
"export": "ArrowClockwise"
}
"source": "icons"
},
{
"type": "AlertCircle",
@@ -1164,10 +1003,7 @@
"canHaveChildren": false,
"description": "AlertCircle icon",
"status": "supported",
"source": "icons",
"load": {
"export": "WarningCircle"
}
"source": "icons"
},
{
"type": "Info",
@@ -1176,10 +1012,7 @@
"canHaveChildren": false,
"description": "Info icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Info"
}
"source": "icons"
},
{
"type": "HelpCircle",
@@ -1188,10 +1021,7 @@
"canHaveChildren": false,
"description": "HelpCircle icon",
"status": "supported",
"source": "icons",
"load": {
"export": "Question"
}
"source": "icons"
},
{
"type": "Home",
@@ -1200,10 +1030,7 @@
"canHaveChildren": false,
"description": "Home icon",
"status": "supported",
"source": "icons",
"load": {
"export": "House"
}
"source": "icons"
},
{
"type": "Menu",
@@ -1212,10 +1039,7 @@
"canHaveChildren": false,
"description": "Menu icon",
"status": "supported",
"source": "icons",
"load": {
"export": "List"
}
"source": "icons"
},
{
"type": "MoreVertical",
@@ -1224,10 +1048,7 @@
"canHaveChildren": false,
"description": "MoreVertical icon",
"status": "supported",
"source": "icons",
"load": {
"export": "DotsThreeVertical"
}
"source": "icons"
},
{
"type": "MoreHorizontal",
@@ -1236,10 +1057,7 @@
"canHaveChildren": false,
"description": "MoreHorizontal icon",
"status": "supported",
"source": "icons",
"load": {
"export": "DotsThree"
}
"source": "icons"
},
{
"type": "Breadcrumb",
@@ -1457,10 +1275,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "GitHubBuildStatus",
"load": {
"export": "GitHubBuildStatusWrapper"
}
"wrapperFor": "GitHubBuildStatus"
},
{
"type": "InfoBox",
@@ -1562,11 +1377,7 @@
"canHaveChildren": true,
"description": "Chart component",
"status": "supported",
"source": "ui",
"load": {
"path": "@/components/ui/chart/chart-container.tsx",
"export": "ChartContainer"
}
"source": "ui"
},
{
"type": "DataList",
@@ -1626,10 +1437,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyBarChart",
"load": {
"export": "LazyBarChartWrapper"
}
"wrapperFor": "LazyBarChart"
},
{
"type": "LazyD3BarChart",
@@ -1652,10 +1460,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyD3BarChart",
"load": {
"export": "LazyD3BarChartWrapper"
}
"wrapperFor": "LazyD3BarChart"
},
{
"type": "LazyLineChart",
@@ -1678,10 +1483,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyLineChart",
"load": {
"export": "LazyLineChartWrapper"
}
"wrapperFor": "LazyLineChart"
},
{
"type": "List",
@@ -1740,10 +1542,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "SeedDataManager",
"load": {
"export": "SeedDataManagerWrapper"
}
"wrapperFor": "SeedDataManager"
},
{
"type": "StatCard",
@@ -2030,10 +1829,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "ComponentTree",
"load": {
"export": "ComponentTreeWrapper"
}
"wrapperFor": "ComponentTree"
},
{
"type": "ComponentTreeNode",
@@ -2113,11 +1909,7 @@
"description": "JSONUIShowcase organism component",
"status": "supported",
"source": "organisms",
"jsonCompatible": true,
"load": {
"path": "@/components/JSONUIShowcase.tsx",
"export": "JSONUIShowcase"
}
"jsonCompatible": true
},
{
"type": "Kbd",
@@ -2174,11 +1966,7 @@
"canHaveChildren": true,
"description": "PageHeader component",
"status": "supported",
"source": "atoms",
"load": {
"path": "@/components/atoms/PageHeader.tsx",
"export": "BasicPageHeader"
}
"source": "atoms"
},
{
"type": "PageHeaderContent",
@@ -2261,11 +2049,7 @@
"canHaveChildren": true,
"description": "Resizable component",
"status": "supported",
"source": "ui",
"load": {
"path": "@/components/ui/resizable.tsx",
"export": "ResizablePanelGroup"
}
"source": "ui"
},
{
"type": "SaveIndicator",
@@ -2288,10 +2072,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "SaveIndicator",
"load": {
"export": "SaveIndicatorWrapper"
}
"wrapperFor": "SaveIndicator"
},
{
"type": "SchemaEditorCanvas",
@@ -2369,11 +2150,7 @@
"canHaveChildren": false,
"description": "Search input with icon",
"status": "supported",
"source": "atoms",
"load": {
"path": "@/components/atoms/SearchInput.tsx",
"export": "BasicSearchInput"
}
"source": "atoms"
},
{
"type": "Sheet",
@@ -2459,10 +2236,7 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "StorageSettings",
"load": {
"export": "StorageSettingsWrapper"
}
"wrapperFor": "StorageSettings"
},
{
"type": "Timestamp",

View File

@@ -6,8 +6,7 @@
"scripts": {
"dev": "vite",
"kill": "fuser -k 5000/tcp",
"predev": "npm run components:generate-types",
"prebuild": "npm run components:generate-types && mkdir -p /tmp/dist || true",
"prebuild": "mkdir -p /tmp/dist || true",
"build": "tsc -b --noCheck && vite build",
"lint": "eslint . --fix && npm run lint:schemas",
"lint:check": "eslint . && npm run lint:schemas",
@@ -25,9 +24,8 @@
"pages:generate": "node scripts/generate-page.js",
"schemas:validate": "tsx scripts/validate-json-schemas.ts",
"components:list": "node scripts/list-json-components.cjs",
"components:generate-types": "tsx scripts/generate-json-ui-component-types.ts",
"components:scan": "node scripts/scan-and-update-registry.cjs",
"components:validate": "node scripts/validate-supported-components.cjs && tsx scripts/validate-json-registry.ts"
"components:validate": "node scripts/validate-supported-components.cjs"
},
"dependencies": {
"@heroicons/react": "^2.2.0",

View File

@@ -39,13 +39,9 @@
},
{
"id": "trends",
"type": "static",
"defaultValue": {
"filesGrowth": 12,
"modelsGrowth": -3,
"componentsGrowth": 8,
"testsGrowth": 15
}
"type": "computed",
"compute": "(data) => ({ filesGrowth: 12, modelsGrowth: -3, componentsGrowth: 8, testsGrowth: 15 })",
"dependencies": ["metrics"]
}
],
"components": [

View File

@@ -25,12 +25,9 @@
},
{
"id": "filteredFiles",
"type": "static",
"expression": "data.files",
"dependencies": [
"files",
"searchQuery"
]
"type": "computed",
"compute": "(data) => {\n if (!data.searchQuery) return data.files;\n return data.files.filter(f => f.name.toLowerCase().includes(data.searchQuery.toLowerCase()));\n}",
"dependencies": ["files", "searchQuery"]
}
],
"components": [

View File

@@ -22,15 +22,6 @@
"type": "string"
}
},
"sourceRoots": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"components": {
"type": "array",
"items": {
@@ -82,19 +73,6 @@
"wrapperFor": {
"type": "string"
},
"load": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"export": {
"type": "string"
}
},
"required": ["export"],
"additionalProperties": false
},
"deprecated": {
"type": "object",
"properties": {

View File

@@ -1,50 +0,0 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
interface RegistryComponent {
type?: string
name?: string
export?: string
}
interface RegistryData {
components?: RegistryComponent[]
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(__dirname, '..')
const registryPath = path.join(rootDir, 'json-components-registry.json')
const outputPath = path.join(rootDir, 'src/types/json-ui-component-types.ts')
const registryData = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as RegistryData
const components = registryData.components ?? []
const seen = new Set<string>()
const componentTypes = components.flatMap((component) => {
const typeName = component.type ?? component.name ?? component.export
if (!typeName || typeof typeName !== 'string') {
throw new Error('Registry component is missing a valid type/name/export entry.')
}
if (seen.has(typeName)) {
return []
}
seen.add(typeName)
return [typeName]
})
const lines = [
'// This file is auto-generated by scripts/generate-json-ui-component-types.ts.',
'// Do not edit this file directly.',
'',
'export const jsonUIComponentTypes = [',
...componentTypes.map((typeName) => ` ${JSON.stringify(typeName)},`),
'] as const',
'',
'export type JSONUIComponentType = typeof jsonUIComponentTypes[number]',
'',
]
fs.writeFileSync(outputPath, `${lines.join('\n')}`)
console.log(`✅ Wrote ${componentTypes.length} component types to ${outputPath}`)

View File

@@ -1,235 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import * as PhosphorIcons from '@phosphor-icons/react'
import { JSONUIShowcase } from '../src/components/JSONUIShowcase'
type ComponentType = unknown
interface JsonRegistryEntry {
name?: string
type?: string
export?: string
source?: string
status?: string
wrapperRequired?: boolean
wrapperComponent?: string
wrapperFor?: string
load?: {
export?: string
}
deprecated?: unknown
}
interface JsonComponentRegistry {
components?: JsonRegistryEntry[]
}
const sourceAliases: Record<string, Record<string, string>> = {
atoms: {
PageHeader: 'BasicPageHeader',
SearchInput: 'BasicSearchInput',
},
molecules: {},
organisms: {},
ui: {
Chart: 'ChartContainer',
Resizable: 'ResizablePanelGroup',
},
wrappers: {},
}
const explicitComponentAllowlist: Record<string, ComponentType> = {
JSONUIShowcase,
}
const getRegistryEntryKey = (entry: JsonRegistryEntry): string | undefined =>
entry.name ?? entry.type
const getRegistryEntryExportName = (entry: JsonRegistryEntry): string | undefined =>
entry.load?.export ?? entry.export ?? getRegistryEntryKey(entry)
const buildComponentMapFromExports = (
exports: Record<string, unknown>
): Record<string, ComponentType> => {
return Object.entries(exports).reduce<Record<string, ComponentType>>((acc, [key, value]) => {
if (value && (typeof value === 'function' || typeof value === 'object')) {
acc[key] = value as ComponentType
}
return acc
}, {})
}
const buildComponentMapFromModules = (
modules: Record<string, unknown>
): Record<string, ComponentType> => {
return Object.values(modules).reduce<Record<string, ComponentType>>((acc, moduleExports) => {
if (!moduleExports || typeof moduleExports !== 'object') {
return acc
}
Object.entries(buildComponentMapFromExports(moduleExports as Record<string, unknown>)).forEach(
([key, component]) => {
acc[key] = component
}
)
return acc
}, {})
}
const listFiles = async (options: {
directory: string
extensions: string[]
recursive: boolean
}): Promise<string[]> => {
const { directory, extensions, recursive } = options
const entries = await fs.readdir(directory, { withFileTypes: true })
const files: string[] = []
await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(directory, entry.name)
if (entry.isDirectory()) {
if (recursive) {
const nested = await listFiles({ directory: fullPath, extensions, recursive })
files.push(...nested)
}
return
}
if (extensions.includes(path.extname(entry.name))) {
files.push(fullPath)
}
})
)
return files
}
const importModules = async (files: string[]): Promise<Record<string, unknown>> => {
const modules: Record<string, unknown> = {}
await Promise.all(
files.map(async (file) => {
const moduleExports = await import(pathToFileURL(file).href)
modules[file] = moduleExports
})
)
return modules
}
const validateRegistry = async () => {
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(scriptDir, '..')
const registryPath = path.join(rootDir, 'json-components-registry.json')
const registryRaw = await fs.readFile(registryPath, 'utf8')
const registry = JSON.parse(registryRaw) as JsonComponentRegistry
const registryEntries = registry.components ?? []
const registryEntryByType = new Map(
registryEntries
.map((entry) => {
const entryKey = getRegistryEntryKey(entry)
return entryKey ? [entryKey, entry] : null
})
.filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry))
)
const sourceConfigs = [
{
source: 'atoms',
directory: path.join(rootDir, 'src/components/atoms'),
extensions: ['.tsx'],
recursive: false,
},
{
source: 'molecules',
directory: path.join(rootDir, 'src/components/molecules'),
extensions: ['.tsx'],
recursive: false,
},
{
source: 'organisms',
directory: path.join(rootDir, 'src/components/organisms'),
extensions: ['.tsx'],
recursive: false,
},
{
source: 'ui',
directory: path.join(rootDir, 'src/components/ui'),
extensions: ['.ts', '.tsx'],
recursive: true,
},
{
source: 'wrappers',
directory: path.join(rootDir, 'src/lib/json-ui/wrappers'),
extensions: ['.tsx'],
recursive: false,
},
]
const componentMaps: Record<string, Record<string, ComponentType>> = {}
await Promise.all(
sourceConfigs.map(async (config) => {
const files = await listFiles({
directory: config.directory,
extensions: config.extensions,
recursive: config.recursive,
})
const modules = await importModules(files)
componentMaps[config.source] = buildComponentMapFromModules(modules)
})
)
componentMaps.icons = buildComponentMapFromExports(PhosphorIcons)
const errors: string[] = []
registryEntries.forEach((entry) => {
const entryKey = getRegistryEntryKey(entry)
const entryExportName = getRegistryEntryExportName(entry)
if (!entryKey || !entryExportName) {
errors.push(`Entry missing name/type/export: ${JSON.stringify(entry)}`)
return
}
const source = entry.source
if (!source || !componentMaps[source]) {
errors.push(`${entryKey}: unknown source "${source ?? 'missing'}"`)
return
}
const aliasName = sourceAliases[source]?.[entryKey]
const component =
componentMaps[source][entryExportName] ??
(aliasName ? componentMaps[source][aliasName] : undefined) ??
explicitComponentAllowlist[entryKey]
if (!component) {
const aliasNote = aliasName ? ` (alias: ${aliasName})` : ''
errors.push(
`${entryKey} (${source}) did not resolve export "${entryExportName}"${aliasNote}`
)
}
if (entry.wrapperRequired) {
if (!entry.wrapperComponent) {
errors.push(`${entryKey} (${source}) requires a wrapperComponent but none is defined`)
return
}
if (!registryEntryByType.has(entry.wrapperComponent)) {
errors.push(
`${entryKey} (${source}) references missing wrapperComponent ${entry.wrapperComponent}`
)
}
}
})
if (errors.length > 0) {
console.error('❌ JSON component registry export validation failed:')
errors.forEach((error) => console.error(`- ${error}`))
process.exit(1)
}
console.log('✅ JSON component registry exports are valid.')
}
await validateRegistry()

View File

@@ -4,7 +4,7 @@ const path = require('path')
const rootDir = path.resolve(__dirname, '..')
const registryPath = path.join(rootDir, 'json-components-registry.json')
const definitionsPath = path.join(rootDir, 'src/lib/component-definitions.json')
const componentTypesPath = path.join(rootDir, 'src/types/json-ui-component-types.ts')
const componentTypesPath = path.join(rootDir, 'src/types/json-ui.ts')
const uiRegistryPath = path.join(rootDir, 'src/lib/json-ui/component-registry.ts')
const atomIndexPath = path.join(rootDir, 'src/components/atoms/index.ts')
const moleculeIndexPath = path.join(rootDir, 'src/components/molecules/index.ts')
@@ -21,10 +21,16 @@ const componentDefinitions = readJson(definitionsPath)
const definitionTypes = new Set(componentDefinitions.map((def) => def.type))
const componentTypesContent = readText(componentTypesPath)
const componentTypesStart = componentTypesContent.indexOf('export type ComponentType')
const componentTypesEnd = componentTypesContent.indexOf('export type ActionType')
if (componentTypesStart === -1 || componentTypesEnd === -1) {
throw new Error('Unable to locate ComponentType union in src/types/json-ui.ts')
}
const componentTypesBlock = componentTypesContent.slice(componentTypesStart, componentTypesEnd)
const componentTypeSet = new Set()
const componentTypeRegex = /"([^"]+)"/g
const componentTypeRegex = /'([^']+)'/g
let match
while ((match = componentTypeRegex.exec(componentTypesContent)) !== null) {
while ((match = componentTypeRegex.exec(componentTypesBlock)) !== null) {
componentTypeSet.add(match[1])
}

View File

@@ -1,64 +1,153 @@
import { PageRenderer } from '@/lib/json-ui/page-renderer'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { FeatureToggles } from '@/types/project'
import { useMemo } from 'react'
import featureToggleSchema from '@/schemas/feature-toggle-settings.json'
import type { PageSchema } from '@/types/json-ui'
import { evaluateExpression } from '@/lib/json-ui/expression-evaluator'
import {
BookOpen,
Code,
Cube,
Database,
FileText,
Flask,
FlowArrow,
Image,
Lightbulb,
PaintBrush,
Play,
Tree,
Wrench,
} from '@phosphor-icons/react'
import { ScrollArea } from '@/components/ui/scroll-area'
import featureToggleSettings from '@/config/feature-toggle-settings.json'
import type { ComponentType } from 'react'
interface FeatureToggleSettingsProps {
features: FeatureToggles
onFeaturesChange: (features: FeatureToggles) => void
}
/**
* FeatureToggleSettings - Now JSON-driven!
*
* This component demonstrates how a complex React component with:
* - Custom hooks and state management
* - Dynamic data rendering (looping over features)
* - Event handlers (toggle switches)
* - Conditional styling (enabled/disabled states)
*
* Can be converted to a pure JSON schema with custom action handlers.
* The JSON schema handles all UI structure, data binding, and loops,
* while custom functions handle business logic.
*
* Converted from 153 lines of React/TSX to:
* - 1 JSON schema file (195 lines, but mostly structure)
* - 45 lines of integration code (this file)
*
* Benefits:
* - UI structure is now data-driven and can be modified without code changes
* - Feature list is in JSON and can be easily extended
* - Styling and layout can be customized via JSON
* - Business logic (toggle handler) stays in TypeScript for type safety
*/
export function FeatureToggleSettings({ features, onFeaturesChange }: FeatureToggleSettingsProps) {
// Custom action handler - this is the "hook" that handles complex logic
const handlers = useMemo(() => ({
updateFeature: (action: any, eventData: any) => {
// Evaluate the params to get the actual values
const context = { data: { features, item: eventData.item }, event: eventData }
// The key param is an expression like "item.key" which needs evaluation
const key = evaluateExpression(action.params.key, context) as keyof FeatureToggles
const checked = eventData as boolean
onFeaturesChange({
...features,
[key]: checked,
})
}
}), [features, onFeaturesChange])
type FeatureToggleIconKey =
| 'BookOpen'
| 'Code'
| 'Cube'
| 'Database'
| 'FileText'
| 'Flask'
| 'FlowArrow'
| 'Image'
| 'Lightbulb'
| 'PaintBrush'
| 'Play'
| 'Tree'
| 'Wrench'
// Pass features as external data to the JSON renderer
const data = useMemo(() => ({ features }), [features])
const iconMap: Record<FeatureToggleIconKey, ComponentType<{ size?: number; weight?: 'duotone' }>> = {
BookOpen,
Code,
Cube,
Database,
FileText,
Flask,
FlowArrow,
Image,
Lightbulb,
PaintBrush,
Play,
Tree,
Wrench,
}
type FeatureToggleItem = {
key: keyof FeatureToggles
label: string
description: string
icon: FeatureToggleIconKey
}
const featuresList = featureToggleSettings as FeatureToggleItem[]
function FeatureToggleHeader({ enabledCount, totalCount }: { enabledCount: number; totalCount: number }) {
return (
<PageRenderer
schema={featureToggleSchema as PageSchema}
data={data}
functions={handlers}
/>
<div className="mb-6">
<h2 className="text-2xl font-bold mb-2">Feature Toggles</h2>
<p className="text-muted-foreground">
Enable or disable features to customize your workspace. {enabledCount} of {totalCount} features enabled.
</p>
</div>
)
}
function FeatureToggleCard({
item,
enabled,
onToggle,
}: {
item: FeatureToggleItem
enabled: boolean
onToggle: (value: boolean) => void
}) {
const Icon = iconMap[item.icon]
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${enabled ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
<Icon size={20} weight="duotone" />
</div>
<div>
<CardTitle className="text-base">{item.label}</CardTitle>
<CardDescription className="text-xs mt-1">{item.description}</CardDescription>
</div>
</div>
<Switch id={item.key} checked={enabled} onCheckedChange={onToggle} />
</div>
</CardHeader>
</Card>
)
}
function FeatureToggleGrid({
items,
features,
onToggle,
}: {
items: FeatureToggleItem[]
features: FeatureToggles
onToggle: (key: keyof FeatureToggles, value: boolean) => void
}) {
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pr-4">
{items.map((item) => (
<FeatureToggleCard
key={item.key}
item={item}
enabled={features[item.key]}
onToggle={(checked) => onToggle(item.key, checked)}
/>
))}
</div>
)
}
export function FeatureToggleSettings({ features, onFeaturesChange }: FeatureToggleSettingsProps) {
const handleToggle = (key: keyof FeatureToggles, value: boolean) => {
onFeaturesChange({
...features,
[key]: value,
})
}
const enabledCount = Object.values(features).filter(Boolean).length
const totalCount = Object.keys(features).length
return (
<div className="h-full p-6 bg-background">
<FeatureToggleHeader enabledCount={enabledCount} totalCount={totalCount} />
<ScrollArea className="h-[calc(100vh-200px)]">
<FeatureToggleGrid items={featuresList} features={features} onToggle={handleToggle} />
</ScrollArea>
</div>
)
}

View File

@@ -1,11 +1,24 @@
import { useMemo, useState } from 'react'
import showcaseCopy from '@/config/ui-examples/showcase.json'
import dashboardExample from '@/config/ui-examples/dashboard.json'
import formExample from '@/config/ui-examples/form.json'
import tableExample from '@/config/ui-examples/table.json'
import listTableTimelineExample from '@/config/ui-examples/list-table-timeline.json'
import settingsExample from '@/config/ui-examples/settings.json'
import { FileCode, ChartBar, ListBullets, Table, Gear, Clock } from '@phosphor-icons/react'
import { ShowcaseHeader } from '@/components/json-ui-showcase/ShowcaseHeader'
import { ShowcaseTabs } from '@/components/json-ui-showcase/ShowcaseTabs'
import { ShowcaseFooter } from '@/components/json-ui-showcase/ShowcaseFooter'
import { ShowcaseExample } from '@/components/json-ui-showcase/types'
const exampleConfigs = {
dashboard: dashboardExample,
form: formExample,
table: tableExample,
'list-table-timeline': listTableTimelineExample,
settings: settingsExample,
}
const exampleIcons = {
ChartBar,
ListBullets,
@@ -14,22 +27,14 @@ const exampleIcons = {
Gear,
}
const configModules = import.meta.glob('/src/config/ui-examples/*.json', { eager: true })
const resolveExampleConfig = (configPath: string) => {
const moduleEntry = configModules[configPath] as { default: ShowcaseExample['config'] } | undefined
return moduleEntry?.default ?? {}
}
export function JSONUIShowcase() {
const [selectedExample, setSelectedExample] = useState(showcaseCopy.defaultExampleKey)
const [showJSON, setShowJSON] = useState(false)
const examples = useMemo<ShowcaseExample[]>(() => {
return showcaseCopy.examples.map((example) => {
const icon = exampleIcons[example.iconId as keyof typeof exampleIcons] || FileCode
const config = resolveExampleConfig(example.configPath)
const icon = exampleIcons[example.icon as keyof typeof exampleIcons] || FileCode
const config = exampleConfigs[example.configKey as keyof typeof exampleConfigs]
return {
key: example.key,

View File

@@ -45,12 +45,11 @@ function getCompletionMessage(score: number): string {
}
export function ProjectDashboard(props: ProjectDashboardProps) {
const completionMetrics = calculateCompletionScore(props)
return (
<JSONPageRenderer
schema={dashboardSchema as any}
data={{ ...props, ...completionMetrics }}
data={props}
functions={{ calculateCompletionScore }}
/>
)
}

View File

@@ -1,6 +1,6 @@
import { Badge } from '@/components/ui/badge'
import { DataSourceType } from '@/types/json-ui'
import { Database, File } from '@phosphor-icons/react'
import { Database, Function, File } from '@phosphor-icons/react'
interface DataSourceBadgeProps {
type: DataSourceType
@@ -13,6 +13,11 @@ const dataSourceConfig = {
label: 'KV Storage',
className: 'bg-accent/20 text-accent border-accent/30'
},
computed: {
icon: Function,
label: 'Computed',
className: 'bg-primary/20 text-primary border-primary/30'
},
static: {
icon: File,
label: 'Static',

View File

@@ -108,7 +108,7 @@ function PageCard({ card, data, functions }: PageCardProps) {
if (card.type === 'gradient-card') {
const computeFn = functions[card.dataSource?.compute]
const computedData = computeFn ? computeFn(data) : data
const computedData = computeFn ? computeFn(data) : {}
return (
<Card className={cn('bg-gradient-to-br border-primary/20', card.gradient)}>

View File

@@ -1,7 +1,7 @@
import { Card, IconButton, Stack, Flex, Text } from '@/components/atoms'
import { Card, Badge, IconButton, Stack, Flex, Text } from '@/components/atoms'
import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
import { DataSource } from '@/types/json-ui'
import { Pencil, Trash } from '@phosphor-icons/react'
import { Pencil, Trash, ArrowsDownUp } from '@phosphor-icons/react'
interface DataSourceCardProps {
dataSource: DataSource
@@ -11,6 +11,13 @@ interface DataSourceCardProps {
}
export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }: DataSourceCardProps) {
const getDependencyCount = () => {
if (dataSource.type === 'computed') {
return dataSource.dependencies?.length || 0
}
return 0
}
const renderTypeSpecificInfo = () => {
if (dataSource.type === 'kv') {
return (
@@ -20,6 +27,18 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
)
}
if (dataSource.type === 'computed') {
const depCount = getDependencyCount()
return (
<Flex align="center" gap="sm">
<Badge variant="outline" className="text-xs">
<ArrowsDownUp className="w-3 h-3 mr-1" />
{depCount} {depCount === 1 ? 'dependency' : 'dependencies'}
</Badge>
</Flex>
)
}
return null
}
@@ -40,7 +59,7 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
{dependents.length > 0 && (
<div className="pt-2 border-t border-border/50">
<Text variant="caption">
Used by {dependents.length} dependent {dependents.length === 1 ? 'source' : 'sources'}
Used by {dependents.length} computed {dependents.length === 1 ? 'source' : 'sources'}
</Text>
</div>
)}

View File

@@ -5,12 +5,14 @@ import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
import { DataSourceIdField } from '@/components/molecules/data-source-editor/DataSourceIdField'
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 dataSourceEditorCopy from '@/data/data-source-editor-dialog.json'
import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor'
interface DataSourceEditorDialogProps {
open: boolean
dataSource: DataSource | null
allDataSources: DataSource[]
onOpenChange: (open: boolean) => void
onSave: (dataSource: DataSource) => void
}
@@ -18,13 +20,19 @@ interface DataSourceEditorDialogProps {
export function DataSourceEditorDialog({
open,
dataSource,
allDataSources,
onOpenChange,
onSave,
}: DataSourceEditorDialogProps) {
const {
editingSource,
updateField,
} = useDataSourceEditor(dataSource)
addDependency,
removeDependency,
availableDeps,
selectedDeps,
unselectedDeps,
} = useDataSourceEditor(dataSource, allDataSources)
const handleSave = () => {
if (!editingSource) return
@@ -72,6 +80,18 @@ export function DataSourceEditorDialog({
/>
)}
{editingSource.type === 'computed' && (
<ComputedSourceFields
editingSource={editingSource}
availableDeps={availableDeps}
selectedDeps={selectedDeps}
unselectedDeps={unselectedDeps}
copy={dataSourceEditorCopy.computed}
onUpdateField={updateField}
onAddDependency={addDependency}
onRemoveDependency={removeDependency}
/>
)}
</div>
<DialogFooter>

View File

@@ -0,0 +1,128 @@
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { DataSource } from '@/types/json-ui'
import { X } from '@phosphor-icons/react'
interface ComputedSourceFieldsCopy {
expressionLabel: string
expressionPlaceholder: string
expressionHelp: string
valueTemplateLabel: string
valueTemplatePlaceholder: string
valueTemplateHelp: string
dependenciesLabel: string
availableSourcesLabel: string
emptyDependencies: string
}
interface ComputedSourceFieldsProps {
editingSource: DataSource
availableDeps: DataSource[]
selectedDeps: string[]
unselectedDeps: DataSource[]
copy: ComputedSourceFieldsCopy
onUpdateField: <K extends keyof DataSource>(field: K, value: DataSource[K]) => void
onAddDependency: (depId: string) => void
onRemoveDependency: (depId: string) => void
}
export function ComputedSourceFields({
editingSource,
availableDeps,
selectedDeps,
unselectedDeps,
copy,
onUpdateField,
onAddDependency,
onRemoveDependency,
}: ComputedSourceFieldsProps) {
return (
<>
<div className="space-y-2">
<Label>{copy.expressionLabel}</Label>
<Textarea
value={editingSource.expression || ''}
onChange={(e) => {
onUpdateField('expression', e.target.value)
}}
placeholder={copy.expressionPlaceholder}
className="font-mono text-sm h-24"
/>
<p className="text-xs text-muted-foreground">
{copy.expressionHelp}
</p>
</div>
<div className="space-y-2">
<Label>{copy.valueTemplateLabel}</Label>
<Textarea
value={editingSource.valueTemplate ? JSON.stringify(editingSource.valueTemplate, null, 2) : ''}
onChange={(e) => {
try {
const template = JSON.parse(e.target.value)
onUpdateField('valueTemplate', template)
} catch (err) {
// Invalid JSON
}
}}
placeholder={copy.valueTemplatePlaceholder}
className="font-mono text-sm h-24"
/>
<p className="text-xs text-muted-foreground">
{copy.valueTemplateHelp}
</p>
</div>
<div className="space-y-2">
<Label>{copy.dependenciesLabel}</Label>
{selectedDeps.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded border border-border">
{selectedDeps.map(depId => (
<Badge
key={depId}
variant="secondary"
className="flex items-center gap-1"
>
{depId}
<button
onClick={() => onRemoveDependency(depId)}
className="ml-1 hover:text-destructive"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
)}
{unselectedDeps.length > 0 && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{copy.availableSourcesLabel}</Label>
<div className="flex flex-wrap gap-2">
{unselectedDeps.map(ds => (
<Button
key={ds.id}
variant="outline"
size="sm"
onClick={() => onAddDependency(ds.id)}
className="h-7 text-xs"
>
+ {ds.id}
</Button>
))}
</div>
</div>
)}
{availableDeps.length === 0 && selectedDeps.length === 0 && (
<p className="text-sm text-muted-foreground">
{copy.emptyDependencies}
</p>
)}
</div>
</>
)
}

View File

@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DataSourceEditorDialog } from '@/components/molecules/DataSourceEditorDialog'
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
import { DataSource, DataSourceType } from '@/types/json-ui'
import { Database, FileText } from '@phosphor-icons/react'
import { Database, Function, FileText } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { EmptyState, Stack } from '@/components/atoms'
import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader'
@@ -66,6 +66,7 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
const groupedSources = {
kv: localSources.filter(ds => ds.type === 'kv'),
computed: localSources.filter(ds => ds.type === 'computed'),
static: localSources.filter(ds => ds.type === 'static'),
}
@@ -109,6 +110,15 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
onEdit={handleEditSource}
onDelete={handleDeleteSource}
/>
<DataSourceGroupSection
icon={<Function size={16} />}
label={dataSourceManagerCopy.groups.computed}
dataSources={groupedSources.computed}
getDependents={getDependents}
onEdit={handleEditSource}
onDelete={handleDeleteSource}
/>
</Stack>
)}
</CardContent>
@@ -117,6 +127,7 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
<DataSourceEditorDialog
open={dialogOpen}
dataSource={editingSource}
allDataSources={localSources}
onOpenChange={setDialogOpen}
onSave={handleSaveSource}
/>

View File

@@ -5,7 +5,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ActionButton, Heading, Stack, Text } from '@/components/atoms'
import { Plus, Database, FileText } from '@phosphor-icons/react'
import { Plus, Database, Function, FileText } from '@phosphor-icons/react'
import { DataSourceType } from '@/types/json-ui'
interface DataSourceManagerHeaderCopy {
@@ -14,6 +14,7 @@ interface DataSourceManagerHeaderCopy {
addLabel: string
menu: {
kv: string
computed: string
static: string
}
}
@@ -48,6 +49,10 @@ export function DataSourceManagerHeader({ copy, onAdd }: DataSourceManagerHeader
<Database className="w-4 h-4 mr-2" />
{copy.menu.kv}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAdd('computed')}>
<Function className="w-4 h-4 mr-2" />
{copy.menu.computed}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAdd('static')}>
<FileText className="w-4 h-4 mr-2" />
{copy.menu.static}

View File

@@ -37,6 +37,13 @@ export function useDataSource(source: DataSource) {
loading: false,
error: null,
}
case 'computed':
return {
data: source.defaultValue,
setData: () => {},
loading: false,
error: null,
}
default:
return {
data: null,
@@ -60,7 +67,7 @@ export function useDataSources(sources: DataSource[]) {
useEffect(() => {
sources.forEach((source) => {
if (source.type === 'static') {
if (source.type === 'static' || source.type === 'computed') {
updateData(source.id, source.defaultValue)
}
})

View File

@@ -10,7 +10,7 @@ export const ActionSchema = z.object({
export const DataSourceSchema = z.object({
id: z.string(),
type: z.enum(['kv', 'api', 'static'], { message: 'Invalid data source type' }),
type: z.enum(['kv', 'api', 'computed', 'static'], { message: 'Invalid data source type' }),
key: z.string().optional(),
endpoint: z.string().optional(),
transform: z.string().optional(),

View File

@@ -33,20 +33,15 @@
},
{
"id": "selectedTree",
"type": "static",
"expression": "data.trees.find(id === data.selectedTreeId)",
"dependencies": [
"trees",
"selectedTreeId"
]
"type": "computed",
"compute": "(data) => data.trees?.find(t => t.id === data.selectedTreeId) || null",
"dependencies": ["trees", "selectedTreeId"]
},
{
"id": "treeCount",
"type": "static",
"expression": "data.trees.length",
"dependencies": [
"trees"
]
"type": "computed",
"compute": "(data) => (data.trees || []).length",
"dependencies": ["trees"]
}
],
"components": [
@@ -141,145 +136,55 @@
},
"children": [
{
"id": "tree-selection-state",
"id": "empty-state",
"type": "div",
"conditional": {
"if": "selectedTree != null",
"then": {
"id": "tree-editor",
"props": {
"className": "flex-1 flex items-center justify-center"
},
"condition": {
"source": "selectedTree",
"transform": "(val) => !val"
},
"children": [
{
"id": "empty-state-content",
"type": "div",
"props": {
"className": "flex-1 p-6 overflow-auto"
"className": "text-center space-y-4"
},
"children": [
{
"id": "tree-header",
"type": "div",
"id": "empty-state-title",
"type": "Heading",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "tree-name",
"type": "Heading",
"props": {
"className": "text-3xl font-bold mb-2"
},
"bindings": {
"children": {
"source": "selectedTree",
"path": "name"
}
}
},
{
"id": "tree-description",
"type": "Text",
"props": {
"className": "text-muted-foreground"
},
"bindings": {
"children": {
"source": "selectedTree",
"path": "description"
}
}
}
]
"className": "text-2xl font-bold text-muted-foreground",
"children": "No Tree Selected"
}
},
{
"id": "tree-canvas",
"type": "Card",
"id": "empty-state-description",
"type": "Text",
"props": {
"className": "min-h-[500px]"
},
"children": [
{
"id": "canvas-header",
"type": "CardHeader",
"children": [
{
"id": "canvas-title",
"type": "CardTitle",
"props": {
"children": "Component Hierarchy"
}
},
{
"id": "canvas-description",
"type": "CardDescription",
"props": {
"children": "Build your component tree structure"
}
}
]
},
{
"id": "canvas-content",
"type": "CardContent",
"children": [
{
"id": "canvas-placeholder",
"type": "div",
"props": {
"className": "text-center text-muted-foreground py-12 border-2 border-dashed border-border rounded-lg",
"children": "Component tree builder - Add components to build your hierarchy"
}
}
]
}
]
}
]
},
"else": {
"id": "empty-state",
"type": "div",
"props": {
"className": "flex-1 flex items-center justify-center"
},
"children": [
"className": "text-muted-foreground",
"children": "Select a component tree from the sidebar or create a new one"
}
},
{
"id": "empty-state-content",
"type": "div",
"id": "empty-state-button",
"type": "Button",
"props": {
"className": "text-center space-y-4"
"variant": "default",
"children": "Create Your First Tree"
},
"children": [
"events": [
{
"id": "empty-state-title",
"type": "Heading",
"props": {
"className": "text-2xl font-bold text-muted-foreground",
"children": "No Tree Selected"
}
},
{
"id": "empty-state-description",
"type": "Text",
"props": {
"className": "text-muted-foreground",
"children": "Select a component tree from the sidebar or create a new one"
}
},
{
"id": "empty-state-button",
"type": "Button",
"props": {
"variant": "default",
"children": "Create Your First Tree"
},
"events": [
"event": "click",
"actions": [
{
"event": "click",
"actions": [
{
"id": "open-create-from-empty",
"type": "set-value",
"target": "createDialogOpen",
"value": true
}
]
"id": "open-create-from-empty",
"type": "set-value",
"target": "createDialogOpen",
"value": true
}
]
}
@@ -287,7 +192,98 @@
}
]
}
}
]
},
{
"id": "tree-editor",
"type": "div",
"props": {
"className": "flex-1 p-6 overflow-auto"
},
"condition": {
"source": "selectedTree",
"transform": "(val) => !!val"
},
"children": [
{
"id": "tree-header",
"type": "div",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "tree-name",
"type": "Heading",
"props": {
"className": "text-3xl font-bold mb-2"
},
"bindings": {
"children": {
"source": "selectedTree",
"path": "name"
}
}
},
{
"id": "tree-description",
"type": "Text",
"props": {
"className": "text-muted-foreground"
},
"bindings": {
"children": {
"source": "selectedTree",
"path": "description"
}
}
}
]
},
{
"id": "tree-canvas",
"type": "Card",
"props": {
"className": "min-h-[500px]"
},
"children": [
{
"id": "canvas-header",
"type": "CardHeader",
"children": [
{
"id": "canvas-title",
"type": "CardTitle",
"props": {
"children": "Component Hierarchy"
}
},
{
"id": "canvas-description",
"type": "CardDescription",
"props": {
"children": "Build your component tree structure"
}
}
]
},
{
"id": "canvas-content",
"type": "CardContent",
"children": [
{
"id": "canvas-placeholder",
"type": "div",
"props": {
"className": "text-center text-muted-foreground py-12 border-2 border-dashed border-border rounded-lg",
"children": "Component tree builder - Add components to build your hierarchy"
}
}
]
}
]
}
]
}
]
}
@@ -295,4 +291,4 @@
}
],
"globalActions": []
}
}

View File

@@ -6,6 +6,10 @@
"title": "Project Completeness",
"icon": "CheckCircle",
"gradient": "from-primary/10 to-accent/10",
"dataSource": {
"type": "computed",
"compute": "calculateCompletionScore"
},
"components": [
{
"type": "metric",

View File

@@ -133,11 +133,9 @@
"data": [
{
"id": "activeFile",
"type": "static",
"expression": "data.files.0",
"dependencies": [
"files"
]
"type": "computed",
"dependencies": ["files", "activeFileId"],
"compute": "context.files.find(f => f.id === context.activeFileId)"
}
],
"actions": [

View File

@@ -35,28 +35,27 @@
},
{
"id": "selectedBlueprint",
"type": "static",
"expression": "data.flaskConfig.blueprints.find(id === data.selectedBlueprintId)",
"dependencies": [
"flaskConfig",
"selectedBlueprintId"
]
"type": "computed",
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.find(b => b.id === data.selectedBlueprintId) || null; }",
"dependencies": ["flaskConfig", "selectedBlueprintId"]
},
{
"id": "blueprintCount",
"type": "static",
"expression": "data.flaskConfig.blueprints.length",
"dependencies": [
"flaskConfig"
]
"type": "computed",
"compute": "(data) => ((data.flaskConfig || {}).blueprints || []).length",
"dependencies": ["flaskConfig"]
},
{
"id": "endpointCount",
"type": "static",
"expression": "data.selectedBlueprint.endpoints.length",
"dependencies": [
"selectedBlueprint"
]
"type": "computed",
"compute": "(data) => { const bp = data.selectedBlueprint; return bp ? (bp.endpoints || []).length : 0; }",
"dependencies": ["selectedBlueprint"]
},
{
"id": "totalEndpoints",
"type": "computed",
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.reduce((sum, bp) => sum + (bp.endpoints || []).length, 0); }",
"dependencies": ["flaskConfig"]
}
],
"components": [

View File

@@ -27,20 +27,15 @@
},
{
"id": "selectedLambda",
"type": "static",
"expression": "data.lambdas.find(id === data.selectedLambdaId)",
"dependencies": [
"lambdas",
"selectedLambdaId"
]
"type": "computed",
"compute": "(data) => data.lambdas?.find(l => l.id === data.selectedLambdaId) || null",
"dependencies": ["lambdas", "selectedLambdaId"]
},
{
"id": "lambdaCount",
"type": "static",
"expression": "data.lambdas.length",
"dependencies": [
"lambdas"
]
"type": "computed",
"compute": "(data) => (data.lambdas || []).length",
"dependencies": ["lambdas"]
}
],
"components": [
@@ -76,9 +71,7 @@
"props": {
"className": "text-2xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent"
},
"children": [
"Lambdas"
]
"children": ["Lambdas"]
},
{
"type": "Badge",
@@ -140,9 +133,7 @@
"children": [
{
"type": "text",
"children": [
"Lambda list will be rendered here"
]
"children": ["Lambda list will be rendered here"]
}
]
},
@@ -168,18 +159,14 @@
"props": {
"className": "text-lg font-semibold mb-2"
},
"children": [
"No Lambdas Yet"
]
"children": ["No Lambdas Yet"]
},
{
"type": "p",
"props": {
"className": "text-sm text-muted-foreground mb-4"
},
"children": [
"Create your first serverless function"
]
"children": ["Create your first serverless function"]
}
]
}
@@ -195,106 +182,101 @@
},
"children": [
{
"id": "lambda-selection-state",
"type": "div",
"props": {
"className": "flex-1 flex items-center justify-center p-8"
},
"conditional": {
"if": "selectedLambda != null",
"then": {
"if": "selectedLambda"
},
"children": [
{
"type": "div",
"props": {
"className": "flex-1 flex items-center justify-center p-8"
"className": "max-w-6xl mx-auto w-full space-y-6"
},
"children": [
{
"type": "div",
"props": {
"className": "max-w-6xl mx-auto w-full space-y-6"
"className": "flex items-center justify-between"
},
"children": [
{
"type": "div",
"props": {
"className": "flex items-center justify-between"
},
"children": [
{
"type": "div",
"children": [
{
"type": "h1",
"props": {
"className": "text-3xl font-bold"
},
"bindings": {
"children": {
"source": "selectedLambda",
"path": "name"
}
}
},
{
"type": "p",
"props": {
"className": "text-muted-foreground"
},
"bindings": {
"children": {
"source": "selectedLambda",
"path": "description"
}
}
"type": "h1",
"props": {
"className": "text-3xl font-bold"
},
"bindings": {
"children": {
"source": "selectedLambda",
"path": "name"
}
]
}
},
{
"type": "p",
"props": {
"className": "text-muted-foreground"
},
"bindings": {
"children": {
"source": "selectedLambda",
"path": "description"
}
}
}
]
}
]
}
]
},
"else": {
}
]
},
{
"type": "div",
"props": {
"className": "flex-1 flex items-center justify-center p-8"
},
"conditional": {
"if": "!selectedLambda"
},
"children": [
{
"type": "div",
"props": {
"className": "flex-1 flex items-center justify-center p-8"
"className": "text-center"
},
"children": [
{
"type": "div",
"type": "icon",
"props": {
"className": "text-center"
"name": "Code",
"className": "h-20 w-20 text-muted-foreground/50 mx-auto mb-4",
"weight": "duotone"
}
},
{
"type": "h3",
"props": {
"className": "text-xl font-semibold mb-2"
},
"children": [
{
"type": "icon",
"props": {
"name": "Code",
"className": "h-20 w-20 text-muted-foreground/50 mx-auto mb-4",
"weight": "duotone"
}
},
{
"type": "h3",
"props": {
"className": "text-xl font-semibold mb-2"
},
"children": [
"No Lambda Selected"
]
},
{
"type": "p",
"props": {
"className": "text-muted-foreground"
},
"children": [
"Select a lambda from the sidebar or create a new one"
]
}
]
"children": ["No Lambda Selected"]
},
{
"type": "p",
"props": {
"className": "text-muted-foreground"
},
"children": ["Select a lambda from the sidebar or create a new one"]
}
]
}
}
]
}
]
}

View File

@@ -28,20 +28,15 @@
},
{
"id": "selectedModel",
"type": "static",
"expression": "data.models.find(id === data.selectedModelId)",
"dependencies": [
"models",
"selectedModelId"
]
"type": "computed",
"compute": "(data) => data.models?.find(m => m.id === data.selectedModelId) || null",
"dependencies": ["models", "selectedModelId"]
},
{
"id": "modelCount",
"type": "static",
"expression": "data.models.length",
"dependencies": [
"models"
]
"type": "computed",
"compute": "(data) => (data.models || []).length",
"dependencies": ["models"]
}
],
"components": [
@@ -136,142 +131,55 @@
},
"children": [
{
"id": "model-selection-state",
"id": "empty-state",
"type": "div",
"conditional": {
"if": "selectedModel != null",
"then": {
"id": "model-editor",
"props": {
"className": "flex-1 flex items-center justify-center"
},
"condition": {
"source": "selectedModel",
"transform": "(val) => !val"
},
"children": [
{
"id": "empty-state-content",
"type": "div",
"props": {
"className": "flex-1 p-6 overflow-auto"
"className": "text-center space-y-4"
},
"children": [
{
"id": "model-header",
"type": "div",
"id": "empty-state-title",
"type": "Heading",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "model-name",
"type": "Heading",
"props": {
"className": "text-3xl font-bold mb-2"
},
"bindings": {
"children": {
"source": "selectedModel",
"path": "name"
}
}
},
{
"id": "model-description",
"type": "Text",
"props": {
"className": "text-muted-foreground"
},
"bindings": {
"children": {
"source": "selectedModel",
"path": "description"
}
}
}
]
"className": "text-2xl font-bold text-muted-foreground",
"children": "No Model Selected"
}
},
{
"id": "fields-card",
"type": "Card",
"children": [
{
"id": "fields-header",
"type": "CardHeader",
"children": [
{
"id": "fields-title",
"type": "CardTitle",
"props": {
"children": "Model Fields"
}
},
{
"id": "fields-description",
"type": "CardDescription",
"props": {
"children": "Define the fields and their types for this model"
}
}
]
},
{
"id": "fields-content",
"type": "CardContent",
"children": [
{
"id": "fields-placeholder",
"type": "div",
"props": {
"className": "text-center text-muted-foreground py-8 border-2 border-dashed border-border rounded-lg",
"children": "Add fields to define your data model"
}
}
]
}
]
}
]
},
"else": {
"id": "empty-state",
"type": "div",
"props": {
"className": "flex-1 flex items-center justify-center"
},
"children": [
{
"id": "empty-state-content",
"type": "div",
"id": "empty-state-description",
"type": "Text",
"props": {
"className": "text-center space-y-4"
"className": "text-muted-foreground",
"children": "Select a model from the sidebar or create a new one"
}
},
{
"id": "empty-state-button",
"type": "Button",
"props": {
"variant": "default",
"children": "Create Your First Model"
},
"children": [
"events": [
{
"id": "empty-state-title",
"type": "Heading",
"props": {
"className": "text-2xl font-bold text-muted-foreground",
"children": "No Model Selected"
}
},
{
"id": "empty-state-description",
"type": "Text",
"props": {
"className": "text-muted-foreground",
"children": "Select a model from the sidebar or create a new one"
}
},
{
"id": "empty-state-button",
"type": "Button",
"props": {
"variant": "default",
"children": "Create Your First Model"
},
"events": [
"event": "click",
"actions": [
{
"event": "click",
"actions": [
{
"id": "open-create-from-empty",
"type": "set-value",
"target": "createDialogOpen",
"value": true
}
]
"id": "open-create-from-empty",
"type": "set-value",
"target": "createDialogOpen",
"value": true
}
]
}
@@ -279,7 +187,95 @@
}
]
}
}
]
},
{
"id": "model-editor",
"type": "div",
"props": {
"className": "flex-1 p-6 overflow-auto"
},
"condition": {
"source": "selectedModel",
"transform": "(val) => !!val"
},
"children": [
{
"id": "model-header",
"type": "div",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "model-name",
"type": "Heading",
"props": {
"className": "text-3xl font-bold mb-2"
},
"bindings": {
"children": {
"source": "selectedModel",
"path": "name"
}
}
},
{
"id": "model-description",
"type": "Text",
"props": {
"className": "text-muted-foreground"
},
"bindings": {
"children": {
"source": "selectedModel",
"path": "description"
}
}
}
]
},
{
"id": "fields-card",
"type": "Card",
"children": [
{
"id": "fields-header",
"type": "CardHeader",
"children": [
{
"id": "fields-title",
"type": "CardTitle",
"props": {
"children": "Model Fields"
}
},
{
"id": "fields-description",
"type": "CardDescription",
"props": {
"children": "Define the fields and their types for this model"
}
}
]
},
{
"id": "fields-content",
"type": "CardContent",
"children": [
{
"id": "fields-placeholder",
"type": "div",
"props": {
"className": "text-center text-muted-foreground py-8 border-2 border-dashed border-border rounded-lg",
"children": "Add fields to define your data model"
}
}
]
}
]
}
]
}
]
}

View File

@@ -5,15 +5,63 @@
"id": "lastSaved",
"type": "static",
"defaultValue": null
},
{
"id": "currentTime",
"type": "static",
"defaultValue": 0,
"polling": {
"interval": 10000,
"update": "() => Date.now()"
}
},
{
"id": "isRecent",
"type": "computed",
"compute": "(data) => { if (!data.lastSaved) return false; return Date.now() - data.lastSaved < 3000; }",
"dependencies": ["lastSaved", "currentTime"]
},
{
"id": "timeAgo",
"type": "computed",
"compute": "(data) => { if (!data.lastSaved) return ''; const seconds = Math.floor((Date.now() - data.lastSaved) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; }",
"dependencies": ["lastSaved", "currentTime"]
}
],
"type": "SaveIndicator",
"conditional": {
"if": "lastSaved != null"
"type": "div",
"props": {
"className": "flex items-center gap-1.5 text-xs text-muted-foreground"
},
"bindings": {
"lastSaved": {
"source": "lastSaved"
"conditional": {
"if": "lastSaved !== null"
},
"children": [
{
"id": "status-icon",
"type": "StatusIcon",
"dataBinding": {
"type": {
"source": "isRecent",
"transform": "isRecent => isRecent ? 'saved' : 'synced'"
},
"animate": {
"source": "isRecent"
}
}
},
{
"id": "time-text",
"type": "span",
"props": {
"className": "hidden sm:inline"
},
"dataBinding": {
"children": {
"source": "isRecent",
"path": null,
"transform": "(isRecent, data) => isRecent ? 'Saved' : data.timeAgo"
}
}
}
}
]
}

View File

@@ -54,27 +54,21 @@
},
{
"id": "activeVariant",
"type": "static",
"expression": "data.theme.variants.find(id === data.theme.activeVariantId)",
"dependencies": [
"theme"
]
"type": "computed",
"compute": "(data) => { const theme = data.theme || {}; const variants = theme.variants || []; return variants.find(v => v.id === theme.activeVariantId) || variants[0] || null; }",
"dependencies": ["theme"]
},
{
"id": "variantCount",
"type": "static",
"expression": "data.theme.variants.length",
"dependencies": [
"theme"
]
"type": "computed",
"compute": "(data) => ((data.theme || {}).variants || []).length",
"dependencies": ["theme"]
},
{
"id": "customColorCount",
"type": "static",
"expression": "Object.keys(data.activeVariant.colors.customColors).length",
"dependencies": [
"activeVariant"
]
"type": "computed",
"compute": "(data) => { const variant = data.activeVariant; if (!variant || !variant.colors) return 0; return Object.keys(variant.colors.customColors || {}).length; }",
"dependencies": ["activeVariant"]
}
],
"components": [

View File

@@ -32,20 +32,15 @@
},
{
"id": "selectedWorkflow",
"type": "static",
"expression": "data.workflows.find(id === data.selectedWorkflowId)",
"dependencies": [
"workflows",
"selectedWorkflowId"
]
"type": "computed",
"compute": "(data) => data.workflows?.find(w => w.id === data.selectedWorkflowId) || null",
"dependencies": ["workflows", "selectedWorkflowId"]
},
{
"id": "workflowCount",
"type": "static",
"expression": "data.workflows.length",
"dependencies": [
"workflows"
]
"type": "computed",
"compute": "(data) => (data.workflows || []).length",
"dependencies": ["workflows"]
}
],
"components": [
@@ -76,9 +71,7 @@
"props": {
"className": "text-xl font-bold mb-2 flex items-center gap-2"
},
"children": [
"Workflows"
]
"children": ["Workflows"]
},
{
"id": "create-button",
@@ -124,9 +117,7 @@
"props": {
"className": "text-sm text-muted-foreground"
},
"children": [
"Status Filter"
]
"children": ["Status Filter"]
}
]
},
@@ -145,9 +136,7 @@
"props": {
"className": "text-center py-8 text-muted-foreground"
},
"children": [
"No workflows yet"
]
"children": ["No workflows yet"]
}
]
}
@@ -161,129 +150,122 @@
},
"children": [
{
"id": "workflow-selection-state",
"id": "empty-state",
"type": "div",
"props": {
"className": "flex-1 flex items-center justify-center"
},
"conditional": {
"if": "selectedWorkflow != null",
"then": {
"id": "workflow-editor",
"if": "!selectedWorkflow"
},
"children": [
{
"id": "empty-state-content",
"type": "div",
"props": {
"className": "flex-1 p-6 overflow-auto"
"className": "text-center space-y-4"
},
"children": [
{
"id": "workflow-header",
"type": "div",
"type": "icon",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "workflow-name",
"type": "h1",
"props": {
"className": "text-3xl font-bold mb-2"
},
"bindings": {
"children": {
"source": "selectedWorkflow",
"path": "name"
}
}
},
{
"id": "workflow-description",
"type": "p",
"props": {
"className": "text-muted-foreground"
},
"bindings": {
"children": {
"source": "selectedWorkflow",
"path": "description"
}
}
}
]
"name": "GitBranch",
"className": "h-20 w-20 text-muted-foreground/50 mx-auto",
"weight": "duotone"
}
},
{
"id": "workflow-canvas",
"type": "Card",
"id": "empty-state-title",
"type": "h3",
"props": {
"className": "min-h-[400px] bg-muted/20"
"className": "text-2xl font-bold text-muted-foreground"
},
"children": [
{
"id": "canvas-content",
"type": "CardContent",
"props": {
"className": "p-6"
},
"children": [
{
"id": "canvas-placeholder",
"type": "div",
"props": {
"className": "text-center text-muted-foreground py-12"
},
"children": [
"Workflow canvas - Add nodes to build your workflow"
]
}
]
"children": ["No Workflow Selected"]
},
{
"id": "empty-state-description",
"type": "p",
"props": {
"className": "text-muted-foreground"
},
"children": ["Select a workflow from the sidebar or create a new one"]
}
]
}
]
},
{
"id": "workflow-editor",
"type": "div",
"props": {
"className": "flex-1 p-6 overflow-auto"
},
"conditional": {
"if": "selectedWorkflow"
},
"children": [
{
"id": "workflow-header",
"type": "div",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "workflow-name",
"type": "h1",
"props": {
"className": "text-3xl font-bold mb-2"
},
"bindings": {
"children": {
"source": "selectedWorkflow",
"path": "name"
}
]
}
},
{
"id": "workflow-description",
"type": "p",
"props": {
"className": "text-muted-foreground"
},
"bindings": {
"children": {
"source": "selectedWorkflow",
"path": "description"
}
}
}
]
},
"else": {
"id": "empty-state",
"type": "div",
{
"id": "workflow-canvas",
"type": "Card",
"props": {
"className": "flex-1 flex items-center justify-center"
"className": "min-h-[400px] bg-muted/20"
},
"children": [
{
"id": "empty-state-content",
"type": "div",
"id": "canvas-content",
"type": "CardContent",
"props": {
"className": "text-center space-y-4"
"className": "p-6"
},
"children": [
{
"type": "icon",
"id": "canvas-placeholder",
"type": "div",
"props": {
"name": "GitBranch",
"className": "h-20 w-20 text-muted-foreground/50 mx-auto",
"weight": "duotone"
}
},
{
"id": "empty-state-title",
"type": "h3",
"props": {
"className": "text-2xl font-bold text-muted-foreground"
"className": "text-center text-muted-foreground py-12"
},
"children": [
"No Workflow Selected"
]
},
{
"id": "empty-state-description",
"type": "p",
"props": {
"className": "text-muted-foreground"
},
"children": [
"Select a workflow from the sidebar or create a new one"
]
"children": ["Workflow canvas - Add nodes to build your workflow"]
}
]
}
]
}
}
]
}
]
}

View File

@@ -15,36 +15,36 @@
"key": "dashboard",
"name": "Dashboard",
"description": "Complete dashboard with stats, activity feed, and quick actions",
"iconId": "ChartBar",
"configPath": "/src/config/ui-examples/dashboard.json"
"icon": "ChartBar",
"configKey": "dashboard"
},
{
"key": "form",
"name": "Form",
"description": "Dynamic form with validation and data binding",
"iconId": "ListBullets",
"configPath": "/src/config/ui-examples/form.json"
"icon": "ListBullets",
"configKey": "form"
},
{
"key": "table",
"name": "Data Table",
"description": "Interactive table with row actions and looping",
"iconId": "Table",
"configPath": "/src/config/ui-examples/table.json"
"icon": "Table",
"configKey": "table"
},
{
"key": "bindings",
"name": "Bindings",
"description": "List, table, and timeline bindings with shared data sources",
"iconId": "Clock",
"configPath": "/src/config/ui-examples/list-table-timeline.json"
"icon": "Clock",
"configKey": "list-table-timeline"
},
{
"key": "settings",
"name": "Settings",
"description": "Tabbed settings panel with switches and selections",
"iconId": "Gear",
"configPath": "/src/config/ui-examples/settings.json"
"icon": "Gear",
"configKey": "settings"
}
],
"footer": {

View File

@@ -1,7 +1,7 @@
{
"header": {
"title": "Data Binding Designer",
"description": "Connect UI components to KV storage and static data"
"description": "Connect UI components to KV storage and computed values"
},
"bindingsCard": {
"title": "Component Bindings",
@@ -13,6 +13,7 @@
"title": "How It Works",
"steps": [
"Create data sources (KV store for persistence, static for constants)",
"Add computed sources to derive values from other sources",
"Bind component properties to data sources for reactive updates"
]
},
@@ -33,6 +34,12 @@
"key": "app-counter",
"defaultValue": 0
},
{
"id": "displayName",
"type": "computed",
"dependencies": ["userProfile"],
"expression": "data.userProfile.name"
}
],
"components": [
{
@@ -43,8 +50,7 @@
},
"bindings": {
"children": {
"source": "userProfile",
"path": "name"
"source": "displayName"
}
}
},

View File

@@ -1,6 +1,6 @@
{
"title": "Edit Data Source",
"description": "Configure the data source settings",
"description": "Configure the data source settings and dependencies",
"fields": {
"id": {
"label": "ID",
@@ -18,6 +18,17 @@
"valueLabel": "Value (JSON)",
"valuePlaceholder": "{\"key\": \"value\"}"
},
"computed": {
"expressionLabel": "Expression",
"expressionPlaceholder": "data.source1",
"expressionHelp": "Expression that computes the value from other data sources",
"valueTemplateLabel": "Value Template (JSON)",
"valueTemplatePlaceholder": "{\n \"total\": \"data.items.length\"\n}",
"valueTemplateHelp": "Template object with expressions for computed fields",
"dependenciesLabel": "Dependencies",
"availableSourcesLabel": "Available Sources",
"emptyDependencies": "No data sources available. Create KV or static sources first."
},
"actions": {
"cancel": "Cancel",
"save": "Save Changes"

View File

@@ -1,13 +1,14 @@
{
"header": {
"title": "Data Sources",
"description": "Manage KV storage and static data"
"description": "Manage KV storage, computed values, and static data"
},
"actions": {
"add": "Add Data Source"
},
"menu": {
"kv": "KV Store",
"computed": "Computed Value",
"static": "Static Data"
},
"emptyState": {
@@ -16,11 +17,12 @@
},
"groups": {
"kv": "KV Store",
"static": "Static Data"
"static": "Static Data",
"computed": "Computed Values"
},
"toasts": {
"deleteBlockedTitle": "Cannot delete",
"deleteBlockedDescription": "This source is used by {count} dependent {noun}",
"deleteBlockedDescription": "This source is used by {count} computed {noun}",
"deleted": "Data source deleted",
"updated": "Data source updated"
}

View File

@@ -1,8 +1,9 @@
import { useCallback, useEffect, useState } from 'react'
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)
@@ -14,8 +15,44 @@ export function useDataSourceEditor(
setEditingSource(prev => (prev ? { ...prev, [field]: value } : prev))
}, [])
const addDependency = useCallback((depId: string) => {
setEditingSource(prev => {
if (!prev || prev.type !== 'computed') return prev
const deps = prev.dependencies || []
if (deps.includes(depId)) return prev
return { ...prev, dependencies: [...deps, depId] }
})
}, [])
const removeDependency = useCallback((depId: string) => {
setEditingSource(prev => {
if (!prev || prev.type !== 'computed') return prev
const deps = prev.dependencies || []
return { ...prev, dependencies: deps.filter(dep => dep !== depId) }
})
}, [])
const availableDeps = useMemo(() => {
if (!editingSource) return []
return allDataSources.filter(
ds => ds.id !== editingSource.id && ds.type !== 'computed',
)
}, [allDataSources, editingSource])
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
const unselectedDeps = useMemo(
() => availableDeps.filter(ds => !selectedDeps.includes(ds.id)),
[availableDeps, selectedDeps],
)
return {
editingSource,
updateField,
addDependency,
removeDependency,
availableDeps,
selectedDeps,
unselectedDeps,
}
}

View File

@@ -9,6 +9,7 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
id: `ds-${Date.now()}`,
type,
...(type === 'kv' && { key: '', defaultValue: null }),
...(type === 'computed' && { expression: '', dependencies: [] }),
...(type === 'static' && { defaultValue: null }),
}
@@ -32,6 +33,7 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
const getDependents = useCallback((sourceId: string) => {
return dataSources.filter(ds =>
ds.type === 'computed' &&
ds.dependencies?.includes(sourceId)
)
}, [dataSources])

View File

@@ -1,11 +1,13 @@
import { useKV } from '@/hooks/use-kv'
export type DataSourceType = 'kv' | 'static'
export type DataSourceType = 'kv' | 'static' | 'computed'
export interface DataSourceConfig<T = any> {
type: DataSourceType
key?: string
defaultValue?: T
compute?: (allData: Record<string, any>) => T
dependencies?: string[]
}
export function useKVDataSource<T = any>(key: string, defaultValue?: T) {
@@ -16,6 +18,13 @@ export function useStaticDataSource<T = any>(defaultValue: T) {
return [defaultValue, () => {}, () => {}] as const
}
export function useComputedDataSource<T = any>(
compute: (allData: Record<string, any>) => T,
dependencies: Record<string, any>
) {
return compute(dependencies)
}
export function useMultipleDataSources(_sources: DataSourceConfig[]) {
return {}
}

View File

@@ -41,20 +41,20 @@ export function useDataSources(dataSources: DataSource[]) {
}, [])
useEffect(() => {
const derivedSources = dataSources.filter(ds => ds.expression || ds.valueTemplate)
derivedSources.forEach(source => {
const computedSources = dataSources.filter(ds => ds.type === 'computed')
computedSources.forEach(source => {
const deps = source.dependencies || []
const hasAllDeps = deps.every(dep => dep in data)
if (hasAllDeps) {
const evaluationContext = { data }
const derivedValue = source.expression
const computedValue = source.expression
? evaluateExpression(source.expression, evaluationContext)
: source.valueTemplate
? evaluateTemplate(source.valueTemplate, evaluationContext)
: source.defaultValue
setData(prev => ({ ...prev, [source.id]: derivedValue }))
setData(prev => ({ ...prev, [source.id]: computedValue }))
}
})
}, [data, dataSources])

View File

@@ -13,8 +13,8 @@ export function useDataSources(dataSources: DataSource[]) {
[dataSources]
)
const derivedSources = useMemo(
() => dataSources.filter((ds) => ds.expression || ds.valueTemplate),
const computedSources = useMemo(
() => dataSources.filter((ds) => ds.type === 'computed'),
[dataSources]
)
@@ -54,8 +54,8 @@ export function useDataSources(dataSources: DataSource[]) {
const computedData = useMemo(() => {
const result: Record<string, any> = {}
derivedSources.forEach((ds) => {
const evaluationContext = { data: { ...data, ...result } }
computedSources.forEach((ds) => {
const evaluationContext = { data }
if (ds.expression) {
result[ds.id] = evaluateExpression(ds.expression, evaluationContext)
return
@@ -70,7 +70,7 @@ export function useDataSources(dataSources: DataSource[]) {
})
return result
}, [derivedSources, data])
}, [computedSources, data])
const allData = useMemo(
() => ({ ...data, ...computedData }),

View File

@@ -45,26 +45,22 @@ export function usePage(schema: PageSchema) {
useEffect(() => {
if (schema.data) {
const computed: Record<string, any> = {}
schema.data.forEach(source => {
if (source.expression) {
computed[source.id] = evaluateBindingExpression(source.expression, { ...dataContext, ...computed }, {
fallback: undefined,
label: `derived data (${source.id})`,
})
return
}
if (source.valueTemplate) {
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: { ...dataContext, ...computed } })
return
}
if (source.type === 'static' && source.defaultValue !== undefined) {
if (source.type === 'computed') {
if (source.expression) {
computed[source.id] = evaluateBindingExpression(source.expression, dataContext, {
fallback: undefined,
label: `computed data (${source.id})`,
})
} else if (source.valueTemplate) {
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: dataContext })
}
} else if (source.type === 'static' && source.defaultValue !== undefined) {
computed[source.id] = source.defaultValue
}
})
setComputedData(computed)
}
}, [schema.data, dataContext])

View File

@@ -1,14 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
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) {
@@ -25,15 +27,51 @@ export function useDataSourceEditor({
})
}, [])
const addDependency = useCallback((depId: string) => {
setEditingSource((prev) => {
if (!prev || prev.type !== 'computed') return prev
const deps = prev.dependencies || []
if (deps.includes(depId)) return prev
return { ...prev, dependencies: [...deps, depId] }
})
}, [])
const removeDependency = useCallback((depId: string) => {
setEditingSource((prev) => {
if (!prev || prev.type !== 'computed') return prev
const deps = prev.dependencies || []
return { ...prev, dependencies: deps.filter((id) => id !== depId) }
})
}, [])
const handleSave = useCallback(() => {
if (!editingSource) return
onSave(editingSource)
onOpenChange(false)
}, [editingSource, onOpenChange, onSave])
const availableDeps = useMemo(() => {
if (!editingSource) return []
return allDataSources.filter(
(ds) => ds.id !== editingSource.id && ds.type !== 'computed',
)
}, [allDataSources, editingSource])
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
const unselectedDeps = useMemo(() => {
if (!editingSource) return []
return availableDeps.filter((ds) => !selectedDeps.includes(ds.id))
}, [availableDeps, editingSource, selectedDeps])
return {
editingSource,
updateField,
addDependency,
removeDependency,
handleSave,
availableDeps,
selectedDeps,
unselectedDeps,
}
}

View File

@@ -2,101 +2,6 @@
import { PrismaModel, ComponentNode, ThemeConfig, ProjectFile } from '@/types/project'
import { ProtectedLLMService } from './protected-llm-service'
import { toast } from 'sonner'
import { z } from 'zod'
const componentNodeSchema: z.ZodType<ComponentNode> = z.lazy(() => z.object({
id: z.string(),
type: z.string(),
name: z.string(),
props: z.record(z.any()),
children: z.array(componentNodeSchema)
}))
const prismaFieldSchema = z.object({
id: z.string(),
name: z.string(),
type: z.string(),
isRequired: z.boolean(),
isUnique: z.boolean(),
isArray: z.boolean(),
defaultValue: z.string().optional(),
relation: z.string().optional()
})
const prismaModelSchema = z.object({
id: z.string(),
name: z.string(),
fields: z.array(prismaFieldSchema)
})
const themeSchema = z.object({
primaryColor: z.string(),
secondaryColor: z.string(),
errorColor: z.string(),
warningColor: z.string(),
successColor: z.string(),
fontFamily: z.string(),
fontSize: z.object({
small: z.number(),
medium: z.number(),
large: z.number()
}),
spacing: z.number(),
borderRadius: z.number()
})
const projectFileSchema = z.object({
id: z.string(),
name: z.string(),
path: z.string(),
content: z.string(),
language: z.string()
})
const componentResponseSchema = z.object({ component: componentNodeSchema })
const prismaModelResponseSchema = z.object({ model: prismaModelSchema })
const themeResponseSchema = z.object({ theme: themeSchema })
const suggestFieldsResponseSchema = z.object({ fields: z.array(z.string()) })
const completeAppResponseSchema = z.object({
files: z.array(projectFileSchema),
models: z.array(prismaModelSchema),
theme: themeSchema
})
const parseAndValidateJson = <T,>(
result: string,
schema: z.ZodType<T>,
context: string,
toastMessage: string
): T | null => {
let parsed: unknown
try {
parsed = JSON.parse(result)
} catch (error) {
console.error('AI response JSON parse failed', {
context,
error: error instanceof Error ? error.message : String(error),
rawResponse: result
})
toast.error(toastMessage)
return null
}
const validation = schema.safeParse(parsed)
if (!validation.success) {
console.error('AI response validation failed', {
context,
issues: validation.error.issues,
rawResponse: parsed
})
toast.error(toastMessage)
return null
}
return validation.data
}
export class AIService {
static async generateComponent(description: string): Promise<ComponentNode | null> {
@@ -124,13 +29,8 @@ Make sure to use appropriate Material UI components and props. Keep the structur
)
if (result) {
const parsed = parseAndValidateJson(
result,
componentResponseSchema,
'generate-component',
'AI component response was invalid. Please retry or clarify your description.'
)
return parsed ? parsed.component : null
const parsed = JSON.parse(result)
return parsed.component
}
return null
} catch (error) {
@@ -180,13 +80,8 @@ Return a valid JSON object with a single property "model" containing the model s
)
if (result) {
const parsed = parseAndValidateJson(
result,
prismaModelResponseSchema,
'generate-model',
'AI model response was invalid. Please retry or describe the model differently.'
)
return parsed ? parsed.model : null
const parsed = JSON.parse(result)
return parsed.model
}
return null
} catch (error) {
@@ -277,13 +172,8 @@ Return a valid JSON object with a single property "theme" containing:
)
if (result) {
const parsed = parseAndValidateJson(
result,
themeResponseSchema,
'generate-theme',
'AI theme response was invalid. Please retry or specify the theme requirements.'
)
return parsed ? parsed.theme : null
const parsed = JSON.parse(result)
return parsed.theme
}
return null
} catch (error) {
@@ -312,13 +202,8 @@ Suggest 3-5 common fields that would be useful for this model type. Use camelCas
)
if (result) {
const parsed = parseAndValidateJson(
result,
suggestFieldsResponseSchema,
'suggest-fields',
'AI field suggestions were invalid. Please retry with a clearer model name.'
)
return parsed ? parsed.fields : null
const parsed = JSON.parse(result)
return parsed.fields
}
return null
} catch (error) {
@@ -399,12 +284,7 @@ Create 2-4 essential files for the app structure. Include appropriate Prisma mod
)
if (result) {
return parseAndValidateJson(
result,
completeAppResponseSchema,
'generate-app',
'AI app generation response was invalid. Please retry with more detail.'
)
return JSON.parse(result)
}
return null
} catch (error) {

View File

@@ -1,52 +0,0 @@
import { describe, expect, it } from 'vitest'
import jsonComponentsRegistry from '../../../../json-components-registry.json'
import { getUIComponent } from '../component-registry'
type JsonRegistryEntry = {
type?: string
name?: string
status?: string
source?: string
}
type JsonComponentRegistry = {
components?: JsonRegistryEntry[]
}
const registry = jsonComponentsRegistry as JsonComponentRegistry
const registryEntries = registry.components ?? []
const allowlistedMissingComponents = new Map<string, string>([])
const getTellTaleEntryKey = (entry: JsonRegistryEntry): string | undefined =>
entry.type ?? entry.name
describe('json component registry coverage', () => {
it('resolves every registry entry to a UI component or allowlisted exception', () => {
for (const entry of registryEntries) {
const type = getTellTaleEntryKey(entry)
if (!type) {
throw new Error(
`Registry entry missing type/name. Status: ${entry.status ?? 'unknown'} Source: ${
entry.source ?? 'unknown'
}`
)
}
const component = getUIComponent(type)
if (!component) {
const allowlistedReason = allowlistedMissingComponents.get(type)
if (allowlistedReason) {
expect(
component,
`Allowlisted missing component should stay null: ${type}. Reason: ${allowlistedReason}`
).toBeNull()
continue
}
throw new Error(`Missing UI component for registry type "${type}".`)
}
expect(component, `Registry type "${type}" should resolve to a component.`).toBeTruthy()
}
})
})

View File

@@ -1,6 +1,63 @@
import { ComponentType } from 'react'
import * as PhosphorIcons from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { InputOtp } from '@/components/ui/input-otp'
import { Textarea } from '@/components/ui/textarea'
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 as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertDialog } from '@/components/ui/alert-dialog'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { Carousel } from '@/components/ui/carousel'
import { ChartContainer as Chart } from '@/components/ui/chart'
import { Collapsible } from '@/components/ui/collapsible'
import { Command } from '@/components/ui/command'
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 { DropdownMenu } from '@/components/ui/dropdown-menu'
import { Menubar } from '@/components/ui/menubar'
import { NavigationMenu } from '@/components/ui/navigation-menu'
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 as ShadcnSkeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Pagination } from '@/components/ui/pagination'
import { ResizablePanelGroup as Resizable } from '@/components/ui/resizable'
import { Sheet } from '@/components/ui/sheet'
import { Sidebar } from '@/components/ui/sidebar'
import { Toaster as Sonner } from '@/components/ui/sonner'
import { ToggleGroup } from '@/components/ui/toggle-group'
import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { CircularProgress, Divider, ProgressBar } from '@/components/atoms'
import * as AtomComponents from '@/components/atoms'
import * as MoleculeComponents from '@/components/molecules'
import * as OrganismComponents from '@/components/organisms'
import {
ComponentBindingDialogWrapper,
ComponentTreeWrapper,
DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
LazyBarChartWrapper,
LazyD3BarChartWrapper,
LazyLineChartWrapper,
SaveIndicatorWrapper,
SeedDataManagerWrapper,
StorageSettingsWrapper,
} from '@/lib/json-ui/wrappers'
import jsonComponentsRegistry from '../../../json-components-registry.json'
import {
ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass,
Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed,
CaretUp, CaretDown, CaretLeft, CaretRight,
Gear, User, Bell, Envelope, Calendar, Clock, Star,
Heart, ShareNetwork, LinkSimple, Copy, FloppyDisk, ArrowClockwise, WarningCircle,
Info, Question, House, List as ListIcon, DotsThreeVertical, DotsThree
} from '@phosphor-icons/react'
export interface UIComponentRegistry {
[key: string]: ComponentType<any>
@@ -15,16 +72,11 @@ interface JsonRegistryEntry {
wrapperRequired?: boolean
wrapperComponent?: string
wrapperFor?: string
load?: {
path?: string
export?: string
}
deprecated?: DeprecatedComponentInfo
}
interface JsonComponentRegistry {
components?: JsonRegistryEntry[]
sourceRoots?: Record<string, string[]>
}
export interface DeprecatedComponentInfo {
@@ -33,127 +85,70 @@ export interface DeprecatedComponentInfo {
}
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
const sourceRoots = jsonRegistry.sourceRoots ?? {}
const moduleMapsBySource = Object.fromEntries(
Object.entries(sourceRoots).map(([source, patterns]) => {
if (!patterns || patterns.length === 0) {
return [source, {}]
const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
entry.export ?? entry.name ?? entry.type
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 [source, import.meta.glob(patterns, { eager: true })]
})
) as Record<string, Record<string, unknown>>
const getRegistryEntryKey = (entry: JsonRegistryEntry): string | undefined =>
entry.name ?? entry.type
const getRegistryEntryExportName = (entry: JsonRegistryEntry): string | undefined =>
entry.load?.export ?? entry.export ?? getRegistryEntryKey(entry)
return registry
}, {})
}
const jsonRegistryEntries = jsonRegistry.components ?? []
const registryEntryByType = new Map(
jsonRegistryEntries
.map((entry) => {
const entryKey = getRegistryEntryKey(entry)
return entryKey ? [entryKey, entry] : null
const entryName = getRegistryEntryName(entry)
return entryName ? [entryName, entry] : null
})
.filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry))
)
const atomComponentMap = AtomComponents as Record<string, ComponentType<any>>
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
(acc, entry) => {
const entryKey = getRegistryEntryKey(entry)
if (!entryKey) {
const entryName = getRegistryEntryName(entry)
if (!entryName) {
return acc
}
if (entry.status === 'deprecated' || entry.deprecated) {
acc[entryKey] = entry.deprecated ?? {}
acc[entryName] = entry.deprecated ?? {}
}
return acc
},
{}
)
const buildComponentMapFromExports = (
exports: Record<string, unknown>
): Record<string, ComponentType<any>> => {
return Object.entries(exports).reduce<Record<string, ComponentType<any>>>((acc, [key, value]) => {
if (value && (typeof value === 'function' || typeof value === 'object')) {
acc[key] = value as ComponentType<any>
}
return acc
}, {})
}
const buildComponentMapFromModules = (
modules: Record<string, unknown>
): Record<string, ComponentType<any>> => {
return Object.values(modules).reduce<Record<string, ComponentType<any>>>((acc, moduleExports) => {
if (!moduleExports || typeof moduleExports !== 'object') {
return acc
}
Object.entries(buildComponentMapFromExports(moduleExports as Record<string, unknown>)).forEach(
([key, component]) => {
acc[key] = component
}
)
return acc
}, {})
}
const atomModules = import.meta.glob('@/components/atoms/*.tsx', { eager: true })
const moleculeModules = import.meta.glob('@/components/molecules/*.tsx', { eager: true })
const organismModules = import.meta.glob('@/components/organisms/*.tsx', { eager: true })
const uiModules = import.meta.glob('@/components/ui/**/*.{ts,tsx}', { eager: true })
const wrapperModules = import.meta.glob('@/lib/json-ui/wrappers/*.tsx', { eager: true })
const explicitModules = import.meta.glob(
['@/components/**/*.tsx', '@/lib/json-ui/wrappers/**/*.tsx'],
{ eager: true }
)
const atomComponentMap = buildComponentMapFromModules(atomModules)
const moleculeComponentMap = buildComponentMapFromModules(moleculeModules)
const organismComponentMap = buildComponentMapFromModules(organismModules)
const uiComponentMap = buildComponentMapFromModules(uiModules)
const wrapperComponentMap = buildComponentMapFromModules(wrapperModules)
const iconComponentMap = buildComponentMapFromExports(PhosphorIcons)
const resolveComponentFromExplicitPath = (
entry: JsonRegistryEntry,
entryExportName: string
): ComponentType<any> | undefined => {
if (!entry.load?.path) {
return undefined
}
const moduleExports = explicitModules[entry.load.path]
if (!moduleExports || typeof moduleExports !== 'object') {
return undefined
}
const explicitComponents = buildComponentMapFromExports(
moduleExports as Record<string, unknown>
)
return explicitComponents[entryExportName]
}
const buildRegistryFromEntries = (
source: string,
componentMap: Record<string, ComponentType<any>>
): UIComponentRegistry => {
return jsonRegistryEntries
.filter((entry) => entry.source === source)
.reduce<UIComponentRegistry>((registry, entry) => {
const entryKey = getRegistryEntryKey(entry)
const entryExportName = getRegistryEntryExportName(entry)
if (!entryKey || !entryExportName) {
return registry
}
const component =
resolveComponentFromExplicitPath(entry, entryExportName) ??
componentMap[entryExportName]
if (component) {
registry[entryKey] = component
}
return registry
}, {})
}
const atomRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'atoms')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const moleculeRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'molecules')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const organismRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'organisms')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const shadcnRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'ui')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const wrapperRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'wrappers')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const iconRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'icons')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
export const primitiveComponents: UIComponentRegistry = {
div: 'div' as any,
@@ -174,33 +169,173 @@ export const primitiveComponents: UIComponentRegistry = {
nav: 'nav' as any,
}
export const shadcnComponents: UIComponentRegistry = buildRegistryFromEntries(
'ui',
uiComponentMap
const shadcnComponentMap: Record<string, ComponentType<any>> = {
AlertDialog,
AspectRatio,
Button,
Carousel,
Chart,
Collapsible,
Command,
DropdownMenu,
Input,
InputOtp,
Textarea,
Label,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
Badge,
Separator,
Alert: ShadcnAlert,
AlertDescription,
AlertTitle,
Switch,
Checkbox,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Table: ShadcnTable,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Menubar,
NavigationMenu,
Skeleton: ShadcnSkeleton,
Pagination,
Progress,
Resizable,
Sheet,
Sidebar,
Sonner,
ToggleGroup,
Avatar: ShadcnAvatar,
AvatarFallback,
AvatarImage,
}
export const shadcnComponents: UIComponentRegistry = buildRegistryFromNames(
shadcnRegistryNames,
shadcnComponentMap
)
export const atomComponents: UIComponentRegistry = buildRegistryFromEntries(
'atoms',
atomComponentMap
export const atomComponents: UIComponentRegistry = {
...buildRegistryFromNames(
atomRegistryNames,
atomComponentMap
),
DatePicker: atomComponentMap.DatePicker,
FileUpload: atomComponentMap.FileUpload,
CircularProgress,
Divider,
ProgressBar,
DataList: (AtomComponents as Record<string, ComponentType<any>>).DataList,
DataTable: (AtomComponents as Record<string, ComponentType<any>>).DataTable,
ListItem: (AtomComponents as Record<string, ComponentType<any>>).ListItem,
MetricCard: (AtomComponents as Record<string, ComponentType<any>>).MetricCard,
Timeline: (AtomComponents as Record<string, ComponentType<any>>).Timeline,
}
const breadcrumbComponent = AtomComponents.Breadcrumb ?? AtomComponents.BreadcrumbNav
if (breadcrumbComponent) {
atomComponents.Breadcrumb = breadcrumbComponent as ComponentType<any>
}
export const moleculeComponents: UIComponentRegistry = {
...buildRegistryFromNames(
moleculeRegistryNames,
MoleculeComponents as Record<string, ComponentType<any>>
),
AppBranding: (MoleculeComponents as Record<string, ComponentType<any>>).AppBranding,
LabelWithBadge: (MoleculeComponents as Record<string, ComponentType<any>>).LabelWithBadge,
NavigationGroupHeader: (MoleculeComponents as Record<string, ComponentType<any>>).NavigationGroupHeader,
}
export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
organismRegistryNames,
OrganismComponents as Record<string, ComponentType<any>>
)
export const moleculeComponents: UIComponentRegistry = buildRegistryFromEntries(
'molecules',
moleculeComponentMap
)
const wrapperComponentMap: Record<string, ComponentType<any>> = {
ComponentBindingDialogWrapper,
ComponentTreeWrapper,
DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
SaveIndicatorWrapper,
LazyBarChartWrapper,
LazyLineChartWrapper,
LazyD3BarChartWrapper,
SeedDataManagerWrapper,
StorageSettingsWrapper,
}
export const organismComponents: UIComponentRegistry = buildRegistryFromEntries(
'organisms',
organismComponentMap
)
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromEntries(
'wrappers',
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames(
wrapperRegistryNames,
wrapperComponentMap
)
export const iconComponents: UIComponentRegistry = buildRegistryFromEntries(
'icons',
const iconComponentMap: Record<string, ComponentType<any>> = {
ArrowLeft,
ArrowRight,
Check,
X,
Plus,
Minus,
Search: MagnifyingGlass,
Filter: Funnel,
Download,
Upload,
Edit: PencilSimple,
Trash,
Eye,
EyeOff: EyeClosed,
ChevronUp: CaretUp,
ChevronDown: CaretDown,
ChevronLeft: CaretLeft,
ChevronRight: CaretRight,
Settings: Gear,
User,
Bell,
Mail: Envelope,
Calendar,
Clock,
Star,
Heart,
Share: ShareNetwork,
Link: LinkSimple,
Copy,
Save: FloppyDisk,
RefreshCw: ArrowClockwise,
AlertCircle: WarningCircle,
Info,
HelpCircle: Question,
Home: House,
Menu: ListIcon,
MoreVertical: DotsThreeVertical,
MoreHorizontal: DotsThree,
}
export const iconComponents: UIComponentRegistry = buildRegistryFromNames(
iconRegistryNames,
iconComponentMap
)

View File

@@ -54,35 +54,6 @@ export function evaluateExpression(
return lengthSuffix ? filtered.length : filtered
}
const findMatch = expression.match(
/^data\.([a-zA-Z0-9_.]+)\.find\(\s*([a-zA-Z0-9_.]+)\s*(===|==|!==|!=)\s*(.+?)\s*\)$/
)
if (findMatch) {
const [, collectionPath, fieldPath, operator, rawValue] = findMatch
const collection = getNestedValue(data, collectionPath)
if (!Array.isArray(collection)) {
return undefined
}
const expectedValue = evaluateExpression(rawValue.trim(), { data, event })
const isNegated = operator === '!=' || operator === '!=='
return collection.find((item) => {
const fieldValue = getNestedValue(item, fieldPath)
return isNegated ? fieldValue !== expectedValue : fieldValue === expectedValue
})
}
const objectKeysLengthMatch = expression.match(
/^Object\.keys\(\s*data\.([a-zA-Z0-9_.]+)\s*\)\.length$/
)
if (objectKeysLengthMatch) {
const value = getNestedValue(data, objectKeysLengthMatch[1])
if (!value || typeof value !== 'object') {
return 0
}
return Object.keys(value).length
}
// Handle direct data access: "data.fieldName"
if (expression.startsWith('data.')) {
return getNestedValue(data, expression.substring(5))

View File

@@ -8,6 +8,7 @@ export function useJSONDataSource<T = unknown>(
) {
const kvConfig = config.type === 'kv' ? config.config : undefined
const apiConfig = config.type === 'api' ? config.config : undefined
const computedConfig = config.type === 'computed' ? config.config : undefined
const defaultValue =
config.type === 'static' ? config.config : config.config?.defaultValue
@@ -56,6 +57,8 @@ export function useJSONDataSource<T = unknown>(
return apiValue
case 'static':
return config.config
case 'computed':
return computedConfig?.defaultValue
default:
return null
}

View File

@@ -220,7 +220,7 @@ export const PageUISchema = z.object({
tables: z.array(TableSchema).optional(),
menus: z.array(MenuSchema).optional(),
dataSources: z.record(z.string(), z.object({
type: z.enum(['kv', 'api', 'static']),
type: z.enum(['kv', 'api', 'computed', 'static']),
config: z.any(),
})).optional(),
})
@@ -241,6 +241,13 @@ export type DataSourceConfig<T = unknown> =
transform?: (data: unknown) => T
}
}
| {
type: 'computed'
config: {
defaultValue?: T
transform?: (data: unknown) => T
}
}
| {
type: 'static'
config: T

View File

@@ -1,31 +1,14 @@
import { StatusIcon } from '@/components/atoms'
import { useSaveIndicator } from '@/hooks/use-save-indicator'
import { cn } from '@/lib/utils'
import type { SaveIndicatorWrapperProps } from './interfaces'
export function SaveIndicatorWrapper({
lastSaved,
status = 'saved',
label,
showLabel = true,
animate,
className,
}: SaveIndicatorWrapperProps) {
const { timeAgo, isRecent } = useSaveIndicator(lastSaved ?? null)
if (lastSaved) {
const resolvedStatus = isRecent ? 'saved' : 'synced'
const resolvedLabel = label ?? (isRecent ? 'Saved' : timeAgo)
const shouldAnimate = animate ?? isRecent
return (
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
<StatusIcon type={resolvedStatus} animate={shouldAnimate} />
{showLabel && <span className="hidden sm:inline">{resolvedLabel}</span>}
</div>
)
}
const resolvedLabel = label ?? (status === 'saved' ? 'Saved' : 'Synced')
const shouldAnimate = animate ?? status === 'saved'

View File

@@ -4,7 +4,6 @@ import type { UIComponent } from '@/types/json-ui'
export type SaveIndicatorStatus = 'saved' | 'synced'
export interface SaveIndicatorWrapperProps {
lastSaved?: number | null
status?: SaveIndicatorStatus
label?: string
showLabel?: boolean

View File

@@ -1,56 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RateLimiter } from './rate-limiter'
describe('RateLimiter.throttle', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date(0))
})
afterEach(() => {
vi.useRealTimers()
})
it('returns null when the window is saturated for medium priority', async () => {
const limiter = new RateLimiter({
maxRequests: 1,
windowMs: 1000,
retryDelay: 10,
maxRetries: 2
})
const fn = vi.fn(async () => 'ok')
await limiter.throttle('key', fn, 'medium')
const result = await limiter.throttle('key', fn, 'medium')
expect(result).toBeNull()
expect(fn).toHaveBeenCalledTimes(1)
})
it('bounds high-priority retries without recursion when the window is saturated', async () => {
const limiter = new RateLimiter({
maxRequests: 1,
windowMs: 1000,
retryDelay: 10,
maxRetries: 3
})
const fn = vi.fn(async () => 'ok')
await limiter.throttle('key', fn, 'high')
const spy = vi.spyOn(limiter, 'throttle')
let resolved: unknown = 'pending'
const pending = limiter.throttle('key', fn, 'high').then(result => {
resolved = result
return result
})
await vi.advanceTimersByTimeAsync(30)
await pending
expect(resolved).toBeNull()
expect(fn).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledTimes(1)
})
})

View File

@@ -2,7 +2,6 @@ interface RateLimitConfig {
maxRequests: number
windowMs: number
retryDelay: number
maxRetries?: number
}
interface RequestRecord {
@@ -10,15 +9,14 @@ interface RequestRecord {
count: number
}
export class RateLimiter {
class RateLimiter {
private requests: Map<string, RequestRecord> = new Map()
private config: RateLimitConfig
constructor(config: RateLimitConfig = {
maxRequests: 5,
windowMs: 60000,
retryDelay: 2000,
maxRetries: 3
retryDelay: 2000
}) {
this.config = config
}
@@ -28,60 +26,49 @@ export class RateLimiter {
fn: () => Promise<T>,
priority: 'low' | 'medium' | 'high' = 'medium'
): Promise<T | null> {
const maxRetries = this.config.maxRetries ?? 3
let attempts = 0
const now = Date.now()
const record = this.requests.get(key)
while (true) {
const now = Date.now()
const record = this.requests.get(key)
let isLimited = false
if (record) {
const timeElapsed = now - record.timestamp
if (record) {
const timeElapsed = now - record.timestamp
if (timeElapsed < this.config.windowMs) {
if (record.count >= this.config.maxRequests) {
console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`)
isLimited = true
} else {
record.count++
if (timeElapsed < this.config.windowMs) {
if (record.count >= this.config.maxRequests) {
console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`)
if (priority === 'high') {
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay))
return this.throttle(key, fn, priority)
}
} else {
this.requests.set(key, { timestamp: now, count: 1 })
return null
}
record.count++
} else {
this.requests.set(key, { timestamp: now, count: 1 })
}
} else {
this.requests.set(key, { timestamp: now, count: 1 })
}
this.cleanup()
this.cleanup()
if (isLimited) {
if (priority === 'high' && attempts < maxRetries) {
attempts += 1
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay))
continue
try {
return await fn()
} catch (error) {
if (error instanceof Error && (
error.message.includes('502') ||
error.message.includes('Bad Gateway') ||
error.message.includes('429') ||
error.message.includes('rate limit')
)) {
console.error(`Gateway error for ${key}:`, error.message)
if (record) {
record.count = this.config.maxRequests
}
return null
}
try {
return await fn()
} catch (error) {
if (error instanceof Error && (
error.message.includes('502') ||
error.message.includes('Bad Gateway') ||
error.message.includes('429') ||
error.message.includes('rate limit')
)) {
console.error(`Gateway error for ${key}:`, error.message)
const updatedRecord = this.requests.get(key)
if (updatedRecord) {
updatedRecord.count = this.config.maxRequests
}
}
throw error
}
throw error
}
}

View File

@@ -24,18 +24,22 @@ export class FlaskBackendAdapter implements StorageAdapter {
clearTimeout(timeoutId)
const contentLength = response.headers.get('content-length')
const contentType = response.headers.get('content-type')
const hasJsonBody = contentLength !== '0' && contentType?.includes('application/json')
if (!response.ok) {
const errorPayload = hasJsonBody ? await response.json().catch(() => null) : null
const errorMessage = errorPayload?.error || response.statusText || `HTTP ${response.status}`
throw new Error(errorMessage)
}
if (response.status === 204 || !hasJsonBody) {
return undefined as T
let errorMessage = response.statusText
try {
const errorText = await response.text()
if (errorText) {
try {
const parsed = JSON.parse(errorText) as { error?: string }
errorMessage = parsed.error || errorText
} catch {
errorMessage = errorText
}
}
} catch {
// ignore error parsing failures
}
throw new Error(errorMessage || `HTTP ${response.status}`)
}
const responseText = await response.text()

View File

@@ -22,13 +22,13 @@
},
{
"id": "filteredUsers",
"type": "static",
"type": "computed",
"expression": "data.users",
"dependencies": ["users", "filterQuery"]
},
{
"id": "stats",
"type": "static",
"type": "computed",
"valueTemplate": {
"total": "data.users.length",
"active": "data.users.filter(status === 'active').length",

View File

@@ -1,300 +0,0 @@
{
"id": "feature-toggle-settings",
"name": "Feature Toggle Settings",
"description": "Enable or disable features to customize your workspace",
"dataSources": [
{
"id": "featuresList",
"type": "static",
"defaultValue": [
{
"key": "codeEditor",
"label": "Code Editor",
"description": "Monaco-based code editor with syntax highlighting",
"icon": "Code"
},
{
"key": "models",
"label": "Database Models",
"description": "Prisma schema designer for database models",
"icon": "Database"
},
{
"key": "components",
"label": "Component Builder",
"description": "Visual component tree builder for React components",
"icon": "Tree"
},
{
"key": "componentTrees",
"label": "Component Trees Manager",
"description": "Manage multiple component tree configurations",
"icon": "Tree"
},
{
"key": "workflows",
"label": "Workflow Designer",
"description": "n8n-style visual workflow automation builder",
"icon": "FlowArrow"
},
{
"key": "lambdas",
"label": "Lambda Functions",
"description": "Serverless function editor with multiple runtimes",
"icon": "Code"
},
{
"key": "styling",
"label": "Theme Designer",
"description": "Material UI theme customization and styling",
"icon": "PaintBrush"
},
{
"key": "flaskApi",
"label": "Flask API Designer",
"description": "Python Flask backend API endpoint designer",
"icon": "Flask"
},
{
"key": "playwright",
"label": "Playwright Tests",
"description": "E2E testing with Playwright configuration",
"icon": "Play"
},
{
"key": "storybook",
"label": "Storybook Stories",
"description": "Component documentation and development",
"icon": "BookOpen"
},
{
"key": "unitTests",
"label": "Unit Tests",
"description": "Component and function unit test designer",
"icon": "Cube"
},
{
"key": "errorRepair",
"label": "Error Repair",
"description": "Auto-detect and fix code errors",
"icon": "Wrench"
},
{
"key": "documentation",
"label": "Documentation",
"description": "Project documentation, roadmap, and guides",
"icon": "FileText"
},
{
"key": "sassStyles",
"label": "Sass Styles",
"description": "Custom Sass/SCSS styling showcase",
"icon": "PaintBrush"
},
{
"key": "faviconDesigner",
"label": "Favicon Designer",
"description": "Design and generate app favicons and icons",
"icon": "Image"
},
{
"key": "ideaCloud",
"label": "Feature Idea Cloud",
"description": "Brainstorm and organize feature ideas",
"icon": "Lightbulb"
}
]
},
{
"id": "enabledCount",
"type": "static",
"expression": "Object.values(data.features || {}).filter(Boolean).length"
},
{
"id": "totalCount",
"type": "static",
"expression": "Object.keys(data.features || {}).length"
}
],
"components": [
{
"id": "root",
"type": "div",
"props": {
"className": "h-full p-6 bg-background"
},
"children": [
{
"id": "header",
"type": "div",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "title",
"type": "Heading",
"props": {
"level": 2,
"className": "text-2xl font-bold mb-2",
"children": "Feature Toggles"
}
},
{
"id": "description",
"type": "Text",
"props": {
"className": "text-muted-foreground"
},
"children": [
{
"type": "text",
"value": "Enable or disable features to customize your workspace. "
},
{
"type": "text",
"dataBinding": "enabledCount"
},
{
"type": "text",
"value": " of "
},
{
"type": "text",
"dataBinding": "totalCount"
},
{
"type": "text",
"value": " features enabled."
}
]
}
]
},
{
"id": "scroll-area",
"type": "ScrollArea",
"props": {
"className": "h-[calc(100vh-200px)]"
},
"children": [
{
"id": "grid",
"type": "div",
"props": {
"className": "grid grid-cols-1 lg:grid-cols-2 gap-4 pr-4"
},
"loop": {
"source": "featuresList",
"itemVar": "item",
"indexVar": "index"
},
"children": [
{
"id": "feature-card",
"type": "Card",
"children": [
{
"id": "card-header",
"type": "div",
"props": {
"className": "p-6 pb-3"
},
"children": [
{
"id": "card-content",
"type": "div",
"props": {
"className": "flex items-start justify-between"
},
"children": [
{
"id": "left-content",
"type": "div",
"props": {
"className": "flex items-center gap-3"
},
"children": [
{
"id": "icon-container",
"type": "div",
"props": {
"className": {
"expression": "data.features?.[item.key] ? 'p-2 rounded-lg bg-primary text-primary-foreground' : 'p-2 rounded-lg bg-muted text-muted-foreground'"
}
},
"children": [
{
"id": "icon",
"type": {
"dataBinding": "item.icon"
},
"props": {
"size": 20,
"weight": "duotone"
}
}
]
},
{
"id": "text-content",
"type": "div",
"children": [
{
"id": "title",
"type": "div",
"props": {
"className": "text-base font-semibold"
},
"dataBinding": "item.label"
},
{
"id": "description",
"type": "div",
"props": {
"className": "text-xs mt-1 text-muted-foreground"
},
"dataBinding": "item.description"
}
]
}
]
},
{
"id": "switch",
"type": "Switch",
"bindings": {
"checked": {
"expression": "data.features?.[item.key] || false"
}
},
"events": [
{
"event": "checkedChange",
"actions": [
{
"id": "updateFeature",
"type": "custom",
"params": {
"key": "item.key",
"checked": "event"
}
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -22,7 +22,7 @@
},
{
"id": "stats",
"type": "static",
"type": "computed",
"valueTemplate": {
"total": "data.todos.length",
"completed": "data.todos.filter(completed === true).length",

View File

@@ -1,48 +0,0 @@
const itemSlices = [
'files',
'models',
'components',
'componentTrees',
'workflows',
'lambdas',
] as const
const itemChangeActionNames = ['addItem', 'updateItem', 'removeItem'] as const
export const itemChangeActionTypes = new Set(
itemSlices.flatMap((slice) =>
itemChangeActionNames.map((actionName) => `${slice}/${actionName}`)
)
)
export const persistenceSingleItemActionNames = new Set([
'addItem',
'updateItem',
'saveFile',
'saveModel',
'saveComponent',
'saveComponentTree',
'saveWorkflow',
'saveLambda',
])
export const persistenceBulkActionNames = new Set([
'addItems',
'setItems',
'setFiles',
'setModels',
'setComponents',
'setComponentTrees',
'setWorkflows',
'setLambdas',
])
export const persistenceDeleteActionNames = new Set([
'removeItem',
'deleteFile',
'deleteModel',
'deleteComponent',
'deleteComponentTree',
'deleteWorkflow',
'deleteLambda',
])

View File

@@ -1,112 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { AutoSyncManager } from './autoSyncMiddleware'
const { syncToFlaskBulkMock } = vi.hoisted(() => ({
syncToFlaskBulkMock: vi.fn(() => ({ type: 'sync/bulk' })),
}))
vi.mock('../slices/syncSlice', () => ({
syncToFlaskBulk: syncToFlaskBulkMock,
checkFlaskConnection: vi.fn(() => ({ type: 'sync/check' })),
}))
const nextTick = () => new Promise(resolve => setTimeout(resolve, 0))
const waitFor = async (assertion: () => void, attempts = 5) => {
let lastError: unknown
for (let i = 0; i < attempts; i += 1) {
await nextTick()
try {
assertion()
return
} catch (error) {
lastError = error
}
}
throw lastError
}
const createControlledPromise = () => {
let resolve: () => void
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise
})
return {
promise,
resolve: resolve!,
}
}
describe('AutoSyncManager', () => {
let manager: AutoSyncManager
let dispatchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
manager = new AutoSyncManager()
dispatchMock = vi.fn()
manager.setDispatch(dispatchMock)
syncToFlaskBulkMock.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
it('serializes performSync calls', async () => {
const firstSync = createControlledPromise()
dispatchMock
.mockReturnValueOnce(firstSync.promise)
.mockResolvedValueOnce(undefined)
const firstRun = manager.syncNow()
const secondRun = manager.syncNow()
await waitFor(() => {
expect(dispatchMock).toHaveBeenCalledTimes(1)
})
firstSync.resolve()
await Promise.all([firstRun, secondRun])
expect(dispatchMock).toHaveBeenCalledTimes(2)
})
it('resets changeCounter after a successful sync', async () => {
dispatchMock.mockResolvedValue(undefined)
manager.trackChange()
manager.trackChange()
await manager.syncNow()
expect(manager.getStatus().changeCounter).toBe(0)
})
it('runs one pending sync after an in-flight sync finishes', async () => {
const firstSync = createControlledPromise()
dispatchMock
.mockReturnValueOnce(firstSync.promise)
.mockResolvedValueOnce(undefined)
const syncPromise = manager.syncNow()
await waitFor(() => {
expect(dispatchMock).toHaveBeenCalledTimes(1)
})
manager.trackChange()
manager.trackChange()
firstSync.resolve()
await syncPromise
await waitFor(() => {
expect(dispatchMock).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -1,7 +1,6 @@
import { Middleware } from '@reduxjs/toolkit'
import { syncToFlaskBulk, checkFlaskConnection } from '../slices/syncSlice'
import { RootState } from '../index'
import { itemChangeActionTypes } from '../actionNames'
interface AutoSyncConfig {
enabled: boolean
@@ -10,7 +9,7 @@ interface AutoSyncConfig {
maxQueueSize: number
}
export class AutoSyncManager {
class AutoSyncManager {
private config: AutoSyncConfig = {
enabled: false,
intervalMs: 30000,
@@ -21,8 +20,6 @@ export class AutoSyncManager {
private timer: ReturnType<typeof setTimeout> | null = null
private lastSyncTime = 0
private changeCounter = 0
private inFlight = false
private pendingSync = false
private dispatch: any = null
configure(config: Partial<AutoSyncConfig>) {
@@ -71,33 +68,18 @@ export class AutoSyncManager {
private async performSync() {
if (!this.dispatch) return
if (this.inFlight) {
this.pendingSync = true
return
}
this.inFlight = true
try {
await this.dispatch(syncToFlaskBulk())
this.lastSyncTime = Date.now()
this.changeCounter = 0
} catch (error) {
console.error('[AutoSync] Sync failed:', error)
} finally {
this.inFlight = false
}
if (this.pendingSync) {
this.pendingSync = false
await this.performSync()
}
}
trackChange() {
this.changeCounter++
if (this.inFlight) {
this.pendingSync = true
}
if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) {
this.performSync()
@@ -145,7 +127,28 @@ export const createAutoSyncMiddleware = (): Middleware => {
})
}
if (itemChangeActionTypes.has(action.type)) {
const changeActions = [
'files/addItem',
'files/updateItem',
'files/removeItem',
'models/addItem',
'models/updateItem',
'models/removeItem',
'components/addItem',
'components/updateItem',
'components/removeItem',
'componentTrees/addItem',
'componentTrees/updateItem',
'componentTrees/removeItem',
'workflows/addItem',
'workflows/updateItem',
'workflows/removeItem',
'lambdas/addItem',
'lambdas/updateItem',
'lambdas/removeItem',
]
if (changeActions.includes(action.type)) {
autoSyncManager.trackChange()
}

View File

@@ -37,7 +37,6 @@ export async function syncToFlask(
}
} catch (error) {
console.error('[FlaskSync] Error syncing to Flask:', error)
throw error
}
}

View File

@@ -1,103 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PersistenceQueue } from './persistenceMiddleware'
const { putMock, deleteMock, syncMock } = vi.hoisted(() => ({
putMock: vi.fn<[string, unknown], Promise<void>>(),
deleteMock: vi.fn<[string, string], Promise<void>>(),
syncMock: vi.fn<[string, string, unknown, string], Promise<void>>()
}))
vi.mock('@/lib/db', () => ({
db: {
put: putMock,
delete: deleteMock
}
}))
vi.mock('./flaskSync', () => ({
syncToFlask: syncMock
}))
const nextTick = () => new Promise(resolve => setTimeout(resolve, 0))
const waitFor = async (assertion: () => void, attempts = 5) => {
let lastError: unknown
for (let i = 0; i < attempts; i += 1) {
await nextTick()
try {
assertion()
return
} catch (error) {
lastError = error
}
}
throw lastError
}
const createControlledPromise = () => {
let resolve: () => void
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise
})
return {
promise,
resolve: resolve!
}
}
describe('PersistenceQueue', () => {
beforeEach(() => {
putMock.mockReset()
deleteMock.mockReset()
syncMock.mockReset()
syncMock.mockResolvedValue(undefined)
})
afterEach(() => {
vi.useRealTimers()
})
it('flushes new operations enqueued while processing after the first batch finishes', async () => {
const queue = new PersistenceQueue()
const controlled = createControlledPromise()
putMock
.mockReturnValueOnce(controlled.promise)
.mockResolvedValueOnce(undefined)
queue.enqueue({
type: 'put',
storeName: 'files',
key: 'file-1',
value: { id: 'file-1' },
timestamp: Date.now(),
}, 0)
await waitFor(() => {
expect(putMock).toHaveBeenCalledTimes(1)
})
queue.enqueue({
type: 'put',
storeName: 'files',
key: 'file-2',
value: { id: 'file-2' },
timestamp: Date.now(),
}, 0)
await nextTick()
expect(putMock).toHaveBeenCalledTimes(1)
controlled.resolve()
await waitFor(() => {
expect(putMock).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -2,11 +2,6 @@ import { Middleware } from '@reduxjs/toolkit'
import { db } from '@/lib/db'
import { syncToFlask } from './flaskSync'
import { RootState } from '../index'
import {
persistenceBulkActionNames,
persistenceDeleteActionNames,
persistenceSingleItemActionNames,
} from '../actionNames'
interface PersistenceConfig {
storeName: string
@@ -43,23 +38,10 @@ type PendingOperation = {
timestamp: number
}
type FailedSyncOperation = PendingOperation & {
attempt: number
lastError: string
nextRetryAt: number
}
const MAX_SYNC_RETRIES = 5
const BASE_SYNC_RETRY_DELAY_MS = 1000
const MAX_SYNC_RETRY_DELAY_MS = 30000
class PersistenceQueue {
private queue: Map<string, PendingOperation> = new Map()
private processing = false
private pendingFlush = false
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private failedSyncs: Map<string, FailedSyncOperation> = new Map()
private retryTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
enqueue(operation: PendingOperation, debounceMs: number) {
const opKey = `${operation.storeName}:${operation.key}`
@@ -80,12 +62,7 @@ class PersistenceQueue {
}
async processQueue() {
if (this.processing) {
this.pendingFlush = true
return
}
if (this.queue.size === 0) return
if (this.processing || this.queue.size === 0) return
this.processing = true
@@ -98,10 +75,14 @@ class PersistenceQueue {
try {
if (op.type === 'put') {
await db.put(op.storeName as any, op.value)
await this.syncToFlaskWithRetry(op, op.value)
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
await syncToFlask(op.storeName, op.key, op.value, 'put')
}
} else if (op.type === 'delete') {
await db.delete(op.storeName as any, op.key)
await this.syncToFlaskWithRetry(op, null)
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
await syncToFlask(op.storeName, op.key, null, 'delete')
}
}
} catch (error) {
console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error)
@@ -116,23 +97,6 @@ class PersistenceQueue {
}
} finally {
this.processing = false
const needsFlush = this.pendingFlush || this.queue.size > 0
this.pendingFlush = false
if (needsFlush) {
await this.processQueue()
}
}
}
getFailedSyncs() {
return Array.from(this.failedSyncs.values()).sort((a, b) => a.nextRetryAt - b.nextRetryAt)
}
async retryFailedSyncs() {
for (const [opKey, failure] of this.failedSyncs.entries()) {
if (failure.nextRetryAt <= Date.now()) {
await this.retryFailedSync(opKey)
}
}
}
@@ -143,89 +107,6 @@ class PersistenceQueue {
this.debounceTimers.clear()
await this.processQueue()
}
private async syncToFlaskWithRetry(op: PendingOperation, value: any) {
if (!sliceToPersistenceMap[op.storeName]?.syncToFlask) return
try {
await syncToFlask(op.storeName, op.key, value, op.type)
this.clearSyncFailure(op)
} catch (error) {
this.recordSyncFailure(op, error)
console.warn(
`[PersistenceMiddleware] Flask sync failed for ${op.storeName}:${op.key} (${op.type}); queued for retry.`,
error
)
}
}
private recordSyncFailure(op: PendingOperation, error: unknown) {
const opKey = this.getFailureKey(op)
const previous = this.failedSyncs.get(opKey)
const attempt = previous ? previous.attempt + 1 : 1
const delayMs = this.getRetryDelayMs(attempt)
const nextRetryAt = Date.now() + delayMs
const lastError = error instanceof Error ? error.message : String(error)
this.failedSyncs.set(opKey, {
...op,
attempt,
lastError,
nextRetryAt,
})
const existingTimer = this.retryTimers.get(opKey)
if (existingTimer) {
clearTimeout(existingTimer)
}
if (attempt <= MAX_SYNC_RETRIES) {
const timer = setTimeout(() => {
this.retryTimers.delete(opKey)
void this.retryFailedSync(opKey)
}, delayMs)
this.retryTimers.set(opKey, timer)
}
}
private clearSyncFailure(op: PendingOperation) {
const opKey = this.getFailureKey(op)
const timer = this.retryTimers.get(opKey)
if (timer) {
clearTimeout(timer)
this.retryTimers.delete(opKey)
}
this.failedSyncs.delete(opKey)
}
private async retryFailedSync(opKey: string) {
const failure = this.failedSyncs.get(opKey)
if (!failure) return
if (failure.attempt > MAX_SYNC_RETRIES) {
return
}
this.enqueue(
{
type: failure.type,
storeName: failure.storeName,
key: failure.key,
value: failure.value,
timestamp: Date.now(),
},
0
)
}
private getRetryDelayMs(attempt: number) {
const delay = BASE_SYNC_RETRY_DELAY_MS * Math.pow(2, attempt - 1)
return Math.min(delay, MAX_SYNC_RETRY_DELAY_MS)
}
private getFailureKey(op: PendingOperation) {
return `${op.storeName}:${op.key}:${op.type}`
}
}
const persistenceQueue = new PersistenceQueue()
@@ -247,7 +128,10 @@ export const createPersistenceMiddleware = (): Middleware => {
if (!sliceState) return result
try {
if (persistenceSingleItemActionNames.has(actionName)) {
if (actionName === 'addItem' || actionName === 'updateItem' || actionName === 'saveFile' ||
actionName === 'saveModel' || actionName === 'saveComponent' || actionName === 'saveComponentTree' ||
actionName === 'saveWorkflow' || actionName === 'saveLambda') {
const item = action.payload
if (item && item.id) {
persistenceQueue.enqueue({
@@ -260,7 +144,10 @@ export const createPersistenceMiddleware = (): Middleware => {
}
}
if (persistenceBulkActionNames.has(actionName)) {
if (actionName === 'addItems' || actionName === 'setItems' || actionName === 'setFiles' ||
actionName === 'setModels' || actionName === 'setComponents' || actionName === 'setComponentTrees' ||
actionName === 'setWorkflows' || actionName === 'setLambdas') {
const items = action.payload
if (Array.isArray(items)) {
items.forEach((item: any) => {
@@ -277,7 +164,10 @@ export const createPersistenceMiddleware = (): Middleware => {
}
}
if (persistenceDeleteActionNames.has(actionName)) {
if (actionName === 'removeItem' || actionName === 'deleteFile' || actionName === 'deleteModel' ||
actionName === 'deleteComponent' || actionName === 'deleteComponentTree' ||
actionName === 'deleteWorkflow' || actionName === 'deleteLambda') {
const itemId = typeof action.payload === 'string' ? action.payload : action.payload?.id
if (itemId) {
persistenceQueue.enqueue({
@@ -318,8 +208,6 @@ export const createPersistenceMiddleware = (): Middleware => {
}
export const flushPersistence = () => persistenceQueue.flush()
export const getFailedSyncOperations = () => persistenceQueue.getFailedSyncs()
export const retryFailedSyncOperations = () => persistenceQueue.retryFailedSyncs()
export const configurePersistence = (sliceName: string, config: Partial<PersistenceConfig>) => {
if (sliceToPersistenceMap[sliceName]) {

View File

@@ -107,18 +107,21 @@ export const createSyncMonitorMiddleware = (): Middleware => {
const isFulfilledAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/fulfilled`)
const isRejectedAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/rejected`)
if (isPendingAction && action.meta?.requestId) {
syncMonitor.startOperation(action.meta.requestId)
if (isPendingAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.startOperation(operationId)
}
const result = next(action)
if (isFulfilledAction && action.meta?.requestId) {
syncMonitor.endOperation(action.meta.requestId, true)
if (isFulfilledAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.endOperation(operationId, true)
}
if (isRejectedAction && action.meta?.requestId) {
syncMonitor.endOperation(action.meta.requestId, false)
if (isRejectedAction) {
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.endOperation(operationId, false)
}
return result

View File

@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockFetchAllFromFlask,
mockDbPut,
mockDbGetAll,
mockDbDelete
} = vi.hoisted(() => {
return {
mockFetchAllFromFlask: vi.fn<[], Promise<Record<string, any>>>(),
mockDbPut: vi.fn<[string, any], Promise<void>>(),
mockDbGetAll: vi.fn<[string], Promise<any[]>>(),
mockDbDelete: vi.fn<[string, string], Promise<void>>()
}
})
vi.mock('@/store/middleware/flaskSync', () => ({
fetchAllFromFlask: mockFetchAllFromFlask
}))
vi.mock('@/lib/db', () => ({
db: {
put: mockDbPut,
getAll: mockDbGetAll,
delete: mockDbDelete
}
}))
import { syncFromFlaskBulk } from './syncSlice'
describe('syncFromFlaskBulk', () => {
const dispatch = vi.fn()
const getState = vi.fn()
beforeEach(() => {
mockFetchAllFromFlask.mockReset()
mockDbPut.mockReset()
mockDbGetAll.mockReset()
mockDbDelete.mockReset()
dispatch.mockReset()
getState.mockReset()
})
it('ignores invalid keys from Flask', async () => {
mockFetchAllFromFlask.mockResolvedValue({
'unknown:1': { id: '1' },
'files': { id: 'missing-colon' },
'models:': { id: 'empty-id' },
'components:abc:extra': { id: 'abc' }
})
mockDbGetAll.mockResolvedValue([])
const action = await syncFromFlaskBulk()(dispatch, getState, undefined)
expect(action.type).toBe('sync/syncFromFlaskBulk/fulfilled')
expect(mockDbPut).not.toHaveBeenCalled()
expect(mockDbDelete).not.toHaveBeenCalled()
})
it('updates local DB for valid keys', async () => {
const file = { id: 'file-1', name: 'File 1' }
const model = { id: 'model-1', name: 'Model 1' }
mockFetchAllFromFlask.mockResolvedValue({
'files:file-1': file,
'models:model-1': model
})
mockDbGetAll.mockResolvedValue([])
const action = await syncFromFlaskBulk()(dispatch, getState, undefined)
expect(action.type).toBe('sync/syncFromFlaskBulk/fulfilled')
expect(mockDbPut).toHaveBeenCalledWith('files', file)
expect(mockDbPut).toHaveBeenCalledWith('models', model)
})
it('deletes local entries missing from Flask data', async () => {
const file = { id: 'keep', name: 'Keep' }
mockFetchAllFromFlask.mockResolvedValue({
'files:keep': file
})
mockDbGetAll.mockImplementation((storeName) => {
if (storeName === 'files') {
return Promise.resolve([file, { id: 'stale', name: 'Stale' }])
}
return Promise.resolve([])
})
const action = await syncFromFlaskBulk()(dispatch, getState, undefined)
expect(action.type).toBe('sync/syncFromFlaskBulk/fulfilled')
expect(mockDbPut).toHaveBeenCalledWith('files', file)
expect(mockDbDelete).toHaveBeenCalledTimes(1)
expect(mockDbDelete).toHaveBeenCalledWith('files', 'stale')
expect(mockDbDelete).not.toHaveBeenCalledWith('files', 'keep')
})
})

View File

@@ -9,8 +9,6 @@ import { db } from '@/lib/db'
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'
const SYNCABLE_STORES = new Set(['files', 'models', 'components', 'workflows'])
interface SyncState {
status: SyncStatus
lastSyncedAt: number | null
@@ -70,51 +68,15 @@ export const syncFromFlaskBulk = createAsyncThunk(
async (_, { rejectWithValue }) => {
try {
const data = await fetchAllFromFlask()
const allowedStoreNames = new Set(['files', 'models', 'components', 'workflows'])
const serverIdsByStore = {
files: new Set<string>(),
models: new Set<string>(),
components: new Set<string>(),
workflows: new Set<string>(),
}
for (const [key, value] of Object.entries(data)) {
const [storeName, id] = key.split(':')
if (SYNCABLE_STORES.has(storeName)) {
if (storeName === 'files' ||
storeName === 'models' ||
storeName === 'components' ||
storeName === 'workflows') {
await db.put(storeName as any, value)
if (typeof key !== 'string') {
continue
}
const parts = key.split(':')
if (parts.length !== 2) {
continue
}
const [storeName, id] = parts
if (!storeName || !id) {
continue
}
if (!allowedStoreNames.has(storeName)) {
continue
}
serverIdsByStore[storeName as keyof typeof serverIdsByStore].add(id)
await db.put(storeName as any, value)
}
// Explicit merge strategy: server is source of truth; delete local records missing from server response.
const storeNames = Array.from(allowedStoreNames)
for (const storeName of storeNames) {
const localRecords = await db.getAll(storeName as any)
for (const record of localRecords) {
const recordId = record?.id
const recordIdString = recordId == null ? '' : String(recordId)
if (!serverIdsByStore[storeName as keyof typeof serverIdsByStore].has(recordIdString)) {
await db.delete(storeName as any, recordId)
}
}
}

View File

@@ -1,249 +0,0 @@
// This file is auto-generated by scripts/generate-json-ui-component-types.ts.
// Do not edit this file directly.
export const jsonUIComponentTypes = [
"div",
"section",
"article",
"header",
"footer",
"main",
"ActionCard",
"AlertDialog",
"Card",
"CodeExplanationDialog",
"CompletionCard",
"ComponentBindingDialog",
"ComponentBindingDialogWrapper",
"Container",
"DataSourceCard",
"DataSourceEditorDialog",
"DataSourceEditorDialogWrapper",
"Dialog",
"Drawer",
"Flex",
"GlowCard",
"Grid",
"HoverCard",
"Modal",
"ResponsiveGrid",
"Section",
"Stack",
"TipsCard",
"TreeCard",
"TreeFormDialog",
"ActionButton",
"Button",
"ButtonGroup",
"Checkbox",
"ConfirmButton",
"CopyButton",
"DatePicker",
"FileUpload",
"FilterInput",
"Form",
"IconButton",
"Input",
"InputOtp",
"NumberInput",
"PasswordInput",
"QuickActionButton",
"Radio",
"RadioGroup",
"RangeSlider",
"Select",
"Slider",
"Switch",
"TextArea",
"Toggle",
"ToggleGroup",
"ToolbarButton",
"ActionIcon",
"Avatar",
"AvatarGroup",
"Badge",
"CircularProgress",
"Code",
"Divider",
"FileIcon",
"Heading",
"HelperText",
"IconText",
"IconWrapper",
"Image",
"Label",
"Progress",
"ProgressBar",
"SchemaCodeViewer",
"Separator",
"Skeleton",
"Spinner",
"Tag",
"Text",
"Textarea",
"TextGradient",
"TextHighlight",
"TreeIcon",
"ArrowLeft",
"ArrowRight",
"Check",
"X",
"Plus",
"Minus",
"Search",
"Filter",
"Download",
"Upload",
"Edit",
"Trash",
"Eye",
"EyeOff",
"ChevronUp",
"ChevronDown",
"ChevronLeft",
"ChevronRight",
"Settings",
"User",
"Bell",
"Mail",
"Calendar",
"Clock",
"Star",
"Heart",
"Share",
"Link",
"Copy",
"Save",
"RefreshCw",
"AlertCircle",
"Info",
"HelpCircle",
"Home",
"Menu",
"MoreVertical",
"MoreHorizontal",
"Breadcrumb",
"ContextMenu",
"DropdownMenu",
"FileTabs",
"Menubar",
"NavigationGroupHeader",
"NavigationItem",
"NavigationMenu",
"TabIcon",
"Tabs",
"Alert",
"CountBadge",
"DataSourceBadge",
"EmptyCanvasState",
"EmptyEditorState",
"EmptyMessage",
"EmptyState",
"EmptyStateIcon",
"ErrorBadge",
"GitHubBuildStatus",
"GitHubBuildStatusWrapper",
"InfoBox",
"LabelWithBadge",
"LoadingFallback",
"LoadingSpinner",
"LoadingState",
"Notification",
"SchemaEditorStatusBar",
"SeedDataStatus",
"StatusBadge",
"StatusIcon",
"Chart",
"DataList",
"DataSourceManager",
"DataTable",
"KeyValue",
"LazyBarChart",
"LazyBarChartWrapper",
"LazyD3BarChart",
"LazyD3BarChartWrapper",
"LazyLineChart",
"LazyLineChartWrapper",
"List",
"ListItem",
"MetricCard",
"MetricDisplay",
"SeedDataManager",
"SeedDataManagerWrapper",
"StatCard",
"Table",
"TableHeader",
"TableBody",
"TableRow",
"TableCell",
"TableHead",
"Timeline",
"TreeListHeader",
"TreeListPanel",
"Accordion",
"ActionBar",
"AppBranding",
"AppHeader",
"AppLogo",
"AspectRatio",
"BindingEditor",
"BindingIndicator",
"CanvasRenderer",
"Carousel",
"Chip",
"Collapsible",
"ColorSwatch",
"Command",
"CommandPalette",
"ComponentPalette",
"ComponentPaletteItem",
"ComponentTree",
"ComponentTreeWrapper",
"ComponentTreeNode",
"DataCard",
"DetailRow",
"Dot",
"EditorActions",
"EditorToolbar",
"InfoPanel",
"JSONUIShowcase",
"Kbd",
"LazyInlineMonacoEditor",
"LazyMonacoEditor",
"LiveIndicator",
"MonacoEditorPanel",
"PageHeader",
"PageHeaderContent",
"Pagination",
"PanelHeader",
"Popover",
"PropertyEditor",
"PropertyEditorField",
"Pulse",
"Rating",
"Resizable",
"SaveIndicator",
"SaveIndicatorWrapper",
"SchemaEditorCanvas",
"SchemaEditorLayout",
"SchemaEditorPropertiesPanel",
"SchemaEditorSidebar",
"SchemaEditorToolbar",
"ScrollArea",
"SearchBar",
"SearchInput",
"Sheet",
"Sidebar",
"Sonner",
"Spacer",
"Sparkle",
"StepIndicator",
"Stepper",
"StorageSettings",
"StorageSettingsWrapper",
"Timestamp",
"ToolbarActions",
"Tooltip",
] as const
export type JSONUIComponentType = typeof jsonUIComponentTypes[number]

View File

@@ -1,6 +1,29 @@
import type { JSONUIComponentType } from './json-ui-component-types'
export type ComponentType = JSONUIComponentType
export type ComponentType =
| 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'
| 'Button' | 'Card' | 'CardHeader' | 'CardTitle' | 'CardDescription' | 'CardContent' | 'CardFooter'
| 'Input' | 'TextArea' | 'Textarea' | 'Select' | 'Checkbox' | 'Radio' | 'Switch' | 'Slider' | 'NumberInput' | 'DatePicker' | 'FileUpload'
| 'Badge' | 'Progress' | 'Separator' | 'Tabs' | 'TabsContent' | 'TabsList' | 'TabsTrigger' | 'Dialog'
| 'Text' | 'Heading' | 'Label' | 'List' | 'ListItem' | 'Grid' | 'Stack' | 'Flex' | 'Container'
| 'Link' | 'Breadcrumb' | 'Image' | 'Avatar' | 'Code' | 'Tag' | 'Spinner' | 'Skeleton'
| 'CircularProgress' | 'Divider' | 'ProgressBar'
| 'Alert' | 'InfoBox' | 'EmptyState' | 'StatusBadge'
| 'ErrorBadge' | 'Notification' | 'StatusIcon'
| 'Table' | 'TableHeader' | 'TableBody' | 'TableRow' | 'TableCell' | 'TableHead'
| 'KeyValue' | 'StatCard' | 'DataCard' | 'SearchInput' | 'ActionBar'
| 'DataList' | 'DataTable' | 'MetricCard' | 'Timeline'
| 'LazyBarChart' | 'LazyLineChart' | 'LazyD3BarChart' | 'SeedDataManager'
| 'SaveIndicator' | 'StorageSettings'
| 'AppBranding' | 'LabelWithBadge' | 'NavigationGroupHeader' | 'EmptyEditorState' | 'LoadingFallback' | 'LoadingState'
| 'CodeExplanationDialog' | 'ComponentBindingDialog' | 'DataSourceCard' | 'DataSourceEditorDialog' | 'TreeCard' | 'TreeFormDialog'
| 'ToolbarButton'
| 'SchemaCodeViewer'
| 'FileTabs' | 'NavigationItem' | 'NavigationMenu'
| 'EmptyCanvasState' | 'SchemaEditorStatusBar'
| 'DataSourceManager' | 'TreeListHeader' | 'TreeListPanel'
| 'AppHeader' | 'BindingEditor' | 'CanvasRenderer' | 'ComponentPalette' | 'ComponentTree' | 'EditorActions'
| 'EditorToolbar' | 'JSONUIShowcase' | 'LazyInlineMonacoEditor' | 'LazyMonacoEditor' | 'MonacoEditorPanel'
| 'PageHeaderContent' | 'PropertyEditor' | 'SchemaEditorCanvas' | 'SchemaEditorLayout'
| 'SchemaEditorPropertiesPanel' | 'SchemaEditorSidebar' | 'SchemaEditorToolbar' | 'SearchBar' | 'ToolbarActions'
export interface BreadcrumbItem {
label: string
@@ -19,7 +42,7 @@ export type ActionType =
| 'custom'
export type DataSourceType =
| 'kv' | 'static'
| 'kv' | 'computed' | 'static'
export type BindingSourceType =
| 'data' | 'bindings' | 'state'

View File

@@ -26,7 +26,7 @@ export const ComponentSchema: z.ZodType<any> = z.lazy(() =>
export const DataSourceSchema = z.object({
id: z.string(),
type: z.enum(['kv', 'static', 'ai'], { message: 'Invalid data source type' }),
type: z.enum(['kv', 'computed', 'static', 'ai'], { message: 'Invalid data source type' }),
key: z.string().optional(),
defaultValue: z.any().optional(),
dependencies: z.array(z.string()).optional(),