mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 21:54:56 +00:00
Compare commits
1 Commits
codex/add-
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
| b57c4014ca |
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
@@ -45,11 +45,12 @@ function getCompletionMessage(score: number): string {
|
||||
}
|
||||
|
||||
export function ProjectDashboard(props: ProjectDashboardProps) {
|
||||
const completionSummary = calculateCompletionScore(props)
|
||||
|
||||
return (
|
||||
<JSONPageRenderer
|
||||
schema={dashboardSchema as any}
|
||||
data={props}
|
||||
functions={{ calculateCompletionScore }}
|
||||
data={{ ...props, completionSummary }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DataSourceType } from '@/types/json-ui'
|
||||
import { Database, Function, File } from '@phosphor-icons/react'
|
||||
import { Database, File } from '@phosphor-icons/react'
|
||||
|
||||
interface DataSourceBadgeProps {
|
||||
type: DataSourceType
|
||||
@@ -13,11 +13,6 @@ const dataSourceConfig = {
|
||||
label: 'KV Storage',
|
||||
className: 'bg-accent/20 text-accent border-accent/30'
|
||||
},
|
||||
computed: {
|
||||
icon: Function,
|
||||
label: 'Computed',
|
||||
className: 'bg-primary/20 text-primary border-primary/30'
|
||||
},
|
||||
static: {
|
||||
icon: File,
|
||||
label: 'Static',
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Progress } from '@/components/ui/progress'
|
||||
import { StatCard } from '@/components/atoms'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getIcon, resolveBinding } from './utils'
|
||||
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
|
||||
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
|
||||
import { LegacyPageSchema, PageSectionConfig } from './types'
|
||||
|
||||
interface PageSectionRendererProps {
|
||||
@@ -107,8 +109,21 @@ function PageCard({ card, data, functions }: PageCardProps) {
|
||||
const icon = card.icon ? getIcon(card.icon) : null
|
||||
|
||||
if (card.type === 'gradient-card') {
|
||||
const computeFn = functions[card.dataSource?.compute]
|
||||
const computedData = computeFn ? computeFn(data) : {}
|
||||
const dataSource = card.dataSource
|
||||
let computedData: Record<string, any> = {}
|
||||
|
||||
if (dataSource?.expression) {
|
||||
const resolved = evaluateBindingExpression(dataSource.expression, data, {
|
||||
fallback: {},
|
||||
label: `dashboard card (${card.id})`,
|
||||
})
|
||||
computedData = resolved || {}
|
||||
} else if (dataSource?.valueTemplate) {
|
||||
computedData = evaluateTemplate(dataSource.valueTemplate, { data })
|
||||
} else if (dataSource?.compute) {
|
||||
const computeFn = functions[dataSource.compute]
|
||||
computedData = computeFn ? computeFn(data) : {}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('bg-gradient-to-br border-primary/20', card.gradient)}>
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
import { Card, Badge, IconButton, Stack, Flex, Text } from '@/components/atoms'
|
||||
import { Card, IconButton, Stack, Flex, Text } from '@/components/atoms'
|
||||
import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
import { Pencil, Trash, ArrowsDownUp } from '@phosphor-icons/react'
|
||||
import { Pencil, Trash } from '@phosphor-icons/react'
|
||||
|
||||
interface DataSourceCardProps {
|
||||
dataSource: DataSource
|
||||
dependents?: DataSource[]
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
|
||||
export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }: DataSourceCardProps) {
|
||||
const getDependencyCount = () => {
|
||||
if (dataSource.type === 'computed') {
|
||||
return dataSource.dependencies?.length || 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function DataSourceCard({ dataSource, onEdit, onDelete }: DataSourceCardProps) {
|
||||
const renderTypeSpecificInfo = () => {
|
||||
if (dataSource.type === 'kv') {
|
||||
return (
|
||||
@@ -26,19 +18,7 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
if (dataSource.type === 'computed') {
|
||||
const depCount = getDependencyCount()
|
||||
return (
|
||||
<Flex align="center" gap="sm">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<ArrowsDownUp className="w-3 h-3 mr-1" />
|
||||
{depCount} {depCount === 1 ? 'dependency' : 'dependencies'}
|
||||
</Badge>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -56,13 +36,6 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
|
||||
|
||||
{renderTypeSpecificInfo()}
|
||||
|
||||
{dependents.length > 0 && (
|
||||
<div className="pt-2 border-t border-border/50">
|
||||
<Text variant="caption">
|
||||
Used by {dependents.length} computed {dependents.length === 1 ? 'source' : 'sources'}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Flex align="center" gap="xs">
|
||||
@@ -78,7 +51,6 @@ export function DataSourceCard({ dataSource, dependents = [], onEdit, onDelete }
|
||||
size="sm"
|
||||
onClick={() => onDelete(dataSource.id)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={dependents.length > 0}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -5,14 +5,12 @@ import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
|
||||
import { DataSourceIdField } from '@/components/molecules/data-source-editor/DataSourceIdField'
|
||||
import { KvSourceFields } from '@/components/molecules/data-source-editor/KvSourceFields'
|
||||
import { StaticSourceFields } from '@/components/molecules/data-source-editor/StaticSourceFields'
|
||||
import { ComputedSourceFields } from '@/components/molecules/data-source-editor/ComputedSourceFields'
|
||||
import dataSourceEditorCopy from '@/data/data-source-editor-dialog.json'
|
||||
import { useDataSourceEditor } from '@/hooks/data/use-data-source-editor'
|
||||
|
||||
interface DataSourceEditorDialogProps {
|
||||
open: boolean
|
||||
dataSource: DataSource | null
|
||||
allDataSources: DataSource[]
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSave: (dataSource: DataSource) => void
|
||||
}
|
||||
@@ -20,19 +18,13 @@ interface DataSourceEditorDialogProps {
|
||||
export function DataSourceEditorDialog({
|
||||
open,
|
||||
dataSource,
|
||||
allDataSources,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: DataSourceEditorDialogProps) {
|
||||
const {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
} = useDataSourceEditor(dataSource, allDataSources)
|
||||
} = useDataSourceEditor(dataSource)
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editingSource) return
|
||||
@@ -80,18 +72,6 @@ export function DataSourceEditorDialog({
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingSource.type === 'computed' && (
|
||||
<ComputedSourceFields
|
||||
editingSource={editingSource}
|
||||
availableDeps={availableDeps}
|
||||
selectedDeps={selectedDeps}
|
||||
unselectedDeps={unselectedDeps}
|
||||
copy={dataSourceEditorCopy.computed}
|
||||
onUpdateField={updateField}
|
||||
onAddDependency={addDependency}
|
||||
onRemoveDependency={removeDependency}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
import { X } from '@phosphor-icons/react'
|
||||
|
||||
interface ComputedSourceFieldsCopy {
|
||||
expressionLabel: string
|
||||
expressionPlaceholder: string
|
||||
expressionHelp: string
|
||||
valueTemplateLabel: string
|
||||
valueTemplatePlaceholder: string
|
||||
valueTemplateHelp: string
|
||||
dependenciesLabel: string
|
||||
availableSourcesLabel: string
|
||||
emptyDependencies: string
|
||||
}
|
||||
|
||||
interface ComputedSourceFieldsProps {
|
||||
editingSource: DataSource
|
||||
availableDeps: DataSource[]
|
||||
selectedDeps: string[]
|
||||
unselectedDeps: DataSource[]
|
||||
copy: ComputedSourceFieldsCopy
|
||||
onUpdateField: <K extends keyof DataSource>(field: K, value: DataSource[K]) => void
|
||||
onAddDependency: (depId: string) => void
|
||||
onRemoveDependency: (depId: string) => void
|
||||
}
|
||||
|
||||
export function ComputedSourceFields({
|
||||
editingSource,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
copy,
|
||||
onUpdateField,
|
||||
onAddDependency,
|
||||
onRemoveDependency,
|
||||
}: ComputedSourceFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{copy.expressionLabel}</Label>
|
||||
<Textarea
|
||||
value={editingSource.expression || ''}
|
||||
onChange={(e) => {
|
||||
onUpdateField('expression', e.target.value)
|
||||
}}
|
||||
placeholder={copy.expressionPlaceholder}
|
||||
className="font-mono text-sm h-24"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{copy.expressionHelp}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{copy.valueTemplateLabel}</Label>
|
||||
<Textarea
|
||||
value={editingSource.valueTemplate ? JSON.stringify(editingSource.valueTemplate, null, 2) : ''}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const template = JSON.parse(e.target.value)
|
||||
onUpdateField('valueTemplate', template)
|
||||
} catch (err) {
|
||||
// Invalid JSON
|
||||
}
|
||||
}}
|
||||
placeholder={copy.valueTemplatePlaceholder}
|
||||
className="font-mono text-sm h-24"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{copy.valueTemplateHelp}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{copy.dependenciesLabel}</Label>
|
||||
|
||||
{selectedDeps.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 bg-muted/30 rounded border border-border">
|
||||
{selectedDeps.map(depId => (
|
||||
<Badge
|
||||
key={depId}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
{depId}
|
||||
<button
|
||||
onClick={() => onRemoveDependency(depId)}
|
||||
className="ml-1 hover:text-destructive"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{unselectedDeps.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{copy.availableSourcesLabel}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{unselectedDeps.map(ds => (
|
||||
<Button
|
||||
key={ds.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onAddDependency(ds.id)}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
+ {ds.id}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{availableDeps.length === 0 && selectedDeps.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{copy.emptyDependencies}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
import { DataSourceEditorDialog } from '@/components/molecules/DataSourceEditorDialog'
|
||||
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
|
||||
import { DataSource, DataSourceType } from '@/types/json-ui'
|
||||
import { Database, Function, FileText } from '@phosphor-icons/react'
|
||||
import { Database, FileText } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import { EmptyState, Stack } from '@/components/atoms'
|
||||
import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader'
|
||||
@@ -21,7 +21,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
addDataSource,
|
||||
updateDataSource,
|
||||
deleteDataSource,
|
||||
getDependents,
|
||||
} = useDataSourceManager(dataSources)
|
||||
|
||||
const [editingSource, setEditingSource] = useState<DataSource | null>(null)
|
||||
@@ -42,17 +41,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
}
|
||||
|
||||
const handleDeleteSource = (id: string) => {
|
||||
const dependents = getDependents(id)
|
||||
if (dependents.length > 0) {
|
||||
const noun = dependents.length === 1 ? 'source' : 'sources'
|
||||
toast.error(dataSourceManagerCopy.toasts.deleteBlockedTitle, {
|
||||
description: dataSourceManagerCopy.toasts.deleteBlockedDescription
|
||||
.replace('{count}', String(dependents.length))
|
||||
.replace('{noun}', noun),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
deleteDataSource(id)
|
||||
onChange(localSources.filter(ds => ds.id !== id))
|
||||
toast.success(dataSourceManagerCopy.toasts.deleted)
|
||||
@@ -66,7 +54,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
|
||||
const groupedSources = {
|
||||
kv: localSources.filter(ds => ds.type === 'kv'),
|
||||
computed: localSources.filter(ds => ds.type === 'computed'),
|
||||
static: localSources.filter(ds => ds.type === 'static'),
|
||||
}
|
||||
|
||||
@@ -97,7 +84,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
icon={<Database size={16} />}
|
||||
label={dataSourceManagerCopy.groups.kv}
|
||||
dataSources={groupedSources.kv}
|
||||
getDependents={getDependents}
|
||||
onEdit={handleEditSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
@@ -106,16 +92,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
icon={<FileText size={16} />}
|
||||
label={dataSourceManagerCopy.groups.static}
|
||||
dataSources={groupedSources.static}
|
||||
getDependents={getDependents}
|
||||
onEdit={handleEditSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
|
||||
<DataSourceGroupSection
|
||||
icon={<Function size={16} />}
|
||||
label={dataSourceManagerCopy.groups.computed}
|
||||
dataSources={groupedSources.computed}
|
||||
getDependents={getDependents}
|
||||
onEdit={handleEditSource}
|
||||
onDelete={handleDeleteSource}
|
||||
/>
|
||||
@@ -127,7 +103,6 @@ export function DataSourceManager({ dataSources, onChange }: DataSourceManagerPr
|
||||
<DataSourceEditorDialog
|
||||
open={dialogOpen}
|
||||
dataSource={editingSource}
|
||||
allDataSources={localSources}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={handleSaveSource}
|
||||
/>
|
||||
|
||||
@@ -7,7 +7,6 @@ interface DataSourceGroupSectionProps {
|
||||
icon: ReactNode
|
||||
label: string
|
||||
dataSources: DataSource[]
|
||||
getDependents: (id: string) => string[]
|
||||
onEdit: (id: string) => void
|
||||
onDelete: (id: string) => void
|
||||
}
|
||||
@@ -16,7 +15,6 @@ export function DataSourceGroupSection({
|
||||
icon,
|
||||
label,
|
||||
dataSources,
|
||||
getDependents,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: DataSourceGroupSectionProps) {
|
||||
@@ -37,7 +35,6 @@ export function DataSourceGroupSection({
|
||||
<DataSourceCard
|
||||
key={ds.id}
|
||||
dataSource={ds}
|
||||
dependents={getDependents(ds.id)}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { ActionButton, Heading, Stack, Text } from '@/components/atoms'
|
||||
import { Plus, Database, Function, FileText } from '@phosphor-icons/react'
|
||||
import { Plus, Database, FileText } from '@phosphor-icons/react'
|
||||
import { DataSourceType } from '@/types/json-ui'
|
||||
|
||||
interface DataSourceManagerHeaderCopy {
|
||||
@@ -14,7 +14,6 @@ interface DataSourceManagerHeaderCopy {
|
||||
addLabel: string
|
||||
menu: {
|
||||
kv: string
|
||||
computed: string
|
||||
static: string
|
||||
}
|
||||
}
|
||||
@@ -49,10 +48,6 @@ export function DataSourceManagerHeader({ copy, onAdd }: DataSourceManagerHeader
|
||||
<Database className="w-4 h-4 mr-2" />
|
||||
{copy.menu.kv}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAdd('computed')}>
|
||||
<Function className="w-4 h-4 mr-2" />
|
||||
{copy.menu.computed}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onAdd('static')}>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
{copy.menu.static}
|
||||
|
||||
@@ -245,17 +245,6 @@
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -37,13 +37,6 @@ export function useDataSource(source: DataSource) {
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
case 'computed':
|
||||
return {
|
||||
data: source.defaultValue,
|
||||
setData: () => {},
|
||||
loading: false,
|
||||
error: null,
|
||||
}
|
||||
default:
|
||||
return {
|
||||
data: null,
|
||||
@@ -67,7 +60,7 @@ export function useDataSources(sources: DataSource[]) {
|
||||
|
||||
useEffect(() => {
|
||||
sources.forEach((source) => {
|
||||
if (source.type === 'static' || source.type === 'computed') {
|
||||
if (source.type === 'static') {
|
||||
updateData(source.id, source.defaultValue)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,7 +10,7 @@ export const ActionSchema = z.object({
|
||||
|
||||
export const DataSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['kv', 'api', 'computed', 'static'], { message: 'Invalid data source type' }),
|
||||
type: z.enum(['kv', 'api', 'static'], { message: 'Invalid data source type' }),
|
||||
key: z.string().optional(),
|
||||
endpoint: z.string().optional(),
|
||||
transform: z.string().optional(),
|
||||
|
||||
@@ -3,22 +3,8 @@ 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 +20,14 @@ export interface ResizableConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export interface BasePageConfig {
|
||||
export interface PageConfig {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
component?: string
|
||||
type?: 'json' | 'component'
|
||||
schemaPath?: string
|
||||
schema?: PageSchema
|
||||
enabled: boolean
|
||||
isRoot?: boolean
|
||||
toggleKey?: string
|
||||
@@ -48,22 +38,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 +107,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)
|
||||
}
|
||||
|
||||
@@ -18,10 +18,11 @@ 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(),
|
||||
component: z.string(),
|
||||
enabled: z.boolean(),
|
||||
toggleKey: z.string().optional(),
|
||||
shortcut: z.string().optional(),
|
||||
@@ -31,21 +32,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 +65,18 @@ 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(),
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -365,16 +365,6 @@
|
||||
"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",
|
||||
|
||||
@@ -33,15 +33,15 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedTree",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.trees?.find(t => t.id === data.selectedTreeId) || null",
|
||||
"dependencies": ["trees", "selectedTreeId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.trees, data.selectedTreeId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "treeCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.trees || []).length",
|
||||
"dependencies": ["trees"]
|
||||
"type": "static",
|
||||
"expression": "length(data.trees)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"icon": "CheckCircle",
|
||||
"gradient": "from-primary/10 to-accent/10",
|
||||
"dataSource": {
|
||||
"type": "computed",
|
||||
"compute": "calculateCompletionScore"
|
||||
"type": "expression",
|
||||
"expression": "data.completionSummary"
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
|
||||
@@ -133,9 +133,9 @@
|
||||
"data": [
|
||||
{
|
||||
"id": "activeFile",
|
||||
"type": "computed",
|
||||
"dependencies": ["files", "activeFileId"],
|
||||
"compute": "context.files.find(f => f.id === context.activeFileId)"
|
||||
"type": "static",
|
||||
"expression": "findById(data.files, data.activeFileId)",
|
||||
"defaultValue": null
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
|
||||
@@ -35,27 +35,27 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedBlueprint",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.find(b => b.id === data.selectedBlueprintId) || null; }",
|
||||
"dependencies": ["flaskConfig", "selectedBlueprintId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.flaskConfig.blueprints, data.selectedBlueprintId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "blueprintCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => ((data.flaskConfig || {}).blueprints || []).length",
|
||||
"dependencies": ["flaskConfig"]
|
||||
"type": "static",
|
||||
"expression": "length(data.flaskConfig.blueprints)",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "endpointCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const bp = data.selectedBlueprint; return bp ? (bp.endpoints || []).length : 0; }",
|
||||
"dependencies": ["selectedBlueprint"]
|
||||
"type": "static",
|
||||
"expression": "length(data.selectedBlueprint.endpoints)",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "totalEndpoints",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const config = data.flaskConfig || {}; const blueprints = config.blueprints || []; return blueprints.reduce((sum, bp) => sum + (bp.endpoints || []).length, 0); }",
|
||||
"dependencies": ["flaskConfig"]
|
||||
"type": "static",
|
||||
"expression": "sumByLength(data.flaskConfig.blueprints, \"endpoints\")",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedLambda",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.lambdas?.find(l => l.id === data.selectedLambdaId) || null",
|
||||
"dependencies": ["lambdas", "selectedLambdaId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.lambdas, data.selectedLambdaId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "lambdaCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.lambdas || []).length",
|
||||
"dependencies": ["lambdas"]
|
||||
"type": "static",
|
||||
"expression": "length(data.lambdas)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -28,15 +28,15 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedModel",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.models?.find(m => m.id === data.selectedModelId) || null",
|
||||
"dependencies": ["models", "selectedModelId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.models, data.selectedModelId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "modelCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.models || []).length",
|
||||
"dependencies": ["models"]
|
||||
"type": "static",
|
||||
"expression": "length(data.models)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
},
|
||||
{
|
||||
"id": "isRecent",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { if (!data.lastSaved) return false; return Date.now() - data.lastSaved < 3000; }",
|
||||
"dependencies": ["lastSaved", "currentTime"]
|
||||
"type": "static",
|
||||
"expression": "isRecentTimestamp(data.lastSaved, data.currentTime, 3000)",
|
||||
"defaultValue": false
|
||||
},
|
||||
{
|
||||
"id": "timeAgo",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { if (!data.lastSaved) return ''; const seconds = Math.floor((Date.now() - data.lastSaved) / 1000); if (seconds < 60) return 'just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; }",
|
||||
"dependencies": ["lastSaved", "currentTime"]
|
||||
"type": "static",
|
||||
"expression": "timeAgo(data.lastSaved, data.currentTime)",
|
||||
"defaultValue": ""
|
||||
}
|
||||
],
|
||||
"type": "div",
|
||||
|
||||
@@ -54,21 +54,21 @@
|
||||
},
|
||||
{
|
||||
"id": "activeVariant",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const theme = data.theme || {}; const variants = theme.variants || []; return variants.find(v => v.id === theme.activeVariantId) || variants[0] || null; }",
|
||||
"dependencies": ["theme"]
|
||||
"type": "static",
|
||||
"expression": "findByIdOrFirst(data.theme.variants, data.theme.activeVariantId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "variantCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => ((data.theme || {}).variants || []).length",
|
||||
"dependencies": ["theme"]
|
||||
"type": "static",
|
||||
"expression": "length(data.theme.variants)",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "customColorCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => { const variant = data.activeVariant; if (!variant || !variant.colors) return 0; return Object.keys(variant.colors.customColors || {}).length; }",
|
||||
"dependencies": ["activeVariant"]
|
||||
"type": "static",
|
||||
"expression": "keyCount(data.activeVariant.colors.customColors)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -32,15 +32,15 @@
|
||||
},
|
||||
{
|
||||
"id": "selectedWorkflow",
|
||||
"type": "computed",
|
||||
"compute": "(data) => data.workflows?.find(w => w.id === data.selectedWorkflowId) || null",
|
||||
"dependencies": ["workflows", "selectedWorkflowId"]
|
||||
"type": "static",
|
||||
"expression": "findById(data.workflows, data.selectedWorkflowId)",
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"id": "workflowCount",
|
||||
"type": "computed",
|
||||
"compute": "(data) => (data.workflows || []).length",
|
||||
"dependencies": ["workflows"]
|
||||
"type": "static",
|
||||
"expression": "length(data.workflows)",
|
||||
"defaultValue": 0
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
|
||||
@@ -153,53 +153,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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "Data Binding Designer",
|
||||
"description": "Connect UI components to KV storage and computed values"
|
||||
"description": "Connect UI components to KV storage and static values"
|
||||
},
|
||||
"bindingsCard": {
|
||||
"title": "Component Bindings",
|
||||
@@ -13,7 +13,6 @@
|
||||
"title": "How It Works",
|
||||
"steps": [
|
||||
"Create data sources (KV store for persistence, static for constants)",
|
||||
"Add computed sources to derive values from other sources",
|
||||
"Bind component properties to data sources for reactive updates"
|
||||
]
|
||||
},
|
||||
@@ -33,12 +32,6 @@
|
||||
"type": "kv",
|
||||
"key": "app-counter",
|
||||
"defaultValue": 0
|
||||
},
|
||||
{
|
||||
"id": "displayName",
|
||||
"type": "computed",
|
||||
"dependencies": ["userProfile"],
|
||||
"expression": "data.userProfile.name"
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -50,7 +43,8 @@
|
||||
},
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "displayName"
|
||||
"source": "userProfile",
|
||||
"path": "name"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Edit Data Source",
|
||||
"description": "Configure the data source settings and dependencies",
|
||||
"description": "Configure the data source settings",
|
||||
"fields": {
|
||||
"id": {
|
||||
"label": "ID",
|
||||
@@ -18,17 +18,6 @@
|
||||
"valueLabel": "Value (JSON)",
|
||||
"valuePlaceholder": "{\"key\": \"value\"}"
|
||||
},
|
||||
"computed": {
|
||||
"expressionLabel": "Expression",
|
||||
"expressionPlaceholder": "data.source1",
|
||||
"expressionHelp": "Expression that computes the value from other data sources",
|
||||
"valueTemplateLabel": "Value Template (JSON)",
|
||||
"valueTemplatePlaceholder": "{\n \"total\": \"data.items.length\"\n}",
|
||||
"valueTemplateHelp": "Template object with expressions for computed fields",
|
||||
"dependenciesLabel": "Dependencies",
|
||||
"availableSourcesLabel": "Available Sources",
|
||||
"emptyDependencies": "No data sources available. Create KV or static sources first."
|
||||
},
|
||||
"actions": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Changes"
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"header": {
|
||||
"title": "Data Sources",
|
||||
"description": "Manage KV storage, computed values, and static data"
|
||||
"description": "Manage KV storage and static data"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add Data Source"
|
||||
},
|
||||
"menu": {
|
||||
"kv": "KV Store",
|
||||
"computed": "Computed Value",
|
||||
"static": "Static Data"
|
||||
},
|
||||
"emptyState": {
|
||||
@@ -17,12 +16,9 @@
|
||||
},
|
||||
"groups": {
|
||||
"kv": "KV Store",
|
||||
"static": "Static Data",
|
||||
"computed": "Computed Values"
|
||||
"static": "Static Data"
|
||||
},
|
||||
"toasts": {
|
||||
"deleteBlockedTitle": "Cannot delete",
|
||||
"deleteBlockedDescription": "This source is used by {count} computed {noun}",
|
||||
"deleted": "Data source deleted",
|
||||
"updated": "Data source updated"
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
|
||||
export function useDataSourceEditor(
|
||||
dataSource: DataSource | null,
|
||||
allDataSources: DataSource[],
|
||||
) {
|
||||
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
|
||||
|
||||
@@ -15,44 +14,8 @@ export function useDataSourceEditor(
|
||||
setEditingSource(prev => (prev ? { ...prev, [field]: value } : prev))
|
||||
}, [])
|
||||
|
||||
const addDependency = useCallback((depId: string) => {
|
||||
setEditingSource(prev => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
if (deps.includes(depId)) return prev
|
||||
return { ...prev, dependencies: [...deps, depId] }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeDependency = useCallback((depId: string) => {
|
||||
setEditingSource(prev => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
return { ...prev, dependencies: deps.filter(dep => dep !== depId) }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const availableDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return allDataSources.filter(
|
||||
ds => ds.id !== editingSource.id && ds.type !== 'computed',
|
||||
)
|
||||
}, [allDataSources, editingSource])
|
||||
|
||||
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
|
||||
|
||||
const unselectedDeps = useMemo(
|
||||
() => availableDeps.filter(ds => !selectedDeps.includes(ds.id)),
|
||||
[availableDeps, selectedDeps],
|
||||
)
|
||||
|
||||
return {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
|
||||
id: `ds-${Date.now()}`,
|
||||
type,
|
||||
...(type === 'kv' && { key: '', defaultValue: null }),
|
||||
...(type === 'computed' && { expression: '', dependencies: [] }),
|
||||
...(type === 'static' && { defaultValue: null }),
|
||||
}
|
||||
|
||||
@@ -31,19 +30,11 @@ export function useDataSourceManager(initialSources: DataSource[] = []) {
|
||||
return dataSources.find(ds => ds.id === id)
|
||||
}, [dataSources])
|
||||
|
||||
const getDependents = useCallback((sourceId: string) => {
|
||||
return dataSources.filter(ds =>
|
||||
ds.type === 'computed' &&
|
||||
ds.dependencies?.includes(sourceId)
|
||||
)
|
||||
}, [dataSources])
|
||||
|
||||
return {
|
||||
dataSources,
|
||||
addDataSource,
|
||||
updateDataSource,
|
||||
deleteDataSource,
|
||||
getDataSource,
|
||||
getDependents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useKV } from '@/hooks/use-kv'
|
||||
|
||||
export type DataSourceType = 'kv' | 'static' | 'computed'
|
||||
export type DataSourceType = 'kv' | 'static'
|
||||
|
||||
export interface DataSourceConfig<T = any> {
|
||||
type: DataSourceType
|
||||
key?: string
|
||||
defaultValue?: T
|
||||
compute?: (allData: Record<string, any>) => T
|
||||
dependencies?: string[]
|
||||
}
|
||||
|
||||
export function useKVDataSource<T = any>(key: string, defaultValue?: T) {
|
||||
@@ -18,13 +16,6 @@ export function useStaticDataSource<T = any>(defaultValue: T) {
|
||||
return [defaultValue, () => {}, () => {}] as const
|
||||
}
|
||||
|
||||
export function useComputedDataSource<T = any>(
|
||||
compute: (allData: Record<string, any>) => T,
|
||||
dependencies: Record<string, any>
|
||||
) {
|
||||
return compute(dependencies)
|
||||
}
|
||||
|
||||
export function useMultipleDataSources(_sources: DataSourceConfig[]) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useState, useCallback, useEffect, useMemo } 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'
|
||||
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
|
||||
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
|
||||
|
||||
export function useDataSources(dataSources: DataSource[]) {
|
||||
const [data, setData] = useState<Record<string, any>>({})
|
||||
@@ -28,7 +29,7 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
if (kvIndex !== -1 && kvStates[kvIndex]) {
|
||||
newData[source.id] = kvStates[kvIndex][0]
|
||||
}
|
||||
} else if (source.type === 'static') {
|
||||
} else if (source.type === 'static' && !source.expression && !source.valueTemplate) {
|
||||
newData[source.id] = source.defaultValue
|
||||
}
|
||||
})
|
||||
@@ -40,23 +41,32 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
initializeData()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const computedSources = dataSources.filter(ds => ds.type === 'computed')
|
||||
|
||||
computedSources.forEach(source => {
|
||||
const deps = source.dependencies || []
|
||||
const hasAllDeps = deps.every(dep => dep in data)
|
||||
const derivedData = useMemo(() => {
|
||||
const result: Record<string, any> = {}
|
||||
const context = { ...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 }))
|
||||
dataSources.forEach((source) => {
|
||||
if (!source.expression && !source.valueTemplate) return
|
||||
|
||||
let computedValue: any
|
||||
if (source.expression) {
|
||||
computedValue = evaluateBindingExpression(source.expression, context, {
|
||||
fallback: undefined,
|
||||
label: `data source (${source.id})`,
|
||||
})
|
||||
} else if (source.valueTemplate) {
|
||||
computedValue = evaluateTemplate(source.valueTemplate, { data: context })
|
||||
}
|
||||
|
||||
if (computedValue === undefined && source.defaultValue !== undefined) {
|
||||
computedValue = source.defaultValue
|
||||
}
|
||||
|
||||
result[source.id] = computedValue
|
||||
context[source.id] = computedValue
|
||||
})
|
||||
|
||||
return result
|
||||
}, [data, dataSources])
|
||||
|
||||
const updateData = useCallback((sourceId: string, value: any) => {
|
||||
@@ -105,8 +115,10 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
})
|
||||
}, [dataSources, kvSources, kvStates])
|
||||
|
||||
const mergedData = useMemo(() => ({ ...data, ...derivedData }), [data, derivedData])
|
||||
|
||||
return {
|
||||
data,
|
||||
data: mergedData,
|
||||
updateData,
|
||||
updatePath,
|
||||
loading,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
import { evaluateExpression, evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
|
||||
import { evaluateBindingExpression } from '@/lib/json-ui/expression-helpers'
|
||||
import { evaluateTemplate } from '@/lib/json-ui/expression-evaluator'
|
||||
|
||||
export function useDataSources(dataSources: DataSource[]) {
|
||||
const [data, setData] = useState<Record<string, any>>({})
|
||||
@@ -13,8 +14,8 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
[dataSources]
|
||||
)
|
||||
|
||||
const computedSources = useMemo(
|
||||
() => dataSources.filter((ds) => ds.type === 'computed'),
|
||||
const derivedSources = useMemo(
|
||||
() => dataSources.filter((ds) => ds.expression || ds.valueTemplate),
|
||||
[dataSources]
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
} catch {
|
||||
initialData[ds.id] = ds.defaultValue
|
||||
}
|
||||
} else if (ds.type === 'static') {
|
||||
} else if (ds.type === 'static' && !ds.expression && !ds.valueTemplate) {
|
||||
initialData[ds.id] = ds.defaultValue
|
||||
}
|
||||
}
|
||||
@@ -53,24 +54,29 @@ export function useDataSources(dataSources: DataSource[]) {
|
||||
|
||||
const computedData = useMemo(() => {
|
||||
const result: Record<string, any> = {}
|
||||
|
||||
computedSources.forEach((ds) => {
|
||||
const evaluationContext = { data }
|
||||
const context = { ...data }
|
||||
|
||||
derivedSources.forEach((ds) => {
|
||||
let value: any
|
||||
if (ds.expression) {
|
||||
result[ds.id] = evaluateExpression(ds.expression, evaluationContext)
|
||||
return
|
||||
value = evaluateBindingExpression(ds.expression, context, {
|
||||
fallback: undefined,
|
||||
label: `data source (${ds.id})`,
|
||||
})
|
||||
} else if (ds.valueTemplate) {
|
||||
value = evaluateTemplate(ds.valueTemplate, { data: context })
|
||||
}
|
||||
if (ds.valueTemplate) {
|
||||
result[ds.id] = evaluateTemplate(ds.valueTemplate, evaluationContext)
|
||||
return
|
||||
}
|
||||
if (ds.defaultValue !== undefined) {
|
||||
result[ds.id] = ds.defaultValue
|
||||
|
||||
if (value === undefined && ds.defaultValue !== undefined) {
|
||||
value = ds.defaultValue
|
||||
}
|
||||
|
||||
result[ds.id] = value
|
||||
context[ds.id] = value
|
||||
})
|
||||
|
||||
return result
|
||||
}, [computedSources, data])
|
||||
}, [derivedSources, data])
|
||||
|
||||
const allData = useMemo(
|
||||
() => ({ ...data, ...computedData }),
|
||||
|
||||
@@ -45,19 +45,24 @@ export function usePage(schema: PageSchema) {
|
||||
useEffect(() => {
|
||||
if (schema.data) {
|
||||
const computed: Record<string, any> = {}
|
||||
const context = { ...dataContext }
|
||||
|
||||
schema.data.forEach(source => {
|
||||
if (source.type === 'computed') {
|
||||
if (source.type === 'static') {
|
||||
if (source.expression) {
|
||||
computed[source.id] = evaluateBindingExpression(source.expression, dataContext, {
|
||||
fallback: undefined,
|
||||
label: `computed data (${source.id})`,
|
||||
computed[source.id] = evaluateBindingExpression(source.expression, context, {
|
||||
fallback: source.defaultValue,
|
||||
label: `data source (${source.id})`,
|
||||
})
|
||||
} else if (source.valueTemplate) {
|
||||
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: dataContext })
|
||||
computed[source.id] = evaluateTemplate(source.valueTemplate, { data: context })
|
||||
} else if (source.defaultValue !== undefined) {
|
||||
computed[source.id] = source.defaultValue
|
||||
}
|
||||
} else if (source.type === 'static' && source.defaultValue !== undefined) {
|
||||
computed[source.id] = source.defaultValue
|
||||
}
|
||||
|
||||
if (computed[source.id] !== undefined) {
|
||||
context[source.id] = computed[source.id]
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { DataSource } from '@/types/json-ui'
|
||||
|
||||
interface UseDataSourceEditorParams {
|
||||
dataSource: DataSource | null
|
||||
allDataSources: DataSource[]
|
||||
onSave: (dataSource: DataSource) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function useDataSourceEditor({
|
||||
dataSource,
|
||||
allDataSources,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
}: UseDataSourceEditorParams) {
|
||||
@@ -27,51 +25,15 @@ export function useDataSourceEditor({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addDependency = useCallback((depId: string) => {
|
||||
setEditingSource((prev) => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
if (deps.includes(depId)) return prev
|
||||
return { ...prev, dependencies: [...deps, depId] }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeDependency = useCallback((depId: string) => {
|
||||
setEditingSource((prev) => {
|
||||
if (!prev || prev.type !== 'computed') return prev
|
||||
const deps = prev.dependencies || []
|
||||
return { ...prev, dependencies: deps.filter((id) => id !== depId) }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!editingSource) return
|
||||
onSave(editingSource)
|
||||
onOpenChange(false)
|
||||
}, [editingSource, onOpenChange, onSave])
|
||||
|
||||
const availableDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return allDataSources.filter(
|
||||
(ds) => ds.id !== editingSource.id && ds.type !== 'computed',
|
||||
)
|
||||
}, [allDataSources, editingSource])
|
||||
|
||||
const selectedDeps = useMemo(() => editingSource?.dependencies || [], [editingSource])
|
||||
|
||||
const unselectedDeps = useMemo(() => {
|
||||
if (!editingSource) return []
|
||||
return availableDeps.filter((ds) => !selectedDeps.includes(ds.id))
|
||||
}, [availableDeps, editingSource, selectedDeps])
|
||||
|
||||
return {
|
||||
editingSource,
|
||||
updateField,
|
||||
addDependency,
|
||||
removeDependency,
|
||||
handleSave,
|
||||
availableDeps,
|
||||
selectedDeps,
|
||||
unselectedDeps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,6 +8,121 @@ interface EvaluationContext {
|
||||
event?: any
|
||||
}
|
||||
|
||||
type ExpressionFunction = (args: any[], context: EvaluationContext) => any
|
||||
|
||||
const parseArguments = (argsString: string): string[] => {
|
||||
if (!argsString.trim()) return []
|
||||
const args: string[] = []
|
||||
let current = ''
|
||||
let depth = 0
|
||||
let inSingleQuote = false
|
||||
let inDoubleQuote = false
|
||||
|
||||
for (let i = 0; i < argsString.length; i++) {
|
||||
const char = argsString[i]
|
||||
const prevChar = argsString[i - 1]
|
||||
|
||||
if (char === "'" && !inDoubleQuote && prevChar !== '\\') {
|
||||
inSingleQuote = !inSingleQuote
|
||||
} else if (char === '"' && !inSingleQuote && prevChar !== '\\') {
|
||||
inDoubleQuote = !inDoubleQuote
|
||||
} else if (!inSingleQuote && !inDoubleQuote) {
|
||||
if (char === '(') depth += 1
|
||||
if (char === ')') depth = Math.max(0, depth - 1)
|
||||
if (char === ',' && depth === 0) {
|
||||
args.push(current.trim())
|
||||
current = ''
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
current += char
|
||||
}
|
||||
|
||||
if (current.trim()) {
|
||||
args.push(current.trim())
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
const expressionFunctions: Record<string, ExpressionFunction> = {
|
||||
findById: ([list, id]) => {
|
||||
if (!Array.isArray(list)) return null
|
||||
return list.find((item) => item?.id === id) ?? null
|
||||
},
|
||||
findByIdOrFirst: ([list, id]) => {
|
||||
if (!Array.isArray(list)) return null
|
||||
return list.find((item) => item?.id === id) ?? list[0] ?? null
|
||||
},
|
||||
length: ([value]) => {
|
||||
if (Array.isArray(value) || typeof value === 'string') {
|
||||
return value.length
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.keys(value).length
|
||||
}
|
||||
return 0
|
||||
},
|
||||
keyCount: ([value]) => {
|
||||
if (!value || typeof value !== 'object') return 0
|
||||
return Object.keys(value).length
|
||||
},
|
||||
sumByLength: ([list, path], context) => {
|
||||
if (!Array.isArray(list)) return 0
|
||||
const resolvedPath = typeof path === 'string' ? path : evaluateExpression(String(path), context)
|
||||
if (!resolvedPath || typeof resolvedPath !== 'string') return 0
|
||||
return list.reduce((sum, item) => {
|
||||
const value = getNestedValue(item, resolvedPath)
|
||||
const length = Array.isArray(value) || typeof value === 'string' ? value.length : 0
|
||||
return sum + length
|
||||
}, 0)
|
||||
},
|
||||
isRecentTimestamp: ([lastSaved, nowValue, thresholdValue]) => {
|
||||
if (!lastSaved) return false
|
||||
const now = typeof nowValue === 'number' ? nowValue : Date.now()
|
||||
const threshold = typeof thresholdValue === 'number' ? thresholdValue : 3000
|
||||
return now - lastSaved < threshold
|
||||
},
|
||||
timeAgo: ([lastSaved, nowValue]) => {
|
||||
if (!lastSaved) return ''
|
||||
const now = typeof nowValue === 'number' ? nowValue : Date.now()
|
||||
const seconds = Math.floor((now - lastSaved) / 1000)
|
||||
if (seconds < 60) return 'just now'
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`
|
||||
return `${Math.floor(seconds / 86400)}d ago`
|
||||
},
|
||||
filterUsersByQuery: ([users, query]) => {
|
||||
const list = Array.isArray(users) ? users : []
|
||||
const normalizedQuery = typeof query === 'string' ? query.toLowerCase() : ''
|
||||
if (!normalizedQuery) return list
|
||||
return list.filter((user) => {
|
||||
const name = String(user?.name ?? '').toLowerCase()
|
||||
const email = String(user?.email ?? '').toLowerCase()
|
||||
return name.includes(normalizedQuery) || email.includes(normalizedQuery)
|
||||
})
|
||||
},
|
||||
userStats: ([users]) => {
|
||||
const list = Array.isArray(users) ? users : []
|
||||
return {
|
||||
total: list.length,
|
||||
active: list.filter((user) => user?.status === 'active').length,
|
||||
inactive: list.filter((user) => user?.status === 'inactive').length,
|
||||
}
|
||||
},
|
||||
todoStats: ([todos]) => {
|
||||
const list = Array.isArray(todos) ? todos : []
|
||||
return {
|
||||
total: list.length,
|
||||
completed: list.filter((todo) => Boolean(todo?.completed)).length,
|
||||
remaining: list.filter((todo) => !todo?.completed).length,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const supportedExpressionFunctions = new Set(Object.keys(expressionFunctions))
|
||||
|
||||
/**
|
||||
* Safely evaluate a JSON expression
|
||||
* Supports:
|
||||
@@ -26,6 +141,17 @@ export function evaluateExpression(
|
||||
const { data, event } = context
|
||||
|
||||
try {
|
||||
const functionMatch = expression.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\((.*)\)$/)
|
||||
if (functionMatch) {
|
||||
const functionName = functionMatch[1]
|
||||
const argString = functionMatch[2]
|
||||
const handler = expressionFunctions[functionName]
|
||||
if (handler) {
|
||||
const args = parseArguments(argString).map((arg) => evaluateExpression(arg, context))
|
||||
return handler(args, context)
|
||||
}
|
||||
}
|
||||
|
||||
if (expression === 'event') {
|
||||
return event
|
||||
}
|
||||
@@ -34,26 +160,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))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { evaluateCondition, evaluateExpression } from './expression-evaluator'
|
||||
import { evaluateCondition, evaluateExpression, supportedExpressionFunctions } from './expression-evaluator'
|
||||
|
||||
const IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/
|
||||
const NUMBER_PATTERN = /^-?\d+(?:\.\d+)?$/
|
||||
@@ -10,6 +10,8 @@ interface EvaluationOptions {
|
||||
event?: any
|
||||
}
|
||||
|
||||
const FUNCTION_PATTERN = /^([A-Za-z_$][A-Za-z0-9_$]*)\((.*)\)$/
|
||||
|
||||
const isSupportedExpression = (expression: string) => {
|
||||
if (expression === 'event' || expression === 'data') return true
|
||||
if (expression.startsWith('data.') || expression.startsWith('event.')) return true
|
||||
@@ -17,6 +19,10 @@ const isSupportedExpression = (expression: string) => {
|
||||
if (STRING_PATTERN.test(expression)) return true
|
||||
if (NUMBER_PATTERN.test(expression)) return true
|
||||
if (['true', 'false', 'null', 'undefined'].includes(expression)) return true
|
||||
const functionMatch = expression.match(FUNCTION_PATTERN)
|
||||
if (functionMatch) {
|
||||
return supportedExpressionFunctions.has(functionMatch[1])
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ export function useJSONDataSource<T = unknown>(
|
||||
) {
|
||||
const kvConfig = config.type === 'kv' ? config.config : undefined
|
||||
const apiConfig = config.type === 'api' ? config.config : undefined
|
||||
const computedConfig = config.type === 'computed' ? config.config : undefined
|
||||
const defaultValue =
|
||||
config.type === 'static' ? config.config : config.config?.defaultValue
|
||||
|
||||
@@ -57,8 +56,6 @@ export function useJSONDataSource<T = unknown>(
|
||||
return apiValue
|
||||
case 'static':
|
||||
return config.config
|
||||
case 'computed':
|
||||
return computedConfig?.defaultValue
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
@@ -220,7 +221,7 @@ export const PageUISchema = z.object({
|
||||
tables: z.array(TableSchema).optional(),
|
||||
menus: z.array(MenuSchema).optional(),
|
||||
dataSources: z.record(z.string(), z.object({
|
||||
type: z.enum(['kv', 'api', 'computed', 'static']),
|
||||
type: z.enum(['kv', 'api', 'static']),
|
||||
config: z.any(),
|
||||
})).optional(),
|
||||
})
|
||||
@@ -241,13 +242,6 @@ export type DataSourceConfig<T = unknown> =
|
||||
transform?: (data: unknown) => T
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'computed'
|
||||
config: {
|
||||
defaultValue?: T
|
||||
transform?: (data: unknown) => T
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'static'
|
||||
config: T
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -78,12 +78,6 @@ 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}`)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -24,25 +24,12 @@ 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 errorPayload = hasJsonBody ? await response.json().catch(() => null) : null
|
||||
const errorMessage = errorPayload?.error || response.statusText || `HTTP ${response.status}`
|
||||
throw new Error(errorMessage)
|
||||
const error = await response.json().catch(() => ({ error: response.statusText }))
|
||||
throw new Error(error.error || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
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
|
||||
return response.json()
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId)
|
||||
if (error.name === 'AbortError') {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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...')
|
||||
|
||||
@@ -89,22 +89,15 @@ 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')
|
||||
|
||||
// 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>
|
||||
) => {
|
||||
const renderJsonPage = (page: typeof enabledPages[number]) => {
|
||||
if (page.schema) {
|
||||
console.log('[ROUTES] 🧾 Rendering preloaded JSON schema for page:', page.id)
|
||||
return <PageRenderer schema={page.schema} data={data} functions={functions} />
|
||||
return <PageRenderer schema={page.schema} />
|
||||
}
|
||||
|
||||
if (page.schemaPath) {
|
||||
console.log('[ROUTES] 🧾 Rendering JSON schema loader for page:', page.id)
|
||||
return <JSONSchemaPageLoader schemaPath={page.schemaPath} data={data} functions={functions} />
|
||||
return <JSONSchemaPageLoader schemaPath={page.schemaPath} />
|
||||
}
|
||||
|
||||
console.error('[ROUTES] ❌ JSON page missing schemaPath:', page.id)
|
||||
@@ -121,18 +114,9 @@ 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, jsonData, jsonFunctions)
|
||||
element: renderJsonPage(page)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,18 +168,9 @@ 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, jsonData, jsonFunctions)
|
||||
element: renderJsonPage(rootPage)
|
||||
})
|
||||
} else if (!rootPage.component) {
|
||||
console.error('[ROUTES] ❌ Root page missing component:', rootPage.id)
|
||||
|
||||
@@ -22,19 +22,19 @@
|
||||
},
|
||||
{
|
||||
"id": "filteredUsers",
|
||||
"type": "computed",
|
||||
"expression": "data.users",
|
||||
"dependencies": ["users", "filterQuery"]
|
||||
"type": "static",
|
||||
"expression": "filterUsersByQuery(data.users, data.filterQuery)",
|
||||
"defaultValue": []
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"type": "computed",
|
||||
"valueTemplate": {
|
||||
"total": "data.users.length",
|
||||
"active": "data.users.filter(status === 'active').length",
|
||||
"inactive": "data.users.filter(status === 'inactive').length"
|
||||
},
|
||||
"dependencies": ["users"]
|
||||
"type": "static",
|
||||
"expression": "userStats(data.users)",
|
||||
"defaultValue": {
|
||||
"total": 0,
|
||||
"active": 0,
|
||||
"inactive": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -197,7 +197,7 @@
|
||||
"bindings": {
|
||||
"children": {
|
||||
"source": "filteredUsers",
|
||||
"path": "length"
|
||||
"transform": "transformFilteredUsers"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@
|
||||
"id": "update-filter",
|
||||
"type": "set-value",
|
||||
"target": "filterQuery",
|
||||
"expression": "event.target.value"
|
||||
"compute": "updateFilterQuery"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -243,75 +243,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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
88
src/schemas/compute-functions.ts
Normal file
88
src/schemas/compute-functions.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}))
|
||||
@@ -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,82 @@ 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) => 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,13 +22,13 @@
|
||||
},
|
||||
{
|
||||
"id": "stats",
|
||||
"type": "computed",
|
||||
"valueTemplate": {
|
||||
"total": "data.todos.length",
|
||||
"completed": "data.todos.filter(completed === true).length",
|
||||
"remaining": "data.todos.filter(completed === false).length"
|
||||
},
|
||||
"dependencies": ["todos"]
|
||||
"type": "static",
|
||||
"expression": "todoStats(data.todos)",
|
||||
"defaultValue": {
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"remaining": 0
|
||||
}
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -198,7 +198,7 @@
|
||||
"id": "update-input",
|
||||
"type": "set-value",
|
||||
"target": "newTodo",
|
||||
"expression": "event.target.value"
|
||||
"compute": "updateNewTodo"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -216,11 +216,7 @@
|
||||
"id": "add-todo",
|
||||
"type": "create",
|
||||
"target": "todos",
|
||||
"valueTemplate": {
|
||||
"id": "Date.now()",
|
||||
"text": "data.newTodo",
|
||||
"completed": false
|
||||
}
|
||||
"compute": "computeAddTodo"
|
||||
},
|
||||
{
|
||||
"id": "clear-input",
|
||||
@@ -235,7 +231,7 @@
|
||||
"variant": "success"
|
||||
}
|
||||
],
|
||||
"condition": "data.newTodo.length > 0"
|
||||
"condition": "checkCanAddTodo"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AutoSyncManager } from '../autoSyncMiddleware'
|
||||
import { syncToFlaskBulk } from '../../slices/syncSlice'
|
||||
|
||||
vi.mock('../../slices/syncSlice', () => ({
|
||||
syncToFlaskBulk: vi.fn(() => ({ type: 'sync/syncToFlaskBulk' })),
|
||||
checkFlaskConnection: vi.fn(() => ({ type: 'sync/checkConnection' })),
|
||||
}))
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (error?: unknown) => void
|
||||
}
|
||||
|
||||
const createDeferred = <T,>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (error?: unknown) => void
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
describe('AutoSyncManager', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('serializes syncs and runs one pending sync after completion', async () => {
|
||||
const manager = new AutoSyncManager()
|
||||
const deferreds = [createDeferred<void>(), createDeferred<void>()]
|
||||
const dispatch = vi
|
||||
.fn()
|
||||
.mockImplementation(() => deferreds.shift()?.promise ?? Promise.resolve())
|
||||
|
||||
manager.setDispatch(dispatch)
|
||||
|
||||
const firstSync = manager.syncNow()
|
||||
const secondSync = manager.syncNow()
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
|
||||
deferreds[0].resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(2)
|
||||
|
||||
deferreds[1].resolve()
|
||||
|
||||
await firstSync
|
||||
await secondSync
|
||||
})
|
||||
|
||||
it('resets changeCounter after a successful sync', async () => {
|
||||
const manager = new AutoSyncManager()
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
manager.setDispatch(dispatch)
|
||||
manager.trackChange()
|
||||
manager.trackChange()
|
||||
|
||||
expect(manager.getStatus().changeCounter).toBe(2)
|
||||
|
||||
await manager.syncNow()
|
||||
|
||||
expect(manager.getStatus().changeCounter).toBe(0)
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('coalesces multiple pending sync requests into one run', async () => {
|
||||
const manager = new AutoSyncManager()
|
||||
const deferreds = [createDeferred<void>(), createDeferred<void>()]
|
||||
const dispatch = vi
|
||||
.fn()
|
||||
.mockImplementation(() => deferreds.shift()?.promise ?? Promise.resolve())
|
||||
|
||||
manager.setDispatch(dispatch)
|
||||
|
||||
const firstSync = manager.syncNow()
|
||||
const secondSync = manager.syncNow()
|
||||
const thirdSync = manager.syncNow()
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
|
||||
deferreds[0].resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(2)
|
||||
|
||||
deferreds[1].resolve()
|
||||
|
||||
await firstSync
|
||||
await secondSync
|
||||
await thirdSync
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('dispatches the sync thunk when performing a sync', async () => {
|
||||
const manager = new AutoSyncManager()
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
manager.setDispatch(dispatch)
|
||||
|
||||
await manager.syncNow()
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(syncToFlaskBulk())
|
||||
})
|
||||
})
|
||||
@@ -9,7 +9,7 @@ interface AutoSyncConfig {
|
||||
maxQueueSize: number
|
||||
}
|
||||
|
||||
export class AutoSyncManager {
|
||||
class AutoSyncManager {
|
||||
private config: AutoSyncConfig = {
|
||||
enabled: false,
|
||||
intervalMs: 30000,
|
||||
@@ -21,8 +21,6 @@ export class AutoSyncManager {
|
||||
private lastSyncTime = 0
|
||||
private changeCounter = 0
|
||||
private dispatch: any = null
|
||||
private syncInFlight: Promise<void> | null = null
|
||||
private pendingSync = false
|
||||
|
||||
configure(config: Partial<AutoSyncConfig>) {
|
||||
this.config = { ...this.config, ...config }
|
||||
@@ -71,32 +69,12 @@ export class AutoSyncManager {
|
||||
private async performSync() {
|
||||
if (!this.dispatch) return
|
||||
|
||||
if (this.syncInFlight) {
|
||||
this.pendingSync = true
|
||||
return
|
||||
}
|
||||
|
||||
const syncPromise = (async () => {
|
||||
try {
|
||||
await this.dispatch(syncToFlaskBulk())
|
||||
this.lastSyncTime = Date.now()
|
||||
this.changeCounter = 0
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Sync failed:', error)
|
||||
}
|
||||
})()
|
||||
|
||||
this.syncInFlight = syncPromise
|
||||
|
||||
try {
|
||||
await syncPromise
|
||||
} finally {
|
||||
this.syncInFlight = null
|
||||
}
|
||||
|
||||
if (this.pendingSync) {
|
||||
this.pendingSync = false
|
||||
await this.performSync()
|
||||
await this.dispatch(syncToFlaskBulk())
|
||||
this.lastSyncTime = Date.now()
|
||||
this.changeCounter = 0
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Sync failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export type ActionType =
|
||||
| 'custom'
|
||||
|
||||
export type DataSourceType =
|
||||
| 'kv' | 'computed' | 'static'
|
||||
| 'kv' | 'static'
|
||||
|
||||
export type BindingSourceType =
|
||||
| 'data' | 'bindings' | 'state'
|
||||
@@ -54,7 +54,6 @@ export interface DataSource {
|
||||
defaultValue?: any
|
||||
expression?: string
|
||||
valueTemplate?: Record<string, any>
|
||||
dependencies?: string[]
|
||||
}
|
||||
|
||||
export interface Action {
|
||||
@@ -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>
|
||||
|
||||
@@ -2,10 +2,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
|
||||
component?: string
|
||||
type?: 'json' | 'component'
|
||||
schemaPath?: string
|
||||
schema?: PageSchema
|
||||
enabled: boolean
|
||||
isRoot?: boolean
|
||||
toggleKey?: string
|
||||
@@ -15,19 +19,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
|
||||
|
||||
@@ -26,10 +26,9 @@ export const ComponentSchema: z.ZodType<any> = z.lazy(() =>
|
||||
|
||||
export const DataSourceSchema = z.object({
|
||||
id: z.string(),
|
||||
type: z.enum(['kv', 'computed', 'static', 'ai'], { message: 'Invalid data source type' }),
|
||||
type: z.enum(['kv', 'static', 'ai'], { message: 'Invalid data source type' }),
|
||||
key: z.string().optional(),
|
||||
defaultValue: z.any().optional(),
|
||||
dependencies: z.array(z.string()).optional(),
|
||||
expression: z.string().optional(),
|
||||
valueTemplate: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user