diff --git a/apps/azure/src/components/EvidenceSheet.tsx b/apps/azure/src/components/EvidenceSheet.tsx new file mode 100644 index 000000000..d1a1db354 --- /dev/null +++ b/apps/azure/src/components/EvidenceSheet.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { X } from 'lucide-react'; +import type { Finding, ProcessStateItem } from '@variscout/core'; + +export interface EvidenceSheetProps { + /** When null, sheet is hidden. */ + item: ProcessStateItem | null; + /** Null while loading; empty array when no findings. */ + findings: readonly Finding[] | null; + onSelectFinding: (finding: Finding) => void; + onClose: () => void; +} + +const STATUS_LABELS: Record = { + observed: 'Observed', + investigating: 'Investigating', + analyzed: 'Analyzed', + improving: 'Improving', + resolved: 'Resolved', +}; + +const EvidenceSheet: React.FC = ({ + item, + findings, + onSelectFinding, + onClose, +}) => { + React.useEffect(() => { + if (!item) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [item, onClose]); + + if (!item) return null; + + return ( + <> +
+
+
+

{item.label}

+ +
+ + {findings === null ? ( +

Loading findings…

+ ) : findings.length === 0 ? ( +

+ No findings recorded for this item yet. +

+ ) : ( +
    + {findings.map(finding => ( +
  • onSelectFinding(finding)} + > +
    + {finding.text} + + {STATUS_LABELS[finding.status]} + +
    +
  • + ))} +
+ )} +
+ + ); +}; + +export default EvidenceSheet; diff --git a/apps/azure/src/components/ProcessHubReviewPanel.tsx b/apps/azure/src/components/ProcessHubReviewPanel.tsx index ff728744b..217cdc12c 100644 --- a/apps/azure/src/components/ProcessHubReviewPanel.tsx +++ b/apps/azure/src/components/ProcessHubReviewPanel.tsx @@ -17,7 +17,6 @@ import { ProcessHubCurrentStatePanel } from '@variscout/ui'; import ProcessHubCadenceQuestions from './ProcessHubCadenceQuestions'; import ProcessHubCadenceQueues from './ProcessHubCadenceQueues'; import { formatLatestActivity } from './ProcessHubFormat'; -import { safeTrackEvent } from '../lib/appInsights'; interface ProcessHubReviewPanelProps { rollup: ProcessHubRollup; @@ -32,6 +31,11 @@ interface ProcessHubReviewPanelProps { onRequestEditNote: (item: ProcessStateItem, note: ProcessStateNote, hubId: string) => void; onDeleteNote: (item: ProcessStateItem, noteId: string, hubId: string) => void; currentUserId: string; + /** Evidence wiring (PR #2) — async finding loader; Dashboard owns sheet state. */ + loadFindingsForItem: (item: ProcessStateItem, hubId: string) => Promise; + /** count is threaded through so Dashboard can include it in chip-click telemetry. */ + onChipClick: (item: ProcessStateItem, hubId: string, count: number) => void; + onFindingSelect: (item: ProcessStateItem, finding: Finding, hubId: string) => void; } const SnapshotCard: React.FC<{ @@ -65,6 +69,9 @@ const ProcessHubReviewPanel: React.FC = ({ onRequestEditNote, onDeleteNote, currentUserId, + loadFindingsForItem, + onChipClick, + onFindingSelect, }) => { const cadence = buildProcessHubCadence(rollup); const currentState = buildCurrentProcessState(rollup, cadence); @@ -104,34 +111,18 @@ const ProcessHubReviewPanel: React.FC = ({ [rollup.investigations] ); - // findingsFor: chip count is derived from - // ProcessHubInvestigationMetadata.findingCounts (already on the rollup — - // no extra data load needed). Returns an array of synthetic Finding-shaped - // placeholder objects with just enough surface (id) for the chip to count. - // - // Real Finding[] objects aren't loaded hub-wide on Dashboard today. The - // EvidenceSheet rendering (which would need the full objects) is deferred - // to a follow-up PR — see plan PR #5 future work. - const findingsFor = React.useCallback( - (item: ProcessStateItem): readonly Finding[] => { + // countFor: cheap derivation from rollup metadata (same arithmetic as the + // PR #99 synthetic-Finding length, but returns the integer directly). + const countFor = React.useCallback( + (item: ProcessStateItem): number => { const investigationIds = investigationIdResolver(item); - let totalRelevantCount = 0; + let total = 0; for (const invId of investigationIds) { const inv = rollup.investigations.find(i => i.id === invId); const counts = inv?.metadata?.findingCounts ?? {}; - totalRelevantCount += - (counts.analyzed ?? 0) + (counts.improving ?? 0) + (counts.resolved ?? 0); + total += (counts.analyzed ?? 0) + (counts.improving ?? 0) + (counts.resolved ?? 0); } - if (totalRelevantCount === 0) return []; - // Placeholder objects carry only `id` — chip rendering only reads `.length`. - // The double cast is the documented pragmatic shortcut for this PR (see - // spec § Implementation Reality Notes). The follow-up EvidenceSheet PR - // will introduce a narrow `FindingCountPlaceholder = Pick` - // type at the panel's evidence-contract boundary so the cast goes away. - // TODO(evidence-sheet-pr): remove this cast once contracts narrow. - return Array.from({ length: totalRelevantCount }, (_, idx) => ({ - id: `${item.id}-finding-placeholder-${idx}`, - })) as unknown as readonly Finding[]; + return total; }, [rollup.investigations, investigationIdResolver] ); @@ -159,26 +150,6 @@ const ProcessHubReviewPanel: React.FC = ({ [rollup.investigations] ); - const handleChipClick = React.useCallback( - (item: ProcessStateItem, findings: readonly Finding[]) => { - safeTrackEvent('process_hub.evidence_chip_click', { - hubId: rollup.hub.id, - responsePath: item.responsePath, - lens: item.lens, - evidenceCount: findings.length, - }); - // Navigate to a linked investigation. Per-investigation items use - // item.investigationIds[0] (the order the projection builder produced — - // typically a single linked investigation, so most-recency is moot). - // Hub-aggregate items fall back to defaultInvestigationId, which is - // sorted by `modified` descending. Full sheet rendering deferred to a - // follow-up PR — when Dashboard loads findings hub-wide. - const targetId = item.investigationIds?.[0] ?? defaultInvestigationId; - if (targetId) onOpenInvestigation(targetId); - }, - [rollup.hub.id, defaultInvestigationId, onOpenInvestigation] - ); - const headingId = `process-hub-current-state-${rollup.hub.id}`; return ( @@ -215,7 +186,12 @@ const ProcessHubReviewPanel: React.FC = ({ actionFor, onInvoke: (item, action) => onResponsePathAction(item, action, rollup.hub.id), }} - evidence={{ findingsFor, onChipClick: handleChipClick }} + evidence={{ + countFor, + loadFindingsFor: item => loadFindingsForItem(item, rollup.hub.id), + onChipClick: item => onChipClick(item, rollup.hub.id, countFor(item)), + onFindingSelect: (item, finding) => onFindingSelect(item, finding, rollup.hub.id), + }} notes={{ notesFor, onRequestAddNote: item => onRequestAddNote(item, rollup.hub.id), diff --git a/apps/azure/src/components/__tests__/EvidenceSheet.test.tsx b/apps/azure/src/components/__tests__/EvidenceSheet.test.tsx new file mode 100644 index 000000000..7b988b7f2 --- /dev/null +++ b/apps/azure/src/components/__tests__/EvidenceSheet.test.tsx @@ -0,0 +1,94 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Finding, ProcessStateItem } from '@variscout/core'; +import EvidenceSheet from '../EvidenceSheet'; + +const buildItem = (overrides: Partial = {}): ProcessStateItem => ({ + id: 'item-1', + lens: 'outcome', + severity: 'amber', + responsePath: 'monitor', + source: 'review-signal', + label: 'Capability gap', + ...overrides, +}); + +const buildFinding = (id: string, status: Finding['status'], text = 'A finding'): Finding => + ({ + id, + text, + createdAt: 1714000000000, + context: {} as Finding['context'], + status, + comments: [], + statusChangedAt: 1714000000000, + }) as Finding; + +describe('EvidenceSheet', () => { + it('renders nothing when item is null', () => { + render(); + expect(screen.queryByTestId('evidence-sheet')).not.toBeInTheDocument(); + }); + + it('shows a loading state when findings is null', () => { + render( + + ); + expect(screen.getByText(/loading/i)).toBeInTheDocument(); + }); + + it('shows empty-state placeholder when findings is empty', () => { + render( + + ); + expect(screen.getByText(/no findings recorded/i)).toBeInTheDocument(); + }); + + it('renders finding labels + statuses', () => { + const findings = [ + buildFinding('f-1', 'analyzed', 'First'), + buildFinding('f-2', 'resolved', 'Second'), + ]; + render( + + ); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); + expect(screen.getByText(/analyzed/i)).toBeInTheDocument(); + expect(screen.getByText(/resolved/i)).toBeInTheDocument(); + }); + + it('fires onSelectFinding when a finding row is clicked', () => { + const finding = buildFinding('f-1', 'analyzed', 'Click me'); + const onSelectFinding = vi.fn(); + render( + + ); + fireEvent.click(screen.getByText('Click me')); + expect(onSelectFinding).toHaveBeenCalledWith(finding); + }); + + it('fires onClose when the close button is clicked', () => { + const onClose = vi.fn(); + render( + + ); + fireEvent.click(screen.getByLabelText(/close/i)); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/apps/azure/src/pages/Dashboard.tsx b/apps/azure/src/pages/Dashboard.tsx index ff44bfcd0..aa7ebcccc 100644 --- a/apps/azure/src/pages/Dashboard.tsx +++ b/apps/azure/src/pages/Dashboard.tsx @@ -4,9 +4,11 @@ import { buildProcessHubRollups, hasTeamFeatures, normalizeProcessHubId, + linkFindingsToStateItems, } from '@variscout/core'; import type { ProcessHub, SustainmentRecord, ControlHandoff } from '@variscout/core'; import type { EvidenceSnapshot } from '@variscout/core'; +import type { Finding } from '@variscout/core'; import type { ProcessStateItem, ProcessStateNote, @@ -32,6 +34,7 @@ import { FileBrowseButton, type FilePickerResult } from '../components/FileBrows import ProjectCard from '../components/ProjectCard'; import ProcessHubCard from '../components/ProcessHubCard'; import ProcessHubEvidencePanel from '../components/ProcessHubEvidencePanel'; +import EvidenceSheet from '../components/EvidenceSheet'; import ProcessHubReviewPanel from '../components/ProcessHubReviewPanel'; import SampleDataPicker from '../components/SampleDataPicker'; import StateItemNotesDrawer from '../components/StateItemNotesDrawer'; @@ -78,6 +81,11 @@ export const Dashboard: React.FC = ({ | null >(null); const [isSavingNote, setIsSavingNote] = useState(false); + const [sheetState, setSheetState] = useState<{ + item: ProcessStateItem; + hubId: string; + findings: readonly Finding[] | null; + } | null>(null); // Fetch current user ID for task ownership display useEffect(() => { @@ -255,7 +263,96 @@ export const Dashboard: React.FC = ({ onOpenProject(action.investigationId); } }, - [onOpenProject, safeTrackEvent] + [onOpenProject] + ); + + const loadFindingsForItem = React.useCallback( + async (item: ProcessStateItem, hubId: string): Promise => { + // Find the rollup for this hub to know which investigations belong to it + const rollup = hubRollups.find(r => r.hub.id === hubId); + if (!rollup) return []; + + // Resolver mirrors the one used in ProcessHubReviewPanel.notesFor: + // per-investigation items use item.investigationIds; aggregate items use all + const investigationIds = + item.investigationIds && item.investigationIds.length > 0 + ? item.investigationIds + : rollup.investigations.map(i => i.id); + + // Look up each investigation's project name (loadProject uses name+location) + const hubProjects = projects.filter( + p => normalizeProcessHubId(p.metadata?.processHubId) === normalizeProcessHubId(hubId) + ); + + // Load findings from each linked investigation in parallel + const findingsByInv = new Map(); + await Promise.all( + investigationIds.map(async invId => { + const projectMeta = hubProjects.find(p => (p.id || p.name) === invId); + if (!projectMeta) return; + const project = await loadProject(projectMeta.name, projectMeta.location); + if (!project) return; + const findings = (project as { findings?: Finding[] }).findings; + if (Array.isArray(findings)) { + findingsByInv.set(invId, findings); + } + }) + ); + + // Use the pure aggregator to filter to relevant statuses + match by item + const result = linkFindingsToStateItems([item], findingsByInv, () => investigationIds); + return result.byItemId.get(item.id) ?? []; + }, + [hubRollups, projects, loadProject] + ); + + const handleChipClick = React.useCallback( + async (item: ProcessStateItem, hubId: string, count: number) => { + safeTrackEvent('process_hub.evidence_sheet_opened', { + hubId, + responsePath: item.responsePath, + lens: item.lens, + evidenceCount: count, + }); + setSheetState({ item, hubId, findings: null }); + try { + const findings = await loadFindingsForItem(item, hubId); + // Race guard: only apply if same item is still selected + setSheetState(prev => + prev?.item.id === item.id && prev.hubId === hubId ? { ...prev, findings } : prev + ); + } catch (err) { + console.error('[Dashboard] Loading evidence findings failed:', err); + setSheetState(prev => + prev?.item.id === item.id && prev.hubId === hubId ? { ...prev, findings: [] } : prev + ); + } + }, + [loadFindingsForItem] + ); + + const handleFindingSelect = React.useCallback( + (item: ProcessStateItem, finding: Finding, hubId: string) => { + safeTrackEvent('process_hub.evidence_sheet_finding_clicked', { + hubId, + lens: item.lens, + findingStatus: finding.status, + }); + setSheetState(null); + // Navigate to the linked investigation. Use the same heuristic as elsewhere: + // item.investigationIds[0], falling back to first hub investigation. + const rollup = hubRollups.find(r => r.hub.id === hubId); + const targetInvestigationId = item.investigationIds?.[0] ?? rollup?.investigations[0]?.id; + if (targetInvestigationId) { + onOpenProject(targetInvestigationId); + // Best-effort hash for finding deep-link; editor may or may not honor it yet. + // Always set — assigning the same value is a no-op in browsers. + setTimeout(() => { + window.location.hash = `finding-${finding.id}`; + }, 100); + } + }, + [hubRollups, onOpenProject] ); const handleRequestAddNote = useCallback((item: ProcessStateItem, hubId: string) => { @@ -626,6 +723,9 @@ export const Dashboard: React.FC = ({ onRequestEditNote={handleRequestEditNote} onDeleteNote={handleDeleteNote} currentUserId={userId} + loadFindingsForItem={loadFindingsForItem} + onChipClick={handleChipClick} + onFindingSelect={handleFindingSelect} /> = ({ disabled={isSavingNote} /> )} + + {/* Evidence sheet — bottom sheet for finding click-thru */} + {sheetState && ( + + handleFindingSelect(sheetState.item, finding, sheetState.hubId) + } + onClose={() => setSheetState(null)} + /> + )}
); }; diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx index 916dff71c..cf2b1c927 100644 --- a/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/ProcessHubCurrentStatePanel.tsx @@ -19,8 +19,14 @@ export interface ProcessHubActionsContract { } export interface ProcessHubEvidenceContract { - findingsFor: (item: ProcessStateItem) => readonly Finding[]; - onChipClick: (item: ProcessStateItem, findings: readonly Finding[]) => void; + /** Sync count for the chip — derived from rollup metadata. */ + countFor: (item: ProcessStateItem) => number; + /** Async load for the sheet — called only when chip is clicked (consumer side). */ + loadFindingsFor: (item: ProcessStateItem) => Promise; + /** Fired when chip is clicked — telemetry only. */ + onChipClick: (item: ProcessStateItem) => void; + /** Fired when user selects a finding in the sheet. */ + onFindingSelect: (item: ProcessStateItem, finding: Finding) => void; } export interface ProcessHubNotesContract { @@ -157,8 +163,8 @@ const StateItemCard: React.FC<{ item: ProcessStateItem; action: ResponsePathAction; onInvoke: (item: ProcessStateItem, action: ResponsePathAction) => void; - findings: readonly Finding[]; - onChipClick: (item: ProcessStateItem, findings: readonly Finding[]) => void; + count: number; + onChipClick: () => void; notes: readonly ProcessStateNote[]; currentUserId: string; onRequestAddNote: () => void; @@ -168,7 +174,7 @@ const StateItemCard: React.FC<{ item, action, onInvoke, - findings, + count, onChipClick, notes, currentUserId, @@ -258,7 +264,7 @@ const StateItemCard: React.FC<{ · {UNSUPPORTED_PILL_LABEL[unsupportedReason]} )}

- onChipClick(item, findings)} /> + @@ -348,8 +354,8 @@ export const ProcessHubCurrentStatePanel: React.FC evidence.onChipClick(item)} notes={notes.notesFor(item)} currentUserId={notes.currentUserId} onRequestAddNote={() => notes.onRequestAddNote(item)} diff --git a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx index 698b0f830..29b69733a 100644 --- a/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx +++ b/packages/ui/src/components/ProcessHubCurrentStatePanel/__tests__/ProcessHubCurrentStatePanel.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; import { vi } from 'vitest'; import type { @@ -56,11 +56,17 @@ function makeActions( return { actionFor, onInvoke }; } -function makeEvidence() { - // Stubbed evidence contract for PR #4 tests; chip behavior is tested in PR #5. +function makeEvidence( + overrides: { + countFor?: (item: ProcessStateItem) => number; + loadFindingsFor?: (item: ProcessStateItem) => Promise; + } = {} +) { return { - findingsFor: () => [], + countFor: overrides.countFor ?? (() => 0), + loadFindingsFor: overrides.loadFindingsFor ?? (async () => []), onChipClick: vi.fn(), + onFindingSelect: vi.fn(), }; } @@ -364,17 +370,9 @@ describe('ProcessHubCurrentStatePanel — actions', () => { }); describe('ProcessHubCurrentStatePanel — evidence chip', () => { - it('shows the chip with finding count when findingsFor returns non-empty', () => { + it('shows the chip with finding count when countFor returns non-zero', () => { const item = buildItem({ id: 'item-e1', responsePath: 'focused-investigation' }); - const findings = [ - { id: 'f-1' } as unknown as Finding, - { id: 'f-2' } as unknown as Finding, - { id: 'f-3' } as unknown as Finding, - ]; - const evidence = { - findingsFor: () => findings, - onChipClick: vi.fn(), - }; + const evidence = makeEvidence({ countFor: () => 3 }); render( { it('shows singular text for one finding', () => { const item = buildItem({ id: 'item-e2' }); - const evidence = { - findingsFor: () => [{ id: 'f-only' } as unknown as Finding], - onChipClick: vi.fn(), - }; + const evidence = makeEvidence({ countFor: () => 1 }); render( { expect(screen.getByTestId('current-state-evidence-chip')).toHaveTextContent('1 finding'); }); - it('omits the chip when findingsFor returns empty', () => { + it('omits the chip when countFor returns zero', () => { const item = buildItem({ id: 'item-e3' }); - const evidence = { - findingsFor: () => [], - onChipClick: vi.fn(), - }; + const evidence = makeEvidence({ countFor: () => 0 }); render( { expect(screen.queryByTestId('current-state-evidence-chip')).not.toBeInTheDocument(); }); - it('fires onChipClick with item + findings on chip click and stops card propagation', () => { + it('fires onChipClick(item) on chip click and stops card propagation', () => { const item = buildItem({ id: 'item-e4', responsePath: 'focused-investigation' }); - const findings = [{ id: 'f-1' } as unknown as Finding]; const onChipClick = vi.fn(); const onInvoke = vi.fn(); @@ -444,26 +435,24 @@ describe('ProcessHubCurrentStatePanel — evidence chip', () => { }), onInvoke, }} - evidence={{ findingsFor: () => findings, onChipClick }} + evidence={{ + countFor: () => 1, + loadFindingsFor: async () => [], + onChipClick, + onFindingSelect: vi.fn(), + }} notes={makeNotes()} /> ); - const chip = screen.getByTestId('current-state-evidence-chip'); - chip.click(); - - expect(onChipClick).toHaveBeenCalledWith(item, findings); - // Card click should NOT have fired because chip stops propagation + fireEvent.click(screen.getByTestId('current-state-evidence-chip')); + expect(onChipClick).toHaveBeenCalledWith(item); expect(onInvoke).not.toHaveBeenCalled(); }); it('renders chip on Planned/unsupported cards too (chip independent of action support)', () => { const item = buildItem({ id: 'item-e5', responsePath: 'measurement-system-work' }); - const findings = [{ id: 'f-1' } as unknown as Finding]; - const evidence = { - findingsFor: () => findings, - onChipClick: vi.fn(), - }; + const evidence = makeEvidence({ countFor: () => 1 }); render( { expect(screen.getByTestId('current-state-evidence-chip')).toHaveTextContent('1 finding'); }); + + it('does not call loadFindingsFor when only the count is needed for the chip', () => { + const loadFindingsFor = vi.fn(async () => []); + const item = buildItem(); + + render( + 5, loadFindingsFor })} + notes={makeNotes()} + /> + ); + + expect(loadFindingsFor).not.toHaveBeenCalled(); + }); + + it('exposes onFindingSelect on the contract for consumer-side sheet wiring', () => { + const evidence = makeEvidence(); + expect(typeof evidence.onFindingSelect).toBe('function'); + }); }); describe('ProcessHubCurrentStatePanel — notes', () => {