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
40 changes: 40 additions & 0 deletions apps/azure/src/components/ProcessHubReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
ProcessHubInvestigation,
ProcessHubRollup,
ProcessStateItem,
ProcessStateNote,
ResponsePathAction,
} from '@variscout/core';
import { ProcessHubCurrentStatePanel } from '@variscout/ui';
Expand All @@ -26,6 +27,11 @@ interface ProcessHubReviewPanelProps {
onLogReview: (recordId: string) => void;
onRecordHandoff: (investigationId: string) => void;
onResponsePathAction: (item: ProcessStateItem, action: ResponsePathAction, hubId: string) => void;
/** Notes wiring */
onRequestAddNote: (item: ProcessStateItem, hubId: string) => void;
onRequestEditNote: (item: ProcessStateItem, note: ProcessStateNote, hubId: string) => void;
onDeleteNote: (item: ProcessStateItem, noteId: string, hubId: string) => void;
currentUserId: string;
}

const SnapshotCard: React.FC<{
Expand Down Expand Up @@ -55,6 +61,10 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
onLogReview,
onRecordHandoff,
onResponsePathAction,
onRequestAddNote,
onRequestEditNote,
onDeleteNote,
currentUserId,
}) => {
const cadence = buildProcessHubCadence(rollup);
const currentState = buildCurrentProcessState(rollup, cadence);
Expand Down Expand Up @@ -126,6 +136,29 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
[rollup.investigations, investigationIdResolver]
);

