From 4f0a660cb82787966abb238b3e418db32135fe82 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 11 Jun 2026 12:35:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(geschichte):=20StoryDocumentPanel=20?= =?UTF-8?q?=E2=80=94=20sidebar=20document=20management=20for=20stories=20(?= =?UTF-8?q?#795)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar-section-styled panel (p-4 card, mobile
accordion, no inner scroll clamp) that lists a story's journey items in position order. Add is pessimistic via POST /items; remove is optimistic with snapshot rollback via DELETE /items/{id}; both through csrfFetch. Already-linked documents are unselectable in the reused DocumentPickerDropdown (visible label wired via inputId). Document-less items (ON DELETE SET NULL) render as removable placeholder rows. 409 capacity/duplicate map to story-worded messages, everything else through getErrorMessage(). Add/ remove are announced in a polite live region and focus moves to the previous row's remove button (picker input when the list empties). Co-Authored-By: Claude Fable 5 --- .../lib/geschichte/StoryDocumentPanel.svelte | 193 +++++++++++ .../StoryDocumentPanel.svelte.spec.ts | 312 ++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 frontend/src/lib/geschichte/StoryDocumentPanel.svelte create mode 100644 frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts diff --git a/frontend/src/lib/geschichte/StoryDocumentPanel.svelte b/frontend/src/lib/geschichte/StoryDocumentPanel.svelte new file mode 100644 index 00000000..9ac55d5d --- /dev/null +++ b/frontend/src/lib/geschichte/StoryDocumentPanel.svelte @@ -0,0 +1,193 @@ + + + +
{liveAnnounce}
+ +
+ + {m.geschichte_documents_heading()} + +
+ +

{m.geschichte_documents_hint()}

