Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 133 additions & 0 deletions apps/e2e/tests/studio/metamodel-diagram.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { test, expect, fixture } from '../../support/fixtures'

// The metamodel editor's Diagram tab, on the bookstore fixture switched to
// the Governance metamodel (16 node types, 14 relation types).
test.beforeEach(async ({ studio }) => {
const doc = JSON.parse(fixture('bookstore'))
doc.metamodel = { id: 'c4-ddd-governance-builtin', name: 'C4 + DDD + Governance', nodeTypes: {}, relationTypes: {} }
await studio.seedDocument(JSON.stringify(doc))
})

test('the Diagram tab draws every node type and its relations', async ({ page, studio }) => {
await studio.open('v-context', 'metamodel')
const editor = page.getByRole('dialog', { name: 'Metamodel editor' })
await editor.getByRole('tab', { name: 'Diagram' }).click()

const diagram = editor.locator('.mmd-canvas')
const legend = editor.locator('.mmd-legend')
await expect(diagram.locator('.react-flow__node-mmType')).toHaveCount(16)
await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 })
await expect(diagram.locator('.react-flow__edge').first()).toBeVisible()
// Smart Layout, then the physics settles it: no two type boxes overlap.
const boxes = await diagram.locator('.react-flow__node-mmType').evaluateAll((els) =>
els.map((el) => {
const r = el.getBoundingClientRect()
return { id: (el as HTMLElement).dataset.id, x: r.x, y: r.y, w: r.width, h: r.height }
}),
)
const overlaps = boxes.flatMap((a, i) =>
boxes.slice(i + 1).filter((b) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h).map((b) => `${a.id}/${b.id}`),
)
expect(overlaps).toEqual([])

// Types sit in a frame per palette category.
await expect(diagram.locator('.mmd-category-label')).toHaveText(['C4', 'Domain', 'Governance', 'Requirements', 'UX', 'Other'])
// Relation names are on the lines without selecting anything.
await expect(diagram.locator('.mmd-edge-label', { hasText: 'Verifies' })).toBeVisible()
// A relation from a type to itself is a chip, not a loop.
await expect(diagram.getByTestId('rf__node-adr').getByText('↻ Supersedes')).toBeVisible()
if (process.env.E2E_SHOTS) await page.screenshot({ path: `${process.env.E2E_SHOTS}/diagram.png` })

// By default every containment edge and every type's properties are shown.
await expect(legend.getByRole('checkbox', { name: 'Show Contains' })).toBeChecked()
await expect(legend.getByRole('checkbox', { name: 'Show properties on boxes' })).toBeChecked()
await expect(diagram.locator('.react-flow__edge[data-testid^="rf__edge-contains:"]')).toHaveCount(35)
await expect(diagram.getByTestId('rf__node-requirement').getByText('ears_type')).toBeVisible()

// With Contains off, Blueprint (no relation type reaches it) still shows
// its two allowed parents (Domain, Group), so it does not float.
await legend.getByRole('checkbox', { name: 'Show Contains' }).uncheck()
await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 })
await expect(diagram.locator('.react-flow__edge[data-testid^="rf__edge-contains:"]')).toHaveCount(2)

// "none" hides every edge; one relation type back on shows only its edges.
await legend.getByRole('button', { name: 'none' }).click()
await expect(diagram.locator('.react-flow__edge')).toHaveCount(0)
await legend.getByRole('checkbox', { name: 'Show Constrains' }).check()
await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 })
// ADR, Fitness Function and Requirement each constrain all eight C4
// element types: one edge per pair, each ending on its type — and only
// those 11 types are left on the diagram.
await expect(diagram.locator('.react-flow__edge')).toHaveCount(24)
await expect(diagram.locator('.react-flow__node-mmType')).toHaveCount(11)
if (process.env.E2E_SHOTS) await page.screenshot({ path: `${process.env.E2E_SHOTS}/constrains.png` })

await legend.getByRole('checkbox', { name: 'Show properties on boxes' }).uncheck()
await expect(diagram.getByTestId('rf__node-requirement').getByText('ears_type')).toHaveCount(0, { timeout: 20_000 })
})

