From 1b5a2a8a00dd42226fbeaec8d14746450cd30082 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Thu, 1 Oct 2026 15:53:57 +0200 Subject: [PATCH 1/4] feat(common): describe node wizards in the metamodel A node type can carry a `wizard`: steps of fields, relations to link, or a built-in editor (EARS quick entry), plus prefilled values. Being plain metamodel data, custom metamodels can define wizards too. The governance preset gets wizards for ADR, fitness function, requirement, scenario and mockup. Helpers in metamodel/wizard.ts resolve each step's visible fields, the nodes a relation step may link, missing required fields and the links a node already has. Co-Authored-By: Claude Opus 5.5 --- packages/common/src/metamodel/index.ts | 2 + .../src/metamodel/presets/governance.ts | 82 +++++++++ packages/common/src/metamodel/types.ts | 49 ++++++ packages/common/src/metamodel/wizard.ts | 164 ++++++++++++++++++ packages/common/tests/nodeWizard.test.ts | 126 ++++++++++++++ 5 files changed, 423 insertions(+) create mode 100644 packages/common/src/metamodel/wizard.ts create mode 100644 packages/common/tests/nodeWizard.test.ts diff --git a/packages/common/src/metamodel/index.ts b/packages/common/src/metamodel/index.ts index 4b462ea1..8652cc32 100644 --- a/packages/common/src/metamodel/index.ts +++ b/packages/common/src/metamodel/index.ts @@ -6,10 +6,12 @@ // • lookup.ts — allowed-parent / allowed-relation / cardinality helpers // • validate.ts — validateModel + Issue // • ears.ts — EARS sentence compose/parse for the Requirement node type +// • wizard.ts — step / field / relation helpers behind the node wizard // • presets/ — the built-in C4, C4+DDD, and C4+DDD+Governance presets export * from './types' export * from './lookup' export * from './validate' export * from './ears' +export * from './wizard' export * from './presets' diff --git a/packages/common/src/metamodel/presets/governance.ts b/packages/common/src/metamodel/presets/governance.ts index 01e45e38..2e1f294e 100644 --- a/packages/common/src/metamodel/presets/governance.ts +++ b/packages/common/src/metamodel/presets/governance.ts @@ -46,6 +46,26 @@ export function builtInGovernanceMetamodel(): Metamodel { builtin: true, tableTab: true, properties: adrProps, + wizard: { + trigger: 'create', + prefill: { status: 'proposed', date: '{{today}}' }, + steps: [ + { kind: 'fields', title: 'Title & status', fields: ['label', 'status', 'date'], + help: 'Name the decision in a few words, e.g. "Use PostgreSQL for order storage".' }, + { kind: 'fields', title: 'Context', fields: ['context'], + help: 'What forces are at play — the problem, constraints and drivers that make a decision necessary now?' }, + { kind: 'fields', title: 'Decision', fields: ['decision'], + help: 'What did you decide? State it actively: "We will …".' }, + { kind: 'fields', title: 'Alternatives', fields: ['alternatives'], + help: 'Which other options did you consider, and why were they rejected?' }, + { kind: 'fields', title: 'Consequences', fields: ['consequences'], + help: 'What becomes easier or harder? Include the downsides and the risks you accept.' }, + { kind: 'relations', title: 'Affected elements', relationType: 'constrains', direction: 'out', + help: 'Which parts of the architecture must follow this decision?' }, + { kind: 'relations', title: 'Supersedes', relationType: 'supersedes', direction: 'out', + help: 'Does this decision replace an earlier one? Leave empty if not.' }, + ], + }, } const fitnessFnProps: PropertyDef[] = [ @@ -76,6 +96,21 @@ export function builtInGovernanceMetamodel(): Metamodel { builtin: true, tableTab: true, properties: fitnessFnProps, + wizard: { + trigger: 'create', + steps: [ + { kind: 'fields', title: 'Name & category', fields: ['label', 'category'], + help: 'Name the architectural characteristic this function protects, e.g. "Checkout p95 latency".' }, + { kind: 'fields', title: 'What it measures', fields: ['description'], + help: 'How is the characteristic measured — a test, a metric, a static check?' }, + { kind: 'fields', title: 'Success criteria', fields: ['threshold'], + help: 'When does it pass? Give a concrete threshold, e.g. "p95 < 300 ms".' }, + { kind: 'relations', title: 'Verified decisions', relationType: 'implements', direction: 'out', + help: 'Which ADRs does this fitness function verify?' }, + { kind: 'relations', title: 'Guarded elements', relationType: 'constrains', direction: 'out', + help: 'Which elements does it check?' }, + ], + }, } // ── EARS Requirement ────────────────────────────────────────────────────── @@ -113,6 +148,25 @@ export function builtInGovernanceMetamodel(): Metamodel { tableTab: true, hierarchyRelation: 'derives', properties: requirementProps, + wizard: { + trigger: 'create', + steps: [ + { kind: 'fields', title: 'Name', fields: ['label'], + help: 'A short name for the requirement, e.g. "Lock account after failed logins".' }, + { kind: 'custom', title: 'Sentence', component: 'ears-quick-entry', + help: 'Type the requirement as one sentence and press Enter — the EARS fields are filled for you. Or skip and fill them in the next step.' }, + { kind: 'fields', title: 'EARS fields', fields: ['ears_type', 'trigger', 'precondition', 'unwanted_condition', 'feature', 'action'], + help: 'Pick the EARS pattern; only the clauses it uses are shown.' }, + { kind: 'fields', title: 'Rationale', fields: ['rationale'], + help: 'Why is this requirement needed? Who asked for it?' }, + { kind: 'relations', title: 'Satisfied by', relationType: 'satisfies', direction: 'in', + help: 'Which elements must satisfy this requirement?' }, + { kind: 'relations', title: 'Derives from', relationType: 'derives', direction: 'out', + help: 'Is this a refinement of a broader requirement?' }, + { kind: 'relations', title: 'Traces to', relationType: 'traces-to', direction: 'out', + help: 'Which decisions or fitness functions follow from it?' }, + ], + }, } const constraintSources = ['adr', 'fitness-fn', 'requirement'] as const @@ -211,6 +265,21 @@ export function builtInGovernanceMetamodel(): Metamodel { builtin: true, tableTab: true, properties: scenarioProps, + wizard: { + trigger: 'create', + steps: [ + { kind: 'fields', title: 'Name', fields: ['label'], + help: 'Name the behaviour, e.g. "Card payment declined".' }, + { kind: 'fields', title: 'Given', fields: ['given'], + help: 'The starting state: who is involved and what is already true.' }, + { kind: 'fields', title: 'When', fields: ['when'], + help: 'The single action or event under test.' }, + { kind: 'fields', title: 'Then', fields: ['then', 'gherkin'], + help: 'The observable outcome. Add And/But steps below if needed.' }, + { kind: 'relations', title: 'Verified requirements', relationType: 'verifies', direction: 'out', + help: 'Which requirements does this scenario verify?' }, + ], + }, } // scenario → requirement: this scenario verifies that requirement @@ -289,6 +358,19 @@ export function builtInGovernanceMetamodel(): Metamodel { builtin: true, tableTab: true, properties: mockupProps, + wizard: { + trigger: 'create', + steps: [ + { kind: 'fields', title: 'Screen', fields: ['label', 'screen', 'description'], + help: 'Which screen is this, and what does the user do on it?' }, + { kind: 'fields', title: 'Design link', fields: ['link'], + help: 'A Figma / Penpot link, if the design exists. You can generate a wireframe from the properties panel later.' }, + { kind: 'relations', title: 'Illustrates', relationType: 'illustrates', direction: 'out', + help: 'Which requirements or scenarios does this screen show?' }, + { kind: 'relations', title: 'Presented by', relationType: 'presented-by', direction: 'out', + help: 'Which part of the system renders it?' }, + ], + }, } // mockup → requirement / scenario: this screen illustrates that behaviour diff --git a/packages/common/src/metamodel/types.ts b/packages/common/src/metamodel/types.ts index 705dceec..e68f56a6 100644 --- a/packages/common/src/metamodel/types.ts +++ b/packages/common/src/metamodel/types.ts @@ -63,6 +63,55 @@ export interface NodeTypeDef { * requiring `allowedParents` to include this type, which it usually * can't — containment and this kind of relation are different things). */ hierarchyRelation?: string + /** Guided, step-by-step form for filling in a node of this type. */ + wizard?: NodeWizardDef +} + +// ─── Node wizard ───────────────────────────────────────────────────────────── +// +// A wizard walks the user through a node's properties (and the relations +// that give it meaning — e.g. which elements an ADR constrains) one step at +// a time, instead of leaving them to a wall of fields in the properties +// panel. It is plain data, so custom metamodels get wizards too. + +export interface NodeWizardDef { + /** 'create' — opens whenever the user creates a node of this type (canvas, + * table, wiki). 'manual' — only from the properties panel on an existing + * node. */ + trigger: 'create' | 'manual' + /** Values a new node starts with. The string `'{{today}}'` becomes the + * current date (YYYY-MM-DD). */ + prefill?: Record + steps: WizardStep[] +} + +export type WizardStep = WizardFieldsStep | WizardRelationsStep | WizardCustomStep + +interface WizardStepBase { + title: string + /** Guidance shown above the step's inputs. */ + help?: string +} + +export interface WizardFieldsStep extends WizardStepBase { + kind: 'fields' + /** Keys of the type's `properties`, plus the built-ins `label` and + * `description`. Hidden properties (`visibleWhen`) are skipped. */ + fields: string[] +} + +export interface WizardRelationsStep extends WizardStepBase { + kind: 'relations' + /** RelationTypeDef id; its `allowedPairs` decide which nodes are offered. */ + relationType: string + /** 'out' — the wizard's node is the source; 'in' — it is the target. */ + direction: 'out' | 'in' +} + +export interface WizardCustomStep extends WizardStepBase { + kind: 'custom' + /** A built-in editor the generic field/relation steps can't express. */ + component: 'ears-quick-entry' } export interface RelationPair { diff --git a/packages/common/src/metamodel/wizard.ts b/packages/common/src/metamodel/wizard.ts new file mode 100644 index 00000000..dd02988d --- /dev/null +++ b/packages/common/src/metamodel/wizard.ts @@ -0,0 +1,164 @@ +// ─── Node wizard helpers ───────────────────────────────────────────────────── +// +// Pure functions behind the node wizard UI: which wizard applies, which +// fields a step shows for the values entered so far, which existing nodes a +// relations step offers, and what is still missing before it can finish. + +import { isPropertyVisible } from './lookup' +import type { + Metamodel, + NodeTypeDef, + NodeWizardDef, + PropertyDef, + WizardRelationsStep, + WizardStep, +} from './types' + +/** A relation the wizard will create between its node and `otherId`. */ +export interface WizardLink { + relationType: string + /** 'out' — the wizard's node is the source; 'in' — it is the target. */ + direction: 'out' | 'in' + otherId: string +} + +export function sameWizardLink(a: WizardLink, b: WizardLink): boolean { + return a.relationType === b.relationType && a.direction === b.direction && a.otherId === b.otherId +} + +/** `label` and `description` live on every node but aren't PropertyDefs. */ +const BUILTIN_FIELDS: Record = { + label: { key: 'label', label: 'Name', type: 'text', required: true }, + description: { key: 'description', label: 'Description', type: 'textarea' }, +} + +/** The wizard for `typeId`, or undefined. With `trigger: 'create'`, only a + * wizard that opens on creation counts. */ +export function wizardFor( + metamodel: Metamodel | undefined, + typeId: string, + trigger?: 'create', +): NodeWizardDef | undefined { + const wizard = metamodel?.nodeTypes[typeId]?.wizard + if (!wizard || wizard.steps.length === 0) return undefined + if (trigger && wizard.trigger !== trigger) return undefined + return wizard +} + +/** The wizard's starting values, with `{{today}}` resolved. */ +export function wizardPrefill(wizard: NodeWizardDef, now: Date = new Date()): Record { + const out: Record = {} + for (const [key, value] of Object.entries(wizard.prefill ?? {})) { + out[key] = value === '{{today}}' ? isoDate(now) : value + } + return out +} + +function isoDate(d: Date): string { + const pad = (n: number): string => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +} + +/** The fields a step shows, given the values entered so far. Unknown keys + * are dropped, as are properties hidden by `visibleWhen`. */ +export function wizardStepFields( + step: WizardStep, + typeDef: NodeTypeDef | undefined, + values: Record, +): PropertyDef[] { + if (step.kind !== 'fields') return [] + const props = typeDef?.properties ?? [] + const out: PropertyDef[] = [] + for (const key of step.fields) { + const def = props.find((p) => p.key === key) ?? BUILTIN_FIELDS[key] + if (def && isPropertyVisible(def, values)) out.push(def) + } + return out +} + +/** Node types a relations step may link to, or `null` when its relation + * type allows any pair. */ +export function wizardRelationTargetTypes( + metamodel: Metamodel | undefined, + step: WizardRelationsStep, + nodeType: string, +): string[] | null { + const rt = metamodel?.relationTypes[step.relationType] + if (!rt) return [] + if (rt.allowedPairs.length === 0) return null + const types = step.direction === 'out' + ? rt.allowedPairs.filter((p) => p.from === nodeType).map((p) => p.to) + : rt.allowedPairs.filter((p) => p.to === nodeType).map((p) => p.from) + return [...new Set(types)] +} + +/** Existing nodes a relations step offers, sorted by type then label. */ +export function wizardRelationCandidates( + metamodel: Metamodel | undefined, + step: WizardRelationsStep, + nodeType: string, + nodes: Record, + selfId?: string, +): N[] { + const types = wizardRelationTargetTypes(metamodel, step, nodeType) + return Object.values(nodes) + .filter((n) => n.id !== selfId && (types === null || types.includes(n.type))) + .sort((a, b) => a.type.localeCompare(b.type) || a.label.localeCompare(b.label)) +} + +/** The node's existing relations that the wizard's relation steps cover — + * what an edit-mode wizard starts with checked. */ +export function wizardLinksOf( + wizard: NodeWizardDef, + nodeId: string, + relations: Record, +): Array { + const out: Array = [] + const seen: WizardLink[] = [] + for (const step of wizard.steps) { + if (step.kind !== 'relations') continue + for (const r of Object.values(relations)) { + if (r.relationType !== step.relationType) continue + const otherId = step.direction === 'out' + ? (r.sourceId === nodeId ? r.targetId : null) + : (r.targetId === nodeId ? r.sourceId : null) + if (!otherId) continue + const link: WizardLink = { relationType: step.relationType, direction: step.direction, otherId } + // Two steps over the same relation type + direction share their links. + if (seen.some((l) => sameWizardLink(l, link))) continue + seen.push(link) + out.push({ ...link, relationId: r.id }) + } + } + return out +} + +/** Whether a step has anything to offer this node type — a relations step + * whose relation type can't involve it is skipped. */ +export function isWizardStepApplicable( + metamodel: Metamodel | undefined, + step: WizardStep, + nodeType: string, +): boolean { + if (step.kind !== 'relations') return true + const types = wizardRelationTargetTypes(metamodel, step, nodeType) + return types === null || types.length > 0 +} + +/** Labels of required, visible fields across the wizard's steps that are + * still empty. */ +export function wizardMissingRequired( + wizard: NodeWizardDef, + typeDef: NodeTypeDef | undefined, + values: Record, +): string[] { + const missing: string[] = [] + for (const step of wizard.steps) { + for (const def of wizardStepFields(step, typeDef, values)) { + if (!def.required || def.type === 'boolean') continue + const v = values[def.key] + if (v === undefined || v === null || String(v).trim() === '') missing.push(def.label) + } + } + return missing +} diff --git a/packages/common/tests/nodeWizard.test.ts b/packages/common/tests/nodeWizard.test.ts new file mode 100644 index 00000000..852f4097 --- /dev/null +++ b/packages/common/tests/nodeWizard.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest' +import { + builtInGovernanceMetamodel, + builtInC4Metamodel, + isWizardStepApplicable, + wizardFor, + wizardLinksOf, + wizardMissingRequired, + wizardPrefill, + wizardRelationCandidates, + wizardStepFields, + type WizardRelationsStep, +} from '../src/metamodel/index' + +const mm = builtInGovernanceMetamodel() + +describe('wizardFor', () => { + it('finds the governance types’ create-time wizards', () => { + for (const t of ['adr', 'fitness-fn', 'requirement', 'scenario', 'mockup']) { + expect(wizardFor(mm, t, 'create'), t).toBeDefined() + } + }) + + it('returns undefined for types without one', () => { + expect(wizardFor(mm, 'system')).toBeUndefined() + expect(wizardFor(builtInC4Metamodel(), 'container')).toBeUndefined() + expect(wizardFor(undefined, 'adr')).toBeUndefined() + }) + + it('skips a manual-only wizard when asked for create-time ones', () => { + const custom = builtInGovernanceMetamodel() + custom.nodeTypes.adr.wizard!.trigger = 'manual' + expect(wizardFor(custom, 'adr', 'create')).toBeUndefined() + expect(wizardFor(custom, 'adr')).toBeDefined() + }) +}) + +describe('governance wizards are consistent with their types', () => { + it('every field and relation step refers to something that exists', () => { + for (const [typeId, def] of Object.entries(mm.nodeTypes)) { + for (const step of def.wizard?.steps ?? []) { + if (step.kind === 'fields') { + for (const key of step.fields) { + const known = key === 'label' || key === 'description' || def.properties?.some((p) => p.key === key) + expect(known, `${typeId}: ${key}`).toBe(true) + } + } + if (step.kind === 'relations') { + expect(mm.relationTypes[step.relationType], `${typeId}: ${step.relationType}`).toBeDefined() + expect(isWizardStepApplicable(mm, step, typeId), `${typeId}: ${step.title}`).toBe(true) + } + } + } + }) +}) + +describe('wizardPrefill', () => { + it('resolves {{today}} to a local YYYY-MM-DD date', () => { + const v = wizardPrefill(mm.nodeTypes.adr.wizard!, new Date(2026, 0, 5, 23, 30)) + expect(v).toEqual({ status: 'proposed', date: '2026-01-05' }) + }) +}) + +describe('wizardStepFields', () => { + const req = mm.nodeTypes.requirement + const earsStep = req.wizard!.steps.find((s) => s.kind === 'fields' && s.fields.includes('ears_type'))! + + it('only shows the clauses the chosen EARS type uses', () => { + const keys = (values: Record): string[] => + wizardStepFields(earsStep, req, values).map((p) => p.key) + expect(keys({ ears_type: 'ubiquitous' })).toEqual(['ears_type', 'action']) + expect(keys({ ears_type: 'event-driven' })).toEqual(['ears_type', 'trigger', 'action']) + }) + + it('resolves the built-in label field', () => { + const [label] = wizardStepFields(mm.nodeTypes.adr.wizard!.steps[0], mm.nodeTypes.adr, {}) + expect(label).toMatchObject({ key: 'label', required: true }) + }) +}) + +describe('wizardMissingRequired', () => { + it('requires a name', () => { + const adr = mm.nodeTypes.adr + expect(wizardMissingRequired(adr.wizard!, adr, { label: ' ' })).toEqual(['Name']) + expect(wizardMissingRequired(adr.wizard!, adr, { label: 'Use Postgres' })).toEqual([]) + }) +}) + +describe('relation steps', () => { + const nodes = { + sys: { id: 'sys', type: 'system', label: 'Shop' }, + db: { id: 'db', type: 'database', label: 'Orders DB' }, + old: { id: 'old', type: 'adr', label: 'ADR-1' }, + me: { id: 'me', type: 'adr', label: 'ADR-2' }, + req: { id: 'req', type: 'requirement', label: 'R1' }, + } + const step = (relationType: string, direction: 'out' | 'in'): WizardRelationsStep => + ({ kind: 'relations', title: '', relationType, direction }) + + it('offers only nodes the relation type allows, never the node itself', () => { + const ids = (s: WizardRelationsStep, type: string, self?: string): string[] => + wizardRelationCandidates(mm, s, type, nodes, self).map((n) => n.id) + expect(ids(step('constrains', 'out'), 'adr')).toEqual(['db', 'sys']) + expect(ids(step('supersedes', 'out'), 'adr', 'me')).toEqual(['old']) + // satisfies runs element → requirement, so a requirement links "in". + expect(ids(step('satisfies', 'in'), 'requirement')).toEqual(['db', 'sys']) + }) + + it('skips a step whose relation type cannot involve the node type', () => { + expect(isWizardStepApplicable(mm, step('supersedes', 'out'), 'scenario')).toBe(false) + expect(isWizardStepApplicable(mm, step('missing', 'out'), 'adr')).toBe(false) + }) + + it('wizardLinksOf lists the existing relations the steps cover', () => { + const relations = { + r1: { id: 'r1', sourceId: 'me', targetId: 'sys', relationType: 'constrains' }, + r2: { id: 'r2', sourceId: 'me', targetId: 'old', relationType: 'supersedes' }, + r3: { id: 'r3', sourceId: 'old', targetId: 'me', relationType: 'supersedes' }, + r4: { id: 'r4', sourceId: 'me', targetId: 'db', relationType: 'uses' }, + } + expect(wizardLinksOf(mm.nodeTypes.adr.wizard!, 'me', relations)).toEqual([ + { relationType: 'constrains', direction: 'out', otherId: 'sys', relationId: 'r1' }, + { relationType: 'supersedes', direction: 'out', otherId: 'old', relationId: 'r2' }, + ]) + }) +}) From b65eaeb2c8b86fc7905372a38b1180364b4f4822 Mon Sep 17 00:00:00 2001 From: Tomasz Zajac Date: Thu, 1 Oct 2026 15:53:57 +0200 Subject: [PATCH 2/4] feat(studio): open a node's wizard wherever the node is created MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas (palette drop, double-click), the table view and the wiki now create nodes through `requestCreateNode`, which opens the type's wizard instead when it has one. The node and its links are created when the wizard finishes, as one undo step; cancelling creates nothing. AI tools and imports keep calling `addNode` and never open a wizard. The wiki's "Add child" passes the hierarchy relation as a link, so the wizard shows it pre-checked instead of adding a duplicate afterwards. "Fill in with wizard…" in the properties panel reopens it for an existing node (only changed values are written; links are added and removed to match). "Wizard on create" in the app menu turns the automatic wizard off. Co-Authored-By: Claude Opus 5.5 --- apps/studio/src/renderer/src/App.tsx | 2 + .../src/renderer/src/components/Toolbar.tsx | 19 + apps/studio/src/renderer/src/studio.css | 2 + packages/ui/package.json | 1 + packages/ui/src/components/Canvas.tsx | 11 +- packages/ui/src/components/NodeWizard.tsx | 337 ++++++++++++++++++ packages/ui/src/components/RightPanel.tsx | 6 + packages/ui/src/components/TableView.tsx | 18 +- packages/ui/src/components/WikiView.tsx | 36 +- packages/ui/src/index.css | 161 +++++++++ packages/ui/src/store/diagramStore.ts | 171 +++++++++ packages/ui/src/studioSettings.ts | 5 +- packages/ui/tests/nodeWizard.test.ts | 169 +++++++++ 13 files changed, 906 insertions(+), 32 deletions(-) create mode 100644 packages/ui/src/components/NodeWizard.tsx create mode 100644 packages/ui/tests/nodeWizard.test.ts diff --git a/apps/studio/src/renderer/src/App.tsx b/apps/studio/src/renderer/src/App.tsx index d6089ff2..4b3deacc 100644 --- a/apps/studio/src/renderer/src/App.tsx +++ b/apps/studio/src/renderer/src/App.tsx @@ -12,6 +12,7 @@ import { PresentationBar, PresenterDock } from './components/PresentationBar' import { RightPanel, LeftPanel } from '@radical/ui/components/RightPanel' import { MetamodelEditor } from './components/MetamodelEditor' import { NotificationHost } from '@radical/ui/components/NotificationHost' +import { NodeWizard } from '@radical/ui/components/NodeWizard' import { SelectionActionBar } from './components/SelectionActionBar' import { EdgeActionBar } from './components/EdgeActionBar' import { QuickSearch } from './components/QuickSearch' @@ -114,6 +115,7 @@ function AppInner(): React.ReactElement { {!isPresenting && isCanvasView && } {!isPresenting && isCanvasView && } {isCanvasView && } + {isDesigner && } {isMetamodel && !isPresenting && } diff --git a/apps/studio/src/renderer/src/components/Toolbar.tsx b/apps/studio/src/renderer/src/components/Toolbar.tsx index 5c645a94..29ca5fdf 100644 --- a/apps/studio/src/renderer/src/components/Toolbar.tsx +++ b/apps/studio/src/renderer/src/components/Toolbar.tsx @@ -291,6 +291,14 @@ function AppMenu({ return next }) }, []) + const setNodeWizardOnCreate = useCallback((on: boolean) => { + setStudioSettings((prev) => { + const next = { ...prev, nodeWizardOnCreate: on } + saveStudioSettings(next) + window.dispatchEvent(new Event(STUDIO_SETTINGS_CHANGED_EVENT)) + return next + }) + }, []) // Close on outside-click (covers pointerdown + mousedown, capture phase // so descendants that call stopPropagation can't keep the menu open). @@ -421,6 +429,17 @@ function AppMenu({ +
diff --git a/apps/studio/src/renderer/src/studio.css b/apps/studio/src/renderer/src/studio.css index e97d1ecf..2d8abf6e 100644 --- a/apps/studio/src/renderer/src/studio.css +++ b/apps/studio/src/renderer/src/studio.css @@ -198,6 +198,8 @@ gap: 10px; padding: 6px 10px; } +label.app-menu-row { cursor: pointer; } +.app-menu-row input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; } .app-menu-row-label { font-size: 12px; color: var(--text-secondary); diff --git a/packages/ui/package.json b/packages/ui/package.json index ade51ee9..24e14e41 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -15,6 +15,7 @@ "./components/TableView": "./src/components/TableView.tsx", "./components/RightPanel": "./src/components/RightPanel.tsx", "./components/NotificationHost": "./src/components/NotificationHost.tsx", + "./components/NodeWizard": "./src/components/NodeWizard.tsx", "./components/SmartLayoutButton": "./src/components/SmartLayoutButton.tsx", "./components/ErrorBoundary": "./src/components/ErrorBoundary.tsx", "./components/wireframeGeneration": "./src/components/wireframeGeneration.ts", diff --git a/packages/ui/src/components/Canvas.tsx b/packages/ui/src/components/Canvas.tsx index 0d8247b7..977ba0ce 100644 --- a/packages/ui/src/components/Canvas.tsx +++ b/packages/ui/src/components/Canvas.tsx @@ -111,7 +111,7 @@ function StructuralCanvas(): React.ReactElement { const onEdgesChange = useDiagramStore((s) => s.onEdgesChange) const selectNode = useDiagramStore((s) => s.selectNode) const selectEdge = useDiagramStore((s) => s.selectEdge) - const addNode = useDiagramStore((s) => s.addNode) + const requestCreateNode = useDiagramStore((s) => s.requestCreateNode) const liveGrab = useDiagramStore((s) => s.liveGrab) const liveDrag = useDiagramStore((s) => s.liveDrag) const liveRelease = useDiagramStore((s) => s.liveRelease) @@ -521,7 +521,7 @@ function StructuralCanvas(): React.ReactElement { x: event.clientX, y: event.clientY, }) - addNode({ + requestCreateNode({ type: 'system' as C4ElementType, label: 'New System', description: '', @@ -532,7 +532,7 @@ function StructuralCanvas(): React.ReactElement { ...NODE_SIZES.system, }) }, - [addNode] + [requestCreateNode] ) const onNodeClick: NodeMouseHandler = useCallback( @@ -739,7 +739,8 @@ function StructuralCanvas(): React.ReactElement { } const size = NODE_SIZES[typeStr] - addNode({ + // Opens the type's wizard instead, when it has one. + requestCreateNode({ type: typeStr, label: typeStr[0].toUpperCase() + typeStr.slice(1), description: '', @@ -752,7 +753,7 @@ function StructuralCanvas(): React.ReactElement { ...size, }) }, - [addNode] + [requestCreateNode] ) const onDragOver = useCallback((event: React.DragEvent) => { diff --git a/packages/ui/src/components/NodeWizard.tsx b/packages/ui/src/components/NodeWizard.tsx new file mode 100644 index 00000000..5c3792eb --- /dev/null +++ b/packages/ui/src/components/NodeWizard.tsx @@ -0,0 +1,337 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { + composeEarsSentence, + resolveEarsSubject, + isWizardStepApplicable, + sameWizardLink, + wizardFor, + wizardLinksOf, + wizardMissingRequired, + wizardPrefill, + wizardRelationCandidates, + wizardStepFields, + type NodeTypeDef, + type NodeWizardDef, + type PropertyDef, + type WizardLink, + type WizardRelationsStep, + type WizardStep, +} from '@radical/common/metamodel' +import { NODE_COLORS, NODE_FG, TYPE_ICON_PATHS, TYPE_LABELS, type C4Node } from '@radical/common/c4' +import { useDiagramStore, type NodeWizardSession } from '../store/diagramStore' +import { EarsQuickEntry } from './EarsQuickEntry' + +/** + * Step-by-step form for a node type with a `wizard` in the metamodel. Opens + * when the store has a wizard session — `requestCreateNode` (canvas, table, + * wiki) for a new node, `openNodeWizard` for an existing one — so it is + * mounted once, at the app root, and covers every place a node is created. + */ +export function NodeWizard(): React.ReactElement | null { + const session = useDiagramStore((s) => s.nodeWizard) + const metamodel = useDiagramStore((s) => s.metamodel) + const c4Nodes = useDiagramStore((s) => s.c4Nodes) + if (!session) return null + const type = session.mode === 'create' ? session.draft.type : c4Nodes[session.nodeId]?.type + const wizard = type ? wizardFor(metamodel, type) : undefined + if (!type || !wizard) return null + return +} + +function initialValues(session: NodeWizardSession, wizard: NodeWizardDef, typeDef: NodeTypeDef | undefined): Record { + const state = useDiagramStore.getState() + const keys = new Set() + for (const step of wizard.steps) { + if (step.kind === 'fields') step.fields.forEach((k) => keys.add(k)) + } + if (session.mode === 'create') { + const values: Record = { label: session.draft.label } + for (const p of typeDef?.properties ?? []) { + if (p.default !== undefined) values[p.key] = p.default + } + return { ...values, ...wizardPrefill(wizard) } + } + const node = state.c4Nodes[session.nodeId] as (C4Node & Record) | undefined + const values: Record = {} + for (const k of keys) values[k] = node?.[k] + // EARS fields are filled by the custom step, not listed in a fields step. + for (const p of typeDef?.properties ?? []) { + if (!(p.key in values)) values[p.key] = node?.[p.key] + } + return values +} + +function WizardDialog({ session, wizard, typeDef, type }: { + session: NodeWizardSession + wizard: NodeWizardDef + typeDef: NodeTypeDef | undefined + type: string +}): React.ReactElement { + const metamodel = useDiagramStore((s) => s.metamodel) + const c4Nodes = useDiagramStore((s) => s.c4Nodes) + const c4Relations = useDiagramStore((s) => s.c4Relations) + const finish = useDiagramStore((s) => s.finishNodeWizard) + const cancel = useDiagramStore((s) => s.cancelNodeWizard) + + const selfId = session.mode === 'edit' ? session.nodeId : undefined + const steps = useMemo( + () => wizard.steps.filter((s) => isWizardStepApplicable(metamodel, s, type)), + [wizard, metamodel, type], + ) + const [stepIndex, setStepIndex] = useState(0) + const [values, setValues] = useState(() => initialValues(session, wizard, typeDef)) + const [links, setLinks] = useState(() => + session.mode === 'create' + ? session.initialLinks + : wizardLinksOf(wizard, session.nodeId, useDiagramStore.getState().c4Relations) + .map(({ relationType, direction, otherId }) => ({ relationType, direction, otherId })), + ) + + const step = steps[Math.min(stepIndex, steps.length - 1)] + const isLast = stepIndex >= steps.length - 1 + const missing = wizardMissingRequired(wizard, typeDef, values) + + const typeLabel = typeDef?.label ?? TYPE_LABELS[type as C4Node['type']] ?? type + const badgeBg = typeDef?.color ?? NODE_COLORS[type as C4Node['type']] ?? '#334155' + const badgeFg = typeDef?.fg ?? NODE_FG[type as C4Node['type']] ?? '#fff' + const badgeIcon = typeDef?.iconPath ?? TYPE_ICON_PATHS[type as C4Node['type']] + const title = session.mode === 'create' + ? `New ${typeLabel}` + : String(c4Nodes[session.nodeId]?.label ?? typeLabel) + + const done = (): void => { + if (missing.length) return + finish(values, links) + } + const next = (): void => (isLast ? done() : setStepIndex((i) => i + 1)) + + // Esc cancels, Ctrl/Cmd+Enter finishes from any step. + const doneRef = useRef(done) + doneRef.current = done + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { e.preventDefault(); cancel() } + else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); doneRef.current() } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [cancel]) + + // Focus the step's first input. + const bodyRef = useRef(null) + useEffect(() => { + const el = bodyRef.current?.querySelector('input, textarea, select') + el?.focus() + if (el instanceof HTMLInputElement && el.type === 'text') el.select() + }, [stepIndex]) + + const setValue = (key: string, value: unknown): void => setValues((v) => ({ ...v, [key]: value })) + + return ( +
+
e.stopPropagation()} + > +
+ + {badgeIcon && ( + + + + )} + {typeLabel.toUpperCase()} + +

{title}

+
+ +
    + {steps.map((s, i) => ( +
  1. + +
  2. + ))} +
+ +
+ {step.help &&

{step.help}

} + {step.kind === 'fields' && ( + + )} + {step.kind === 'relations' && ( + metamodel?.nodeTypes[t]?.label ?? TYPE_LABELS[t as C4Node['type']] ?? t} + /> + )} + {step.kind === 'custom' && ( + + )} +
+ +
+ + + {missing.length ? `Required: ${missing.join(', ')}` : `${isMac() ? '⌘' : 'Ctrl'}+Enter to ${session.mode === 'create' ? 'create' : 'save'}`} + + {stepIndex > 0 && ( + + )} + {!isLast && ( + + )} + +
+
+
+ ) +} + +function isMac(): boolean { + return typeof navigator !== 'undefined' && !!navigator.platform?.includes('Mac') +} + +function FieldsStep({ fields, values, setValue, roomy, onEnter }: { + fields: PropertyDef[] + values: Record + setValue: (key: string, value: unknown) => void + roomy: boolean + onEnter: () => void +}): React.ReactElement { + return ( + <> + {fields.map((p) => { + const value = values[p.key] + const id = `node-wizard-field-${p.key}` + if (p.type === 'boolean') { + return ( + + ) + } + return ( +
+ + {p.type === 'enum' ? ( + + ) : p.type === 'textarea' ? ( +