// Aggregate stateNotes from all linked investigations for an item.
// Per-investigation items use item.investigationIds; aggregate items pull
// from all hub investigations.
const notesFor = React.useCallback(
(item: ProcessStateItem): readonly ProcessStateNote[] => {
const investigationIds =
item.investigationIds && item.investigationIds.length > 0
? item.investigationIds
: rollup.investigations.map(inv => inv.id);
const all: ProcessStateNote[] = [];
for (const invId of investigationIds) {
const inv = rollup.investigations.find(i => i.id === invId);
const notes = inv?.metadata?.stateNotes ?? [];
for (const note of notes) {
if (note.itemId === item.id) all.push(note);
}
}
// Sort by createdAt asc so older notes appear first
return all.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
[rollup.investigations]
);

const handleChipClick = React.useCallback(
(item: ProcessStateItem, findings: readonly Finding[]) => {
safeTrackEvent('process_hub.evidence_chip_click', {
Expand Down Expand Up @@ -183,6 +216,13 @@ const ProcessHubReviewPanel: React.FC<ProcessHubReviewPanelProps> = ({
onInvoke: (item, action) => onResponsePathAction(item, action, rollup.hub.id),
}}
evidence={{ findingsFor, onChipClick: handleChipClick }}
notes={{
notesFor,
onRequestAddNote: item => onRequestAddNote(item, rollup.hub.id),
onRequestEditNote: (item, note) => onRequestEditNote(item, note, rollup.hub.id),
onDeleteNote: (item, noteId) => onDeleteNote(item, noteId, rollup.hub.id),
currentUserId,
}}
/>

<div className="mt-4 grid gap-2 sm:grid-cols-5">
Expand Down
95 changes: 95 additions & 0 deletions apps/azure/src/components/StateItemNotesDrawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import React from 'react';
import { PROCESS_STATE_NOTE_KINDS, type ProcessStateNoteKind } from '@variscout/core';

const KIND_LABELS: Record<ProcessStateNoteKind, string> = {
question: 'Question',
gemba: 'Gemba',
'data-gap': 'Data Gap',
decision: 'Decision',
};

export interface StateItemNotesDrawerProps {
open: boolean;
initialKind: ProcessStateNoteKind;
initialText: string;
onSave: (kind: ProcessStateNoteKind, text: string) => void;
onCancel: () => void;
/** When true, save is disabled (e.g. during in-flight persistence). */
disabled?: boolean;
}

const StateItemNotesDrawer: React.FC<StateItemNotesDrawerProps> = ({
open,
initialKind,
initialText,
onSave,
onCancel,
disabled = false,
}) => {
const [kind, setKind] = React.useState<ProcessStateNoteKind>(initialKind);
const [text, setText] = React.useState<string>(initialText);

// Reset internal state when drawer is reopened with new initial values
React.useEffect(() => {
if (open) {
setKind(initialKind);
setText(initialText);
}
}, [open, initialKind, initialText]);

if (!open) return null;

const trimmed = text.trim();
const canSave = trimmed.length > 0;

return (
<div
data-testid="state-item-notes-drawer"
className="mt-3 rounded-md border border-edge bg-surface p-3"
>
<div className="flex flex-wrap gap-2">
{PROCESS_STATE_NOTE_KINDS.map(k => (
<button
key={k}
type="button"
aria-pressed={k === kind}
onClick={() => setKind(k)}
className={
k === kind
? 'rounded-sm border border-blue-500 bg-blue-500/10 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-400'
: 'rounded-sm border border-edge px-2 py-0.5 text-xs font-medium text-content-secondary hover:bg-surface-hover'
}
>
{KIND_LABELS[k]}
</button>
))}
</div>
<textarea
value={text}
onChange={e => setText(e.target.value)}
placeholder="Add a team note…"
className="mt-2 w-full rounded-md border border-edge bg-surface px-2 py-1 text-sm text-content focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={3}
/>
<div className="mt-2 flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-md border border-edge px-3 py-1 text-xs font-medium text-content-secondary hover:bg-surface-hover"
>
Cancel
</button>
<button
type="button"
disabled={!canSave || disabled}
onClick={() => onSave(kind, trimmed)}
className="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Save
</button>
</div>
</div>
);
};

export default StateItemNotesDrawer;
63 changes: 63 additions & 0 deletions apps/azure/src/components/__tests__/StateItemNotesDrawer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import StateItemNotesDrawer, { type StateItemNotesDrawerProps } from '../StateItemNotesDrawer';

const baseProps: StateItemNotesDrawerProps = {
open: true,
initialKind: 'question',
initialText: '',
onSave: vi.fn(),
onCancel: vi.fn(),
};

describe('StateItemNotesDrawer', () => {
it('renders nothing when open is false', () => {
render(<StateItemNotesDrawer {...baseProps} open={false} />);
expect(screen.queryByTestId('state-item-notes-drawer')).not.toBeInTheDocument();
});

it('renders 4 kind buttons matching PROCESS_STATE_NOTE_KINDS', () => {
render(<StateItemNotesDrawer {...baseProps} />);
expect(screen.getByRole('button', { name: /question/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /gemba/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /data.gap/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /decision/i })).toBeInTheDocument();
});

it('disables Save when text is empty', () => {
render(<StateItemNotesDrawer {...baseProps} initialText="" />);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});

it('disables Save when text is only whitespace', () => {
render(<StateItemNotesDrawer {...baseProps} initialText=" " />);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
});

it('enables Save when text has non-whitespace content', () => {
render(<StateItemNotesDrawer {...baseProps} initialText="hello" />);
expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled();
});

it('fires onSave with current kind + trimmed text', () => {
const onSave = vi.fn();
render(<StateItemNotesDrawer {...baseProps} initialText=" hello " onSave={onSave} />);
fireEvent.click(screen.getByRole('button', { name: /save/i }));
expect(onSave).toHaveBeenCalledWith('question', 'hello');
});

it('fires onCancel when Cancel button is clicked', () => {
const onCancel = vi.fn();
render(<StateItemNotesDrawer {...baseProps} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onCancel).toHaveBeenCalled();
});

it('switches active kind when a different kind button is clicked', () => {
const onSave = vi.fn();
render(<StateItemNotesDrawer {...baseProps} initialText="hi" onSave={onSave} />);
fireEvent.click(screen.getByRole('button', { name: /gemba/i }));
fireEvent.click(screen.getByRole('button', { name: /save/i }));
expect(onSave).toHaveBeenCalledWith('gemba', 'hi');
});
});
Loading