feat(geschichte): StoryDocumentPanel — sidebar document management for stories (#795)
Sidebar-section-styled panel (p-4 card, mobile <details> 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 <noreply@anthropic.com>
This commit is contained in:
193
frontend/src/lib/geschichte/StoryDocumentPanel.svelte
Normal file
193
frontend/src/lib/geschichte/StoryDocumentPanel.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { csrfFetch } from '$lib/shared/cookies';
|
||||
import { getErrorMessage } from '$lib/shared/errors';
|
||||
import DocumentPickerDropdown from '$lib/document/DocumentPickerDropdown.svelte';
|
||||
import type { DocumentOption } from '$lib/document/documentTypeahead';
|
||||
|
||||
type JourneyItemView = components['schemas']['JourneyItemView'];
|
||||
|
||||
interface Props {
|
||||
geschichteId: string;
|
||||
items?: JourneyItemView[];
|
||||
}
|
||||
|
||||
let { geschichteId, items: initialItems = [] }: Props = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const pickerInputId = `story-doc-picker-${uid}`;
|
||||
|
||||
// Initial-state snapshot — the panel owns the list after mount and updates
|
||||
// it from API responses; the parent re-mounts to reset (same contract as
|
||||
// GeschichteEditor/JourneyEditor).
|
||||
// svelte-ignore state_referenced_locally
|
||||
let items: JourneyItemView[] = $state([...initialItems].sort((a, b) => a.position - b.position));
|
||||
let errorMessage = $state('');
|
||||
let liveAnnounce = $state('');
|
||||
let announceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let sectionEl: HTMLElement | null = $state(null);
|
||||
|
||||
const alreadyAddedIds = $derived(
|
||||
new Set(items.filter((i) => i.document).map((i) => i.document!.id))
|
||||
);
|
||||
|
||||
function announce(message: string) {
|
||||
liveAnnounce = message;
|
||||
if (announceTimer) clearTimeout(announceTimer);
|
||||
announceTimer = setTimeout(() => {
|
||||
liveAnnounce = '';
|
||||
announceTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function itemTitle(item: JourneyItemView): string {
|
||||
return item.document?.title ?? m.geschichte_documents_deleted_placeholder();
|
||||
}
|
||||
|
||||
/** Maps a failed mutation to a user-facing message — story wording for the
|
||||
* two journey-flavored 409s, whose generic messages say "Lesereise". */
|
||||
async function failureMessage(res: Response): Promise<string> {
|
||||
const code = (await res.json().catch(() => ({})))?.code;
|
||||
if (code === 'JOURNEY_AT_CAPACITY') return m.geschichte_documents_capacity();
|
||||
if (code === 'JOURNEY_DOCUMENT_ALREADY_ADDED') return m.geschichte_documents_duplicate();
|
||||
return code ? getErrorMessage(code) : m.journey_mutation_error_reload();
|
||||
}
|
||||
|
||||
/** Pessimistic append — the list updates only with the server's response. */
|
||||
async function handleAdd(doc: DocumentOption) {
|
||||
errorMessage = '';
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ documentId: doc.id })
|
||||
});
|
||||
if (!res.ok) {
|
||||
errorMessage = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
const newItem: JourneyItemView = await res.json();
|
||||
items = [...items, newItem];
|
||||
announce(m.geschichte_documents_added_announce({ title: itemTitle(newItem) }));
|
||||
} catch (e) {
|
||||
console.error('Story document add failed', e);
|
||||
errorMessage = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
|
||||
/** The removed row's button leaves the DOM — without this, focus drops to
|
||||
* <body> and a keyboard user is teleported to page top. */
|
||||
async function moveFocusAfterRemove(removedIdx: number) {
|
||||
await tick();
|
||||
if (items.length === 0) {
|
||||
sectionEl?.querySelector<HTMLElement>(`#${pickerInputId}`)?.focus();
|
||||
return;
|
||||
}
|
||||
const target = items[Math.max(removedIdx - 1, 0)];
|
||||
sectionEl?.querySelector<HTMLElement>(`[data-item-id="${target.id}"] [data-remove-btn]`)?.focus();
|
||||
}
|
||||
|
||||
/** Optimistic removal with snapshot-and-rollback. */
|
||||
async function handleRemove(item: JourneyItemView) {
|
||||
const idx = items.findIndex((i) => i.id === item.id);
|
||||
const prev = [...items];
|
||||
errorMessage = '';
|
||||
items = items.filter((i) => i.id !== item.id);
|
||||
await moveFocusAfterRemove(idx);
|
||||
try {
|
||||
const res = await csrfFetch(`/api/geschichten/${geschichteId}/items/${item.id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) {
|
||||
items = prev;
|
||||
errorMessage = await failureMessage(res);
|
||||
return;
|
||||
}
|
||||
announce(m.geschichte_documents_removed_announce({ title: itemTitle(item) }));
|
||||
} catch (e) {
|
||||
console.error('Story document remove failed', e);
|
||||
items = prev;
|
||||
errorMessage = m.journey_mutation_error_reload();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Screen-reader live region for add/remove confirmations -->
|
||||
<div aria-live="polite" aria-atomic="true" class="sr-only">{liveAnnounce}</div>
|
||||
|
||||
<details open class="sm:contents">
|
||||
<summary
|
||||
class="flex min-h-[44px] cursor-pointer items-center px-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:hidden"
|
||||
>
|
||||
{m.geschichte_documents_heading()}
|
||||
</summary>
|
||||
<section bind:this={sectionEl} class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2
|
||||
class="mb-2 hidden font-sans text-xs font-bold tracking-widest text-ink-3 uppercase sm:block"
|
||||
>
|
||||
{m.geschichte_documents_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_hint()}</p>
|
||||
|
||||
{#if errorMessage}
|
||||
<p
|
||||
role="alert"
|
||||
class="mb-3 rounded border border-danger bg-danger/10 px-3 py-2 font-sans text-sm text-danger"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if items.length === 0}
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_documents_empty()}</p>
|
||||
{:else}
|
||||
<ul class="m-0 mb-3 flex list-none flex-col p-0">
|
||||
{#each items as item (item.id)}
|
||||
<li
|
||||
data-item-id={item.id}
|
||||
class="flex items-center justify-between gap-2 border-b border-line/60 last:border-b-0"
|
||||
>
|
||||
{#if item.document}
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink">
|
||||
{item.document.title}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="min-w-0 flex-1 truncate font-serif text-sm text-ink-3 italic">
|
||||
{m.geschichte_documents_deleted_placeholder()}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
data-remove-btn
|
||||
onclick={() => handleRemove(item)}
|
||||
aria-label={m.geschichte_documents_remove_label({ title: itemTitle(item) })}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded text-ink-3 hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<label for={pickerInputId} class="mb-1 block font-sans text-xs font-medium text-ink-2">
|
||||
{m.geschichte_documents_picker_label()}
|
||||
</label>
|
||||
<DocumentPickerDropdown
|
||||
inputId={pickerInputId}
|
||||
alreadyAddedIds={alreadyAddedIds}
|
||||
placeholder={m.geschichte_documents_picker_placeholder()}
|
||||
onSelect={handleAdd}
|
||||
/>
|
||||
</section>
|
||||
</details>
|
||||
312
frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts
Normal file
312
frontend/src/lib/geschichte/StoryDocumentPanel.svelte.spec.ts
Normal file
@@ -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<typeof docSummary>,
|
||||
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<string, unknown> = {}) => ({
|
||||
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' })
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user