feat: migrate DataSourceManager to JSON (Tier 2 - Organism 1)

- Create JSON definition in src/components/json-definitions/data-source-manager.json
- Create TypeScript interface in src/lib/json-ui/interfaces/data-source-manager.ts
- Create custom hook useDataSourceManagerState in src/hooks/use-data-source-manager-state.ts
- Register hook in src/lib/json-ui/hooks-registry.ts
- Export DataSourceManager from src/lib/json-ui/json-components.ts
- Update imports in src/components/DataBindingDesigner.tsx
- Remove legacy TSX files and sub-components
- Update exports in src/components/organisms/index.ts
- Add hook export in src/hooks/index.ts

DataSourceManager now uses JSON-driven architecture with custom hooks for state management.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-21 01:29:19 +00:00
parent a78943a854
commit 94d67dfed5
9 changed files with 371 additions and 245 deletions

View File

@@ -1,6 +1,5 @@
import { useState } from 'react'
import { DataSourceManager } from '@/components/organisms/DataSourceManager'
import { ComponentBindingDialog } from '@/lib/json-ui/json-components'
import { DataSourceManager, ComponentBindingDialog } from '@/lib/json-ui/json-components'
import { DataSource, UIComponent } from '@/types/json-ui'
import { DataBindingHeader } from '@/components/data-binding-designer/DataBindingHeader'
import { ComponentBindingsCard } from '@/components/data-binding-designer/ComponentBindingsCard'

View File

