// PromoteToolbar — converts a taxonomy term into a formal SysML element // without leaving the document. Lives inside TermDetail. // // The four buttons map onto the existing ModelOp alphabet so undo/redo and // SSE sync work for free; each op carries `linkedTermId = term.id` so the // new formalism is anchored back to the concept that named it. // // All forms are inline + dismissive: an open form replaces the toolbar, and // the user can cancel back to the toolbar without a destructive action. "use client"; import { useState } from "react"; import { useModelStore } from "../../lib/sync/ModelStore"; import type { ClientTerm } from "../../lib/workspace/analysisStore"; import { useOpenPanes } from "../../lib/workspace/openPanesStore"; import { useEditorPaneContext } from "./sections/paneContext"; import { addBlock, addAssociation, addConstraint, addRequirement, tempId, } from "../../lib/sync/ops"; interface Props { term: ClientTerm; /** Called after a successful promote, so the parent can flash + scroll. */ onPromoted?: (kind: "block" | "association" | "constraint" | "requirement") => void; } type FormKind = null | "association" | "constraint" | "requirement"; export function PromoteToolbar({ term, onPromoted }: Props) { const { model, apply } = useModelStore(); const [open, setOpen] = useState(null); const { openSection } = useOpenPanes(); const { setFocusBlockId } = useEditorPaneContext(); // Persist the term-to-block link server-side after a successful promote so // the next analyze pass sees the link. const persistTermLink = async (blockId: string) => { try { const projectId = window.location.pathname.split("/").pop() ?? ""; if (!projectId) return; await fetch(`/api/projects/${encodeURIComponent(projectId)}/term-link`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ termId: term.id, blockId }), }); } catch (err) { console.error("[PromoteToolbar] term-link persistence failed:", err); } }; const promoteBlock = () => { const id = tempId("blk"); apply([ addBlock( { id, label: term.label, kind: "block", stereotypes: ["block"], properties: [], linkedTermId: term.id, }, id ), ]); void persistTermLink(id); openSection("model"); setFocusBlockId(id); onPromoted?.("block"); }; if (open === "association") { return ( setOpen(null)} onSubmit={(fromId, toId, label) => { const id = tempId("a"); apply([ addAssociation( { id, fromBlockId: fromId, toBlockId: toId, label, kind: "association", linkedTermId: term.id }, id ), ]); setOpen(null); openSection("model"); onPromoted?.("association"); }} /> ); } if (open === "constraint") { return ( setOpen(null)} onSubmit={(label, expression, appliesTo) => { const id = tempId("c"); apply([ addConstraint( { id, label, expression, appliesTo, linkedTermId: term.id }, id ), ]); setOpen(null); openSection("model"); onPromoted?.("constraint"); }} /> ); } if (open === "requirement") { return ( setOpen(null)} onSubmit={(tag, text, satisfiedBy) => { const id = tempId("r"); apply([ addRequirement( { id, tag, text, relations: satisfiedBy.map(blockId => ({ kind: "satisfy" as const, blockId })), linkedTermId: term.id, }, id ), ]); setOpen(null); openSection("requirements"); onPromoted?.("requirement"); }} /> ); } // No block linked yet → recommend "Make Block" as the primary action. const hasBlock = !!term.linkedBlockId; return (
); } // ─── Inline forms ─────────────────────────────────────────────────────── interface AssocFormProps { term: ClientTerm; blocks: { id: string; label: string }[]; onCancel: () => void; onSubmit: (fromId: string, toId: string, label: string) => void; } function AssociationForm({ term, blocks, onCancel, onSubmit }: AssocFormProps) { const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [label, setLabel] = useState(term.label); const canSubmit = from && to && from !== to; return (
{ e.preventDefault(); if (!canSubmit) return; onSubmit(from, to, label.trim() || term.label); }} > setLabel(e.target.value)} placeholder="verb phrase" /> ); } interface ConstraintFormProps { term: ClientTerm; blocks: { id: string; label: string }[]; onCancel: () => void; onSubmit: (label: string, expression: string, appliesTo: string[]) => void; } function ConstraintForm({ term, blocks, onCancel, onSubmit }: ConstraintFormProps) { const [label, setLabel] = useState(term.label); const [expression, setExpression] = useState(""); const [appliesTo, setAppliesTo] = useState([]); const canSubmit = !!label.trim(); return (
{ e.preventDefault(); if (!canSubmit) return; onSubmit(label.trim(), expression.trim(), appliesTo); }} > setLabel(e.target.value)} /> setExpression(e.target.value)} placeholder="e.g. sessions_per_day <= 3" /> ); } interface RequirementFormProps { term: ClientTerm; blocks: { id: string; label: string }[]; onCancel: () => void; onSubmit: (tag: string, text: string, satisfiedBy: string[]) => void; } function RequirementForm({ term, blocks, onCancel, onSubmit }: RequirementFormProps) { const [tag, setTag] = useState("REQ-001"); const [text, setText] = useState(""); const [satisfiedBy, setSatisfiedBy] = useState( term.linkedBlockId ? [term.linkedBlockId] : [] ); const canSubmit = !!tag.trim() && !!text.trim(); return (
{ e.preventDefault(); if (!canSubmit) return; onSubmit(tag.trim(), text.trim(), satisfiedBy); }} > setTag(e.target.value)} />