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

View File

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

View File

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

View File

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

View File

@@ -22,15 +22,6 @@
"type": "string" "type": "string"
} }
}, },
"sourceRoots": {
"type": "object",
"additionalProperties": {
"type": "array",
"items": {
"type": "string"
}
}
},
"components": { "components": {
"type": "array", "type": "array",
"items": { "items": {
@@ -82,19 +73,6 @@
"wrapperFor": { "wrapperFor": {
"type": "string" "type": "string"
}, },
"load": {
"type": "object",
"properties": {
"path": {
"type": "string"
},
"export": {
"type": "string"
}
},
"required": ["export"],
"additionalProperties": false
},
"deprecated": { "deprecated": {
"type": "object", "type": "object",
"properties": { "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 rootDir = path.resolve(__dirname, '..')
const registryPath = path.join(rootDir, 'json-components-registry.json') const registryPath = path.join(rootDir, 'json-components-registry.json')
const definitionsPath = path.join(rootDir, 'src/lib/component-definitions.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 uiRegistryPath = path.join(rootDir, 'src/lib/json-ui/component-registry.ts')
const atomIndexPath = path.join(rootDir, 'src/components/atoms/index.ts') const atomIndexPath = path.join(rootDir, 'src/components/atoms/index.ts')
const moleculeIndexPath = path.join(rootDir, 'src/components/molecules/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 definitionTypes = new Set(componentDefinitions.map((def) => def.type))
const componentTypesContent = readText(componentTypesPath) 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 componentTypeSet = new Set()
const componentTypeRegex = /"([^"]+)"/g const componentTypeRegex = /'([^']+)'/g
let match let match
while ((match = componentTypeRegex.exec(componentTypesContent)) !== null) { while ((match = componentTypeRegex.exec(componentTypesBlock)) !== null) {
componentTypeSet.add(match[1]) 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 { FeatureToggles } from '@/types/project'
import { useMemo } from 'react' import {
import featureToggleSchema from '@/schemas/feature-toggle-settings.json' BookOpen,
import type { PageSchema } from '@/types/json-ui' Code,
import { evaluateExpression } from '@/lib/json-ui/expression-evaluator' 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 { interface FeatureToggleSettingsProps {
features: FeatureToggles features: FeatureToggles
onFeaturesChange: (features: FeatureToggles) => void onFeaturesChange: (features: FeatureToggles) => void
} }
/** type FeatureToggleIconKey =
* FeatureToggleSettings - Now JSON-driven! | 'BookOpen'
* | 'Code'
* This component demonstrates how a complex React component with: | 'Cube'
* - Custom hooks and state management | 'Database'
* - Dynamic data rendering (looping over features) | 'FileText'
* - Event handlers (toggle switches) | 'Flask'
* - Conditional styling (enabled/disabled states) | 'FlowArrow'
* | 'Image'
* Can be converted to a pure JSON schema with custom action handlers. | 'Lightbulb'
* The JSON schema handles all UI structure, data binding, and loops, | 'PaintBrush'
* while custom functions handle business logic. | 'Play'
* | 'Tree'
* Converted from 153 lines of React/TSX to: | 'Wrench'
* - 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 iconMap: Record<FeatureToggleIconKey, ComponentType<{ size?: number; weight?: 'duotone' }>> = {
const key = evaluateExpression(action.params.key, context) as keyof FeatureToggles BookOpen,
const checked = eventData as boolean Code,
Cube,
Database,
FileText,
Flask,
FlowArrow,
Image,
Lightbulb,
PaintBrush,
Play,
Tree,
Wrench,
}
onFeaturesChange({ type FeatureToggleItem = {
...features, key: keyof FeatureToggles
[key]: checked, label: string
}) description: string
} icon: FeatureToggleIconKey
}), [features, onFeaturesChange]) }
// Pass features as external data to the JSON renderer const featuresList = featureToggleSettings as FeatureToggleItem[]
const data = useMemo(() => ({ features }), [features])
function FeatureToggleHeader({ enabledCount, totalCount }: { enabledCount: number; totalCount: number }) {
return ( return (
<PageRenderer <div className="mb-6">
schema={featureToggleSchema as PageSchema} <h2 className="text-2xl font-bold mb-2">Feature Toggles</h2>
data={data} <p className="text-muted-foreground">
functions={handlers} 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 { useMemo, useState } from 'react'
import showcaseCopy from '@/config/ui-examples/showcase.json' 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 { FileCode, ChartBar, ListBullets, Table, Gear, Clock } from '@phosphor-icons/react'
import { ShowcaseHeader } from '@/components/json-ui-showcase/ShowcaseHeader' import { ShowcaseHeader } from '@/components/json-ui-showcase/ShowcaseHeader'
import { ShowcaseTabs } from '@/components/json-ui-showcase/ShowcaseTabs' import { ShowcaseTabs } from '@/components/json-ui-showcase/ShowcaseTabs'
import { ShowcaseFooter } from '@/components/json-ui-showcase/ShowcaseFooter' import { ShowcaseFooter } from '@/components/json-ui-showcase/ShowcaseFooter'
import { ShowcaseExample } from '@/components/json-ui-showcase/types' import { ShowcaseExample } from '@/components/json-ui-showcase/types'
const exampleConfigs = {
dashboard: dashboardExample,
form: formExample,
table: tableExample,
'list-table-timeline': listTableTimelineExample,
settings: settingsExample,
}
const exampleIcons = { const exampleIcons = {
ChartBar, ChartBar,
ListBullets, ListBullets,
@@ -14,22 +27,14 @@ const exampleIcons = {
Gear, 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() { export function JSONUIShowcase() {
const [selectedExample, setSelectedExample] = useState(showcaseCopy.defaultExampleKey) const [selectedExample, setSelectedExample] = useState(showcaseCopy.defaultExampleKey)
const [showJSON, setShowJSON] = useState(false) const [showJSON, setShowJSON] = useState(false)
const examples = useMemo<ShowcaseExample[]>(() => { const examples = useMemo<ShowcaseExample[]>(() => {
return showcaseCopy.examples.map((example) => { return showcaseCopy.examples.map((example) => {
const icon = exampleIcons[example.iconId as keyof typeof exampleIcons] || FileCode const icon = exampleIcons[example.icon as keyof typeof exampleIcons] || FileCode
const config = resolveExampleConfig(example.configPath) const config = exampleConfigs[example.configKey as keyof typeof exampleConfigs]
return { return {
key: example.key, key: example.key,

View File

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

View File

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

View File

@@ -108,7 +108,7 @@ function PageCard({ card, data, functions }: PageCardProps) {
if (card.type === 'gradient-card') { if (card.type === 'gradient-card') {
const computeFn = functions[card.dataSource?.compute] const computeFn = functions[card.dataSource?.compute]
const computedData = computeFn ? computeFn(data) : data const computedData = computeFn ? computeFn(data) : {}
return ( return (
<Card className={cn('bg-gradient-to-br border-primary/20', card.gradient)}> <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 { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
import { DataSource } from '@/types/json-ui' import { DataSource } from '@/types/json-ui'
import { Pencil, Trash } from '@phosphor-icons/react' import { Pencil, Trash, ArrowsDownUp } from '@phosphor-icons/react'
interface DataSourceCardProps { interface DataSourceCardProps {
dataSource: DataSource dataSource: DataSource
@@ -11,6 +11,13 @@ interface DataSourceCardProps {
} }
export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }: DataSourceCardProps) { export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }: DataSourceCardProps) {
const getDependencyCount = () => {
if (dataSource.type === 'computed') {
return dataSource.dependencies?.length || 0
}
return 0
}
const renderTypeSpecificInfo = () => { const renderTypeSpecificInfo = () => {
if (dataSource.type === 'kv') { if (dataSource.type === 'kv') {
return ( 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 return null
} }
@@ -40,7 +59,7 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
{dependents.length > 0 && ( {dependents.length > 0 && (
<div className="pt-2 border-t border-border/50"> <div className="pt-2 border-t border-border/50">
<Text variant="caption"> <Text variant="caption">
Used by {dependents.length} dependent {dependents.length === 1 ? 'source' : 'sources'} Used by {dependents.length} computed {dependents.length === 1 ? 'source' : 'sources'}
</Text> </Text>
</div> </div>
)} )}

View File

@@ -5,12 +5,14 @@ import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
import { DataSourceIdField } from '@/components/molecules/data-source-editor/DataSourceIdField' import { DataSourceIdField } from '@/components/molecules/data-source-editor/DataSourceIdField'
import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields' import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields'
import { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields' import { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields'
import { ComputedSourceFields } from '@/components/molecules/data-source-editor/ComputedSourceFields'
import dataSourceEditorCopy from '@/data/data-source-editor-dialog.json' import dataSourceEditorCopy from '@/data/data-source-editor-dialog.json'
import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor' import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor'
interface DataSourceEditorDialogProps { interface DataSourceEditorDialogProps {
open: boolean open: boolean
dataSource: DataSource | null dataSource: DataSource | null
allDataSources: DataSource[]
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onSave: (dataSource: DataSource) => void onSave: (dataSource: DataSource) => void
} }
@@ -18,13 +20,19 @@ interface DataSourceEditorDialogProps {
export function DataSourceEditorDialog({ export function DataSourceEditorDialog({
open, open,
dataSource, dataSource,
allDataSources,
onOpenChange, onOpenChange,
onSave, onSave,
}: DataSourceEditorDialogProps) { }: DataSourceEditorDialogProps) {
const { const {
editingSource, editingSource,
updateField, updateField,
} = useDataSourceEditor(dataSource) addDependency,
removeDependency,
availableDeps,
selectedDeps,
unselectedDeps,
} = useDataSourceEditor(dataSource, allDataSources)
const handleSave = () => { const handleSave = () => {
if (!editingSource) return 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> </div>
<DialogFooter> <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 { DataSourceEditorDialog } from '@/components/molecules/DataSourceEditorDialog'
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager' import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
import { DataSource, DataSourceType } from '@/types/json-ui' 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 { toast } from 'sonner'
import { EmptyState, Stack } from '@/components/atoms' import { EmptyState, Stack } from '@/components/atoms'
import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader' import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader'
@@ -66,6 +66,7 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
const groupedSources = { const groupedSources = {
kv: localSources.filter(ds => ds.type === 'kv'), kv: localSources.filter(ds => ds.type === 'kv'),
computed: localSources.filter(ds => ds.type === 'computed'),
static: localSources.filter(ds => ds.type === 'static'), static: localSources.filter(ds => ds.type === 'static'),
} }
@@ -109,6 +110,15 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
onEdit={handleEditSource} onEdit={handleEditSource}
onDelete={handleDeleteSource} onDelete={handleDeleteSource}
/> />
<DataSourceGroupSection
icon={<Function size={16} />}
label={dataSourceManagerCopy.groups.computed}
dataSources={groupedSources.computed}
getDependents={getDependents}
onEdit={handleEditSource}
onDelete={handleDeleteSource}
/>
</Stack> </Stack>
)} )}
</CardContent> </CardContent>
@@ -117,6 +127,7 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
<DataSourceEditorDialog <DataSourceEditorDialog
open={dialogOpen} open={dialogOpen}
dataSource={editingSource} dataSource={editingSource}
allDataSources={localSources}
onOpenChange={setDialogOpen} onOpenChange={setDialogOpen}
onSave={handleSaveSource} onSave={handleSaveSource}
/> />

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ export const ActionSchema = z.object({
export const DataSourceSchema = z.object({ export const DataSourceSchema = z.object({
id: z.string(), 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(), key: z.string().optional(),
endpoint: z.string().optional(), endpoint: z.string().optional(),
transform: z.string().optional(), transform: z.string().optional(),

View File

@@ -33,20 +33,15 @@
}, },
{ {
"id": "selectedTree", "id": "selectedTree",
"type": "static", "type": "computed",
"expression": "data.trees.find(id === data.selectedTreeId)", "compute": "(data) => data.trees?.find(t => t.id === data.selectedTreeId) || null",
"dependencies": [ "dependencies": ["trees", "selectedTreeId"]
"trees",
"selectedTreeId"
]
}, },
{ {
"id": "treeCount", "id": "treeCount",
"type": "static", "type": "computed",
"expression": "data.trees.length", "compute": "(data) => (data.trees || []).length",
"dependencies": [ "dependencies": ["trees"]
"trees"
]
} }
], ],
"components": [ "components": [
@@ -141,145 +136,55 @@
}, },
"children": [ "children": [
{ {
"id": "tree-selection-state", "id": "empty-state",
"type": "div", "type": "div",
"conditional": { "props": {
"if": "selectedTree != null", "className": "flex-1 flex items-center justify-center"
"then": { },
"id": "tree-editor", "condition": {
"source": "selectedTree",
"transform": "(val) => !val"
},
"children": [
{
"id": "empty-state-content",
"type": "div", "type": "div",
"props": { "props": {
"className": "flex-1 p-6 overflow-auto" "className": "text-center space-y-4"
}, },
"children": [ "children": [
{ {
"id": "tree-header", "id": "empty-state-title",
"type": "div", "type": "Heading",
"props": { "props": {
"className": "mb-6" "className": "text-2xl font-bold text-muted-foreground",
}, "children": "No Tree Selected"
"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", "id": "empty-state-description",
"type": "Card", "type": "Text",
"props": { "props": {
"className": "min-h-[500px]" "className": "text-muted-foreground",
}, "children": "Select a component tree from the sidebar or create a new one"
"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": [
{ {
"id": "empty-state-content", "id": "empty-state-button",
"type": "div", "type": "Button",
"props": { "props": {
"className": "text-center space-y-4" "variant": "default",
"children": "Create Your First Tree"
}, },
"children": [ "events": [
{ {
"id": "empty-state-title", "event": "click",
"type": "Heading", "actions": [
"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", "id": "open-create-from-empty",
"actions": [ "type": "set-value",
{ "target": "createDialogOpen",
"id": "open-create-from-empty", "value": true
"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"
}
}
]
}
]
}
]
} }
] ]
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,20 +28,15 @@
}, },
{ {
"id": "selectedModel", "id": "selectedModel",
"type": "static", "type": "computed",
"expression": "data.models.find(id === data.selectedModelId)", "compute": "(data) => data.models?.find(m => m.id === data.selectedModelId) || null",
"dependencies": [ "dependencies": ["models", "selectedModelId"]
"models",
"selectedModelId"
]
}, },
{ {
"id": "modelCount", "id": "modelCount",
"type": "static", "type": "computed",
"expression": "data.models.length", "compute": "(data) => (data.models || []).length",
"dependencies": [ "dependencies": ["models"]
"models"
]
} }
], ],
"components": [ "components": [
@@ -136,142 +131,55 @@
}, },
"children": [ "children": [
{ {
"id": "model-selection-state", "id": "empty-state",
"type": "div", "type": "div",
"conditional": { "props": {
"if": "selectedModel != null", "className": "flex-1 flex items-center justify-center"
"then": { },
"id": "model-editor", "condition": {
"source": "selectedModel",
"transform": "(val) => !val"
},
"children": [
{
"id": "empty-state-content",
"type": "div", "type": "div",
"props": { "props": {
"className": "flex-1 p-6 overflow-auto" "className": "text-center space-y-4"
}, },
"children": [ "children": [
{ {
"id": "model-header", "id": "empty-state-title",
"type": "div", "type": "Heading",
"props": { "props": {
"className": "mb-6" "className": "text-2xl font-bold text-muted-foreground",
}, "children": "No Model Selected"
"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", "id": "empty-state-description",
"type": "Card", "type": "Text",
"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",
"props": { "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", "event": "click",
"type": "Heading", "actions": [
"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", "id": "open-create-from-empty",
"actions": [ "type": "set-value",
{ "target": "createDialogOpen",
"id": "open-create-from-empty", "value": true
"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", "id": "lastSaved",
"type": "static", "type": "static",
"defaultValue": null "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", "type": "div",
"conditional": { "props": {
"if": "lastSaved != null" "className": "flex items-center gap-1.5 text-xs text-muted-foreground"
}, },
"bindings": { "conditional": {
"lastSaved": { "if": "lastSaved !== null"
"source": "lastSaved" },
"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", "id": "activeVariant",
"type": "static", "type": "computed",
"expression": "data.theme.variants.find(id === data.theme.activeVariantId)", "compute": "(data) => { const theme = data.theme || {}; const variants = theme.variants || []; return variants.find(v => v.id === theme.activeVariantId) || variants[0] || null; }",
"dependencies": [ "dependencies": ["theme"]
"theme"
]
}, },
{ {
"id": "variantCount", "id": "variantCount",
"type": "static", "type": "computed",
"expression": "data.theme.variants.length", "compute": "(data) => ((data.theme || {}).variants || []).length",
"dependencies": [ "dependencies": ["theme"]
"theme"
]
}, },
{ {
"id": "customColorCount", "id": "customColorCount",
"type": "static", "type": "computed",
"expression": "Object.keys(data.activeVariant.colors.customColors).length", "compute": "(data) => { const variant = data.activeVariant; if (!variant || !variant.colors) return 0; return Object.keys(variant.colors.customColors || {}).length; }",
"dependencies": [ "dependencies": ["activeVariant"]
"activeVariant"
]
} }
], ],
"components": [ "components": [

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"title": "Edit Data Source", "title": "Edit Data Source",
"description": "Configure the data source settings", "description": "Configure the data source settings and dependencies",
"fields": { "fields": {
"id": { "id": {
"label": "ID", "label": "ID",
@@ -18,6 +18,17 @@
"valueLabel": "Value (JSON)", "valueLabel": "Value (JSON)",
"valuePlaceholder": "{\"key\": \"value\"}" "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": { "actions": {
"cancel": "Cancel", "cancel": "Cancel",
"save": "Save Changes" "save": "Save Changes"

View File

@@ -1,13 +1,14 @@
{ {
"header": { "header": {
"title": "Data Sources", "title": "Data Sources",
"description": "Manage KV storage and static data" "description": "Manage KV storage, computed values, and static data"
}, },
"actions": { "actions": {
"add": "Add Data Source" "add": "Add Data Source"
}, },
"menu": { "menu": {
"kv": "KV Store", "kv": "KV Store",
"computed": "Computed Value",
"static": "Static Data" "static": "Static Data"
}, },
"emptyState": { "emptyState": {
@@ -16,11 +17,12 @@
}, },
"groups": { "groups": {
"kv": "KV Store", "kv": "KV Store",
"static": "Static Data" "static": "Static Data",
"computed": "Computed Values"
}, },
"toasts": { "toasts": {
"deleteBlockedTitle": "Cannot delete", "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", "deleted": "Data source deleted",
"updated": "Data source updated" "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' import { DataSource } from '@/types/json-ui'
export function useDataSourceEditor( export function useDataSourceEditor(
dataSource: DataSource | null, dataSource: DataSource | null,
allDataSources: DataSource[],
) { ) {
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource) const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
@@ -14,8 +15,44 @@ export function useDataSourceEditor(
setEditingSource(prev => (prev ? { ...prev, [field]: value } : prev)) 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 { return {
editingSource, editingSource,
updateField, updateField,
addDependency,
removeDependency,
availableDeps,
selectedDeps,
unselectedDeps,
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

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' import { DataSource } from '@/types/json-ui'
interface UseDataSourceEditorParams { interface UseDataSourceEditorParams {
dataSource: DataSource | null dataSource: DataSource | null
allDataSources: DataSource[]
onSave: (dataSource: DataSource) => void onSave: (dataSource: DataSource) => void
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
} }
export function useDataSourceEditor({ export function useDataSourceEditor({
dataSource, dataSource,
allDataSources,
onSave, onSave,
onOpenChange, onOpenChange,
}: UseDataSourceEditorParams) { }: 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(() => { const handleSave = useCallback(() => {
if (!editingSource) return if (!editingSource) return
onSave(editingSource) onSave(editingSource)
onOpenChange(false) onOpenChange(false)
}, [editingSource, onOpenChange, onSave]) }, [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 { return {
editingSource, editingSource,
updateField, updateField,
addDependency,
removeDependency,
handleSave, handleSave,
availableDeps,
selectedDeps,
unselectedDeps,
} }
} }

View File

@@ -2,101 +2,6 @@
import { PrismaModel, ComponentNode, ThemeConfig, ProjectFile } from '@/types/project' import { PrismaModel, ComponentNode, ThemeConfig, ProjectFile } from '@/types/project'
import { ProtectedLLMService } from './protected-llm-service' 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 { export class AIService {
static async generateComponent(description: string): Promise<ComponentNode | null> { 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) { if (result) {
const parsed = parseAndValidateJson( const parsed = JSON.parse(result)
result, return parsed.component
componentResponseSchema,
'generate-component',
'AI component response was invalid. Please retry or clarify your description.'
)
return parsed ? parsed.component : null
} }
return null return null
} catch (error) { } catch (error) {
@@ -180,13 +80,8 @@ Return a valid JSON object with a single property "model" containing the model s
) )
if (result) { if (result) {
const parsed = parseAndValidateJson( const parsed = JSON.parse(result)
result, return parsed.model
prismaModelResponseSchema,
'generate-model',
'AI model response was invalid. Please retry or describe the model differently.'
)
return parsed ? parsed.model : null
} }
return null return null
} catch (error) { } catch (error) {
@@ -277,13 +172,8 @@ Return a valid JSON object with a single property "theme" containing:
) )
if (result) { if (result) {
const parsed = parseAndValidateJson( const parsed = JSON.parse(result)
result, return parsed.theme
themeResponseSchema,
'generate-theme',
'AI theme response was invalid. Please retry or specify the theme requirements.'
)
return parsed ? parsed.theme : null
} }
return null return null
} catch (error) { } catch (error) {
@@ -312,13 +202,8 @@ Suggest 3-5 common fields that would be useful for this model type. Use camelCas
) )
if (result) { if (result) {
const parsed = parseAndValidateJson( const parsed = JSON.parse(result)
result, return parsed.fields
suggestFieldsResponseSchema,
'suggest-fields',
'AI field suggestions were invalid. Please retry with a clearer model name.'
)
return parsed ? parsed.fields : null
} }
return null return null
} catch (error) { } catch (error) {
@@ -399,12 +284,7 @@ Create 2-4 essential files for the app structure. Include appropriate Prisma mod
) )
if (result) { if (result) {
return parseAndValidateJson( return JSON.parse(result)
result,
completeAppResponseSchema,
'generate-app',
'AI app generation response was invalid. Please retry with more detail.'
)
} }
return null return null
} catch (error) { } 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 { 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 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 { export interface UIComponentRegistry {
[key: string]: ComponentType<any> [key: string]: ComponentType<any>
@@ -15,16 +72,11 @@ interface JsonRegistryEntry {
wrapperRequired?: boolean wrapperRequired?: boolean
wrapperComponent?: string wrapperComponent?: string
wrapperFor?: string wrapperFor?: string
load?: {
path?: string
export?: string
}
deprecated?: DeprecatedComponentInfo deprecated?: DeprecatedComponentInfo
} }
interface JsonComponentRegistry { interface JsonComponentRegistry {
components?: JsonRegistryEntry[] components?: JsonRegistryEntry[]
sourceRoots?: Record<string, string[]>
} }
export interface DeprecatedComponentInfo { export interface DeprecatedComponentInfo {
@@ -33,127 +85,70 @@ export interface DeprecatedComponentInfo {
} }
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
const sourceRoots = jsonRegistry.sourceRoots ?? {}
const moduleMapsBySource = Object.fromEntries( const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
Object.entries(sourceRoots).map(([source, patterns]) => { entry.export ?? entry.name ?? entry.type
if (!patterns || patterns.length === 0) {
return [source, {}] 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 })] return registry
}) }, {})
) 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)
const jsonRegistryEntries = jsonRegistry.components ?? [] const jsonRegistryEntries = jsonRegistry.components ?? []
const registryEntryByType = new Map( const registryEntryByType = new Map(
jsonRegistryEntries jsonRegistryEntries
.map((entry) => { .map((entry) => {
const entryKey = getRegistryEntryKey(entry) const entryName = getRegistryEntryName(entry)
return entryKey ? [entryKey, entry] : null return entryName ? [entryName, entry] : null
}) })
.filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry)) .filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry))
) )
const atomComponentMap = AtomComponents as Record<string, ComponentType<any>>
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>( const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
(acc, entry) => { (acc, entry) => {
const entryKey = getRegistryEntryKey(entry) const entryName = getRegistryEntryName(entry)
if (!entryKey) { if (!entryName) {
return acc return acc
} }
if (entry.status === 'deprecated' || entry.deprecated) { if (entry.status === 'deprecated' || entry.deprecated) {
acc[entryKey] = entry.deprecated ?? {} acc[entryName] = entry.deprecated ?? {}
} }
return acc return acc
}, },
{} {}
) )
const atomRegistryNames = jsonRegistryEntries
const buildComponentMapFromExports = ( .filter((entry) => entry.source === 'atoms')
exports: Record<string, unknown> .map((entry) => getRegistryEntryName(entry))
): Record<string, ComponentType<any>> => { .filter((name): name is string => Boolean(name))
return Object.entries(exports).reduce<Record<string, ComponentType<any>>>((acc, [key, value]) => { const moleculeRegistryNames = jsonRegistryEntries
if (value && (typeof value === 'function' || typeof value === 'object')) { .filter((entry) => entry.source === 'molecules')
acc[key] = value as ComponentType<any> .map((entry) => getRegistryEntryName(entry))
} .filter((name): name is string => Boolean(name))
return acc const organismRegistryNames = jsonRegistryEntries
}, {}) .filter((entry) => entry.source === 'organisms')
} .map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const buildComponentMapFromModules = ( const shadcnRegistryNames = jsonRegistryEntries
modules: Record<string, unknown> .filter((entry) => entry.source === 'ui')
): Record<string, ComponentType<any>> => { .map((entry) => getRegistryEntryName(entry))
return Object.values(modules).reduce<Record<string, ComponentType<any>>>((acc, moduleExports) => { .filter((name): name is string => Boolean(name))
if (!moduleExports || typeof moduleExports !== 'object') { const wrapperRegistryNames = jsonRegistryEntries
return acc .filter((entry) => entry.source === 'wrappers')
} .map((entry) => getRegistryEntryName(entry))
Object.entries(buildComponentMapFromExports(moduleExports as Record<string, unknown>)).forEach( .filter((name): name is string => Boolean(name))
([key, component]) => { const iconRegistryNames = jsonRegistryEntries
acc[key] = component .filter((entry) => entry.source === 'icons')
} .map((entry) => getRegistryEntryName(entry))
) .filter((name): name is string => Boolean(name))
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
}, {})
}
export const primitiveComponents: UIComponentRegistry = { export const primitiveComponents: UIComponentRegistry = {
div: 'div' as any, div: 'div' as any,
@@ -174,33 +169,173 @@ export const primitiveComponents: UIComponentRegistry = {
nav: 'nav' as any, nav: 'nav' as any,
} }
export const shadcnComponents: UIComponentRegistry = buildRegistryFromEntries( const shadcnComponentMap: Record<string, ComponentType<any>> = {
'ui', AlertDialog,
uiComponentMap 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( export const atomComponents: UIComponentRegistry = {
'atoms', ...buildRegistryFromNames(
atomComponentMap 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( const wrapperComponentMap: Record<string, ComponentType<any>> = {
'molecules', ComponentBindingDialogWrapper,
moleculeComponentMap ComponentTreeWrapper,
) DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
SaveIndicatorWrapper,
LazyBarChartWrapper,
LazyLineChartWrapper,
LazyD3BarChartWrapper,
SeedDataManagerWrapper,
StorageSettingsWrapper,
}
export const organismComponents: UIComponentRegistry = buildRegistryFromEntries( export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames(
'organisms', wrapperRegistryNames,
organismComponentMap
)
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromEntries(
'wrappers',
wrapperComponentMap wrapperComponentMap
) )
export const iconComponents: UIComponentRegistry = buildRegistryFromEntries( const iconComponentMap: Record<string, ComponentType<any>> = {
'icons', 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 iconComponentMap
) )

View File

@@ -54,35 +54,6 @@ export function evaluateExpression(
return lengthSuffix ? filtered.length : filtered 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" // Handle direct data access: "data.fieldName"
if (expression.startsWith('data.')) { if (expression.startsWith('data.')) {
return getNestedValue(data, expression.substring(5)) 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 kvConfig = config.type === 'kv' ? config.config : undefined
const apiConfig = config.type === 'api' ? config.config : undefined const apiConfig = config.type === 'api' ? config.config : undefined
const computedConfig = config.type === 'computed' ? config.config : undefined
const defaultValue = const defaultValue =
config.type === 'static' ? config.config : config.config?.defaultValue config.type === 'static' ? config.config : config.config?.defaultValue
@@ -56,6 +57,8 @@ export function useJSONDataSource<T = unknown>(
return apiValue return apiValue
case 'static': case 'static':
return config.config return config.config
case 'computed':
return computedConfig?.defaultValue
default: default:
return null return null
} }

View File

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

View File

@@ -1,31 +1,14 @@
import { StatusIcon } from '@/components/atoms' import { StatusIcon } from '@/components/atoms'
import { useSaveIndicator } from '@/hooks/use-save-indicator'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { SaveIndicatorWrapperProps } from './interfaces' import type { SaveIndicatorWrapperProps } from './interfaces'
export function SaveIndicatorWrapper({ export function SaveIndicatorWrapper({
lastSaved,
status = 'saved', status = 'saved',
label, label,
showLabel = true, showLabel = true,
animate, animate,
className, className,
}: SaveIndicatorWrapperProps) { }: 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 resolvedLabel = label ?? (status === 'saved' ? 'Saved' : 'Synced')
const shouldAnimate = animate ?? status === 'saved' const shouldAnimate = animate ?? status === 'saved'

View File

@@ -4,7 +4,6 @@ import type { UIComponent } from '@/types/json-ui'
export type SaveIndicatorStatus = 'saved' | 'synced' export type SaveIndicatorStatus = 'saved' | 'synced'
export interface SaveIndicatorWrapperProps { export interface SaveIndicatorWrapperProps {
lastSaved?: number | null
status?: SaveIndicatorStatus status?: SaveIndicatorStatus
label?: string label?: string
showLabel?: boolean 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 maxRequests: number
windowMs: number windowMs: number
retryDelay: number retryDelay: number
maxRetries?: number
} }
interface RequestRecord { interface RequestRecord {
@@ -10,15 +9,14 @@ interface RequestRecord {
count: number count: number
} }
export class RateLimiter { class RateLimiter {
private requests: Map<string, RequestRecord> = new Map() private requests: Map<string, RequestRecord> = new Map()
private config: RateLimitConfig private config: RateLimitConfig
constructor(config: RateLimitConfig = { constructor(config: RateLimitConfig = {
maxRequests: 5, maxRequests: 5,
windowMs: 60000, windowMs: 60000,
retryDelay: 2000, retryDelay: 2000
maxRetries: 3
}) { }) {
this.config = config this.config = config
} }
@@ -28,60 +26,49 @@ export class RateLimiter {
fn: () => Promise<T>, fn: () => Promise<T>,
priority: 'low' | 'medium' | 'high' = 'medium' priority: 'low' | 'medium' | 'high' = 'medium'
): Promise<T | null> { ): Promise<T | null> {
const maxRetries = this.config.maxRetries ?? 3 const now = Date.now()
let attempts = 0 const record = this.requests.get(key)
while (true) { if (record) {
const now = Date.now() const timeElapsed = now - record.timestamp
const record = this.requests.get(key)
let isLimited = false
if (record) { if (timeElapsed < this.config.windowMs) {
const timeElapsed = now - record.timestamp 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 (timeElapsed < this.config.windowMs) { if (priority === 'high') {
if (record.count >= this.config.maxRequests) { await new Promise(resolve => setTimeout(resolve, this.config.retryDelay))
console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`) return this.throttle(key, fn, priority)
isLimited = true
} else {
record.count++
} }
} else {
this.requests.set(key, { timestamp: now, count: 1 }) return null
} }
record.count++
} else { } else {
this.requests.set(key, { timestamp: now, count: 1 }) this.requests.set(key, { timestamp: now, count: 1 })
} }
} else {
this.requests.set(key, { timestamp: now, count: 1 })
}
this.cleanup() this.cleanup()
if (isLimited) { try {
if (priority === 'high' && attempts < maxRetries) { return await fn()
attempts += 1 } catch (error) {
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay)) if (error instanceof Error && (
continue 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) 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) { if (!response.ok) {
const errorPayload = hasJsonBody ? await response.json().catch(() => null) : null let errorMessage = response.statusText
const errorMessage = errorPayload?.error || response.statusText || `HTTP ${response.status}` try {
throw new Error(errorMessage) const errorText = await response.text()
} if (errorText) {
try {
if (response.status === 204 || !hasJsonBody) { const parsed = JSON.parse(errorText) as { error?: string }
return undefined as T errorMessage = parsed.error || errorText
} catch {
errorMessage = errorText
}
}
} catch {
// ignore error parsing failures
}
throw new Error(errorMessage || `HTTP ${response.status}`)
} }
const responseText = await response.text() const responseText = await response.text()

View File

@@ -22,13 +22,13 @@
}, },
{ {
"id": "filteredUsers", "id": "filteredUsers",
"type": "static", "type": "computed",
"expression": "data.users", "expression": "data.users",
"dependencies": ["users", "filterQuery"] "dependencies": ["users", "filterQuery"]
}, },
{ {
"id": "stats", "id": "stats",
"type": "static", "type": "computed",
"valueTemplate": { "valueTemplate": {
"total": "data.users.length", "total": "data.users.length",
"active": "data.users.filter(status === 'active').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", "id": "stats",
"type": "static", "type": "computed",
"valueTemplate": { "valueTemplate": {
"total": "data.todos.length", "total": "data.todos.length",
"completed": "data.todos.filter(completed === true).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 { Middleware } from '@reduxjs/toolkit'
import { syncToFlaskBulk, checkFlaskConnection } from '../slices/syncSlice' import { syncToFlaskBulk, checkFlaskConnection } from '../slices/syncSlice'
import { RootState } from '../index' import { RootState } from '../index'
import { itemChangeActionTypes } from '../actionNames'
interface AutoSyncConfig { interface AutoSyncConfig {
enabled: boolean enabled: boolean
@@ -10,7 +9,7 @@ interface AutoSyncConfig {
maxQueueSize: number maxQueueSize: number
} }
export class AutoSyncManager { class AutoSyncManager {
private config: AutoSyncConfig = { private config: AutoSyncConfig = {
enabled: false, enabled: false,
intervalMs: 30000, intervalMs: 30000,
@@ -21,8 +20,6 @@ export class AutoSyncManager {
private timer: ReturnType<typeof setTimeout> | null = null private timer: ReturnType<typeof setTimeout> | null = null
private lastSyncTime = 0 private lastSyncTime = 0
private changeCounter = 0 private changeCounter = 0
private inFlight = false
private pendingSync = false
private dispatch: any = null private dispatch: any = null
configure(config: Partial<AutoSyncConfig>) { configure(config: Partial<AutoSyncConfig>) {
@@ -71,33 +68,18 @@ export class AutoSyncManager {
private async performSync() { private async performSync() {
if (!this.dispatch) return if (!this.dispatch) return
if (this.inFlight) {
this.pendingSync = true
return
}
this.inFlight = true
try { try {
await this.dispatch(syncToFlaskBulk()) await this.dispatch(syncToFlaskBulk())
this.lastSyncTime = Date.now() this.lastSyncTime = Date.now()
this.changeCounter = 0 this.changeCounter = 0
} catch (error) { } catch (error) {
console.error('[AutoSync] Sync failed:', error) console.error('[AutoSync] Sync failed:', error)
} finally {
this.inFlight = false
}
if (this.pendingSync) {
this.pendingSync = false
await this.performSync()
} }
} }
trackChange() { trackChange() {
this.changeCounter++ this.changeCounter++
if (this.inFlight) {
this.pendingSync = true
}
if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) { if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) {
this.performSync() 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() autoSyncManager.trackChange()
} }

View File

@@ -37,7 +37,6 @@ export async function syncToFlask(
} }
} catch (error) { } catch (error) {
console.error('[FlaskSync] Error syncing to Flask:', 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 { db } from '@/lib/db'
import { syncToFlask } from './flaskSync' import { syncToFlask } from './flaskSync'
import { RootState } from '../index' import { RootState } from '../index'
import {
persistenceBulkActionNames,
persistenceDeleteActionNames,
persistenceSingleItemActionNames,
} from '../actionNames'
interface PersistenceConfig { interface PersistenceConfig {
storeName: string storeName: string
@@ -43,23 +38,10 @@ type PendingOperation = {
timestamp: number 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 { class PersistenceQueue {
private queue: Map<string, PendingOperation> = new Map() private queue: Map<string, PendingOperation> = new Map()
private processing = false private processing = false
private pendingFlush = false
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map() 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) { enqueue(operation: PendingOperation, debounceMs: number) {
const opKey = `${operation.storeName}:${operation.key}` const opKey = `${operation.storeName}:${operation.key}`
@@ -80,12 +62,7 @@ class PersistenceQueue {
} }
async processQueue() { async processQueue() {
if (this.processing) { if (this.processing || this.queue.size === 0) return
this.pendingFlush = true
return
}
if (this.queue.size === 0) return
this.processing = true this.processing = true
@@ -98,10 +75,14 @@ class PersistenceQueue {
try { try {
if (op.type === 'put') { if (op.type === 'put') {
await db.put(op.storeName as any, op.value) 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') { } else if (op.type === 'delete') {
await db.delete(op.storeName as any, op.key) 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) { } catch (error) {
console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error) console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error)
@@ -116,23 +97,6 @@ class PersistenceQueue {
} }
} finally { } finally {
this.processing = false 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() this.debounceTimers.clear()
await this.processQueue() 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() const persistenceQueue = new PersistenceQueue()
@@ -247,7 +128,10 @@ export const createPersistenceMiddleware = (): Middleware => {
if (!sliceState) return result if (!sliceState) return result
try { 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 const item = action.payload
if (item && item.id) { if (item && item.id) {
persistenceQueue.enqueue({ 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 const items = action.payload
if (Array.isArray(items)) { if (Array.isArray(items)) {
items.forEach((item: any) => { 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 const itemId = typeof action.payload === 'string' ? action.payload : action.payload?.id
if (itemId) { if (itemId) {
persistenceQueue.enqueue({ persistenceQueue.enqueue({
@@ -318,8 +208,6 @@ export const createPersistenceMiddleware = (): Middleware => {
} }
export const flushPersistence = () => persistenceQueue.flush() export const flushPersistence = () => persistenceQueue.flush()
export const getFailedSyncOperations = () => persistenceQueue.getFailedSyncs()
export const retryFailedSyncOperations = () => persistenceQueue.retryFailedSyncs()
export const configurePersistence = (sliceName: string, config: Partial<PersistenceConfig>) => { export const configurePersistence = (sliceName: string, config: Partial<PersistenceConfig>) => {
if (sliceToPersistenceMap[sliceName]) { if (sliceToPersistenceMap[sliceName]) {

View File

@@ -107,18 +107,21 @@ export const createSyncMonitorMiddleware = (): Middleware => {
const isFulfilledAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/fulfilled`) const isFulfilledAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/fulfilled`)
const isRejectedAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/rejected`) const isRejectedAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/rejected`)
if (isPendingAction && action.meta?.requestId) { if (isPendingAction) {
syncMonitor.startOperation(action.meta.requestId) const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.startOperation(operationId)
} }
const result = next(action) const result = next(action)
if (isFulfilledAction && action.meta?.requestId) { if (isFulfilledAction) {
syncMonitor.endOperation(action.meta.requestId, true) const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.endOperation(operationId, true)
} }
if (isRejectedAction && action.meta?.requestId) { if (isRejectedAction) {
syncMonitor.endOperation(action.meta.requestId, false) const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
syncMonitor.endOperation(operationId, false)
} }
return result 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' export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'
const SYNCABLE_STORES = new Set(['files', 'models', 'components', 'workflows'])
interface SyncState { interface SyncState {
status: SyncStatus status: SyncStatus
lastSyncedAt: number | null lastSyncedAt: number | null
@@ -70,51 +68,15 @@ export const syncFromFlaskBulk = createAsyncThunk(
async (_, { rejectWithValue }) => { async (_, { rejectWithValue }) => {
try { try {
const data = await fetchAllFromFlask() 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)) { for (const [key, value] of Object.entries(data)) {
const [storeName, id] = key.split(':') 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) 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 =
| 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'
export type ComponentType = JSONUIComponentType | '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 { export interface BreadcrumbItem {
label: string label: string
@@ -19,7 +42,7 @@ export type ActionType =
| 'custom' | 'custom'
export type DataSourceType = export type DataSourceType =
| 'kv' | 'static' | 'kv' | 'computed' | 'static'
export type BindingSourceType = export type BindingSourceType =
| 'data' | 'bindings' | 'state' | 'data' | 'bindings' | 'state'

View File

@@ -26,7 +26,7 @@ export const ComponentSchema: z.ZodType<any> = z.lazy(() =>
export const DataSourceSchema = z.object({ export const DataSourceSchema = z.object({
id: z.string(), 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(), key: z.string().optional(),
defaultValue: z.any().optional(), defaultValue: z.any().optional(),
dependencies: z.array(z.string()).optional(), dependencies: z.array(z.string()).optional(),