@@ -0,0 +1,265 @@
{
"id": "data-source-manager",
"type": "div",
"props": { "className": "space-y-6" },
"children": [
{
"id": "data-sources-card",
"type": "Card",
"children": [
{
"id": "card-header",
"type": "CardHeader",
"children": [
{
"id": "header-container",
"type": "div",
"props": { "className": "flex items-center justify-between" },
"children": [
{
"id": "header-content",
"type": "Stack",
"props": { "direction": "vertical", "spacing": "xs" },
"children": [
{
"type": "Heading",
"bindings": { "children": "headerCopy.title" },
"props": { "level": 2 }
},
{
"type": "Text",
"bindings": { "children": "headerCopy.description" },
"props": { "variant": "muted" }
}
]
},
{
"id": "add-button-group",
"type": "div",
"children": [
{
"type": "DropdownMenu",
"children": [
{
"type": "DropdownMenuTrigger",
"props": { "asChild": true },
"children": [
{
"type": "div",
"children": [
{
"type": "ActionButton",
"bindings": {
"label": "headerCopy.addLabel",
"onClick": {
"source": "",
"transform": "() => {}"
}
},
"props": {
"variant": "default"
},
"children": [
{
"type": "PhosphorIcon",
"props": { "icon": "Plus", "className": "w-4 h-4" }
}
]
}
]
}
]
},
{
"type": "DropdownMenuContent",
"props": { "align": "end" },
"children": [
{
"type": "DropdownMenuItem",
"bindings": {
"onClick": {
"source": "addDataSource",
"transform": "() => addDataSource('kv')"
}
},
"children": [
{
"type": "PhosphorIcon",
"props": { "icon": "Database", "className": "w-4 h-4 mr-2" }
},
{
"type": "text",
"bindings": { "children": "headerCopy.menu.kv" }
}
]
},
{
"type": "DropdownMenuItem",
"bindings": {
"onClick": {
"source": "addDataSource",
"transform": "() => addDataSource('static')"
}
},
"children": [
{
"type": "PhosphorIcon",
"props": { "icon": "FileText", "className": "w-4 h-4 mr-2" }
},
{
"type": "text",
"bindings": { "children": "headerCopy.menu.static" }
}
]
}
]
}
]
}
]
}
]
}
]
},
{
"id": "card-content",
"type": "CardContent",
"children": [
{
"id": "empty-or-content",
"type": "ConditionalRender",
"bindings": {
"condition": {
"source": "localSources",
"transform": "localSources.length === 0"
}
},
"children": [
{
"id": "empty-state",
"type": "EmptyState",
"bindings": {
"title": "emptyStateCopy.title",
"description": "emptyStateCopy.description"
},
"props": {
"icon": {
"type": "PhosphorIcon",
"props": { "icon": "Database", "className": "w-12 h-12" }
}
}
},
{
"id": "sources-list",
"type": "Stack",
"props": { "direction": "vertical", "spacing": "xl" },
"children": [
{
"id": "kv-section",
"type": "Section",
"bindings": {
"hidden": {
"source": "groupedSources",
"transform": "groupedSources.kv.length === 0"
}
},
"children": [
{
"type": "IconText",
"props": { "className": "text-sm font-semibold mb-3" },
"children": [
{
"type": "PhosphorIcon",
"props": { "icon": "Database", "className": "w-4 h-4" }
},
{
"type": "text",
"bindings": {
"children": {
"source": "groupedSources",
"transform": "`${headerCopy.groups.kv} (${groupedSources.kv.length})`"
}
}
}
]
},
{
"type": "Stack",
"props": { "direction": "vertical", "spacing": "sm" },
"bindings": {
"children": {
"source": "groupedSources",
"transform": "groupedSources.kv.map((ds) => ({ id: ds.id, type: 'kv', item: ds }))"
}
}
}
]
},
{
"id": "static-section",
"type": "Section",
"bindings": {
"hidden": {
"source": "groupedSources",
"transform": "groupedSources.static.length === 0"
}
},
"children": [
{
"type": "IconText",
"props": { "className": "text-sm font-semibold mb-3" },
"children": [
{
"type": "PhosphorIcon",
"props": { "icon": "FileText", "className": "w-4 h-4" }
},
{
"type": "text",
"bindings": {
"children": {
"source": "groupedSources",
"transform": "`${headerCopy.groups.static} (${groupedSources.static.length})`"
}
}
}
]
},
{
"type": "Stack",
"props": { "direction": "vertical", "spacing": "sm" },
"bindings": {
"children": {
"source": "groupedSources",
"transform": "groupedSources.static.map((ds) => ({ id: ds.id, type: 'static', item: ds }))"
}
}
}
]
}
]
}
]
}
]
}
]
},
{
"id": "editor-dialog",
"type": "DataSourceEditorDialog",
"bindings": {
"open": "dialogOpen",
"dataSource": "editingSource",
"onOpenChange": {
"source": "setDialogOpen",
"transform": "setDialogOpen"
},
"onSave": {
"source": "updateDataSource",
"transform": "(source) => updateDataSource(source.id, source)"
}
}
}
]
}

View File

