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
11 changes: 7 additions & 4 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4986,9 +4986,12 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
field with immediate optimistic saves + undo, no heavy modal. */}
{quickEditNode && (() => {
const simNode = (simulationRef.current?.nodes() as any[])?.find((n: any) => n.id === quickEditNode.id);
const node = simNode || quickEditNode;
const gx = node.x ?? 0;
const gy = node.y ?? 0;
// Position from the live sim node, but field DATA (incl. freshly-saved
// metadata/status notes) from the Apollo-backed nodes array so reopening
// the editor shows the latest values, not a stale d3 datum.
const dataNode = (nodes as any[]).find((n: any) => n.id === quickEditNode.id) || quickEditNode;
const gx = simNode?.x ?? dataNode.x ?? 0;
const gy = simNode?.y ?? dataNode.y ?? 0;
const left = gx * currentTransform.scale + currentTransform.x;
const top = gy * currentTransform.scale + currentTransform.y;
const onCommit = (c: QuickEditCommit) => {
Expand All @@ -5005,7 +5008,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected, i
};
return (
<div className="absolute z-50" style={{ left, top, transform: 'translate(-50%, -50%)' }}>
<NodeQuickEdit node={node} onCommit={onCommit} onClose={() => setQuickEditNode(null)} />
<NodeQuickEdit key={quickEditNode.id} node={dataNode} onCommit={onCommit} onClose={() => setQuickEditNode(null)} />
</div>
);
})()}
Expand Down
87 changes: 86 additions & 1 deletion packages/web/src/components/NodeQuickEdit.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { X, Trash2 } from 'lucide-react';
import {
getTypeConfig, getStatusConfig, TYPE_OPTIONS, STATUS_OPTIONS,
type WorkItemType, type WorkItemStatus,
} from '../constants/workItemConstants';
import { getStatusNotes, addStatusNote, editStatusNote, deleteStatusNote } from '../lib/statusNotes';

