Render JSON wrappers from schema definitions

This commit is contained in:
2026-01-18 13:03:14 +00:00
parent 6df9c0c3dd
commit 21ef3d1d3e
5 changed files with 656 additions and 199 deletions

View File

@@ -205,6 +205,11 @@ export function ComponentRenderer({ component, data, context = {}, state, onEven
return null
}
const resolvedChildren = component.children ?? resolvedProps.children
if (resolvedChildren !== undefined && resolvedChildren !== component.children) {
delete resolvedProps.children
}
if (component.loop) {
const items = resolveDataBinding(component.loop.source, data, context, { state, bindings: context }) || []
const loopChildren = items.map((item: unknown, index: number) => {
@@ -232,7 +237,7 @@ export function ComponentRenderer({ component, data, context = {}, state, onEven
return (
<Fragment key={`${component.id}-${index}`}>
{renderChildren(component.children, loopContext)}
{renderChildren(resolvedChildren as UIComponent[] | string | undefined, loopContext)}
</Fragment>
)
})
@@ -254,5 +259,9 @@ export function ComponentRenderer({ component, data, context = {}, state, onEven
}
}
return createElement(Component, resolvedProps, renderChildren(component.children, context))
return createElement(
Component,
resolvedProps,
renderChildren(resolvedChildren as UIComponent[] | string | undefined, context)
)
}

View File

@@ -1,8 +1,7 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { ChangeEvent } from 'react'
import { ComponentRenderer } from '@/lib/json-ui/component-renderer'
import { cn } from '@/lib/utils'
import { componentBindingDialogDefinition } from './definitions'
import type { ComponentBindingDialogWrapperProps } from './interfaces'
export function ComponentBindingDialogWrapper({
@@ -18,58 +17,31 @@ export function ComponentBindingDialogWrapper({
onOpenChange,
className,
}: ComponentBindingDialogWrapperProps) {
const handleBindingFieldChange = (event: ChangeEvent<HTMLInputElement>) => {
const fieldId = event.currentTarget?.dataset?.fieldId || event.target?.dataset?.fieldId
if (!fieldId) return
onBindingChange?.(fieldId, event.target.value)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn('max-w-2xl', className)}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{(componentType || componentId) && (
<div className="rounded-md border border-border bg-muted/30 p-3 text-sm">
{componentType && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Component:</span>
<span className="font-mono font-medium">{componentType}</span>
</div>
)}
{componentId && (
<div className="flex items-center gap-2">
<span className="text-muted-foreground">ID:</span>
<span className="font-mono text-xs">{componentId}</span>
</div>
)}
</div>
)}
<div className="space-y-4">
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground">No bindings configured.</p>
) : (
bindings.map((binding) => (
<div key={binding.id} className="space-y-2">
<Label htmlFor={`binding-${binding.id}`}>{binding.label}</Label>
<Input
id={`binding-${binding.id}`}
value={binding.value ?? ''}
placeholder={binding.placeholder}
onChange={(event) => onBindingChange?.(binding.id, event.target.value)}
/>
</div>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ComponentRenderer
component={componentBindingDialogDefinition}
data={{
open,
title,
description,
componentType,
componentId,
bindingFields: bindings,
emptyMessage: 'No bindings configured.',
contentClassName: cn('max-w-2xl', className),
onBindingFieldChange: handleBindingFieldChange,
onSave,
onCancel,
onOpenChange,
cancelLabel: 'Cancel',
saveLabel: 'Save',
}}
/>
)
}

View File

@@ -1,8 +1,7 @@
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { ChangeEvent } from 'react'
import { ComponentRenderer } from '@/lib/json-ui/component-renderer'
import { cn } from '@/lib/utils'
import { dataSourceEditorDialogDefinition } from './definitions'
import type { DataSourceEditorDialogWrapperProps } from './interfaces'
export function DataSourceEditorDialogWrapper({
@@ -16,44 +15,29 @@ export function DataSourceEditorDialogWrapper({
onOpenChange,
className,
}: DataSourceEditorDialogWrapperProps) {
const handleFieldChange = (event: ChangeEvent<HTMLInputElement>) => {
const fieldId = event.currentTarget?.dataset?.fieldId || event.target?.dataset?.fieldId
if (!fieldId) return
onFieldChange?.(fieldId, event.target.value)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn('max-w-2xl', className)}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{fields.length === 0 ? (
<p className="text-sm text-muted-foreground">No fields configured.</p>
) : (
fields.map((field) => (
<div key={field.id} className="space-y-2">
<Label htmlFor={`field-${field.id}`}>{field.label}</Label>
<Input
id={`field-${field.id}`}
value={field.value ?? ''}
placeholder={field.placeholder}
onChange={(event) => onFieldChange?.(field.id, event.target.value)}
/>
{field.helperText && (
<p className="text-xs text-muted-foreground">{field.helperText}</p>
)}
</div>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ComponentRenderer
component={dataSourceEditorDialogDefinition}
data={{
open,
title,
description,
fields,
emptyMessage: 'No fields configured.',
contentClassName: cn('max-w-2xl', className),
onFieldChange: handleFieldChange,
onSave,
onCancel,
onOpenChange,
cancelLabel: 'Cancel',
saveLabel: 'Save',
}}
/>
)
}

View File

@@ -1,50 +1,25 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ComponentRenderer } from '@/lib/json-ui/component-renderer'
import { cn } from '@/lib/utils'
import {
ArrowSquareOut,
CheckCircle,
Clock,
WarningCircle,
XCircle,
} from '@phosphor-icons/react'
import { gitHubBuildStatusDefinition } from './definitions'
import type { GitHubBuildStatusWrapperProps, GitHubBuildStatusWorkflowItem } from './interfaces'
const getStatusBadge = (workflow: GitHubBuildStatusWorkflowItem) => {
const getWorkflowStatus = (workflow: GitHubBuildStatusWorkflowItem) => {
if (workflow.status === 'completed') {
if (workflow.conclusion === 'success') {
return <Badge className="bg-green-500/10 text-green-600 border-green-500/20">Success</Badge>
return {
label: 'Success',
className: 'bg-green-500/10 text-green-600 border-green-500/20',
}
}
if (workflow.conclusion === 'failure') {
return <Badge variant="destructive">Failed</Badge>
return { label: 'Failed', className: 'bg-red-500/10 text-red-600 border-red-500/20' }
}
if (workflow.conclusion === 'cancelled') {
return <Badge variant="secondary">Cancelled</Badge>
return { label: 'Cancelled', className: 'bg-yellow-500/10 text-yellow-600 border-yellow-500/20' }
}
}
return (
<Badge variant="outline" className="border-blue-500/50 text-blue-500">
Running
</Badge>
)
}
const getStatusIcon = (workflow: GitHubBuildStatusWorkflowItem) => {
if (workflow.status === 'completed') {
if (workflow.conclusion === 'success') {
return <CheckCircle size={18} className="text-green-500" weight="fill" />
}
if (workflow.conclusion === 'failure') {
return <XCircle size={18} className="text-red-500" weight="fill" />
}
if (workflow.conclusion === 'cancelled') {
return <WarningCircle size={18} className="text-yellow-500" weight="fill" />
}
}
return <Clock size={18} className="text-blue-500" weight="duotone" />
return { label: 'Running', className: 'border-blue-500/50 text-blue-500' }
}
export function GitHubBuildStatusWrapper({
@@ -58,70 +33,32 @@ export function GitHubBuildStatusWrapper({
footerLinkUrl,
className,
}: GitHubBuildStatusWrapperProps) {
const normalizedWorkflows = workflows.map((workflow) => {
const status = getWorkflowStatus(workflow)
return {
...workflow,
statusLabel: status.label,
statusClass: status.className,
summaryLine: [workflow.branch, workflow.updatedAt, workflow.event].filter(Boolean).join(' • '),
}
})
return (
<Card className={cn(className)}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ArrowSquareOut size={18} weight="duotone" />
{title}
</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading && <p className="text-sm text-muted-foreground">Loading workflows</p>}
{!isLoading && errorMessage && (
<div className="flex items-center gap-2 text-sm text-red-500">
<WarningCircle size={16} weight="fill" />
<span>{errorMessage}</span>
</div>
)}
{!isLoading && !errorMessage && workflows.length === 0 && (
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
)}
{!isLoading && !errorMessage && workflows.length > 0 && (
<div className="space-y-3">
{workflows.map((workflow) => (
<div
key={workflow.id}
className="flex items-center justify-between gap-3 rounded-lg border border-border p-3"
>
<div className="flex items-center gap-3 min-w-0">
{getStatusIcon(workflow)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{workflow.name}</p>
{getStatusBadge(workflow)}
</div>
<div className="text-xs text-muted-foreground truncate">
{[workflow.branch, workflow.updatedAt, workflow.event]
.filter(Boolean)
.join(' • ')}
</div>
</div>
</div>
{workflow.url && (
<Button variant="ghost" size="sm" asChild>
<a href={workflow.url} target="_blank" rel="noopener noreferrer">
<ArrowSquareOut size={14} />
</a>
</Button>
)}
</div>
))}
</div>
)}
{footerLinkUrl && (
<Button variant="outline" size="sm" asChild className="w-full">
<a href={footerLinkUrl} target="_blank" rel="noopener noreferrer">
{footerLinkLabel}
</a>
</Button>
)}
</CardContent>
</Card>
<ComponentRenderer
component={gitHubBuildStatusDefinition}
data={{
title,
description,
workflows: normalizedWorkflows,
isLoading,
errorMessage,
emptyMessage,
loadingMessage: 'Loading workflows…',
hasWorkflows: normalizedWorkflows.length > 0,
footerLinkLabel,
footerLinkUrl,
className: cn(className),
}}
/>
)
}

View File

@@ -0,0 +1,555 @@
import type { UIComponent } from '@/types/json-ui'
export const componentBindingDialogDefinition: UIComponent = {
id: 'component-binding-dialog',
type: 'Dialog',
bindings: {
open: 'open',
onOpenChange: 'onOpenChange',
},
children: [
{
id: 'component-binding-dialog-content',
type: 'DialogContent',
bindings: {
className: 'contentClassName',
},
children: [
{
id: 'component-binding-dialog-header',
type: 'DialogHeader',
children: [
{
id: 'component-binding-dialog-title',
type: 'DialogTitle',
bindings: {
children: 'title',
},
},
{
id: 'component-binding-dialog-description',
type: 'DialogDescription',
bindings: {
children: 'description',
},
},
],
},
{
id: 'component-binding-dialog-info',
type: 'div',
props: {
className: 'rounded-md border border-border bg-muted/30 p-3 text-sm',
},
conditional: {
if: 'componentType || componentId',
},
children: [
{
id: 'component-binding-dialog-type',
type: 'div',
props: {
className: 'flex items-center gap-2',
},
conditional: {
if: 'componentType',
},
children: [
{
id: 'component-binding-dialog-type-label',
type: 'span',
props: {
className: 'text-muted-foreground',
},
children: 'Component:',
},
{
id: 'component-binding-dialog-type-value',
type: 'span',
props: {
className: 'font-mono font-medium',
},
bindings: {
children: 'componentType',
},
},
],
},
{
id: 'component-binding-dialog-id',
type: 'div',
props: {
className: 'flex items-center gap-2',
},
conditional: {
if: 'componentId',
},
children: [
{
id: 'component-binding-dialog-id-label',
type: 'span',
props: {
className: 'text-muted-foreground',
},
children: 'ID:',
},
{
id: 'component-binding-dialog-id-value',
type: 'span',
props: {
className: 'font-mono text-xs',
},
bindings: {
children: 'componentId',
},
},
],
},
],
},
{
id: 'component-binding-dialog-body',
type: 'div',
props: {
className: 'space-y-4',
},
children: [
{
id: 'component-binding-dialog-empty',
type: 'p',
props: {
className: 'text-sm text-muted-foreground',
},
bindings: {
children: 'emptyMessage',
},
conditional: {
if: '!bindingFields || bindingFields.length === 0',
},
},
{
id: 'component-binding-dialog-fields',
type: 'div',
props: {
className: 'space-y-4',
},
conditional: {
if: 'bindingFields && bindingFields.length > 0',
},
loop: {
source: 'bindingFields',
itemVar: 'field',
},
children: [
{
id: 'component-binding-dialog-field',
type: 'div',
props: {
className: 'space-y-2',
},
children: [
{
id: 'component-binding-dialog-field-label',
type: 'Label',
bindings: {
children: 'field.label',
},
},
{
id: 'component-binding-dialog-field-input',
type: 'Input',
bindings: {
value: 'field.value',
placeholder: 'field.placeholder',
onChange: 'onBindingFieldChange',
'data-field-id': 'field.id',
},
},
],
},
],
},
],
},
{
id: 'component-binding-dialog-footer',
type: 'DialogFooter',
children: [
{
id: 'component-binding-dialog-cancel',
type: 'Button',
props: {
variant: 'outline',
},
bindings: {
onClick: 'onCancel',
children: 'cancelLabel',
},
},
{
id: 'component-binding-dialog-save',
type: 'Button',
bindings: {
onClick: 'onSave',
children: 'saveLabel',
},
},
],
},
],
},
],
}
export const dataSourceEditorDialogDefinition: UIComponent = {
id: 'data-source-editor-dialog',
type: 'Dialog',
bindings: {
open: 'open',
onOpenChange: 'onOpenChange',
},
children: [
{
id: 'data-source-editor-dialog-content',
type: 'DialogContent',
bindings: {
className: 'contentClassName',
},
children: [
{
id: 'data-source-editor-dialog-header',
type: 'DialogHeader',
children: [
{
id: 'data-source-editor-dialog-title',
type: 'DialogTitle',
bindings: {
children: 'title',
},
},
{
id: 'data-source-editor-dialog-description',
type: 'DialogDescription',
bindings: {
children: 'description',
},
},
],
},
{
id: 'data-source-editor-dialog-body',
type: 'div',
props: {
className: 'space-y-4',
},
children: [
{
id: 'data-source-editor-dialog-empty',
type: 'p',
props: {
className: 'text-sm text-muted-foreground',
},
bindings: {
children: 'emptyMessage',
},
conditional: {
if: '!fields || fields.length === 0',
},
},
{
id: 'data-source-editor-dialog-fields',
type: 'div',
props: {
className: 'space-y-4',
},
conditional: {
if: 'fields && fields.length > 0',
},
loop: {
source: 'fields',
itemVar: 'field',
},
children: [
{
id: 'data-source-editor-dialog-field',
type: 'div',
props: {
className: 'space-y-2',
},
children: [
{
id: 'data-source-editor-dialog-field-label',
type: 'Label',
bindings: {
children: 'field.label',
},
},
{
id: 'data-source-editor-dialog-field-input',
type: 'Input',
bindings: {
value: 'field.value',
placeholder: 'field.placeholder',
onChange: 'onFieldChange',
'data-field-id': 'field.id',
},
},
{
id: 'data-source-editor-dialog-field-helper',
type: 'p',
props: {
className: 'text-xs text-muted-foreground',
},
bindings: {
children: 'field.helperText',
},
conditional: {
if: 'field.helperText',
},
},
],
},
],
},
],
},
{
id: 'data-source-editor-dialog-footer',
type: 'DialogFooter',
children: [
{
id: 'data-source-editor-dialog-cancel',
type: 'Button',
props: {
variant: 'outline',
},
bindings: {
onClick: 'onCancel',
children: 'cancelLabel',
},
},
{
id: 'data-source-editor-dialog-save',
type: 'Button',
bindings: {
onClick: 'onSave',
children: 'saveLabel',
},
},
],
},
],
},
],
}
export const gitHubBuildStatusDefinition: UIComponent = {
id: 'github-build-status-card',
type: 'Card',
bindings: {
className: 'className',
},
children: [
{
id: 'github-build-status-header',
type: 'CardHeader',
children: [
{
id: 'github-build-status-title',
type: 'CardTitle',
props: {
className: 'flex items-center gap-2',
},
bindings: {
children: 'title',
},
},
{
id: 'github-build-status-description',
type: 'CardDescription',
bindings: {
children: 'description',
},
},
],
},
{
id: 'github-build-status-content',
type: 'CardContent',
props: {
className: 'space-y-4',
},
children: [
{
id: 'github-build-status-loading',
type: 'p',
props: {
className: 'text-sm text-muted-foreground',
},
bindings: {
children: 'loadingMessage',
},
conditional: {
if: 'isLoading',
},
},
{
id: 'github-build-status-error',
type: 'div',
props: {
className: 'flex items-center gap-2 text-sm text-red-500',
},
conditional: {
if: 'errorMessage',
},
children: [
{
id: 'github-build-status-error-text',
type: 'span',
bindings: {
children: 'errorMessage',
},
},
],
},
{
id: 'github-build-status-empty',
type: 'p',
props: {
className: 'text-sm text-muted-foreground',
},
bindings: {
children: 'emptyMessage',
},
conditional: {
if: '!isLoading && !errorMessage && !hasWorkflows',
},
},
{
id: 'github-build-status-list',
type: 'div',
props: {
className: 'space-y-3',
},
conditional: {
if: 'hasWorkflows',
},
loop: {
source: 'workflows',
itemVar: 'workflow',
},
children: [
{
id: 'github-build-status-item',
type: 'div',
props: {
className: 'flex items-center justify-between gap-3 rounded-lg border border-border p-3',
},
children: [
{
id: 'github-build-status-item-info',
type: 'div',
props: {
className: 'min-w-0',
},
children: [
{
id: 'github-build-status-item-row',
type: 'div',
props: {
className: 'flex items-center gap-2',
},
children: [
{
id: 'github-build-status-item-name',
type: 'p',
props: {
className: 'text-sm font-medium truncate',
},
bindings: {
children: 'workflow.name',
},
},
{
id: 'github-build-status-item-badge',
type: 'Badge',
bindings: {
className: 'workflow.statusClass',
children: 'workflow.statusLabel',
},
},
],
},
{
id: 'github-build-status-item-meta',
type: 'div',
props: {
className: 'text-xs text-muted-foreground truncate',
},
bindings: {
children: 'workflow.summaryLine',
},
},
],
},
{
id: 'github-build-status-item-link',
type: 'Button',
props: {
variant: 'ghost',
size: 'sm',
asChild: true,
},
conditional: {
if: 'workflow.url',
},
children: [
{
id: 'github-build-status-item-anchor',
type: 'a',
bindings: {
href: 'workflow.url',
},
props: {
target: '_blank',
rel: 'noopener noreferrer',
},
children: 'View',
},
],
},
],
},
],
},
{
id: 'github-build-status-footer',
type: 'Button',
props: {
variant: 'outline',
size: 'sm',
asChild: true,
className: 'w-full',
},
conditional: {
if: 'footerLinkUrl',
},
children: [
{
id: 'github-build-status-footer-anchor',
type: 'a',
bindings: {
href: 'footerLinkUrl',
children: 'footerLinkLabel',
},
props: {
target: '_blank',
rel: 'noopener noreferrer',
},
},
],
},
],
},
],
}