Replace legacy schema renderer usage

This commit is contained in:
2026-01-18 16:08:03 +00:00
parent f547d38539
commit 83864189a5
11 changed files with 4 additions and 893 deletions

View File

@@ -1,16 +1,9 @@
import { PageRenderer } from '@/lib/schema-renderer'
import { PageRenderer } from '@/lib/json-ui/page-renderer'
import lambdaDesignerSchema from '@/config/pages/lambda-designer.json'
import { useKV } from '@/hooks/use-kv'
import { Component as ComponentSchema } from '@/schemas/ui-schema'
import { PageSchema } from '@/types/json-ui'
export function JSONLambdaDesigner() {
const [lambdas] = useKV('app-lambdas', [])
return (
<PageRenderer
schema={lambdaDesignerSchema as ComponentSchema}
data={{ lambdas }}
functions={{}}
/>
<PageRenderer schema={lambdaDesignerSchema as PageSchema} />
)
}

View File

@@ -1,172 +0,0 @@
import { useState, useEffect } from 'react'
import { PageRenderer } from '@/lib/schema-renderer'
import { useSchemaLoader } from '@/hooks/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Code, FileText, Database } from '@phosphor-icons/react'
import dashboardSchema from '@/config/schemas/json-ui-dashboard.json'
interface JSONUIShowcaseProps {
files?: any[]
models?: any[]
components?: any[]
}
export function JSONUIShowcase({
files = [],
models = [],
components = []
}: JSONUIShowcaseProps) {
const [showJSON, setShowJSON] = useState(false)
const {schema: loadedSchema, loading, error} = useSchemaLoader({
schema: dashboardSchema as any
})
const data = {
files: files.length > 0 ? files : [
{ name: 'App.tsx', type: 'TypeScript' },
{ name: 'index.css', type: 'CSS' },
{ name: 'schema-renderer.tsx', type: 'TypeScript' },
{ name: 'use-data-binding.ts', type: 'Hook' },
{ name: 'dashboard.json', type: 'JSON' },
],
models: models.length > 0 ? models : [
{ name: 'User', fields: 5 },
{ name: 'Post', fields: 8 },
{ name: 'Comment', fields: 4 },
],
components: components.length > 0 ? components : [
{ name: 'Button', type: 'atom' },
{ name: 'Card', type: 'molecule' },
{ name: 'Dashboard', type: 'organism' },
],
}
const functions = {
handleClick: () => {
console.log('Button clicked from JSON!')
},
}
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">Loading schema...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="h-full p-6">
<Alert variant="destructive">
<AlertDescription>
Failed to load schema: {error.message}
</AlertDescription>
</Alert>
</div>
)
}
if (!loadedSchema) {
return (
<div className="h-full p-6">
<Alert>
<AlertDescription>No schema loaded</AlertDescription>
</Alert>
</div>
)
}
return (
<div className="h-full overflow-auto">
<div className="p-6 space-y-6">
<Card className="bg-gradient-to-br from-primary/10 to-accent/10 border-primary/20">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code size={24} weight="duotone" className="text-primary" />
JSON-Driven UI System
</CardTitle>
<CardDescription>
Complete UI rendering from declarative JSON schemas with data bindings and event handlers
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Button
onClick={() => setShowJSON(!showJSON)}
variant="outline"
size="sm"
>
<FileText size={16} weight="duotone" className="mr-2" />
{showJSON ? 'Hide' : 'Show'} JSON Schema
</Button>
</div>
{showJSON && (
<pre className="bg-secondary/50 p-4 rounded-md overflow-auto text-xs font-mono max-h-96">
{JSON.stringify(loadedSchema, null, 2)}
</pre>
)}
</CardContent>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Database size={20} weight="duotone" className="text-blue-500" />
Schema-Driven
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
UI structure defined in JSON, making it easy to modify without code changes
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Code size={20} weight="duotone" className="text-green-500" />
Data Bindings
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Dynamic expressions in JSON connect UI to application state seamlessly
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<FileText size={20} weight="duotone" className="text-purple-500" />
Atomic Design
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Modular components composed from atoms to organisms following best practices
</p>
</CardContent>
</Card>
</div>
<div className="border-t border-border pt-6">
<h2 className="text-2xl font-bold mb-4">Rendered from JSON</h2>
<p className="text-muted-foreground mb-6">
The content below is entirely generated from the JSON schema above, demonstrating data bindings,
loops, and component composition.
</p>
<PageRenderer schema={loadedSchema} data={data} functions={functions} />
</div>
</div>
</div>
)
}