@@ -1,125 +0,0 @@
import { useState } from 'react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { DataSourceEditorDialog } from '@/lib/json-ui/json-components'
import { useDataSourceManager } from '@/hooks/data/use-data-source-manager'
import { DataSource, DataSourceType } from '@/types/json-ui'
import { Database, FileText } from '@phosphor-icons/react'
import { toast } from 'sonner'
import { EmptyState, Stack } from '@/components/atoms'
import { DataSourceManagerHeader } from '@/components/organisms/data-source-manager/DataSourceManagerHeader'
import { DataSourceGroupSection } from '@/components/organisms/data-source-manager/DataSourceGroupSection'
import dataSourceManagerCopy from '@/data/data-source-manager.json'
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) {
const noun = dependents.length === 1 ? 'source' : 'sources'
toast.error(dataSourceManagerCopy.toasts.deleteBlockedTitle, {
description: dataSourceManagerCopy.toasts.deleteBlockedDescription
.replace('{count}', String(dependents.length))
.replace('{noun}', noun),
})
return
}
deleteDataSource(id)
onChange(localSources.filter(ds => ds.id !== id))
toast.success(dataSourceManagerCopy.toasts.deleted)
}
const handleSaveSource = (updatedSource: DataSource) => {
updateDataSource(updatedSource.id, updatedSource)
onChange(localSources.map(ds => ds.id === updatedSource.id ? updatedSource : ds))
toast.success(dataSourceManagerCopy.toasts.updated)
}
const groupedSources = {
kv: localSources.filter(ds => ds.type === 'kv'),
static: localSources.filter(ds => ds.type === 'static'),
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<DataSourceManagerHeader
copy={{
title: dataSourceManagerCopy.header.title,
description: dataSourceManagerCopy.header.description,
addLabel: dataSourceManagerCopy.actions.add,
menu: dataSourceManagerCopy.menu,
}}
onAdd={handleAddDataSource}
/>
</CardHeader>
<CardContent>
{localSources.length === 0 ? (
<EmptyState
icon={<Database size={48} weight="duotone" />}
title={dataSourceManagerCopy.emptyState.title}
description={dataSourceManagerCopy.emptyState.description}
/>
) : (
<Stack direction="vertical" spacing="xl">
<DataSourceGroupSection
icon={<Database size={16} />}
label={dataSourceManagerCopy.groups.kv}
dataSources={groupedSources.kv}
getDependents={getDependents}
onEdit={handleEditSource}
onDelete={handleDeleteSource}
/>
<DataSourceGroupSection
icon={<FileText size={16} />}
label={dataSourceManagerCopy.groups.static}
dataSources={groupedSources.static}
getDependents={getDependents}
onEdit={handleEditSource}
onDelete={handleDeleteSource}
/>
</Stack>
)}
</CardContent>
</Card>
<DataSourceEditorDialog
open={dialogOpen}
dataSource={editingSource}
onOpenChange={setDialogOpen}
onSave={handleSaveSource}
/>
</div>
)
}

View File

@@ -1,58 +0,0 @@
import { DataSource } from '@/types/json-ui'
import { IconText, Section, Stack } from '@/components/atoms'
import { ReactNode } from 'react'
interface DataSourceGroupSectionProps {
icon: ReactNode
label: string
dataSources: DataSource[]
getDependents: (id: string) => string[]
onEdit: (id: string) => void
onDelete: (id: string) => void
}
export function DataSourceGroupSection({
icon,
label,
dataSources,
getDependents,
onEdit,
onDelete,
}: DataSourceGroupSectionProps) {
if (dataSources.length === 0) {
return null
}
return (
<Section>
<IconText
icon={icon}
className="text-sm font-semibold mb-3"
>
{label} ({dataSources.length})
</IconText>
<Stack direction="vertical" spacing="sm">
{dataSources.map(ds => (
<div
key={ds.id}
className="p-3 border rounded-md hover:bg-gray-50"
>
<div className="font-medium text-sm">{ds.id}</div>
<button
onClick={() => onEdit(ds.id)}
className="text-xs text-blue-600 hover:underline mr-2"
>
Edit
</button>
<button
onClick={() => onDelete(ds.id)}
className="text-xs text-red-600 hover:underline"
>
Delete
</button>
</div>
))}
</Stack>
</Section>
)
}

View File

@@ -1,59 +0,0 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { ActionButton, Heading, Stack, Text } from '@/components/atoms'
import { Plus, Database, FileText } from '@phosphor-icons/react'
import { DataSourceType } from '@/types/json-ui'
interface DataSourceManagerHeaderCopy {
title: string
description: string
addLabel: string
menu: {
kv: string
static: string
}
}
interface DataSourceManagerHeaderProps {
copy: DataSourceManagerHeaderCopy
onAdd: (type: DataSourceType) => void
}
export function DataSourceManagerHeader({ copy, onAdd }: DataSourceManagerHeaderProps) {
return (
<div className="flex items-center justify-between">
<Stack direction="vertical" spacing="xs">
<Heading level={2}>{copy.title}</Heading>
<Text variant="muted">
{copy.description}
</Text>
</Stack>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div>
<ActionButton
icon={<Plus size={16} />}
label={copy.addLabel}
variant="default"
onClick={() => {}}
/>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onAdd('kv')}>
<Database className="w-4 h-4 mr-2" />
{copy.menu.kv}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onAdd('static')}>
<FileText className="w-4 h-4 mr-2" />
{copy.menu.static}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}

