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
89 changes: 89 additions & 0 deletions apps/azure/src/components/EvidenceSheet.tsx
Original file line number Diff line number Diff line change
@@ -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<Finding['status'], string> = {
observed: 'Observed',
investigating: 'Investigating',
analyzed: 'Analyzed',
improving: 'Improving',
resolved: 'Resolved',
};

const EvidenceSheet: React.FC<EvidenceSheetProps> = ({
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 (
<>
<div className="fixed inset-0 z-40 bg-black/40" onClick={onClose} aria-hidden />
<div
data-testid="evidence-sheet"
className="fixed inset-x-0 bottom-0 z-50 max-h-[60vh] overflow-y-auto rounded-t-lg border-t border-edge bg-surface p-4 shadow-lg"
role="dialog"
aria-label="Findings linked to state item"
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-content">{item.label}</h3>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-1 text-content-secondary hover:bg-surface-hover"
>
<X size={16} />
</button>
</div>

{findings === null ? (
<p className="py-6 text-center text-sm text-content-secondary">Loading findings…</p>
) : findings.length === 0 ? (
<p className="py-6 text-center text-sm text-content-secondary">
No findings recorded for this item yet.
</p>
) : (
<ul className="space-y-2">
{findings.map(finding => (
<li
key={finding.id}
className="cursor-pointer rounded-md border border-edge p-2 hover:bg-surface-hover"
onClick={() => onSelectFinding(finding)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-content">{finding.text}</span>
<span className="rounded-sm border border-current px-2 py-0.5 text-xs font-medium text-content-secondary">
{STATUS_LABELS[finding.status]}
</span>
</div>
</li>
))}
</ul>
)}
</div>
</>
);
};

export default EvidenceSheet;
66 changes: 21 additions & 45 deletions apps/azure/src/components/ProcessHubReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProcessHubInvestigation>;
Expand All @@ -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<readonly Finding[]>;
/** 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<{
Expand Down Expand Up @@ -65,6 +69,9 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
onRequestEditNote,
onDeleteNote,
currentUserId,
loadFindingsForItem,
onChipClick,
onFindingSelect,
}) => {
const cadence = buildProcessHubCadence(rollup);
const currentState = buildCurrentProcessState(rollup, cadence);
Expand Down Expand Up @@ -104,34 +111,18 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
[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<Finding, 'id'>`
// 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]
);
Expand Down Expand Up @@ -159,26 +150,6 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
[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 (
Expand Down Expand Up @@ -215,7 +186,12 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
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),
Expand Down
94 changes: 94 additions & 0 deletions apps/azure/src/components/__tests__/EvidenceSheet.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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(<EvidenceSheet item={null} findings={[]} onSelectFinding={vi.fn()} onClose={vi.fn()} />);
expect(screen.queryByTestId('evidence-sheet')).not.toBeInTheDocument();
});

it('shows a loading state when findings is null', () => {
render(
<EvidenceSheet
item={buildItem()}
findings={null}
onSelectFinding={vi.fn()}
onClose={vi.fn()}
/>
);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});

it('shows empty-state placeholder when findings is empty', () => {
render(
<EvidenceSheet item={buildItem()} findings={[]} onSelectFinding={vi.fn()} onClose={vi.fn()} />
);
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(
<EvidenceSheet
item={buildItem()}
findings={findings}
onSelectFinding={vi.fn()}
onClose={vi.fn()}
/>
);
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(
<EvidenceSheet
item={buildItem()}
findings={[finding]}
onSelectFinding={onSelectFinding}
onClose={vi.fn()}
/>
);
fireEvent.click(screen.getByText('Click me'));
expect(onSelectFinding).toHaveBeenCalledWith(finding);
});

it('fires onClose when the close button is clicked', () => {
const onClose = vi.fn();
render(
<EvidenceSheet item={buildItem()} findings={[]} onSelectFinding={vi.fn()} onClose={onClose} />
);
fireEvent.click(screen.getByLabelText(/close/i));
expect(onClose).toHaveBeenCalled();
});
});
Loading