feat(search): add group headers to DocumentList by sort field

Documents sorted by DATE show year dividers, SENDER/RECEIVER sort
shows person name dividers. Dividers only appear when there are 2+
distinct groups. Multi-receiver docs appear in each receiver group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-15 07:59:02 +02:00
parent a9aa1ec924
commit e302d3d689
2 changed files with 166 additions and 94 deletions

View File

@@ -2,13 +2,16 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js'; import { m } from '$lib/paraglide/messages.js';
import { formatDate } from '$lib/utils/date'; import { formatDate } from '$lib/utils/date';
import { groupDocuments } from '$lib/utils/groupDocuments';
import GroupDivider from '$lib/components/GroupDivider.svelte';
let { let {
documents, documents,
canWrite, canWrite,
error, error,
total = 0, total = 0,
q = '' q = '',
sort
}: { }: {
documents: { documents: {
id: string; id: string;
@@ -24,7 +27,14 @@ let {
error?: string | null; error?: string | null;
total?: number; total?: number;
q?: string; q?: string;
sort?: string;
} = $props(); } = $props();
const fallbackLabel = $derived(sort === 'DATE' ? m.docs_group_undated() : m.docs_group_unknown());
const groupedDocuments = $derived.by(() =>
groupDocuments(documents, sort ?? 'DATE', fallbackLabel)
);
const showDividers = $derived(groupedDocuments.length >= 2);
</script> </script>
<!-- DOCUMENT LIST HEADER --> <!-- DOCUMENT LIST HEADER -->
@@ -57,8 +67,12 @@ let {
{error} {error}
</div> </div>
{:else if documents.length > 0} {:else if documents.length > 0}
{#each groupedDocuments as group (group.label)}
{#if showDividers}
<GroupDivider label={group.label} />
{/if}
<ul class="divide-y divide-line-2"> <ul class="divide-y divide-line-2">
{#each documents as doc (doc.id)} {#each group.documents as doc (doc.id)}
<li class="group transition-colors duration-200 hover:bg-muted/50"> <li class="group transition-colors duration-200 hover:bg-muted/50">
<a href="/documents/{doc.id}" class="block p-6"> <a href="/documents/{doc.id}" class="block p-6">
<div class="flex flex-col gap-6 sm:flex-row"> <div class="flex flex-col gap-6 sm:flex-row">
@@ -158,6 +172,7 @@ let {
</li> </li>
{/each} {/each}
</ul> </ul>
{/each}
{:else} {:else}
<!-- Empty State --> <!-- Empty State -->
<div class="p-16 text-center"> <div class="p-16 text-center">

View File

@@ -1,10 +1,12 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi, afterEach } from 'vitest';
import { render } from 'vitest-browser-svelte'; import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import DocumentList from './DocumentList.svelte'; import DocumentList from './DocumentList.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
afterEach(() => cleanup());
const baseProps = { const baseProps = {
documents: [], documents: [],
canWrite: false, canWrite: false,
@@ -13,7 +15,14 @@ const baseProps = {
q: '' q: ''
}; };
const makeDoc = () => ({ type DocOverrides = {
id?: string;
documentDate?: string | null;
sender?: { firstName?: string | null; lastName: string; displayName: string } | null;
receivers?: { firstName?: string | null; lastName: string; displayName: string }[];
};
const makeDoc = (overrides: DocOverrides = {}) => ({
id: '1', id: '1',
title: 'Testbrief', title: 'Testbrief',
originalFilename: 'testbrief.pdf', originalFilename: 'testbrief.pdf',
@@ -21,8 +30,9 @@ const makeDoc = () => ({
documentDate: '2024-03-15', documentDate: '2024-03-15',
location: null, location: null,
sender: null, sender: null,
receivers: [], receivers: [] as { firstName?: string | null; lastName: string; displayName: string }[],
tags: [] tags: [],
...overrides
}); });
describe('DocumentList result count', () => { describe('DocumentList result count', () => {
@@ -49,3 +59,50 @@ describe('DocumentList empty state with search term', () => {
await expect.element(page.getByText(/"Urlaub"/)).toBeInTheDocument(); await expect.element(page.getByText(/"Urlaub"/)).toBeInTheDocument();
}); });
}); });
// ─── Group headers ────────────────────────────────────────────────────────────
describe('DocumentList group headers', () => {
it('renders group-divider elements when DATE sort spans multiple years', async () => {
const documents = [
makeDoc({ id: '1', documentDate: '1923-04-12' }),
makeDoc({ id: '2', documentDate: '1965-08-03' })
];
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' });
await expect.element(page.getByTestId('group-divider').first()).toBeInTheDocument();
});
it('does not render group-divider when DATE sort has only one distinct year', async () => {
const documents = [
makeDoc({ id: '1', documentDate: '1938-01-01' }),
makeDoc({ id: '2', documentDate: '1938-06-15' })
];
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'DATE' });
await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument();
});
it('does not render group-divider for TITLE sort', async () => {
const documents = [
makeDoc({ id: '1', documentDate: '1923-04-12' }),
makeDoc({ id: '2', documentDate: '1965-08-03' })
];
render(DocumentList, { ...baseProps, documents, total: 2, sort: 'TITLE' });
await expect.element(page.getByTestId('group-divider')).not.toBeInTheDocument();
});
it('a doc with two receivers appears in both receiver groups', async () => {
const documents = [
makeDoc({
id: '1',
receivers: [
{ firstName: null, lastName: 'Müller', displayName: 'Anna Müller' },
{ firstName: null, lastName: 'Bauer', displayName: 'Karl Bauer' }
]
})
];
render(DocumentList, { ...baseProps, documents, total: 1, sort: 'RECEIVER' });
const links = page.getByRole('link', { name: /Testbrief/ });
await expect.element(links.first()).toBeInTheDocument();
await expect.element(links.nth(1)).toBeInTheDocument();
});
});