Compare commits

...

27 Commits

Author SHA1 Message Date
52b27dd00c Load wrappers and icons from JSON registry 2026-01-18 18:16:20 +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
9f6b0bd871 Merge pull request #177 from johndoe6345789/codex/add-null-check-for-navigation-timing
Add navigation fallback for bundle performance analysis
2026-01-18 18:03:21 +00:00
1b3e0fecfe Add navigation entry fallback in metrics 2026-01-18 18:03:11 +00:00
425797d5a2 Merge pull request #176 from johndoe6345789/codex/refactor-message-handler-in-use-pwa.ts
Refactor PWA service worker message handler to enable cleanup
2026-01-18 18:02:48 +00:00
9dd5a9c6d0 refactor pwa message handler 2026-01-18 18:02:34 +00:00
adb762d0cd Merge pull request #175 from johndoe6345789/codex/add-unit-tests-for-detectandinitialize
Prefer Flask over IndexedDB in UnifiedStorage and add unit tests
2026-01-18 18:02:05 +00:00
e3e3dbf602 Add tests for unified storage initialization 2026-01-18 18:01:54 +00:00
eb8f249946 Merge pull request #174 from johndoe6345789/codex/add-test-for-flaskbackendadapter.request
Handle empty/204 Flask backend responses and add Vitest for delete/clear
2026-01-18 18:01:36 +00:00
ea21a99c8c Add test for empty Flask backend responses 2026-01-18 18:00:47 +00:00
90d392356d Merge pull request #173 from johndoe6345789/codex/create-tests-for-generateflaskblueprint
Sanitize Flask blueprint and endpoint names; add tests
2026-01-18 17:59:35 +00:00
3d7c59557d Merge branch 'main' into codex/create-tests-for-generateflaskblueprint 2026-01-18 17:59:29 +00:00
818f9878aa Add identifier-safe Flask blueprint tests 2026-01-18 17:58:47 +00:00
c618870d4b Merge pull request #172 from johndoe6345789/codex/add-helper-to-normalize-identifiers
Add sanitizeIdentifier helper and use in Flask generators
2026-01-18 17:58:27 +00:00
2641793e0f Add identifier sanitization for Flask generators 2026-01-18 17:58:01 +00:00
fb971be80b Merge pull request #171 from johndoe6345789/codex/extend-json-schema-for-component-registry
Make json-ui registry sources data-driven
2026-01-18 17:57:31 +00:00
395ab80610 Make json-ui registry sources data-driven 2026-01-18 17:57:15 +00:00
91969e8494 Merge pull request #169 from johndoe6345789/codex/remove-static-componentregistry-map
Use shared component registry for orchestration
2026-01-18 17:49:17 +00:00
a6a44f026f Use shared component registry in orchestration 2026-01-18 17:49:07 +00:00
52f1703c35 Merge pull request #168 from johndoe6345789/codex/update-schemas-to-remove-legacy-compute-functions
Replace legacy compute usage with JSON expressions
2026-01-18 17:42:12 +00:00
966b6f2aa6 Remove legacy compute schema support 2026-01-18 17:41:57 +00:00
9a8e159177 Merge pull request #167 from johndoe6345789/codex/define-json-page-prop-contract
Add JSON page data/functions prop mapping
2026-01-18 17:29:35 +00:00
174f03edd2 Add JSON page data/function prop mapping 2026-01-18 17:29:18 +00:00
42c22aa1ea Merge pull request #166 from johndoe6345789/codex/update-pageconfig-to-support-json-pages
Support JSON pages in page config (discriminator + schemaPath)
2026-01-18 17:25:40 +00:00
f6673e1b77 Add JSON page support to page configs 2026-01-18 17:25:22 +00:00
44 changed files with 1614 additions and 558 deletions

62
JSON_EXPRESSION_SYSTEM.md Normal file
View File

@@ -0,0 +1,62 @@
# JSON Expression System
This document describes the supported JSON expression patterns used across JSON UI schemas.
Legacy compute functions have been removed in favor of expression strings and value templates.
## Core Concepts
### Expressions
Expressions are string values that resolve against a data + event context:
```json
{
"expression": "event.target.value"
}
```
Supported expression patterns:
- `data` or `event`
- Dot access: `data.user.name`, `event.target.value`
- Literals: numbers, booleans, `null`, `undefined`, quoted strings
- Time: `Date.now()`
- Array filtering:
- `data.todos.filter(completed === true)`
- `data.users.filter(status === 'active').length`
### Value Templates
Value templates are JSON objects whose string values are evaluated as expressions:
```json
{
"valueTemplate": {
"id": "Date.now()",
"text": "data.newTodo",
"completed": false
}
}
```
### Conditions
Conditions use expression strings that are evaluated against the data context:
```json
{
"condition": "data.newTodo.length > 0"
}
```
Supported condition patterns:
- `data.field > 0`
- `data.field.length > 0`
- `data.field === 'value'`
- `data.field != null`
## Legacy Compute Functions (Removed)
Schemas should no longer reference function names in `compute`, `transform`, or string-based
condition fields. Use `expression` and `valueTemplate` instead.

View File

