mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Generated by Spark: Add data source binding UI to connect components to KV storage and computed values
This commit is contained in:
391
DATA_BINDING_GUIDE.md
Normal file
391
DATA_BINDING_GUIDE.md
Normal 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
7
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
|
||||
|
||||
@@ -25,6 +25,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = {
|
||||
faviconDesigner: true,
|
||||
ideaCloud: true,
|
||||
schemaEditor: true,
|
||||
dataBinding: true,
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
225
src/components/DataBindingDesigner.tsx
Normal file
225
src/components/DataBindingDesigner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/components/atoms/BindingIndicator.tsx
Normal file
28
src/components/atoms/BindingIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/atoms/DataSourceBadge.tsx
Normal file
37
src/components/atoms/DataSourceBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
137
src/components/molecules/BindingEditor.tsx
Normal file
137
src/components/molecules/BindingEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
104
src/components/molecules/ComponentBindingDialog.tsx
Normal file
104
src/components/molecules/ComponentBindingDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
95
src/components/molecules/DataSourceCard.tsx
Normal file
95
src/components/molecules/DataSourceCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
226
src/components/molecules/DataSourceEditorDialog.tsx
Normal file
226
src/components/molecules/DataSourceEditorDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
195
src/components/organisms/DataSourceManager.tsx
Normal file
195
src/components/organisms/DataSourceManager.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
49
src/hooks/data/use-data-source-manager.ts
Normal file
49
src/hooks/data/use-data-source-manager.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ const DEFAULT_FEATURE_TOGGLES: FeatureToggles = {
|
||||
faviconDesigner: true,
|
||||
ideaCloud: true,
|
||||
schemaEditor: true,
|
||||
dataBinding: true,
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: ThemeConfig = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -283,6 +283,7 @@ export interface FeatureToggles {
|
||||
faviconDesigner: boolean
|
||||
ideaCloud: boolean
|
||||
schemaEditor: boolean
|
||||
dataBinding: boolean
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
|
||||
Reference in New Issue
Block a user