Compare commits

..

54 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
2c0dfd9e7c Convert FeatureToggleSettings to JSON-driven component
Demonstrates that components with hooks and complex logic can be JSON-driven:
- Converted 153 lines of React/TSX to JSON schema + integration code
- UI structure now in feature-toggle-settings.json schema
- Custom hook logic preserved in TypeScript for type safety
- Shows how JSON can handle loops, events, conditional styling
- Business logic separated from presentation

Co-authored-by: johndoe6345789 <224850594+johndoe6345789@users.noreply.github.com>
2026-01-18 19:22:38 +00:00
copilot-swe-agent[bot]
96744a6ab4 Initial plan 2026-01-18 18:55:54 +00:00
f181bb870a Merge pull request #201 from johndoe6345789/codex/add-json-registry-validation-script
Add JSON component registry export validation
2026-01-18 18:49:57 +00:00
05d9034366 Merge pull request #204 from johndoe6345789/codex/add-tests-for-json-components-registry
Add json-ui registry coverage test
2026-01-18 18:49:37 +00:00
29d59ec863 Add registry coverage test for json ui 2026-01-18 18:49:23 +00:00
8841b74027 Merge pull request #203 from johndoe6345789/codex/update-component-registry-paths-and-sources
Prefer explicit `load.path` for JSON components and resolve explicit module paths first
2026-01-18 18:48:59 +00:00
d351f05b14 Merge branch 'main' into codex/update-component-registry-paths-and-sources 2026-01-18 18:48:07 +00:00
85fb859131 Add explicit component paths to JSON registry 2026-01-18 18:47:07 +00:00
d408ceff79 Merge pull request #202 from johndoe6345789/codex/add-build-step-for-typescript-union-generation
Generate JSON UI component types from registry and wire into dev/build
2026-01-18 18:46:36 +00:00
b8dc6f38e6 Generate JSON UI component types 2026-01-18 18:46:02 +00:00
73959e3d48 Add registry export validation script 2026-01-18 18:45:39 +00:00
d20609ecbd Merge pull request #200 from johndoe6345789/codex/add-registry-field-for-source-mapping
Add `sourceRoots` config and build module maps from registry globs
2026-01-18 18:45:13 +00:00
4cb9c01748 Add sourceRoots config for component registry 2026-01-18 18:44:59 +00:00
862e676296 Merge pull request #199 from johndoe6345789/codex/extend-json-config-for-dynamic-loading
Load showcase configs dynamically and add per-example metadata
2026-01-18 18:44:25 +00:00
32dd4d0eac Update showcase config metadata and loading 2026-01-18 18:44:07 +00:00
b34e45067d Merge pull request #198 from johndoe6345789/codex/add-unit-tests-for-autosyncmanager-ijr1r0
Export AutoSyncManager and add unit tests
2026-01-18 18:40:05 +00:00
1a928a29dc Add AutoSyncManager unit tests 2026-01-18 18:38:22 +00:00
27dfebcb24 Merge pull request #197 from johndoe6345789/codex/refactor-computed-json-configs
Remove legacy `computed` data sources; migrate to expression/valueTemplate and update UI
2026-01-18 18:33:08 +00:00
03cc955d20 Update data source editor copy 2026-01-18 18:32:55 +00:00
8c11895fba Merge pull request #195 from johndoe6345789/codex/expand-tests-for-syncfromflaskbulk
Make syncFromFlaskBulk ignore invalid keys, merge remote data and prune local DB; add tests
2026-01-18 18:30:37 +00:00
82b64785bf Merge branch 'main' into codex/expand-tests-for-syncfromflaskbulk 2026-01-18 18:30:23 +00:00
aea8676a33 Add syncFromFlaskBulk merge tests 2026-01-18 18:29:08 +00:00
6abf9f8414 Merge pull request #194 from johndoe6345789/codex/extract-shared-action-names-to-module
Centralize store action name lookups and use Sets in middleware
2026-01-18 18:28:42 +00:00
ee7bc50881 Centralize store action name lookups 2026-01-18 18:28:20 +00:00
f186d67d20 Merge pull request #193 from johndoe6345789/codex/define-syncable_stores-and-refactor-conditions
Centralize syncable stores in syncSlice with `SYNCABLE_STORES`
2026-01-18 18:27:53 +00:00
0c375283ed Merge branch 'main' into codex/define-syncable_stores-and-refactor-conditions 2026-01-18 18:27:46 +00:00
7544c5c2e5 Define syncable store set 2026-01-18 18:27:12 +00:00
5d95bc428b Merge pull request #192 from johndoe6345789/codex/extend-json-components-registry-for-wrappers/icons
Move wrapper/icon resolution into JSON registry
2026-01-18 18:26:50 +00:00
fdd1828fda Move wrapper/icon resolution into JSON registry 2026-01-18 18:26:39 +00:00
c3a05607ba Merge pull request #191 from johndoe6345789/codex/add-tests-for-ratelimiter.throttle
RateLimiter: add bounded retry loop and throttle tests
2026-01-18 18:25:24 +00:00
6c777ed47c Merge branch 'main' into codex/add-tests-for-ratelimiter.throttle 2026-01-18 18:24:45 +00:00
ce9fcaf3d1 Add rate limiter throttle tests 2026-01-18 18:23:56 +00:00
bda28a71e4 Merge pull request #190 from johndoe6345789/codex/add-tests-for-persistencequeue-enqueuing
Ensure PersistenceQueue re-flushes operations enqueued mid-flight and add unit test
2026-01-18 18:23:36 +00:00
4eb4849d57 Merge branch 'main' into codex/add-tests-for-persistencequeue-enqueuing 2026-01-18 18:23:29 +00:00
e098b9184b Add PersistenceQueue mid-flight flush test 2026-01-18 18:22:34 +00:00
b931164c3a Merge pull request #189 from johndoe6345789/codex/modify-syncmonitormiddleware-to-check-requestid
Guard sync monitor tracking by requestId
2026-01-18 18:22:16 +00:00
7d75c6adc0 Guard sync monitor tracking by requestId 2026-01-18 18:22:00 +00:00
33e49b3671 Merge pull request #188 from johndoe6345789/codex/add-runtime-validation-for-json-responses
Validate AI JSON responses with Zod and show actionable toasts on failure
2026-01-18 18:21:40 +00:00
ace40f7e73 Add runtime validation for AI responses 2026-01-18 18:21:25 +00:00
140fe351f8 Merge pull request #187 from johndoe6345789/codex/validate-keys-and-add-reconciliation-step
Validate sync keys and reconcile local data
2026-01-18 18:21:06 +00:00
714fb510ab Validate sync keys and reconcile local data 2026-01-18 18:20:53 +00:00
9c3cc81c35 Merge pull request #186 from johndoe6345789/codex/rethrow-errors-in-synctoflask
Rethrow Flask sync errors and add retryable persistence syncs
2026-01-18 18:20:32 +00:00
def3259178 Handle Flask sync failures with retries 2026-01-18 18:20:22 +00:00
51040a23b9 Merge pull request #182 from johndoe6345789/codex/refactor-ratelimiter.throttle-for-retries
Refactor rate limiter retry loop
2026-01-18 18:19:35 +00:00
785d6afc40 Merge pull request #185 from johndoe6345789/codex/update-persistencequeue-for-proper-flushing
Fix persistence queue flush ordering
2026-01-18 18:19:11 +00:00
0a0046c2f3 Fix persistence queue flush ordering 2026-01-18 18:18:58 +00:00
a0d65352a9 Merge pull request #183 from johndoe6345789/codex/update-dynamic-component-resolution-in-registry
Refactor JSON UI component registry to dynamic resolution
2026-01-18 18:15:58 +00:00
baf5001704 Refactor JSON UI component registry 2026-01-18 18:15:46 +00:00
e075908a15 Refactor rate limiter retry loop 2026-01-18 18:15:25 +00:00
20f116d623 Merge pull request #181 from johndoe6345789/codex/add-inflight-flag-to-autosyncmanager
Add in-flight/pending guards to AutoSyncManager to avoid concurrent syncs
2026-01-18 18:15:05 +00:00
eb9174c80d Add in-flight guard to auto sync 2026-01-18 18:14:51 +00:00
cd9e65d4d2 Merge pull request #180 from johndoe6345789/codex/update-flaskbackendadapter-for-empty-responses
Guard JSON parsing for 204/empty responses in FlaskBackendAdapter
2026-01-18 18:07:25 +00:00
b646b8993f Merge branch 'main' into codex/update-flaskbackendadapter-for-empty-responses 2026-01-18 18:06:35 +00:00
f07bd37b7d Handle empty Flask responses 2026-01-18 18:05:45 +00:00
66 changed files with 2853 additions and 1534 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`)

View 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()

View File

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

View File

@@ -1,153 +1,64 @@
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { PageRenderer } from '@/lib/json-ui/page-renderer'
import { Switch } from '@/components/ui/switch'
import { FeatureToggles } from '@/types/project' import { FeatureToggles } from '@/types/project'
import { import { useMemo } from 'react'
BookOpen, import featureToggleSchema from '@/schemas/feature-toggle-settings.json'
Code, import type { PageSchema } from '@/types/json-ui'
Cube, import { evaluateExpression } from '@/lib/json-ui/expression-evaluator'
Database,
FileText,
Flask,
FlowArrow,
Image,
Lightbulb,
PaintBrush,
Play,
Tree,
Wrench,
} from '@phosphor-icons/react'
import { ScrollArea } from '@/components/ui/scroll-area'
import featureToggleSettings from '@/config/feature-toggle-settings.json'
import type { ComponentType } from 'react'
interface FeatureToggleSettingsProps { interface FeatureToggleSettingsProps {
features: FeatureToggles features: FeatureToggles
onFeaturesChange: (features: FeatureToggles) => void onFeaturesChange: (features: FeatureToggles) => void
} }
type FeatureToggleIconKey = /**
| 'BookOpen' * FeatureToggleSettings - Now JSON-driven!
| 'Code' *
| 'Cube' * This component demonstrates how a complex React component with:
| 'Database' * - Custom hooks and state management
| 'FileText' * - Dynamic data rendering (looping over features)
| 'Flask' * - Event handlers (toggle switches)
| 'FlowArrow' * - Conditional styling (enabled/disabled states)
| 'Image' *
| 'Lightbulb' * Can be converted to a pure JSON schema with custom action handlers.
| 'PaintBrush' * The JSON schema handles all UI structure, data binding, and loops,
| 'Play' * while custom functions handle business logic.
| 'Tree' *
| 'Wrench' * Converted from 153 lines of React/TSX to:
* - 1 JSON schema file (195 lines, but mostly structure)
const iconMap: Record<FeatureToggleIconKey, ComponentType<{ size?: number; weight?: 'duotone' }>> = { * - 45 lines of integration code (this file)
BookOpen, *
Code, * Benefits:
Cube, * - UI structure is now data-driven and can be modified without code changes
Database, * - Feature list is in JSON and can be easily extended
FileText, * - Styling and layout can be customized via JSON
Flask, * - Business logic (toggle handler) stays in TypeScript for type safety
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>
)
}
export function FeatureToggleSettings({ features, onFeaturesChange }: FeatureToggleSettingsProps) { export function FeatureToggleSettings({ features, onFeaturesChange }: FeatureToggleSettingsProps) {
const handleToggle = (key: keyof FeatureToggles, value: boolean) => { // 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({ onFeaturesChange({
...features, ...features,
[key]: value, [key]: checked,
}) })
} }
}), [features, onFeaturesChange])
const enabledCount = Object.values(features).filter(Boolean).length // Pass features as external data to the JSON renderer
const totalCount = Object.keys(features).length const data = useMemo(() => ({ features }), [features])
return ( return (
<div className="h-full p-6 bg-background"> <PageRenderer
<FeatureToggleHeader enabledCount={enabledCount} totalCount={totalCount} /> schema={featureToggleSchema as PageSchema}
data={data}
<ScrollArea className="h-[calc(100vh-200px)]"> functions={handlers}
<FeatureToggleGrid items={featuresList} features={features} onToggle={handleToggle} /> />
</ScrollArea>
</div>
) )
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>
</>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,15 +33,20 @@
}, },
{ {
"id": "selectedTree", "id": "selectedTree",
"type": "computed", "type": "static",
"compute": "(data) => data.trees?.find(t => t.id === data.selectedTreeId) || null", "expression": "data.trees.find(id === data.selectedTreeId)",
"dependencies": ["trees", "selectedTreeId"] "dependencies": [
"trees",
"selectedTreeId"
]
}, },
{ {
"id": "treeCount", "id": "treeCount",
"type": "computed", "type": "static",
"compute": "(data) => (data.trees || []).length", "expression": "data.trees.length",
"dependencies": ["trees"] "dependencies": [
"trees"
]
} }
], ],
"components": [ "components": [
@@ -136,74 +141,16 @@
}, },
"children": [ "children": [
{ {
"id": "empty-state", "id": "tree-selection-state",
"type": "div", "type": "div",
"props": { "conditional": {
"className": "flex-1 flex items-center justify-center" "if": "selectedTree != null",
}, "then": {
"condition": {
"source": "selectedTree",
"transform": "(val) => !val"
},
"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
}
]
}
]
}
]
}
]
},
{
"id": "tree-editor", "id": "tree-editor",
"type": "div", "type": "div",
"props": { "props": {
"className": "flex-1 p-6 overflow-auto" "className": "flex-1 p-6 overflow-auto"
}, },
"condition": {
"source": "selectedTree",
"transform": "(val) => !!val"
},
"children": [ "children": [
{ {
"id": "tree-header", "id": "tree-header",
@@ -284,6 +231,63 @@
] ]
} }
] ]
},
"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
}
]
}
]
}
]
}
]
}
}
} }
] ]
} }

View File

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

View File

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

View File

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

View File

@@ -27,15 +27,20 @@
}, },
{ {
"id": "selectedLambda", "id": "selectedLambda",
"type": "computed", "type": "static",
"compute": "(data) => data.lambdas?.find(l => l.id === data.selectedLambdaId) || null", "expression": "data.lambdas.find(id === data.selectedLambdaId)",
"dependencies": ["lambdas", "selectedLambdaId"] "dependencies": [
"lambdas",
"selectedLambdaId"
]
}, },
{ {
"id": "lambdaCount", "id": "lambdaCount",
"type": "computed", "type": "static",
"compute": "(data) => (data.lambdas || []).length", "expression": "data.lambdas.length",
"dependencies": ["lambdas"] "dependencies": [
"lambdas"
]
} }
], ],
"components": [ "components": [
@@ -71,7 +76,9 @@
"props": { "props": {
"className": "text-2xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent" "className": "text-2xl font-bold bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent"
}, },
"children": ["Lambdas"] "children": [
"Lambdas"
]
}, },
{ {
"type": "Badge", "type": "Badge",
@@ -133,7 +140,9 @@
"children": [ "children": [
{ {
"type": "text", "type": "text",
"children": ["Lambda list will be rendered here"] "children": [
"Lambda list will be rendered here"
]
} }
] ]
}, },
@@ -159,14 +168,18 @@
"props": { "props": {
"className": "text-lg font-semibold mb-2" "className": "text-lg font-semibold mb-2"
}, },
"children": ["No Lambdas Yet"] "children": [
"No Lambdas Yet"
]
}, },
{ {
"type": "p", "type": "p",
"props": { "props": {
"className": "text-sm text-muted-foreground mb-4" "className": "text-sm text-muted-foreground mb-4"
}, },
"children": ["Create your first serverless function"] "children": [
"Create your first serverless function"
]
} }
] ]
} }
@@ -182,13 +195,15 @@
}, },
"children": [ "children": [
{ {
"id": "lambda-selection-state",
"type": "div",
"conditional": {
"if": "selectedLambda != null",
"then": {
"type": "div", "type": "div",
"props": { "props": {
"className": "flex-1 flex items-center justify-center p-8" "className": "flex-1 flex items-center justify-center p-8"
}, },
"conditional": {
"if": "selectedLambda"
},
"children": [ "children": [
{ {
"type": "div", "type": "div",
@@ -237,14 +252,11 @@
} }
] ]
}, },
{ "else": {
"type": "div", "type": "div",
"props": { "props": {
"className": "flex-1 flex items-center justify-center p-8" "className": "flex-1 flex items-center justify-center p-8"
}, },
"conditional": {
"if": "!selectedLambda"
},
"children": [ "children": [
{ {
"type": "div", "type": "div",
@@ -265,21 +277,27 @@
"props": { "props": {
"className": "text-xl font-semibold mb-2" "className": "text-xl font-semibold mb-2"
}, },
"children": ["No Lambda Selected"] "children": [
"No Lambda Selected"
]
}, },
{ {
"type": "p", "type": "p",
"props": { "props": {
"className": "text-muted-foreground" "className": "text-muted-foreground"
}, },
"children": ["Select a lambda from the sidebar or create a new one"] "children": [
} "Select a lambda from the sidebar or create a new one"
] ]
} }
] ]
} }
] ]
} }
}
}
]
}
] ]
} }
] ]

View File

@@ -28,15 +28,20 @@
}, },
{ {
"id": "selectedModel", "id": "selectedModel",
"type": "computed", "type": "static",
"compute": "(data) => data.models?.find(m => m.id === data.selectedModelId) || null", "expression": "data.models.find(id === data.selectedModelId)",
"dependencies": ["models", "selectedModelId"] "dependencies": [
"models",
"selectedModelId"
]
}, },
{ {
"id": "modelCount", "id": "modelCount",
"type": "computed", "type": "static",
"compute": "(data) => (data.models || []).length", "expression": "data.models.length",
"dependencies": ["models"] "dependencies": [
"models"
]
} }
], ],
"components": [ "components": [
@@ -131,74 +136,16 @@
}, },
"children": [ "children": [
{ {
"id": "empty-state", "id": "model-selection-state",
"type": "div", "type": "div",
"props": { "conditional": {
"className": "flex-1 flex items-center justify-center" "if": "selectedModel != null",
}, "then": {
"condition": {
"source": "selectedModel",
"transform": "(val) => !val"
},
"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
}
]
}
]
}
]
}
]
},
{
"id": "model-editor", "id": "model-editor",
"type": "div", "type": "div",
"props": { "props": {
"className": "flex-1 p-6 overflow-auto" "className": "flex-1 p-6 overflow-auto"
}, },
"condition": {
"source": "selectedModel",
"transform": "(val) => !!val"
},
"children": [ "children": [
{ {
"id": "model-header", "id": "model-header",
@@ -276,6 +223,63 @@
] ]
} }
] ]
},
"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
}
]
}
]
}
]
}
]
}
}
} }
] ]
} }

View File

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

View File

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

View File

@@ -32,15 +32,20 @@
}, },
{ {
"id": "selectedWorkflow", "id": "selectedWorkflow",
"type": "computed", "type": "static",
"compute": "(data) => data.workflows?.find(w => w.id === data.selectedWorkflowId) || null", "expression": "data.workflows.find(id === data.selectedWorkflowId)",
"dependencies": ["workflows", "selectedWorkflowId"] "dependencies": [
"workflows",
"selectedWorkflowId"
]
}, },
{ {
"id": "workflowCount", "id": "workflowCount",
"type": "computed", "type": "static",
"compute": "(data) => (data.workflows || []).length", "expression": "data.workflows.length",
"dependencies": ["workflows"] "dependencies": [
"workflows"
]
} }
], ],
"components": [ "components": [
@@ -71,7 +76,9 @@
"props": { "props": {
"className": "text-xl font-bold mb-2 flex items-center gap-2" "className": "text-xl font-bold mb-2 flex items-center gap-2"
}, },
"children": ["Workflows"] "children": [
"Workflows"
]
}, },
{ {
"id": "create-button", "id": "create-button",
@@ -117,7 +124,9 @@
"props": { "props": {
"className": "text-sm text-muted-foreground" "className": "text-sm text-muted-foreground"
}, },
"children": ["Status Filter"] "children": [
"Status Filter"
]
} }
] ]
}, },
@@ -136,7 +145,9 @@
"props": { "props": {
"className": "text-center py-8 text-muted-foreground" "className": "text-center py-8 text-muted-foreground"
}, },
"children": ["No workflows yet"] "children": [
"No workflows yet"
]
} }
] ]
} }
@@ -150,59 +161,16 @@
}, },
"children": [ "children": [
{ {
"id": "empty-state", "id": "workflow-selection-state",
"type": "div", "type": "div",
"props": {
"className": "flex-1 flex items-center justify-center"
},
"conditional": { "conditional": {
"if": "!selectedWorkflow" "if": "selectedWorkflow != null",
}, "then": {
"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"]
}
]
}
]
},
{
"id": "workflow-editor", "id": "workflow-editor",
"type": "div", "type": "div",
"props": { "props": {
"className": "flex-1 p-6 overflow-auto" "className": "flex-1 p-6 overflow-auto"
}, },
"conditional": {
"if": "selectedWorkflow"
},
"children": [ "children": [
{ {
"id": "workflow-header", "id": "workflow-header",
@@ -259,13 +227,63 @@
"props": { "props": {
"className": "text-center text-muted-foreground py-12" "className": "text-center text-muted-foreground py-12"
}, },
"children": ["Workflow canvas - Add nodes to build your workflow"] "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"
]
}
]
}
]
}
}
} }
] ]
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { DataSource } from '@/types/json-ui' import { DataSource } from '@/types/json-ui'
interface UseDataSourceEditorParams { interface UseDataSourceEditorParams {
dataSource: DataSource | null dataSource: DataSource | null
allDataSources: DataSource[]
onSave: (dataSource: DataSource) => void onSave: (dataSource: DataSource) => void
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
} }
export function useDataSourceEditor({ export function useDataSourceEditor({
dataSource, dataSource,
allDataSources,
onSave, onSave,
onOpenChange, onOpenChange,
}: UseDataSourceEditorParams) { }: UseDataSourceEditorParams) {
@@ -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(() => { const handleSave = useCallback(() => {
if (!editingSource) return if (!editingSource) return
onSave(editingSource) onSave(editingSource)
onOpenChange(false) onOpenChange(false)
}, [editingSource, onOpenChange, onSave]) }, [editingSource, onOpenChange, onSave])
const availableDeps = useMemo(() => {
if (!editingSource) return []
return allDataSources.filter(
(ds) => ds.id !== editingSource.id && ds.type !== 'computed',
)
}, [allDataSources, editingSource])
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
const unselectedDeps = useMemo(() => {
if (!editingSource) return []
return availableDeps.filter((ds) => !selectedDeps.includes(ds.id))
}, [availableDeps, editingSource, selectedDeps])
return { return {
editingSource, editingSource,
updateField, updateField,
addDependency,
removeDependency,
handleSave, handleSave,
availableDeps,
selectedDeps,
unselectedDeps,
} }
} }

View File

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

View 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()
}
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
})
})

View File

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

View File

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

View File

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

View File

@@ -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"
}
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}
]
}

View File

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

48
src/store/actionNames.ts Normal file
View 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',
])

View 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)
})
})
})

View File

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

View File

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

View File

@@ -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)
})
})
})

View File

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

View File

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

View 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')
})
})

View File

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

View File

@@ -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]

View File

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

View File

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