+ + {#if errorMessage} + + {/if} + + {#if items.length === 0} +

{m.geschichte_documents_empty()}

+ {:else} +
    + {#each items as item (item.id)} +
  • + {#if item.document} + + {item.document.title} + + {:else} + + {m.geschichte_documents_deleted_placeholder()} + + {/if} + +
  • + {/each} +
+ {/if} + + + +
+
diff --git a/frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts b/frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts new file mode 100644 index 00000000..fd5113e4 --- /dev/null +++ b/frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts @@ -0,0 +1,312 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import { m } from '$lib/paraglide/messages.js'; +import StoryDocumentPanel from './StoryDocumentPanel.svelte'; + +const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); + +const docSummary = (id: string, title: string) => ({ + id, + title, + datePrecision: 'DAY' as const, + receiverCount: 0 +}); + +const makeItem = ( + id: string, + position: number, + document?: ReturnType, + note?: string +) => ({ id, position, document, note }); + +/** DocumentListItem fixture as returned by the picker search endpoint. */ +const makeSearchResultItem = (id: string, title: string) => ({ + id, + title, + documentDate: '1880-01-01', + metaDatePrecision: 'DAY', + originalFilename: 'brief.pdf', + receivers: [], + tags: [], + completionPercentage: 0, + contributors: [], + matchData: { + titleOffsets: [], + senderMatched: false, + matchedReceiverIds: [], + matchedTagIds: [], + snippetOffsets: [], + summaryOffsets: [] + }, + status: 'UPLOADED', + metadataComplete: false, + scriptType: 'UNKNOWN', + createdAt: '2024-01-01T00:00:00', + updatedAt: '2024-01-01T00:00:00' +}); + +type MutationResponse = { ok: boolean; status?: number; body?: object }; + +/** + * Routes the picker's GET search to `searchItems` and every mutation + * (POST/DELETE) to `mutation` — the panel talks to both endpoints. + */ +function stubFetch(searchItems: object[], mutation: MutationResponse = { ok: true, body: {} }) { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const method = (init?.method ?? 'GET').toUpperCase(); + if (method === 'GET') { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ items: searchItems }) + }); + } + return Promise.resolve({ + ok: mutation.ok, + status: mutation.status ?? (mutation.ok ? 200 : 500), + json: () => Promise.resolve(mutation.body ?? {}) + }); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +const defaultProps = (overrides: Record = {}) => ({ + geschichteId: 'g1', + items: [], + ...overrides +}); + +async function addViaPicker(title: RegExp) { + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + await userEvent.click(page.getByText(title)); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('StoryDocumentPanel — rendering', () => { + it('renders linked documents sorted by position', async () => { + render( + StoryDocumentPanel, + defaultProps({ + items: [ + makeItem('i3', 30, docSummary('d3', 'Dritter Brief')), + makeItem('i1', 10, docSummary('d1', 'Erster Brief')) + ] + }) + ); + + const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); + expect(rows[0]).toContain('Erster Brief'); + expect(rows[1]).toContain('Dritter Brief'); + }); + + it('shows the empty state when no items are linked', async () => { + render(StoryDocumentPanel, defaultProps()); + await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument(); + }); + + it('renders a deleted-document item as placeholder row that is still removable', async () => { + render(StoryDocumentPanel, defaultProps({ items: [makeItem('i1', 10, undefined)] })); + + await expect + .element(page.getByText(m.geschichte_documents_deleted_placeholder())) + .toBeInTheDocument(); + await expect + .element( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ + title: m.geschichte_documents_deleted_placeholder() + }) + }) + ) + .toBeInTheDocument(); + }); + + it('wires a visible label to the picker input', async () => { + render(StoryDocumentPanel, defaultProps()); + const input = page.getByRole('combobox').element() as HTMLInputElement; + const label = document.querySelector(`label[for="${input.id}"]`); + expect(label?.textContent).toContain(m.geschichte_documents_picker_label()); + }); +}); + +describe('StoryDocumentPanel — add', () => { + it('POSTs to the items endpoint and appends the created item', async () => { + const fetchMock = stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: true, + body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie')) + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST'); + expect(post?.[0]).toBe('/api/geschichten/g1/items'); + expect(JSON.parse(String(post?.[1]?.body))).toEqual({ documentId: 'd1' }); + const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); + expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true); + }); + + it('marks an already-linked document as not selectable in the dropdown', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')]); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.fill(page.getByRole('combobox'), 'Brief'); + await waitForDebounce(); + + const option = document.querySelector('[role="listbox"] [role="option"]'); + expect(option?.getAttribute('aria-disabled')).toBe('true'); + }); + + it('renders the story-worded duplicate error on a 409 JOURNEY_DOCUMENT_ALREADY_ADDED', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: false, + status: 409, + body: { code: 'JOURNEY_DOCUMENT_ALREADY_ADDED' } + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent(m.geschichte_documents_duplicate()); + }); + + it('renders the story-worded capacity error on a 409 JOURNEY_AT_CAPACITY', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: false, + status: 409, + body: { code: 'JOURNEY_AT_CAPACITY' } + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + await expect + .element(page.getByRole('alert')) + .toHaveTextContent(m.geschichte_documents_capacity()); + }); + + it('announces a successful add via the polite live region', async () => { + stubFetch([makeSearchResultItem('d1', 'Brief von Eugenie')], { + ok: true, + body: makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie')) + }); + render(StoryDocumentPanel, defaultProps()); + + await addViaPicker(/Brief von Eugenie/i); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion?.textContent).toBe( + m.geschichte_documents_added_announce({ title: 'Brief von Eugenie' }) + ); + }); +}); + +describe('StoryDocumentPanel — remove', () => { + it('DELETEs the item endpoint and removes the row', async () => { + const fetchMock = stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + const del = fetchMock.mock.calls.find(([, init]) => init?.method === 'DELETE'); + expect(del?.[0]).toBe('/api/geschichten/g1/items/i1'); + expect(document.querySelectorAll('li').length).toBe(0); + await expect.element(page.getByText(m.geschichte_documents_empty())).toBeInTheDocument(); + }); + + it('restores the row and shows an error when the DELETE fails', async () => { + stubFetch([], { ok: false, status: 500, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + await expect.element(page.getByRole('alert')).toBeInTheDocument(); + const rows = Array.from(document.querySelectorAll('li')).map((li) => li.textContent ?? ''); + expect(rows.some((r) => r.includes('Brief von Eugenie'))).toBe(true); + }); + + it('moves focus to the previous row remove button instead of dropping to body', async () => { + stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ + items: [ + makeItem('i1', 10, docSummary('d1', 'Erster Brief')), + makeItem('i2', 20, docSummary('d2', 'Zweiter Brief')) + ] + }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Zweiter Brief' }) + }) + ); + + expect(document.activeElement).not.toBe(document.body); + expect(document.activeElement?.getAttribute('aria-label')).toBe( + m.geschichte_documents_remove_label({ title: 'Erster Brief' }) + ); + }); + + it('moves focus to the picker input when the last item is removed', async () => { + stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + const input = page.getByRole('combobox').element(); + expect(document.activeElement).toBe(input); + }); + + it('announces a successful remove via the polite live region', async () => { + stubFetch([], { ok: true, body: {} }); + render( + StoryDocumentPanel, + defaultProps({ items: [makeItem('i1', 10, docSummary('d1', 'Brief von Eugenie'))] }) + ); + + await userEvent.click( + page.getByRole('button', { + name: m.geschichte_documents_remove_label({ title: 'Brief von Eugenie' }) + }) + ); + + const liveRegion = document.querySelector('[aria-live="polite"]'); + expect(liveRegion?.textContent).toBe( + m.geschichte_documents_removed_announce({ title: 'Brief von Eugenie' }) + ); + }); +});