Generated by Spark: Add data source binding UI to connect components to KV storage and computed values

This commit is contained in:
2026-01-17 11:10:54 +00:00
committed by GitHub
parent 9b9f0da541
commit d84c221e6e
19 changed files with 1612 additions and 47 deletions

391
DATA_BINDING_GUIDE.md Normal file
View File

@@ -0,0 +1,391 @@
# Data Source Binding Guide
## Overview
The Data Source Binding system enables declarative data management in CodeForge applications. Instead of manually managing React state, you define data sources and bind them directly to component properties.
## Data Source Types
### 1. KV Store (`kv`)
Persistent data storage backed by the Spark KV API. Perfect for user preferences, application state, and any data that needs to survive page refreshes.
```typescript
{
id: 'userProfile',
type: 'kv',
key: 'user-profile-data',
defaultValue: {
name: 'John Doe',
email: 'john@example.com',
preferences: {
theme: 'dark',
notifications: true
}
}
}
```
**Use cases:**
- User profiles and preferences
- Todo lists and task management
- Shopping cart data
- Form drafts
- Application settings
### 2. Computed Values (`computed`)
Derived data that automatically updates when dependencies change. Great for calculations, formatted strings, and aggregated data.
```typescript
{
id: 'displayName',
type: 'computed',
compute: (data) => {
const profile = data.userProfile
return `${profile?.name || 'Guest'} (${profile?.email || 'N/A'})`
},
dependencies: ['userProfile']
}
```
**Use cases:**
- Formatted display text
- Calculated totals and statistics
- Filtered/sorted lists
- Conditional values
- Data transformations
### 3. Static Data (`static`)
Constant values that don't change during the session. Useful for configuration and reference data.
```typescript
{
id: 'appConfig',
type: 'static',
defaultValue: {
apiUrl: 'https://api.example.com',
version: '1.0.0',
features: ['chat', 'notifications']
}
}
```
**Use cases:**
- API endpoints and configuration
- Feature flags
- Reference data (countries, categories)
- Constants
- Initial form values
## Binding Properties
Once you have data sources, bind them to component properties:
```typescript
{
id: 'welcome-heading',
type: 'Heading',
bindings: {
children: {
source: 'displayName'
}
}
}
```
### Path-based Bindings
Access nested properties using dot notation:
```typescript
{
id: 'email-input',
type: 'Input',
bindings: {
value: {
source: 'userProfile',
path: 'email'
}
}
}
```
### Transform Functions
Apply transformations to bound values:
```typescript
{
id: 'price-display',
type: 'Text',
bindings: {
children: {
source: 'price',
transform: (value) => `$${(value / 100).toFixed(2)}`
}
}
}
```
## Dependency Tracking
Computed sources automatically re-calculate when their dependencies change:
```typescript
// Stats computed source depends on todos
{
id: 'stats',
type: 'computed',
compute: (data) => ({
total: data.todos?.length || 0,
completed: data.todos?.filter(t => t.completed).length || 0,
remaining: data.todos?.filter(t => !t.completed).length || 0
}),
dependencies: ['todos']
}
// When todos updates, stats automatically updates too
```
## Best Practices
### 1. Use KV for Persistence
If data needs to survive page refreshes, use a KV source:
```typescript
{ id: 'cart', type: 'kv', key: 'shopping-cart', defaultValue: [] }
{ id: 'cart', type: 'static', defaultValue: [] } // Will reset on refresh
```
### 2. Keep Computed Functions Pure
Computed functions should be deterministic and not have side effects:
```typescript
compute: (data) => data.items.filter(i => i.active)
compute: (data) => {
toast.info('Computing...') // Side effect!
return data.items.filter(i => i.active)
}
```
### 3. Declare All Dependencies
Always list dependencies for computed sources:
```typescript
dependencies: ['todos', 'filter']
dependencies: [] // Missing dependencies!
```
### 4. Use Meaningful IDs
Choose descriptive IDs that clearly indicate the data's purpose:
```typescript
id: 'userProfile'
id: 'todoStats'
id: 'data1'
id: 'temp'
```
### 5. Structure Data Logically
Organize related data in nested objects:
```typescript
{
id: 'settings',
type: 'kv',
defaultValue: {
theme: 'dark',
notifications: true,
language: 'en'
}
}
Multiple separate sources for related data
```
## Complete Example
Here's a full example with multiple data sources and bindings:
```typescript
{
dataSources: [
// KV storage for tasks
{
id: 'tasks',
type: 'kv',
key: 'user-tasks',
defaultValue: []
},
// Static filter options
{
id: 'filterOptions',
type: 'static',
defaultValue: ['all', 'active', 'completed']
},
// Current filter selection
{
id: 'currentFilter',
type: 'kv',
key: 'task-filter',
defaultValue: 'all'
},
// Computed filtered tasks
{
id: 'filteredTasks',
type: 'computed',
compute: (data) => {
const filter = data.currentFilter
const tasks = data.tasks || []
if (filter === 'all') return tasks
if (filter === 'active') return tasks.filter(t => !t.completed)
if (filter === 'completed') return tasks.filter(t => t.completed)
return tasks
},
dependencies: ['tasks', 'currentFilter']
},
// Computed statistics
{
id: 'taskStats',
type: 'computed',
compute: (data) => ({
total: data.tasks?.length || 0,
active: data.tasks?.filter(t => !t.completed).length || 0,
completed: data.tasks?.filter(t => t.completed).length || 0
}),
dependencies: ['tasks']
}
],
components: [
// Display total count
{
id: 'total-badge',
type: 'Badge',
bindings: {
children: {
source: 'taskStats',
path: 'total'
}
}
},
// List filtered tasks
{
id: 'task-list',
type: 'List',
bindings: {
items: {
source: 'filteredTasks'
}
}
}
]
}
```
## UI Components
### Data Source Manager
The `DataSourceManager` component provides a visual interface for creating and managing data sources:
- Create KV, computed, and static sources
- Edit source configuration
- View dependency relationships
- Delete sources (with safety checks)
### Binding Editor
The `BindingEditor` component allows you to bind component properties to data sources:
- Select properties to bind
- Choose data sources
- Specify nested paths
- Preview bindings
### Component Binding Dialog
Open a dialog to edit all bindings for a specific component with live preview.
## Hooks
### useDataSources
The core hook that manages all data sources:
```typescript
import { useDataSources } from '@/hooks/data/use-data-sources'
const { data, updateData, updatePath, loading } = useDataSources(dataSources)
// Access data
const userProfile = data.userProfile
// Update entire source
updateData('userProfile', newProfile)
// Update nested property
updatePath('userProfile', 'email', 'newemail@example.com')
```
### useDataSourceManager
Hook for managing the data source configuration:
```typescript
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
const {
dataSources,
addDataSource,
updateDataSource,
deleteDataSource,
getDataSource,
getDependents
} = useDataSourceManager(initialSources)
```
## Tips & Tricks
### Avoiding Circular Dependencies
Never create circular dependencies between computed sources:
```typescript
Bad:
{
id: 'a',
type: 'computed',
compute: (data) => data.b + 1,
dependencies: ['b']
},
{
id: 'b',
type: 'computed',
compute: (data) => data.a + 1,
dependencies: ['a']
}
```
### Optimizing Computed Sources
Keep compute functions fast and efficient:
```typescript
Fast:
compute: (data) => data.items.length
Slow:
compute: (data) => {
let result = 0
for (let i = 0; i < 1000000; i++) {
result += Math.random()
}
return result
}
```
### Testing Data Sources
Test your data sources independently:
```typescript
const source = {
id: 'stats',
type: 'computed',
compute: (data) => ({ total: data.items.length }),
dependencies: ['items']
}
const testData = { items: [1, 2, 3] }
const result = source.compute(testData)
// result: { total: 3 }
```

