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:
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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: []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user