Both canvases now share a canonical SysMLModel through React context.
Renames in the diagram inspector ripple to every chip in the narrative
referencing the same refId, and double-clicking a chip emits an update op
that re-renders the diagram block. Validation re-runs on every successful
apply. State is in-memory; M5.9 adds Postgres persistence.
apps/web/lib/sync (new)
- ops.ts: ModelOp alphabet (18 op kinds across block / property /
association / constraint / requirement / relation, add/update/remove for
each). tempId() helper + per-kind constructors.
- applyOps.ts: pure (model, ops) → { model, idMapping, errors, applied }
reducer. Atomic per batch. Cascades on remove-block (drops incident
associations + constraint applies-to + requirement satisfiers). tempId
resolution rewrites to canonical ids on duplicate-id collision.
- ModelStore.tsx: React provider exposing { model, apply, issues,
issuesByElement }. Validation memoized on every model change.
Editor refactor
- EditorShell wraps in ModelStoreProvider. Initial model derived from
fixture (+ optional ?break= corruptions). Validation now lives in the
store, not duplicated here.
- LeftRail consumes useModel(): Model section lists real blocks +
constraints (sorted by kind), Requirements section lists real
requirements with traced/untraced status from r.relations.
Diagram refactor (the tricky piece)
- React Flow now owns ephemeral state via useNodesState / useEdgesState.
Positions, drag-in-progress, selection are all RF-internal.
- Model → RF: a useEffect runs on model change, applies targeted setNodes
updates only for elements whose semantic data (label, kind, properties)
changed. Object identity preserved for unchanged nodes — fixes the
"re-render storm on drag" + RF measurement-cache loss.
- RF → Model: onNodesChange / onEdgesChange / onConnect / onDrop emit
ops via useApply(). Constraint-applies edges decompose into
updateConstraint ops. deleteKeyCode={[Backspace, Delete]}.
- onNodesChange now handles type:'remove' too (was missing — that's why
selecting a block + Del removed only the edges, leaving the block).
Chip refactor
- ChipView resolves displayed label from useModel() via refId lookup
(block / requirement / association / property). Double-click chip →
inline rename input → emit update-{block,requirement,association} op.
All other chips with the same refId update on the next render.
- Slash-menu inserted chips have refId=null and skip the rename
affordance (until M6 wires real model element resolution).
Removed obsolete components/diagram-canvas/fixtureToFlow.ts; replaced
with modelToFlow.ts. Bumped CSS for the chip-rename input.
236 lines
7.5 KiB
TypeScript
236 lines
7.5 KiB
TypeScript
// The canonical ModelOp alphabet — every mutation to a SysMLModel is
|
|
// expressible as one or more of these. See docs/sync.md §3 for the design.
|
|
//
|
|
// `tempId` lets clients optimistically create elements with a client-generated
|
|
// id; applyOps may rewrite to a canonical id and return the mapping. (M5
|
|
// in-memory: tempId IS the canonical id; the rewrite seam is here for the
|
|
// later DB / SSE iteration.)
|
|
|
|
import type {
|
|
Block,
|
|
BlockKind,
|
|
Association,
|
|
AssociationKind,
|
|
Constraint,
|
|
Property,
|
|
PropertyType,
|
|
Multiplicity,
|
|
Requirement,
|
|
RequirementRelation,
|
|
} from "../sysml/model";
|
|
|
|
// ─── Block ops ───────────────────────────────────────────────────────────
|
|
|
|
export interface AddBlockOp {
|
|
kind: "add-block";
|
|
block: Block;
|
|
tempId?: string;
|
|
}
|
|
|
|
export interface UpdateBlockOp {
|
|
kind: "update-block";
|
|
blockId: string;
|
|
patch: Partial<Pick<Block, "label" | "kind" | "stereotypes" | "description">>;
|
|
}
|
|
|
|
export interface RemoveBlockOp {
|
|
kind: "remove-block";
|
|
blockId: string;
|
|
}
|
|
|
|
// ─── Property ops ────────────────────────────────────────────────────────
|
|
|
|
export interface AddPropertyOp {
|
|
kind: "add-property";
|
|
blockId: string;
|
|
property: Property;
|
|
tempId?: string;
|
|
}
|
|
|
|
export interface UpdatePropertyOp {
|
|
kind: "update-property";
|
|
blockId: string;
|
|
propertyId: string;
|
|
patch: Partial<Pick<Property, "name" | "type" | "multiplicity" | "description">>;
|
|
}
|
|
|
|
export interface RemovePropertyOp {
|
|
kind: "remove-property";
|
|
blockId: string;
|
|
propertyId: string;
|
|
}
|
|
|
|
// ─── Association ops ─────────────────────────────────────────────────────
|
|
|
|
export interface AddAssociationOp {
|
|
kind: "add-association";
|
|
association: Association;
|
|
tempId?: string;
|
|
}
|
|
|
|
export interface UpdateAssociationOp {
|
|
kind: "update-association";
|
|
associationId: string;
|
|
patch: Partial<Pick<Association, "fromBlockId" | "toBlockId" | "label" | "kind" | "multiplicity">>;
|
|
}
|
|
|
|
export interface RemoveAssociationOp {
|
|
kind: "remove-association";
|
|
associationId: string;
|
|
}
|
|
|
|
// ─── Constraint ops ──────────────────────────────────────────────────────
|
|
|
|
export interface AddConstraintOp {
|
|
kind: "add-constraint";
|
|
constraint: Constraint;
|
|
tempId?: string;
|
|
}
|
|
|
|
export interface UpdateConstraintOp {
|
|
kind: "update-constraint";
|
|
constraintId: string;
|
|
patch: Partial<Pick<Constraint, "label" | "expression" | "appliesTo">>;
|
|
}
|
|
|
|
export interface RemoveConstraintOp {
|
|
kind: "remove-constraint";
|
|
constraintId: string;
|
|
}
|
|
|
|
// ─── Requirement ops ─────────────────────────────────────────────────────
|
|
|
|
export interface AddRequirementOp {
|
|
kind: "add-requirement";
|
|
requirement: Requirement;
|
|
tempId?: string;
|
|
}
|
|
|
|
export interface UpdateRequirementOp {
|
|
kind: "update-requirement";
|
|
requirementId: string;
|
|
patch: Partial<Pick<Requirement, "tag" | "text">>;
|
|
}
|
|
|
|
export interface RemoveRequirementOp {
|
|
kind: "remove-requirement";
|
|
requirementId: string;
|
|
}
|
|
|
|
export interface AddRelationOp {
|
|
kind: "add-relation";
|
|
requirementId: string;
|
|
relation: RequirementRelation;
|
|
}
|
|
|
|
export interface RemoveRelationOp {
|
|
kind: "remove-relation";
|
|
requirementId: string;
|
|
relationIndex: number;
|
|
}
|
|
|
|
// ─── Union ───────────────────────────────────────────────────────────────
|
|
|
|
export type ModelOp =
|
|
| AddBlockOp
|
|
| UpdateBlockOp
|
|
| RemoveBlockOp
|
|
| AddPropertyOp
|
|
| UpdatePropertyOp
|
|
| RemovePropertyOp
|
|
| AddAssociationOp
|
|
| UpdateAssociationOp
|
|
| RemoveAssociationOp
|
|
| AddConstraintOp
|
|
| UpdateConstraintOp
|
|
| RemoveConstraintOp
|
|
| AddRequirementOp
|
|
| UpdateRequirementOp
|
|
| RemoveRequirementOp
|
|
| AddRelationOp
|
|
| RemoveRelationOp;
|
|
|
|
// ─── Op constructors (call sites stay readable) ─────────────────────────
|
|
|
|
export function addBlock(block: Block, tempId?: string): AddBlockOp {
|
|
return { kind: "add-block", block, tempId };
|
|
}
|
|
|
|
export function updateBlock(blockId: string, patch: UpdateBlockOp["patch"]): UpdateBlockOp {
|
|
return { kind: "update-block", blockId, patch };
|
|
}
|
|
|
|
export function removeBlock(blockId: string): RemoveBlockOp {
|
|
return { kind: "remove-block", blockId };
|
|
}
|
|
|
|
export function addProperty(blockId: string, property: Property, tempId?: string): AddPropertyOp {
|
|
return { kind: "add-property", blockId, property, tempId };
|
|
}
|
|
|
|
export function updateProperty(blockId: string, propertyId: string, patch: UpdatePropertyOp["patch"]): UpdatePropertyOp {
|
|
return { kind: "update-property", blockId, propertyId, patch };
|
|
}
|
|
|
|
export function removeProperty(blockId: string, propertyId: string): RemovePropertyOp {
|
|
return { kind: "remove-property", blockId, propertyId };
|
|
}
|
|
|
|
export function addAssociation(association: Association, tempId?: string): AddAssociationOp {
|
|
return { kind: "add-association", association, tempId };
|
|
}
|
|
|
|
export function updateAssociation(associationId: string, patch: UpdateAssociationOp["patch"]): UpdateAssociationOp {
|
|
return { kind: "update-association", associationId, patch };
|
|
}
|
|
|
|
export function removeAssociation(associationId: string): RemoveAssociationOp {
|
|
return { kind: "remove-association", associationId };
|
|
}
|
|
|
|
export function addConstraint(constraint: Constraint, tempId?: string): AddConstraintOp {
|
|
return { kind: "add-constraint", constraint, tempId };
|
|
}
|
|
|
|
export function updateConstraint(constraintId: string, patch: UpdateConstraintOp["patch"]): UpdateConstraintOp {
|
|
return { kind: "update-constraint", constraintId, patch };
|
|
}
|
|
|
|
export function removeConstraint(constraintId: string): RemoveConstraintOp {
|
|
return { kind: "remove-constraint", constraintId };
|
|
}
|
|
|
|
export function addRequirement(requirement: Requirement, tempId?: string): AddRequirementOp {
|
|
return { kind: "add-requirement", requirement, tempId };
|
|
}
|
|
|
|
export function updateRequirement(requirementId: string, patch: UpdateRequirementOp["patch"]): UpdateRequirementOp {
|
|
return { kind: "update-requirement", requirementId, patch };
|
|
}
|
|
|
|
export function removeRequirement(requirementId: string): RemoveRequirementOp {
|
|
return { kind: "remove-requirement", requirementId };
|
|
}
|
|
|
|
export function addRelation(requirementId: string, relation: RequirementRelation): AddRelationOp {
|
|
return { kind: "add-relation", requirementId, relation };
|
|
}
|
|
|
|
export function removeRelation(requirementId: string, relationIndex: number): RemoveRelationOp {
|
|
return { kind: "remove-relation", requirementId, relationIndex };
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
let counter = 0;
|
|
/** Generate a temp id. Distinct prefix so applyOps can recognize them. */
|
|
export function tempId(prefix = "tmp"): string {
|
|
counter++;
|
|
return `${prefix}_${Date.now().toString(36)}_${counter}`;
|
|
}
|
|
|
|
/** Quick property factory used by clients producing add-property ops. */
|
|
export function newProperty(name: string, type: PropertyType = { kind: "string" }, multiplicity: Multiplicity = "0..1"): Property {
|
|
return { id: tempId("p"), name, type, multiplicity };
|
|
}
|