7
PRD.md
View File

@@ -20,6 +20,13 @@ This is an advanced system that interprets JSON schemas, manages state across mu
- **Progression**: Select component from palette → Drag to canvas → Drop at position → Configure properties → Preview result → Export JSON
- **Success criteria**: Users can create complete page schemas visually, with property editing, component tree view, and JSON export
### Data Source Binding UI
- **Functionality**: Visual interface for connecting components to KV storage and computed values with dependency tracking
- **Purpose**: Enable declarative data management without manual state handling
- **Trigger**: User opens data binding designer or edits component bindings in schema editor
- **Progression**: Create data source → Configure type (KV/computed/static) → Set up dependencies → Bind to component properties → Test reactive updates
- **Success criteria**: Users can create KV stores, computed values, and static data, then bind them to components with automatic reactive updates
### JSON Schema Parser
- **Functionality**: Parse and validate JSON UI schemas with full TypeScript type safety
- **Purpose**: Enable building UIs from configuration rather than code

View File

@@ -25,6 +25,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = {
faviconDesigner: true,
ideaCloud: true,
schemaEditor: true,
dataBinding: true,
}
function App() {

View File

@@ -0,0 +1,225 @@
import { useState } from 'react'
import { DataSourceManager } from '@/components/organisms/DataSourceManager'
import { ComponentBindingDialog } from '@/components/molecules/ComponentBindingDialog'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { DataSource, UIComponent } from '@/types/json-ui'
import { Link, Code } from '@phosphor-icons/react'
import { ScrollArea } from '@/components/ui/scroll-area'
export function DataBindingDesigner() {
const [dataSources, setDataSources] = useState<DataSource[]>([
{
id: 'userProfile',
type: 'kv',
key: 'user-profile',
defaultValue: { name: 'John Doe', email: 'john@example.com' },
},
{
id: 'counter',
type: 'kv',
key: 'app-counter',
defaultValue: 0,
},
{
id: 'displayName',
type: 'computed',
compute: (data) => `Welcome, ${data.userProfile?.name || 'Guest'}!`,
dependencies: ['userProfile'],
},
])
const [mockComponents] = useState<UIComponent[]>([
{
id: 'title',
type: 'Heading',
props: { className: 'text-2xl font-bold' },
bindings: {
children: { source: 'displayName' },
},
},
{
id: 'counter-display',
type: 'Text',
props: { className: 'text-lg' },
bindings: {
children: { source: 'counter' },
},
},
{
id: 'email-input',
type: 'Input',
props: { placeholder: 'Enter email' },
bindings: {
value: { source: 'userProfile', path: 'email' },
},
},
])
const [selectedComponent, setSelectedComponent] = useState<UIComponent | null>(null)
const [bindingDialogOpen, setBindingDialogOpen] = useState(false)
const handleEditBinding = (component: UIComponent) => {
setSelectedComponent(component)
setBindingDialogOpen(true)
}
const handleSaveBinding = (updatedComponent: UIComponent) => {
console.log('Updated component bindings:', updatedComponent)
}
const getSourceById = (sourceId: string) => {
return dataSources.find(ds => ds.id === sourceId)
}
return (
<div className="h-full overflow-auto">
<div className="p-6 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2 bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent">
Data Binding Designer
</h1>
<p className="text-muted-foreground">
Connect UI components to KV storage and computed values
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-6">
<DataSourceManager
dataSources={dataSources}
onChange={setDataSources}
/>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Code className="w-5 h-5" />
Component Bindings
</CardTitle>
<CardDescription>
Example components with data bindings
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px] pr-4">
<div className="space-y-3">
{mockComponents.map(component => {
const bindingCount = Object.keys(component.bindings || {}).length
return (
<Card key={component.id} className="bg-card/50 backdrop-blur">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="font-mono text-xs">
{component.type}
</Badge>
<span className="text-sm text-muted-foreground">
#{component.id}
</span>
</div>
{bindingCount > 0 ? (
<div className="space-y-1">
{Object.entries(component.bindings || {}).map(([prop, binding]) => {
const source = getSourceById(binding.source)
return (
<div key={prop} className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground font-mono">
{prop}:
</span>
<Badge
variant="secondary"
className="font-mono h-5 text-xs"
>
{binding.source}
{binding.path && `.${binding.path}`}
</Badge>
{source && (
<Badge
variant="outline"
className="h-5 text-xs"
>
{source.type}
</Badge>
)}
</div>
)
})}
</div>
) : (
<p className="text-xs text-muted-foreground">
No bindings configured
</p>
)}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleEditBinding(component)}
className="h-8 px-3"
>
<Link className="w-4 h-4 mr-1" />
Bind
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="bg-accent/5 border-accent/20">
<CardHeader>
<CardTitle className="text-base">How It Works</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex gap-2">
<div className="w-6 h-6 rounded-full bg-primary/20 text-primary flex items-center justify-center text-xs font-bold flex-shrink-0">
1
</div>
<p className="text-muted-foreground">
Create data sources (KV store for persistence, static for constants)
</p>
</div>
<div className="flex gap-2">
<div className="w-6 h-6 rounded-full bg-primary/20 text-primary flex items-center justify-center text-xs font-bold flex-shrink-0">
2
</div>
<p className="text-muted-foreground">
Add computed sources to derive values from other sources
</p>
</div>
<div className="flex gap-2">
<div className="w-6 h-6 rounded-full bg-primary/20 text-primary flex items-center justify-center text-xs font-bold flex-shrink-0">
3
</div>
<p className="text-muted-foreground">
Bind component properties to data sources for reactive updates
</p>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
<ComponentBindingDialog
open={bindingDialogOpen}
component={selectedComponent}
dataSources={dataSources}
onOpenChange={setBindingDialogOpen}
onSave={handleSaveBinding}
/>
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { Link } from '@phosphor-icons/react'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
interface BindingIndicatorProps {
sourceId: string
path?: string
className?: string
}
export function BindingIndicator({ sourceId, path, className = '' }: BindingIndicatorProps) {
const bindingText = path ? `${sourceId}.${path}` : sourceId
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs bg-accent/10 text-accent border border-accent/30 ${className}`}>
<Link weight="bold" className="w-3 h-3" />
<span className="font-mono">{bindingText}</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">Bound to: {bindingText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,37 @@
import { Badge } from '@/components/ui/badge'
import { Database, Function, FileText } from '@phosphor-icons/react'
import { DataSourceType } from '@/types/json-ui'
interface DataSourceBadgeProps {
type: DataSourceType
className?: string
}
const icons = {
kv: Database,
computed: Function,
static: FileText,
}
const labels = {
kv: 'KV Store',
computed: 'Computed',
static: 'Static',
}
const variants = {
kv: 'bg-accent/20 text-accent border-accent/40',
computed: 'bg-primary/20 text-primary border-primary/40',
static: 'bg-muted text-muted-foreground border-border',
}
export function DataSourceBadge({ type, className = '' }: DataSourceBadgeProps) {
const Icon = icons[type]
return (
<Badge variant="outline" className={`${variants[type]} ${className}`}>
<Icon className="w-3 h-3 mr-1" />
{labels[type]}
</Badge>
)
}

View File

@@ -17,3 +17,6 @@ export { Text } from './Text'
export { Heading } from './Heading'
export { List } from './List'
export { Grid } from './Grid'
export { DataSourceBadge } from './DataSourceBadge'
export { BindingIndicator } from './BindingIndicator'

View File

@@ -0,0 +1,137 @@
import { useState } from 'react'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import { Binding, DataSource } from '@/types/json-ui'
import { BindingIndicator } from '@/components/atoms/BindingIndicator'
import { Plus, X } from '@phosphor-icons/react'
interface BindingEditorProps {
bindings: Record<string, Binding>
dataSources: DataSource[]
availableProps: string[]
onChange: (bindings: Record<string, Binding>) => void
}
export function BindingEditor({ bindings, dataSources, availableProps, onChange }: BindingEditorProps) {
const [selectedProp, setSelectedProp] = useState('')
const [selectedSource, setSelectedSource] = useState('')
const [path, setPath] = useState('')
const addBinding = () => {
if (!selectedProp || !selectedSource) return
const newBindings = {
...bindings,
[selectedProp]: {
source: selectedSource,
...(path && { path }),
},
}
onChange(newBindings)
setSelectedProp('')
setSelectedSource('')
setPath('')
}
const removeBinding = (prop: string) => {
const newBindings = { ...bindings }
delete newBindings[prop]
onChange(newBindings)
}
const boundProps = Object.keys(bindings)
const unboundProps = availableProps.filter(p => !boundProps.includes(p))
return (
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-medium">Bound Properties</Label>
{boundProps.length === 0 ? (
<p className="text-sm text-muted-foreground">No bindings yet</p>
) : (
<div className="space-y-2">
{boundProps.map(prop => (
<div key={prop} className="flex items-center justify-between p-2 bg-muted/30 rounded border border-border">
<div className="flex items-center gap-2">
<span className="text-sm font-mono">{prop}</span>
<span className="text-muted-foreground"></span>
<BindingIndicator
sourceId={bindings[prop].source}
path={bindings[prop].path}
/>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => removeBinding(prop)}
className="h-6 w-6 p-0"
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</div>
{unboundProps.length > 0 && (
<div className="space-y-3 pt-3 border-t border-border">
<Label className="text-sm font-medium">Add New Binding</Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Property</Label>
<Select value={selectedProp} onValueChange={setSelectedProp}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select property" />
</SelectTrigger>
<SelectContent>
{unboundProps.map(prop => (
<SelectItem key={prop} value={prop}>{prop}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Data Source</Label>
<Select value={selectedSource} onValueChange={setSelectedSource}>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select source" />
</SelectTrigger>
<SelectContent>
{dataSources.map(ds => (
<SelectItem key={ds.id} value={ds.id}>{ds.id}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Path (optional)</Label>
<Input
placeholder="e.g., user.name"
value={path}
onChange={(e) => setPath(e.target.value)}
className="h-9 font-mono text-sm"
/>
</div>
<Button
size="sm"
onClick={addBinding}
disabled={!selectedProp || !selectedSource}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
Add Binding
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { BindingEditor } from '@/components/molecules/BindingEditor'
import { DataSource, UIComponent } from '@/types/json-ui'
import { Link } from '@phosphor-icons/react'
interface ComponentBindingDialogProps {
open: boolean
component: UIComponent | null
dataSources: DataSource[]
onOpenChange: (open: boolean) => void
onSave: (component: UIComponent) => void
}
export function ComponentBindingDialog({
open,
component,
dataSources,
onOpenChange,
onSave,
}: ComponentBindingDialogProps) {
const [editingComponent, setEditingComponent] = useState<UIComponent | null>(component)
const handleSave = () => {
if (!editingComponent) return
onSave(editingComponent)
onOpenChange(false)
}
const updateBindings = (bindings: Record<string, any>) => {
if (!editingComponent) return
setEditingComponent({ ...editingComponent, bindings })
}
if (!editingComponent) return null
const availableProps = ['children', 'value', 'checked', 'className', 'disabled', 'placeholder']
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Link className="w-5 h-5" />
Component Data Bindings
</DialogTitle>
<DialogDescription>
Connect component properties to data sources
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="p-4 bg-muted/30 rounded border border-border">
<div className="text-sm space-y-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Component:</span>
<span className="font-mono font-medium">{editingComponent.type}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">ID:</span>
<span className="font-mono text-xs">{editingComponent.id}</span>
</div>
</div>
</div>
<Tabs defaultValue="bindings">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="bindings">Property Bindings</TabsTrigger>
<TabsTrigger value="preview">Preview</TabsTrigger>
</TabsList>
<TabsContent value="bindings" className="space-y-4 mt-4">
<BindingEditor
bindings={editingComponent.bindings || {}}
dataSources={dataSources}
availableProps={availableProps}
onChange={updateBindings}
/>
</TabsContent>
<TabsContent value="preview" className="space-y-4 mt-4">
<div className="p-4 bg-muted/30 rounded border border-border">
<pre className="text-xs overflow-auto">
{JSON.stringify(editingComponent.bindings, null, 2)}
</pre>
</div>
</TabsContent>
</Tabs>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
Save Bindings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,95 @@
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
import { DataSource } from '@/types/json-ui'
import { Pencil, Trash, ArrowsDownUp } from '@phosphor-icons/react'
import { Badge } from '@/components/ui/badge'
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
}
const renderTypeSpecificInfo = () => {
if (dataSource.type === 'kv') {
return (
<div className="text-xs text-muted-foreground font-mono bg-muted/30 px-2 py-1 rounded">
Key: {dataSource.key || 'Not set'}
</div>
)
}
if (dataSource.type === 'computed') {
const depCount = getDependencyCount()
return (
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
<ArrowsDownUp className="w-3 h-3 mr-1" />
{depCount} {depCount === 1 ? 'dependency' : 'dependencies'}
</Badge>
</div>
)
}
return null
}
return (
<Card className="bg-card/50 backdrop-blur hover:bg-card/70 transition-colors">
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<DataSourceBadge type={dataSource.type} />
<span className="font-mono text-sm font-medium truncate">
{dataSource.id}
</span>
</div>
{renderTypeSpecificInfo()}
{dependents.length > 0 && (
<div className="mt-2 pt-2 border-t border-border/50">
<span className="text-xs text-muted-foreground">
Used by {dependents.length} computed {dependents.length === 1 ? 'source' : 'sources'}
</span>
</div>
)}
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => onEdit(dataSource.id)}
className="h-8 w-8 p-0"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => onDelete(dataSource.id)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
disabled={dependents.length > 0}
>
<Trash className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,226 @@
import { useState } from 'react'
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { DataSource, DataSourceType } from '@/types/json-ui'
import { DataSourceBadge } from '@/components/atoms/DataSourceBadge'
import { Badge } from '@/components/ui/badge'
import { X } from '@phosphor-icons/react'
interface DataSourceEditorDialogProps {
open: boolean
dataSource: DataSource | null
allDataSources: DataSource[]
onOpenChange: (open: boolean) => void
onSave: (dataSource: DataSource) => void
}
export function DataSourceEditorDialog({
open,
dataSource,
allDataSources,
onOpenChange,
onSave
}: DataSourceEditorDialogProps) {
const [editingSource, setEditingSource] = useState<DataSource | null>(dataSource)
const handleSave = () => {
if (!editingSource) return
onSave(editingSource)
onOpenChange(false)
}
const updateField = <K extends keyof DataSource>(field: K, value: DataSource[K]) => {
if (!editingSource) return
setEditingSource({ ...editingSource, [field]: value })
}
const addDependency = (depId: string) => {
if (!editingSource || editingSource.type !== 'computed') return
const deps = editingSource.dependencies || []
if (!deps.includes(depId)) {
updateField('dependencies', [...deps, depId])
}
}
const removeDependency = (depId: string) => {
if (!editingSource || editingSource.type !== 'computed') return
const deps = editingSource.dependencies || []
updateField('dependencies', deps.filter(d => d !== depId))
}
if (!editingSource) return null
const availableDeps = allDataSources.filter(
ds => ds.id !== editingSource.id && ds.type !== 'computed'
)
const selectedDeps = editingSource.dependencies || []
const unselectedDeps = availableDeps.filter(ds => !selectedDeps.includes(ds.id))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
Edit Data Source
<DataSourceBadge type={editingSource.type} />
</DialogTitle>
<DialogDescription>
Configure the data source settings and dependencies
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>ID</Label>
<Input
value={editingSource.id}
onChange={(e) => updateField('id', e.target.value)}
placeholder="unique-id"
className="font-mono"
/>
</div>
{editingSource.type === 'kv' && (
<>
<div className="space-y-2">
<Label>KV Store Key</Label>
<Input
value={editingSource.key || ''}
onChange={(e) => updateField('key', e.target.value)}
placeholder="storage-key"
className="font-mono"
/>
<p className="text-xs text-muted-foreground">
Key used for persistent storage in the KV store
</p>
</div>
<div className="space-y-2">
<Label>Default Value (JSON)</Label>
<Textarea
value={JSON.stringify(editingSource.defaultValue, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value)
updateField('defaultValue', parsed)
} catch (err) {
// Invalid JSON, don't update
}
}}
placeholder='{"key": "value"}'
className="font-mono text-sm h-24"
/>
</div>
</>
)}
{editingSource.type === 'static' && (
<div className="space-y-2">
<Label>Value (JSON)</Label>
<Textarea
value={JSON.stringify(editingSource.defaultValue, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value)
updateField('defaultValue', parsed)
} catch (err) {
// Invalid JSON, don't update
}
}}
placeholder='{"key": "value"}'
className="font-mono text-sm h-24"
/>
</div>
)}
{editingSource.type === 'computed' && (
<>
<div className="space-y-2">
<Label>Compute Function</Label>
<Textarea
value={editingSource.compute?.toString() || ''}
onChange={(e) => {
try {
const fn = new Function('data', `return (${e.target.value})`)()
updateField('compute', fn)
} catch (err) {
// Invalid function
}
}}
placeholder="(data) => data.source1 + data.source2"
className="font-mono text-sm h-24"
/>
<p className="text-xs text-muted-foreground">
Function that computes the value from other data sources
</p>
</div>
<div className="space-y-2">
<Label>Dependencies</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={() => removeDependency(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">Available Sources</Label>
<div className="flex flex-wrap gap-2">
{unselectedDeps.map(ds => (
<Button
key={ds.id}
variant="outline"
size="sm"
onClick={() => addDependency(ds.id)}
className="h-7 text-xs"
>
+ {ds.id}
</Button>
))}
</div>
</div>
)}
{unselectedDeps.length === 0 && selectedDeps.length === 0 && (
<p className="text-sm text-muted-foreground">
No data sources available. Create KV or static sources first.
</p>
)}
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -28,3 +28,8 @@ export { TreeListHeader } from './TreeListHeader'
export { DataCard } from './DataCard'
export { SearchInput } from './SearchInput'
export { ActionBar } from './ActionBar'
export { DataSourceCard } from './DataSourceCard'
export { BindingEditor } from './BindingEditor'
export { DataSourceEditorDialog } from './DataSourceEditorDialog'
export { ComponentBindingDialog } from './ComponentBindingDialog'

View File

@@ -0,0 +1,195 @@
import { useState } from 'react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { DataSourceCard } from '@/components/molecules/DataSourceCard'
import { DataSourceEditorDialog } from '@/components/molecules/DataSourceEditorDialog'
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
import { DataSource, DataSourceType } from '@/types/json-ui'
import { Plus, Database, Function, FileText } from '@phosphor-icons/react'
import { toast } from 'sonner'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
interface DataSourceManagerProps {
dataSources: DataSource[]
onChange: (dataSources: DataSource[]) => void
}
export function DataSourceManager({ dataSources, onChange }: DataSourceManagerProps) {
const {
dataSources: localSources,
addDataSource,
updateDataSource,
deleteDataSource,
getDependents,
} = useDataSourceManager(dataSources)
const [editingSource, setEditingSource] = useState<DataSource | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const handleAddDataSource = (type: DataSourceType) => {
const newSource = addDataSource(type)
setEditingSource(newSource)
setDialogOpen(true)
}
const handleEditSource = (id: string) => {
const source = localSources.find(ds => ds.id === id)
if (source) {
setEditingSource(source)
setDialogOpen(true)
}
}
const handleDeleteSource = (id: string) => {
const dependents = getDependents(id)
if (dependents.length > 0) {
toast.error('Cannot delete', {
description: `This source is used by ${dependents.length} computed ${dependents.length === 1 ? 'source' : 'sources'}`,
})
return
}
deleteDataSource(id)
onChange(localSources.filter(ds => ds.id !== id))
toast.success('Data source deleted')
}
const handleSaveSource = (updatedSource: DataSource) => {
updateDataSource(updatedSource.id, updatedSource)
onChange(localSources.map(ds => ds.id === updatedSource.id ? updatedSource : ds))
toast.success('Data source updated')
}
const groupedSources = {
kv: localSources.filter(ds => ds.type === 'kv'),
computed: localSources.filter(ds => ds.type === 'computed'),
static: localSources.filter(ds => ds.type === 'static'),
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Data Sources</CardTitle>
<CardDescription>
Manage KV storage, computed values, and static data
</CardDescription>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
Add Data Source
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleAddDataSource('kv')}>
<Database className="w-4 h-4 mr-2" />
KV Store
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAddDataSource('computed')}>
<Function className="w-4 h-4 mr-2" />
Computed Value
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleAddDataSource('static')}>
<FileText className="w-4 h-4 mr-2" />
Static Data
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent>
{localSources.length === 0 ? (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-4">
<Database className="w-8 h-8 text-primary" />
</div>
<h3 className="text-lg font-semibold mb-2">No data sources yet</h3>
<p className="text-sm text-muted-foreground mb-4">
Create your first data source to start binding data to components
</p>
</div>
) : (
<div className="space-y-6">
{groupedSources.kv.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Database className="w-4 h-4" />
KV Store ({groupedSources.kv.length})
</h3>
<div className="space-y-2">
{groupedSources.kv.map(ds => (
<DataSourceCard
key={ds.id}
dataSource={ds}
dependents={getDependents(ds.id)}
onEdit={handleEditSource}
onDelete={handleDeleteSource}
/>
))}
</div>
</div>
)}
{groupedSources.static.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<FileText className="w-4 h-4" />
Static Data ({groupedSources.static.length})
</h3>
<div className="space-y-2">
{groupedSources.static.map(ds => (
<DataSourceCard
key={ds.id}
dataSource={ds}
dependents={getDependents(ds.id)}
onEdit={handleEditSource}
onDelete={handleDeleteSource}
/>
))}
</div>
</div>
)}
{groupedSources.computed.length > 0 && (
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Function className="w-4 h-4" />
Computed Values ({groupedSources.computed.length})
</h3>
<div className="space-y-2">
{groupedSources.computed.map(ds => (
<DataSourceCard
key={ds.id}
dataSource={ds}
dependents={getDependents(ds.id)}
onEdit={handleEditSource}
onDelete={handleDeleteSource}
/>
))}
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
<DataSourceEditorDialog
open={dialogOpen}
dataSource={editingSource}
allDataSources={localSources}
onOpenChange={setDialogOpen}
onSave={handleSaveSource}
/>
</div>
)
}

View File

@@ -293,6 +293,17 @@
"shortcut": "ctrl+shift+s",
"order": 23,
"props": {}
},
{
"id": "data-binding",
"title": "Data Binding",
"icon": "Link",
"component": "DataBindingDesigner",
"enabled": true,
"toggleKey": "dataBinding",
"shortcut": "ctrl+shift+d",
"order": 24,
"props": {}
}
]
}

View File

@@ -0,0 +1,49 @@
import { useState, useCallback } from 'react'
import { DataSource, DataSourceType } from '@/types/json-ui'
export function useDataSourceManager(initialSources: DataSource[] = []) {
const [dataSources, setDataSources] = useState<DataSource[]>(initialSources)
const addDataSource = useCallback((type: DataSourceType) => {
const newSource: DataSource = {
id: `ds-${Date.now()}`,
type,
...(type === 'kv' && { key: '', defaultValue: null }),
...(type === 'computed' && { compute: () => null, dependencies: [] }),
...(type === 'static' && { defaultValue: null }),
}
setDataSources(prev => [...prev, newSource])
return newSource
}, [])
const updateDataSource = useCallback((id: string, updates: Partial<DataSource>) => {
setDataSources(prev =>
prev.map(ds => ds.id === id ? { ...ds, ...updates } : ds)
)
}, [])
const deleteDataSource = useCallback((id: string) => {
setDataSources(prev => prev.filter(ds => ds.id !== id))
}, [])
const getDataSource = useCallback((id: string) => {
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,
}
}

View File

@@ -1,70 +1,114 @@
import { useState, useCallback, useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
import { DataSource } from '@/types/json-ui'
export function useDataSources(sources: DataSource[]) {
const [data, setData] = useState<Record<string, any>>(() => {
const initial: Record<string, any> = {}
sources.forEach(source => {
initial[source.id] = source.defaultValue
})
return initial
})
export function useDataSources(dataSources: DataSource[]) {
const [data, setData] = useState<Record<string, any>>({})
const [loading, setLoading] = useState(true)
const kvSources = dataSources.filter(ds => ds.type === 'kv')
const kvStates = kvSources.map(ds =>
useKV(ds.key || `ds-${ds.id}`, ds.defaultValue)
)
useEffect(() => {
const computedData = { ...data }
const initializeData = async () => {
const newData: Record<string, any> = {}
dataSources.forEach((source, index) => {
if (source.type === 'kv') {
const kvIndex = kvSources.indexOf(source)
if (kvIndex !== -1 && kvStates[kvIndex]) {
newData[source.id] = kvStates[kvIndex][0]
}
} else if (source.type === 'static') {
newData[source.id] = source.defaultValue
}
})
setData(newData)
setLoading(false)
}
initializeData()
}, [])
useEffect(() => {
const computedSources = dataSources.filter(ds => ds.type === 'computed')
sources.forEach(source => {
if (source.type === 'computed' && source.compute) {
computedData[source.id] = source.compute(data)
computedSources.forEach(source => {
if (source.compute) {
const deps = source.dependencies || []
const hasAllDeps = deps.every(dep => dep in data)
if (hasAllDeps) {
const computedValue = source.compute(data)
setData(prev => ({ ...prev, [source.id]: computedValue }))
}
}
})
setData(computedData)
}, [sources])
}, [data, dataSources])
const updateData = useCallback((sourceId: string, value: any) => {
setData(prev => {
const updated = { ...prev, [sourceId]: value }
sources.forEach(source => {
if (source.type === 'computed' && source.compute) {
updated[source.id] = source.compute(updated)
}
})
return updated
})
}, [sources])
const source = dataSources.find(ds => ds.id === sourceId)
if (!source) {
console.warn(`Data source ${sourceId} not found`)
return
}
if (source.type === 'kv') {
const kvIndex = kvSources.indexOf(source)
if (kvIndex !== -1 && kvStates[kvIndex]) {
kvStates[kvIndex][1](value)
}
}
setData(prev => ({ ...prev, [sourceId]: value }))
}, [dataSources, kvSources, kvStates])
const updatePath = useCallback((sourceId: string, path: string, value: any) => {
const source = dataSources.find(ds => ds.id === sourceId)
if (!source) {
console.warn(`Data source ${sourceId} not found`)
return
}
setData(prev => {
const keys = path.split('.')
const result = { ...prev }
const current = { ...result[sourceId] }
result[sourceId] = current
let target: any = current
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
target[key] = { ...target[key] }
target = target[key]
const sourceData = prev[sourceId]
if (!sourceData || typeof sourceData !== 'object') {
return prev
}
target[keys[keys.length - 1]] = value
sources.forEach(source => {
if (source.type === 'computed' && source.compute) {
result[source.id] = source.compute(result)
const pathParts = path.split('.')
const newData = { ...sourceData }
let current: any = newData
for (let i = 0; i < pathParts.length - 1; i++) {
if (!(pathParts[i] in current)) {
current[pathParts[i]] = {}
}
})
return result
current = current[pathParts[i]]
}
current[pathParts[pathParts.length - 1]] = value
if (source.type === 'kv') {
const kvIndex = kvSources.indexOf(source)
if (kvIndex !== -1 && kvStates[kvIndex]) {
kvStates[kvIndex][1](newData)
}
}
return { ...prev, [sourceId]: newData }
})
}, [sources])
}, [dataSources, kvSources, kvStates])
return {
data,
updateData,
updatePath,
loading,
}
}

View File

@@ -75,6 +75,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = {
faviconDesigner: true,
ideaCloud: true,
schemaEditor: true,
dataBinding: true,
}
const DEFAULT_THEME: ThemeConfig = {

View File

@@ -131,6 +131,11 @@ export const ComponentRegistry = {
() => import('@/components/SchemaEditorPage').then(m => ({ default: m.SchemaEditorPage })),
'SchemaEditor'
),
DataBindingDesigner: lazyWithPreload(
() => import('@/components/DataBindingDesigner').then(m => ({ default: m.DataBindingDesigner })),
'DataBindingDesigner'
),
} as const
export const DialogRegistry = {

View File

@@ -283,6 +283,7 @@ export interface FeatureToggles {
faviconDesigner: boolean
ideaCloud: boolean
schemaEditor: boolean
dataBinding: boolean
}
export interface Project {