diff --git a/packages/ui/src/components/ProcessMap/ProcessMapBase.tsx b/packages/ui/src/components/ProcessMap/ProcessMapBase.tsx new file mode 100644 index 000000000..1835bbab6 --- /dev/null +++ b/packages/ui/src/components/ProcessMap/ProcessMapBase.tsx @@ -0,0 +1,669 @@ +/** + * ProcessMapBase — the interactive river-styled SIPOC Process Map rendered + * in the FRAME workspace. + * + * See ADR-070 and the design spec `docs/superpowers/specs/2026-04-18-frame- + * process-map-design.md`. Composes three regions on one canvas: + * + * - a left→right spine of process steps (SIPOC temporal axis), + * - tributaries (little xs / factors) feeding each step from both banks, + * - an ocean at the right with the CTS (customer-felt outcome) + specs. + * + * V1 interactions are deliberately structured (buttons, dropdowns, inline + * inputs) — not a freeform drag-and-drop canvas. That comes in V2+. + * + * Props-based. No store coupling. Parent (FrameView) owns the map state and + * passes `onChange` callbacks + detected gaps. + */ + +import React from 'react'; +import type { Gap, ProcessMap, ProcessMapTributary, ProcessMapHunch } from '@variscout/core/frame'; + +// ──────────────────────────────────────────────────────────────────────────── +// Types +// ──────────────────────────────────────────────────────────────────────────── + +export interface ProcessMapBaseProps { + /** The current map. Parent owns state; pass a new object on every edit. */ + map: ProcessMap; + /** Column names available from the uploaded dataset. */ + availableColumns: string[]; + /** Called with the next map whenever the user edits. */ + onChange: (next: ProcessMap) => void; + /** Gaps detected by `detectGaps()` in the parent — rendered inline. */ + gaps?: Gap[]; + /** Disable all edits (read-only mode, e.g. Analysis sidebar thumbnail). */ + disabled?: boolean; + /** Optional target value for the CTS (current UI: delegated to parent form). */ + target?: number; + /** Optional USL. */ + usl?: number; + /** Optional LSL. */ + lsl?: number; + /** Called when target/usl/lsl change. */ + onSpecsChange?: (next: { target?: number; usl?: number; lsl?: number }) => void; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Helpers +// ──────────────────────────────────────────────────────────────────────────── + +const uid = (prefix: string): string => + `${prefix}-${Math.random().toString(36).slice(2, 8)}-${Date.now().toString(36)}`; + +const bumpUpdated = (map: ProcessMap): ProcessMap => ({ + ...map, + updatedAt: new Date().toISOString(), +}); + +/** Gaps scoped to a given step, for inline rendering next to the step card. */ +const gapsForStep = (gaps: Gap[] | undefined, stepId: string): Gap[] => + (gaps ?? []).filter(g => g.stepId === stepId); + +/** Gaps that apply to the whole map (no stepId) — rendered in the GapStrip. */ +const globalGaps = (gaps: Gap[] | undefined): Gap[] => (gaps ?? []).filter(g => !g.stepId); + +// ──────────────────────────────────────────────────────────────────────────── +// Sub-components (co-located; not exported from the package) +// ──────────────────────────────────────────────────────────────────────────── + +interface StepCardProps { + step: ProcessMap['nodes'][number]; + tributaries: ProcessMapTributary[]; + subgroupAxes: string[]; + availableColumns: string[]; + gaps: Gap[]; + disabled?: boolean; + onRename: (name: string) => void; + onCtqChange: (column: string | undefined) => void; + onRemove: () => void; + onAddTributary: (column: string) => void; + onRemoveTributary: (tributaryId: string) => void; + onToggleSubgroupAxis: (tributaryId: string) => void; +} + +const StepCard: React.FC = ({ + step, + tributaries, + subgroupAxes, + availableColumns, + gaps, + disabled, + onRename, + onCtqChange, + onRemove, + onAddTributary, + onRemoveTributary, + onToggleSubgroupAxis, +}) => { + const [newTribCol, setNewTribCol] = React.useState(''); + const availableForTrib = availableColumns.filter( + c => !tributaries.some(t => t.column === c) && c !== step.ctqColumn + ); + + return ( +
+
+ onRename(e.target.value)} + disabled={disabled} + placeholder="Step name" + aria-label={`Step ${step.order + 1} name`} + className="flex-1 text-sm font-medium bg-transparent border-none focus:outline-none focus:ring-1 focus:ring-edge-strong rounded px-1 disabled:text-content-secondary" + data-testid={`process-map-step-name-${step.id}`} + /> + {!disabled && ( + + )} +
+ + + + {tributaries.length > 0 && ( + + )} + + {!disabled && availableForTrib.length > 0 && ( +
+ + +
+ )} + + {gaps.length > 0 && ( + + )} +
+ ); +}; + +interface OceanCardProps { + ctsColumn?: string; + availableColumns: string[]; + target?: number; + usl?: number; + lsl?: number; + disabled?: boolean; + onCtsChange: (column: string | undefined) => void; + onSpecsChange?: (next: { target?: number; usl?: number; lsl?: number }) => void; +} + +const OceanCard: React.FC = ({ + ctsColumn, + availableColumns, + target, + usl, + lsl, + disabled, + onCtsChange, + onSpecsChange, +}) => { + const toNum = (s: string): number | undefined => { + if (s === '') return undefined; + const n = Number(s); + return Number.isFinite(n) ? n : undefined; + }; + + return ( +
+
+ Customer outcome (CTS) +
+ + {onSpecsChange && ( +
+ + + +
+ )} +
+ ); +}; + +interface HunchListProps { + hunches: ProcessMapHunch[]; + steps: ProcessMap['nodes']; + tributaries: ProcessMapTributary[]; + disabled?: boolean; + onAdd: (text: string, pin: { stepId?: string; tributaryId?: string }) => void; + onRemove: (id: string) => void; +} + +const HunchList: React.FC = ({ + hunches, + steps, + tributaries, + disabled, + onAdd, + onRemove, +}) => { + const [text, setText] = React.useState(''); + const [pinKey, setPinKey] = React.useState(''); + + const pinOptions = [ + ...steps.map(s => ({ key: `step:${s.id}`, label: `step · ${s.name || '(unnamed)'}` })), + ...tributaries.map(t => ({ key: `trib:${t.id}`, label: `x · ${t.label || t.column}` })), + ]; + + const submit = () => { + const t = text.trim(); + if (!t) return; + const [kind, id] = pinKey.split(':'); + onAdd(t, kind === 'step' ? { stepId: id } : kind === 'trib' ? { tributaryId: id } : {}); + setText(''); + setPinKey(''); + }; + + const labelForHunch = (h: ProcessMapHunch): string | undefined => { + if (h.stepId) return steps.find(s => s.id === h.stepId)?.name; + if (h.tributaryId) { + const t = tributaries.find(x => x.id === h.tributaryId); + return t?.label || t?.column; + } + return undefined; + }; + + return ( +
+

Hunches

+ {hunches.length > 0 && ( +
    + {hunches.map(h => { + const pin = labelForHunch(h); + return ( +
  • + ⚑ {h.text} + {pin && pinned · {pin}} + {!disabled && ( + + )} +
  • + ); + })} +
+ )} + {!disabled && ( +
+ setText(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') submit(); + }} + placeholder="What do you think is causing the variation?" + aria-label="Hunch text" + className="flex-1 text-xs bg-surface-primary border border-edge rounded px-2 py-1" + data-testid="process-map-hunch-text" + /> + + +
+ )} +
+ ); +}; + +interface GapStripProps { + gaps: Gap[]; +} + +const GapStrip: React.FC = ({ gaps }) => { + if (gaps.length === 0) return null; + const required = gaps.filter(g => g.severity === 'required'); + const recommended = gaps.filter(g => g.severity === 'recommended'); + return ( +
+

+ Missing from your map ({gaps.length}) +

+ {required.length > 0 && ( +
    + {required.map((g, i) => ( +
  • + ● required · {g.message} +
  • + ))} +
+ )} + {recommended.length > 0 && ( +
    + {recommended.map((g, i) => ( +
  • + ○ recommended · {g.message} +
  • + ))} +
+ )} +
+ ); +}; + +// ──────────────────────────────────────────────────────────────────────────── +// Main component +// ──────────────────────────────────────────────────────────────────────────── + +export const ProcessMapBase: React.FC = ({ + map, + availableColumns, + onChange, + gaps, + disabled, + target, + usl, + lsl, + onSpecsChange, +}) => { + const sortedSteps = React.useMemo( + () => [...map.nodes].sort((a, b) => a.order - b.order), + [map.nodes] + ); + + const update = (next: ProcessMap) => onChange(bumpUpdated(next)); + + const addStep = () => { + const newOrder = sortedSteps.length; + update({ + ...map, + nodes: [...map.nodes, { id: uid('step'), name: '', order: newOrder }], + }); + }; + + const renameStep = (stepId: string, name: string) => { + update({ + ...map, + nodes: map.nodes.map(n => (n.id === stepId ? { ...n, name } : n)), + }); + }; + + const setStepCtq = (stepId: string, ctqColumn: string | undefined) => { + update({ + ...map, + nodes: map.nodes.map(n => (n.id === stepId ? { ...n, ctqColumn } : n)), + }); + }; + + const removeStep = (stepId: string) => { + const remaining = map.nodes.filter(n => n.id !== stepId); + // Re-pack `order` so it stays 0..N-1 monotonic. + const reordered = [...remaining] + .sort((a, b) => a.order - b.order) + .map((n, i) => ({ ...n, order: i })); + update({ + ...map, + nodes: reordered, + tributaries: map.tributaries.filter(t => t.stepId !== stepId), + hunches: (map.hunches ?? []).filter(h => h.stepId !== stepId), + }); + }; + + const addTributary = (stepId: string, column: string) => { + const newT: ProcessMapTributary = { id: uid('trib'), stepId, column }; + update({ ...map, tributaries: [...map.tributaries, newT] }); + }; + + const removeTributary = (tributaryId: string) => { + update({ + ...map, + tributaries: map.tributaries.filter(t => t.id !== tributaryId), + subgroupAxes: (map.subgroupAxes ?? []).filter(id => id !== tributaryId), + hunches: (map.hunches ?? []).filter(h => h.tributaryId !== tributaryId), + }); + }; + + const toggleSubgroupAxis = (tributaryId: string) => { + const current = map.subgroupAxes ?? []; + const next = current.includes(tributaryId) + ? current.filter(id => id !== tributaryId) + : [...current, tributaryId]; + update({ ...map, subgroupAxes: next }); + }; + + const setCts = (ctsColumn: string | undefined) => { + update({ ...map, ctsColumn }); + }; + + const addHunch = (text: string, pin: { stepId?: string; tributaryId?: string }) => { + const hunch: ProcessMapHunch = { id: uid('hunch'), text, ...pin }; + update({ ...map, hunches: [...(map.hunches ?? []), hunch] }); + }; + + const removeHunch = (hunchId: string) => { + update({ + ...map, + hunches: (map.hunches ?? []).filter(h => h.id !== hunchId), + }); + }; + + return ( +
+
+

Process Map

+ {!disabled && ( + + )} +
+ +
+ {sortedSteps.map((step, i) => ( + + t.stepId === step.id)} + subgroupAxes={map.subgroupAxes ?? []} + availableColumns={availableColumns} + gaps={gapsForStep(gaps, step.id)} + disabled={disabled} + onRename={name => renameStep(step.id, name)} + onCtqChange={col => setStepCtq(step.id, col)} + onRemove={() => removeStep(step.id)} + onAddTributary={col => addTributary(step.id, col)} + onRemoveTributary={removeTributary} + onToggleSubgroupAxis={toggleSubgroupAxis} + /> + {i < sortedSteps.length - 1 && ( + + )} + + ))} + {sortedSteps.length > 0 && ( + + )} + +
+ + + + +
+ ); +}; diff --git a/packages/ui/src/components/ProcessMap/__tests__/ProcessMapBase.test.tsx b/packages/ui/src/components/ProcessMap/__tests__/ProcessMapBase.test.tsx new file mode 100644 index 000000000..ee7361965 --- /dev/null +++ b/packages/ui/src/components/ProcessMap/__tests__/ProcessMapBase.test.tsx @@ -0,0 +1,336 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ProcessMapBase } from '../ProcessMapBase'; +import type { ProcessMap, Gap } from '@variscout/core/frame'; + +const isoNow = () => new Date('2026-04-18T12:00:00.000Z').toISOString(); + +const emptyMap = (): ProcessMap => ({ + version: 1, + nodes: [], + tributaries: [], + createdAt: isoNow(), + updatedAt: isoNow(), +}); + +const mapWithOneStep = (): ProcessMap => ({ + version: 1, + nodes: [{ id: 'step-1', name: 'Fill', order: 0 }], + tributaries: [], + createdAt: isoNow(), + updatedAt: isoNow(), +}); + +const mapWithTwoSteps = (): ProcessMap => ({ + version: 1, + nodes: [ + { id: 'step-1', name: 'Mix', order: 0 }, + { id: 'step-2', name: 'Fill', order: 1, ctqColumn: 'Fill_Weight' }, + ], + tributaries: [{ id: 'trib-1', stepId: 'step-2', column: 'Machine' }], + subgroupAxes: [], + createdAt: isoNow(), + updatedAt: isoNow(), +}); + +const COLUMNS = ['Fill_Weight', 'Machine', 'Shift', 'Lot', 'Timestamp']; + +describe('ProcessMapBase — rendering', () => { + it('renders steps in `order`, regardless of array order', () => { + const map = emptyMap(); + map.nodes = [ + { id: 'step-B', name: 'B', order: 1 }, + { id: 'step-A', name: 'A', order: 0 }, + { id: 'step-C', name: 'C', order: 2 }, + ]; + render(); + const nameInputs = [ + screen.getByTestId('process-map-step-name-step-A'), + screen.getByTestId('process-map-step-name-step-B'), + screen.getByTestId('process-map-step-name-step-C'), + ] as HTMLInputElement[]; + // DOM left→right order must match the `order` field, not array order. + const spine = screen.getByTestId('process-map-spine'); + const allCards = Array.from(spine.querySelectorAll('[data-testid^="process-map-step-step-"]')); + expect(allCards.map(c => c.getAttribute('data-testid'))).toEqual([ + 'process-map-step-step-A', + 'process-map-step-step-B', + 'process-map-step-step-C', + ]); + expect(nameInputs.map(i => i.value)).toEqual(['A', 'B', 'C']); + }); + + it('renders the ocean card with the CTS dropdown', () => { + render(); + expect(screen.getByTestId('process-map-ocean')).toBeInTheDocument(); + expect(screen.getByTestId('process-map-ocean-cts')).toBeInTheDocument(); + }); + + it('renders a flow arrow between each pair of adjacent steps plus a final arrow to the ocean', () => { + render( + + ); + // 2 steps → 1 inter-step arrow + 1 ocean arrow + expect(screen.getByTestId('process-map-arrow-0')).toBeInTheDocument(); + expect(screen.getByTestId('process-map-ocean-arrow')).toBeInTheDocument(); + }); +}); + +describe('ProcessMapBase — step CRUD', () => { + it('invokes onChange with a new step appended when the "+ step" button is clicked', () => { + const onChange = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('process-map-add-step')); + expect(onChange).toHaveBeenCalledTimes(1); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.nodes).toHaveLength(1); + expect(next.nodes[0].order).toBe(0); + expect(next.nodes[0].name).toBe(''); + }); + + it('renames a step inline via the text input', () => { + const onChange = vi.fn(); + render( + + ); + const input = screen.getByTestId('process-map-step-name-step-1') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'Fill-renamed' } }); + expect(onChange).toHaveBeenCalledTimes(1); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.nodes[0].name).toBe('Fill-renamed'); + }); + + it('removes a step and re-packs the order field', () => { + const onChange = vi.fn(); + const map: ProcessMap = { + ...emptyMap(), + nodes: [ + { id: 'step-1', name: 'A', order: 0 }, + { id: 'step-2', name: 'B', order: 1 }, + { id: 'step-3', name: 'C', order: 2 }, + ], + }; + render(); + fireEvent.click(screen.getByLabelText('Remove step B')); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.nodes.map(n => n.id)).toEqual(['step-1', 'step-3']); + expect(next.nodes.map(n => n.order)).toEqual([0, 1]); + }); + + it('removing a step also cleans up its tributaries and hunches', () => { + const onChange = vi.fn(); + const map: ProcessMap = { + ...mapWithTwoSteps(), + hunches: [{ id: 'h-1', text: 'Nozzle wear', stepId: 'step-2' }], + }; + render(); + fireEvent.click(screen.getByLabelText('Remove step Fill')); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.nodes.some(n => n.id === 'step-2')).toBe(false); + expect(next.tributaries.some(t => t.stepId === 'step-2')).toBe(false); + expect(next.hunches?.some(h => h.stepId === 'step-2')).toBe(false); + }); + + it('sets the CTQ column on a step via the dropdown', () => { + const onChange = vi.fn(); + render( + + ); + const select = screen.getByTestId('process-map-step-ctq-step-1') as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'Fill_Weight' } }); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.nodes[0].ctqColumn).toBe('Fill_Weight'); + }); +}); + +describe('ProcessMapBase — tributary CRUD', () => { + it('adds a tributary to a step via the inline selector', () => { + const onChange = vi.fn(); + render( + + ); + const selector = screen.getByTestId( + 'process-map-step-add-tributary-select-step-1' + ) as HTMLSelectElement; + fireEvent.change(selector, { target: { value: 'Machine' } }); + fireEvent.click(screen.getByLabelText('Confirm add tributary to Fill')); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.tributaries).toHaveLength(1); + expect(next.tributaries[0].column).toBe('Machine'); + expect(next.tributaries[0].stepId).toBe('step-1'); + }); + + it('removes a tributary and also clears it from subgroupAxes', () => { + const onChange = vi.fn(); + const map: ProcessMap = { + ...mapWithTwoSteps(), + subgroupAxes: ['trib-1'], + }; + render(); + fireEvent.click(screen.getByLabelText('Remove tributary Machine')); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.tributaries).toHaveLength(0); + expect(next.subgroupAxes).toEqual([]); + }); + + it('adds the tributary to subgroupAxes when toggled on', () => { + const onChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByLabelText('Use Machine as subgroup axis')); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.subgroupAxes).toEqual(['trib-1']); + }); + + it('removes the tributary from subgroupAxes when toggled off', () => { + const onChange = vi.fn(); + const map: ProcessMap = { ...mapWithTwoSteps(), subgroupAxes: ['trib-1'] }; + render(); + fireEvent.click(screen.getByLabelText('Use Machine as subgroup axis')); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.subgroupAxes).toEqual([]); + }); +}); + +describe('ProcessMapBase — CTS / ocean', () => { + it('sets the CTS column via the ocean dropdown', () => { + const onChange = vi.fn(); + render(); + fireEvent.change(screen.getByTestId('process-map-ocean-cts'), { + target: { value: 'Fill_Weight' }, + }); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.ctsColumn).toBe('Fill_Weight'); + }); + + it('invokes onSpecsChange when target/USL/LSL inputs change', () => { + const onChange = vi.fn(); + const onSpecsChange = vi.fn(); + render( + + ); + fireEvent.change(screen.getByTestId('process-map-ocean-lsl'), { target: { value: '490' } }); + expect(onSpecsChange).toHaveBeenCalledWith({ target: 500, usl: 505, lsl: 490 }); + }); +}); + +describe('ProcessMapBase — hunches', () => { + it('adds a hunch via the text input + "+ hunch" button', () => { + const onChange = vi.fn(); + render( + + ); + fireEvent.change(screen.getByTestId('process-map-hunch-text'), { + target: { value: 'Nozzle wear on night shift' }, + }); + fireEvent.change(screen.getByTestId('process-map-hunch-pin'), { + target: { value: 'trib:trib-1' }, + }); + fireEvent.click(screen.getByTestId('process-map-hunch-add')); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.hunches).toHaveLength(1); + expect(next.hunches?.[0].text).toBe('Nozzle wear on night shift'); + expect(next.hunches?.[0].tributaryId).toBe('trib-1'); + }); + + it('does not add an empty hunch', () => { + const onChange = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('process-map-hunch-add')); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('removes a hunch via the per-item × button', () => { + const onChange = vi.fn(); + const map: ProcessMap = { + ...mapWithOneStep(), + hunches: [{ id: 'h-1', text: 'Nozzle wear', stepId: 'step-1' }], + }; + render(); + fireEvent.click(screen.getByLabelText('Remove hunch Nozzle wear')); + const next = onChange.mock.calls[0][0] as ProcessMap; + expect(next.hunches).toEqual([]); + }); +}); + +describe('ProcessMapBase — gap rendering', () => { + const requiredGap: Gap = { + kind: 'missing-spec-limits', + severity: 'required', + message: 'No specification limits.', + }; + const recommendedGap: Gap = { + kind: 'missing-time-axis', + severity: 'recommended', + message: 'No time or batch axis.', + }; + const stepGap: Gap = { + kind: 'missing-ctq-at-step', + severity: 'recommended', + message: 'No CTQ at "Fill".', + stepId: 'step-2', + }; + + it('renders the GapStrip with required and recommended gaps at the bottom', () => { + render( + + ); + expect(screen.getByTestId('process-map-gap-strip')).toBeInTheDocument(); + expect(screen.getByTestId('process-map-gap-required-missing-spec-limits')).toBeInTheDocument(); + expect(screen.getByTestId('process-map-gap-recommended-missing-time-axis')).toBeInTheDocument(); + }); + + it('renders step-scoped gaps inline next to the affected step, not in the strip', () => { + render( + + ); + expect(screen.getByTestId('process-map-step-gaps-step-2')).toBeInTheDocument(); + expect(screen.queryByTestId('process-map-gap-strip')).not.toBeInTheDocument(); + }); + + it('renders nothing when the gaps array is empty or omitted', () => { + render( + + ); + expect(screen.queryByTestId('process-map-gap-strip')).not.toBeInTheDocument(); + }); +}); + +describe('ProcessMapBase — disabled mode', () => { + it('hides destructive / additive controls when disabled', () => { + render( + + ); + expect(screen.queryByTestId('process-map-add-step')).not.toBeInTheDocument(); + expect(screen.queryByTestId('process-map-hunch-add')).not.toBeInTheDocument(); + // Existing elements still render; name input still shows value + expect(screen.getByTestId('process-map-step-name-step-1')).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d1825d549..6d4505ef4 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -582,6 +582,9 @@ export { } from './components/CapabilityMetricToggle'; export { SubgroupConfigPopover, type SubgroupConfigProps } from './components/SubgroupConfig'; +// FRAME workspace — visual Process Map (ADR-070) +export { ProcessMapBase, type ProcessMapBaseProps } from './components/ProcessMap/ProcessMapBase'; + // Hooks export { useIsMobile,