diff --git a/DATA_BINDING_GUIDE.md b/DATA_BINDING_GUIDE.md new file mode 100644 index 0000000..d094b83 --- /dev/null +++ b/DATA_BINDING_GUIDE.md @@ -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 } +``` diff --git a/PRD.md b/PRD.md index 219c0bc..d4a9760 100644 --- a/PRD.md +++ b/PRD.md @@ -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 diff --git a/src/App.simple.tsx b/src/App.simple.tsx index 2d60e67..f4e87e7 100644 --- a/src/App.simple.tsx +++ b/src/App.simple.tsx @@ -25,6 +25,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = { faviconDesigner: true, ideaCloud: true, schemaEditor: true, + dataBinding: true, } function App() { diff --git a/src/components/DataBindingDesigner.tsx b/src/components/DataBindingDesigner.tsx new file mode 100644 index 0000000..aef1e29 --- /dev/null +++ b/src/components/DataBindingDesigner.tsx @@ -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([ + { + 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([ + { + 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(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 ( +
+
+
+

+ Data Binding Designer +

+

+ Connect UI components to KV storage and computed values +

+
+ +
+
+ +
+ +
+ + + + + Component Bindings + + + Example components with data bindings + + + + +
+ {mockComponents.map(component => { + const bindingCount = Object.keys(component.bindings || {}).length + + return ( + + +
+
+
+ + {component.type} + + + #{component.id} + +
+ + {bindingCount > 0 ? ( +
+ {Object.entries(component.bindings || {}).map(([prop, binding]) => { + const source = getSourceById(binding.source) + return ( +
+ + {prop}: + + + {binding.source} + {binding.path && `.${binding.path}`} + + {source && ( + + {source.type} + + )} +
+ ) + })} +
+ ) : ( +

+ No bindings configured +

+ )} +
+ + +
+
+
+ ) + })} +
+
+
+
+ + + + How It Works + + +
+
+ 1 +
+

+ Create data sources (KV store for persistence, static for constants) +

+
+
+
+ 2 +
+

+ Add computed sources to derive values from other sources +

+
+
+
+ 3 +
+

+ Bind component properties to data sources for reactive updates +

+
+
+
+
+
+
+ + +
+ ) +} diff --git a/src/components/atoms/BindingIndicator.tsx b/src/components/atoms/BindingIndicator.tsx new file mode 100644 index 0000000..045a76a --- /dev/null +++ b/src/components/atoms/BindingIndicator.tsx @@ -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 ( + + + +
+ + {bindingText} +
+
+ +

Bound to: {bindingText}

+
+
+
+ ) +} diff --git a/src/components/atoms/DataSourceBadge.tsx b/src/components/atoms/DataSourceBadge.tsx new file mode 100644 index 0000000..e599878 --- /dev/null +++ b/src/components/atoms/DataSourceBadge.tsx @@ -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 ( + + + {labels[type]} + + ) +} diff --git a/src/components/atoms/index.ts b/src/components/atoms/index.ts index 2ac5726..8dedb1b 100644 --- a/src/components/atoms/index.ts +++ b/src/components/atoms/index.ts @@ -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' + diff --git a/src/components/molecules/BindingEditor.tsx b/src/components/molecules/BindingEditor.tsx new file mode 100644 index 0000000..4482adc --- /dev/null +++ b/src/components/molecules/BindingEditor.tsx @@ -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 + dataSources: DataSource[] + availableProps: string[] + onChange: (bindings: Record) => 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 ( +
+
+ + {boundProps.length === 0 ? ( +

No bindings yet

+ ) : ( +
+ {boundProps.map(prop => ( +
+
+ {prop} + + +
+ +
+ ))} +
+ )} +
+ + {unboundProps.length > 0 && ( +
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ + setPath(e.target.value)} + className="h-9 font-mono text-sm" + /> +
+ + +
+ )} +
+ ) +} diff --git a/src/components/molecules/ComponentBindingDialog.tsx b/src/components/molecules/ComponentBindingDialog.tsx new file mode 100644 index 0000000..7b8fb9e --- /dev/null +++ b/src/components/molecules/ComponentBindingDialog.tsx @@ -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(component) + + const handleSave = () => { + if (!editingComponent) return + onSave(editingComponent) + onOpenChange(false) + } + + const updateBindings = (bindings: Record) => { + if (!editingComponent) return + setEditingComponent({ ...editingComponent, bindings }) + } + + if (!editingComponent) return null + + const availableProps = ['children', 'value', 'checked', 'className', 'disabled', 'placeholder'] + + return ( + + + + + + Component Data Bindings + + + Connect component properties to data sources + + + +
+
+
+
+ Component: + {editingComponent.type} +
+
+ ID: + {editingComponent.id} +
+
+
+ + + + Property Bindings + Preview + + + + + + + +
+
+                  {JSON.stringify(editingComponent.bindings, null, 2)}
+                
+
+
+
+
+ + + + + +
+
+ ) +} diff --git a/src/components/molecules/DataSourceCard.tsx b/src/components/molecules/DataSourceCard.tsx new file mode 100644 index 0000000..1a4b36b --- /dev/null +++ b/src/components/molecules/DataSourceCard.tsx @@ -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 ( +
+ Key: {dataSource.key || 'Not set'} +
+ ) + } + + if (dataSource.type === 'computed') { + const depCount = getDependencyCount() + return ( +
+ + + {depCount} {depCount === 1 ? 'dependency' : 'dependencies'} + +
+ ) + } + + return null + } + + return ( + + +
+
+
+ + + {dataSource.id} + +
+ + {renderTypeSpecificInfo()} + + {dependents.length > 0 && ( +
+ + Used by {dependents.length} computed {dependents.length === 1 ? 'source' : 'sources'} + +
+ )} +
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/components/molecules/DataSourceEditorDialog.tsx b/src/components/molecules/DataSourceEditorDialog.tsx new file mode 100644 index 0000000..fbac361 --- /dev/null +++ b/src/components/molecules/DataSourceEditorDialog.tsx @@ -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) + + const handleSave = () => { + if (!editingSource) return + onSave(editingSource) + onOpenChange(false) + } + + const updateField = (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 ( + + + + + Edit Data Source + + + + Configure the data source settings and dependencies + + + +
+
+ + updateField('id', e.target.value)} + placeholder="unique-id" + className="font-mono" + /> +
+ + {editingSource.type === 'kv' && ( + <> +
+ + updateField('key', e.target.value)} + placeholder="storage-key" + className="font-mono" + /> +

+ Key used for persistent storage in the KV store +

+
+ +
+ +