function noteId(): string {
try { return crypto.randomUUID(); } catch { return `n-${Date.now()}-${Math.round(Math.random() * 1e6)}`; }
}
function fmtTime(at: number): string {
try { return new Date(at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return ''; }
}

export interface QuickEditCommit {
update: Record<string, any>;
Expand Down Expand Up @@ -34,6 +42,26 @@ export function NodeQuickEdit({ node, onCommit, onClose, rootTestId = 'node-quic
const [title, setTitle] = useState<string>(node.title || '');
const [description, setDescription] = useState<string>(node.description || '');
const [priority, setPriority] = useState<number>(Math.round(((node.priority ?? 0) as number) * 100));
// Status notes live in metadata.statusNotes. Keep a local copy so the list
// stays correct within the session regardless of prop-refresh timing.
const [meta, setMeta] = useState<any>(node.metadata ?? {});
const [noteText, setNoteText] = useState('');
const [editingNote, setEditingNote] = useState<string | null>(null);
const notes = getStatusNotes(meta);

const commitMeta = (next: Record<string, any>) => {
// metadata is a String field (JSON-as-string) in the schema — serialize on
// write; reads parse it back via getStatusNotes.
const prevStr = typeof meta === 'string' ? meta : JSON.stringify(meta ?? {});
setMeta(next);
onCommit({ update: { metadata: JSON.stringify(next) }, prev: { metadata: prevStr }, label: 'Status note' });
};
const addNote = () => {
const t = noteText.trim();
if (!t) return;
commitMeta(addStatusNote(meta, t, Date.now(), noteId()));
setNoteText('');
};

const typeCfg = getTypeConfig(node.type as WorkItemType);

Expand Down Expand Up @@ -157,6 +185,63 @@ export function NodeQuickEdit({ node, onCommit, onClose, rootTestId = 'node-quic
})}
</div>
</div>

{/* Status notes — timestamped, editable updates (like comments) */}
<div data-testid="quick-status-notes">
<span className="block text-[11px] text-gray-500 mb-1">Status notes</span>
<div className="flex gap-1">
<input
data-testid="quick-note-input"
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addNote(); } }}
placeholder="Add a status update…"
className="flex-1 min-w-0 px-2 py-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-200 outline-none focus:border-emerald-400"
/>
<button
data-testid="quick-note-add"
onClick={addNote}
disabled={!noteText.trim()}
className="px-2 py-1.5 rounded-lg bg-emerald-600/80 hover:bg-emerald-500 text-white text-[11px] font-medium disabled:opacity-40 disabled:cursor-not-allowed"
>
Add
</button>
</div>
{notes.length > 0 && (
<ul className="mt-2 space-y-1.5" data-testid="quick-note-list">
{notes.map((n) => (
<li key={n.id} className="group rounded-lg bg-gray-800/60 border border-gray-700/60 px-2 py-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-gray-500">{fmtTime(n.at)}</span>
<button
onClick={() => commitMeta(deleteStatusNote(meta, n.id))}
className="ml-auto p-0.5 text-gray-500 hover:text-rose-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete note" aria-label="Delete note"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{editingNote === n.id ? (
<input
autoFocus
defaultValue={n.text}
onBlur={(e) => { commitMeta(editStatusNote(meta, n.id, e.target.value)); setEditingNote(null); }}
onKeyDown={(e) => {
if (e.key === 'Enter') { commitMeta(editStatusNote(meta, n.id, (e.target as HTMLInputElement).value)); setEditingNote(null); }
if (e.key === 'Escape') setEditingNote(null);
}}
className="mt-1 w-full px-1.5 py-1 rounded bg-gray-900 border border-emerald-400 text-gray-100 text-[12px] outline-none"
/>
) : (
<div className="text-[12px] text-gray-200 whitespace-pre-wrap cursor-text" onClick={() => setEditingNote(n.id)} title="Click to edit">
{n.text}
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
Expand Down
53 changes: 53 additions & 0 deletions packages/web/src/lib/__tests__/statusNotes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import {
getStatusNotes, addStatusNote, editStatusNote, deleteStatusNote, type StatusNote,
} from '../statusNotes';

describe('statusNotes', () => {
it('returns [] for null / undefined / empty metadata', () => {
expect(getStatusNotes(null)).toEqual([]);
expect(getStatusNotes(undefined)).toEqual([]);
expect(getStatusNotes({})).toEqual([]);
});

it('parses metadata whether it is an object or a JSON string', () => {
const obj = { statusNotes: [{ id: 'a', text: 'hi', at: 1000 }] };
expect(getStatusNotes(obj)).toHaveLength(1);
expect(getStatusNotes(JSON.stringify(obj))).toHaveLength(1);
});

it('addStatusNote prepends a timestamped note and preserves other metadata', () => {
const md = { tags: ['x'] };
const next = addStatusNote(md, 'started work', 1700, 'n1');
const notes = getStatusNotes(next);
expect(notes[0]).toEqual<StatusNote>({ id: 'n1', text: 'started work', at: 1700 });
expect((next as any).tags).toEqual(['x']); // untouched
});

it('addStatusNote ignores blank text', () => {
const next = addStatusNote({}, ' ', 1, 'n');
expect(getStatusNotes(next)).toEqual([]);
});

it('newest note is first after multiple adds', () => {
let md: any = {};
md = addStatusNote(md, 'one', 1, 'a');
md = addStatusNote(md, 'two', 2, 'b');
expect(getStatusNotes(md).map((n) => n.id)).toEqual(['b', 'a']);
});

it('editStatusNote updates only the matching note text', () => {
let md: any = addStatusNote({}, 'typo', 1, 'a');
md = addStatusNote(md, 'keep', 2, 'b');
md = editStatusNote(md, 'a', 'fixed');
const byId = Object.fromEntries(getStatusNotes(md).map((n) => [n.id, n.text]));
expect(byId).toEqual({ a: 'fixed', b: 'keep' });
});

it('deleteStatusNote removes the matching note', () => {
let md: any = addStatusNote({}, 'a', 1, 'a');
md = addStatusNote(md, 'b', 2, 'b');
md = deleteStatusNote(md, 'a');
expect(getStatusNotes(md).map((n) => n.id)).toEqual(['b']);
});
});
49 changes: 49 additions & 0 deletions packages/web/src/lib/statusNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Status notes — timestamped, editable free-text updates attached to a work item,
* stored under its `metadata.statusNotes` (so no schema change beyond the
* existing metadata JSON field). Pure helpers; the UI + persistence layer call
* these and write the returned metadata back via updateWorkItems.
*/

export interface StatusNote {
id: string;
text: string;
at: number; // unix ms
}

type Meta = Record<string, any> | string | null | undefined;

function parse(metadata: Meta): Record<string, any> {
if (!metadata) return {};
if (typeof metadata === 'string') {
try { return JSON.parse(metadata) || {}; } catch { return {}; }
}
return { ...metadata };
}

export function getStatusNotes(metadata: Meta): StatusNote[] {
const m = parse(metadata);
const notes = Array.isArray(m.statusNotes) ? m.statusNotes : [];
return notes.filter((n: any) => n && typeof n.id === 'string' && typeof n.text === 'string');
}

/** Prepend a new timestamped note (newest first). Blank text is ignored. */
export function addStatusNote(metadata: Meta, text: string, at: number, id: string): Record<string, any> {
const m = parse(metadata);
const t = (text || '').trim();
if (!t) return m;
const notes = getStatusNotes(m);
return { ...m, statusNotes: [{ id, text: t, at }, ...notes] };
}

export function editStatusNote(metadata: Meta, id: string, text: string): Record<string, any> {
const m = parse(metadata);
const notes = getStatusNotes(m).map((n) => (n.id === id ? { ...n, text: (text || '').trim() } : n));
return { ...m, statusNotes: notes };
}

export function deleteStatusNote(metadata: Meta, id: string): Record<string, any> {
const m = parse(metadata);
const notes = getStatusNotes(m).filter((n) => n.id !== id);
return { ...m, statusNotes: notes };
}
27 changes: 27 additions & 0 deletions tests/e2e/node-quick-edit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,31 @@ test.describe('node quick-edit @quickedit', () => {
await page.waitForTimeout(800);
}
});

test('adds a timestamped status note that persists, then deletes it', async ({ page }) => {
await gotoGraph(page);
await page.locator('.graph-container svg .node').first().dblclick();
await expect(page.locator('[data-testid="node-quick-edit"]')).toBeVisible();

const note = `note-${Date.now() % 100000}`;
await page.locator('[data-testid="quick-note-input"]').fill(note);
await page.locator('[data-testid="quick-note-add"]').click();
// Appears in the list immediately.
await expect(page.locator('[data-testid="quick-note-list"]').getByText(note, { exact: false })).toBeVisible();
await page.waitForTimeout(1500); // let the metadata mutation persist

// Reload (strongest persistence proof) → the note survives, loaded from the
// backend's saved metadata.
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForSelector('.graph-container svg .node', { timeout: 15_000 });
await page.waitForTimeout(3500);
await page.locator('.graph-container svg .node').first().dblclick();
await expect(page.locator('[data-testid="quick-note-list"]').getByText(note, { exact: false })).toBeVisible({ timeout: 8000 });

// Clean up: delete the note so seed data is unchanged.
const item = page.locator('[data-testid="quick-note-list"] li', { hasText: note }).first();
await item.hover();
await item.getByRole('button', { name: 'Delete note' }).click();
await expect(page.locator('[data-testid="quick-note-list"]').getByText(note, { exact: false })).toHaveCount(0);
});
});
Loading