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:
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user