test('selecting a type shows its rules; Edit opens it in the list', async ({ page, studio }) => {
await studio.open('v-context', 'metamodel')
const editor = page.getByRole('dialog', { name: 'Metamodel editor' })
await editor.getByRole('tab', { name: 'Diagram' }).click()

const diagram = editor.locator('.mmd-canvas')
await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 30_000 })
const zoom = async (): Promise<number> =>
diagram.locator('.react-flow__viewport').evaluate((el) => Number(/scale\(([\d.]+)\)/.exec((el as HTMLElement).style.transform)?.[1]))
// The fit after the physics animates; read the overview once it has stopped.
let overview = await zoom()
await expect
.poll(async () => {
const prev = overview
overview = await zoom()
return overview === prev
}, { intervals: [150] })
.toBe(true)

// Selecting a type frames it with what it is connected to.
await diagram.getByTestId('rf__node-blueprint').click()
await expect.poll(zoom).toBeGreaterThan(overview * 1.5)
await diagram.locator('.react-flow__pane').click({ position: { x: 5, y: 5 } })
await expect.poll(zoom).toBeCloseTo(overview, 2)

// Selecting a relation shows only its sources and targets: ADR, Fitness
// Function and Requirement constrain the eight C4 element types.
await editor.locator('.mmd-legend').getByRole('button', { name: 'Constrains' }).click()
await expect(diagram.locator('.react-flow__node-mmType')).toHaveCount(11)
await expect(diagram.locator('.react-flow__edge')).toHaveCount(24)
await expect(diagram.locator('.mmd-category-label')).toHaveText(['C4', 'Domain', 'Governance', 'Requirements'])
if (process.env.E2E_SHOTS) await page.screenshot({ path: `${process.env.E2E_SHOTS}/relation.png` })
await page.keyboard.press('Escape')
await expect(diagram.locator('.react-flow__node-mmType')).toHaveCount(16)

await editor.locator('.mmd-canvas').getByTestId('rf__node-requirement').click()
const inspector = editor.locator('.mm-editor-side')
await expect(inspector.getByText('Relations out')).toBeVisible()
await expect(inspector.getByRole('button', { name: 'Derives from' }).first()).toBeVisible()
if (process.env.E2E_SHOTS) await page.screenshot({ path: `${process.env.E2E_SHOTS}/selected.png` })

// Esc clears the selection before it would close the editor.
await page.keyboard.press('Escape')
await expect(inspector.getByText('Validation')).toBeVisible()
await expect(editor).toBeVisible()

await editor.locator('.mmd-canvas').getByTestId('rf__node-requirement').click()
await inspector.getByRole('button', { name: 'Edit type' }).click()
await expect(editor.getByRole('tab', { name: 'List' })).toHaveAttribute('aria-selected', 'true')
await expect(editor.locator('.mm-card.focused input.mm-input').first()).toHaveValue('Requirement')
})

// CI runners are slow: there the last frame of the physics used to leave the
// type boxes unmeasured, hidden and without edges. A throttled CPU reproduces it.
test('the diagram stays drawn on a slow machine', async ({ page, studio }) => {
await studio.open('v-context', 'metamodel')
const cdp = await page.context().newCDPSession(page)
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 6 })
const editor = page.getByRole('dialog', { name: 'Metamodel editor' })
await editor.getByRole('tab', { name: 'Diagram' }).click()
const diagram = editor.locator('.mmd-canvas')
await expect(diagram.locator('.mmd-status')).toHaveCount(0, { timeout: 60_000 })
await expect(diagram.locator('.react-flow__edge')).toHaveCount(96)
await expect(diagram.getByTestId('rf__node-requirement')).toBeVisible()
})
1 change: 1 addition & 0 deletions apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@radical/common": "*",
"@radical/host-bridge": "*",
"@radical/hub-catalogue": "*",
"@radical/layout": "*",
"@radical/node-files": "*",
"@radical/ui": "*",
"@types/react": "^18.2.0",
Expand Down
124 changes: 116 additions & 8 deletions apps/studio/src/renderer/src/components/MetamodelEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useEffect } from 'react'
import React, { useState, useMemo, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useDiagramStore } from '@radical/ui/store/diagramStore'
import { builtInC4Metamodel, validateModel } from '@radical/common/metamodel'
Expand All @@ -10,6 +10,14 @@ import type {
PropertyDef,
PropertyType,
} from '@radical/common/metamodel'
import {
MetamodelDiagram,
MetamodelLegend,
DEFAULT_DIAGRAM_FILTER,
type DiagramSelection,
} from './metamodel/MetamodelDiagram'
import { MetamodelInspector } from './metamodel/MetamodelInspector'
import { CONTAINS_EDGE, type MetamodelGraphFilter } from './metamodel/metamodelGraph'