View File

@@ -1,6 +1,5 @@
export { NavigationMenu } from './NavigationMenu'
export { AppHeader } from './AppHeader'
export { DataSourceManager } from './DataSourceManager'
export { TreeListPanel } from './TreeListPanel'
export { SchemaEditorLayout } from './SchemaEditorLayout'
export { SchemaCodeViewer } from './SchemaCodeViewer'

View File

@@ -0,0 +1,95 @@
import { useState, useCallback } from 'react'
import { DataSource, DataSourceType } from '@/types/json-ui'
import { toast } from 'sonner'
import dataSourceManagerCopy from '@/data/data-source-manager.json'
interface UseDataSourceManagerStateReturn {
localSources: DataSource[]
editingSource: DataSource | null
dialogOpen: boolean
groupedSources: {
kv: DataSource[]
static: DataSource[]
}
addDataSource: (type: DataSourceType) => void
updateDataSource: (id: string, updates: Partial<DataSource>) => void
deleteDataSource: (id: string) => void
getDependents: (sourceId: string) => DataSource[]
setEditingSource: (source: DataSource | null) => void
setDialogOpen: (open: boolean) => void
}
export function useDataSourceManagerState(
dataSources: DataSource[],
onChange: (dataSources: DataSource[]) => void
): UseDataSourceManagerStateReturn {
const [localSources, setLocalSources] = useState<DataSource[]>(dataSources)
const [editingSource, setEditingSource] = useState<DataSource | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
const getDependents = useCallback((sourceId: string) => {
return localSources.filter(ds =>
ds.dependencies?.includes(sourceId)
)
}, [localSources])
const addDataSource = useCallback((type: DataSourceType) => {
const newSource: DataSource = {
id: `ds-${Date.now()}`,
type,
...(type === 'kv' && { key: '', defaultValue: null }),
...(type === 'static' && { defaultValue: null }),
}
setLocalSources(prev => [...prev, newSource])
setEditingSource(newSource)
setDialogOpen(true)
}, [])
const updateDataSource = useCallback((id: string, updates: Partial<DataSource>) => {
const updated = localSources.map(ds =>
ds.id === id ? { ...ds, ...updates } : ds
)
setLocalSources(updated)
onChange(updated)
toast.success(dataSourceManagerCopy.toasts.updated)
}, [localSources, onChange])
const deleteDataSource = useCallback((id: string) => {
const dependents = localSources.filter(ds =>
ds.dependencies?.includes(id)
)
if (dependents.length > 0) {
const noun = dependents.length === 1 ? 'source' : 'sources'
toast.error(dataSourceManagerCopy.toasts.deleteBlockedTitle, {
description: dataSourceManagerCopy.toasts.deleteBlockedDescription
.replace('{count}', String(dependents.length))
.replace('{noun}', noun),
})
return
}
const updated = localSources.filter(ds => ds.id !== id)
setLocalSources(updated)
onChange(updated)
toast.success(dataSourceManagerCopy.toasts.deleted)
}, [localSources, onChange])
const groupedSources = {
kv: localSources.filter(ds => ds.type === 'kv'),
static: localSources.filter(ds => ds.type === 'static'),
}
return {
localSources,
editingSource,
dialogOpen,
groupedSources,
addDataSource,
updateDataSource,
deleteDataSource,
getDependents,
setEditingSource,
setDialogOpen,
}
}

View File

@@ -0,0 +1,6 @@
import { DataSource, DataSourceType } from '@/types/json-ui'
export interface DataSourceManagerProps {
dataSources: DataSource[]
onChange: (dataSources: DataSource[]) => void
}

View File

@@ -332,6 +332,10 @@ export const jsonUIComponentTypes = [
"Upload",
"User",
"X",
"AppLayout",
"AppRouterLayout",
"AppMainPanel",
"AppDialogs",
] as const
export type JSONUIComponentType = typeof jsonUIComponentTypes[number]