@@ -80,7 +80,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "ComponentBindingDialog"
"wrapperFor": "ComponentBindingDialog",
"loadFrom": {
"module": "wrappers",
"export": "ComponentBindingDialogWrapper"
}
},
{
"type": "Container",
@@ -122,7 +126,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "DataSourceEditorDialog"
"wrapperFor": "DataSourceEditorDialog",
"loadFrom": {
"module": "wrappers",
"export": "DataSourceEditorDialogWrapper"
}
},
{
"type": "Dialog",
@@ -717,6 +725,500 @@
"status": "supported",
"source": "atoms"
},
{
"type": "ArrowLeft",
"name": "ArrowLeft",
"category": "display",
"canHaveChildren": false,
"description": "ArrowLeft icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "ArrowLeft"
}
},
{
"type": "ArrowRight",
"name": "ArrowRight",
"category": "display",
"canHaveChildren": false,
"description": "ArrowRight icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "ArrowRight"
}
},
{
"type": "Check",
"name": "Check",
"category": "display",
"canHaveChildren": false,
"description": "Check icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Check"
}
},
{
"type": "X",
"name": "X",
"category": "display",
"canHaveChildren": false,
"description": "X icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "X"
}
},
{
"type": "Plus",
"name": "Plus",
"category": "display",
"canHaveChildren": false,
"description": "Plus icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Plus"
}
},
{
"type": "Minus",
"name": "Minus",
"category": "display",
"canHaveChildren": false,
"description": "Minus icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Minus"
}
},
{
"type": "Search",
"name": "Search",
"category": "display",
"canHaveChildren": false,
"description": "Search icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "MagnifyingGlass"
}
},
{
"type": "Filter",
"name": "Filter",
"category": "display",
"canHaveChildren": false,
"description": "Filter icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Funnel"
}
},
{
"type": "Download",
"name": "Download",
"category": "display",
"canHaveChildren": false,
"description": "Download icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Download"
}
},
{
"type": "Upload",
"name": "Upload",
"category": "display",
"canHaveChildren": false,
"description": "Upload icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Upload"
}
},
{
"type": "Edit",
"name": "Edit",
"category": "display",
"canHaveChildren": false,
"description": "Edit icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "PencilSimple"
}
},
{
"type": "Trash",
"name": "Trash",
"category": "display",
"canHaveChildren": false,
"description": "Trash icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Trash"
}
},
{
"type": "Eye",
"name": "Eye",
"category": "display",
"canHaveChildren": false,
"description": "Eye icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Eye"
}
},
{
"type": "EyeOff",
"name": "EyeOff",
"category": "display",
"canHaveChildren": false,
"description": "EyeOff icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "EyeClosed"
}
},
{
"type": "ChevronUp",
"name": "ChevronUp",
"category": "display",
"canHaveChildren": false,
"description": "ChevronUp icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "CaretUp"
}
},
{
"type": "ChevronDown",
"name": "ChevronDown",
"category": "display",
"canHaveChildren": false,
"description": "ChevronDown icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "CaretDown"
}
},
{
"type": "ChevronLeft",
"name": "ChevronLeft",
"category": "display",
"canHaveChildren": false,
"description": "ChevronLeft icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "CaretLeft"
}
},
{
"type": "ChevronRight",
"name": "ChevronRight",
"category": "display",
"canHaveChildren": false,
"description": "ChevronRight icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "CaretRight"
}
},
{
"type": "Settings",
"name": "Settings",
"category": "display",
"canHaveChildren": false,
"description": "Settings icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Gear"
}
},
{
"type": "User",
"name": "User",
"category": "display",
"canHaveChildren": false,
"description": "User icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "User"
}
},
{
"type": "Bell",
"name": "Bell",
"category": "display",
"canHaveChildren": false,
"description": "Bell icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Bell"
}
},
{
"type": "Mail",
"name": "Mail",
"category": "display",
"canHaveChildren": false,
"description": "Mail icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Envelope"
}
},
{
"type": "Calendar",
"name": "Calendar",
"category": "display",
"canHaveChildren": false,
"description": "Calendar icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Calendar"
}
},
{
"type": "Clock",
"name": "Clock",
"category": "display",
"canHaveChildren": false,
"description": "Clock icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Clock"
}
},
{
"type": "Star",
"name": "Star",
"category": "display",
"canHaveChildren": false,
"description": "Star icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Star"
}
},
{
"type": "Heart",
"name": "Heart",
"category": "display",
"canHaveChildren": false,
"description": "Heart icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Heart"
}
},
{
"type": "Share",
"name": "Share",
"category": "display",
"canHaveChildren": false,
"description": "Share icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "ShareNetwork"
}
},
{
"type": "Link",
"name": "Link",
"category": "display",
"canHaveChildren": false,
"description": "Link icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "LinkSimple"
}
},
{
"type": "Copy",
"name": "Copy",
"category": "display",
"canHaveChildren": false,
"description": "Copy icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Copy"
}
},
{
"type": "Save",
"name": "Save",
"category": "display",
"canHaveChildren": false,
"description": "Save icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "FloppyDisk"
}
},
{
"type": "RefreshCw",
"name": "RefreshCw",
"category": "display",
"canHaveChildren": false,
"description": "RefreshCw icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "ArrowClockwise"
}
},
{
"type": "AlertCircle",
"name": "AlertCircle",
"category": "display",
"canHaveChildren": false,
"description": "AlertCircle icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "WarningCircle"
}
},
{
"type": "Info",
"name": "Info",
"category": "display",
"canHaveChildren": false,
"description": "Info icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Info"
}
},
{
"type": "HelpCircle",
"name": "HelpCircle",
"category": "display",
"canHaveChildren": false,
"description": "HelpCircle icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "Question"
}
},
{
"type": "Home",
"name": "Home",
"category": "display",
"canHaveChildren": false,
"description": "Home icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "House"
}
},
{
"type": "Menu",
"name": "Menu",
"category": "display",
"canHaveChildren": false,
"description": "Menu icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "List"
}
},
{
"type": "MoreVertical",
"name": "MoreVertical",
"category": "display",
"canHaveChildren": false,
"description": "MoreVertical icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "DotsThreeVertical"
}
},
{
"type": "MoreHorizontal",
"name": "MoreHorizontal",
"category": "display",
"canHaveChildren": false,
"description": "MoreHorizontal icon",
"status": "supported",
"source": "icons",
"loadFrom": {
"module": "icons",
"export": "DotsThree"
}
},
{
"type": "Breadcrumb",
"name": "Breadcrumb",
@@ -933,7 +1435,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "GitHubBuildStatus"
"wrapperFor": "GitHubBuildStatus",
"loadFrom": {
"module": "wrappers",
"export": "GitHubBuildStatusWrapper"
}
},
{
"type": "InfoBox",
@@ -1095,7 +1601,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyBarChart"
"wrapperFor": "LazyBarChart",
"loadFrom": {
"module": "wrappers",
"export": "LazyBarChartWrapper"
}
},
{
"type": "LazyD3BarChart",
@@ -1118,7 +1628,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyD3BarChart"
"wrapperFor": "LazyD3BarChart",
"loadFrom": {
"module": "wrappers",
"export": "LazyD3BarChartWrapper"
}
},
{
"type": "LazyLineChart",
@@ -1141,7 +1655,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "LazyLineChart"
"wrapperFor": "LazyLineChart",
"loadFrom": {
"module": "wrappers",
"export": "LazyLineChartWrapper"
}
},
{
"type": "List",
@@ -1200,7 +1718,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "SeedDataManager"
"wrapperFor": "SeedDataManager",
"loadFrom": {
"module": "wrappers",
"export": "SeedDataManagerWrapper"
}
},
{
"type": "StatCard",
@@ -1487,7 +2009,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "ComponentTree"
"wrapperFor": "ComponentTree",
"loadFrom": {
"module": "wrappers",
"export": "ComponentTreeWrapper"
}
},
{
"type": "ComponentTreeNode",
@@ -1730,7 +2256,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "SaveIndicator"
"wrapperFor": "SaveIndicator",
"loadFrom": {
"module": "wrappers",
"export": "SaveIndicatorWrapper"
}
},
{
"type": "SchemaEditorCanvas",
@@ -1894,7 +2424,11 @@
"status": "json-compatible",
"source": "wrappers",
"jsonCompatible": true,
"wrapperFor": "StorageSettings"
"wrapperFor": "StorageSettings",
"loadFrom": {
"module": "wrappers",
"export": "StorageSettingsWrapper"
}
},
{
"type": "Timestamp",
@@ -1926,25 +2460,27 @@
}
],
"statistics": {
"total": 222,
"supported": 209,
"total": 239,
"supported": 226,
"planned": 0,
"jsonCompatible": 13,
"jsonCompatible": 50,
"maybeJsonCompatible": 0,
"byCategory": {
"layout": 25,
"input": 34,
"display": 31,
"navigation": 15,
"feedback": 23,
"data": 25,
"custom": 69
"layout": 24,
"input": 26,
"display": 64,
"navigation": 12,
"feedback": 21,
"data": 27,
"custom": 65
},
"bySource": {
"atoms": 117,
"molecules": 40,
"organisms": 15,
"ui": 50
"molecules": 36,
"organisms": 13,
"ui": 25,
"wrappers": 10,
"icons": 38
}
}
}

View File

@@ -0,0 +1,114 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "JSON Components Registry",
"type": "object",
"required": ["version", "description", "components"],
"properties": {
"$schema": {
"type": "string"
},
"version": {
"type": "string"
},
"description": {
"type": "string"
},
"lastUpdated": {
"type": "string"
},
"categories": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"components": {
"type": "array",
"items": {
"type": "object",
"required": [
"type",
"name",
"category",
"canHaveChildren",
"description",
"status",
"source"
],
"properties": {
"type": {
"type": "string"
},
"name": {
"type": "string"
},
"export": {
"type": "string"
},
"category": {
"type": "string"
},
"canHaveChildren": {
"type": "boolean"
},
"description": {
"type": "string"
},
"status": {
"type": "string"
},
"source": {
"type": "string",
"enum": ["atoms", "molecules", "organisms", "ui", "wrappers", "icons"]
},
"jsonCompatible": {
"type": "boolean"
},
"wrapperRequired": {
"type": "boolean"
},
"wrapperComponent": {
"type": "string"
},
"wrapperFor": {
"type": "string"
},
"loadFrom": {
"type": "object",
"properties": {
"module": {
"type": "string"
},
"export": {
"type": "string"
}
},
"additionalProperties": false
},
"deprecated": {
"type": "object",
"properties": {
"replacedBy": {
"type": "string"
},
"message": {
"type": "string"
}
},
"additionalProperties": false
},
"metadata": {
"type": "object",
"additionalProperties": true
}
},
"additionalProperties": true
}
},
"statistics": {
"type": "object",
"additionalProperties": true
}
},
"additionalProperties": true
}

View File

