'use client' import { useState } from 'react' import { Plus, Trash2 } from 'lucide-react' import { Button } from '../ui/button' import { Input } from '../ui/input' import { Label } from '../ui/label' import { Checkbox } from '../ui/checkbox' import { Card, CardContent, CardHeader, CardTitle } from '../ui/card' import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from '../ui/select' import { notify as defaultNotify } from '../toast' import { cn } from '../../lib/utils' import { type DataType, type EditableDraft, type ExistingAttribute, type AttributePayload, type SchemaId, DATA_TYPES, blankAttribute, toKey, draftToAttributePayloads, attributeToDraft, diffAttributes, } from './schema-builder-core' /** The type the backend echoes back after creation (only `id` is required). */ export interface CreatedType { id: SchemaId key: string label: string } /** A persisted type loaded into the builder for editing. */ export interface ExistingType { id: SchemaId key: string label: string attributes: ExistingAttribute[] } /** A minimal notifier surface; defaults to @startsimpli/ui's toast notify. */ export interface SchemaTypeBuilderNotifier { success?: (message: string) => void error?: (message: string) => void } /** Theme slots so a consuming app can restyle the builder without forking it. */ export interface SchemaTypeBuilderClassNames { root?: string card?: string fieldRow?: string submit?: string } export interface SchemaTypeBuilderProps { /** Persist the entity type. Must resolve to at least its server-assigned id. */ createType: (input: { key: string; label: string }) => Promise<{ id: SchemaId }> /** Persist one attribute against a type. */ createAttribute: (input: AttributePayload) => Promise /** Fired after the type + its attributes are persisted (create OR save). Navigate / refetch here. */ onCreated?: (created: CreatedType) => void // ---- edit mode (optional): pass an existing type to load + edit it ---- /** When present, the builder opens in edit mode pre-filled from this type. */ existingType?: ExistingType /** Rename a type's label (key is immutable). Required for label edits. */ updateType?: (id: SchemaId, input: { label: string }) => Promise /** Patch an existing attribute. Required for field edits in edit mode. */ updateAttribute?: ( id: SchemaId, input: Omit, ) => Promise /** Delete an existing attribute. Required to remove fields in edit mode. */ deleteAttribute?: (id: SchemaId) => Promise /** Delete the whole type. When provided in edit mode, a delete control is shown. */ deleteType?: (id: SchemaId) => Promise /** Fired after the type is deleted (navigate away / refetch here). */ onDeleted?: (type: ExistingType) => void /** Inject a notifier; defaults to the shared @startsimpli/ui notify. */ notify?: SchemaTypeBuilderNotifier /** Override the create-mode submit label (default "Create type"). */ submitLabel?: string classNames?: SchemaTypeBuilderClassNames } function initialDrafts(existingType?: ExistingType): EditableDraft[] { if (existingType && existingType.attributes.length) { return existingType.attributes.map(attributeToDraft) } return [blankAttribute()] } /** * No-code entity-type builder, promoted from frontend-starter (rule #9). The * persistence layer is injected via callback props so the component carries no * API client, router, or query-cache coupling. Pass `existingType` (+ the * update/delete callbacks) to open it in edit mode; otherwise it creates. */ export function SchemaTypeBuilder({ createType, createAttribute, onCreated, existingType, updateType, updateAttribute, deleteAttribute, deleteType, onDeleted, notify = defaultNotify, submitLabel = 'Create type', classNames, }: SchemaTypeBuilderProps) { const editing = !!existingType const [label, setLabel] = useState(existingType?.label ?? '') const [keyOverride, setKeyOverride] = useState('') const [attributes, setAttributes] = useState(() => initialDrafts(existingType)) const [saving, setSaving] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false) const effectiveKey = editing ? existingType!.key : keyOverride.trim() || toKey(label) function updateAttr(index: number, patch: Partial) { setAttributes((prev) => prev.map((a, i) => (i === index ? { ...a, ...patch } : a))) } function addAttr() { setAttributes((prev) => [...prev, blankAttribute()]) } function removeAttr(index: number) { setAttributes((prev) => prev.filter((_, i) => i !== index)) } async function persistEdit() { const type = existingType! if (updateType && label.trim() !== type.label) { await updateType(type.id, { label: label.trim() }) } const { creates, updates, deletes } = diffAttributes(type.id, type.attributes, attributes) for (const payload of creates) await createAttribute(payload) for (const u of updates) { const { id, ...rest } = u if (updateAttribute) await updateAttribute(id, rest) } for (const id of deletes) { if (deleteAttribute) await deleteAttribute(id) } } async function persistCreate() { const type = await createType({ key: effectiveKey, label: label.trim() }) // Create each declared attribute, in order; blank rows + config are handled // by the pure helper. (New attributes carry no id in edit mode either.) for (const payload of draftToAttributePayloads(type.id, attributes)) { await createAttribute(payload) } return type.id } async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!label.trim() || !effectiveKey) { notify.error?.('Give your type a name.') return } setSaving(true) try { if (editing) { await persistEdit() notify.success?.(`Saved "${label.trim()}".`) onCreated?.({ id: existingType!.id, key: effectiveKey, label: label.trim() }) } else { const id = await persistCreate() notify.success?.(`Created "${label.trim()}".`) onCreated?.({ id, key: effectiveKey, label: label.trim() }) } } catch (err) { notify.error?.(err instanceof Error ? err.message : 'Could not save the type.') } finally { setSaving(false) } } async function handleDelete() { if (!existingType || !deleteType) return setSaving(true) try { await deleteType(existingType.id) notify.success?.(`Deleted "${existingType.label}".`) onDeleted?.(existingType) } catch (err) { notify.error?.(err instanceof Error ? err.message : 'Could not delete the type.') } finally { setSaving(false) setConfirmingDelete(false) } } return (
Type details
setLabel(e.target.value)} placeholder="e.g. Customer, Invoice, Property" />
setKeyOverride(e.target.value)} disabled={editing} placeholder={toKey(label) || 'auto-generated from the name'} />

{editing ? ( <>The key is fixed once a type exists. ) : ( <> Stable identifier used in URLs and the API. Defaults to{' '} {effectiveKey || '—'}. )}

Fields {attributes.map((attr, i) => (
updateAttr(i, { name: e.target.value })} placeholder="e.g. email, due_date, amount" />
updateAttr(i, { required: c === true })} />
{attr.dataType === 'enum' && (
updateAttr(i, { choices: e.target.value })} placeholder="e.g. Open, In progress, Done" />
)} {(attr.dataType === 'number' || attr.dataType === 'integer') && (
updateAttr(i, { min: e.target.value })} placeholder="e.g. 0" />
updateAttr(i, { max: e.target.value })} placeholder="e.g. 100" />
)} {(attr.dataType === 'text' || attr.dataType === 'longtext') && (
updateAttr(i, { regex: e.target.value })} placeholder="e.g. [^@]+@[^@]+\.[^@]+" />

Values must fully match this pattern.

)}
))}
{editing && deleteType && ( confirmingDelete ? (
Delete this type and all its fields?
) : ( ) )}
) }