// ── small icon helpers ─────────────────────────────────────────────────────

Expand Down Expand Up @@ -107,23 +115,39 @@ function PropertyEditor({
)
}

/** Opens and scrolls to a card when `focusToken` changes — how the diagram's
* "Edit" lands on the right card in the list. */
function useCardFocus(focusToken: number | undefined, setOpen: (v: boolean) => void) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (focusToken === undefined) return
setOpen(true)
requestAnimationFrame(() => ref.current?.scrollIntoView({ block: 'start', behavior: 'smooth' }))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusToken])
return ref
}

// ── Node-type card ─────────────────────────────────────────────────────────

function NodeTypeCard({
def,
allTypes,
focusToken,
onChange,
onDelete,
}: {
def: NodeTypeDef
allTypes: NodeTypeDef[]
focusToken?: number
onChange: (patch: Partial<NodeTypeDef>) => void
onDelete: () => void
}): React.ReactElement {
const [open, setOpen] = useState(false)
const ref = useCardFocus(focusToken, setOpen)

return (
<div className="mm-card">
<div ref={ref} className={`mm-card${focusToken !== undefined ? ' focused' : ''}`}>
<div className="mm-card-header" onClick={() => setOpen((v) => !v)}>
<svg className={`mm-chevron${open ? ' open' : ''}`} viewBox="0 0 16 16" width="10" height="10">
<path d="M5 3 L11 8 L5 13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
Expand Down Expand Up @@ -293,15 +317,18 @@ function NodeTypeCard({
function RelationTypeCard({
def,
allTypes,
focusToken,
onChange,
onDelete,
}: {
def: RelationTypeDef
allTypes: NodeTypeDef[]
focusToken?: number
onChange: (patch: Partial<RelationTypeDef>) => void
onDelete: () => void
}): React.ReactElement {
const [open, setOpen] = useState(false)
const ref = useCardFocus(focusToken, setOpen)

const updatePair = (idx: number, patch: Partial<RelationPair>) =>
onChange({ allowedPairs: def.allowedPairs.map((p, i) => (i === idx ? { ...p, ...patch } : p)) })
Expand All @@ -313,7 +340,7 @@ function RelationTypeCard({
}

return (
<div className="mm-card">
<div ref={ref} className={`mm-card${focusToken !== undefined ? ' focused' : ''}`}>
<div className="mm-card-header" onClick={() => setOpen((v) => !v)}>
<svg className={`mm-chevron${open ? ' open' : ''}`} viewBox="0 0 16 16" width="10" height="10">
<path d="M5 3 L11 8 L5 13" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
Expand Down Expand Up @@ -464,6 +491,17 @@ function IssuesPanel(): React.ReactElement {

// ── Main perspective ───────────────────────────────────────────────────────

type EditorTab = 'list' | 'diagram'
const LS_TAB = 'radical-metamodel-tab'

function readTab(): EditorTab {
try {
return localStorage.getItem(LS_TAB) === 'diagram' ? 'diagram' : 'list'
} catch {
return 'list'
}
}

export function MetamodelEditor(): React.ReactElement {
const metamodel = useDiagramStore((s) => s.metamodel)
const upsertNodeType = useDiagramStore((s) => s.upsertNodeType)
Expand Down Expand Up @@ -509,9 +547,32 @@ export function MetamodelEditor(): React.ReactElement {
const setAppMode = useDiagramStore((s) => s.setAppMode)
const close = (): void => setAppMode('designer')

// Esc closes the editor.
const [tab, setTabState] = useState<EditorTab>(readTab)
const setTab = (t: EditorTab): void => {
setTabState(t)
try { localStorage.setItem(LS_TAB, t) } catch { /* storage unavailable */ }
}
const [selection, setSelection] = useState<DiagramSelection>(null)
const [diagramFilter, setDiagramFilter] = useState<MetamodelGraphFilter>(DEFAULT_DIAGRAM_FILTER)
// Card to open + scroll to in the list; the counter re-triggers the same card.
const [focusCard, setFocusCard] = useState<{ kind: 'type' | 'relation'; id: string; token: number } | null>(null)
const editInList = (s: NonNullable<DiagramSelection>): void => {
if (s.kind === 'relation' && s.id === CONTAINS_EDGE) return
setFocusCard((cur) => ({ kind: s.kind, id: s.id, token: (cur?.token ?? 0) + 1 }))
setTab('list')
}
const focusTokenFor = (kind: 'type' | 'relation', id: string): number | undefined =>
focusCard && focusCard.kind === kind && focusCard.id === id ? focusCard.token : undefined

// Esc clears the diagram selection first, then closes the editor.
const selectionRef = useRef(selection)
selectionRef.current = selection
useEffect(() => {
const onKey = (e: KeyboardEvent): void => { if (e.key === 'Escape') close() }
const onKey = (e: KeyboardEvent): void => {
if (e.key !== 'Escape') return
if (selectionRef.current) setSelection(null)
else close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand All @@ -531,7 +592,7 @@ export function MetamodelEditor(): React.ReactElement {
}}
>
<div
className="milestone-modal mm-editor-modal"
className={`milestone-modal mm-editor-modal${tab === 'diagram' ? ' is-diagram' : ''}`}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
role="dialog"
Expand All @@ -547,7 +608,7 @@ export function MetamodelEditor(): React.ReactElement {
✕
</button>
<div className="mm-editor">
<div className="mm-editor-main">
<div className={`mm-editor-main${tab === 'diagram' ? ' is-diagram' : ''}`}>
<div className="mm-editor-header">
<div>
<input
Expand All @@ -560,6 +621,19 @@ export function MetamodelEditor(): React.ReactElement {
</div>
</div>
<div style={{ flex: 1 }} />
<div className="mm-tabs" role="tablist" aria-label="Metamodel view">
{(['list', 'diagram'] as const).map((t) => (
<button
key={t}
role="tab"
aria-selected={tab === t}
className={`mm-tab${tab === t ? ' active' : ''}`}
onClick={() => setTab(t)}
>
{t === 'list' ? 'List' : 'Diagram'}
</button>
))}
</div>
<button
className="mm-btn"
onClick={() => {
Expand All @@ -572,6 +646,16 @@ export function MetamodelEditor(): React.ReactElement {
</button>
</div>

{tab === 'diagram' ? (
<MetamodelDiagram
metamodel={metamodel}
filter={diagramFilter}
selection={selection}
onSelect={setSelection}
onEdit={(id) => editInList({ kind: 'type', id })}
/>
) : (
<>
<div className="mm-section">
<div className="mm-section-header">
<h3>Node types</h3>
Expand All @@ -585,6 +669,7 @@ export function MetamodelEditor(): React.ReactElement {
key={t.id}
def={t}
allTypes={nodeTypes}
focusToken={focusTokenFor('type', t.id)}
onChange={(patch) => upsertNodeType({ ...t, ...patch })}
onDelete={() => removeNodeType(t.id)}
/>
Expand All @@ -605,6 +690,7 @@ export function MetamodelEditor(): React.ReactElement {
key={t.id}
def={t}
allTypes={nodeTypes}
focusToken={focusTokenFor('relation', t.id)}
onChange={(patch) => upsertRelationType({ ...t, ...patch })}
onDelete={() => removeRelationType(t.id)}
/>
Expand All @@ -617,10 +703,32 @@ export function MetamodelEditor(): React.ReactElement {
stored in the metamodel and surface in the Issues panel; full canvas styling for
custom types lands in a follow-up.
</div>
</>
)}
</div>

<aside className="mm-editor-side">
<IssuesPanel />
{tab === 'diagram' && selection ? (
<MetamodelInspector
metamodel={metamodel}
selection={selection}
onSelect={setSelection}
onEdit={editInList}
/>
) : tab === 'diagram' ? (
<>
<MetamodelLegend
metamodel={metamodel}
filter={diagramFilter}
selection={selection}
onFilter={setDiagramFilter}
onSelect={setSelection}
/>
<IssuesPanel />
</>
) : (
<IssuesPanel />
)}
</aside>
</div>
</div>
Expand Down
Loading
Loading