import { IconArrowNarrowRight, IconChevronRight, IconDatabase, IconKey, IconLink, IconPlus, IconTrash, } from "@tabler/icons-react"; import { useCallback, useMemo, useRef, useState } from "react"; import { cn } from "../../utils.js"; import { ltrCodeBlockProps } from "../code-block-direction.js"; import type { BlockEditProps, BlockReadProps } from "../types.js"; import type { DataModelChange, DataModelData, DataModelEntity, DataModelField, DataModelRelation, DataModelRelationKind, } from "./data-model.config.js"; import { DATA_MODEL_CHANGES } from "./data-model.config.js"; import { DevInput, DevSelect } from "./dev-doc-ui.js"; /** * Read + Edit renderers for a `data-model` block — a dbdiagram / Prisma-style * entity-relationship diagram. Lives in core so any app can register the dev-doc * block (no shadcn import). */ /* ── Theme-aware change tokens (shared vocabulary with `file-tree`) ─────────── */ /** * Change-chip palette — the SAME tinted-bg + saturated-text scheme the * `file-tree` block uses, in BOTH the `.dark` plan theme and light mode (never a * dark-only palette). Keeps data-model diff chips visually consistent with the * file-tree change badges so a reviewer reads one vocabulary across dev-doc * blocks. */ const CHANGE_BADGE: Record = { added: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300", modified: "bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300", removed: "bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300", renamed: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300", }; /** Human-readable chip label, matching the file-tree change labels. */ const CHANGE_LABEL: Record = { added: "Added", modified: "Modified", removed: "Removed", renamed: "Renamed", }; /** Accent ink for a changed field/entity name, echoing its change color. */ const CHANGE_NAME_INK: Record = { added: "text-emerald-700 dark:text-emerald-300", modified: "text-blue-700 dark:text-blue-300", removed: "text-red-600 line-through dark:text-red-300", renamed: "text-violet-700 dark:text-violet-300", }; /** Subtle left accent rule on a changed field row (added = green, removed = red). */ const CHANGE_ROW_ACCENT: Record = { added: "border-l-2 border-l-emerald-400 dark:border-l-emerald-500/60", modified: "border-l-2 border-l-blue-400 dark:border-l-blue-500/60", removed: "border-l-2 border-l-red-400 dark:border-l-red-500/60", renamed: "border-l-2 border-l-violet-400 dark:border-l-violet-500/60", }; /** A small theme-aware change chip ("Added" / "Modified" / …). */ function ChangeChip({ change, className, }: { change: DataModelChange; className?: string; }) { return ( {CHANGE_LABEL[change]} ); } /* ── Resolution helpers (shared by Read + relation inference) ──────────────── */ /** Split a `fk` string like `"User.id"` into `{ entity: "User", field: "id" }`. */ function parseFk(fk: string): { entity: string; field?: string } { const trimmed = fk.trim(); const dot = trimmed.indexOf("."); if (dot === -1) return { entity: trimmed }; return { entity: trimmed.slice(0, dot).trim(), field: trimmed.slice(dot + 1).trim() || undefined, }; } /** * Resolve an entity reference (used by `fk` targets and `relation.from`/`to`) * against the entity list by `id` first, then by case-insensitive `name`. Returns * the matched entity or `undefined`. */ function resolveEntity( entities: DataModelEntity[], ref: string, ): DataModelEntity | undefined { const needle = ref.trim(); return ( entities.find((entity) => entity.id === needle) ?? entities.find( (entity) => entity.name.toLowerCase() === needle.toLowerCase(), ) ); } /** A short, readable label for an entity reference (its name, or the raw ref). */ function entityLabel(entities: DataModelEntity[], ref: string): string { return resolveEntity(entities, ref)?.name ?? ref; } /** The cardinality glyph shown in the relations list (1:1 / 1:n / n:n). */ function relationGlyph(kind?: DataModelRelationKind): string { if (kind === "1-1") return "1:1"; if (kind === "n-n") return "n:n"; return "1:n"; } /** * Relations to render: explicit `relations` when present, otherwise inferred — * every `fk` field becomes a `1-n` relation from the referenced (parent) entity * to the entity holding the foreign key, so the connectors list is never empty * when the schema clearly implies them. */ function effectiveRelations(data: DataModelData): DataModelRelation[] { if (data.relations && data.relations.length > 0) return data.relations; const inferred: DataModelRelation[] = []; for (const entity of data.entities) { for (const field of entity.fields) { if (!field.fk) continue; const target = resolveEntity(data.entities, parseFk(field.fk).entity); if (!target) continue; inferred.push({ from: target.id, to: entity.id, kind: "1-n", label: field.name, }); } } return inferred; } /* ── Read (interactive ERD) ────────────────────────────────────────────────── */ /** * Read-only renderer for a `data-model` block — a dbdiagram / Prisma-style * entity-relationship diagram. Each entity is a collapsible card: the header * shows the entity name + field count, and expanding it reveals a compact field * table (Field · Type · flags) with PK / FK / nullable indicators. * * INTERACTIVITY (the reason this is a custom block, not a plain table): hovering * or clicking a foreign-key field highlights the referenced entity card — it * scrolls into view, expands, and gets a temporary accent ring — so a reader can * trace a relationship across the whole model. Explicit `relations` (or relations * inferred from `fk` fields) render as a labeled connector list below the cards. * * Every color is theme-aware via Tailwind `dark:` variants or plan CSS vars, so * the diagram reads correctly in both the `.dark` plan theme and light mode. */ export function DataModelRead({ data, blockId, title, summary, }: BlockReadProps) { const entities = data.entities ?? []; const relations = useMemo(() => effectiveRelations(data), [data]); // Per-entity collapse state. Default: the first entity expanded (or all of them // when the model is small) so the block is useful at a glance without a click. const [expanded, setExpanded] = useState>(() => { const initial: Record = {}; const expandAll = entities.length <= 2; entities.forEach((entity, index) => { initial[entity.id] = expandAll || index === 0; }); return initial; }); // Which entity is being hovered/clicked-to via an FK — drives the accent ring. const [highlighted, setHighlighted] = useState(null); const cardRefs = useRef>({}); const toggle = useCallback((id: string) => { setExpanded((current) => ({ ...current, [id]: !current[id] })); }, []); // Highlight + reveal a referenced entity: expand it, ring it, and scroll it // into view. Used on FK hover (transient) and click (scroll). const focusEntity = useCallback( (targetId: string | undefined, scroll: boolean) => { if (!targetId) { setHighlighted(null); return; } setHighlighted(targetId); if (scroll) { setExpanded((current) => ({ ...current, [targetId]: true })); cardRefs.current[targetId]?.scrollIntoView({ behavior: "smooth", block: "nearest", }); } }, [], ); return (
{title &&
{title}
}
{entities.map((entity) => { const isOpen = expanded[entity.id] ?? false; const isHighlighted = highlighted === entity.id; return (
{ cardRefs.current[entity.id] = node; }} data-entity-id={entity.id} className={cn( "overflow-hidden rounded-xl border bg-plan-block transition-shadow", isHighlighted ? "border-blue-400 ring-2 ring-blue-400/60 dark:border-blue-400 dark:ring-blue-400/50" : "border-plan-line", )} > {/* Entity header — always visible, toggles the field table. */} {/* Expanded field table. */} {isOpen && (
{entity.note && (

{entity.note}

)} {entity.fields.map((field, index) => { const fkTarget = field.fk ? resolveEntity(entities, parseFk(field.fk).entity) : undefined; return ( focusEntity(fkTarget.id, false) : undefined } onMouseLeave={ fkTarget ? () => focusEntity(undefined, false) : undefined } onClick={ fkTarget ? () => focusEntity(fkTarget.id, true) : undefined } > ); })} {entity.fields.length === 0 && ( )}
{field.pk && ( )} {field.fk && ( )} {field.name}
{/* Prior value (`was`) for a modified field — struck through ahead of the current type. */} {field.change === "modified" && field.was && ( <> {field.was} )} {field.type && ( {field.type} )}
{field.change && ( )} {field.pk && ( PK )} {field.fk && ( FK {fkTarget ? `${fkTarget.name}${ parseFk(field.fk).field ? `.${parseFk(field.fk).field}` : "" }` : field.fk} )} {field.nullable && ( nullable )} {field.default != null && field.default !== "" && ( = {field.default} )}
{field.note && (
{field.note}
)}
No fields yet.
)}
); })}
{/* Relations / connectors list. */} {relations.length > 0 && (
Relations
{relations.map((relation, index) => { const fromEntity = resolveEntity(entities, relation.from); const toEntity = resolveEntity(entities, relation.to); return ( ); })}
)} {summary &&

{summary}

}
); } /* ── Edit (panel form) ─────────────────────────────────────────────────────── */ let entitySeq = 0; /** Stable-enough new entity id for a freshly-added entity in the editor. */ function newEntityId(): string { entitySeq += 1; return `e_${Date.now().toString(36)}_${entitySeq}`; } /** * Panel editor for a `data-model` block. A structured form: a list of entities * (add/remove), each with a name Input, an optional note, and repeatable field * rows (add/remove) carrying name / type / PK checkbox / FK input / nullable * checkbox. Relations are derived from `fk` in v1, so the form focuses on the * entities + fields. Renders BARE content (no `
`); the registry's panel * surface supplies the popover chrome. */ export function DataModelEdit({ data, onChange, editable, }: BlockEditProps) { const entities = data.entities ?? []; const patchEntities = (next: DataModelEntity[]) => onChange({ ...data, entities: next }); const updateEntity = (index: number, next: Partial) => patchEntities( entities.map((entity, i) => i === index ? { ...entity, ...next } : entity, ), ); const removeEntity = (index: number) => patchEntities(entities.filter((_, i) => i !== index)); const addEntity = () => patchEntities([ ...entities, { id: newEntityId(), name: "NewEntity", fields: [{ name: "id", pk: true }], }, ]); const updateField = ( entityIndex: number, fieldIndex: number, next: Partial, ) => { const entity = entities[entityIndex]; if (!entity) return; updateEntity(entityIndex, { fields: entity.fields.map((field, i) => i === fieldIndex ? { ...field, ...next } : field, ), }); }; const removeField = (entityIndex: number, fieldIndex: number) => { const entity = entities[entityIndex]; if (!entity) return; updateEntity(entityIndex, { fields: entity.fields.filter((_, i) => i !== fieldIndex), }); }; const addField = (entityIndex: number) => { const entity = entities[entityIndex]; if (!entity) return; updateEntity(entityIndex, { fields: [...entity.fields, { name: "field" }], }); }; return (
{entities.map((entity, entityIndex) => (
updateEntity(entityIndex, { name: event.target.value }) } /> updateEntity(entityIndex, { change: value === "none" ? undefined : (value as DataModelChange), }) } options={[ { value: "none", label: "No change" }, ...DATA_MODEL_CHANGES.map((change) => ({ value: change, label: CHANGE_LABEL[change], })), ]} /> {editable && ( )}
{/* Field rows. */}
{entity.fields.map((field, fieldIndex) => (
updateField(entityIndex, fieldIndex, { name: event.target.value, }) } /> updateField(entityIndex, fieldIndex, { type: event.target.value || undefined, }) } /> {editable && ( )}
updateField(entityIndex, fieldIndex, { fk: event.target.value || undefined, }) } />
{/* Diff row: change kind + the prior value (`was`) when the field is "modified", so before/after renders in Read. */}
updateField(entityIndex, fieldIndex, { change: value === "none" ? undefined : (value as DataModelChange), // Drop a stale `was` when leaving the modified state. ...(value === "modified" ? {} : { was: undefined }), }) } options={[ { value: "none", label: "No change" }, ...DATA_MODEL_CHANGES.map((change) => ({ value: change, label: CHANGE_LABEL[change], })), ]} /> {field.change === "modified" && ( updateField(entityIndex, fieldIndex, { was: event.target.value || undefined, }) } /> )}
))} {editable && ( )}
))} {editable && ( )}
); }