Compare commits

..

1 Commits

Author SHA1 Message Date
1dfd891e24 Add JSON page config support 2026-01-18 17:16:30 +00:00
44 changed files with 529 additions and 1433 deletions

View File

@@ -1,62 +0,0 @@
# 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

@@ -717,348 +717,6 @@
"status": "supported",
"source": "atoms"
},
{
"type": "ArrowLeft",
"name": "ArrowLeft",
"category": "display",
"canHaveChildren": false,
"description": "ArrowLeft icon",
"status": "supported",
"source": "icons"
},
{
"type": "ArrowRight",
"name": "ArrowRight",
"category": "display",
"canHaveChildren": false,
"description": "ArrowRight icon",
"status": "supported",
"source": "icons"
},
{
"type": "Check",
"name": "Check",
"category": "display",
"canHaveChildren": false,
"description": "Check icon",
"status": "supported",
"source": "icons"
},
{
"type": "X",
"name": "X",
"category": "display",
"canHaveChildren": false,
"description": "X icon",
"status": "supported",
"source": "icons"
},
{
"type": "Plus",
"name": "Plus",
"category": "display",
"canHaveChildren": false,
"description": "Plus icon",
"status": "supported",
"source": "icons"
},
{
"type": "Minus",
"name": "Minus",
"category": "display",
"canHaveChildren": false,
"description": "Minus icon",
"status": "supported",
"source": "icons"
},
{
"type": "Search",
"name": "Search",
"category": "display",
"canHaveChildren": false,
"description": "Search icon",
"status": "supported",
"source": "icons"
},
{
"type": "Filter",
"name": "Filter",
"category": "display",
"canHaveChildren": false,
"description": "Filter icon",
"status": "supported",
"source": "icons"
},
{
"type": "Download",
"name": "Download",
"category": "display",
"canHaveChildren": false,
"description": "Download icon",
"status": "supported",
"source": "icons"
},
{
"type": "Upload",
"name": "Upload",
"category": "display",
"canHaveChildren": false,
"description": "Upload icon",
"status": "supported",
"source": "icons"
},
{
"type": "Edit",
"name": "Edit",
"category": "display",
"canHaveChildren": false,
"description": "Edit icon",
"status": "supported",
"source": "icons"
},
{
"type": "Trash",
"name": "Trash",
"category": "display",
"canHaveChildren": false,
"description": "Trash icon",
"status": "supported",
"source": "icons"
},
{
"type": "Eye",
"name": "Eye",
"category": "display",
"canHaveChildren": false,
"description": "Eye icon",
"status": "supported",
"source": "icons"
},
{
"type": "EyeOff",
"name": "EyeOff",
"category": "display",
"canHaveChildren": false,
"description": "EyeOff icon",
"status": "supported",
"source": "icons"
},
{
"type": "ChevronUp",
"name": "ChevronUp",
"category": "display",
"canHaveChildren": false,
"description": "ChevronUp icon",
"status": "supported",
"source": "icons"
},
{
"type": "ChevronDown",
"name": "ChevronDown",
"category": "display",
"canHaveChildren": false,
"description": "ChevronDown icon",
"status": "supported",
"source": "icons"
},
{
"type": "ChevronLeft",
"name": "ChevronLeft",
"category": "display",
"canHaveChildren": false,
"description": "ChevronLeft icon",
"status": "supported",
"source": "icons"
},
{
"type": "ChevronRight",
"name": "ChevronRight",
"category": "display",
"canHaveChildren": false,
"description": "ChevronRight icon",
"status": "supported",
"source": "icons"
},
{
"type": "Settings",
"name": "Settings",
"category": "display",
"canHaveChildren": false,
"description": "Settings icon",
"status": "supported",
"source": "icons"
},
{
"type": "User",
"name": "User",
"category": "display",
"canHaveChildren": false,
"description": "User icon",
"status": "supported",
"source": "icons"
},
{
"type": "Bell",
"name": "Bell",
"category": "display",
"canHaveChildren": false,
"description": "Bell icon",
"status": "supported",
"source": "icons"
},
{
"type": "Mail",
"name": "Mail",
"category": "display",
"canHaveChildren": false,
"description": "Mail icon",
"status": "supported",
"source": "icons"
},
{
"type": "Calendar",
"name": "Calendar",
"category": "display",
"canHaveChildren": false,
"description": "Calendar icon",
"status": "supported",
"source": "icons"
},
{
"type": "Clock",
"name": "Clock",
"category": "display",
"canHaveChildren": false,
"description": "Clock icon",
"status": "supported",
"source": "icons"
},
{
"type": "Star",
"name": "Star",
"category": "display",
"canHaveChildren": false,
"description": "Star icon",
"status": "supported",
"source": "icons"
},
{
"type": "Heart",
"name": "Heart",
"category": "display",
"canHaveChildren": false,
"description": "Heart icon",
"status": "supported",
"source": "icons"
},
{
"type": "Share",
"name": "Share",
"category": "display",
"canHaveChildren": false,
"description": "Share icon",
"status": "supported",
"source": "icons"
},
{
"type": "Link",
"name": "Link",
"category": "display",
"canHaveChildren": false,
"description": "Link icon",
"status": "supported",
"source": "icons"
},
{
"type": "Copy",
"name": "Copy",
"category": "display",
"canHaveChildren": false,
"description": "Copy icon",
"status": "supported",
"source": "icons"
},
{
"type": "Save",
"name": "Save",
"category": "display",
"canHaveChildren": false,
"description": "Save icon",
"status": "supported",
"source": "icons"
},
{
"type": "RefreshCw",
"name": "RefreshCw",
"category": "display",
"canHaveChildren": false,
"description": "RefreshCw icon",
"status": "supported",
"source": "icons"
},
{
"type": "AlertCircle",
"name": "AlertCircle",
"category": "display",
"canHaveChildren": false,
"description": "AlertCircle icon",
"status": "supported",
"source": "icons"
},
{
"type": "Info",
"name": "Info",
"category": "display",
"canHaveChildren": false,
"description": "Info icon",
"status": "supported",
"source": "icons"
},
{
"type": "HelpCircle",
"name": "HelpCircle",
"category": "display",
"canHaveChildren": false,
"description": "HelpCircle icon",
"status": "supported",
"source": "icons"
},
{
"type": "Home",
"name": "Home",
"category": "display",
"canHaveChildren": false,
"description": "Home icon",
"status": "supported",
"source": "icons"
},
{
"type": "Menu",
"name": "Menu",
"category": "display",
"canHaveChildren": false,
"description": "Menu icon",
"status": "supported",
"source": "icons"
},
{
"type": "MoreVertical",
"name": "MoreVertical",
"category": "display",
"canHaveChildren": false,
"description": "MoreVertical icon",
"status": "supported",
"source": "icons"
},
{
"type": "MoreHorizontal",
"name": "MoreHorizontal",
"category": "display",
"canHaveChildren": false,
"description": "MoreHorizontal icon",
"status": "supported",
"source": "icons"
},
{
"type": "Breadcrumb",
"name": "Breadcrumb",
@@ -2268,27 +1926,25 @@
}
],
"statistics": {
"total": 239,
"supported": 226,
"total": 222,
"supported": 209,
"planned": 0,
"jsonCompatible": 50,
"jsonCompatible": 13,
"maybeJsonCompatible": 0,
"byCategory": {
"layout": 24,
"input": 26,
"display": 64,
"navigation": 12,
"feedback": 21,
"data": 27,
"custom": 65
"layout": 25,
"input": 34,
"display": 31,
"navigation": 15,
"feedback": 23,
"data": 25,
"custom": 69
},
"bySource": {
"atoms": 117,
"molecules": 36,
"organisms": 13,
"ui": 25,
"wrappers": 10,
"icons": 38
"molecules": 40,
"organisms": 15,
"ui": 50
}
}
}

