refactor: modularize level 4 schema editor

This commit is contained in:
2025-12-27 18:43:54 +00:00
parent cadaa8c5fe
commit ecd04fa1a0
5 changed files with 420 additions and 255 deletions

View File

@@ -1,19 +1,9 @@
import { useState } from 'react'
import { Button } from '@/components/ui'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Input } from '@/components/ui'
import { Label } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { Switch } from '@/components/ui'
import { SchemaTabs } from '@/components/schema/level4/Tabs'
import { useSchemaLevel4 } from '@/components/schema/level4/useSchemaLevel4'
import type { ModelSchema } from '@/lib/schema-types'
import { Plus, Trash } from '@phosphor-icons/react'
import { toast } from 'sonner'
import type { ModelSchema, FieldSchema, FieldType } from '@/lib/schema-types'
interface SchemaEditorLevel4Props {
schemas: ModelSchema[]
@@ -21,74 +11,17 @@ interface SchemaEditorLevel4Props {
}
export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLevel4Props) {
const [selectedModel, setSelectedModel] = useState<string | null>(
schemas.length > 0 ? schemas[0].name : null
)
const currentModel = schemas.find(s => s.name === selectedModel)
const handleAddModel = () => {
const newModel: ModelSchema = {
name: `Model_${Date.now()}`,
label: 'New Model',
fields: [],
}
onSchemasChange([...schemas, newModel])
setSelectedModel(newModel.name)
toast.success('Model created')
}
const handleDeleteModel = (modelName: string) => {
onSchemasChange(schemas.filter(s => s.name !== modelName))
if (selectedModel === modelName) {
setSelectedModel(schemas.length > 1 ? schemas[0].name : null)
}
toast.success('Model deleted')
}
const handleUpdateModel = (updates: Partial<ModelSchema>) => {
if (!currentModel) return
onSchemasChange(
schemas.map(s => s.name === selectedModel ? { ...s, ...updates } : s)
)
}
const handleAddField = () => {
if (!currentModel) return
const newField: FieldSchema = {
name: `field_${Date.now()}`,
type: 'string',
label: 'New Field',
required: false,
editable: true,
}
handleUpdateModel({
fields: [...currentModel.fields, newField],
})
toast.success('Field added')
}
const handleDeleteField = (fieldName: string) => {
if (!currentModel) return
handleUpdateModel({
fields: currentModel.fields.filter(f => f.name !== fieldName),
})
toast.success('Field deleted')
}
const handleUpdateField = (fieldName: string, updates: Partial<FieldSchema>) => {
if (!currentModel) return
handleUpdateModel({
fields: currentModel.fields.map(f =>
f.name === fieldName ? { ...f, ...updates } : f
),
})
}
const {
currentModel,
selectedModel,
selectModel,
handleAddField,
handleAddModel,
handleDeleteField,
handleDeleteModel,
handleUpdateField,
handleUpdateModel,
} = useSchemaLevel4({ schemas, onSchemasChange })
return (
<div className="grid md:grid-cols-3 gap-6 h-full">
@@ -117,7 +50,7 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev
? 'bg-accent border-accent-foreground'
: 'hover:bg-muted border-border'
}`}
onClick={() => setSelectedModel(schema.name)}
onClick={() => selectModel(schema.name)}
>
<div>
<div className="font-medium text-sm">{schema.label || schema.name}</div>
@@ -150,179 +83,13 @@ export function SchemaEditorLevel4({ schemas, onSchemasChange }: SchemaEditorLev
</div>
</CardContent>
) : (
<>
<CardHeader>
<CardTitle>Edit Model: {currentModel.label}</CardTitle>
<CardDescription>Configure model properties and fields</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Model Name (ID)</Label>
<Input
value={currentModel.name}
onChange={(e) => handleUpdateModel({ name: e.target.value })}
placeholder="user_model"
/>
</div>
<div className="space-y-2">
<Label>Display Label</Label>
<Input
value={currentModel.label || ''}
onChange={(e) => handleUpdateModel({ label: e.target.value })}
placeholder="User"
/>
</div>
<div className="space-y-2">
<Label>Plural Label</Label>
<Input
value={currentModel.labelPlural || ''}
onChange={(e) => handleUpdateModel({ labelPlural: e.target.value })}
placeholder="Users"
/>
</div>
<div className="space-y-2">
<Label>Icon Name</Label>
<Input
value={currentModel.icon || ''}
onChange={(e) => handleUpdateModel({ icon: e.target.value })}
placeholder="Users"
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-4">
<Label className="text-base">Fields</Label>
<Button size="sm" onClick={handleAddField}>
<Plus className="mr-2" size={16} />
Add Field
</Button>
</div>
<div className="space-y-4">
{currentModel.fields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8 border border-dashed rounded-lg">
No fields yet. Add a field to start.
</p>
) : (
currentModel.fields.map((field) => (
<Card key={field.name} className="border-2">
<CardContent className="pt-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="grid gap-4 md:grid-cols-2 flex-1">
<div className="space-y-2">
<Label className="text-xs">Field Name</Label>
<Input
value={field.name}
onChange={(e) =>
handleUpdateField(field.name, { name: e.target.value })
}
placeholder="email"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Label</Label>
<Input
value={field.label || ''}
onChange={(e) =>
handleUpdateField(field.name, { label: e.target.value })
}
placeholder="Email Address"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Type</Label>
<Select
value={field.type}
onValueChange={(value) =>
handleUpdateField(field.name, { type: value as FieldType })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">String</SelectItem>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="datetime">DateTime</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="url">URL</SelectItem>
<SelectItem value="select">Select</SelectItem>
<SelectItem value="relation">Relation</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-xs">Default Value</Label>
<Input
value={field.default || ''}
onChange={(e) =>
handleUpdateField(field.name, { default: e.target.value })
}
placeholder="Default"
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteField(field.name)}
>
<Trash size={16} />
</Button>
</div>
<div className="flex gap-6">
<div className="flex items-center gap-2">
<Switch
checked={field.required || false}
onCheckedChange={(checked) =>
handleUpdateField(field.name, { required: checked })
}
/>
<Label className="text-xs">Required</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={field.unique || false}
onCheckedChange={(checked) =>
handleUpdateField(field.name, { unique: checked })
}
/>
<Label className="text-xs">Unique</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={field.editable !== false}
onCheckedChange={(checked) =>
handleUpdateField(field.name, { editable: checked })
}
/>
<Label className="text-xs">Editable</Label>
</div>
<div className="flex items-center gap-2">
<Switch
checked={field.searchable || false}
onCheckedChange={(checked) =>
handleUpdateField(field.name, { searchable: checked })
}
/>
<Label className="text-xs">Searchable</Label>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
</CardContent>
</>
<SchemaTabs
currentModel={currentModel}
onUpdateModel={handleUpdateModel}
onAddField={handleAddField}
onDeleteField={handleDeleteField}
onUpdateField={handleUpdateField}
/>
)}
</Card>
</div>

View File

@@ -55,6 +55,7 @@ export function Level4({ user, onLogout, onNavigate, onPreview }: Level4Props) {
<Level4Tabs
appConfig={appConfig}
user={user}
nerdMode={nerdMode}
onSchemasChange={handleSchemasChange}
onWorkflowsChange={handleWorkflowsChange}

View File

@@ -0,0 +1,186 @@
import { Button, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui'
import { Input, Label } from '@/components/ui'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui'
import { ValidationPanel } from '@/components/schema/level4/ValidationPanel'
import type { FieldSchema, FieldType, ModelSchema } from '@/lib/schema-types'
import { Plus, Trash } from '@phosphor-icons/react'
interface SchemaTabsProps {
currentModel: ModelSchema
onUpdateModel: (updates: Partial<ModelSchema>) => void
onAddField: () => void
onDeleteField: (fieldName: string) => void
onUpdateField: (fieldName: string, updates: Partial<FieldSchema>) => void
}
export function SchemaTabs({
currentModel,
onUpdateModel,
onAddField,
onDeleteField,
onUpdateField,
}: SchemaTabsProps) {
const handleFieldChange = (fieldName: string, updates: Partial<FieldSchema>) => {
onUpdateField(fieldName, updates)
}
return (
<>
<CardHeader>
<CardTitle>Edit Model: {currentModel.label ?? currentModel.name}</CardTitle>
<CardDescription>Configure model properties and fields</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<TextField
label="Model Name (ID)"
value={currentModel.name}
onChange={(value) => onUpdateModel({ name: value })}
placeholder="user_model"
/>
<TextField
label="Display Label"
value={currentModel.label || ''}
onChange={(value) => onUpdateModel({ label: value })}
placeholder="User"
/>
<TextField
label="Plural Label"
value={currentModel.labelPlural || ''}
onChange={(value) => onUpdateModel({ labelPlural: value })}
placeholder="Users"
/>
<TextField
label="Icon Name"
value={currentModel.icon || ''}
onChange={(value) => onUpdateModel({ icon: value })}
placeholder="users"
/>
</div>
<div>
<div className="flex items-center justify-between mb-4">
<Label className="text-base">Fields</Label>
<Button size="sm" onClick={onAddField}>
<Plus className="mr-2" size={16} />
Add Field
</Button>
</div>
<div className="space-y-4">
{currentModel.fields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8 border border-dashed rounded-lg">
No fields yet. Add a field to start.
</p>
) : (
currentModel.fields.map((field) => (
<FieldCard
key={field.name}
field={field}
onChange={(updates) => handleFieldChange(field.name, updates)}
onDelete={() => onDeleteField(field.name)}
/>
))
)}
</div>
</div>
</CardContent>
</>
)
}
interface FieldCardProps {
field: FieldSchema
onChange: (updates: Partial<FieldSchema>) => void
onDelete: () => void
}
function FieldCard({ field, onChange, onDelete }: FieldCardProps) {
return (
<div className="border-2 rounded-lg">
<CardContent className="pt-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="grid gap-4 md:grid-cols-2 flex-1">
<TextField
label="Field Name"
value={field.name}
onChange={(value) => onChange({ name: value })}
placeholder="email"
labelClassName="text-xs"
/>
<TextField
label="Label"
value={field.label || ''}
onChange={(value) => onChange({ label: value })}
placeholder="Email Address"
labelClassName="text-xs"
/>
<div className="space-y-2">
<Label className="text-xs">Type</Label>
<Select
value={field.type}
onValueChange={(value) => onChange({ type: value as FieldType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">String</SelectItem>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="boolean">Boolean</SelectItem>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="datetime">DateTime</SelectItem>
<SelectItem value="email">Email</SelectItem>
<SelectItem value="url">URL</SelectItem>
<SelectItem value="select">Select</SelectItem>
<SelectItem value="relation">Relation</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
</div>
<TextField
label="Default Value"
value={field.default ?? ''}
onChange={(value) => onChange({ default: value || undefined })}
placeholder="Default"
labelClassName="text-xs"
/>
</div>
<Button variant="ghost" size="sm" onClick={onDelete}>
<Trash size={16} />
</Button>
</div>
<ValidationPanel field={field} onChange={onChange} />
</CardContent>
</div>
)
}
interface TextFieldProps {
label: string
value: string
onChange: (value: string) => void
placeholder?: string
labelClassName?: string
}
function TextField({ label, value, onChange, placeholder, labelClassName }: TextFieldProps) {
return (
<div className="space-y-2">
<Label className={labelClassName}>{label}</Label>
<Input
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
/>
</div>
)
}

View File

@@ -0,0 +1,84 @@
import { Input, Label, Switch } from '@/components/ui'
import type { FieldSchema } from '@/lib/schema-types'
interface ValidationPanelProps {
field: FieldSchema
onChange: (updates: Partial<FieldSchema>) => void
}
const numericKeys = ['min', 'max', 'minLength', 'maxLength'] as const
type NumericValidationKey = (typeof numericKeys)[number]
const labels: Record<NumericValidationKey, string> = {
min: 'Minimum Value',
max: 'Maximum Value',
minLength: 'Minimum Length',
maxLength: 'Maximum Length',
}
export function ValidationPanel({ field, onChange }: ValidationPanelProps) {
const handleNumberChange = (key: NumericValidationKey, value: string) => {
const parsedValue = value === '' ? undefined : Number(value)
const nextValidation = {
...field.validation,
[key]: Number.isNaN(parsedValue) ? undefined : parsedValue,
}
onChange({ validation: nextValidation })
}
return (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{numericKeys.map((key) => (
<div key={key} className="space-y-2">
<Label className="text-xs">{labels[key]}</Label>
<Input
type="number"
value={field.validation?.[key] ?? ''}
onChange={(event) => handleNumberChange(key, event.target.value)}
placeholder="0"
/>
</div>
))}
<div className="space-y-2 lg:col-span-2">
<Label className="text-xs">Pattern (regex)</Label>
<Input
value={field.validation?.pattern ?? ''}
onChange={(event) =>
onChange({
validation: {
...field.validation,
pattern: event.target.value || undefined,
},
})
}
placeholder="^\\d{3}-\\d{3}-\\d{4}$"
/>
</div>
</div>
<div className="flex flex-wrap gap-4">
<Toggle label="Required" checked={field.required} onCheckedChange={(checked) => onChange({ required: checked })} />
<Toggle label="Unique" checked={field.unique} onCheckedChange={(checked) => onChange({ unique: checked })} />
<Toggle label="Editable" checked={field.editable !== false} onCheckedChange={(checked) => onChange({ editable: checked })} />
<Toggle label="Searchable" checked={field.searchable} onCheckedChange={(checked) => onChange({ searchable: checked })} />
</div>
</div>
)
}
interface ToggleProps {
label: string
checked?: boolean
onCheckedChange: (value: boolean) => void
}
function Toggle({ label, checked, onCheckedChange }: ToggleProps) {
return (
<div className="flex items-center gap-2">
<Switch checked={checked ?? false} onCheckedChange={onCheckedChange} />
<Label className="text-xs">{label}</Label>
</div>
)
}

View File

@@ -0,0 +1,127 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import type { FieldSchema, ModelSchema } from '@/lib/schema-types'
interface UseSchemaLevel4Props {
schemas: ModelSchema[]
onSchemasChange: (schemas: ModelSchema[]) => void
}
export function useSchemaLevel4({ schemas, onSchemasChange }: UseSchemaLevel4Props) {
const [selectedModel, setSelectedModel] = useState<string | null>(schemas[0]?.name ?? null)
useEffect(() => {
if (!selectedModel && schemas[0]) {
setSelectedModel(schemas[0].name)
}
if (selectedModel && !schemas.some(schema => schema.name === selectedModel)) {
setSelectedModel(schemas[0]?.name ?? null)
}
}, [schemas, selectedModel])
const currentModel = useMemo(
() => schemas.find((schema) => schema.name === selectedModel) ?? null,
[schemas, selectedModel]
)
const applyChanges = useCallback(
(nextSchemas: ModelSchema[]) => {
onSchemasChange(nextSchemas)
},
[onSchemasChange]
)
const handleAddModel = useCallback(() => {
const newModel: ModelSchema = {
name: `Model_${Date.now()}`,
label: 'New Model',
fields: [],
}
applyChanges([...schemas, newModel])
setSelectedModel(newModel.name)
toast.success('Model created')
}, [applyChanges, schemas])
const handleDeleteModel = useCallback(
(modelName: string) => {
const updatedSchemas = schemas.filter((schema) => schema.name !== modelName)
applyChanges(updatedSchemas)
if (selectedModel === modelName) {
setSelectedModel(updatedSchemas[0]?.name ?? null)
}
toast.success('Model deleted')
},
[applyChanges, schemas, selectedModel]
)
const handleUpdateModel = useCallback(
(updates: Partial<ModelSchema>) => {
if (!currentModel) return
applyChanges(
schemas.map((schema) =>
schema.name === currentModel.name ? { ...schema, ...updates } : schema
)
)
},
[applyChanges, currentModel, schemas]
)
const handleAddField = useCallback(() => {
if (!currentModel) return
const newField: FieldSchema = {
name: `field_${Date.now()}`,
type: 'string',
label: 'New Field',
required: false,
editable: true,
}
handleUpdateModel({
fields: [...currentModel.fields, newField],
})
toast.success('Field added')
}, [currentModel, handleUpdateModel])
const handleDeleteField = useCallback(
(fieldName: string) => {
if (!currentModel) return
handleUpdateModel({
fields: currentModel.fields.filter((field) => field.name !== fieldName),
})
toast.success('Field deleted')
},
[currentModel, handleUpdateModel]
)
const handleUpdateField = useCallback(
(fieldName: string, updates: Partial<FieldSchema>) => {
if (!currentModel) return
handleUpdateModel({
fields: currentModel.fields.map((field) =>
field.name === fieldName ? { ...field, ...updates } : field
),
})
},
[currentModel, handleUpdateModel]
)
return {
currentModel,
selectedModel,
selectModel: setSelectedModel,
handleAddField,
handleAddModel,
handleDeleteField,
handleDeleteModel,
handleUpdateField,
handleUpdateModel,
}
}