refactor(document): migrate frontend from DocumentSearchItem to flat DocumentListItem

All components, specs, and the generated API client now use the new
DocumentListItem shape — flat access (item.title, item.sender) instead of
the removed item.document.* nesting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-22 18:20:59 +02:00
parent 41b205becc
commit 6583226d79
8 changed files with 193 additions and 199 deletions

View File

@@ -5,7 +5,7 @@ import { clickOutside } from '$lib/shared/actions/clickOutside';
import { formatDate } from '$lib/shared/utils/date'; import { formatDate } from '$lib/shared/utils/date';
type Document = components['schemas']['Document']; type Document = components['schemas']['Document'];
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
interface Props { interface Props {
selectedDocuments?: Document[]; selectedDocuments?: Document[];
@@ -45,8 +45,8 @@ function handleInput() {
try { try {
const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`); const res = await fetch(`/api/documents/search?q=${encodeURIComponent(searchTerm)}&size=10`);
if (res.ok) { if (res.ok) {
const body: { items: DocumentSearchItem[] } = await res.json(); const body: { items: DocumentListItem[] } = await res.json();
const docs = body.items.map((it) => it.document); const docs = body.items as unknown as Document[];
results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id)); results = docs.filter((d) => !selectedDocuments.some((s) => s.id === d.id));
} }
} catch { } catch {

View File

@@ -9,11 +9,11 @@ import ProgressRing from '$lib/shared/primitives/ProgressRing.svelte';
import ContributorStack from '$lib/shared/primitives/ContributorStack.svelte'; import ContributorStack from '$lib/shared/primitives/ContributorStack.svelte';
import DocumentThumbnail from './DocumentThumbnail.svelte'; import DocumentThumbnail from './DocumentThumbnail.svelte';
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
let { item, canWrite = false }: { item: DocumentSearchItem; canWrite?: boolean } = $props(); let { item, canWrite = false }: { item: DocumentListItem; canWrite?: boolean } = $props();
const doc = $derived(item.document); const doc = $derived(item);
const titleText = $derived(doc.title || doc.originalFilename); const titleText = $derived(doc.title || doc.originalFilename);
const titleOffsets = $derived(item.matchData?.titleOffsets ?? []); const titleOffsets = $derived(item.matchData?.titleOffsets ?? []);
const titleSegments = $derived(applyOffsets(titleText, titleOffsets)); const titleSegments = $derived(applyOffsets(titleText, titleOffsets));

View File

@@ -14,24 +14,17 @@ afterEach(() => {
bulkSelectionStore.clear(); bulkSelectionStore.clear();
}); });
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem { function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
return { return {
document: {
id: '1', id: '1',
title: 'Testbrief', title: 'Testbrief',
originalFilename: 'testbrief.pdf', originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15', documentDate: '2024-03-15',
sender: null, sender: undefined,
receivers: [], receivers: [],
tags: [], tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: { matchData: {
titleOffsets: [], titleOffsets: [],
senderMatched: false, senderMatched: false,
@@ -55,14 +48,14 @@ describe('DocumentRow title', () => {
}); });
it('falls back to originalFilename when title is null', async () => { it('falls back to originalFilename when title is null', async () => {
const item = makeItem({ document: { ...makeItem().document, title: null } }); const item = makeItem({ title: null as unknown as string });
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument(); await expect.element(page.getByRole('heading', { name: 'testbrief.pdf' })).toBeInTheDocument();
}); });
it('renders a mark element for highlighted title offsets', async () => { it('renders a mark element for highlighted title offsets', async () => {
const item = makeItem({ const item = makeItem({
document: { ...makeItem().document, title: 'Brief an Anna' }, title: 'Brief an Anna',
matchData: { matchData: {
titleOffsets: [{ start: 0, length: 5 }], titleOffsets: [{ start: 0, length: 5 }],
senderMatched: false, senderMatched: false,
@@ -109,9 +102,12 @@ describe('DocumentRow snippet', () => {
describe('DocumentRow sender', () => { describe('DocumentRow sender', () => {
it('shows sender display name', async () => { it('shows sender display name', async () => {
const item = makeItem({ const item = makeItem({
document: { sender: {
...makeItem().document, id: 's1',
sender: { id: 's1', displayName: 'Großmutter Maria' } lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
} }
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
@@ -126,9 +122,12 @@ describe('DocumentRow sender', () => {
it('highlights the sender when senderMatched is true', async () => { it('highlights the sender when senderMatched is true', async () => {
const item = makeItem({ const item = makeItem({
document: { sender: {
...makeItem().document, id: 's1',
sender: { id: 's1', displayName: 'Großmutter Maria' } lastName: 'Maria',
displayName: 'Großmutter Maria',
personType: 'PERSON',
familyMember: false
}, },
matchData: { matchData: {
...makeItem().matchData, ...makeItem().matchData,
@@ -142,10 +141,15 @@ describe('DocumentRow sender', () => {
it('highlights a receiver when matchedReceiverIds includes its id', async () => { it('highlights a receiver when matchedReceiverIds includes its id', async () => {
const item = makeItem({ const item = makeItem({
document: { receivers: [
...makeItem().document, {
receivers: [{ id: 'r1', displayName: 'Onkel Karl' }] id: 'r1',
}, lastName: 'Karl',
displayName: 'Onkel Karl',
personType: 'PERSON',
familyMember: false
}
],
matchData: { matchData: {
...makeItem().matchData, ...makeItem().matchData,
matchedReceiverIds: ['r1'] matchedReceiverIds: ['r1']
@@ -162,10 +166,7 @@ describe('DocumentRow sender', () => {
describe('DocumentRow summary', () => { describe('DocumentRow summary', () => {
it('renders the document summary when present', async () => { it('renders the document summary when present', async () => {
const item = makeItem({ const item = makeItem({
document: {
...makeItem().document,
summary: 'Brief von Eugenie über die Heimreise aus dem Süden.' summary: 'Brief von Eugenie über die Heimreise aus dem Süden.'
}
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect await expect
@@ -180,7 +181,7 @@ describe('DocumentRow summary', () => {
it('applies summary search-match highlight via summaryOffsets', async () => { it('applies summary search-match highlight via summaryOffsets', async () => {
const item = makeItem({ const item = makeItem({
document: { ...makeItem().document, summary: 'Brief über Menton' }, summary: 'Brief über Menton',
matchData: { matchData: {
...makeItem().matchData, ...makeItem().matchData,
summaryOffsets: [{ start: 11, length: 6 }] summaryOffsets: [{ start: 11, length: 6 }]
@@ -196,25 +197,19 @@ describe('DocumentRow summary', () => {
describe('DocumentRow archive chips', () => { describe('DocumentRow archive chips', () => {
it('renders the archive box chip when set', async () => { it('renders the archive box chip when set', async () => {
const item = makeItem({ const item = makeItem({ archiveBox: 'K3' });
document: { ...makeItem().document, archiveBox: 'K3' }
});
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByText('K3')).toBeInTheDocument(); await expect.element(page.getByText('K3')).toBeInTheDocument();
}); });
it('renders the archive folder chip when set', async () => { it('renders the archive folder chip when set', async () => {
const item = makeItem({ const item = makeItem({ archiveFolder: 'Mappe A' });
document: { ...makeItem().document, archiveFolder: 'Mappe A' }
});
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByText('Mappe A')).toBeInTheDocument(); await expect.element(page.getByText('Mappe A')).toBeInTheDocument();
}); });
it('renders the location chip when meta_location is set', async () => { it('renders the location chip when meta_location is set', async () => {
const item = makeItem({ const item = makeItem({ location: 'Berlin' });
document: { ...makeItem().document, location: 'Berlin' }
});
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByText('Berlin')).toBeInTheDocument(); await expect.element(page.getByText('Berlin')).toBeInTheDocument();
}); });
@@ -225,10 +220,7 @@ describe('DocumentRow archive chips', () => {
describe('DocumentRow tags', () => { describe('DocumentRow tags', () => {
it('renders tag buttons', async () => { it('renders tag buttons', async () => {
const item = makeItem({ const item = makeItem({
document: { tags: [{ id: 't1', name: 'Familie' }]
...makeItem().document,
tags: [{ id: 't1', name: 'Familie', color: null, parentId: null }]
}
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument(); await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
@@ -236,10 +228,7 @@ describe('DocumentRow tags', () => {
it('navigates to /documents?tag=… on tag click', async () => { it('navigates to /documents?tag=… on tag click', async () => {
const item = makeItem({ const item = makeItem({
document: { tags: [{ id: 't1', name: 'Urlaub & Reise' }]
...makeItem().document,
tags: [{ id: 't1', name: 'Urlaub & Reise', color: null, parentId: null }]
}
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
// Tailwind CSS isn't loaded in the vitest-browser client project, so the // Tailwind CSS isn't loaded in the vitest-browser client project, so the
@@ -255,10 +244,7 @@ describe('DocumentRow tags', () => {
it('tag click does not navigate to the document detail page', async () => { it('tag click does not navigate to the document detail page', async () => {
const item = makeItem({ const item = makeItem({
document: { tags: [{ id: 't2', name: 'Familie' }]
...makeItem().document,
tags: [{ id: 't2', name: 'Familie', color: null, parentId: null }]
}
}); });
render(DocumentRow, { item }); render(DocumentRow, { item });
const before = window.location.href; const before = window.location.href;
@@ -281,7 +267,7 @@ describe('DocumentRow bulk selection checkbox', () => {
}); });
it('checkbox aria-label includes the document title', async () => { it('checkbox aria-label includes the document title', async () => {
const item = makeItem({ document: { ...makeItem().document, title: 'Brief an Anna' } }); const item = makeItem({ title: 'Brief an Anna' });
render(DocumentRow, { item, canWrite: true }); render(DocumentRow, { item, canWrite: true });
await expect await expect
.element(page.getByRole('checkbox', { name: /Brief an Anna/i })) .element(page.getByRole('checkbox', { name: /Brief an Anna/i }))
@@ -289,7 +275,7 @@ describe('DocumentRow bulk selection checkbox', () => {
}); });
it('toggling the checkbox calls bulkSelectionStore.toggle', async () => { it('toggling the checkbox calls bulkSelectionStore.toggle', async () => {
const item = makeItem({ document: { ...makeItem().document, id: 'doc-42' } }); const item = makeItem({ id: 'doc-42' });
render(DocumentRow, { item, canWrite: true }); render(DocumentRow, { item, canWrite: true });
expect(bulkSelectionStore.has('doc-42')).toBe(false); expect(bulkSelectionStore.has('doc-42')).toBe(false);
@@ -300,7 +286,7 @@ describe('DocumentRow bulk selection checkbox', () => {
it('checked state mirrors the store', async () => { it('checked state mirrors the store', async () => {
bulkSelectionStore.add('doc-99'); bulkSelectionStore.add('doc-99');
const item = makeItem({ document: { ...makeItem().document, id: 'doc-99' } }); const item = makeItem({ id: 'doc-99' });
render(DocumentRow, { item, canWrite: true }); render(DocumentRow, { item, canWrite: true });
await expect.element(page.getByRole('checkbox')).toBeChecked(); await expect.element(page.getByRole('checkbox')).toBeChecked();
}); });

View File

@@ -20,10 +20,31 @@ const { default: DocumentRow } = await import('./DocumentRow.svelte');
afterEach(cleanup); afterEach(cleanup);
const sender = { id: 's1', displayName: 'Anna Schmidt' }; const sender = {
const receiver = { id: 'r1', displayName: 'Bert Meier' }; id: 's1',
lastName: 'Schmidt',
displayName: 'Anna Schmidt',
personType: 'PERSON' as const,
familyMember: false
};
const receiver = {
id: 'r1',
lastName: 'Meier',
displayName: 'Bert Meier',
personType: 'PERSON' as const,
familyMember: false
};
const makeDoc = (overrides: Record<string, unknown> = {}) => ({ const emptyMatchData = {
titleOffsets: [],
senderMatched: false,
matchedReceiverIds: [],
matchedTagIds: [],
snippetOffsets: [],
summaryOffsets: []
};
const baseItem = (overrides: Record<string, unknown> = {}) => ({
id: 'd1', id: 'd1',
title: 'Brief 1923', title: 'Brief 1923',
originalFilename: 'b.pdf', originalFilename: 'b.pdf',
@@ -31,20 +52,14 @@ const makeDoc = (overrides: Record<string, unknown> = {}) => ({
sender, sender,
receivers: [receiver], receivers: [receiver],
tags: [], tags: [],
thumbnailUrl: null, summary: undefined,
contentType: 'application/pdf', archiveBox: undefined,
summary: null, archiveFolder: undefined,
archiveBox: null, location: undefined,
archiveFolder: null, matchData: emptyMatchData,
location: null,
...overrides
});
const baseItem = (docOverrides: Record<string, unknown> = {}) => ({
document: makeDoc(docOverrides),
matchData: null,
completionPercentage: 0, completionPercentage: 0,
contributors: [] contributors: [],
...overrides
}); });
describe('DocumentRow', () => { describe('DocumentRow', () => {
@@ -121,12 +136,9 @@ describe('DocumentRow', () => {
it('renders the snippet when matchData provides a transcriptionSnippet', async () => { it('renders the snippet when matchData provides a transcriptionSnippet', async () => {
render(DocumentRow, { render(DocumentRow, {
props: { props: {
item: { item: baseItem({
document: makeDoc(), matchData: { ...emptyMatchData, transcriptionSnippet: 'Hello world snippet' }
matchData: { transcriptionSnippet: 'Hello world snippet' }, })
completionPercentage: 50,
contributors: []
}
} }
}); });

View File

@@ -2068,12 +2068,20 @@ export interface components {
}; };
ImportStatus: { ImportStatus: {
/** @enum {string} */ /** @enum {string} */
state?: "IDLE" | "RUNNING" | "DONE" | "FAILED"; state: "IDLE" | "RUNNING" | "DONE" | "FAILED";
statusCode?: string; statusCode: string;
/** Format: int32 */ /** Format: int32 */
processed?: number; processed: number;
skippedFiles: components["schemas"]["SkippedFile"][];
/** Format: date-time */ /** Format: date-time */
startedAt?: string; startedAt?: string;
/** Format: int32 */
skipped?: number;
};
SkippedFile: {
filename: string;
/** @enum {string} */
reason: "INVALID_FILENAME_PATH_TRAVERSAL" | "INVALID_PDF_SIGNATURE" | "FILE_READ_ERROR" | "ALREADY_EXISTS" | "S3_UPLOAD_FAILED";
}; };
BackfillStatus: { BackfillStatus: {
/** @enum {string} */ /** @enum {string} */
@@ -2197,10 +2205,10 @@ export interface components {
totalStories: number; totalStories: number;
}; };
PersonSummaryDTO: { PersonSummaryDTO: {
title?: string;
/** Format: uuid */ /** Format: uuid */
id?: string; id?: string;
displayName?: string; displayName?: string;
title?: string;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
/** Format: int64 */ /** Format: int64 */
@@ -2307,14 +2315,14 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
totalPages?: number; totalPages?: number;
pageable?: components["schemas"]["PageableObject"]; pageable?: components["schemas"]["PageableObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */ /** Format: int32 */
size?: number; size?: number;
content?: components["schemas"]["NotificationDTO"][]; content?: components["schemas"]["NotificationDTO"][];
/** Format: int32 */ /** Format: int32 */
number?: number; number?: number;
sort?: components["schemas"]["SortObject"]; sort?: components["schemas"]["SortObject"];
first?: boolean;
last?: boolean;
/** Format: int32 */ /** Format: int32 */
numberOfElements?: number; numberOfElements?: number;
empty?: boolean; empty?: boolean;
@@ -2380,15 +2388,28 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
totalPages?: number; totalPages?: number;
}; };
DocumentSearchItem: { DocumentListItem: {
document: components["schemas"]["Document"]; /** Format: uuid */
matchData: components["schemas"]["SearchMatchData"]; id: string;
title: string;
originalFilename: string;
thumbnailUrl?: string;
/** Format: date */
documentDate?: string;
sender?: components["schemas"]["Person"];
receivers: components["schemas"]["Person"][];
tags: components["schemas"]["Tag"][];
archiveBox?: string;
archiveFolder?: string;
location?: string;
summary?: string;
/** Format: int32 */ /** Format: int32 */
completionPercentage: number; completionPercentage: number;
contributors: components["schemas"]["ActivityActorDTO"][]; contributors: components["schemas"]["ActivityActorDTO"][];
matchData: components["schemas"]["SearchMatchData"];
}; };
DocumentSearchResult: { DocumentSearchResult: {
items: components["schemas"]["DocumentSearchItem"][]; items: components["schemas"]["DocumentListItem"][];
/** Format: int64 */ /** Format: int64 */
totalElements: number; totalElements: number;
/** Format: int32 */ /** Format: int32 */

View File

@@ -5,7 +5,7 @@ import DocumentRow from '$lib/document/DocumentRow.svelte';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import type { components } from '$lib/generated/api'; import type { components } from '$lib/generated/api';
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE'; type SortMode = 'DATE' | 'TITLE' | 'SENDER' | 'RECEIVER' | 'UPLOAD_DATE' | 'RELEVANCE';
@@ -17,7 +17,7 @@ let {
q = '', q = '',
sort = 'DATE' sort = 'DATE'
}: { }: {
items: DocumentSearchItem[]; items: DocumentListItem[];
canWrite: boolean; canWrite: boolean;
error?: string | null; error?: string | null;
total?: number; total?: number;
@@ -31,10 +31,10 @@ const groups = $derived.by(() => {
return groupByYear(items); return groupByYear(items);
}); });
function groupByYear(docItems: DocumentSearchItem[]) { function groupByYear(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>(); const map = new SvelteMap<string, DocumentListItem[]>();
for (const item of docItems) { for (const item of docItems) {
const label = item.document.documentDate?.substring(0, 4) ?? m.docs_group_undated(); const label = item.documentDate?.substring(0, 4) ?? m.docs_group_undated();
const bucket = map.get(label); const bucket = map.get(label);
if (bucket) bucket.push(item); if (bucket) bucket.push(item);
else map.set(label, [item]); else map.set(label, [item]);
@@ -42,10 +42,10 @@ function groupByYear(docItems: DocumentSearchItem[]) {
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
} }
function groupBySender(docItems: DocumentSearchItem[]) { function groupBySender(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>(); const map = new SvelteMap<string, DocumentListItem[]>();
for (const item of docItems) { for (const item of docItems) {
const label = item.document.sender?.displayName ?? m.docs_group_unknown_sender(); const label = item.sender?.displayName ?? m.docs_group_unknown_sender();
const bucket = map.get(label); const bucket = map.get(label);
if (bucket) bucket.push(item); if (bucket) bucket.push(item);
else map.set(label, [item]); else map.set(label, [item]);
@@ -53,10 +53,10 @@ function groupBySender(docItems: DocumentSearchItem[]) {
return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems })); return Array.from(map.entries()).map(([label, groupItems]) => ({ label, items: groupItems }));
} }
function groupByReceiver(docItems: DocumentSearchItem[]) { function groupByReceiver(docItems: DocumentListItem[]) {
const map = new SvelteMap<string, DocumentSearchItem[]>(); const map = new SvelteMap<string, DocumentListItem[]>();
for (const item of docItems) { for (const item of docItems) {
const receivers = item.document.receivers ?? []; const receivers = item.receivers ?? [];
const labels = const labels =
receivers.length > 0 receivers.length > 0
? receivers.map((r) => r.displayName) ? receivers.map((r) => r.displayName)
@@ -99,7 +99,7 @@ function groupByReceiver(docItems: DocumentSearchItem[]) {
> >
</div> </div>
<ul class="divide-y divide-line"> <ul class="divide-y divide-line">
{#each group.items as item (group.label + '-' + item.document.id)} {#each group.items as item (group.label + '-' + item.id)}
<DocumentRow item={item} canWrite={canWrite} /> <DocumentRow item={item} canWrite={canWrite} />
{/each} {/each}
</ul> </ul>

View File

@@ -8,24 +8,17 @@ vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => cleanup()); afterEach(() => cleanup());
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
function makeItem(overrides: Partial<DocumentSearchItem> = {}): DocumentSearchItem { function makeItem(overrides: Partial<DocumentListItem> = {}): DocumentListItem {
return { return {
document: {
id: '1', id: '1',
title: 'Testbrief', title: 'Testbrief',
originalFilename: 'testbrief.pdf', originalFilename: 'testbrief.pdf',
status: 'UPLOADED',
documentDate: '2024-03-15', documentDate: '2024-03-15',
sender: undefined, sender: undefined,
receivers: [], receivers: [],
tags: [], tags: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
metadataComplete: false,
scriptType: 'UNKNOWN'
},
matchData: { matchData: {
titleOffsets: [], titleOffsets: [],
senderMatched: false, senderMatched: false,
@@ -75,8 +68,8 @@ describe('DocumentList empty state', () => {
describe('DocumentList year grouping', () => { describe('DocumentList year grouping', () => {
it('groups documents by year into separate cards', async () => { it('groups documents by year into separate cards', async () => {
const items = [ const items = [
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1923-04-12' } }), makeItem({ id: '1', documentDate: '1923-04-12' }),
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1965-08-03' } }) makeItem({ id: '2', documentDate: '1965-08-03' })
]; ];
render(DocumentList, { ...baseProps, items, total: 2 }); render(DocumentList, { ...baseProps, items, total: 2 });
const groupCards = page.getByTestId('group-card'); const groupCards = page.getByTestId('group-card');
@@ -85,17 +78,15 @@ describe('DocumentList year grouping', () => {
}); });
it('uses undated label for items with no documentDate', async () => { it('uses undated label for items with no documentDate', async () => {
const items = [ const items = [makeItem({ id: '1', documentDate: undefined })];
makeItem({ document: { ...makeItem().document, id: '1', documentDate: undefined } })
];
render(DocumentList, { ...baseProps, items, total: 1 }); render(DocumentList, { ...baseProps, items, total: 1 });
await expect.element(page.getByText('Undatiert')).toBeInTheDocument(); await expect.element(page.getByText('Undatiert')).toBeInTheDocument();
}); });
it('single year renders one group-card', async () => { it('single year renders one group-card', async () => {
const items = [ const items = [
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '1938-01-01' } }), makeItem({ id: '1', documentDate: '1938-01-01' }),
makeItem({ document: { ...makeItem().document, id: '2', documentDate: '1938-06-15' } }) makeItem({ id: '2', documentDate: '1938-06-15' })
]; ];
render(DocumentList, { ...baseProps, items, total: 2 }); render(DocumentList, { ...baseProps, items, total: 2 });
const groupCards = page.getByTestId('group-card'); const groupCards = page.getByTestId('group-card');
@@ -108,9 +99,7 @@ describe('DocumentList year grouping', () => {
describe('DocumentList sort fallback', () => { describe('DocumentList sort fallback', () => {
it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => { it('falls back to year grouping when sort is not SENDER or RECEIVER', async () => {
const items = [ const items = [makeItem({ id: '1', documentDate: '2024-03-15' })];
makeItem({ document: { ...makeItem().document, id: '1', documentDate: '2024-03-15' } })
];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'TITLE' });
await expect await expect
.element(page.getByTestId('group-header').filter({ hasText: '2024' })) .element(page.getByTestId('group-header').filter({ hasText: '2024' }))
@@ -124,8 +113,6 @@ describe('DocumentList sender grouping', () => {
it('groups by sender displayName when sort is SENDER', async () => { it('groups by sender displayName when sort is SENDER', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: {
...makeItem().document,
id: '1', id: '1',
sender: { sender: {
id: 's1', id: 's1',
@@ -134,11 +121,8 @@ describe('DocumentList sender grouping', () => {
personType: 'PERSON', personType: 'PERSON',
familyMember: false familyMember: false
} }
}
}), }),
makeItem({ makeItem({
document: {
...makeItem().document,
id: '2', id: '2',
sender: { sender: {
id: 's2', id: 's2',
@@ -147,7 +131,6 @@ describe('DocumentList sender grouping', () => {
personType: 'PERSON', personType: 'PERSON',
familyMember: false familyMember: false
} }
}
}) })
]; ];
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' }); render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
@@ -167,10 +150,7 @@ describe('DocumentList sender grouping', () => {
personType: 'PERSON' as const, personType: 'PERSON' as const,
familyMember: false familyMember: false
}; };
const items = [ const items = [makeItem({ id: '1', sender }), makeItem({ id: '2', sender })];
makeItem({ document: { ...makeItem().document, id: '1', sender } }),
makeItem({ document: { ...makeItem().document, id: '2', sender } })
];
render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' }); render(DocumentList, { ...baseProps, items, total: 2, sort: 'SENDER' });
const cards = page.getByTestId('group-card'); const cards = page.getByTestId('group-card');
await expect.element(cards.first()).toBeInTheDocument(); await expect.element(cards.first()).toBeInTheDocument();
@@ -178,7 +158,7 @@ describe('DocumentList sender grouping', () => {
}); });
it('places items with no sender under fallback label', async () => { it('places items with no sender under fallback label', async () => {
const items = [makeItem({ document: { ...makeItem().document, id: '1', sender: undefined } })]; const items = [makeItem({ id: '1', sender: undefined })];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'SENDER' });
await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument(); await expect.element(page.getByText('Unbekannter Absender')).toBeInTheDocument();
}); });
@@ -190,8 +170,6 @@ describe('DocumentList receiver grouping', () => {
it('groups by receiver displayName when sort is RECEIVER', async () => { it('groups by receiver displayName when sort is RECEIVER', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: {
...makeItem().document,
id: '1', id: '1',
receivers: [ receivers: [
{ {
@@ -202,7 +180,6 @@ describe('DocumentList receiver grouping', () => {
familyMember: false familyMember: false
} }
] ]
}
}) })
]; ];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
@@ -214,8 +191,6 @@ describe('DocumentList receiver grouping', () => {
it('duplicates a document into each receiver group', async () => { it('duplicates a document into each receiver group', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: {
...makeItem().document,
id: '1', id: '1',
title: 'Rundbriefchen', title: 'Rundbriefchen',
receivers: [ receivers: [
@@ -234,7 +209,6 @@ describe('DocumentList receiver grouping', () => {
familyMember: false familyMember: false
} }
] ]
}
}) })
]; ];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
@@ -249,7 +223,7 @@ describe('DocumentList receiver grouping', () => {
}); });
it('places items with no receivers under fallback label', async () => { it('places items with no receivers under fallback label', async () => {
const items = [makeItem({ document: { ...makeItem().document, id: '1', receivers: [] } })]; const items = [makeItem({ id: '1', receivers: [] })];
render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' }); render(DocumentList, { ...baseProps, items, total: 1, sort: 'RECEIVER' });
await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument(); await expect.element(page.getByText('Unbekannter Empfänger')).toBeInTheDocument();
}); });
@@ -261,7 +235,7 @@ describe('DocumentList DocumentRow delegation', () => {
it('shows transcription snippet when matchData has one', async () => { it('shows transcription snippet when matchData has one', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: { ...makeItem().document, id: 'doc1' }, id: 'doc1',
matchData: { matchData: {
transcriptionSnippet: 'Er schrieb einen langen Brief', transcriptionSnippet: 'Er schrieb einen langen Brief',
titleOffsets: [], titleOffsets: [],
@@ -278,7 +252,7 @@ describe('DocumentList DocumentRow delegation', () => {
}); });
it('does not render snippet when matchData has no transcription snippet', async () => { it('does not render snippet when matchData has no transcription snippet', async () => {
const items = [makeItem({ document: { ...makeItem().document, id: 'doc1' } })]; const items = [makeItem({ id: 'doc1' })];
render(DocumentList, { ...baseProps, items, total: 1 }); render(DocumentList, { ...baseProps, items, total: 1 });
await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument(); await expect.element(page.getByTestId('search-snippet')).not.toBeInTheDocument();
}); });
@@ -286,7 +260,8 @@ describe('DocumentList DocumentRow delegation', () => {
it('renders mark for title highlight when titleOffsets present', async () => { it('renders mark for title highlight when titleOffsets present', async () => {
const items = [ const items = [
makeItem({ makeItem({
document: { ...makeItem().document, id: 'doc1', title: 'Brief an Anna' }, id: 'doc1',
title: 'Brief an Anna',
matchData: { matchData: {
titleOffsets: [{ start: 0, length: 5 }], // "Brief" titleOffsets: [{ start: 0, length: 5 }], // "Brief"
senderMatched: false, senderMatched: false,

View File

@@ -20,7 +20,7 @@ async function resolvePersonName(
} }
} }
type DocumentSearchItem = components['schemas']['DocumentSearchItem']; type DocumentListItem = components['schemas']['DocumentListItem'];
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const; const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE', 'RELEVANCE'] as const;
type ValidSort = (typeof VALID_SORTS)[number]; type ValidSort = (typeof VALID_SORTS)[number];
@@ -77,7 +77,7 @@ export async function load({ url, fetch }) {
]); ]);
} catch { } catch {
return { return {
items: [] as DocumentSearchItem[], items: [] as DocumentListItem[],
totalElements: 0, totalElements: 0,
pageNumber: 0, pageNumber: 0,
pageSize: PAGE_SIZE, pageSize: PAGE_SIZE,
@@ -107,7 +107,7 @@ export async function load({ url, fetch }) {
: null; : null;
return { return {
items: (result.data?.items ?? []) as DocumentSearchItem[], items: (result.data?.items ?? []) as DocumentListItem[],
totalElements: result.data?.totalElements ?? 0, totalElements: result.data?.totalElements ?? 0,
pageNumber: result.data?.pageNumber ?? page, pageNumber: result.data?.pageNumber ?? page,
pageSize: result.data?.pageSize ?? PAGE_SIZE, pageSize: result.data?.pageSize ?? PAGE_SIZE,