@@ -7,30 +7,9 @@ import { ComponentBindingsCard } from '@/components/data-binding-designer/Compon
import { HowItWorksCard } from '@/components/data-binding-designer/HowItWorksCard'
import dataBindingCopy from '@/data/data-binding-designer.json'
interface SeedDataSource extends Omit<DataSource, 'compute'> {
computeId?: string
}
const computeRegistry: Record<string, (data: Record<string, any>) => any> = {
displayName: (data) => `Welcome, ${data.userProfile?.name || 'Guest'}!`,
}
const buildSeedDataSources = (sources: SeedDataSource[]): DataSource[] => {
return sources.map((source) => {
if (source.type === 'computed' && source.computeId) {
return {
...source,
compute: computeRegistry[source.computeId],
}
}
return source
})
}
export function DataBindingDesigner() {
const [dataSources, setDataSources] = useState<DataSource[]>(
buildSeedDataSources(dataBindingCopy.seed.dataSources as SeedDataSource[]),
dataBindingCopy.seed.dataSources as DataSource[],
)
const [mockComponents] = useState<UIComponent[]>(dataBindingCopy.seed.components)

View File

@@ -4,9 +4,11 @@ import { useSchemaLoader } from '@/hooks/use-schema-loader'
interface JSONSchemaPageLoaderProps {
schemaPath: string
data?: Record<string, any>
functions?: Record<string, any>
}
export function JSONSchemaPageLoader({ schemaPath }: JSONSchemaPageLoaderProps) {
export function JSONSchemaPageLoader({ schemaPath, data, functions }: JSONSchemaPageLoaderProps) {
const { schema, loading, error } = useSchemaLoader(schemaPath)
if (loading) {
@@ -21,5 +23,5 @@ export function JSONSchemaPageLoader({ schemaPath }: JSONSchemaPageLoaderProps)
)
}
return <PageRenderer schema={schema} />
return <PageRenderer schema={schema} data={data} functions={functions} />
}

View File

@@ -6,9 +6,12 @@ import { DataSource } from '@/types/json-ui'
import { X } from '@phosphor-icons/react'
interface ComputedSourceFieldsCopy {
computeLabel: string
computePlaceholder: string
computeHelp: string
expressionLabel: string
expressionPlaceholder: string
expressionHelp: string
valueTemplateLabel: string
valueTemplatePlaceholder: string
valueTemplateHelp: string
dependenciesLabel: string
availableSourcesLabel: string
emptyDependencies: string
@@ -38,22 +41,37 @@ export function ComputedSourceFields({
return (
<>
<div className="space-y-2">
<Label>{copy.computeLabel}</Label>
<Label>{copy.expressionLabel}</Label>
<Textarea
value={editingSource.compute?.toString() || ''}
value={editingSource.expression || ''}
onChange={(e) => {
try {
const fn = new Function('data', `return (${e.target.value})`)()
onUpdateField('compute', fn)
} catch (err) {
// Invalid function
}
onUpdateField('expression', e.target.value)
}}
placeholder={copy.computePlaceholder}
placeholder={copy.expressionPlaceholder}
className="font-mono text-sm h-24"
/>
<p className="text-xs text-muted-foreground">
{copy.computeHelp}
{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>

View File

@@ -245,6 +245,17 @@
"type": "single"
}
},
{
"id": "json-ui-schema",
"title": "JSON UI (Schema)",
"description": "Render JSON UI from a schema file",
"icon": "Code",
"type": "json",
"schemaPath": "json-ui-page.json",
"layout": {
"type": "single"
}
},
{
"id": "sass",
"title": "Sass Styles",

View File

@@ -1,69 +1,5 @@
import { ComponentType } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { ProjectDashboard } from '@/components/ProjectDashboard'
import { CodeEditor } from '@/components/CodeEditor'
import { JSONModelDesigner } from '@/components/JSONModelDesigner'
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
import { JSONComponentTreeManager } from '@/components/JSONComponentTreeManager'
import { JSONWorkflowDesigner } from '@/components/JSONWorkflowDesigner'
import { JSONLambdaDesigner } from '@/components/JSONLambdaDesigner'
import { JSONStyleDesigner } from '@/components/JSONStyleDesigner'
import { FileExplorer } from '@/components/FileExplorer'
import { PlaywrightDesigner } from '@/components/PlaywrightDesigner'
import { StorybookDesigner } from '@/components/StorybookDesigner'
import { UnitTestDesigner } from '@/components/UnitTestDesigner'
import { JSONFlaskDesigner } from '@/components/JSONFlaskDesigner'
import { ProjectSettingsDesigner } from '@/components/ProjectSettingsDesigner'
import { ErrorPanel } from '@/components/ErrorPanel'
import { DocumentationView } from '@/components/DocumentationView'
import { SassStylesShowcase } from '@/components/SassStylesShowcase'
import { FeatureToggleSettings } from '@/components/FeatureToggleSettings'
import { PWASettings } from '@/components/PWASettings'
import { FaviconDesigner } from '@/components/FaviconDesigner'
import { FeatureIdeaCloud } from '@/components/FeatureIdeaCloud'
import { JSONUIShowcase } from '@/components/JSONUIShowcase'
import { JSONConversionShowcase } from '@/components/JSONConversionShowcase'
export const ComponentRegistry: Record<string, ComponentType<any>> = {
Button,
Input,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Badge,
Textarea,
ProjectDashboard,
CodeEditor,
JSONModelDesigner,
ComponentTreeBuilder,
JSONComponentTreeManager,
JSONWorkflowDesigner,
JSONLambdaDesigner,
JSONStyleDesigner,
FileExplorer,
PlaywrightDesigner,
StorybookDesigner,
UnitTestDesigner,
JSONFlaskDesigner,
ProjectSettingsDesigner,
ErrorPanel,
DocumentationView,
SassStylesShowcase,
FeatureToggleSettings,
PWASettings,
FaviconDesigner,
FeatureIdeaCloud,
JSONUIShowcase,
JSONConversionShowcase,
}
import { ComponentRegistry } from '@/lib/component-registry'
export function getComponent(name: string): ComponentType<any> | null {
return ComponentRegistry[name] || null

View File

@@ -3,8 +3,22 @@ import { PageSchema } from '@/types/json-ui'
import { FeatureToggles } from '@/types/project'
export interface PropConfig {
/**
* Component page prop bindings (map to stateContext).
*/
state?: string[]
/**
* Component page action bindings (map to actionContext).
*/
actions?: string[]
/**
* JSON page data bindings (map to stateContext).
*/
data?: string[]
/**
* JSON page function bindings (map to actionContext).
*/
functions?: string[]
}
export interface ResizableConfig {
@@ -20,14 +34,10 @@ export interface ResizableConfig {
}
}
export interface PageConfig {
export interface BasePageConfig {
id: string
title: string
icon: string
component?: string
type?: 'json' | 'component'
schemaPath?: string
schema?: PageSchema
enabled: boolean
isRoot?: boolean
toggleKey?: string
@@ -38,6 +48,22 @@ export interface PageConfig {
resizableConfig?: ResizableConfig
}
export interface ComponentPageConfig extends BasePageConfig {
type?: 'component'
component: string
schemaPath?: undefined
schema?: undefined
}
export interface JsonPageConfig extends BasePageConfig {
type: 'json'
component?: undefined
schemaPath?: string
schema?: PageSchema
}
export type PageConfig = ComponentPageConfig | JsonPageConfig
export interface PagesConfig {
pages: PageConfig[]
}
@@ -107,44 +133,39 @@ export function resolveProps(propConfig: PropConfig | undefined, stateContext: R
const resolvedProps: Record<string, any> = {}
const resolveEntries = (
entries: string[] | undefined,
context: Record<string, any>,
label: string
) => {
if (!entries?.length) {
return
}
console.log('[CONFIG] 📦 Resolving', entries.length, label)
for (const entry of entries) {
try {
const [propName, contextKey] = entry.includes(':')
? entry.split(':')
: [entry, entry]
if (context[contextKey] !== undefined) {
resolvedProps[propName] = context[contextKey]
console.log('[CONFIG] ✅ Resolved', label, 'prop:', propName)
} else {
console.log('[CONFIG] ⚠️', label, 'prop not found:', contextKey)
}
} catch (err) {
console.warn('[CONFIG] ❌ Failed to resolve', label, 'prop:', entry, err)
}
}
}
try {
if (propConfig.state) {
console.log('[CONFIG] 📦 Resolving', propConfig.state.length, 'state props')
for (const stateKey of propConfig.state) {
try {
const [propName, contextKey] = stateKey.includes(':')
? stateKey.split(':')
: [stateKey, stateKey]
if (stateContext[contextKey] !== undefined) {
resolvedProps[propName] = stateContext[contextKey]
console.log('[CONFIG] ✅ Resolved state prop:', propName)
} else {
console.log('[CONFIG] ⚠️ State prop not found:', contextKey)
}
} catch (err) {
console.warn('[CONFIG] ❌ Failed to resolve state prop:', stateKey, err)
}
}
}
if (propConfig.actions) {
console.log('[CONFIG] 🎬 Resolving', propConfig.actions.length, 'action props')
for (const actionKey of propConfig.actions) {
try {
const [propName, contextKey] = actionKey.split(':')
if (actionContext[contextKey]) {
resolvedProps[propName] = actionContext[contextKey]
console.log('[CONFIG] ✅ Resolved action prop:', propName)
} else {
console.log('[CONFIG] ⚠️ Action prop not found:', contextKey)
}
} catch (err) {
console.warn('[CONFIG] ❌ Failed to resolve action prop:', actionKey, err)
}
}
}
resolveEntries(propConfig.state, stateContext, 'state')
resolveEntries(propConfig.data, stateContext, 'data')
resolveEntries(propConfig.actions, actionContext, 'action')
resolveEntries(propConfig.functions, actionContext, 'function')
} catch (err) {
console.error('[CONFIG] ❌ Failed to resolve props:', err)
}

View File

@@ -18,11 +18,10 @@ export const ResizableConfigSchema = z.object({
rightPanel: ResizablePanelConfigSchema,
})
export const SimplePageConfigSchema = z.object({
const SimplePageConfigBaseSchema = z.object({
id: z.string(),
title: z.string(),
icon: z.string(),
component: z.string(),
enabled: z.boolean(),
toggleKey: z.string().optional(),
shortcut: z.string().optional(),
@@ -32,6 +31,21 @@ export const SimplePageConfigSchema = z.object({
resizableConfig: ResizableConfigSchema.optional(),
})
const SimpleComponentPageConfigSchema = SimplePageConfigBaseSchema.extend({
type: z.literal('component').optional(),
component: z.string(),
})
const SimpleJsonPageConfigSchema = SimplePageConfigBaseSchema.extend({
type: z.literal('json'),
schemaPath: z.string(),
})
export const SimplePageConfigSchema = z.union([
SimpleComponentPageConfigSchema,
SimpleJsonPageConfigSchema,
])
export const SimplePagesConfigSchema = z.object({
pages: z.array(SimplePageConfigSchema),
})
@@ -65,18 +79,32 @@ export const FeatureConfigSchema = z.object({
config: z.record(z.string(), z.any()).optional(),
})
export const PageConfigSchema = z.object({
const PageConfigBaseSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string(),
icon: z.string(),
component: z.string(),
layout: LayoutConfigSchema,
features: z.array(FeatureConfigSchema).optional(),
permissions: z.array(z.string()).optional(),
shortcuts: z.array(KeyboardShortcutSchema).optional(),
})
const ComponentPageConfigSchema = PageConfigBaseSchema.extend({
type: z.literal('component').optional(),
component: z.string(),
})
const JsonPageConfigSchema = PageConfigBaseSchema.extend({
type: z.literal('json'),
schemaPath: z.string(),
})
export const PageConfigSchema = z.union([
ComponentPageConfigSchema,
JsonPageConfigSchema,
])
export const PageRegistrySchema = z.object({
pages: z.array(PageConfigSchema),
})

View File

@@ -365,6 +365,16 @@
"order": 22,
"props": {}
},
{
"id": "json-ui-schema",
"title": "JSON UI (Schema)",
"icon": "Code",
"type": "json",
"schemaPath": "json-ui-page.json",
"enabled": true,
"order": 22.2,
"props": {}
},
{
"id": "json-conversion-showcase",
"title": "JSON Conversion Showcase",

View File

@@ -153,44 +153,53 @@ export function validatePageConfig(): ValidationError[] {
}
if (page.props) {
if (page.props.state) {
page.props.state.forEach(stateKey => {
const [, contextKey] = stateKey.includes(':')
? stateKey.split(':')
const validateStateKeys = (keys: string[] | undefined, field: string) => {
if (!keys) return
keys.forEach(stateKey => {
const [, contextKey] = stateKey.includes(':')
? stateKey.split(':')
: [stateKey, stateKey]
if (!validStateKeys.includes(contextKey)) {
errors.push({
page: page.id || 'Unknown',
field: 'props.state',
field,
message: `Unknown state key: ${contextKey}. Valid keys: ${validStateKeys.join(', ')}`,
severity: 'error',
})
}
})
}
if (page.props.actions) {
page.props.actions.forEach(actionKey => {
const [, contextKey] = actionKey.split(':')
const validateActionKeys = (keys: string[] | undefined, field: string) => {
if (!keys) return
keys.forEach(actionKey => {
const [, contextKey] = actionKey.includes(':')
? actionKey.split(':')
: [actionKey, actionKey]
if (!contextKey) {
errors.push({
page: page.id || 'Unknown',
field: 'props.actions',
field,
message: `Action key must use format "propName:functionName". Got: ${actionKey}`,
severity: 'error',
})
} else if (!validActionKeys.includes(contextKey)) {
errors.push({
page: page.id || 'Unknown',
field: 'props.actions',
field,
message: `Unknown action key: ${contextKey}. Valid keys: ${validActionKeys.join(', ')}`,
severity: 'error',
})
}
})
}
validateStateKeys(page.props.state, 'props.state')
validateActionKeys(page.props.actions, 'props.actions')
validateStateKeys(page.props.data, 'props.data')
validateActionKeys(page.props.functions, 'props.functions')
}
if (page.requiresResizable) {

View File

@@ -38,7 +38,7 @@
"id": "displayName",
"type": "computed",
"dependencies": ["userProfile"],
"computeId": "displayName"
"expression": "data.userProfile.name"
}
],
"components": [

View File

@@ -19,9 +19,12 @@
"valuePlaceholder": "{\"key\": \"value\"}"
},
"computed": {
"computeLabel": "Compute Function",
"computePlaceholder": "(data) => data.source1 + data.source2",
"computeHelp": "Function that computes the value from other data sources",
"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."

View File

@@ -9,7 +9,7 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
id: `ds-${Date.now()}`,
type,
...(type === 'kv' && { key: '', defaultValue: null }),
...(type === 'computed' && { compute: () => null, dependencies: [] }),
...(type === 'computed' && { expression: '', dependencies: [] }),
...(type === 'static' && { defaultValue: null }),
}

View File

@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'
import { useKV } from '@/hooks/use-kv'
import { DataSource } from '@/types/json-ui'
import { setNestedValue } from '@/lib/json-ui/utils'
import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function useDataSources(dataSources: DataSource[]) {
const [data, setData] = useState<Record<string, any>>({})
@@ -43,14 +44,17 @@ export function useDataSources(dataSources: DataSource[]) {
const computedSources = dataSources.filter(ds => ds.type === 'computed')
computedSources.forEach(source => {
if (source.compute) {
const deps = source.dependencies || []
const hasAllDeps = deps.every(dep => dep in data)
if (hasAllDeps) {
const computedValue = source.compute(data)
setData(prev => ({ ...prev, [source.id]: computedValue }))
}
const deps = source.dependencies || []
const hasAllDeps = deps.every(dep => dep in data)
if (hasAllDeps) {
const evaluationContext = { data }
const computedValue = source.expression
? evaluateExpression(source.expression, evaluationContext)
: source.valueTemplate
? evaluateTemplate(source.valueTemplate, evaluationContext)
: source.defaultValue
setData(prev => ({ ...prev, [source.id]: computedValue }))
}
})
}, [data, dataSources])

View File

@@ -2,6 +2,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react'
import { DataSource } from '@/types/json-ui'
import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function useDataSources(dataSources: DataSource[]) {
const [data, setData] = useState<Record<string, any>>({})
@@ -54,8 +55,17 @@ export function useDataSources(dataSources: DataSource[]) {
const result: Record<string, any> = {}
computedSources.forEach((ds) => {
if (ds.compute && typeof ds.compute === 'function') {
result[ds.id] = ds.compute(data)
const evaluationContext = { data }
if (ds.expression) {
result[ds.id] = evaluateExpression(ds.expression, evaluationContext)
return
}
if (ds.valueTemplate) {
result[ds.id] = evaluateTemplate(ds.valueTemplate, evaluationContext)
return
}
if (ds.defaultValue !== undefined) {
result[ds.id] = ds.defaultValue
}
})

View File

@@ -7,6 +7,7 @@ import { useWorkflows } from '../data/use-workflows'
import { useLambdas } from '../data/use-lambdas'
import { useActions } from './use-actions'
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
export function usePage(schema: PageSchema) {
const files = useFiles()
@@ -46,11 +47,15 @@ export function usePage(schema: PageSchema) {
const computed: Record<string, any> = {}
schema.data.forEach(source => {
if (source.type === 'computed' && source.compute) {
computed[source.id] = evaluateBindingExpression(source.compute, dataContext, {
fallback: undefined,
label: `computed data (${source.id})`,
})
if (source.type === 'computed') {
if (source.expression) {
computed[source.id] = evaluateBindingExpression(source.expression, dataContext, {
fallback: undefined,
label: `computed data (${source.id})`,
})
} else if (source.valueTemplate) {
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: dataContext })
}
} else if (source.type === 'static' && source.defaultValue !== undefined) {
computed[source.id] = source.defaultValue
}

View File

@@ -56,10 +56,7 @@ export function useActionExecutor(context: JSONUIContext) {
const currentData = data[action.target] || []
let newValue
if (action.compute) {
// Legacy: compute function
newValue = action.compute(data, event)
} else if (action.expression) {
if (action.expression) {
// New: JSON expression
newValue = evaluateExpression(action.expression, evaluationContext)
} else if (action.valueTemplate) {
@@ -79,9 +76,7 @@ export function useActionExecutor(context: JSONUIContext) {
if (!targetParts) return
let newValue
if (action.compute) {
newValue = action.compute(data, event)
} else if (action.expression) {
if (action.expression) {
newValue = evaluateExpression(action.expression, evaluationContext)
} else if (action.valueTemplate) {
newValue = evaluateTemplate(action.valueTemplate, evaluationContext)
@@ -102,9 +97,7 @@ export function useActionExecutor(context: JSONUIContext) {
const currentData = data[action.target] || []
let selectorValue
if (action.compute) {
selectorValue = action.compute(data, event)
} else if (action.expression) {
if (action.expression) {
selectorValue = evaluateExpression(action.expression, evaluationContext)
} else if (action.valueTemplate) {
selectorValue = evaluateTemplate(action.valueTemplate, evaluationContext)
@@ -129,9 +122,7 @@ export function useActionExecutor(context: JSONUIContext) {
if (!targetParts) return
let newValue
if (action.compute) {
newValue = action.compute(data, event)
} else if (action.expression) {
if (action.expression) {
newValue = evaluateExpression(action.expression, evaluationContext)
} else if (action.valueTemplate) {
newValue = evaluateTemplate(action.valueTemplate, evaluationContext)

View File

@@ -69,6 +69,12 @@ export function usePWA() {
setState(prev => ({ ...prev, isOnline: false }))
}
const handleServiceWorkerMessage = (event: MessageEvent) => {
if (event.data && event.data.type === 'CACHE_CLEARED') {
window.location.reload()
}
}
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
window.addEventListener('online', handleOnline)
@@ -96,11 +102,7 @@ export function usePWA() {
console.error('[PWA] Service Worker registration failed:', error)
})
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'CACHE_CLEARED') {
window.location.reload()
}
})
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
}
return () => {
@@ -108,6 +110,9 @@ export function usePWA() {
window.removeEventListener('appinstalled', handleAppInstalled)
window.removeEventListener('online', handleOnline)
window.removeEventListener('offline', handleOffline)
if ('serviceWorker' in navigator) {
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage)
}
}
}, [])

View File

@@ -87,7 +87,12 @@ export function analyzePerformance() {
return null
}
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
const navigation = performance.getEntriesByType('navigation')[0] as
| PerformanceNavigationTiming
| undefined
if (!navigation) {
console.warn('[BUNDLE] ⚠️ Navigation performance entry not available')
}
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const jsResources = resources.filter(r => r.name.endsWith('.js'))
@@ -97,9 +102,11 @@ export function analyzePerformance() {
const totalCssSize = cssResources.reduce((sum, r) => sum + (r.transferSize || 0), 0)
const analysis = {
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart,
loadComplete: navigation.loadEventEnd - navigation.fetchStart,
ttfb: navigation.responseStart - navigation.fetchStart,
domContentLoaded: navigation
? navigation.domContentLoadedEventEnd - navigation.fetchStart
: NaN,
loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : NaN,
ttfb: navigation ? navigation.responseStart - navigation.fetchStart : NaN,
resources: {
js: {
count: jsResources.length,

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import type { FlaskBlueprint } from '@/types/project'
import { generateFlaskBlueprint } from '../generateFlaskBlueprint'
const isValidIdentifier = (name: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)
const extractBlueprintVariable = (code: string): { variable: string; name: string } => {
const match = code.match(/^([A-Za-z_][A-Za-z0-9_]*)_bp = Blueprint\('([^']+)'/m)
if (!match) {
throw new Error('Blueprint definition not found.')
}
return { variable: `${match[1]}_bp`, name: match[2] }
}
const extractFunctionNames = (code: string): string[] => {
return Array.from(code.matchAll(/^def ([A-Za-z_][A-Za-z0-9_]*)\(\):/gm)).map(match => match[1])
}
const extractDecoratorBlueprints = (code: string): string[] => {
return Array.from(code.matchAll(/^@([A-Za-z_][A-Za-z0-9_]*)\.route/gm)).map(match => match[1])
}
describe('generateFlaskBlueprint identifier sanitization', () => {
it('creates valid, consistent identifiers for tricky endpoint names', () => {
const blueprint: FlaskBlueprint = {
id: 'bp-1',
name: 'User Auth',
urlPrefix: '/auth',
description: 'Auth endpoints',
endpoints: [
{
id: 'ep-1',
name: 'get-user',
description: 'Fetch a user',
method: 'GET',
path: '/user'
},
{
id: 'ep-2',
name: '2fa',
description: 'Two factor auth',
method: 'POST',
path: '/2fa'
},
{
id: 'ep-3',
name: 'user.v1',
description: 'User v1 endpoint',
method: 'GET',
path: '/user/v1'
}
]
}
const code = generateFlaskBlueprint(blueprint)
const blueprintDefinition = extractBlueprintVariable(code)
const functionNames = extractFunctionNames(code)
const decoratorBlueprints = extractDecoratorBlueprints(code)
expect(isValidIdentifier(blueprintDefinition.name)).toBe(true)
expect(isValidIdentifier(blueprintDefinition.variable)).toBe(true)
expect(blueprintDefinition.variable).toBe('user_auth_bp')
expect(blueprintDefinition.name).toBe('user_auth')
expect(new Set(decoratorBlueprints)).toEqual(new Set([blueprintDefinition.variable]))
expect(functionNames).toEqual(['get_user', '_2fa', 'user_v1'])
functionNames.forEach(name => {
expect(isValidIdentifier(name)).toBe(true)
})
})
})

View File

@@ -1,5 +1,6 @@
import { FlaskConfig } from '@/types/project'
import { generateFlaskBlueprint } from './generateFlaskBlueprint'
import { sanitizeIdentifier } from './sanitizeIdentifier'
export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
const files: Record<string, string> = {}
@@ -11,7 +12,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
appCode += `\n`
config.blueprints.forEach(blueprint => {
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
appCode += `from blueprints.${blueprintVarName} import ${blueprintVarName}_bp\n`
})
@@ -34,7 +35,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
}
config.blueprints.forEach(blueprint => {
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
appCode += ` app.register_blueprint(${blueprintVarName}_bp)\n`
})
@@ -50,7 +51,7 @@ export function generateFlaskApp(config: FlaskConfig): Record<string, string> {
files['app.py'] = appCode
config.blueprints.forEach(blueprint => {
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
files[`blueprints/${blueprintVarName}.py`] = generateFlaskBlueprint(blueprint)
})

View File

@@ -1,14 +1,28 @@
import { FlaskBlueprint } from '@/types/project'
import { sanitizeIdentifier } from './sanitizeIdentifier'
function toPythonIdentifier(value: string, fallback: string): string {
const normalized = value
.toLowerCase()
.replace(/[^a-z0-9_]/g, '_')
.replace(/_+/g, '_')
.replace(/^_+|_+$/g, '')
let safe = normalized || fallback
if (/^[0-9]/.test(safe)) {
safe = `_${safe}`
}
return safe
}
export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string {
let code = `from flask import Blueprint, request, jsonify\n`
code += `from typing import Dict, Any\n\n`
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
const blueprintVarName = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
code += `${blueprintVarName}_bp = Blueprint('${blueprintVarName}', __name__, url_prefix='${blueprint.urlPrefix}')\n\n`
blueprint.endpoints.forEach(endpoint => {
const functionName = endpoint.name.toLowerCase().replace(/\s+/g, '_')
const functionName = sanitizeIdentifier(endpoint.name, { fallback: 'endpoint' })
code += `@${blueprintVarName}_bp.route('${endpoint.path}', methods=['${endpoint.method}'])\n`
code += `def ${functionName}():\n`
code += ` """\n`
@@ -31,13 +45,14 @@ export function generateFlaskBlueprint(blueprint: FlaskBlueprint): string {
if (endpoint.queryParams && endpoint.queryParams.length > 0) {
endpoint.queryParams.forEach(param => {
const paramVarName = sanitizeIdentifier(param.name, { fallback: 'param' })
if (param.required) {
code += ` ${param.name} = request.args.get('${param.name}')\n`
code += ` if ${param.name} is None:\n`
code += ` ${paramVarName} = request.args.get('${param.name}')\n`
code += ` if ${paramVarName} is None:\n`
code += ` return jsonify({'error': '${param.name} is required'}), 400\n\n`
} else {
const defaultVal = param.defaultValue || (param.type === 'string' ? "''" : param.type === 'number' ? '0' : 'None')
code += ` ${param.name} = request.args.get('${param.name}', ${defaultVal})\n`
code += ` ${paramVarName} = request.args.get('${param.name}', ${defaultVal})\n`
}
})
code += `\n`

View File

@@ -0,0 +1,23 @@
type SanitizeIdentifierOptions = {
fallback?: string
}
export function sanitizeIdentifier(value: string, options: SanitizeIdentifierOptions = {}): string {
const fallback = options.fallback ?? 'identifier'
const trimmed = value.trim()
const normalized = trimmed
.toLowerCase()
.replace(/[^a-z0-9_]+/g, '_')
.replace(/^_+|_+$/g, '')
.replace(/_+/g, '_')
if (!normalized) {
return fallback
}
if (/^[0-9]/.test(normalized)) {
return `_${normalized}`
}
return normalized
}

View File

@@ -1,43 +1,45 @@
import { ComponentType } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { InputOtp } from '@/components/ui/input-otp'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertDialog } from '@/components/ui/alert-dialog'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { Carousel } from '@/components/ui/carousel'
import { ChartContainer as Chart } from '@/components/ui/chart'
import { Collapsible } from '@/components/ui/collapsible'
import { Command } from '@/components/ui/command'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu } from '@/components/ui/dropdown-menu'
import { Menubar } from '@/components/ui/menubar'
import { NavigationMenu } from '@/components/ui/navigation-menu'
import { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Pagination } from '@/components/ui/pagination'
import { ResizablePanelGroup as Resizable } from '@/components/ui/resizable'
import { Sheet } from '@/components/ui/sheet'
import { Sidebar } from '@/components/ui/sidebar'
import { Toaster as Sonner } from '@/components/ui/sonner'
import { ToggleGroup } from '@/components/ui/toggle-group'
import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { CircularProgress, Divider, ProgressBar } from '@/components/atoms'
import * as AtomComponents from '@/components/atoms'
import * as MoleculeComponents from '@/components/molecules'
import * as OrganismComponents from '@/components/organisms'
import {
LazyBarChartWrapper,
LazyD3BarChartWrapper,
LazyLineChartWrapper,
SaveIndicatorWrapper,
SeedDataManagerWrapper,
StorageSettingsWrapper,
} from '@/lib/json-ui/wrappers'
import * as WrapperComponents from '@/lib/json-ui/wrappers'
import jsonComponentsRegistry from '../../../json-components-registry.json'
import {
ArrowLeft, ArrowRight, Check, X, Plus, Minus, MagnifyingGlass,
Funnel, Download, Upload, PencilSimple, Trash, Eye, EyeClosed,
CaretUp, CaretDown, CaretLeft, CaretRight,
Gear, User, Bell, Envelope, Calendar, Clock, Star,
Heart, ShareNetwork, LinkSimple, Copy, FloppyDisk, ArrowClockwise, WarningCircle,
Info, Question, House, List as ListIcon, DotsThreeVertical, DotsThree
} from '@phosphor-icons/react'
import * as IconComponents from '@phosphor-icons/react'
export interface UIComponentRegistry {
[key: string]: ComponentType<any>
@@ -49,6 +51,13 @@ interface JsonRegistryEntry {
export?: string
source?: string
status?: string
wrapperRequired?: boolean
wrapperComponent?: string
wrapperFor?: string
loadFrom?: {
module?: string
export?: string
}
deprecated?: DeprecatedComponentInfo
}
@@ -62,6 +71,13 @@ export interface DeprecatedComponentInfo {
}
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
const componentLoaders: Record<string, Record<string, ComponentType<any>>> = {
wrappers: WrapperComponents as Record<string, ComponentType<any>>,
icons: IconComponents as Record<string, ComponentType<any>>,
}
const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
entry.name ?? entry.type ?? entry.export
const buildRegistryFromNames = (
names: string[],
@@ -76,11 +92,40 @@ const buildRegistryFromNames = (
}, {})
}
const resolveLoadedComponent = (entry: JsonRegistryEntry): ComponentType<any> | null => {
const moduleKey = entry.loadFrom?.module ?? entry.source
const exportName = entry.loadFrom?.export ?? getRegistryEntryName(entry)
if (!moduleKey || !exportName) {
return null
}
const moduleComponents = componentLoaders[moduleKey]
return moduleComponents?.[exportName] ?? null
}
const buildRegistryFromEntries = (entries: JsonRegistryEntry[]): UIComponentRegistry => {
return entries.reduce<UIComponentRegistry>((registry, entry) => {
const registryName = getRegistryEntryName(entry)
const component = resolveLoadedComponent(entry)
if (registryName && component) {
registry[registryName] = component
}
return registry
}, {})
}
const jsonRegistryEntries = jsonRegistry.components ?? []
const registryEntryByType = new Map(
jsonRegistryEntries
.map((entry) => {
const entryName = getRegistryEntryName(entry)
return entryName ? [entryName, entry] : null
})
.filter((entry): entry is [string, JsonRegistryEntry] => Boolean(entry))
)
const atomComponentMap = AtomComponents as Record<string, ComponentType<any>>
const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, DeprecatedComponentInfo>>(
(acc, entry) => {
const entryName = entry.export ?? entry.name ?? entry.type
const entryName = getRegistryEntryName(entry)
if (!entryName) {
return acc
}
@@ -93,16 +138,22 @@ const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, Deprec
)
const atomRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'atoms')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const moleculeRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'molecules')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const organismRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'organisms')
.map((entry) => entry.export ?? entry.name ?? entry.type)
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const shadcnRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'ui')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const wrapperRegistryEntries = jsonRegistryEntries.filter((entry) => entry.source === 'wrappers')
const iconRegistryEntries = jsonRegistryEntries.filter((entry) => entry.source === 'icons')
export const primitiveComponents: UIComponentRegistry = {
div: 'div' as any,
@@ -123,9 +174,17 @@ export const primitiveComponents: UIComponentRegistry = {
nav: 'nav' as any,
}
export const shadcnComponents: UIComponentRegistry = {
const shadcnComponentMap: Record<string, ComponentType<any>> = {
AlertDialog,
AspectRatio,
Button,
Carousel,
Chart,
Collapsible,
Command,
DropdownMenu,
Input,
InputOtp,
Textarea,
Label,
Card,
@@ -164,13 +223,26 @@ export const shadcnComponents: UIComponentRegistry = {
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 = {
...buildRegistryFromNames(
atomRegistryNames,
@@ -208,55 +280,11 @@ export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
OrganismComponents as Record<string, ComponentType<any>>
)
export const jsonWrapperComponents: UIComponentRegistry = {
SaveIndicator: SaveIndicatorWrapper,
LazyBarChart: LazyBarChartWrapper,
LazyLineChart: LazyLineChartWrapper,
LazyD3BarChart: LazyD3BarChartWrapper,
SeedDataManager: SeedDataManagerWrapper,
StorageSettings: StorageSettingsWrapper,
}
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromEntries(
wrapperRegistryEntries
)
export const iconComponents: UIComponentRegistry = {
ArrowLeft,
ArrowRight,
Check,
X,
Plus,
Minus,
Search: MagnifyingGlass,
Filter: Funnel,
Download,
Upload,
Edit: PencilSimple,
Trash,
Eye,
EyeOff: EyeClosed,
ChevronUp: CaretUp,
ChevronDown: CaretDown,
ChevronLeft: CaretLeft,
ChevronRight: CaretRight,
Settings: Gear,
User,
Bell,
Mail: Envelope,
Calendar,
Clock,
Star,
Heart,
Share: ShareNetwork,
Link: LinkSimple,
Copy,
Save: FloppyDisk,
RefreshCw: ArrowClockwise,
AlertCircle: WarningCircle,
Info,
HelpCircle: Question,
Home: House,
Menu: ListIcon,
MoreVertical: DotsThreeVertical,
MoreHorizontal: DotsThree,
}
export const iconComponents: UIComponentRegistry = buildRegistryFromEntries(iconRegistryEntries)
export const uiComponentRegistry: UIComponentRegistry = {
...primitiveComponents,

View File

@@ -99,9 +99,7 @@ export function ComponentRenderer({ component, data, context = {}, state, onEven
resolvedEventHandlers.forEach(handler => {
resolved[`on${handler.event.charAt(0).toUpperCase()}${handler.event.slice(1)}`] = (e: unknown) => {
const conditionMet = !handler.condition
|| (typeof handler.condition === 'function'
? handler.condition(mergedData as Record<string, any>)
: evaluateConditionExpression(handler.condition, mergedData as Record<string, any>, { label: 'event handler condition' }))
|| evaluateConditionExpression(handler.condition, mergedData as Record<string, any>, { label: 'event handler condition' })
if (conditionMet) {
const eventPayload = typeof e === 'object' && e !== null
? Object.assign(e as Record<string, unknown>, context)

View File

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

View File

@@ -1,5 +1,5 @@
import { useCallback } from 'react'
import { PageSchema } from '@/types/json-ui'
import { useCallback, useMemo } from 'react'
import { Action, PageSchema } from '@/types/json-ui'
import { useDataSources } from '@/hooks/data/use-data-sources'
import { useActionExecutor } from '@/hooks/ui/use-action-executor'
import { useAppSelector } from '@/store'
@@ -8,20 +8,34 @@ import { ComponentRenderer } from './component-renderer'
interface PageRendererProps {
schema: PageSchema
onCustomAction?: (action: any, event?: any) => Promise<void>
data?: Record<string, any>
functions?: Record<string, any>
}
export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
const { data, updateData, updatePath } = useDataSources(schema.dataSources)
export function PageRenderer({ schema, onCustomAction, data: externalData, functions }: PageRendererProps) {
const { data: sourceData, updateData, updatePath } = useDataSources(schema.dataSources)
const state = useAppSelector((rootState) => rootState)
const mergedData = useMemo(() => ({ ...sourceData, ...externalData }), [externalData, sourceData])
const executeCustomAction = useCallback(async (action: Action, event?: any) => {
if (onCustomAction) {
await onCustomAction(action, event)
return
}
const handler = functions?.[action.id]
if (typeof handler === 'function') {
await handler(action, event)
}
}, [functions, onCustomAction])
const context = {
data,
const actionContext = {
data: mergedData,
updateData,
updatePath,
executeAction: onCustomAction || (async () => {}),
executeAction: executeCustomAction,
}
const { executeActions } = useActionExecutor(context)
const { executeActions } = useActionExecutor(actionContext)
const handleEvent = useCallback((_componentId: string, handler: { actions: any[] }, eventData: any) => {
if (!handler?.actions?.length) return
@@ -34,7 +48,8 @@ export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
<ComponentRenderer
key={component.id || index}
component={component}
data={data}
data={mergedData}
context={functions}
state={state}
onEvent={handleEvent}
/>

View File

@@ -35,7 +35,6 @@ export const ActionSchema = z.object({
path: z.string().optional(),
value: z.any().optional(),
params: z.record(z.string(), z.any()).optional(),
compute: z.any().optional(),
expression: z.string().optional(),
valueTemplate: z.record(z.string(), z.any()).optional(),
message: z.string().optional(),
@@ -45,14 +44,14 @@ export const ActionSchema = z.object({
export const EventHandlerSchema = z.object({
event: z.string(),
actions: z.array(ActionSchema),
condition: z.any().optional(),
condition: z.string().optional(),
})
export const JSONEventDefinitionSchema = z.object({
action: z.string().optional(),
actions: z.array(ActionSchema).optional(),
payload: z.record(z.string(), z.any()).optional(),
condition: z.any().optional(),
condition: z.string().optional(),
})
export const JSONEventMapSchema = z.record(

View File

@@ -1,6 +1,6 @@
import { evaluateTransformExpression } from './expression-helpers'
type BindingTransform = string | ((data: unknown) => unknown)
type BindingTransform = string
interface BindingSourceOptions {
state?: Record<string, any>
@@ -50,10 +50,6 @@ function applyTransform(value: unknown, transform?: BindingTransform) {
return value
}
if (typeof transform === 'function') {
return transform(value)
}
return evaluateTransformExpression(transform, value, {}, { label: 'data binding transform' })
}

View File

@@ -78,6 +78,12 @@ export class RoutePreloadManager {
return
}
if (page.type === 'json' || page.schemaPath) {
console.log(`[PRELOAD_MGR] 🧾 Skipping preload for JSON page: ${pageId}`)
this.preloadedRoutes.add(pageId)
return
}
try {
const componentName = page.component as ComponentName
console.log(`[PRELOAD_MGR] 🚀 Preloading ${pageId}${componentName}`)

View File

@@ -0,0 +1,45 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { FlaskBackendAdapter } from '../flask-backend-adapter'
type MockResponse = {
ok: boolean
status: number
statusText: string
text: ReturnType<typeof vi.fn>
}
const createMockResponse = (status: number, body: string): MockResponse => ({
ok: status >= 200 && status < 300,
status,
statusText: status === 204 ? 'No Content' : 'OK',
text: vi.fn().mockResolvedValue(body),
})
describe('FlaskBackendAdapter.request', () => {
const baseUrl = 'http://example.test'
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.resetAllMocks()
})
it('resolves delete/clear when response is 204 or empty body', async () => {
fetchMock
.mockResolvedValueOnce(createMockResponse(204, '') as unknown as Response)
.mockResolvedValueOnce(createMockResponse(200, '') as unknown as Response)
const adapter = new FlaskBackendAdapter(baseUrl)
await expect(adapter.delete('example-key')).resolves.toBeUndefined()
await expect(adapter.clear()).resolves.toBeUndefined()
expect(fetchMock).toHaveBeenCalledTimes(2)
})
})

View File

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

View File

@@ -0,0 +1,145 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
callOrder,
mockFlaskGet,
mockIndexedGet,
mockSQLiteGet,
mockSparkGet,
MockFlaskBackendAdapter,
MockIndexedDBAdapter,
MockSQLiteAdapter,
MockSparkKVAdapter
} = vi.hoisted(() => {
const callOrder: string[] = []
const mockFlaskGet = vi.fn<[], Promise<unknown>>()
const mockIndexedGet = vi.fn<[], Promise<unknown>>()
const mockSQLiteGet = vi.fn<[], Promise<unknown>>()
const mockSparkGet = vi.fn<[], Promise<unknown>>()
class MockFlaskBackendAdapter {
constructor() {
callOrder.push('flask')
}
get = mockFlaskGet
}
class MockIndexedDBAdapter {
constructor() {
callOrder.push('indexeddb')
}
get = mockIndexedGet
}
class MockSQLiteAdapter {
constructor() {
callOrder.push('sqlite')
}
get = mockSQLiteGet
}
class MockSparkKVAdapter {
constructor() {
callOrder.push('sparkkv')
}
get = mockSparkGet
}
return {
callOrder,
mockFlaskGet,
mockIndexedGet,
mockSQLiteGet,
mockSparkGet,
MockFlaskBackendAdapter,
MockIndexedDBAdapter,
MockSQLiteAdapter,
MockSparkKVAdapter
}
})
vi.mock('./unified-storage-adapters', () => ({
FlaskBackendAdapter: MockFlaskBackendAdapter,
IndexedDBAdapter: MockIndexedDBAdapter,
SQLiteAdapter: MockSQLiteAdapter,
SparkKVAdapter: MockSparkKVAdapter
}))
const createLocalStorageMock = () => {
const store = new Map<string, string>()
return {
getItem: vi.fn((key: string) => store.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value)
}),
removeItem: vi.fn((key: string) => {
store.delete(key)
}),
clear: vi.fn(() => {
store.clear()
})
}
}
describe('UnifiedStorage.detectAndInitialize', () => {
let localStorageMock: ReturnType<typeof createLocalStorageMock>
beforeEach(() => {
vi.resetModules()
callOrder.length = 0
mockFlaskGet.mockReset()
mockIndexedGet.mockReset()
mockSQLiteGet.mockReset()
mockSparkGet.mockReset()
localStorageMock = createLocalStorageMock()
vi.stubGlobal('localStorage', localStorageMock)
vi.stubGlobal('window', { spark: undefined })
if (!(import.meta as { env?: Record<string, string | undefined> }).env) {
;(import.meta as { env?: Record<string, string | undefined> }).env = {}
}
})
it('tries Flask before IndexedDB when prefer-flask is set', async () => {
localStorageMock.setItem('codeforge-prefer-flask', 'true')
mockFlaskGet.mockRejectedValue(new Error('flask down'))
mockIndexedGet.mockResolvedValue(undefined)
vi.stubGlobal('indexedDB', {})
const { unifiedStorage } = await import('./unified-storage')
await unifiedStorage.getBackend()
expect(callOrder[0]).toBe('flask')
expect(callOrder).toContain('indexeddb')
})
it('falls back to IndexedDB when Flask initialization fails', async () => {
localStorageMock.setItem('codeforge-prefer-flask', 'true')
mockFlaskGet.mockRejectedValue(new Error('flask down'))
mockIndexedGet.mockResolvedValue(undefined)
vi.stubGlobal('indexedDB', {})
const { unifiedStorage } = await import('./unified-storage')
const backend = await unifiedStorage.getBackend()
expect(backend).toBe('indexeddb')
})
it('honors prefer-sqlite when configured', async () => {
localStorageMock.setItem('codeforge-prefer-sqlite', 'true')
mockSQLiteGet.mockResolvedValue(undefined)
delete (globalThis as { indexedDB?: unknown }).indexedDB
const { unifiedStorage } = await import('./unified-storage')
const backend = await unifiedStorage.getBackend()
expect(backend).toBe('sqlite')
expect(callOrder).toContain('sqlite')
})
})

View File

@@ -19,6 +19,23 @@ class UnifiedStorage {
const flaskEnvUrl = import.meta.env.VITE_FLASK_BACKEND_URL
const preferSQLite = localStorage.getItem('codeforge-prefer-sqlite') === 'true'
if (preferFlask || flaskEnvUrl) {
try {
console.log('[Storage] Flask backend explicitly configured, attempting to initialize...')
const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl)
await Promise.race([
flaskAdapter.get('_health_check'),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000))
])
this.adapter = flaskAdapter
this.backend = 'flask'
console.log('[Storage] ✓ Using Flask backend')
return
} catch (error) {
console.warn('[Storage] Flask backend not available, falling back to IndexedDB:', error)
}
}
if (typeof indexedDB !== 'undefined') {
try {
console.log('[Storage] Initializing default IndexedDB backend...')
@@ -33,26 +50,6 @@ class UnifiedStorage {
}
}
if (preferFlask || flaskEnvUrl) {
try {
console.log('[Storage] Flask backend explicitly configured, attempting to initialize...')
const flaskAdapter = new FlaskBackendAdapter(flaskEnvUrl)
const testResponse = await Promise.race([
flaskAdapter.get('_health_check'),
new Promise((_, reject) => setTimeout(() => reject(new Error('Flask connection timeout')), 2000))
])
this.adapter = flaskAdapter
this.backend = 'flask'
console.log('[Storage] ✓ Using Flask backend')
return
} catch (error) {
console.warn('[Storage] Flask backend not available, already using IndexedDB:', error)
if (this.adapter && this.backend === 'indexeddb') {
return
}
}
}
if (preferSQLite) {
try {
console.log('[Storage] SQLite fallback, attempting to initialize...')

View File

@@ -89,15 +89,22 @@ export function createRoutes(
const rootPage = enabledPages.find(p => p.isRoot)
console.log('[ROUTES] 🏠 Root page search result:', rootPage ? `Found: ${rootPage.id} (${rootPage.component})` : 'NOT FOUND - will redirect to /dashboard')
const renderJsonPage = (page: typeof enabledPages[number]) => {
// JSON page prop contract: page.props.data maps to stateContext -> data bindings,
// page.props.functions maps to actionContext -> custom action handlers.
// The mapping syntax matches props.state/props.actions (propName[:contextKey]).
const renderJsonPage = (
page: typeof enabledPages[number],
data?: Record<string, any>,
functions?: Record<string, any>
) => {
if (page.schema) {
console.log('[ROUTES] 🧾 Rendering preloaded JSON schema for page:', page.id)
return <PageRenderer schema={page.schema} />
return <PageRenderer schema={page.schema} data={data} functions={functions} />
}
if (page.schemaPath) {
console.log('[ROUTES] 🧾 Rendering JSON schema loader for page:', page.id)
return <JSONSchemaPageLoader schemaPath={page.schemaPath} />
return <JSONSchemaPageLoader schemaPath={page.schemaPath} data={data} functions={functions} />
}
console.error('[ROUTES] ❌ JSON page missing schemaPath:', page.id)
@@ -114,9 +121,18 @@ export function createRoutes(
: {}
if (page.type === 'json' || page.schemaPath) {
const jsonDataConfig = page.props?.data ?? page.props?.state
const jsonFunctionsConfig = page.props?.functions ?? page.props?.actions
const jsonData = jsonDataConfig
? resolveProps({ state: jsonDataConfig }, stateContext, actionContext)
: {}
const jsonFunctions = jsonFunctionsConfig
? resolveProps({ actions: jsonFunctionsConfig }, stateContext, actionContext)
: {}
return {
path: `/${page.id}`,
element: renderJsonPage(page)
element: renderJsonPage(page, jsonData, jsonFunctions)
}
}
@@ -168,9 +184,18 @@ export function createRoutes(
: {}
if (rootPage.type === 'json' || rootPage.schemaPath) {
const jsonDataConfig = rootPage.props?.data ?? rootPage.props?.state
const jsonFunctionsConfig = rootPage.props?.functions ?? rootPage.props?.actions
const jsonData = jsonDataConfig
? resolveProps({ state: jsonDataConfig }, stateContext, actionContext)
: {}
const jsonFunctions = jsonFunctionsConfig
? resolveProps({ actions: jsonFunctionsConfig }, stateContext, actionContext)
: {}
routes.push({
path: '/',
element: renderJsonPage(rootPage)
element: renderJsonPage(rootPage, jsonData, jsonFunctions)
})
} else if (!rootPage.component) {
console.error('[ROUTES] ❌ Root page missing component:', rootPage.id)

View File

@@ -23,13 +23,17 @@
{
"id": "filteredUsers",
"type": "computed",
"compute": "computeFilteredUsers",
"expression": "data.users",
"dependencies": ["users", "filterQuery"]
},
{
"id": "stats",
"type": "computed",
"compute": "computeStats",
"valueTemplate": {
"total": "data.users.length",
"active": "data.users.filter(status === 'active').length",
"inactive": "data.users.filter(status === 'inactive').length"
},
"dependencies": ["users"]
}
],
@@ -193,7 +197,7 @@
"bindings": {
"children": {
"source": "filteredUsers",
"transform": "transformFilteredUsers"
"path": "length"
}
}
}
@@ -227,7 +231,7 @@
"id": "update-filter",
"type": "set-value",
"target": "filterQuery",
"compute": "updateFilterQuery"
"expression": "event.target.value"
}
]
}
@@ -239,12 +243,75 @@
"id": "users-list",
"type": "div",
"props": { "className": "space-y-4" },
"bindings": {
"children": {
"source": "filteredUsers",
"transform": "transformUserList"
"loop": {
"source": "filteredUsers",
"itemVar": "user",
"indexVar": "userIndex"
},
"children": [
{
"id": "user-card",
"type": "Card",
"props": {
"className": "bg-background/50 hover:bg-background/80 transition-colors border-l-4 border-l-primary"
},
"children": [
{
"id": "user-card-content",
"type": "CardContent",
"props": { "className": "pt-6" },
"children": [
{
"id": "user-card-row",
"type": "div",
"props": { "className": "flex items-start justify-between" },
"children": [
{
"id": "user-card-info",
"type": "div",
"props": { "className": "flex-1" },
"children": [
{
"id": "user-card-name",
"type": "div",
"props": { "className": "font-semibold text-lg mb-1" },
"bindings": {
"children": { "source": "user", "path": "name" }
}
},
{
"id": "user-card-email",
"type": "div",
"props": { "className": "text-sm text-muted-foreground" },
"bindings": {
"children": { "source": "user", "path": "email" }
}
},
{
"id": "user-card-joined",
"type": "div",
"props": { "className": "text-xs text-muted-foreground mt-2" },
"bindings": {
"children": { "source": "user", "path": "joined" }
}
}
]
},
{
"id": "user-card-status",
"type": "Badge",
"props": { "variant": "secondary" },
"bindings": {
"children": { "source": "user", "path": "status" }
}
}
]
}
]
}
]
}
}
]
}
]
}

View File

@@ -1,88 +0,0 @@
export const computeFilteredUsers = (data: any) => {
const query = (data.filterQuery || '').toLowerCase()
if (!query) return data.users || []
return (data.users || []).filter((user: any) =>
user.name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
)
}
export const computeStats = (data: any) => ({
total: data.users?.length || 0,
active: data.users?.filter((u: any) => u.status === 'active').length || 0,
inactive: data.users?.filter((u: any) => u.status === 'inactive').length || 0,
})
export const computeTodoStats = (data: any) => ({
total: data.todos?.length || 0,
completed: data.todos?.filter((t: any) => t.completed).length || 0,
remaining: data.todos?.filter((t: any) => !t.completed).length || 0,
})
export const computeAddTodo = (data: any) => ({
id: Date.now(),
text: data.newTodo,
completed: false,
})
export const updateFilterQuery = (_: any, event: any) => event?.target?.value || ''
export const updateNewTodo = (data: any, event: any) => event?.target?.value || ''
export const checkCanAddTodo = (data: any) => data.newTodo?.trim().length > 0
export const transformFilteredUsers = (users: any[]) => `${users?.length || 0} users`
export const transformUserList = (users: any[]) => (users || []).map((user: any) => ({
type: 'Card',
id: `user-${user.id}`,
props: {
className: 'bg-background/50 hover:bg-background/80 transition-colors border-l-4 border-l-primary',
},
children: [
{
type: 'CardContent',
id: `user-content-${user.id}`,
props: { className: 'pt-6' },
children: [
{
type: 'div',
id: `user-row-${user.id}`,
props: { className: 'flex items-start justify-between' },
children: [
{
type: 'div',
id: `user-info-${user.id}`,
props: { className: 'flex-1' },
children: [
{
type: 'div',
id: `user-name-${user.id}`,
props: { className: 'font-semibold text-lg mb-1', children: user.name },
},
{
type: 'div',
id: `user-email-${user.id}`,
props: { className: 'text-sm text-muted-foreground', children: user.email },
},
{
type: 'div',
id: `user-joined-${user.id}`,
props: { className: 'text-xs text-muted-foreground mt-2', children: `Joined ${user.joined}` },
},
],
},
{
type: 'Badge',
id: `user-status-${user.id}`,
props: {
variant: user.status === 'active' ? 'default' : 'secondary',
children: user.status,
},
},
],
},
],
},
],
}))

View File

@@ -1,7 +1,4 @@
import { PageSchema } from '@/types/json-ui'
import * as computeFunctions from './compute-functions'
type ComputeFunctionMap = typeof computeFunctions
export function hydrateSchema(jsonSchema: any): PageSchema {
// Validate basic schema structure
@@ -13,95 +10,5 @@ export function hydrateSchema(jsonSchema: any): PageSchema {
console.warn('Schema missing required fields: id and name')
}
const schema = { ...jsonSchema }
if (schema.dataSources) {
schema.dataSources = schema.dataSources.map((ds: any) => {
if (ds.type === 'computed' && typeof ds.compute === 'string') {
const functionName = ds.compute as keyof ComputeFunctionMap
const computeFunction = computeFunctions[functionName]
if (!computeFunction) {
console.warn(`Compute function "${functionName}" not found`)
}
return {
...ds,
compute: computeFunction || (() => null)
}
}
return ds
})
}
if (schema.components) {
schema.components = hydrateComponents(schema.components)
}
return schema as PageSchema
}
function hydrateComponents(components: any[]): any[] {
return components.map(component => {
const hydratedComponent = { ...component }
if (component.events) {
hydratedComponent.events = component.events.map((event: any) => {
const hydratedEvent = { ...event }
if (event.condition && typeof event.condition === 'string') {
const functionName = event.condition as keyof ComputeFunctionMap
const conditionFunction = computeFunctions[functionName]
if (!conditionFunction) {
console.warn(`Condition function "${functionName}" not found`)
}
hydratedEvent.condition = conditionFunction || (() => false)
}
if (event.actions) {
hydratedEvent.actions = event.actions.map((action: any) => {
if (action.compute && typeof action.compute === 'string') {
const functionName = action.compute as keyof ComputeFunctionMap
const computeFunction = computeFunctions[functionName]
if (!computeFunction) {
console.warn(`Action compute function "${functionName}" not found`)
}
return {
...action,
compute: computeFunction || (() => null)
}
}
return action
})
}
return hydratedEvent
})
}
if (component.bindings) {
const hydratedBindings: Record<string, any> = {}
for (const [key, binding] of Object.entries(component.bindings)) {
const b = binding as any
if (b.transform && typeof b.transform === 'string') {
const functionName = b.transform as keyof ComputeFunctionMap
const transformFunction = computeFunctions[functionName]
if (!transformFunction) {
console.warn(`Transform function "${functionName}" not found`)
}
hydratedBindings[key] = {
...b,
transform: transformFunction || ((x: any) => x)
}
} else {
hydratedBindings[key] = b
}
}
hydratedComponent.bindings = hydratedBindings
}
if (component.children) {
hydratedComponent.children = hydrateComponents(component.children)
}
return hydratedComponent
})
return jsonSchema as PageSchema
}

View File

@@ -23,7 +23,11 @@
{
"id": "stats",
"type": "computed",
"compute": "computeTodoStats",
"valueTemplate": {
"total": "data.todos.length",
"completed": "data.todos.filter(completed === true).length",
"remaining": "data.todos.filter(completed === false).length"
},
"dependencies": ["todos"]
}
],
@@ -194,7 +198,7 @@
"id": "update-input",
"type": "set-value",
"target": "newTodo",
"compute": "updateNewTodo"
"expression": "event.target.value"
}
]
}
@@ -212,7 +216,11 @@
"id": "add-todo",
"type": "create",
"target": "todos",
"compute": "computeAddTodo"
"valueTemplate": {
"id": "Date.now()",
"text": "data.newTodo",
"completed": false
}
},
{
"id": "clear-input",
@@ -227,7 +235,7 @@
"variant": "success"
}
],
"condition": "checkCanAddTodo"
"condition": "data.newTodo.length > 0"
}
]
}

View File

@@ -52,7 +52,8 @@ export interface DataSource {
type: DataSourceType
key?: string
defaultValue?: any
compute?: (data: Record<string, any>) => any
expression?: string
valueTemplate?: Record<string, any>
dependencies?: string[]
}
@@ -63,8 +64,6 @@ export interface Action {
path?: string
value?: any
params?: Record<string, any>
// Legacy: function-based compute
compute?: ((data: Record<string, any>, event?: any) => any) | string
// New: JSON-friendly expression (e.g., "event.target.value", "data.fieldName")
expression?: string
// New: JSON template with dynamic values
@@ -77,20 +76,20 @@ export interface Binding {
source: string
sourceType?: BindingSourceType
path?: string
transform?: string | ((value: any) => any)
transform?: string
}
export interface EventHandler {
event: string
actions: Action[]
condition?: string | ((data: Record<string, any>) => boolean)
condition?: string
}
export interface JSONEventDefinition {
action?: string
actions?: Action[]
payload?: Record<string, any>
condition?: string | ((data: Record<string, any>) => boolean)
condition?: string
}
export type JSONEventMap = Record<string, JSONEventDefinition | JSONEventDefinition[] | string>

View File

@@ -2,14 +2,10 @@ import { PageSchema } from './json-ui'
import { PropConfig } from './prop-config'
import { ResizableConfig } from './resizable-config'
export interface PageConfig {
export interface BasePageConfig {
id: string
title: string
icon: string
component?: string
type?: 'json' | 'component'
schemaPath?: string
schema?: PageSchema
enabled: boolean
isRoot?: boolean
toggleKey?: string
@@ -19,3 +15,19 @@ export interface PageConfig {
props?: PropConfig
resizableConfig?: ResizableConfig
}
export interface ComponentPageConfig extends BasePageConfig {
type?: 'component'
component: string
schemaPath?: undefined
schema?: undefined
}
export interface JsonPageConfig extends BasePageConfig {
type: 'json'
component?: undefined
schemaPath?: string
schema?: PageSchema
}
export type PageConfig = ComponentPageConfig | JsonPageConfig

View File

@@ -30,7 +30,8 @@ export const DataSourceSchema = z.object({
key: z.string().optional(),
defaultValue: z.any().optional(),
dependencies: z.array(z.string()).optional(),
compute: z.string().optional(),
expression: z.string().optional(),
valueTemplate: z.record(z.string(), z.any()).optional(),
})
export const ActionConfigSchema = z.object({