feat(bulk-edit): extend BulkDocumentEditLayout with mode="edit"

- New FieldLabelBadge component (additive / replace variants, WCAG AA contrast)
- WhoWhenSection: hideDate prop, editMode prop renders badges next to sender
  and receivers, hides the meta_location field
- DescriptionSection: editMode prop renders badges next to tags and archive
  fields; new bindable archiveBox / archiveFolder inputs only in editMode
- PersonTypeahead: optional badge prop forwards to FieldLabelBadge
- FileSwitcherStrip FileEntry: file is now optional, documentId added so
  edit-mode entries reference an existing document by UUID
- BulkDocumentEditLayout: mode prop branches drop zone / read-only title /
  callout / save handler. Edit save chunks 500 IDs per PATCH, stops on chunk
  failure with retry, marks per-document errors as chips, clears the bulk
  selection store on full success.

Refs #225

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-25 15:16:06 +02:00
parent d4f32ed5d4
commit fa5dc43864
8 changed files with 610 additions and 110 deletions

View File

@@ -312,3 +312,190 @@ describe('BulkDocumentEditLayout', () => {
);
});
});
// ─── mode="edit" ─────────────────────────────────────────────────────────────
describe('BulkDocumentEditLayout — mode="edit"', () => {
const editEntry = (i: number) => ({
documentId: `doc-${i}`,
title: `Brief ${i}`,
pdfUrl: `/api/documents/doc-${i}/file`
});
it('does not render the BulkDropZone in edit mode', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="bulk-drop-zone"]')).toBeNull();
});
it('renders the onboarding callout with role=note in edit mode', async () => {
render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
const callout = page.getByTestId('bulk-edit-callout');
await expect.element(callout).toBeInTheDocument();
await expect.element(callout).toHaveAttribute('role', 'note');
});
it('renders read-only title display (no input) in edit mode', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="readonly-title"]')).not.toBeNull();
// Per-file ScopeCard absent at N=1 — title rendered in the single card
const titleInput = container.querySelector('input[type="text"][value="Brief 1"]');
expect(titleInput).toBeNull();
});
it('hides the date field via WhoWhenSection hideDate prop', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="who-when-date"]')).toBeNull();
});
it('shows additive badge next to tags label', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="field-label-badge-additive"]')).not.toBeNull();
});
it('shows replace badges next to sender and archive fields', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
const replaceBadges = container.querySelectorAll('[data-testid="field-label-badge-replace"]');
// sender + documentLocation + archiveBox + archiveFolder = 4
expect(replaceBadges.length).toBeGreaterThanOrEqual(4);
});
it('shows the archiveBox and archiveFolder bulk-only inputs', async () => {
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1)]
});
expect(container.querySelector('[data-testid="description-archive-box"]')).not.toBeNull();
expect(container.querySelector('[data-testid="description-archive-folder"]')).not.toBeNull();
});
it('save calls PATCH /api/documents/bulk in edit mode', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ updated: 2, errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(1), { timeout: 3000 });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/documents/bulk');
expect(init.method).toBe('PATCH');
const body = JSON.parse(init.body);
expect(body.documentIds).toEqual(['doc-1', 'doc-2']);
});
it('chunks IDs into 500-sized PATCH requests', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ updated: 500, errors: [] })
});
vi.stubGlobal('fetch', mockFetch);
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: entries
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(3), { timeout: 5000 });
expect(JSON.parse(mockFetch.mock.calls[0][1].body).documentIds.length).toBe(500);
expect(JSON.parse(mockFetch.mock.calls[1][1].body).documentIds.length).toBe(500);
expect(JSON.parse(mockFetch.mock.calls[2][1].body).documentIds.length).toBe(100);
});
it('stops on chunk failure and shows the partial-failure alert with retry', async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce({ ok: true, json: async () => ({ updated: 500, errors: [] }) })
.mockResolvedValueOnce({ ok: false, json: async () => ({ code: 'INTERNAL_ERROR' }) });
vi.stubGlobal('fetch', mockFetch);
const entries = Array.from({ length: 1100 }, (_, i) => editEntry(i));
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: entries
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const alert = container.querySelector('[data-testid="bulk-edit-partial-failure"]');
expect(alert).not.toBeNull();
},
{ timeout: 5000 }
);
// Should have called twice — chunks 0 and 1 — but not the third.
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(vi.mocked(goto)).not.toHaveBeenCalled();
});
it('marks per-document error chips when service returns errors[]', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
updated: 1,
errors: [{ id: 'doc-2', message: 'Sender not found' }]
})
})
);
const { container } = render(BulkDocumentEditLayout, {
mode: 'edit',
initialEditEntries: [editEntry(1), editEntry(2)]
});
const saveBtn = container.querySelector(
'button[data-testid="bulk-save-btn"]'
) as HTMLButtonElement;
saveBtn.click();
await vi.waitFor(
() => {
const errorChip = container.querySelector(
'[data-testid="file-switcher-strip"] [data-chip-id="doc-2"][data-status="error"]'
);
expect(errorChip).not.toBeNull();
},
{ timeout: 3000 }
);
});
});