View File

@@ -12,4 +12,4 @@ export { SchemaEditorLayout } from './SchemaEditorLayout'
export { EmptyCanvasState } from './EmptyCanvasState'
export { SchemaEditorStatusBar } from './SchemaEditorStatusBar'
export { SchemaCodeViewer } from './SchemaCodeViewer'
export { JSONUIShowcase } from './JSONUIShowcase'
export { JSONUIShowcase } from '../JSONUIShowcase'

View File

@@ -1,236 +0,0 @@
{
"id": "json-ui-dashboard",
"title": "JSON-Driven Dashboard",
"description": "A complete UI page rendered from JSON declarations",
"layout": {
"type": "flex",
"direction": "column",
"gap": "6",
"className": "h-full overflow-auto p-6"
},
"components": [
{
"id": "page-header",
"type": "div",
"props": {
"className": "mb-6"
},
"children": [
{
"id": "page-title",
"type": "div",
"props": {
"className": "text-3xl font-bold mb-2"
},
"children": [
{
"id": "title-text",
"type": "div",
"props": {
"children": "JSON-Driven UI System"
}
}
]
},
{
"id": "page-description",
"type": "div",
"props": {
"className": "text-muted-foreground",
"children": "This entire page is rendered from JSON schemas with data bindings"
}
}
]
},
{
"id": "stat-cards-grid",
"type": "div",
"props": {
"className": "grid gap-4 md:grid-cols-2 lg:grid-cols-3"
},
"children": [
{
"id": "stat-card-1",
"type": "Card",
"children": [
{
"id": "stat-card-1-header",
"type": "CardHeader",
"children": [
{
"id": "stat-card-1-title",
"type": "CardTitle",
"props": {
"className": "flex items-center gap-2"
},
"children": [
{
"id": "stat-1-text",
"type": "div",
"props": {
"children": "{{files.length}} Files"
}
}
]
}
]
},
{
"id": "stat-card-1-content",
"type": "CardContent",
"children": [
{
"id": "stat-1-value",
"type": "div",
"props": {
"className": "text-2xl font-bold",
"children": "{{files.length}}"
}
}
]
}
]
},
{
"id": "stat-card-2",
"type": "Card",
"children": [
{
"id": "stat-card-2-header",
"type": "CardHeader",
"children": [
{
"id": "stat-card-2-title",
"type": "CardTitle",
"props": {
"children": "{{models.length}} Models"
}
}
]
},
{
"id": "stat-card-2-content",
"type": "CardContent",
"children": [
{
"id": "stat-2-value",
"type": "div",
"props": {
"className": "text-2xl font-bold",
"children": "{{models.length}}"
}
}
]
}
]
},
{
"id": "stat-card-3",
"type": "Card",
"children": [
{
"id": "stat-card-3-header",
"type": "CardHeader",
"children": [
{
"id": "stat-card-3-title",
"type": "CardTitle",
"props": {
"children": "{{components.length}} Components"
}
}
]
},
{
"id": "stat-card-3-content",
"type": "CardContent",
"children": [
{
"id": "stat-3-value",
"type": "div",
"props": {
"className": "text-2xl font-bold",
"children": "{{components.length}}"
}
}
]
}
]
}
]
},
{
"id": "files-section",
"type": "Card",
"props": {
"className": "mt-6"
},
"children": [
{
"id": "files-header",
"type": "CardHeader",
"children": [
{
"id": "files-title",
"type": "CardTitle",
"props": {
"children": "Recent Files"
}
},
{
"id": "files-description",
"type": "CardDescription",
"props": {
"children": "Files loaded from your project"
}
}
]
},
{
"id": "files-content",
"type": "CardContent",
"children": [
{
"id": "files-list",
"type": "div",
"props": {
"className": "space-y-2"
},
"repeat": {
"items": "files.slice(0, 5)",
"itemVar": "file"
},
"children": [
{
"id": "file-item",
"type": "div",
"props": {
"className": "flex items-center justify-between p-2 border border-border rounded-md hover:bg-accent/50 transition-colors"
},
"children": [
{
"id": "file-name",
"type": "div",
"props": {
"className": "font-mono text-sm",
"children": "{{file.name}}"
}
},
{
"id": "file-badge",
"type": "Badge",
"props": {
"variant": "outline",
"children": "{{file.type}}"
}
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -1,7 +1,3 @@
export { useDataBinding } from './use-data-binding'
export { useEventHandlers } from './use-event-handlers'
export { useSchemaLoader } from './use-schema-loader'
export { useComponentRegistry } from './use-component-registry'
export { useDashboardMetrics } from './use-dashboard-metrics'
export { useDashboardTips } from './use-dashboard-tips'
export { useToggle } from './use-toggle'

View File

@@ -1,37 +0,0 @@
import { createElement, useMemo } from 'react'
import { uiComponentRegistry, iconComponents } from '@/lib/json-ui/component-registry'
import * as Icons from '@phosphor-icons/react'
interface ComponentRegistryOptions {
customComponents?: Record<string, React.ComponentType<any>>
}
export function useComponentRegistry({ customComponents = {} }: ComponentRegistryOptions = {}) {
const registry = useMemo(
() => ({
...uiComponentRegistry,
...customComponents,
}),
[customComponents]
)
const getComponent = (type: string): React.ComponentType<any> | null => {
return registry[type as keyof typeof registry] || null
}
const getIcon = (iconName: string, props?: any): React.ReactElement | null => {
const registryIcon = registry[iconName as keyof typeof registry]
const IconComponent =
(registryIcon && typeof registryIcon !== 'string' ? registryIcon : null) ||
iconComponents[iconName as keyof typeof iconComponents] ||
(Icons as any)[iconName]
if (!IconComponent || typeof IconComponent === 'string') return null
return createElement(IconComponent, { size: 24, weight: "duotone", ...props })
}
return {
registry,
getComponent,
getIcon,
}
}

View File

@@ -1,74 +0,0 @@
import { useCallback, useMemo } from 'react'
interface UseDataBindingOptions {
data: Record<string, any>
onError?: (error: Error, expression: string) => void
}
export function useDataBinding({ data, onError }: UseDataBindingOptions) {
const resolveBinding = useCallback(
(expression: string, fallback?: any): any => {
if (!expression) return fallback
try {
const keys = Object.keys(data)
const values = Object.values(data)
const func = new Function(...keys, `"use strict"; return (${expression})`)
return func(...values)
} catch (error) {
if (onError) {
onError(error as Error, expression)
}
console.warn(`Failed to resolve binding: ${expression}`, error)
return fallback
}
},
[data, onError]
)
const resolveCondition = useCallback(
(condition: string): boolean => {
try {
const result = resolveBinding(condition, false)
return Boolean(result)
} catch {
return false
}
},
[resolveBinding]
)
const resolveProps = useCallback(
(props: Record<string, any>): Record<string, any> => {
if (!props) return {}
const resolved: Record<string, any> = {}
for (const [key, value] of Object.entries(props)) {
if (typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}')) {
const expression = value.slice(2, -2).trim()
resolved[key] = resolveBinding(expression)
} else if (typeof value === 'object' && value !== null && value.type === 'binding') {
resolved[key] = resolveBinding(value.expression, value.fallback)
} else {
resolved[key] = value
}
}
return resolved
},
[resolveBinding]
)
const context = useMemo(
() => ({
resolveBinding,
resolveCondition,
resolveProps,
data,
}),
[resolveBinding, resolveCondition, resolveProps, data]
)
return context
}

View File

@@ -1,63 +0,0 @@
import { useCallback, useMemo } from 'react'
interface UseEventHandlersOptions {
functions?: Record<string, (...args: any[]) => any>
onError?: (error: Error, functionName: string) => void
}
export function useEventHandlers({ functions = {}, onError }: UseEventHandlersOptions) {
const createHandler = useCallback(
(functionName: string) => {
return (...args: any[]) => {
const handler = functions[functionName]
if (!handler) {
const error = new Error(`Function "${functionName}" not found`)
if (onError) {
onError(error, functionName)
} else {
console.error(error)
}
return
}
try {
return handler(...args)
} catch (error) {
if (onError) {
onError(error as Error, functionName)
} else {
console.error(`Error executing function "${functionName}":`, error)
}
}
}
},
[functions, onError]
)
const resolveEvents = useCallback(
(events?: Record<string, string>): Record<string, (...args: any[]) => any> => {
if (!events) return {}
const resolved: Record<string, (...args: any[]) => any> = {}
for (const [eventName, functionName] of Object.entries(events)) {
resolved[eventName] = createHandler(functionName)
}
return resolved
},
[createHandler]
)
const context = useMemo(
() => ({
createHandler,
resolveEvents,
functions,
}),
[createHandler, resolveEvents, functions]
)
return context
}

View File

@@ -1,60 +0,0 @@
import { useState, useCallback, useEffect } from 'react'
import { PageSchemaType } from '@/schemas/ui-schema'
interface UseSchemaLoaderOptions {
schemaUrl?: string
schema?: PageSchemaType
onError?: (error: Error) => void
}
export function useSchemaLoader({ schemaUrl, schema: initialSchema, onError }: UseSchemaLoaderOptions) {
const [schema, setSchema] = useState<PageSchemaType | null>(initialSchema || null)
const [loading, setLoading] = useState(!!schemaUrl && !initialSchema)
const [error, setError] = useState<Error | null>(null)
const loadSchema = useCallback(
async (url: string) => {
setLoading(true)
setError(null)
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`Failed to load schema: ${response.statusText}`)
}
const data = await response.json()
setSchema(data)
} catch (err) {
const error = err instanceof Error ? err : new Error('Unknown error loading schema')
setError(error)
if (onError) {
onError(error)
}
} finally {
setLoading(false)
}
},
[onError]
)
useEffect(() => {
if (schemaUrl && !initialSchema) {
loadSchema(schemaUrl)
}
}, [schemaUrl, initialSchema, loadSchema])
const reloadSchema = useCallback(() => {
if (schemaUrl) {
loadSchema(schemaUrl)
}
}, [schemaUrl, loadSchema])
return {
schema,
loading,
error,
reloadSchema,
setSchema,
}
}

View File

@@ -1,179 +0,0 @@
import { createElement, type ComponentType, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { Component as ComponentSchema, Layout } from '@/schemas/ui-schema'
import { useDataBinding, useEventHandlers, useComponentRegistry } from '@/hooks/ui'
import { getDeprecatedComponentInfo } from '@/lib/json-ui/component-registry'
interface SchemaRendererProps {
schema: ComponentSchema
data: Record<string, any>
functions?: Record<string, (...args: any[]) => any>
componentRegistry?: Record<string, ComponentType<any>>
}
interface LayoutRendererProps {
layout: Layout
children: ReactNode
}
const warnedDeprecatedComponents = new Set<string>()
const warnDeprecatedComponent = (schema: ComponentSchema) => {
const deprecatedInfo = getDeprecatedComponentInfo(schema.type)
if (!deprecatedInfo || warnedDeprecatedComponents.has(schema.type)) {
return
}
const idSuffix = schema.id ? ` (id: ${schema.id})` : ''
const replacementHint = deprecatedInfo.replacedBy
? ` Replace with "${deprecatedInfo.replacedBy}".`
: ''
const extraMessage = deprecatedInfo.message ? ` ${deprecatedInfo.message}` : ''
console.warn(
`[SchemaRenderer] Deprecated component "${schema.type}" detected in schema${idSuffix}.${replacementHint}${extraMessage}`
)
warnedDeprecatedComponents.add(schema.type)
}
function LayoutRenderer({ layout, children }: LayoutRendererProps) {
const getLayoutClasses = () => {
const classes: string[] = []
if (layout.type === 'flex') {
classes.push('flex')
if (layout.direction) {
classes.push(layout.direction === 'column' ? 'flex-col' : 'flex-row')
}
} else if (layout.type === 'grid') {
classes.push('grid')
if (layout.columns) {
const { base = 1, sm, md, lg, xl } = layout.columns
classes.push(`grid-cols-${base}`)
if (sm) classes.push(`sm:grid-cols-${sm}`)
if (md) classes.push(`md:grid-cols-${md}`)
if (lg) classes.push(`lg:grid-cols-${lg}`)
if (xl) classes.push(`xl:grid-cols-${xl}`)
}
} else if (layout.type === 'stack') {
classes.push('flex flex-col')
}
if (layout.gap) {
classes.push(`gap-${layout.gap}`)
}
if (layout.className) {
classes.push(layout.className)
}
return cn(...classes)
}
return <div className={getLayoutClasses()}>{children}</div>
}
export function SchemaRenderer({ schema, data, functions = {}, componentRegistry }: SchemaRendererProps) {
const { resolveCondition, resolveProps, resolveBinding } = useDataBinding({ data })
const { resolveEvents } = useEventHandlers({ functions })
const { getComponent, getIcon } = useComponentRegistry({ customComponents: componentRegistry })
if (schema.condition && !resolveCondition(schema.condition)) {
return null
}
if (schema.repeat) {
const items = resolveBinding(schema.repeat.items, []) as any[]
return (
<>
{items.map((item, index) => {
const itemData = {
...data,
[schema.repeat!.itemVar]: item,
...(schema.repeat!.indexVar ? { [schema.repeat!.indexVar]: index } : {}),
}
return (
<SchemaRenderer
key={index}
schema={{ ...schema, repeat: undefined }}
data={itemData}
functions={functions}
componentRegistry={componentRegistry}
/>
)
})}
</>
)
}
warnDeprecatedComponent(schema)
const props = resolveProps(schema.props || {})
const events = resolveEvents(schema.events)
const combinedProps = { ...props, ...events }
if (schema.binding) {
const iconName = resolveBinding(schema.binding)
if (typeof iconName === 'string' && schema.type === 'Icon') {
const IconComponent = getComponent(iconName)
if (IconComponent) {
return createElement(IconComponent, combinedProps)
}
return getIcon(iconName, combinedProps)
}
}
const Component = getComponent(schema.type)
if (!Component) {
console.warn(`Component type "${schema.type}" not found in registry`)
return (
<div className="border-2 border-dashed border-destructive p-4 rounded-md">
<p className="text-destructive font-mono text-sm">
Unknown component: {schema.type}
</p>
</div>
)
}
const children = schema.children?.map((child, index) => (
<SchemaRenderer
key={child.id || index}
schema={child}
data={data}
functions={functions}
componentRegistry={componentRegistry}
/>
))
return <Component {...combinedProps}>{children}</Component>
}
interface PageRendererProps {
schema: {
id: string
title?: string
description?: string
layout: Layout
components: ComponentSchema[]
}
data: Record<string, any>
functions?: Record<string, (...args: any[]) => any>
componentRegistry?: Record<string, ComponentType<any>>
}
export function PageRenderer({ schema, data, functions = {}, componentRegistry }: PageRendererProps) {
return (
<LayoutRenderer layout={schema.layout}>
{schema.components.map((component) => (
<SchemaRenderer
key={component.id}
schema={component}
data={data}
functions={functions}
componentRegistry={componentRegistry}
/>
))}
</LayoutRenderer>
)
}

View File

@@ -1,57 +0,0 @@
import { z } from 'zod'
export const BindingSchema = z.object({
type: z.literal('binding'),
expression: z.string(),
fallback: z.any().optional(),
})
export const ComponentSchema: z.ZodType<any> = z.lazy(() =>
z.object({
id: z.string(),
type: z.string(),
props: z.record(z.string(), z.any()).optional(),
children: z.array(ComponentSchema).optional(),
binding: z.string().optional(),
condition: z.string().optional(),
repeat: z
.object({
items: z.string(),
itemVar: z.string(),
indexVar: z.string().optional(),
})
.optional(),
events: z.record(z.string(), z.string()).optional(),
})
)
export const LayoutSchema = z.object({
type: z.enum(['flex', 'grid', 'stack', 'custom']),
direction: z.enum(['row', 'column']).optional(),
gap: z.string().optional(),
columns: z
.object({
base: z.number().optional(),
sm: z.number().optional(),
md: z.number().optional(),
lg: z.number().optional(),
xl: z.number().optional(),
})
.optional(),
className: z.string().optional(),
})
export const PageSchema = z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
layout: LayoutSchema,
components: z.array(ComponentSchema),
dataBindings: z.array(z.string()).optional(),
functions: z.record(z.string(), z.string()).optional(),
})
export type Binding = z.infer<typeof BindingSchema>
export type Component = z.infer<typeof ComponentSchema>
export type Layout = z.infer<typeof LayoutSchema>
export type PageSchemaType = z.infer<typeof PageSchema>