View File

@@ -1,102 +0,0 @@
{
"$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"
},
"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,9 +7,30 @@ 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[]>(
dataBindingCopy.seed.dataSources as DataSource[],
buildSeedDataSources(dataBindingCopy.seed.dataSources as SeedDataSource[]),
)
const [mockComponents] = useState<UIComponent[]>(dataBindingCopy.seed.components)

View File

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

View File

@@ -6,12 +6,9 @@ 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
computeLabel: string
computePlaceholder: string
computeHelp: string
dependenciesLabel: string
availableSourcesLabel: string
emptyDependencies: string
@@ -41,37 +38,22 @@ export function ComputedSourceFields({
return (
<>
<div className="space-y-2">
<Label>{copy.expressionLabel}</Label>
<Label>{copy.computeLabel}</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) : ''}
value={editingSource.compute?.toString() || ''}
onChange={(e) => {
try {
const template = JSON.parse(e.target.value)
onUpdateField('valueTemplate', template)
const fn = new Function('data', `return (${e.target.value})`)()
onUpdateField('compute', fn)
} catch (err) {
// Invalid JSON
// Invalid function
}
}}
placeholder={copy.valueTemplatePlaceholder}
placeholder={copy.computePlaceholder}
className="font-mono text-sm h-24"
/>
<p className="text-xs text-muted-foreground">
{copy.valueTemplateHelp}
{copy.computeHelp}
</p>
</div>

View File

@@ -247,11 +247,11 @@
},
{
"id": "json-ui-schema",
"title": "JSON UI (Schema)",
"description": "Render JSON UI from a schema file",
"title": "JSON UI Schema",
"description": "Schema-driven JSON UI page",
"icon": "Code",
"type": "json",
"schemaPath": "json-ui-page.json",
"schemaPath": "json-ui-showcase-page.json",
"layout": {
"type": "single"
}

View File

@@ -1,5 +1,69 @@
import { ComponentType } from 'react'
import { ComponentRegistry } from '@/lib/component-registry'
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,
}
export function getComponent(name: string): ComponentType<any> | null {
return ComponentRegistry[name] || null

View File

@@ -1,24 +1,9 @@
import pagesConfig from './pages.json'
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 {
@@ -34,10 +19,13 @@ export interface ResizableConfig {
}
}
export interface BasePageConfig {
export interface PageConfig {
id: string
title: string
icon: string
type?: 'component' | 'json'
component?: string
schemaPath?: string
enabled: boolean
isRoot?: boolean
toggleKey?: string
@@ -48,22 +36,6 @@ export interface BasePageConfig {
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[]
}
@@ -133,39 +105,44 @@ 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)
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)
}
} catch (err) {
console.warn('[CONFIG] ❌ Failed to resolve', label, 'prop:', entry, err)
}
}
}
try {
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,10 +18,13 @@ export const ResizableConfigSchema = z.object({
rightPanel: ResizablePanelConfigSchema,
})
const SimplePageConfigBaseSchema = z.object({
export const SimplePageConfigSchema = z.object({
id: z.string(),
title: z.string(),
icon: z.string(),
type: z.enum(['component', 'json']).optional(),
component: z.string().optional(),
schemaPath: z.string().optional(),
enabled: z.boolean(),
toggleKey: z.string().optional(),
shortcut: z.string().optional(),
@@ -31,21 +34,6 @@ const SimplePageConfigBaseSchema = 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),
})
@@ -79,32 +67,20 @@ export const FeatureConfigSchema = z.object({
config: z.record(z.string(), z.any()).optional(),
})
const PageConfigBaseSchema = z.object({
export const PageConfigSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string(),
icon: z.string(),
type: z.enum(['component', 'json']).optional(),
component: z.string().optional(),
schemaPath: z.string().optional(),
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

@@ -367,12 +367,12 @@
},
{
"id": "json-ui-schema",
"title": "JSON UI (Schema)",
"title": "JSON UI Schema",
"icon": "Code",
"type": "json",
"schemaPath": "json-ui-page.json",
"schemaPath": "json-ui-showcase-page.json",
"enabled": true,
"order": 22.2,
"order": 22.05,
"props": {}
},
{

View File

@@ -30,6 +30,8 @@ export function validatePageConfig(): ValidationError[] {
]
pagesConfig.pages.forEach((page: PageConfig) => {
const pageType = page.type ?? 'component'
if (!page.id) {
errors.push({
page: page.title || 'Unknown',
@@ -57,9 +59,16 @@ export function validatePageConfig(): ValidationError[] {
})
}
const isJsonPage = page.type === 'json' || Boolean(page.schemaPath)
if (page.type && !['component', 'json'].includes(page.type)) {
errors.push({
page: page.id || 'Unknown',
field: 'type',
message: `Unknown page type: ${page.type}. Expected "component" or "json".`,
severity: 'error',
})
}
if (!page.component && !isJsonPage) {
if (pageType === 'component' && !page.component) {
errors.push({
page: page.id || 'Unknown',
field: 'component',
@@ -68,7 +77,7 @@ export function validatePageConfig(): ValidationError[] {
})
}
if (isJsonPage && !page.schemaPath && !page.schema) {
if (pageType === 'json' && !page.schemaPath) {
errors.push({
page: page.id || 'Unknown',
field: 'schemaPath',
@@ -153,53 +162,44 @@ export function validatePageConfig(): ValidationError[] {
}
if (page.props) {
const validateStateKeys = (keys: string[] | undefined, field: string) => {
if (!keys) return
keys.forEach(stateKey => {
const [, contextKey] = stateKey.includes(':')
? stateKey.split(':')
if (page.props.state) {
page.props.state.forEach(stateKey => {
const [, contextKey] = stateKey.includes(':')
? stateKey.split(':')
: [stateKey, stateKey]
if (!validStateKeys.includes(contextKey)) {
errors.push({
page: page.id || 'Unknown',
field,
field: 'props.state',
message: `Unknown state key: ${contextKey}. Valid keys: ${validStateKeys.join(', ')}`,
severity: 'error',
})
}
})
}
const validateActionKeys = (keys: string[] | undefined, field: string) => {
if (!keys) return
keys.forEach(actionKey => {
const [, contextKey] = actionKey.includes(':')
? actionKey.split(':')
: [actionKey, actionKey]
if (page.props.actions) {
page.props.actions.forEach(actionKey => {
const [, contextKey] = actionKey.split(':')
if (!contextKey) {
errors.push({
page: page.id || 'Unknown',
field,
field: 'props.actions',
message: `Action key must use format "propName:functionName". Got: ${actionKey}`,
severity: 'error',
})
} else if (!validActionKeys.includes(contextKey)) {
errors.push({
page: page.id || 'Unknown',
field,
field: 'props.actions',
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"],
"expression": "data.userProfile.name"
"computeId": "displayName"
}
],
"components": [

View File

@@ -19,12 +19,9 @@
"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",
"computeLabel": "Compute Function",
"computePlaceholder": "(data) => data.source1 + data.source2",
"computeHelp": "Function that computes the value from other data sources",
"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' && { expression: '', dependencies: [] }),
...(type === 'computed' && { compute: () => null, dependencies: [] }),
...(type === 'static' && { defaultValue: null }),
}

View File

@@ -2,7 +2,6 @@ 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>>({})
@@ -44,17 +43,14 @@ export function useDataSources(dataSources: DataSource[]) {
const computedSources = dataSources.filter(ds => ds.type === 'computed')
computedSources.forEach(source => {
const deps = source.dependencies || []
const hasAllDeps = deps.every(dep => dep in data)
if (hasAllDeps) {
const evaluationContext = { data }
const computedValue = source.expression
? evaluateExpression(source.expression, evaluationContext)
: source.valueTemplate
? evaluateTemplate(source.valueTemplate, evaluationContext)
: source.defaultValue
setData(prev => ({ ...prev, [source.id]: computedValue }))
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 }))
}
}
})
}, [data, dataSources])

View File

@@ -2,7 +2,6 @@
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>>({})
@@ -55,17 +54,8 @@ export function useDataSources(dataSources: DataSource[]) {
const result: Record<string, any> = {}
computedSources.forEach((ds) => {
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
if (ds.compute && typeof ds.compute === 'function') {
result[ds.id] = ds.compute(data)
}
})

View File

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

View File

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

View File

@@ -69,12 +69,6 @@ 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)
@@ -102,7 +96,11 @@ export function usePWA() {
console.error('[PWA] Service Worker registration failed:', error)
})
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage)
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'CACHE_CLEARED') {
window.location.reload()
}
})
}
return () => {
@@ -110,9 +108,6 @@ 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,12 +87,7 @@ export function analyzePerformance() {
return null
}
const navigation = performance.getEntriesByType('navigation')[0] as
| PerformanceNavigationTiming
| undefined
if (!navigation) {
console.warn('[BUNDLE] ⚠️ Navigation performance entry not available')
}
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]
const jsResources = resources.filter(r => r.name.endsWith('.js'))
@@ -102,11 +97,9 @@ export function analyzePerformance() {
const totalCssSize = cssResources.reduce((sum, r) => sum + (r.transferSize || 0), 0)
const analysis = {
domContentLoaded: navigation
? navigation.domContentLoadedEventEnd - navigation.fetchStart
: NaN,
loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : NaN,
ttfb: navigation ? navigation.responseStart - navigation.fetchStart : NaN,
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.fetchStart,
loadComplete: navigation.loadEventEnd - navigation.fetchStart,
ttfb: navigation.responseStart - navigation.fetchStart,
resources: {
js: {
count: jsResources.length,

View File

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

View File

@@ -1,28 +1,14 @@
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 = sanitizeIdentifier(blueprint.name, { fallback: 'blueprint' })
const blueprintVarName = blueprint.name.toLowerCase().replace(/\s+/g, '_')
code += `${blueprintVarName}_bp = Blueprint('${blueprintVarName}', __name__, url_prefix='${blueprint.urlPrefix}')\n\n`
blueprint.endpoints.forEach(endpoint => {
const functionName = sanitizeIdentifier(endpoint.name, { fallback: 'endpoint' })
const functionName = endpoint.name.toLowerCase().replace(/\s+/g, '_')
code += `@${blueprintVarName}_bp.route('${endpoint.path}', methods=['${endpoint.method}'])\n`
code += `def ${functionName}():\n`
code += ` """\n`
@@ -45,14 +31,13 @@ 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 += ` ${paramVarName} = request.args.get('${param.name}')\n`
code += ` if ${paramVarName} is None:\n`
code += ` ${param.name} = request.args.get('${param.name}')\n`
code += ` if ${param.name} 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 += ` ${paramVarName} = request.args.get('${param.name}', ${defaultVal})\n`
code += ` ${param.name} = request.args.get('${param.name}', ${defaultVal})\n`
}
})
code += `\n`

View File

@@ -1,23 +0,0 @@
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,47 +1,27 @@
import { ComponentType } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { InputOtp } from '@/components/ui/input-otp'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { Alert as ShadcnAlert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { AlertDialog } from '@/components/ui/alert-dialog'
import { AspectRatio } from '@/components/ui/aspect-ratio'
import { Carousel } from '@/components/ui/carousel'
import { ChartContainer as Chart } from '@/components/ui/chart'
import { Collapsible } from '@/components/ui/collapsible'
import { Command } from '@/components/ui/command'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { DropdownMenu } from '@/components/ui/dropdown-menu'
import { Menubar } from '@/components/ui/menubar'
import { NavigationMenu } from '@/components/ui/navigation-menu'
import { Table as ShadcnTable, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Skeleton as ShadcnSkeleton } from '@/components/ui/skeleton'
import { Progress } from '@/components/ui/progress'
import { Pagination } from '@/components/ui/pagination'
import { ResizablePanelGroup as Resizable } from '@/components/ui/resizable'
import { Sheet } from '@/components/ui/sheet'
import { Sidebar } from '@/components/ui/sidebar'
import { Toaster as Sonner } from '@/components/ui/sonner'
import { ToggleGroup } from '@/components/ui/toggle-group'
import { Avatar as ShadcnAvatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { CircularProgress, Divider, ProgressBar } from '@/components/atoms'
import * as AtomComponents from '@/components/atoms'
import * as MoleculeComponents from '@/components/molecules'
import * as OrganismComponents from '@/components/organisms'
import {
ComponentBindingDialogWrapper,
ComponentTreeWrapper,
DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
LazyBarChartWrapper,
LazyD3BarChartWrapper,
LazyLineChartWrapper,
@@ -69,9 +49,6 @@ interface JsonRegistryEntry {
export?: string
source?: string
status?: string
wrapperRequired?: boolean
wrapperComponent?: string
wrapperFor?: string
deprecated?: DeprecatedComponentInfo
}
@@ -86,9 +63,6 @@ export interface DeprecatedComponentInfo {
const jsonRegistry = jsonComponentsRegistry as JsonComponentRegistry
const getRegistryEntryName = (entry: JsonRegistryEntry): string | undefined =>
entry.export ?? entry.name ?? entry.type
const buildRegistryFromNames = (
names: string[],
components: Record<string, ComponentType<any>>
@@ -103,18 +77,10 @@ const buildRegistryFromNames = (
}
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 = getRegistryEntryName(entry)
const entryName = entry.export ?? entry.name ?? entry.type
if (!entryName) {
return acc
}
@@ -127,27 +93,15 @@ const deprecatedComponentInfo = jsonRegistryEntries.reduce<Record<string, Deprec
)
const atomRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'atoms')
.map((entry) => getRegistryEntryName(entry))
.map((entry) => entry.export ?? entry.name ?? entry.type)
.filter((name): name is string => Boolean(name))
const moleculeRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'molecules')
.map((entry) => getRegistryEntryName(entry))
.map((entry) => entry.export ?? entry.name ?? entry.type)
.filter((name): name is string => Boolean(name))
const organismRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'organisms')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const shadcnRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'ui')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const wrapperRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'wrappers')
.map((entry) => getRegistryEntryName(entry))
.filter((name): name is string => Boolean(name))
const iconRegistryNames = jsonRegistryEntries
.filter((entry) => entry.source === 'icons')
.map((entry) => getRegistryEntryName(entry))
.map((entry) => entry.export ?? entry.name ?? entry.type)
.filter((name): name is string => Boolean(name))
export const primitiveComponents: UIComponentRegistry = {
@@ -169,17 +123,9 @@ export const primitiveComponents: UIComponentRegistry = {
nav: 'nav' as any,
}
const shadcnComponentMap: Record<string, ComponentType<any>> = {
AlertDialog,
AspectRatio,
export const shadcnComponents: UIComponentRegistry = {
Button,
Carousel,
Chart,
Collapsible,
Command,
DropdownMenu,
Input,
InputOtp,
Textarea,
Label,
Card,
@@ -218,26 +164,13 @@ const shadcnComponentMap: Record<string, ComponentType<any>> = {
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,
@@ -275,25 +208,16 @@ export const organismComponents: UIComponentRegistry = buildRegistryFromNames(
OrganismComponents as Record<string, ComponentType<any>>
)
const wrapperComponentMap: Record<string, ComponentType<any>> = {
ComponentBindingDialogWrapper,
ComponentTreeWrapper,
DataSourceEditorDialogWrapper,
GitHubBuildStatusWrapper,
SaveIndicatorWrapper,
LazyBarChartWrapper,
LazyLineChartWrapper,
LazyD3BarChartWrapper,
SeedDataManagerWrapper,
StorageSettingsWrapper,
export const jsonWrapperComponents: UIComponentRegistry = {
SaveIndicator: SaveIndicatorWrapper,
LazyBarChart: LazyBarChartWrapper,
LazyLineChart: LazyLineChartWrapper,
LazyD3BarChart: LazyD3BarChartWrapper,
SeedDataManager: SeedDataManagerWrapper,
StorageSettings: StorageSettingsWrapper,
}
export const jsonWrapperComponents: UIComponentRegistry = buildRegistryFromNames(
wrapperRegistryNames,
wrapperComponentMap
)
const iconComponentMap: Record<string, ComponentType<any>> = {
export const iconComponents: UIComponentRegistry = {
ArrowLeft,
ArrowRight,
Check,
@@ -334,11 +258,6 @@ const iconComponentMap: Record<string, ComponentType<any>> = {
MoreHorizontal: DotsThree,
}
export const iconComponents: UIComponentRegistry = buildRegistryFromNames(
iconRegistryNames,
iconComponentMap
)
export const uiComponentRegistry: UIComponentRegistry = {
...primitiveComponents,
...shadcnComponents,

View File

@@ -99,7 +99,9 @@ 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
|| evaluateConditionExpression(handler.condition, mergedData as Record<string, any>, { label: 'event 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' }))
if (conditionMet) {
const eventPayload = typeof e === 'object' && e !== null
? Object.assign(e as Record<string, unknown>, context)

View File

@@ -34,26 +34,6 @@ 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, useMemo } from 'react'
import { Action, PageSchema } from '@/types/json-ui'
import { useCallback } from 'react'
import { 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,34 +8,20 @@ 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, data: externalData, functions }: PageRendererProps) {
const { data: sourceData, updateData, updatePath } = useDataSources(schema.dataSources)
export function PageRenderer({ schema, onCustomAction }: PageRendererProps) {
const { data, 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 actionContext = {
data: mergedData,
const context = {
data,
updateData,
updatePath,
executeAction: executeCustomAction,
executeAction: onCustomAction || (async () => {}),
}
const { executeActions } = useActionExecutor(actionContext)
const { executeActions } = useActionExecutor(context)
const handleEvent = useCallback((_componentId: string, handler: { actions: any[] }, eventData: any) => {
if (!handler?.actions?.length) return
@@ -48,8 +34,7 @@ export function PageRenderer({ schema, onCustomAction, data: externalData, funct
<ComponentRenderer
key={component.id || index}
component={component}
data={mergedData}
context={functions}
data={data}
state={state}
onEvent={handleEvent}
/>

View File

@@ -35,6 +35,7 @@ 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(),
@@ -44,14 +45,14 @@ export const ActionSchema = z.object({
export const EventHandlerSchema = z.object({
event: z.string(),
actions: z.array(ActionSchema),
condition: z.string().optional(),
condition: z.any().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.string().optional(),
condition: z.any().optional(),
})
export const JSONEventMapSchema = z.record(

View File

@@ -1,6 +1,6 @@
import { evaluateTransformExpression } from './expression-helpers'
type BindingTransform = string
type BindingTransform = string | ((data: unknown) => unknown)
interface BindingSourceOptions {
state?: Record<string, any>
@@ -50,6 +50,10 @@ 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,13 +78,13 @@ 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 {
if (page.type === 'json') {
console.log(`[PRELOAD_MGR] 🧩 Skipping component preload for JSON page: ${pageId}`)
this.preloadedRoutes.add(pageId)
return
}
const componentName = page.component as ComponentName
console.log(`[PRELOAD_MGR] 🚀 Preloading ${pageId}${componentName}`)
preloadComponentByName(componentName)

View File

@@ -1,45 +0,0 @@
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

@@ -25,28 +25,11 @@ export class FlaskBackendAdapter implements StorageAdapter {
clearTimeout(timeoutId)
if (!response.ok) {
let errorMessage = response.statusText
try {
const errorText = await response.text()
if (errorText) {
try {
const parsed = JSON.parse(errorText) as { error?: string }
errorMessage = parsed.error || errorText
} catch {
errorMessage = errorText
}
}
} catch {
// ignore error parsing failures
}
throw new Error(errorMessage || `HTTP ${response.status}`)
const error = await response.json().catch(() => ({ error: response.statusText }))
throw new Error(error.error || `HTTP ${response.status}`)
}
const responseText = await response.text()
if (!responseText) {
return undefined as T
}
return JSON.parse(responseText) as T
return response.json()
} catch (error: any) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {

View File

@@ -1,145 +0,0 @@
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,23 +19,6 @@ 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...')
@@ -50,6 +33,26 @@ 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

@@ -1,11 +1,10 @@
import { lazy, Suspense } from 'react'
import { RouteObject, Navigate } from 'react-router-dom'
import { LoadingFallback } from '@/components/molecules'
import { JSONSchemaPageLoader } from '@/components/JSONSchemaPageLoader'
import { NotFoundPage } from '@/components/NotFoundPage'
import { JSONSchemaPageLoader } from '@/components/JSONSchemaPageLoader'
import { getEnabledPages, resolveProps } from '@/config/page-loader'
import { ComponentRegistry } from '@/lib/component-registry'
import { PageRenderer } from '@/lib/json-ui/page-renderer'
import { FeatureToggles } from '@/types/project'
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
@@ -82,34 +81,14 @@ export function createRoutes(
console.log('[ROUTES] 📄 Enabled pages details:', JSON.stringify(enabledPages.map(p => ({
id: p.id,
component: p.component,
type: p.type,
schemaPath: p.schemaPath,
isRoot: p.isRoot,
enabled: p.enabled
})), null, 2))
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')
// 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} data={data} functions={functions} />
}
if (page.schemaPath) {
console.log('[ROUTES] 🧾 Rendering JSON schema loader for page:', page.id)
return <JSONSchemaPageLoader schemaPath={page.schemaPath} data={data} functions={functions} />
}
console.error('[ROUTES] ❌ JSON page missing schemaPath:', page.id)
return <LoadingFallback message={`Schema path missing for JSON page: ${page.id}`} />
}
console.log('[ROUTES] 🏠 Root page search result:', rootPage ? `Found: ${rootPage.id} (${rootPage.type ?? 'component'})` : 'NOT FOUND - will redirect to /dashboard')
const routes: RouteObject[] = enabledPages
.filter(p => !p.isRoot)
@@ -120,35 +99,18 @@ export function createRoutes(
? resolveProps(page.props, stateContext, actionContext)
: {}
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)
: {}
if (page.type === 'json' && page.schemaPath) {
return {
path: `/${page.id}`,
element: renderJsonPage(page, jsonData, jsonFunctions)
element: <JSONSchemaPageLoader schemaPath={page.schemaPath} />
}
}
if (page.requiresResizable && page.resizableConfig) {
if (page.requiresResizable && page.resizableConfig && page.component) {
console.log('[ROUTES] 🔀 Page requires resizable layout:', page.id)
const config = page.resizableConfig
const leftProps = resolveProps(config.leftProps, stateContext, actionContext)
if (!page.component) {
console.error('[ROUTES] ❌ Resizable page missing component:', page.id)
return {
path: `/${page.id}`,
element: <LoadingFallback message={`Component missing for page: ${page.id}`} />
}
}
return {
path: `/${page.id}`,
element: (
@@ -163,50 +125,31 @@ export function createRoutes(
}
}
if (!page.component) {
console.error('[ROUTES] ❌ Page missing component:', page.id)
return {
path: `/${page.id}`,
element: <LoadingFallback message={`Component missing for page: ${page.id}`} />
}
}
return {
path: `/${page.id}`,
element: <LazyComponent componentName={page.component} props={props} />
element: page.component
? <LazyComponent componentName={page.component} props={props} />
: <LoadingFallback message={`Component not configured for ${page.id}`} />
}
})
if (rootPage) {
console.log('[ROUTES] ✅ Adding root route from JSON config:', rootPage.component)
const props = rootPage.props
? resolveProps(rootPage.props, stateContext, actionContext)
: {}
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)
: {}
console.log('[ROUTES] ✅ Adding root route from JSON config:', rootPage.type ?? 'component')
if (rootPage.type === 'json' && rootPage.schemaPath) {
routes.push({
path: '/',
element: renderJsonPage(rootPage, jsonData, jsonFunctions)
})
} else if (!rootPage.component) {
console.error('[ROUTES] ❌ Root page missing component:', rootPage.id)
routes.push({
path: '/',
element: <LoadingFallback message="Root page component missing" />
element: <JSONSchemaPageLoader schemaPath={rootPage.schemaPath} />
})
} else {
const props = rootPage.props
? resolveProps(rootPage.props, stateContext, actionContext)
: {}
routes.push({
path: '/',
element: <LazyComponent componentName={rootPage.component} props={props} />
element: rootPage.component
? <LazyComponent componentName={rootPage.component} props={props} />
: <LoadingFallback message="Root component not configured" />
})
}
} else {

View File

@@ -23,17 +23,13 @@
{
"id": "filteredUsers",
"type": "computed",
"expression": "data.users",
"compute": "computeFilteredUsers",
"dependencies": ["users", "filterQuery"]
},
{
"id": "stats",
"type": "computed",
"valueTemplate": {
"total": "data.users.length",
"active": "data.users.filter(status === 'active').length",
"inactive": "data.users.filter(status === 'inactive').length"
},
"compute": "computeStats",
"dependencies": ["users"]
}
],
@@ -197,7 +193,7 @@
"bindings": {
"children": {
"source": "filteredUsers",
"path": "length"
"transform": "transformFilteredUsers"
}
}
}
@@ -231,7 +227,7 @@
"id": "update-filter",
"type": "set-value",
"target": "filterQuery",
"expression": "event.target.value"
"compute": "updateFilterQuery"
}
]
}
@@ -243,75 +239,12 @@
"id": "users-list",
"type": "div",
"props": { "className": "space-y-4" },
"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" }
}
}
]
}
]
}
]
"bindings": {
"children": {
"source": "filteredUsers",
"transform": "transformUserList"
}
]
}
}
]
}

View File

@@ -0,0 +1,88 @@
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,4 +1,7 @@
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
@@ -10,5 +13,95 @@ export function hydrateSchema(jsonSchema: any): PageSchema {
console.warn('Schema missing required fields: id and name')
}
return jsonSchema as PageSchema
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
})
}

View File

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

View File

@@ -52,8 +52,7 @@ export interface DataSource {
type: DataSourceType
key?: string
defaultValue?: any
expression?: string
valueTemplate?: Record<string, any>
compute?: (data: Record<string, any>) => any
dependencies?: string[]
}
@@ -64,6 +63,8 @@ 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
@@ -76,20 +77,20 @@ export interface Binding {
source: string
sourceType?: BindingSourceType
path?: string
transform?: string
transform?: string | ((value: any) => any)
}
export interface EventHandler {
event: string
actions: Action[]
condition?: string
condition?: string | ((data: Record<string, any>) => boolean)
}
export interface JSONEventDefinition {
action?: string
actions?: Action[]
payload?: Record<string, any>
condition?: string
condition?: string | ((data: Record<string, any>) => boolean)
}
export type JSONEventMap = Record<string, JSONEventDefinition | JSONEventDefinition[] | string>

View File

@@ -1,11 +1,14 @@
import { PageSchema } from './json-ui'
import { PropConfig } from './prop-config'
import { ResizableConfig } from './resizable-config'
export interface BasePageConfig {
export interface PageConfig {
id: string
title: string
icon: string
type?: 'component' | 'json'
component?: string
schemaPath?: string
schema?: string
enabled: boolean
isRoot?: boolean
toggleKey?: string
@@ -15,19 +18,3 @@ export interface BasePageConfig {
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,8 +30,7 @@ export const DataSourceSchema = z.object({
key: z.string().optional(),
defaultValue: z.any().optional(),
dependencies: z.array(z.string()).optional(),
expression: z.string().optional(),
valueTemplate: z.record(z.string(), z.any()).optional(),
compute: z.string().optional(),
})
export const ActionConfigSchema = z.object({