mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 21:54:56 +00:00
Compare commits
51 Commits
codex/exte
...
copilot/re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0dfd9e7c | ||
|
|
96744a6ab4 | ||
| f181bb870a | |||
| 05d9034366 | |||
| 29d59ec863 | |||
| 8841b74027 | |||
| d351f05b14 | |||
| 85fb859131 | |||
| d408ceff79 | |||
| b8dc6f38e6 | |||
| 73959e3d48 | |||
| d20609ecbd | |||
| 4cb9c01748 | |||
| 862e676296 | |||
| 32dd4d0eac | |||
| b34e45067d | |||
| 1a928a29dc | |||
| 27dfebcb24 | |||
| 03cc955d20 | |||
| 8c11895fba | |||
| 82b64785bf | |||
| aea8676a33 | |||
| 6abf9f8414 | |||
| ee7bc50881 | |||
| f186d67d20 | |||
| 0c375283ed | |||
| 7544c5c2e5 | |||
| 5d95bc428b | |||
| fdd1828fda | |||
| c3a05607ba | |||
| 6c777ed47c | |||
| ce9fcaf3d1 | |||
| bda28a71e4 | |||
| 4eb4849d57 | |||
| e098b9184b | |||
| b931164c3a | |||
| 7d75c6adc0 | |||
| 33e49b3671 | |||
| ace40f7e73 | |||
| 140fe351f8 | |||
| 714fb510ab | |||
| 9c3cc81c35 | |||
| def3259178 | |||
| 51040a23b9 | |||
| 785d6afc40 | |||
| 0a0046c2f3 | |||
| a0d65352a9 | |||
| baf5001704 | |||
| e075908a15 | |||
| 20f116d623 | |||
| eb9174c80d |
@@ -12,7 +12,69 @@
|
||||
"data": "Data display and visualization components",
|
||||
"custom": "Custom domain-specific components"
|
||||
},
|
||||
"sourceRoots": {
|
||||
"atoms": ["@/components/atoms/*.tsx"],
|
||||
"molecules": ["@/components/molecules/*.tsx"],
|
||||
"organisms": ["@/components/organisms/*.tsx"],
|
||||
"ui": ["@/components/ui/**/*.{ts,tsx}"],
|
||||
"wrappers": ["@/lib/json-ui/wrappers/*.tsx"],
|
||||
"icons": []
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "div",
|
||||
"name": "div",
|
||||
"category": "layout",
|
||||
"canHaveChildren": true,
|
||||
"description": "Generic block container",
|
||||
"status": "supported",
|
||||
"source": "primitive"
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"name": "section",
|
||||
"category": "layout",
|
||||
"canHaveChildren": true,
|
||||
"description": "Semantic section container",
|
||||
"status": "supported",
|
||||
"source": "primitive"
|
||||
},
|
||||
{
|
||||
"type": "article",
|
||||
"name": "article",
|
||||
"category": "layout",
|
||||
"canHaveChildren": true,
|
||||
"description": "Semantic article container",
|
||||
"status": "supported",
|
||||
"source": "primitive"
|
||||
},
|
||||
{
|
||||
"type": "header",
|
||||
"name": "header",
|
||||
"category": "layout",
|
||||
"canHaveChildren": true,
|
||||
"description": "Semantic header container",
|
||||
"status": "supported",
|
||||
"source": "primitive"
|
||||
},
|
||||
{
|
||||
"type": "footer",
|
||||
"name": "footer",
|
||||
"category": "layout",
|
||||
"canHaveChildren": true,
|
||||
"description": "Semantic footer container",
|
||||
"status": "supported",
|
||||
"source": "primitive"
|
||||
},
|
||||
{
|
||||
"type": "main",
|
||||
"name": "main",
|
||||
"category": "layout",
|
||||
"canHaveChildren": true,
|
||||
"description": "Semantic main container",
|
||||
"status": "supported",
|
||||
"source": "primitive"
|
||||
},
|
||||
{
|
||||
"type": "ActionCard",
|
||||
"name": "ActionCard",
|
||||
@@ -80,7 +142,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "ComponentBindingDialog"
|
||||
"wrapperFor": "ComponentBindingDialog",
|
||||
"load": {
|
||||
"export": "ComponentBindingDialogWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Container",
|
||||
@@ -122,7 +187,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "DataSourceEditorDialog"
|
||||
"wrapperFor": "DataSourceEditorDialog",
|
||||
"load": {
|
||||
"export": "DataSourceEditorDialogWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Dialog",
|
||||
@@ -724,7 +792,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "ArrowLeft icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "ArrowLeft"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ArrowRight",
|
||||
@@ -733,7 +804,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "ArrowRight icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "ArrowRight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Check",
|
||||
@@ -742,7 +816,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Check icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Check"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "X",
|
||||
@@ -751,7 +828,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "X icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "X"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Plus",
|
||||
@@ -760,7 +840,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Plus icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Plus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Minus",
|
||||
@@ -769,7 +852,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Minus icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Minus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Search",
|
||||
@@ -778,7 +864,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Search icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "MagnifyingGlass"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Filter",
|
||||
@@ -787,7 +876,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Filter icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Funnel"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Download",
|
||||
@@ -796,7 +888,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Download icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Download"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Upload",
|
||||
@@ -805,7 +900,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Upload icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Upload"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Edit",
|
||||
@@ -814,7 +912,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Edit icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "PencilSimple"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Trash",
|
||||
@@ -823,7 +924,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Trash icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Trash"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Eye",
|
||||
@@ -832,7 +936,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Eye icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Eye"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "EyeOff",
|
||||
@@ -841,7 +948,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "EyeOff icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "EyeClosed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ChevronUp",
|
||||
@@ -850,7 +960,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "ChevronUp icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "CaretUp"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ChevronDown",
|
||||
@@ -859,7 +972,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "ChevronDown icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "CaretDown"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ChevronLeft",
|
||||
@@ -868,7 +984,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "ChevronLeft icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "CaretLeft"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ChevronRight",
|
||||
@@ -877,7 +996,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "ChevronRight icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "CaretRight"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Settings",
|
||||
@@ -886,7 +1008,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Settings icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Gear"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "User",
|
||||
@@ -895,7 +1020,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "User icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "User"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Bell",
|
||||
@@ -904,7 +1032,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Bell icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Bell"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Mail",
|
||||
@@ -913,7 +1044,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Mail icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Envelope"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Calendar",
|
||||
@@ -922,7 +1056,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Calendar icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Calendar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Clock",
|
||||
@@ -931,7 +1068,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Clock icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Clock"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Star",
|
||||
@@ -940,7 +1080,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Star icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Star"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Heart",
|
||||
@@ -949,7 +1092,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Heart icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Heart"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Share",
|
||||
@@ -958,7 +1104,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Share icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "ShareNetwork"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Link",
|
||||
@@ -967,7 +1116,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Link icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "LinkSimple"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Copy",
|
||||
@@ -976,7 +1128,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Copy icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Copy"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Save",
|
||||
@@ -985,7 +1140,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Save icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "FloppyDisk"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "RefreshCw",
|
||||
@@ -994,7 +1152,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "RefreshCw icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "ArrowClockwise"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "AlertCircle",
|
||||
@@ -1003,7 +1164,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "AlertCircle icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "WarningCircle"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Info",
|
||||
@@ -1012,7 +1176,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Info icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Info"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "HelpCircle",
|
||||
@@ -1021,7 +1188,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "HelpCircle icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "Question"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Home",
|
||||
@@ -1030,7 +1200,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Home icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "House"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Menu",
|
||||
@@ -1039,7 +1212,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Menu icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "List"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "MoreVertical",
|
||||
@@ -1048,7 +1224,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "MoreVertical icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "DotsThreeVertical"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "MoreHorizontal",
|
||||
@@ -1057,7 +1236,10 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "MoreHorizontal icon",
|
||||
"status": "supported",
|
||||
"source": "icons"
|
||||
"source": "icons",
|
||||
"load": {
|
||||
"export": "DotsThree"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Breadcrumb",
|
||||
@@ -1275,7 +1457,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "GitHubBuildStatus"
|
||||
"wrapperFor": "GitHubBuildStatus",
|
||||
"load": {
|
||||
"export": "GitHubBuildStatusWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "InfoBox",
|
||||
@@ -1377,7 +1562,11 @@
|
||||
"canHaveChildren": true,
|
||||
"description": "Chart component",
|
||||
"status": "supported",
|
||||
"source": "ui"
|
||||
"source": "ui",
|
||||
"load": {
|
||||
"path": "@/components/ui/chart/chart-container.tsx",
|
||||
"export": "ChartContainer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "DataList",
|
||||
@@ -1437,7 +1626,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "LazyBarChart"
|
||||
"wrapperFor": "LazyBarChart",
|
||||
"load": {
|
||||
"export": "LazyBarChartWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "LazyD3BarChart",
|
||||
@@ -1460,7 +1652,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "LazyD3BarChart"
|
||||
"wrapperFor": "LazyD3BarChart",
|
||||
"load": {
|
||||
"export": "LazyD3BarChartWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "LazyLineChart",
|
||||
@@ -1483,7 +1678,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "LazyLineChart"
|
||||
"wrapperFor": "LazyLineChart",
|
||||
"load": {
|
||||
"export": "LazyLineChartWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "List",
|
||||
@@ -1542,7 +1740,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "SeedDataManager"
|
||||
"wrapperFor": "SeedDataManager",
|
||||
"load": {
|
||||
"export": "SeedDataManagerWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "StatCard",
|
||||
@@ -1829,7 +2030,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "ComponentTree"
|
||||
"wrapperFor": "ComponentTree",
|
||||
"load": {
|
||||
"export": "ComponentTreeWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "ComponentTreeNode",
|
||||
@@ -1909,7 +2113,11 @@
|
||||
"description": "JSONUIShowcase organism component",
|
||||
"status": "supported",
|
||||
"source": "organisms",
|
||||
"jsonCompatible": true
|
||||
"jsonCompatible": true,
|
||||
"load": {
|
||||
"path": "@/components/JSONUIShowcase.tsx",
|
||||
"export": "JSONUIShowcase"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Kbd",
|
||||
@@ -1966,7 +2174,11 @@
|
||||
"canHaveChildren": true,
|
||||
"description": "PageHeader component",
|
||||
"status": "supported",
|
||||
"source": "atoms"
|
||||
"source": "atoms",
|
||||
"load": {
|
||||
"path": "@/components/atoms/PageHeader.tsx",
|
||||
"export": "BasicPageHeader"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "PageHeaderContent",
|
||||
@@ -2049,7 +2261,11 @@
|
||||
"canHaveChildren": true,
|
||||
"description": "Resizable component",
|
||||
"status": "supported",
|
||||
"source": "ui"
|
||||
"source": "ui",
|
||||
"load": {
|
||||
"path": "@/components/ui/resizable.tsx",
|
||||
"export": "ResizablePanelGroup"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "SaveIndicator",
|
||||
@@ -2072,7 +2288,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "SaveIndicator"
|
||||
"wrapperFor": "SaveIndicator",
|
||||
"load": {
|
||||
"export": "SaveIndicatorWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "SchemaEditorCanvas",
|
||||
@@ -2150,7 +2369,11 @@
|
||||
"canHaveChildren": false,
|
||||
"description": "Search input with icon",
|
||||
"status": "supported",
|
||||
"source": "atoms"
|
||||
"source": "atoms",
|
||||
"load": {
|
||||
"path": "@/components/atoms/SearchInput.tsx",
|
||||
"export": "BasicSearchInput"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Sheet",
|
||||
@@ -2236,7 +2459,10 @@
|
||||
"status": "json-compatible",
|
||||
"source": "wrappers",
|
||||
"jsonCompatible": true,
|
||||
"wrapperFor": "StorageSettings"
|
||||
"wrapperFor": "StorageSettings",
|
||||
"load": {
|
||||
"export": "StorageSettingsWrapper"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Timestamp",
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"kill": "fuser -k 5000/tcp",
|
||||
"prebuild": "mkdir -p /tmp/dist || true",
|
||||
"predev": "npm run components:generate-types",
|
||||
"prebuild": "npm run components:generate-types && mkdir -p /tmp/dist || true",
|
||||
"build": "tsc -b --noCheck && vite build",
|
||||
"lint": "eslint . --fix && npm run lint:schemas",
|
||||
"lint:check": "eslint . && npm run lint:schemas",
|
||||
@@ -24,8 +25,9 @@
|
||||
"pages:generate": "node scripts/generate-page.js",
|
||||
"schemas:validate": "tsx scripts/validate-json-schemas.ts",
|
||||
"components:list": "node scripts/list-json-components.cjs",
|
||||
"components:generate-types": "tsx scripts/generate-json-ui-component-types.ts",
|
||||
"components:scan": "node scripts/scan-and-update-registry.cjs",
|
||||
"components:validate": "node scripts/validate-supported-components.cjs"
|
||||
"components:validate": "node scripts/validate-supported-components.cjs && tsx scripts/validate-json-registry.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
|
||||
@@ -39,9 +39,13 @@
|
||||
},
|
||||
{
|
||||
"id": "trends",
|
||||
"type": "computed",
|
||||
"compute": "(data) => ({ filesGrowth: 12, modelsGrowth: -3, componentsGrowth: 8, testsGrowth: 15 })",
|
||||
"dependencies": ["metrics"]
|
||||
"type": "static",
|
||||
"defaultValue": {
|
||||
"filesGrowth": 12,
|
||||
"modelsGrowth": -3,
|
||||
"componentsGrowth": 8,
|
||||
"testsGrowth": 15
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -25,9 +25,12 @@
|
||||
},
|
||||
{
|
||||
"id": "filteredFiles",
|
||||
"type": "computed",
|
||||
"compute": "(data) => {\n if (!data.searchQuery) return data.files;\n return data.files.filter(f => f.name.toLowerCase().includes(data.searchQuery.toLowerCase()));\n}",
|
||||
"dependencies": ["files", "searchQuery"]
|
||||
"type": "static",
|
||||
"expression": "data.files",
|
||||
"dependencies": [
|
||||
"files",
|
||||
"searchQuery"
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -22,6 +22,15 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"sourceRoots": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -73,6 +82,19 @@
|
||||
"wrapperFor": {
|
||||
"type": "string"
|
||||
},
|
||||
"load": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"export": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["export"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"deprecated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
50
scripts/generate-json-ui-component-types.ts
Normal file
50
scripts/generate-json-ui-component-types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
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}`)
|
||||
235
scripts/validate-json-registry.ts
Normal file
235
scripts/validate-json-registry.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
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()
|
||||
@@ -4,7 +4,7 @@ const path = require('path')
|
||||
const rootDir = path.resolve(__dirname, '..')
|
||||
const registryPath = path.join(rootDir, 'json-components-registry.json')
|
||||
const definitionsPath = path.join(rootDir, 'src/lib/component-definitions.json')
|
||||
const componentTypesPath = path.join(rootDir, 'src/types/json-ui.ts')
|
||||
const componentTypesPath = path.join(rootDir, 'src/types/json-ui-component-types.ts')
|
||||
const uiRegistryPath = path.join(rootDir, 'src/lib/json-ui/component-registry.ts')
|
||||
const atomIndexPath = path.join(rootDir, 'src/components/atoms/index.ts')
|
||||
const moleculeIndexPath = path.join(rootDir, 'src/components/molecules/index.ts')
|
||||
@@ -21,16 +21,10 @@ const componentDefinitions = readJson(definitionsPath)
|
||||
const definitionTypes = new Set(componentDefinitions.map((def) => def.type))
|
||||
|
||||
const componentTypesContent = readText(componentTypesPath)
|
||||
const componentTypesStart = componentTypesContent.indexOf('export type ComponentType')
|
||||
const componentTypesEnd = componentTypesContent.indexOf('export type ActionType')
|
||||
if (componentTypesStart === -1 || componentTypesEnd === -1) {
|
||||
throw new Error('Unable to locate ComponentType union in src/types/json-ui.ts')
|
||||
}
|
||||
const componentTypesBlock = componentTypesContent.slice(componentTypesStart, componentTypesEnd)
|
||||
const componentTypeSet = new Set()
|
||||
const componentTypeRegex = /'([^']+)'/g
|
||||
const componentTypeRegex = /"([^"]+)"/g
|
||||
let match
|
||||
while ((match = componentTypeRegex.exec(componentTypesBlock)) !== null) {
|
||||
while ((match = componentTypeRegex.exec(componentTypesContent)) !== null) {
|
||||
componentTypeSet.add(match[1])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,153 +1,64 @@
|
||||
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { PageRenderer } from '@/lib/json-ui/page-renderer'
|
||||
import { FeatureToggles } from '@/types/project'
|
||||
import {
|
||||
BookOpen,
|
||||
Code,
|
||||
Cube,
|
||||
Database,
|
||||
FileText,
|
||||
Flask,
|
||||
FlowArrow,
|
||||
Image,
|
||||
Lightbulb,
|
||||
PaintBrush,
|
||||
Play,
|
||||
Tree,
|
||||
Wrench,
|
||||
} from '@phosphor-icons/react'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import featureToggleSettings from '@/config/feature-toggle-settings.json'
|
||||
import type { ComponentType } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import featureToggleSchema from '@/schemas/feature-toggle-settings.json'
|
||||
import type { PageSchema } from '@/types/json-ui'
|
||||
import { evaluateExpression } from '@/lib/json-ui/expression-evaluator'
|
||||
|
||||
interface FeatureToggleSettingsProps {
|
||||
features: FeatureToggles
|
||||
onFeaturesChange: (features: FeatureToggles) => void
|
||||
}
|
||||
|
||||
type FeatureToggleIconKey =
|
||||
| 'BookOpen'
|
||||
| 'Code'
|
||||
| 'Cube'
|
||||
| 'Database'
|
||||
| 'FileText'
|
||||
| 'Flask'
|
||||
| 'FlowArrow'
|
||||
| 'Image'
|
||||
| 'Lightbulb'
|
||||
| 'PaintBrush'
|
||||
| 'Play'
|
||||
| 'Tree'
|
||||
| 'Wrench'
|
||||
|
||||
const iconMap: Record<FeatureToggleIconKey, ComponentType<{ size?: number; weight?: 'duotone' }>> = {
|
||||
BookOpen,
|
||||
Code,
|
||||
Cube,
|
||||
Database,
|
||||
FileText,
|
||||
Flask,
|
||||
FlowArrow,
|
||||
Image,
|
||||
Lightbulb,
|
||||
PaintBrush,
|
||||
Play,
|
||||
Tree,
|
||||
Wrench,
|
||||
}
|
||||
|
||||
type FeatureToggleItem = {
|
||||
key: keyof FeatureToggles
|
||||
label: string
|
||||
description: string
|
||||
icon: FeatureToggleIconKey
|
||||
}
|
||||
|
||||
const featuresList = featureToggleSettings as FeatureToggleItem[]
|
||||
|
||||
function FeatureToggleHeader({ enabledCount, totalCount }: { enabledCount: number; totalCount: number }) {
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl font-bold mb-2">Feature Toggles</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Enable or disable features to customize your workspace. {enabledCount} of {totalCount} features enabled.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureToggleCard({
|
||||
item,
|
||||
enabled,
|
||||
onToggle,
|
||||
}: {
|
||||
item: FeatureToggleItem
|
||||
enabled: boolean
|
||||
onToggle: (value: boolean) => void
|
||||
}) {
|
||||
const Icon = iconMap[item.icon]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${enabled ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground'}`}>
|
||||
<Icon size={20} weight="duotone" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{item.label}</CardTitle>
|
||||
<CardDescription className="text-xs mt-1">{item.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<Switch id={item.key} checked={enabled} onCheckedChange={onToggle} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureToggleGrid({
|
||||
items,
|
||||
features,
|
||||
onToggle,
|
||||
}: {
|
||||
items: FeatureToggleItem[]
|
||||
features: FeatureToggles
|
||||
onToggle: (key: keyof FeatureToggles, value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pr-4">
|
||||
{items.map((item) => (
|
||||
<FeatureToggleCard
|
||||
key={item.key}
|
||||
item={item}
|
||||
enabled={features[item.key]}
|
||||
onToggle={(checked) => onToggle(item.key, checked)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* FeatureToggleSettings - Now JSON-driven!
|
||||
*
|
||||
* This component demonstrates how a complex React component with:
|
||||
* - Custom hooks and state management
|
||||
* - Dynamic data rendering (looping over features)
|
||||
* - Event handlers (toggle switches)
|
||||
* - Conditional styling (enabled/disabled states)
|
||||
*
|
||||
* Can be converted to a pure JSON schema with custom action handlers.
|
||||
* The JSON schema handles all UI structure, data binding, and loops,
|
||||
* while custom functions handle business logic.
|
||||
*
|
||||
* Converted from 153 lines of React/TSX to:
|
||||
* - 1 JSON schema file (195 lines, but mostly structure)
|
||||
* - 45 lines of integration code (this file)
|
||||
*
|
||||
* Benefits:
|
||||
* - UI structure is now data-driven and can be modified without code changes
|
||||
* - Feature list is in JSON and can be easily extended
|
||||
* - Styling and layout can be customized via JSON
|
||||
* - Business logic (toggle handler) stays in TypeScript for type safety
|
||||
*/
|
||||
export function FeatureToggleSettings({ features, onFeaturesChange }: FeatureToggleSettingsProps) {
|
||||
const handleToggle = (key: keyof FeatureToggles, value: boolean) => {
|
||||
onFeaturesChange({
|
||||
...features,
|
||||
[key]: value,
|
||||
})
|
||||
}
|
||||
// Custom action handler - this is the "hook" that handles complex logic
|
||||
const handlers = useMemo(() => ({
|
||||
updateFeature: (action: any, eventData: any) => {
|
||||
// Evaluate the params to get the actual values
|
||||
const context = { data: { features, item: eventData.item }, event: eventData }
|
||||
|
||||
// The key param is an expression like "item.key" which needs evaluation
|
||||
const key = evaluateExpression(action.params.key, context) as keyof FeatureToggles
|
||||
const checked = eventData as boolean
|
||||
|
||||
onFeaturesChange({
|
||||
...features,
|
||||
[key]: checked,
|
||||
})
|
||||
}
|
||||
}), [features, onFeaturesChange])
|
||||
|
||||
const enabledCount = Object.values(features).filter(Boolean).length
|
||||
const totalCount = Object.keys(features).length
|
||||
// Pass features as external data to the JSON renderer
|
||||
const data = useMemo(() => ({ features }), [features])
|
||||
|
||||
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>
|
||||
<PageRenderer
|
||||
schema={featureToggleSchema as PageSchema}
|
||||
data={data}
|
||||
functions={handlers}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import showcaseCopy from '@/config/ui-examples/showcase.json'
|
||||
import dashboardExample from '@/config/ui-examples/dashboard.json'
|
||||
import formExample from '@/config/ui-examples/form.json'
|
||||
import tableExample from '@/config/ui-examples/table.json'
|
||||
import listTableTimelineExample from '@/config/ui-examples/list-table-timeline.json'
|
||||
import settingsExample from '@/config/ui-examples/settings.json'
|
||||
import { FileCode, ChartBar, ListBullets, Table, Gear, Clock } from '@phosphor-icons/react'
|
||||
import { ShowcaseHeader } from '@/components/json-ui-showcase/ShowcaseHeader'
|
||||
import { ShowcaseTabs } from '@/components/json-ui-showcase/ShowcaseTabs'
|
||||
import { ShowcaseFooter } from '@/components/json-ui-showcase/ShowcaseFooter'
|
||||
import { ShowcaseExample } from '@/components/json-ui-showcase/types'
|
||||
|
||||
const exampleConfigs = {
|
||||
dashboard: dashboardExample,
|
||||
form: formExample,
|
||||
table: tableExample,
|
||||
'list-table-timeline': listTableTimelineExample,
|
||||
settings: settingsExample,
|
||||
}
|
||||
|
||||
const exampleIcons = {
|
||||
ChartBar,
|
||||
ListBullets,
|
||||
@@ -27,14 +14,22 @@ const exampleIcons = {
|
||||
Gear,
|
||||
}
|
||||
|
||||
const configModules = import.meta.glob('/src/config/ui-examples/*.json', { eager: true })
|
||||
|
||||
const resolveExampleConfig = (configPath: string) => {
|
||||
const moduleEntry = configModules[configPath] as { default: ShowcaseExample['config'] } | undefined
|
||||
|
||||
return moduleEntry?.default ?? {}
|
||||
}
|
||||
|
||||
export function JSONUIShowcase() {
|
||||
const [selectedExample, setSelectedExample] = useState(showcaseCopy.defaultExampleKey)
|
||||
const [showJSON, setShowJSON] = useState(false)
|
||||
|
||||
const examples = useMemo<ShowcaseExample[]>(() => {
|
||||
return showcaseCopy.examples.map((example) => {
|
||||
const icon = exampleIcons[example.icon as keyof typeof exampleIcons] || FileCode
|
||||
const config = exampleConfigs[example.configKey as keyof typeof exampleConfigs]
|
||||
const icon = exampleIcons[example.iconId as keyof typeof exampleIcons] || FileCode
|
||||
const config = resolveExampleConfig(example.configPath)
|
||||
|
||||
return {
|
||||
key: example.key,
|
||||
|
||||
@@ -45,11 +45,12 @@ function getCompletionMessage(score: number): string {
|
||||
}
|
||||
|
||||
export function ProjectDashboard(props: ProjectDashboardProps) {
|
||||
const completionMetrics = calculateCompletionScore(props)
|
||||
|
||||
return (
|
||||
<JSONPageRenderer
|
||||
schema={dashboardSchema as any}
|
||||
data={props}
|
||||
functions={{ calculateCompletionScore }}
|
||||
data={{ ...props, ...completionMetrics }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DataSourceType } from '@/types/json-ui'
|
||||
import { Database, Function, File } from '@phosphor-icons/react'
|
||||
import { Database, File } from '@phosphor-icons/react'
|
||||
|
||||
interface DataSourceBadgeProps {
|
||||
type: DataSourceType
|
||||
@@ -13,11 +13,6 @@ const dataSourceConfig = {
|
||||
label: 'KV Storage',
|
||||
className: 'bg-accent/20 text-accent border-accent/30'
|
||||
},
|
||||
computed: {
|
||||
icon: Function,
|
||||
label: 'Computed',
|
||||
className: 'bg-primary/20 text-primary border-primary/30'
|
||||
},
|
||||
static: {
|
||||
icon: File,
|
||||
label: 'Static',
|
||||
|
||||
@@ -108,7 +108,7 @@ function PageCard({ card, data, functions }: PageCardProps) {
|
||||
|
||||
if (card.type === 'gradient-card') {
|
||||
const computeFn = functions[card.dataSource?.compute]
|
||||
const computedData = computeFn ? computeFn(data) : {}
|
||||
const computedData = computeFn ? computeFn(data) : data
|
||||
|
||||
return (
|
||||
<Card className={cn('bg-gradient-to-br border-primary/20', card.gradient)}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Card, Badge, IconButton, Stack, Flex, Text } from '@/components/atoms'
|
||||
import { Card, IconButton, Stack, Flex, Text } from '@/components/atoms'
|
||||
import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
import { Pencil, Trash, ArrowsDownUp } from '@phosphor-icons/react'
|
||||
import { Pencil, Trash } from '@phosphor-icons/react'
|
||||
|
||||
interface DataSourceCardProps {
|
||||
dataSource: DataSource
|
||||
@@ -11,13 +11,6 @@ interface DataSourceCardProps {
|
||||
}
|
||||
|
||||
export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }: DataSourceCardProps) {
|
||||
const getDependencyCount = () => {
|
||||
if (dataSource.type === 'computed') {
|
||||
return dataSource.dependencies?.length || 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const renderTypeSpecificInfo = () => {
|
||||
if (dataSource.type === 'kv') {
|
||||
return (
|
||||
@@ -27,18 +20,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -59,7 +40,7 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
|
||||
{dependents.length > 0 && (
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<Text variant="caption">
|
||||
Used by {dependents.length} computed {dependents.length === 1 ? 'source' : 'sources'}
|
||||
Used by {dependents.length} dependent {dependents.length === 1 ? 'source' : 'sources'}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5,14 +5,12 @@ import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
|
||||
import { DataSourceIdField } from '@/components/molecules/data-source-editor/DataSourceIdField'
|
||||
import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields'
|
||||
import { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields'
|
||||
import { ComputedSourceFields } from '@/components/molecules/data-source-editor/ComputedSourceFields'
|
||||
import dataSourceEditorCopy from '@/data/data-source-editor-dialog.json'
|
||||
import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor'
|
||||
|
||||
interface DataSourceEditorDialogProps {
|
||||
open: boolean
|
||||
dataSource: DataSource | null
|
||||
allDataSources: DataSource[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (dataSource: DataSource) => void
|
||||
}
|
||||
@@ -20,19 +18,13 @@ interface DataSourceEditorDialogProps {
|
||||
export function DataSourceEditorDialog({
|
||||
open,
|
||||
dataSource,
|
||||
allDataSources,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: DataSourceEditorDialogProps) {
|
||||
const {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
} = useDataSourceEditor(dataSource, allDataSources)
|
||||
} = useDataSourceEditor(dataSource)
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editingSource) return
|
||||
@@ -80,18 +72,6 @@ export function DataSourceEditorDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSource.type === 'computed' && (
|
||||
<ComputedSourceFields
|
||||
editingSource={editingSource}
|
||||
availableDeps={availableDeps}
|
||||
selectedDeps={selectedDeps}
|
||||
unselectedDeps={unselectedDeps}
|
||||
copy={dataSourceEditorCopy.computed}
|
||||
onUpdateField={updateField}
|
||||
onAddDependency={addDependency}
|
||||
onRemoveDependency={removeDependency}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { DataSourceEditorDialog } from '@/components/molecules/DataSourceEditorDialog'
|
||||
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
|
||||
import { DataSource, DataSourceType } from '@/types/json-ui'
|
||||
import { Database, Function, FileText } from '@phosphor-icons/react'
|
||||
import { Database, FileText } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { EmptyState, Stack } from '@/components/atoms'
|
||||
import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader'
|
||||
@@ -66,7 +66,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
|
||||
const groupedSources = {
|
||||
kv: localSources.filter(ds => ds.type === 'kv'),
|
||||
computed: localSources.filter(ds => ds.type === 'computed'),
|
||||
static: localSources.filter(ds => ds.type === 'static'),
|
||||
}
|
||||
|
||||
@@ -110,15 +109,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
onEdit={handleEditSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
|
||||
<DataSourceGroupSection
|
||||
icon={<Function size={16} />}
|
||||
label={dataSourceManagerCopy.groups.computed}
|
||||
dataSources={groupedSources.computed}
|
||||
getDependents={getDependents}
|
||||
onEdit={handleEditSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -127,7 +117,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
<DataSourceEditorDialog
|
||||
open={dialogOpen}
|
||||
dataSource={editingSource}
|
||||
allDataSources={localSources}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSaveSource}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ActionButton, Heading, Stack, Text } from '@/components/atoms'
|
||||
import { Plus, Database, Function, FileText } from '@phosphor-icons/react'
|
||||
import { Plus, Database, FileText } from '@phosphor-icons/react'
|
||||
import { DataSourceType } from '@/types/json-ui'
|
||||
|
||||
interface DataSourceManagerHeaderCopy {
|
||||
@@ -14,7 +14,6 @@ interface DataSourceManagerHeaderCopy {
|
||||
addLabel: string
|
||||
menu: {
|
||||
kv: string
|
||||
computed: string
|
||||
static: string
|
||||
}
|
||||
}
|
||||
@@ -49,10 +48,6 @@ export function DataSourceManagerHeader({ copy, onAdd }: DataSourceManagerHeader
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
{copy.menu.kv}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAdd('computed')}>
|
||||
<Function className="w-4 h-4 mr-2" />
|
||||
{copy.menu.computed}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAdd('static')}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{copy.menu.static}
|
||||
|
||||
@@ -37,13 +37,6 @@ export function useDataSource(source: DataSource) {
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
case 'computed':
|
||||
return {
|
||||
data: source.defaultValue,
|
||||
setData: () => {},
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
data: null,
|
||||
@@ -67,7 +60,7 @@ export function useDataSources(sources: DataSource[]) {
|
||||
|
||||
useEffect(() => {
|
||||
sources.forEach((source) => {
|
||||
if (source.type === 'static' || source.type === 'computed') {
|
||||
if (source.type === 'static') {
|
||||
updateData(source.id, source.defaultValue)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ActionSchema = z.object({
|
||||
|
||||
export const DataSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['kv', 'api', 'computed', 'static'], { message: 'Invalid data source type' }),
|
||||
type: z.enum(['kv', 'api', 'static'], { message: 'Invalid data source type' }),
|
||||
key: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
transform: z.string().optional(),
|
||||
|
||||
@@ -33,15 +33,20 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedTree",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.trees?.find(t => t.id === data.selectedTreeId) || null",
|
||||
"dependencies": ["trees", "selectedTreeId"]
|
||||
"type": "static",
|
||||
"expression": "data.trees.find(id === data.selectedTreeId)",
|
||||
"dependencies": [
|
||||
"trees",
|
||||
"selectedTreeId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "treeCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.trees || []).length",
|
||||
"dependencies": ["trees"]
|
||||
"type": "static",
|
||||
"expression": "data.trees.length",
|
||||
"dependencies": [
|
||||
"trees"
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -136,55 +141,145 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state",
|
||||
"id": "tree-selection-state",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex-1 flex items-center justify-center"
|
||||
},
|
||||
"condition": {
|
||||
"source": "selectedTree",
|
||||
"transform": "(val) => !val"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state-content",
|
||||
"conditional": {
|
||||
"if": "selectedTree != null",
|
||||
"then": {
|
||||
"id": "tree-editor",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "text-center space-y-4"
|
||||
"className": "flex-1 p-6 overflow-auto"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state-title",
|
||||
"type": "Heading",
|
||||
"id": "tree-header",
|
||||
"type": "div",
|
||||
"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"
|
||||
"className": "mb-6"
|
||||
},
|
||||
"events": [
|
||||
"children": [
|
||||
{
|
||||
"event": "click",
|
||||
"actions": [
|
||||
"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": "open-create-from-empty",
|
||||
"type": "set-value",
|
||||
"target": "createDialogOpen",
|
||||
"value": true
|
||||
"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",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "text-center space-y-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state-title",
|
||||
"type": "Heading",
|
||||
"props": {
|
||||
"className": "text-2xl font-bold text-muted-foreground",
|
||||
"children": "No Tree Selected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "empty-state-description",
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"className": "text-muted-foreground",
|
||||
"children": "Select a component tree from the sidebar or create a new one"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "empty-state-button",
|
||||
"type": "Button",
|
||||
"props": {
|
||||
"variant": "default",
|
||||
"children": "Create Your First Tree"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "click",
|
||||
"actions": [
|
||||
{
|
||||
"id": "open-create-from-empty",
|
||||
"type": "set-value",
|
||||
"target": "createDialogOpen",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -192,98 +287,7 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -291,4 +295,4 @@
|
||||
}
|
||||
],
|
||||
"globalActions": []
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,6 @@
|
||||
"title": "Project Completeness",
|
||||
"icon": "CheckCircle",
|
||||
"gradient": "from-primary/10 to-accent/10",
|
||||
"dataSource": {
|
||||
"type": "computed",
|
||||
"compute": "calculateCompletionScore"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"type": "metric",
|
||||
|
||||
@@ -133,9 +133,11 @@
|
||||
"data": [
|
||||
{
|
||||
"id": "activeFile",
|
||||
"type": "computed",
|
||||
"dependencies": ["files", "activeFileId"],
|
||||
"compute": "context.files.find(f => f.id === context.activeFileId)"
|
||||
"type": "static",
|
||||
"expression": "data.files.0",
|
||||
"dependencies": [
|
||||
"files"
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
|
||||
@@ -35,27 +35,28 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedBlueprint",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.find(b => b.id === data.selectedBlueprintId) || null; }",
|
||||
"dependencies": ["flaskConfig", "selectedBlueprintId"]
|
||||
"type": "static",
|
||||
"expression": "data.flaskConfig.blueprints.find(id === data.selectedBlueprintId)",
|
||||
"dependencies": [
|
||||
"flaskConfig",
|
||||
"selectedBlueprintId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "blueprintCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => ((data.flaskConfig || {}).blueprints || []).length",
|
||||
"dependencies": ["flaskConfig"]
|
||||
"type": "static",
|
||||
"expression": "data.flaskConfig.blueprints.length",
|
||||
"dependencies": [
|
||||
"flaskConfig"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "endpointCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const bp = data.selectedBlueprint; return bp ? (bp.endpoints || []).length : 0; }",
|
||||
"dependencies": ["selectedBlueprint"]
|
||||
},
|
||||
{
|
||||
"id": "totalEndpoints",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.reduce((sum, bp) => sum + (bp.endpoints || []).length, 0); }",
|
||||
"dependencies": ["flaskConfig"]
|
||||
"type": "static",
|
||||
"expression": "data.selectedBlueprint.endpoints.length",
|
||||
"dependencies": [
|
||||
"selectedBlueprint"
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -27,15 +27,20 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedLambda",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.lambdas?.find(l => l.id === data.selectedLambdaId) || null",
|
||||
"dependencies": ["lambdas", "selectedLambdaId"]
|
||||
"type": "static",
|
||||
"expression": "data.lambdas.find(id === data.selectedLambdaId)",
|
||||
"dependencies": [
|
||||
"lambdas",
|
||||
"selectedLambdaId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "lambdaCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.lambdas || []).length",
|
||||
"dependencies": ["lambdas"]
|
||||
"type": "static",
|
||||
"expression": "data.lambdas.length",
|
||||
"dependencies": [
|
||||
"lambdas"
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -71,7 +76,9 @@
|
||||
"props": {
|
||||
"className": "text-2xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent"
|
||||
},
|
||||
"children": ["Lambdas"]
|
||||
"children": [
|
||||
"Lambdas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Badge",
|
||||
@@ -133,7 +140,9 @@
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"children": ["Lambda list will be rendered here"]
|
||||
"children": [
|
||||
"Lambda list will be rendered here"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -159,14 +168,18 @@
|
||||
"props": {
|
||||
"className": "text-lg font-semibold mb-2"
|
||||
},
|
||||
"children": ["No Lambdas Yet"]
|
||||
"children": [
|
||||
"No Lambdas Yet"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"props": {
|
||||
"className": "text-sm text-muted-foreground mb-4"
|
||||
},
|
||||
"children": ["Create your first serverless function"]
|
||||
"children": [
|
||||
"Create your first serverless function"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -182,101 +195,106 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "lambda-selection-state",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex-1 flex items-center justify-center p-8"
|
||||
},
|
||||
"conditional": {
|
||||
"if": "selectedLambda"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"if": "selectedLambda != null",
|
||||
"then": {
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "max-w-6xl mx-auto w-full space-y-6"
|
||||
"className": "flex-1 flex items-center justify-center p-8"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex items-center justify-between"
|
||||
"className": "max-w-6xl mx-auto w-full space-y-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex items-center justify-between"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "h1",
|
||||
"props": {
|
||||
"className": "text-3xl font-bold"
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "selectedLambda",
|
||||
"path": "name"
|
||||
"type": "div",
|
||||
"children": [
|
||||
{
|
||||
"type": "h1",
|
||||
"props": {
|
||||
"className": "text-3xl font-bold"
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "selectedLambda",
|
||||
"path": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"props": {
|
||||
"className": "text-muted-foreground"
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "selectedLambda",
|
||||
"path": "description"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"props": {
|
||||
"className": "text-muted-foreground"
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "selectedLambda",
|
||||
"path": "description"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex-1 flex items-center justify-center p-8"
|
||||
},
|
||||
"conditional": {
|
||||
"if": "!selectedLambda"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
},
|
||||
"else": {
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "text-center"
|
||||
"className": "flex-1 flex items-center justify-center p-8"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "icon",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"name": "Code",
|
||||
"className": "h-20 w-20 text-muted-foreground/50 mx-auto mb-4",
|
||||
"weight": "duotone"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "h3",
|
||||
"props": {
|
||||
"className": "text-xl font-semibold mb-2"
|
||||
"className": "text-center"
|
||||
},
|
||||
"children": ["No Lambda Selected"]
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"props": {
|
||||
"className": "text-muted-foreground"
|
||||
},
|
||||
"children": ["Select a lambda from the sidebar or create a new one"]
|
||||
"children": [
|
||||
{
|
||||
"type": "icon",
|
||||
"props": {
|
||||
"name": "Code",
|
||||
"className": "h-20 w-20 text-muted-foreground/50 mx-auto mb-4",
|
||||
"weight": "duotone"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "h3",
|
||||
"props": {
|
||||
"className": "text-xl font-semibold mb-2"
|
||||
},
|
||||
"children": [
|
||||
"No Lambda Selected"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"props": {
|
||||
"className": "text-muted-foreground"
|
||||
},
|
||||
"children": [
|
||||
"Select a lambda from the sidebar or create a new one"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -28,15 +28,20 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedModel",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.models?.find(m => m.id === data.selectedModelId) || null",
|
||||
"dependencies": ["models", "selectedModelId"]
|
||||
"type": "static",
|
||||
"expression": "data.models.find(id === data.selectedModelId)",
|
||||
"dependencies": [
|
||||
"models",
|
||||
"selectedModelId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "modelCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.models || []).length",
|
||||
"dependencies": ["models"]
|
||||
"type": "static",
|
||||
"expression": "data.models.length",
|
||||
"dependencies": [
|
||||
"models"
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -131,55 +136,142 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state",
|
||||
"id": "model-selection-state",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex-1 flex items-center justify-center"
|
||||
},
|
||||
"condition": {
|
||||
"source": "selectedModel",
|
||||
"transform": "(val) => !val"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state-content",
|
||||
"conditional": {
|
||||
"if": "selectedModel != null",
|
||||
"then": {
|
||||
"id": "model-editor",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "text-center space-y-4"
|
||||
"className": "flex-1 p-6 overflow-auto"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state-title",
|
||||
"type": "Heading",
|
||||
"id": "model-header",
|
||||
"type": "div",
|
||||
"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"
|
||||
"className": "mb-6"
|
||||
},
|
||||
"events": [
|
||||
"children": [
|
||||
{
|
||||
"event": "click",
|
||||
"actions": [
|
||||
"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": "open-create-from-empty",
|
||||
"type": "set-value",
|
||||
"target": "createDialogOpen",
|
||||
"value": true
|
||||
"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": {
|
||||
"className": "text-center space-y-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state-title",
|
||||
"type": "Heading",
|
||||
"props": {
|
||||
"className": "text-2xl font-bold text-muted-foreground",
|
||||
"children": "No Model Selected"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "empty-state-description",
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"className": "text-muted-foreground",
|
||||
"children": "Select a model from the sidebar or create a new one"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "empty-state-button",
|
||||
"type": "Button",
|
||||
"props": {
|
||||
"variant": "default",
|
||||
"children": "Create Your First Model"
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"event": "click",
|
||||
"actions": [
|
||||
{
|
||||
"id": "open-create-from-empty",
|
||||
"type": "set-value",
|
||||
"target": "createDialogOpen",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -187,95 +279,7 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,63 +5,15 @@
|
||||
"id": "lastSaved",
|
||||
"type": "static",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "currentTime",
|
||||
"type": "static",
|
||||
"defaultValue": 0,
|
||||
"polling": {
|
||||
"interval": 10000,
|
||||
"update": "() => Date.now()"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "isRecent",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { if (!data.lastSaved) return false; return Date.now() - data.lastSaved < 3000; }",
|
||||
"dependencies": ["lastSaved", "currentTime"]
|
||||
},
|
||||
{
|
||||
"id": "timeAgo",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { if (!data.lastSaved) return ''; const seconds = Math.floor((Date.now() - data.lastSaved) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; }",
|
||||
"dependencies": ["lastSaved", "currentTime"]
|
||||
}
|
||||
],
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex items-center gap-1.5 text-xs text-muted-foreground"
|
||||
},
|
||||
"type": "SaveIndicator",
|
||||
"conditional": {
|
||||
"if": "lastSaved !== null"
|
||||
"if": "lastSaved != null"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "status-icon",
|
||||
"type": "StatusIcon",
|
||||
"dataBinding": {
|
||||
"type": {
|
||||
"source": "isRecent",
|
||||
"transform": "isRecent => isRecent ? 'saved' : 'synced'"
|
||||
},
|
||||
"animate": {
|
||||
"source": "isRecent"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "time-text",
|
||||
"type": "span",
|
||||
"props": {
|
||||
"className": "hidden sm:inline"
|
||||
},
|
||||
"dataBinding": {
|
||||
"children": {
|
||||
"source": "isRecent",
|
||||
"path": null,
|
||||
"transform": "(isRecent, data) => isRecent ? 'Saved' : data.timeAgo"
|
||||
}
|
||||
}
|
||||
"bindings": {
|
||||
"lastSaved": {
|
||||
"source": "lastSaved"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,21 +54,27 @@
|
||||
},
|
||||
{
|
||||
"id": "activeVariant",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const theme = data.theme || {}; const variants = theme.variants || []; return variants.find(v => v.id === theme.activeVariantId) || variants[0] || null; }",
|
||||
"dependencies": ["theme"]
|
||||
"type": "static",
|
||||
"expression": "data.theme.variants.find(id === data.theme.activeVariantId)",
|
||||
"dependencies": [
|
||||
"theme"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "variantCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => ((data.theme || {}).variants || []).length",
|
||||
"dependencies": ["theme"]
|
||||
"type": "static",
|
||||
"expression": "data.theme.variants.length",
|
||||
"dependencies": [
|
||||
"theme"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "customColorCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const variant = data.activeVariant; if (!variant || !variant.colors) return 0; return Object.keys(variant.colors.customColors || {}).length; }",
|
||||
"dependencies": ["activeVariant"]
|
||||
"type": "static",
|
||||
"expression": "Object.keys(data.activeVariant.colors.customColors).length",
|
||||
"dependencies": [
|
||||
"activeVariant"
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -32,15 +32,20 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedWorkflow",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.workflows?.find(w => w.id === data.selectedWorkflowId) || null",
|
||||
"dependencies": ["workflows", "selectedWorkflowId"]
|
||||
"type": "static",
|
||||
"expression": "data.workflows.find(id === data.selectedWorkflowId)",
|
||||
"dependencies": [
|
||||
"workflows",
|
||||
"selectedWorkflowId"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "workflowCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.workflows || []).length",
|
||||
"dependencies": ["workflows"]
|
||||
"type": "static",
|
||||
"expression": "data.workflows.length",
|
||||
"dependencies": [
|
||||
"workflows"
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -71,7 +76,9 @@
|
||||
"props": {
|
||||
"className": "text-xl font-bold mb-2 flex items-center gap-2"
|
||||
},
|
||||
"children": ["Workflows"]
|
||||
"children": [
|
||||
"Workflows"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "create-button",
|
||||
@@ -117,7 +124,9 @@
|
||||
"props": {
|
||||
"className": "text-sm text-muted-foreground"
|
||||
},
|
||||
"children": ["Status Filter"]
|
||||
"children": [
|
||||
"Status Filter"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -136,7 +145,9 @@
|
||||
"props": {
|
||||
"className": "text-center py-8 text-muted-foreground"
|
||||
},
|
||||
"children": ["No workflows yet"]
|
||||
"children": [
|
||||
"No workflows yet"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -150,122 +161,129 @@
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state",
|
||||
"id": "workflow-selection-state",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex-1 flex items-center justify-center"
|
||||
},
|
||||
"conditional": {
|
||||
"if": "!selectedWorkflow"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state-content",
|
||||
"if": "selectedWorkflow != null",
|
||||
"then": {
|
||||
"id": "workflow-editor",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "text-center space-y-4"
|
||||
"className": "flex-1 p-6 overflow-auto"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "icon",
|
||||
"id": "workflow-header",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"name": "GitBranch",
|
||||
"className": "h-20 w-20 text-muted-foreground/50 mx-auto",
|
||||
"weight": "duotone"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "empty-state-title",
|
||||
"type": "h3",
|
||||
"props": {
|
||||
"className": "text-2xl font-bold text-muted-foreground"
|
||||
},
|
||||
"children": ["No Workflow Selected"]
|
||||
},
|
||||
{
|
||||
"id": "empty-state-description",
|
||||
"type": "p",
|
||||
"props": {
|
||||
"className": "text-muted-foreground"
|
||||
},
|
||||
"children": ["Select a workflow from the sidebar or create a new one"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "workflow-editor",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex-1 p-6 overflow-auto"
|
||||
},
|
||||
"conditional": {
|
||||
"if": "selectedWorkflow"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "workflow-header",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "mb-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "workflow-name",
|
||||
"type": "h1",
|
||||
"props": {
|
||||
"className": "text-3xl font-bold mb-2"
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "selectedWorkflow",
|
||||
"path": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "workflow-description",
|
||||
"type": "p",
|
||||
"props": {
|
||||
"className": "text-muted-foreground"
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "selectedWorkflow",
|
||||
"path": "description"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "workflow-canvas",
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"className": "min-h-[400px] bg-muted/20"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "canvas-content",
|
||||
"type": "CardContent",
|
||||
"props": {
|
||||
"className": "p-6"
|
||||
"className": "mb-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "canvas-placeholder",
|
||||
"type": "div",
|
||||
"id": "workflow-name",
|
||||
"type": "h1",
|
||||
"props": {
|
||||
"className": "text-center text-muted-foreground py-12"
|
||||
"className": "text-3xl font-bold mb-2"
|
||||
},
|
||||
"children": ["Workflow canvas - Add nodes to build your workflow"]
|
||||
"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",
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"className": "min-h-[400px] bg-muted/20"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "canvas-content",
|
||||
"type": "CardContent",
|
||||
"props": {
|
||||
"className": "p-6"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "canvas-placeholder",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "text-center text-muted-foreground py-12"
|
||||
},
|
||||
"children": [
|
||||
"Workflow canvas - Add nodes to build your workflow"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"else": {
|
||||
"id": "empty-state",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "flex-1 flex items-center justify-center"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"id": "empty-state-content",
|
||||
"type": "div",
|
||||
"props": {
|
||||
"className": "text-center space-y-4"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "icon",
|
||||
"props": {
|
||||
"name": "GitBranch",
|
||||
"className": "h-20 w-20 text-muted-foreground/50 mx-auto",
|
||||
"weight": "duotone"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "empty-state-title",
|
||||
"type": "h3",
|
||||
"props": {
|
||||
"className": "text-2xl font-bold text-muted-foreground"
|
||||
},
|
||||
"children": [
|
||||
"No Workflow Selected"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "empty-state-description",
|
||||
"type": "p",
|
||||
"props": {
|
||||
"className": "text-muted-foreground"
|
||||
},
|
||||
"children": [
|
||||
"Select a workflow from the sidebar or create a new one"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -15,36 +15,36 @@
|
||||
"key": "dashboard",
|
||||
"name": "Dashboard",
|
||||
"description": "Complete dashboard with stats, activity feed, and quick actions",
|
||||
"icon": "ChartBar",
|
||||
"configKey": "dashboard"
|
||||
"iconId": "ChartBar",
|
||||
"configPath": "/src/config/ui-examples/dashboard.json"
|
||||
},
|
||||
{
|
||||
"key": "form",
|
||||
"name": "Form",
|
||||
"description": "Dynamic form with validation and data binding",
|
||||
"icon": "ListBullets",
|
||||
"configKey": "form"
|
||||
"iconId": "ListBullets",
|
||||
"configPath": "/src/config/ui-examples/form.json"
|
||||
},
|
||||
{
|
||||
"key": "table",
|
||||
"name": "Data Table",
|
||||
"description": "Interactive table with row actions and looping",
|
||||
"icon": "Table",
|
||||
"configKey": "table"
|
||||
"iconId": "Table",
|
||||
"configPath": "/src/config/ui-examples/table.json"
|
||||
},
|
||||
{
|
||||
"key": "bindings",
|
||||
"name": "Bindings",
|
||||
"description": "List, table, and timeline bindings with shared data sources",
|
||||
"icon": "Clock",
|
||||
"configKey": "list-table-timeline"
|
||||
"iconId": "Clock",
|
||||
"configPath": "/src/config/ui-examples/list-table-timeline.json"
|
||||
},
|
||||
{
|
||||
"key": "settings",
|
||||
"name": "Settings",
|
||||
"description": "Tabbed settings panel with switches and selections",
|
||||
"icon": "Gear",
|
||||
"configKey": "settings"
|
||||
"iconId": "Gear",
|
||||
"configPath": "/src/config/ui-examples/settings.json"
|
||||
}
|
||||
],
|
||||
"footer": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "Data Binding Designer",
|
||||
"description": "Connect UI components to KV storage and computed values"
|
||||
"description": "Connect UI components to KV storage and static data"
|
||||
},
|
||||
"bindingsCard": {
|
||||
"title": "Component Bindings",
|
||||
@@ -13,7 +13,6 @@
|
||||
"title": "How It Works",
|
||||
"steps": [
|
||||
"Create data sources (KV store for persistence, static for constants)",
|
||||
"Add computed sources to derive values from other sources",
|
||||
"Bind component properties to data sources for reactive updates"
|
||||
]
|
||||
},
|
||||
@@ -34,12 +33,6 @@
|
||||
"key": "app-counter",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "displayName",
|
||||
"type": "computed",
|
||||
"dependencies": ["userProfile"],
|
||||
"expression": "data.userProfile.name"
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
{
|
||||
@@ -50,7 +43,8 @@
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "displayName"
|
||||
"source": "userProfile",
|
||||
"path": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Edit Data Source",
|
||||
"description": "Configure the data source settings and dependencies",
|
||||
"description": "Configure the data source settings",
|
||||
"fields": {
|
||||
"id": {
|
||||
"label": "ID",
|
||||
@@ -18,17 +18,6 @@
|
||||
"valueLabel": "Value (JSON)",
|
||||
"valuePlaceholder": "{\"key\": \"value\"}"
|
||||
},
|
||||
"computed": {
|
||||
"expressionLabel": "Expression",
|
||||
"expressionPlaceholder": "data.source1",
|
||||
"expressionHelp": "Expression that computes the value from other data sources",
|
||||
"valueTemplateLabel": "Value Template (JSON)",
|
||||
"valueTemplatePlaceholder": "{\n \"total\": \"data.items.length\"\n}",
|
||||
"valueTemplateHelp": "Template object with expressions for computed fields",
|
||||
"dependenciesLabel": "Dependencies",
|
||||
"availableSourcesLabel": "Available Sources",
|
||||
"emptyDependencies": "No data sources available. Create KV or static sources first."
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Changes"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "Data Sources",
|
||||
"description": "Manage KV storage, computed values, and static data"
|
||||
"description": "Manage KV storage and static data"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add Data Source"
|
||||
},
|
||||
"menu": {
|
||||
"kv": "KV Store",
|
||||
"computed": "Computed Value",
|
||||
"static": "Static Data"
|
||||
},
|
||||
"emptyState": {
|
||||
@@ -17,12 +16,11 @@
|
||||
},
|
||||
"groups": {
|
||||
"kv": "KV Store",
|
||||
"static": "Static Data",
|
||||
"computed": "Computed Values"
|
||||
"static": "Static Data"
|
||||
},
|
||||
"toasts": {
|
||||
"deleteBlockedTitle": "Cannot delete",
|
||||
"deleteBlockedDescription": "This source is used by {count} computed {noun}",
|
||||
"deleteBlockedDescription": "This source is used by {count} dependent {noun}",
|
||||
"deleted": "Data source deleted",
|
||||
"updated": "Data source updated"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
|
||||
export function useDataSourceEditor(
|
||||
dataSource: DataSource | null,
|
||||
allDataSources: DataSource[],
|
||||
) {
|
||||
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
|
||||
|
||||
@@ -15,44 +14,8 @@ export function useDataSourceEditor(
|
||||
setEditingSource(prev => (prev ? { ...prev, [field]: value } : prev))
|
||||
}, [])
|
||||
|
||||
const addDependency = useCallback((depId: string) => {
|
||||
setEditingSource(prev => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
if (deps.includes(depId)) return prev
|
||||
return { ...prev, dependencies: [...deps, depId] }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeDependency = useCallback((depId: string) => {
|
||||
setEditingSource(prev => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
return { ...prev, dependencies: deps.filter(dep => dep !== depId) }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const availableDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return allDataSources.filter(
|
||||
ds => ds.id !== editingSource.id && ds.type !== 'computed',
|
||||
)
|
||||
}, [allDataSources, editingSource])
|
||||
|
||||
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
|
||||
|
||||
const unselectedDeps = useMemo(
|
||||
() => availableDeps.filter(ds => !selectedDeps.includes(ds.id)),
|
||||
[availableDeps, selectedDeps],
|
||||
)
|
||||
|
||||
return {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
|
||||
id: `ds-${Date.now()}`,
|
||||
type,
|
||||
...(type === 'kv' && { key: '', defaultValue: null }),
|
||||
...(type === 'computed' && { expression: '', dependencies: [] }),
|
||||
...(type === 'static' && { defaultValue: null }),
|
||||
}
|
||||
|
||||
@@ -33,7 +32,6 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
|
||||
|
||||
const getDependents = useCallback((sourceId: string) => {
|
||||
return dataSources.filter(ds =>
|
||||
ds.type === 'computed' &&
|
||||
ds.dependencies?.includes(sourceId)
|
||||
)
|
||||
}, [dataSources])
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
|
||||
export type DataSourceType = 'kv' | 'static' | 'computed'
|
||||
export type DataSourceType = 'kv' | 'static'
|
||||
|
||||
export interface DataSourceConfig<T = any> {
|
||||
type: DataSourceType
|
||||
key?: string
|
||||
defaultValue?: T
|
||||
compute?: (allData: Record<string, any>) => T
|
||||
dependencies?: string[]
|
||||
}
|
||||
|
||||
export function useKVDataSource<T = any>(key: string, defaultValue?: T) {
|
||||
@@ -18,13 +16,6 @@ export function useStaticDataSource<T = any>(defaultValue: T) {
|
||||
return [defaultValue, () => {}, () => {}] as const
|
||||
}
|
||||
|
||||
export function useComputedDataSource<T = any>(
|
||||
compute: (allData: Record<string, any>) => T,
|
||||
dependencies: Record<string, any>
|
||||
) {
|
||||
return compute(dependencies)
|
||||
}
|
||||
|
||||
export function useMultipleDataSources(_sources: DataSourceConfig[]) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -41,20 +41,20 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const computedSources = dataSources.filter(ds => ds.type === 'computed')
|
||||
|
||||
computedSources.forEach(source => {
|
||||
const derivedSources = dataSources.filter(ds => ds.expression || ds.valueTemplate)
|
||||
|
||||
derivedSources.forEach(source => {
|
||||
const deps = source.dependencies || []
|
||||
const hasAllDeps = deps.every(dep => dep in data)
|
||||
|
||||
if (hasAllDeps) {
|
||||
const evaluationContext = { data }
|
||||
const computedValue = source.expression
|
||||
const derivedValue = source.expression
|
||||
? evaluateExpression(source.expression, evaluationContext)
|
||||
: source.valueTemplate
|
||||
? evaluateTemplate(source.valueTemplate, evaluationContext)
|
||||
: source.defaultValue
|
||||
setData(prev => ({ ...prev, [source.id]: computedValue }))
|
||||
setData(prev => ({ ...prev, [source.id]: derivedValue }))
|
||||
}
|
||||
})
|
||||
}, [data, dataSources])
|
||||
|
||||
@@ -13,8 +13,8 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
[dataSources]
|
||||
)
|
||||
|
||||
const computedSources = useMemo(
|
||||
() => dataSources.filter((ds) => ds.type === 'computed'),
|
||||
const derivedSources = useMemo(
|
||||
() => dataSources.filter((ds) => ds.expression || ds.valueTemplate),
|
||||
[dataSources]
|
||||
)
|
||||
|
||||
@@ -54,8 +54,8 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
const computedData = useMemo(() => {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
computedSources.forEach((ds) => {
|
||||
const evaluationContext = { data }
|
||||
derivedSources.forEach((ds) => {
|
||||
const evaluationContext = { data: { ...data, ...result } }
|
||||
if (ds.expression) {
|
||||
result[ds.id] = evaluateExpression(ds.expression, evaluationContext)
|
||||
return
|
||||
@@ -70,7 +70,7 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
})
|
||||
|
||||
return result
|
||||
}, [computedSources, data])
|
||||
}, [derivedSources, data])
|
||||
|
||||
const allData = useMemo(
|
||||
() => ({ ...data, ...computedData }),
|
||||
|
||||
@@ -45,22 +45,26 @@ export function usePage(schema: PageSchema) {
|
||||
useEffect(() => {
|
||||
if (schema.data) {
|
||||
const computed: Record<string, any> = {}
|
||||
|
||||
|
||||
schema.data.forEach(source => {
|
||||
if (source.type === 'computed') {
|
||||
if (source.expression) {
|
||||
computed[source.id] = evaluateBindingExpression(source.expression, dataContext, {
|
||||
fallback: undefined,
|
||||
label: `computed data (${source.id})`,
|
||||
})
|
||||
} else if (source.valueTemplate) {
|
||||
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: dataContext })
|
||||
}
|
||||
} else if (source.type === 'static' && source.defaultValue !== undefined) {
|
||||
if (source.expression) {
|
||||
computed[source.id] = evaluateBindingExpression(source.expression, { ...dataContext, ...computed }, {
|
||||
fallback: undefined,
|
||||
label: `derived data (${source.id})`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (source.valueTemplate) {
|
||||
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: { ...dataContext, ...computed } })
|
||||
return
|
||||
}
|
||||
|
||||
if (source.type === 'static' && source.defaultValue !== undefined) {
|
||||
computed[source.id] = source.defaultValue
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
setComputedData(computed)
|
||||
}
|
||||
}, [schema.data, dataContext])
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
|
||||
interface UseDataSourceEditorParams {
|
||||
dataSource: DataSource | null
|
||||
allDataSources: DataSource[]
|
||||
onSave: (dataSource: DataSource) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function useDataSourceEditor({
|
||||
dataSource,
|
||||
allDataSources,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
}: UseDataSourceEditorParams) {
|
||||
@@ -27,51 +25,15 @@ export function useDataSourceEditor({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addDependency = useCallback((depId: string) => {
|
||||
setEditingSource((prev) => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
if (deps.includes(depId)) return prev
|
||||
return { ...prev, dependencies: [...deps, depId] }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeDependency = useCallback((depId: string) => {
|
||||
setEditingSource((prev) => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
return { ...prev, dependencies: deps.filter((id) => id !== depId) }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!editingSource) return
|
||||
onSave(editingSource)
|
||||
onOpenChange(false)
|
||||
}, [editingSource, onOpenChange, onSave])
|
||||
|
||||
const availableDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return allDataSources.filter(
|
||||
(ds) => ds.id !== editingSource.id && ds.type !== 'computed',
|
||||
)
|
||||
}, [allDataSources, editingSource])
|
||||
|
||||
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
|
||||
|
||||
const unselectedDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return availableDeps.filter((ds) => !selectedDeps.includes(ds.id))
|
||||
}, [availableDeps, editingSource, selectedDeps])
|
||||
|
||||
return {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
handleSave,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,101 @@
|
||||
|
||||
import { PrismaModel, ComponentNode, ThemeConfig, ProjectFile } from '@/types/project'
|
||||
import { ProtectedLLMService } from './protected-llm-service'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
const componentNodeSchema: z.ZodType<ComponentNode> = z.lazy(() => z.object({
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
name: z.string(),
|
||||
props: z.record(z.any()),
|
||||
children: z.array(componentNodeSchema)
|
||||
}))
|
||||
|
||||
const prismaFieldSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
isRequired: z.boolean(),
|
||||
isUnique: z.boolean(),
|
||||
isArray: z.boolean(),
|
||||
defaultValue: z.string().optional(),
|
||||
relation: z.string().optional()
|
||||
})
|
||||
|
||||
const prismaModelSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
fields: z.array(prismaFieldSchema)
|
||||
})
|
||||
|
||||
const themeSchema = z.object({
|
||||
primaryColor: z.string(),
|
||||
secondaryColor: z.string(),
|
||||
errorColor: z.string(),
|
||||
warningColor: z.string(),
|
||||
successColor: z.string(),
|
||||
fontFamily: z.string(),
|
||||
fontSize: z.object({
|
||||
small: z.number(),
|
||||
medium: z.number(),
|
||||
large: z.number()
|
||||
}),
|
||||
spacing: z.number(),
|
||||
borderRadius: z.number()
|
||||
})
|
||||
|
||||
const projectFileSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
language: z.string()
|
||||
})
|
||||
|
||||
const componentResponseSchema = z.object({ component: componentNodeSchema })
|
||||
const prismaModelResponseSchema = z.object({ model: prismaModelSchema })
|
||||
const themeResponseSchema = z.object({ theme: themeSchema })
|
||||
const suggestFieldsResponseSchema = z.object({ fields: z.array(z.string()) })
|
||||
const completeAppResponseSchema = z.object({
|
||||
files: z.array(projectFileSchema),
|
||||
models: z.array(prismaModelSchema),
|
||||
theme: themeSchema
|
||||
})
|
||||
|
||||
const parseAndValidateJson = <T,>(
|
||||
result: string,
|
||||
schema: z.ZodType<T>,
|
||||
context: string,
|
||||
toastMessage: string
|
||||
): T | null => {
|
||||
let parsed: unknown
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(result)
|
||||
} catch (error) {
|
||||
console.error('AI response JSON parse failed', {
|
||||
context,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
rawResponse: result
|
||||
})
|
||||
toast.error(toastMessage)
|
||||
return null
|
||||
}
|
||||
|
||||
const validation = schema.safeParse(parsed)
|
||||
if (!validation.success) {
|
||||
console.error('AI response validation failed', {
|
||||
context,
|
||||
issues: validation.error.issues,
|
||||
rawResponse: parsed
|
||||
})
|
||||
toast.error(toastMessage)
|
||||
return null
|
||||
}
|
||||
|
||||
return validation.data
|
||||
}
|
||||
|
||||
export class AIService {
|
||||
static async generateComponent(description: string): Promise<ComponentNode | null> {
|
||||
@@ -29,8 +124,13 @@ Make sure to use appropriate Material UI components and props. Keep the structur
|
||||
)
|
||||
|
||||
if (result) {
|
||||
const parsed = JSON.parse(result)
|
||||
return parsed.component
|
||||
const parsed = parseAndValidateJson(
|
||||
result,
|
||||
componentResponseSchema,
|
||||
'generate-component',
|
||||
'AI component response was invalid. Please retry or clarify your description.'
|
||||
)
|
||||
return parsed ? parsed.component : null
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
@@ -80,8 +180,13 @@ Return a valid JSON object with a single property "model" containing the model s
|
||||
)
|
||||
|
||||
if (result) {
|
||||
const parsed = JSON.parse(result)
|
||||
return parsed.model
|
||||
const parsed = parseAndValidateJson(
|
||||
result,
|
||||
prismaModelResponseSchema,
|
||||
'generate-model',
|
||||
'AI model response was invalid. Please retry or describe the model differently.'
|
||||
)
|
||||
return parsed ? parsed.model : null
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
@@ -172,8 +277,13 @@ Return a valid JSON object with a single property "theme" containing:
|
||||
)
|
||||
|
||||
if (result) {
|
||||
const parsed = JSON.parse(result)
|
||||
return parsed.theme
|
||||
const parsed = parseAndValidateJson(
|
||||
result,
|
||||
themeResponseSchema,
|
||||
'generate-theme',
|
||||
'AI theme response was invalid. Please retry or specify the theme requirements.'
|
||||
)
|
||||
return parsed ? parsed.theme : null
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
@@ -202,8 +312,13 @@ Suggest 3-5 common fields that would be useful for this model type. Use camelCas
|
||||
)
|
||||
|
||||
if (result) {
|
||||
const parsed = JSON.parse(result)
|
||||
return parsed.fields
|
||||
const parsed = parseAndValidateJson(
|
||||
result,
|
||||
suggestFieldsResponseSchema,
|
||||
'suggest-fields',
|
||||
'AI field suggestions were invalid. Please retry with a clearer model name.'
|
||||
)
|
||||
return parsed ? parsed.fields : null
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
@@ -284,7 +399,12 @@ Create 2-4 essential files for the app structure. Include appropriate Prisma mod
|
||||
)
|
||||
|
||||
if (result) {
|
||||
return JSON.parse(result)
|
||||
return parseAndValidateJson(
|
||||
result,
|
||||
completeAppResponseSchema,
|
||||
'generate-app',
|
||||
'AI app generation response was invalid. Please retry with more detail.'
|
||||
)
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
|
||||
52
src/lib/json-ui/__tests__/component-registry.test.ts
Normal file
52
src/lib/json-ui/__tests__/component-registry.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,63 +1,6 @@
|
||||
import { ComponentType } from '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 * as PhosphorIcons from '@phosphor-icons/react'
|
||||
import jsonComponentsRegistry from '../../../json-components-registry.json'
|
||||
import {
|
||||
ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass,
|
||||
Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed,
|
||||
CaretUp, CaretDown, CaretLeft, CaretRight,
|
||||
Gear, User, Bell, Envelope, Calendar, Clock, Star,
|
||||
Heart, ShareNetwork, LinkSimple, Copy, FloppyDisk, ArrowClockwise, WarningCircle,
|
||||
Info, Question, House, List as ListIcon, DotsThreeVertical, DotsThree
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
export interface UIComponentRegistry {
|
||||
[key: string]: ComponentType<any>
|
||||
@@ -72,11 +15,16 @@ interface JsonRegistryEntry {
|
||||
wrapperRequired?: boolean
|
||||
wrapperComponent?: string
|
||||
wrapperFor?: string
|
||||
load?: {
|
||||
path?: string
|
||||
export?: string
|
||||
}
|
||||
deprecated?: DeprecatedComponentInfo
|
||||
}
|
||||
|
||||
interface JsonComponentRegistry {
|
||||
components?: JsonRegistryEntry[]
|
||||
sourceRoots?: Record<string, string[]>
|
||||
}
|
||||
|
||||
export interface DeprecatedComponentInfo {
|
||||
@@ -85,70 +33,127 @@ export interface DeprecatedComponentInfo {
|
||||
}
|
||||
|
||||
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
|
||||
|
||||
const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
|
||||
entry.export ?? entry.name ?? entry.type
|
||||
|
||||
const buildRegistryFromNames = (
|
||||
names: string[],
|
||||
components: Record<string, ComponentType<any>>
|
||||
): UIComponentRegistry => {
|
||||
return names.reduce<UIComponentRegistry>((registry, name) => {
|
||||
const component = components[name]
|
||||
if (component) {
|
||||
registry[name] = component
|
||||
const sourceRoots = jsonRegistry.sourceRoots ?? {}
|
||||
const moduleMapsBySource = Object.fromEntries(
|
||||
Object.entries(sourceRoots).map(([source, patterns]) => {
|
||||
if (!patterns || patterns.length === 0) {
|
||||
return [source, {}]
|
||||
}
|
||||
return registry
|
||||
}, {})
|
||||
}
|
||||
return [source, import.meta.glob(patterns, { eager: true })]
|
||||
})
|
||||
) as Record<string, Record<string, unknown>>
|
||||
|
||||
const getRegistryEntryKey = (entry: JsonRegistryEntry): string | undefined =>
|
||||
entry.name ?? entry.type
|
||||
|
||||
const getRegistryEntryExportName = (entry: JsonRegistryEntry): string | undefined =>
|
||||
entry.load?.export ?? entry.export ?? getRegistryEntryKey(entry)
|
||||
|
||||
const jsonRegistryEntries = jsonRegistry.components ?? []
|
||||
const registryEntryByType = new Map(
|
||||
jsonRegistryEntries
|
||||
.map((entry) => {
|
||||
const entryName = getRegistryEntryName(entry)
|
||||
return entryName ? [entryName, entry] : null
|
||||
const entryKey = getRegistryEntryKey(entry)
|
||||
return entryKey ? [entryKey, entry] : null
|
||||
})
|
||||
.filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry))
|
||||
)
|
||||
const atomComponentMap = AtomComponents as Record<string, ComponentType<any>>
|
||||
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
|
||||
(acc, entry) => {
|
||||
const entryName = getRegistryEntryName(entry)
|
||||
if (!entryName) {
|
||||
const entryKey = getRegistryEntryKey(entry)
|
||||
if (!entryKey) {
|
||||
return acc
|
||||
}
|
||||
if (entry.status === 'deprecated' || entry.deprecated) {
|
||||
acc[entryName] = entry.deprecated ?? {}
|
||||
acc[entryKey] = entry.deprecated ?? {}
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
const atomRegistryNames = jsonRegistryEntries
|
||||
.filter((entry) => entry.source === 'atoms')
|
||||
.map((entry) => getRegistryEntryName(entry))
|
||||
.filter((name): name is string => Boolean(name))
|
||||
const moleculeRegistryNames = jsonRegistryEntries
|
||||
.filter((entry) => entry.source === 'molecules')
|
||||
.map((entry) => getRegistryEntryName(entry))
|
||||
.filter((name): name is string => Boolean(name))
|
||||
const organismRegistryNames = jsonRegistryEntries
|
||||
.filter((entry) => entry.source === 'organisms')
|
||||
.map((entry) => getRegistryEntryName(entry))
|
||||
.filter((name): name is string => Boolean(name))
|
||||
const shadcnRegistryNames = jsonRegistryEntries
|
||||
.filter((entry) => entry.source === 'ui')
|
||||
.map((entry) => getRegistryEntryName(entry))
|
||||
.filter((name): name is string => Boolean(name))
|
||||
const wrapperRegistryNames = jsonRegistryEntries
|
||||
.filter((entry) => entry.source === 'wrappers')
|
||||
.map((entry) => getRegistryEntryName(entry))
|
||||
.filter((name): name is string => Boolean(name))
|
||||
const iconRegistryNames = jsonRegistryEntries
|
||||
.filter((entry) => entry.source === 'icons')
|
||||
.map((entry) => getRegistryEntryName(entry))
|
||||
.filter((name): name is string => Boolean(name))
|
||||
|
||||
const buildComponentMapFromExports = (
|
||||
exports: Record<string, unknown>
|
||||
): Record<string, ComponentType<any>> => {
|
||||
return Object.entries(exports).reduce<Record<string, ComponentType<any>>>((acc, [key, value]) => {
|
||||
if (value && (typeof value === 'function' || typeof value === 'object')) {
|
||||
acc[key] = value as ComponentType<any>
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const buildComponentMapFromModules = (
|
||||
modules: Record<string, unknown>
|
||||
): Record<string, ComponentType<any>> => {
|
||||
return Object.values(modules).reduce<Record<string, ComponentType<any>>>((acc, moduleExports) => {
|
||||
if (!moduleExports || typeof moduleExports !== 'object') {
|
||||
return acc
|
||||
}
|
||||
Object.entries(buildComponentMapFromExports(moduleExports as Record<string, unknown>)).forEach(
|
||||
([key, component]) => {
|
||||
acc[key] = component
|
||||
}
|
||||
)
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const atomModules = import.meta.glob('@/components/atoms/*.tsx', { eager: true })
|
||||
const moleculeModules = import.meta.glob('@/components/molecules/*.tsx', { eager: true })
|
||||
const organismModules = import.meta.glob('@/components/organisms/*.tsx', { eager: true })
|
||||
const uiModules = import.meta.glob('@/components/ui/**/*.{ts,tsx}', { eager: true })
|
||||
const wrapperModules = import.meta.glob('@/lib/json-ui/wrappers/*.tsx', { eager: true })
|
||||
const explicitModules = import.meta.glob(
|
||||
['@/components/**/*.tsx', '@/lib/json-ui/wrappers/**/*.tsx'],
|
||||
{ eager: true }
|
||||
)
|
||||
|
||||
const atomComponentMap = buildComponentMapFromModules(atomModules)
|
||||
const moleculeComponentMap = buildComponentMapFromModules(moleculeModules)
|
||||
const organismComponentMap = buildComponentMapFromModules(organismModules)
|
||||
const uiComponentMap = buildComponentMapFromModules(uiModules)
|
||||
const wrapperComponentMap = buildComponentMapFromModules(wrapperModules)
|
||||
const iconComponentMap = buildComponentMapFromExports(PhosphorIcons)
|
||||
|
||||
const resolveComponentFromExplicitPath = (
|
||||
entry: JsonRegistryEntry,
|
||||
entryExportName: string
|
||||
): ComponentType<any> | undefined => {
|
||||
if (!entry.load?.path) {
|
||||
return undefined
|
||||
}
|
||||
const moduleExports = explicitModules[entry.load.path]
|
||||
if (!moduleExports || typeof moduleExports !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const explicitComponents = buildComponentMapFromExports(
|
||||
moduleExports as Record<string, unknown>
|
||||
)
|
||||
return explicitComponents[entryExportName]
|
||||
}
|
||||
|
||||
const buildRegistryFromEntries = (
|
||||
source: string,
|
||||
componentMap: Record<string, ComponentType<any>>
|
||||
): UIComponentRegistry => {
|
||||
return jsonRegistryEntries
|
||||
.filter((entry) => entry.source === source)
|
||||
.reduce<UIComponentRegistry>((registry, entry) => {
|
||||
const entryKey = getRegistryEntryKey(entry)
|
||||
const entryExportName = getRegistryEntryExportName(entry)
|
||||
if (!entryKey || !entryExportName) {
|
||||
return registry
|
||||
}
|
||||
const component =
|
||||
resolveComponentFromExplicitPath(entry, entryExportName) ??
|
||||
componentMap[entryExportName]
|
||||
if (component) {
|
||||
registry[entryKey] = component
|
||||
}
|
||||
return registry
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const primitiveComponents: UIComponentRegistry = {
|
||||
div: 'div' as any,
|
||||
@@ -169,173 +174,33 @@ export const primitiveComponents: UIComponentRegistry = {
|
||||
nav: 'nav' as any,
|
||||
}
|
||||
|
||||
const shadcnComponentMap: Record<string, ComponentType<any>> = {
|
||||
AlertDialog,
|
||||
AspectRatio,
|
||||
Button,
|
||||
Carousel,
|
||||
Chart,
|
||||
Collapsible,
|
||||
Command,
|
||||
DropdownMenu,
|
||||
Input,
|
||||
InputOtp,
|
||||
Textarea,
|
||||
Label,
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
Badge,
|
||||
Separator,
|
||||
Alert: ShadcnAlert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
Switch,
|
||||
Checkbox,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Table: ShadcnTable,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Menubar,
|
||||
NavigationMenu,
|
||||
Skeleton: ShadcnSkeleton,
|
||||
Pagination,
|
||||
Progress,
|
||||
Resizable,
|
||||
Sheet,
|
||||
Sidebar,
|
||||
Sonner,
|
||||
ToggleGroup,
|
||||
Avatar: ShadcnAvatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
}
|
||||
|
||||
export const shadcnComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
shadcnRegistryNames,
|
||||
shadcnComponentMap
|
||||
export const shadcnComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'ui',
|
||||
uiComponentMap
|
||||
)
|
||||
|
||||
export const atomComponents: UIComponentRegistry = {
|
||||
...buildRegistryFromNames(
|
||||
atomRegistryNames,
|
||||
atomComponentMap
|
||||
),
|
||||
DatePicker: atomComponentMap.DatePicker,
|
||||
FileUpload: atomComponentMap.FileUpload,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
ProgressBar,
|
||||
DataList: (AtomComponents as Record<string, ComponentType<any>>).DataList,
|
||||
DataTable: (AtomComponents as Record<string, ComponentType<any>>).DataTable,
|
||||
ListItem: (AtomComponents as Record<string, ComponentType<any>>).ListItem,
|
||||
MetricCard: (AtomComponents as Record<string, ComponentType<any>>).MetricCard,
|
||||
Timeline: (AtomComponents as Record<string, ComponentType<any>>).Timeline,
|
||||
}
|
||||
|
||||
const breadcrumbComponent = AtomComponents.Breadcrumb ?? AtomComponents.BreadcrumbNav
|
||||
if (breadcrumbComponent) {
|
||||
atomComponents.Breadcrumb = breadcrumbComponent as ComponentType<any>
|
||||
}
|
||||
|
||||
export const moleculeComponents: UIComponentRegistry = {
|
||||
...buildRegistryFromNames(
|
||||
moleculeRegistryNames,
|
||||
MoleculeComponents as Record<string, ComponentType<any>>
|
||||
),
|
||||
AppBranding: (MoleculeComponents as Record<string, ComponentType<any>>).AppBranding,
|
||||
LabelWithBadge: (MoleculeComponents as Record<string, ComponentType<any>>).LabelWithBadge,
|
||||
NavigationGroupHeader: (MoleculeComponents as Record<string, ComponentType<any>>).NavigationGroupHeader,
|
||||
}
|
||||
|
||||
export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
organismRegistryNames,
|
||||
OrganismComponents as Record<string, ComponentType<any>>
|
||||
export const atomComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'atoms',
|
||||
atomComponentMap
|
||||
)
|
||||
|
||||
const wrapperComponentMap: Record<string, ComponentType<any>> = {
|
||||
ComponentBindingDialogWrapper,
|
||||
ComponentTreeWrapper,
|
||||
DataSourceEditorDialogWrapper,
|
||||
GitHubBuildStatusWrapper,
|
||||
SaveIndicatorWrapper,
|
||||
LazyBarChartWrapper,
|
||||
LazyLineChartWrapper,
|
||||
LazyD3BarChartWrapper,
|
||||
SeedDataManagerWrapper,
|
||||
StorageSettingsWrapper,
|
||||
}
|
||||
export const moleculeComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'molecules',
|
||||
moleculeComponentMap
|
||||
)
|
||||
|
||||
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
wrapperRegistryNames,
|
||||
export const organismComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'organisms',
|
||||
organismComponentMap
|
||||
)
|
||||
|
||||
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'wrappers',
|
||||
wrapperComponentMap
|
||||
)
|
||||
|
||||
const iconComponentMap: Record<string, ComponentType<any>> = {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
X,
|
||||
Plus,
|
||||
Minus,
|
||||
Search: MagnifyingGlass,
|
||||
Filter: Funnel,
|
||||
Download,
|
||||
Upload,
|
||||
Edit: PencilSimple,
|
||||
Trash,
|
||||
Eye,
|
||||
EyeOff: EyeClosed,
|
||||
ChevronUp: CaretUp,
|
||||
ChevronDown: CaretDown,
|
||||
ChevronLeft: CaretLeft,
|
||||
ChevronRight: CaretRight,
|
||||
Settings: Gear,
|
||||
User,
|
||||
Bell,
|
||||
Mail: Envelope,
|
||||
Calendar,
|
||||
Clock,
|
||||
Star,
|
||||
Heart,
|
||||
Share: ShareNetwork,
|
||||
Link: LinkSimple,
|
||||
Copy,
|
||||
Save: FloppyDisk,
|
||||
RefreshCw: ArrowClockwise,
|
||||
AlertCircle: WarningCircle,
|
||||
Info,
|
||||
HelpCircle: Question,
|
||||
Home: House,
|
||||
Menu: ListIcon,
|
||||
MoreVertical: DotsThreeVertical,
|
||||
MoreHorizontal: DotsThree,
|
||||
}
|
||||
|
||||
export const iconComponents: UIComponentRegistry = buildRegistryFromNames(
|
||||
iconRegistryNames,
|
||||
export const iconComponents: UIComponentRegistry = buildRegistryFromEntries(
|
||||
'icons',
|
||||
iconComponentMap
|
||||
)
|
||||
|
||||
|
||||
@@ -54,6 +54,35 @@ export function evaluateExpression(
|
||||
return lengthSuffix ? filtered.length : filtered
|
||||
}
|
||||
|
||||
const findMatch = expression.match(
|
||||
/^data\.([a-zA-Z0-9_.]+)\.find\(\s*([a-zA-Z0-9_.]+)\s*(===|==|!==|!=)\s*(.+?)\s*\)$/
|
||||
)
|
||||
if (findMatch) {
|
||||
const [, collectionPath, fieldPath, operator, rawValue] = findMatch
|
||||
const collection = getNestedValue(data, collectionPath)
|
||||
if (!Array.isArray(collection)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const expectedValue = evaluateExpression(rawValue.trim(), { data, event })
|
||||
const isNegated = operator === '!=' || operator === '!=='
|
||||
return collection.find((item) => {
|
||||
const fieldValue = getNestedValue(item, fieldPath)
|
||||
return isNegated ? fieldValue !== expectedValue : fieldValue === expectedValue
|
||||
})
|
||||
}
|
||||
|
||||
const objectKeysLengthMatch = expression.match(
|
||||
/^Object\.keys\(\s*data\.([a-zA-Z0-9_.]+)\s*\)\.length$/
|
||||
)
|
||||
if (objectKeysLengthMatch) {
|
||||
const value = getNestedValue(data, objectKeysLengthMatch[1])
|
||||
if (!value || typeof value !== 'object') {
|
||||
return 0
|
||||
}
|
||||
return Object.keys(value).length
|
||||
}
|
||||
|
||||
// Handle direct data access: "data.fieldName"
|
||||
if (expression.startsWith('data.')) {
|
||||
return getNestedValue(data, expression.substring(5))
|
||||
|
||||
@@ -8,7 +8,6 @@ export function useJSONDataSource<T = unknown>(
|
||||
) {
|
||||
const kvConfig = config.type === 'kv' ? config.config : undefined
|
||||
const apiConfig = config.type === 'api' ? config.config : undefined
|
||||
const computedConfig = config.type === 'computed' ? config.config : undefined
|
||||
const defaultValue =
|
||||
config.type === 'static' ? config.config : config.config?.defaultValue
|
||||
|
||||
@@ -57,8 +56,6 @@ export function useJSONDataSource<T = unknown>(
|
||||
return apiValue
|
||||
case 'static':
|
||||
return config.config
|
||||
case 'computed':
|
||||
return computedConfig?.defaultValue
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ export const PageUISchema = z.object({
|
||||
tables: z.array(TableSchema).optional(),
|
||||
menus: z.array(MenuSchema).optional(),
|
||||
dataSources: z.record(z.string(), z.object({
|
||||
type: z.enum(['kv', 'api', 'computed', 'static']),
|
||||
type: z.enum(['kv', 'api', 'static']),
|
||||
config: z.any(),
|
||||
})).optional(),
|
||||
})
|
||||
@@ -241,13 +241,6 @@ export type DataSourceConfig<T = unknown> =
|
||||
transform?: (data: unknown) => T
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'computed'
|
||||
config: {
|
||||
defaultValue?: T
|
||||
transform?: (data: unknown) => T
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'static'
|
||||
config: T
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
import { StatusIcon } from '@/components/atoms'
|
||||
import { useSaveIndicator } from '@/hooks/use-save-indicator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SaveIndicatorWrapperProps } from './interfaces'
|
||||
|
||||
export function SaveIndicatorWrapper({
|
||||
lastSaved,
|
||||
status = 'saved',
|
||||
label,
|
||||
showLabel = true,
|
||||
animate,
|
||||
className,
|
||||
}: SaveIndicatorWrapperProps) {
|
||||
const { timeAgo, isRecent } = useSaveIndicator(lastSaved ?? null)
|
||||
|
||||
if (lastSaved) {
|
||||
const resolvedStatus = isRecent ? 'saved' : 'synced'
|
||||
const resolvedLabel = label ?? (isRecent ? 'Saved' : timeAgo)
|
||||
const shouldAnimate = animate ?? isRecent
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1.5 text-xs text-muted-foreground', className)}>
|
||||
<StatusIcon type={resolvedStatus} animate={shouldAnimate} />
|
||||
{showLabel && <span className="hidden sm:inline">{resolvedLabel}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedLabel = label ?? (status === 'saved' ? 'Saved' : 'Synced')
|
||||
const shouldAnimate = animate ?? status === 'saved'
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { UIComponent } from '@/types/json-ui'
|
||||
export type SaveIndicatorStatus = 'saved' | 'synced'
|
||||
|
||||
export interface SaveIndicatorWrapperProps {
|
||||
lastSaved?: number | null
|
||||
status?: SaveIndicatorStatus
|
||||
label?: string
|
||||
showLabel?: boolean
|
||||
|
||||
56
src/lib/rate-limiter.test.ts
Normal file
56
src/lib/rate-limiter.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ interface RateLimitConfig {
|
||||
maxRequests: number
|
||||
windowMs: number
|
||||
retryDelay: number
|
||||
maxRetries?: number
|
||||
}
|
||||
|
||||
interface RequestRecord {
|
||||
@@ -9,14 +10,15 @@ interface RequestRecord {
|
||||
count: number
|
||||
}
|
||||
|
||||
class RateLimiter {
|
||||
export class RateLimiter {
|
||||
private requests: Map<string, RequestRecord> = new Map()
|
||||
private config: RateLimitConfig
|
||||
|
||||
constructor(config: RateLimitConfig = {
|
||||
maxRequests: 5,
|
||||
windowMs: 60000,
|
||||
retryDelay: 2000
|
||||
retryDelay: 2000,
|
||||
maxRetries: 3
|
||||
}) {
|
||||
this.config = config
|
||||
}
|
||||
@@ -26,49 +28,60 @@ class RateLimiter {
|
||||
fn: () => Promise<T>,
|
||||
priority: 'low' | 'medium' | 'high' = 'medium'
|
||||
): Promise<T | null> {
|
||||
const now = Date.now()
|
||||
const record = this.requests.get(key)
|
||||
const maxRetries = this.config.maxRetries ?? 3
|
||||
let attempts = 0
|
||||
|
||||
if (record) {
|
||||
const timeElapsed = now - record.timestamp
|
||||
while (true) {
|
||||
const now = Date.now()
|
||||
const record = this.requests.get(key)
|
||||
let isLimited = false
|
||||
|
||||
if (timeElapsed < this.config.windowMs) {
|
||||
if (record.count >= this.config.maxRequests) {
|
||||
console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`)
|
||||
|
||||
if (priority === 'high') {
|
||||
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay))
|
||||
return this.throttle(key, fn, priority)
|
||||
if (record) {
|
||||
const timeElapsed = now - record.timestamp
|
||||
|
||||
if (timeElapsed < this.config.windowMs) {
|
||||
if (record.count >= this.config.maxRequests) {
|
||||
console.warn(`Rate limit exceeded for ${key}. Try again in ${Math.ceil((this.config.windowMs - timeElapsed) / 1000)}s`)
|
||||
isLimited = true
|
||||
} else {
|
||||
record.count++
|
||||
}
|
||||
|
||||
return null
|
||||
} else {
|
||||
this.requests.set(key, { timestamp: now, count: 1 })
|
||||
}
|
||||
|
||||
record.count++
|
||||
} else {
|
||||
this.requests.set(key, { timestamp: now, count: 1 })
|
||||
}
|
||||
} else {
|
||||
this.requests.set(key, { timestamp: now, count: 1 })
|
||||
}
|
||||
|
||||
this.cleanup()
|
||||
this.cleanup()
|
||||
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (
|
||||
error.message.includes('502') ||
|
||||
error.message.includes('Bad Gateway') ||
|
||||
error.message.includes('429') ||
|
||||
error.message.includes('rate limit')
|
||||
)) {
|
||||
console.error(`Gateway error for ${key}:`, error.message)
|
||||
if (record) {
|
||||
record.count = this.config.maxRequests
|
||||
if (isLimited) {
|
||||
if (priority === 'high' && attempts < maxRetries) {
|
||||
attempts += 1
|
||||
await new Promise(resolve => setTimeout(resolve, this.config.retryDelay))
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
},
|
||||
{
|
||||
"id": "filteredUsers",
|
||||
"type": "computed",
|
||||
"type": "static",
|
||||
"expression": "data.users",
|
||||
"dependencies": ["users", "filterQuery"]
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"type": "computed",
|
||||
"type": "static",
|
||||
"valueTemplate": {
|
||||
"total": "data.users.length",
|
||||
"active": "data.users.filter(status === 'active').length",
|
||||
|
||||
300
src/schemas/feature-toggle-settings.json
Normal file
300
src/schemas/feature-toggle-settings.json
Normal file
@@ -0,0 +1,300 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"type": "computed",
|
||||
"type": "static",
|
||||
"valueTemplate": {
|
||||
"total": "data.todos.length",
|
||||
"completed": "data.todos.filter(completed === true).length",
|
||||
|
||||
48
src/store/actionNames.ts
Normal file
48
src/store/actionNames.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
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',
|
||||
])
|
||||
112
src/store/middleware/autoSyncMiddleware.test.ts
Normal file
112
src/store/middleware/autoSyncMiddleware.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Middleware } from '@reduxjs/toolkit'
|
||||
import { syncToFlaskBulk, checkFlaskConnection } from '../slices/syncSlice'
|
||||
import { RootState } from '../index'
|
||||
import { itemChangeActionTypes } from '../actionNames'
|
||||
|
||||
interface AutoSyncConfig {
|
||||
enabled: boolean
|
||||
@@ -9,7 +10,7 @@ interface AutoSyncConfig {
|
||||
maxQueueSize: number
|
||||
}
|
||||
|
||||
class AutoSyncManager {
|
||||
export class AutoSyncManager {
|
||||
private config: AutoSyncConfig = {
|
||||
enabled: false,
|
||||
intervalMs: 30000,
|
||||
@@ -20,6 +21,8 @@ class AutoSyncManager {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private lastSyncTime = 0
|
||||
private changeCounter = 0
|
||||
private inFlight = false
|
||||
private pendingSync = false
|
||||
private dispatch: any = null
|
||||
|
||||
configure(config: Partial<AutoSyncConfig>) {
|
||||
@@ -68,18 +71,33 @@ class AutoSyncManager {
|
||||
|
||||
private async performSync() {
|
||||
if (!this.dispatch) return
|
||||
if (this.inFlight) {
|
||||
this.pendingSync = true
|
||||
return
|
||||
}
|
||||
|
||||
this.inFlight = true
|
||||
try {
|
||||
await this.dispatch(syncToFlaskBulk())
|
||||
this.lastSyncTime = Date.now()
|
||||
this.changeCounter = 0
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Sync failed:', error)
|
||||
} finally {
|
||||
this.inFlight = false
|
||||
}
|
||||
|
||||
if (this.pendingSync) {
|
||||
this.pendingSync = false
|
||||
await this.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
trackChange() {
|
||||
this.changeCounter++
|
||||
if (this.inFlight) {
|
||||
this.pendingSync = true
|
||||
}
|
||||
|
||||
if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) {
|
||||
this.performSync()
|
||||
@@ -127,28 +145,7 @@ export const createAutoSyncMiddleware = (): Middleware => {
|
||||
})
|
||||
}
|
||||
|
||||
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)) {
|
||||
if (itemChangeActionTypes.has(action.type)) {
|
||||
autoSyncManager.trackChange()
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export async function syncToFlask(
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FlaskSync] Error syncing to Flask:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
103
src/store/middleware/persistenceMiddleware.test.ts
Normal file
103
src/store/middleware/persistenceMiddleware.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,11 @@ import { Middleware } from '@reduxjs/toolkit'
|
||||
import { db } from '@/lib/db'
|
||||
import { syncToFlask } from './flaskSync'
|
||||
import { RootState } from '../index'
|
||||
import {
|
||||
persistenceBulkActionNames,
|
||||
persistenceDeleteActionNames,
|
||||
persistenceSingleItemActionNames,
|
||||
} from '../actionNames'
|
||||
|
||||
interface PersistenceConfig {
|
||||
storeName: string
|
||||
@@ -38,10 +43,23 @@ type PendingOperation = {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type FailedSyncOperation = PendingOperation & {
|
||||
attempt: number
|
||||
lastError: string
|
||||
nextRetryAt: number
|
||||
}
|
||||
|
||||
const MAX_SYNC_RETRIES = 5
|
||||
const BASE_SYNC_RETRY_DELAY_MS = 1000
|
||||
const MAX_SYNC_RETRY_DELAY_MS = 30000
|
||||
|
||||
class PersistenceQueue {
|
||||
private queue: Map<string, PendingOperation> = new Map()
|
||||
private processing = false
|
||||
private pendingFlush = false
|
||||
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
private failedSyncs: Map<string, FailedSyncOperation> = new Map()
|
||||
private retryTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
|
||||
enqueue(operation: PendingOperation, debounceMs: number) {
|
||||
const opKey = `${operation.storeName}:${operation.key}`
|
||||
@@ -62,7 +80,12 @@ class PersistenceQueue {
|
||||
}
|
||||
|
||||
async processQueue() {
|
||||
if (this.processing || this.queue.size === 0) return
|
||||
if (this.processing) {
|
||||
this.pendingFlush = true
|
||||
return
|
||||
}
|
||||
|
||||
if (this.queue.size === 0) return
|
||||
|
||||
this.processing = true
|
||||
|
||||
@@ -75,14 +98,10 @@ class PersistenceQueue {
|
||||
try {
|
||||
if (op.type === 'put') {
|
||||
await db.put(op.storeName as any, op.value)
|
||||
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
|
||||
await syncToFlask(op.storeName, op.key, op.value, 'put')
|
||||
}
|
||||
await this.syncToFlaskWithRetry(op, op.value)
|
||||
} else if (op.type === 'delete') {
|
||||
await db.delete(op.storeName as any, op.key)
|
||||
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
|
||||
await syncToFlask(op.storeName, op.key, null, 'delete')
|
||||
}
|
||||
await this.syncToFlaskWithRetry(op, null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error)
|
||||
@@ -97,6 +116,23 @@ class PersistenceQueue {
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
const needsFlush = this.pendingFlush || this.queue.size > 0
|
||||
this.pendingFlush = false
|
||||
if (needsFlush) {
|
||||
await this.processQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getFailedSyncs() {
|
||||
return Array.from(this.failedSyncs.values()).sort((a, b) => a.nextRetryAt - b.nextRetryAt)
|
||||
}
|
||||
|
||||
async retryFailedSyncs() {
|
||||
for (const [opKey, failure] of this.failedSyncs.entries()) {
|
||||
if (failure.nextRetryAt <= Date.now()) {
|
||||
await this.retryFailedSync(opKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +143,89 @@ class PersistenceQueue {
|
||||
this.debounceTimers.clear()
|
||||
await this.processQueue()
|
||||
}
|
||||
|
||||
private async syncToFlaskWithRetry(op: PendingOperation, value: any) {
|
||||
if (!sliceToPersistenceMap[op.storeName]?.syncToFlask) return
|
||||
|
||||
try {
|
||||
await syncToFlask(op.storeName, op.key, value, op.type)
|
||||
this.clearSyncFailure(op)
|
||||
} catch (error) {
|
||||
this.recordSyncFailure(op, error)
|
||||
console.warn(
|
||||
`[PersistenceMiddleware] Flask sync failed for ${op.storeName}:${op.key} (${op.type}); queued for retry.`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private recordSyncFailure(op: PendingOperation, error: unknown) {
|
||||
const opKey = this.getFailureKey(op)
|
||||
const previous = this.failedSyncs.get(opKey)
|
||||
const attempt = previous ? previous.attempt + 1 : 1
|
||||
const delayMs = this.getRetryDelayMs(attempt)
|
||||
const nextRetryAt = Date.now() + delayMs
|
||||
const lastError = error instanceof Error ? error.message : String(error)
|
||||
|
||||
this.failedSyncs.set(opKey, {
|
||||
...op,
|
||||
attempt,
|
||||
lastError,
|
||||
nextRetryAt,
|
||||
})
|
||||
|
||||
const existingTimer = this.retryTimers.get(opKey)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
}
|
||||
|
||||
if (attempt <= MAX_SYNC_RETRIES) {
|
||||
const timer = setTimeout(() => {
|
||||
this.retryTimers.delete(opKey)
|
||||
void this.retryFailedSync(opKey)
|
||||
}, delayMs)
|
||||
this.retryTimers.set(opKey, timer)
|
||||
}
|
||||
}
|
||||
|
||||
private clearSyncFailure(op: PendingOperation) {
|
||||
const opKey = this.getFailureKey(op)
|
||||
const timer = this.retryTimers.get(opKey)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
this.retryTimers.delete(opKey)
|
||||
}
|
||||
this.failedSyncs.delete(opKey)
|
||||
}
|
||||
|
||||
private async retryFailedSync(opKey: string) {
|
||||
const failure = this.failedSyncs.get(opKey)
|
||||
if (!failure) return
|
||||
|
||||
if (failure.attempt > MAX_SYNC_RETRIES) {
|
||||
return
|
||||
}
|
||||
|
||||
this.enqueue(
|
||||
{
|
||||
type: failure.type,
|
||||
storeName: failure.storeName,
|
||||
key: failure.key,
|
||||
value: failure.value,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
private getRetryDelayMs(attempt: number) {
|
||||
const delay = BASE_SYNC_RETRY_DELAY_MS * Math.pow(2, attempt - 1)
|
||||
return Math.min(delay, MAX_SYNC_RETRY_DELAY_MS)
|
||||
}
|
||||
|
||||
private getFailureKey(op: PendingOperation) {
|
||||
return `${op.storeName}:${op.key}:${op.type}`
|
||||
}
|
||||
}
|
||||
|
||||
const persistenceQueue = new PersistenceQueue()
|
||||
@@ -128,10 +247,7 @@ export const createPersistenceMiddleware = (): Middleware => {
|
||||
if (!sliceState) return result
|
||||
|
||||
try {
|
||||
if (actionName === 'addItem' || actionName === 'updateItem' || actionName === 'saveFile' ||
|
||||
actionName === 'saveModel' || actionName === 'saveComponent' || actionName === 'saveComponentTree' ||
|
||||
actionName === 'saveWorkflow' || actionName === 'saveLambda') {
|
||||
|
||||
if (persistenceSingleItemActionNames.has(actionName)) {
|
||||
const item = action.payload
|
||||
if (item && item.id) {
|
||||
persistenceQueue.enqueue({
|
||||
@@ -144,10 +260,7 @@ export const createPersistenceMiddleware = (): Middleware => {
|
||||
}
|
||||
}
|
||||
|
||||
if (actionName === 'addItems' || actionName === 'setItems' || actionName === 'setFiles' ||
|
||||
actionName === 'setModels' || actionName === 'setComponents' || actionName === 'setComponentTrees' ||
|
||||
actionName === 'setWorkflows' || actionName === 'setLambdas') {
|
||||
|
||||
if (persistenceBulkActionNames.has(actionName)) {
|
||||
const items = action.payload
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((item: any) => {
|
||||
@@ -164,10 +277,7 @@ export const createPersistenceMiddleware = (): Middleware => {
|
||||
}
|
||||
}
|
||||
|
||||
if (actionName === 'removeItem' || actionName === 'deleteFile' || actionName === 'deleteModel' ||
|
||||
actionName === 'deleteComponent' || actionName === 'deleteComponentTree' ||
|
||||
actionName === 'deleteWorkflow' || actionName === 'deleteLambda') {
|
||||
|
||||
if (persistenceDeleteActionNames.has(actionName)) {
|
||||
const itemId = typeof action.payload === 'string' ? action.payload : action.payload?.id
|
||||
if (itemId) {
|
||||
persistenceQueue.enqueue({
|
||||
@@ -208,6 +318,8 @@ export const createPersistenceMiddleware = (): Middleware => {
|
||||
}
|
||||
|
||||
export const flushPersistence = () => persistenceQueue.flush()
|
||||
export const getFailedSyncOperations = () => persistenceQueue.getFailedSyncs()
|
||||
export const retryFailedSyncOperations = () => persistenceQueue.retryFailedSyncs()
|
||||
|
||||
export const configurePersistence = (sliceName: string, config: Partial<PersistenceConfig>) => {
|
||||
if (sliceToPersistenceMap[sliceName]) {
|
||||
|
||||
@@ -107,21 +107,18 @@ export const createSyncMonitorMiddleware = (): Middleware => {
|
||||
const isFulfilledAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/fulfilled`)
|
||||
const isRejectedAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/rejected`)
|
||||
|
||||
if (isPendingAction) {
|
||||
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
|
||||
syncMonitor.startOperation(operationId)
|
||||
if (isPendingAction && action.meta?.requestId) {
|
||||
syncMonitor.startOperation(action.meta.requestId)
|
||||
}
|
||||
|
||||
const result = next(action)
|
||||
|
||||
if (isFulfilledAction) {
|
||||
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
|
||||
syncMonitor.endOperation(operationId, true)
|
||||
if (isFulfilledAction && action.meta?.requestId) {
|
||||
syncMonitor.endOperation(action.meta.requestId, true)
|
||||
}
|
||||
|
||||
if (isRejectedAction) {
|
||||
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
|
||||
syncMonitor.endOperation(operationId, false)
|
||||
if (isRejectedAction && action.meta?.requestId) {
|
||||
syncMonitor.endOperation(action.meta.requestId, false)
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
98
src/store/slices/syncSlice.test.ts
Normal file
98
src/store/slices/syncSlice.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,8 @@ import { db } from '@/lib/db'
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'success' | 'error'
|
||||
|
||||
const SYNCABLE_STORES = new Set(['files', 'models', 'components', 'workflows'])
|
||||
|
||||
interface SyncState {
|
||||
status: SyncStatus
|
||||
lastSyncedAt: number | null
|
||||
@@ -68,15 +70,51 @@ export const syncFromFlaskBulk = createAsyncThunk(
|
||||
async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const data = await fetchAllFromFlask()
|
||||
|
||||
const allowedStoreNames = new Set(['files', 'models', 'components', 'workflows'])
|
||||
const serverIdsByStore = {
|
||||
files: new Set<string>(),
|
||||
models: new Set<string>(),
|
||||
components: new Set<string>(),
|
||||
workflows: new Set<string>(),
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const [storeName, id] = key.split(':')
|
||||
|
||||
if (storeName === 'files' ||
|
||||
storeName === 'models' ||
|
||||
storeName === 'components' ||
|
||||
storeName === 'workflows') {
|
||||
if (SYNCABLE_STORES.has(storeName)) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
249
src/types/json-ui-component-types.ts
Normal file
249
src/types/json-ui-component-types.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
// 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]
|
||||
@@ -1,29 +1,6 @@
|
||||
export type ComponentType =
|
||||
| 'div' | 'section' | 'article' | 'header' | 'footer' | 'main'
|
||||
| 'Button' | 'Card' | 'CardHeader' | 'CardTitle' | 'CardDescription' | 'CardContent' | 'CardFooter'
|
||||
| 'Input' | 'TextArea' | 'Textarea' | 'Select' | 'Checkbox' | 'Radio' | 'Switch' | 'Slider' | 'NumberInput' | 'DatePicker' | 'FileUpload'
|
||||
| 'Badge' | 'Progress' | 'Separator' | 'Tabs' | 'TabsContent' | 'TabsList' | 'TabsTrigger' | 'Dialog'
|
||||
| 'Text' | 'Heading' | 'Label' | 'List' | 'ListItem' | 'Grid' | 'Stack' | 'Flex' | 'Container'
|
||||
| 'Link' | 'Breadcrumb' | 'Image' | 'Avatar' | 'Code' | 'Tag' | 'Spinner' | 'Skeleton'
|
||||
| 'CircularProgress' | 'Divider' | 'ProgressBar'
|
||||
| 'Alert' | 'InfoBox' | 'EmptyState' | 'StatusBadge'
|
||||
| 'ErrorBadge' | 'Notification' | 'StatusIcon'
|
||||
| 'Table' | 'TableHeader' | 'TableBody' | 'TableRow' | 'TableCell' | 'TableHead'
|
||||
| 'KeyValue' | 'StatCard' | 'DataCard' | 'SearchInput' | 'ActionBar'
|
||||
| 'DataList' | 'DataTable' | 'MetricCard' | 'Timeline'
|
||||
| 'LazyBarChart' | 'LazyLineChart' | 'LazyD3BarChart' | 'SeedDataManager'
|
||||
| 'SaveIndicator' | 'StorageSettings'
|
||||
| 'AppBranding' | 'LabelWithBadge' | 'NavigationGroupHeader' | 'EmptyEditorState' | 'LoadingFallback' | 'LoadingState'
|
||||
| 'CodeExplanationDialog' | 'ComponentBindingDialog' | 'DataSourceCard' | 'DataSourceEditorDialog' | 'TreeCard' | 'TreeFormDialog'
|
||||
| 'ToolbarButton'
|
||||
| 'SchemaCodeViewer'
|
||||
| 'FileTabs' | 'NavigationItem' | 'NavigationMenu'
|
||||
| 'EmptyCanvasState' | 'SchemaEditorStatusBar'
|
||||
| 'DataSourceManager' | 'TreeListHeader' | 'TreeListPanel'
|
||||
| 'AppHeader' | 'BindingEditor' | 'CanvasRenderer' | 'ComponentPalette' | 'ComponentTree' | 'EditorActions'
|
||||
| 'EditorToolbar' | 'JSONUIShowcase' | 'LazyInlineMonacoEditor' | 'LazyMonacoEditor' | 'MonacoEditorPanel'
|
||||
| 'PageHeaderContent' | 'PropertyEditor' | 'SchemaEditorCanvas' | 'SchemaEditorLayout'
|
||||
| 'SchemaEditorPropertiesPanel' | 'SchemaEditorSidebar' | 'SchemaEditorToolbar' | 'SearchBar' | 'ToolbarActions'
|
||||
import type { JSONUIComponentType } from './json-ui-component-types'
|
||||
|
||||
export type ComponentType = JSONUIComponentType
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string
|
||||
@@ -42,7 +19,7 @@ export type ActionType =
|
||||
| 'custom'
|
||||
|
||||
export type DataSourceType =
|
||||
| 'kv' | 'computed' | 'static'
|
||||
| 'kv' | 'static'
|
||||
|
||||
export type BindingSourceType =
|
||||
| 'data' | 'bindings' | 'state'
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ComponentSchema: z.ZodType<any> = z.lazy(() =>
|
||||
|
||||
export const DataSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['kv', 'computed', 'static', 'ai'], { message: 'Invalid data source type' }),
|
||||
type: z.enum(['kv', 'static', 'ai'], { message: 'Invalid data source type' }),
|
||||
key: z.string().optional(),
|
||||
defaultValue: z.any().optional(),
|
||||
dependencies: z.array(z.string()).optional(),
|
||||
|
||||
Reference in New Issue
Block a user