feat(dashboard): Classic Split — remove notification widget, restructure into 2-col layout #172
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
@@ -31,3 +31,6 @@ src/lib/paraglide
|
||||
# src/lib/generated/api.ts
|
||||
src/lib/paraglide_bak*
|
||||
/coverage
|
||||
|
||||
# Playwright auth state — regenerated at the start of each CI run via auth.setup.ts
|
||||
e2e/.auth/
|
||||
|
||||
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
29
frontend/e2e/dashboard-classic-split.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { login } from './helpers/auth';
|
||||
|
||||
/**
|
||||
* Classic Split layout — verifies the right column visibility guard.
|
||||
*
|
||||
* The right column (DropZone + NeedsMetadata queue) is only rendered when
|
||||
* `canWrite === true` or there are incomplete docs. A read-only user with a
|
||||
* complete archive must never see an empty 300px ghost column.
|
||||
*/
|
||||
|
||||
test.describe('Dashboard Classic Split — write user', () => {
|
||||
test('right column is visible for admin user', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByTestId('dashboard-right-column')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Dashboard Classic Split — read-only user', () => {
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await login(page, 'reader', 'reader123');
|
||||
});
|
||||
|
||||
test('right column is absent for read-only user with no incomplete docs', async ({ page }) => {
|
||||
await expect(page.getByTestId('dashboard-right-column')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -80,8 +80,7 @@ test.describe('Password reset', () => {
|
||||
await page.locator('input[name="currentPassword"]').fill(newPassword);
|
||||
await page.locator('input[name="newPassword"]').fill(originalPassword);
|
||||
await page.locator('input[name="confirmPassword"]').fill(originalPassword);
|
||||
// Profile page has two "Speichern" buttons — the password form is the last one
|
||||
await page.locator('button[type="submit"]').last().click();
|
||||
await page.getByTestId('submit-password').click();
|
||||
// After changing password, auth_token is stale → redirect to login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"dashboard_needs_metadata_heading": "Metadaten fehlen",
|
||||
"dashboard_needs_metadata_show_all": "Alle anzeigen",
|
||||
"dashboard_recent_heading": "Zuletzt aktiv",
|
||||
"dashboard_stats_documents": "Dokumente",
|
||||
"dashboard_stats_persons": "Personen",
|
||||
"dashboard_resume_label": "Zuletzt geöffnet:",
|
||||
"dashboard_resume_fallback": "Unbekanntes Dokument",
|
||||
"doc_status_placeholder": "Platzhalter",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"dashboard_needs_metadata_heading": "Missing Metadata",
|
||||
"dashboard_needs_metadata_show_all": "Show all",
|
||||
"dashboard_recent_heading": "Recent Activity",
|
||||
"dashboard_stats_documents": "Documents",
|
||||
"dashboard_stats_persons": "Persons",
|
||||
"dashboard_resume_label": "Last opened:",
|
||||
"dashboard_resume_fallback": "Unknown document",
|
||||
"doc_status_placeholder": "Placeholder",
|
||||
|
||||
@@ -375,6 +375,8 @@
|
||||
"dashboard_needs_metadata_heading": "Metadatos incompletos",
|
||||
"dashboard_needs_metadata_show_all": "Ver todos",
|
||||
"dashboard_recent_heading": "Actividad reciente",
|
||||
"dashboard_stats_documents": "Documentos",
|
||||
"dashboard_stats_persons": "Personas",
|
||||
"dashboard_resume_label": "Último abierto:",
|
||||
"dashboard_resume_fallback": "Documento desconocido",
|
||||
"doc_status_placeholder": "Marcador",
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
referenceId?: string;
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
mentions: NotificationDTO[];
|
||||
}
|
||||
|
||||
let { mentions }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if mentions.length > 0}
|
||||
<div data-testid="dashboard-mentions" class="rounded-sm border border-line bg-surface p-6">
|
||||
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-gray-400 uppercase">
|
||||
{m.dashboard_notifications_heading()}
|
||||
</h2>
|
||||
<div>
|
||||
{#each mentions as mention (mention.id)}
|
||||
<div class="flex items-center gap-3 border-b border-line py-2 last:border-0">
|
||||
{#if mention.documentId}
|
||||
<a
|
||||
href={mention.annotationId
|
||||
? `/documents/${mention.documentId}?commentId=${mention.referenceId}&annotationId=${mention.annotationId}`
|
||||
: `/documents/${mention.documentId}?commentId=${mention.referenceId}`}
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
>{mention.actorName ?? ''}</a
|
||||
>
|
||||
<span class="font-sans text-xs text-gray-400">
|
||||
{mention.type === 'MENTION'
|
||||
? m.dashboard_notification_mentioned()
|
||||
: m.dashboard_notification_replied()}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="font-serif text-lg text-ink">{mention.actorName ?? ''}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-4 border-t border-line pt-4">
|
||||
<a
|
||||
href="/notifications"
|
||||
class="text-sm font-medium text-ink-2 transition-colors hover:text-ink"
|
||||
>{m.notification_history_view_link()}</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
|
||||
import DashboardMentions from './DashboardMentions.svelte';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
type NotificationDTO = {
|
||||
id: string;
|
||||
type: 'REPLY' | 'MENTION';
|
||||
documentId?: string;
|
||||
referenceId?: string;
|
||||
annotationId?: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
actorName?: string;
|
||||
};
|
||||
|
||||
function makeMention(overrides: Partial<NotificationDTO> = {}): NotificationDTO {
|
||||
return {
|
||||
id: 'notif-1',
|
||||
type: 'MENTION',
|
||||
documentId: 'doc-abc',
|
||||
referenceId: 'comment-xyz',
|
||||
read: false,
|
||||
createdAt: '2026-01-15T10:00:00Z',
|
||||
actorName: 'Anna Schmidt',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('DashboardMentions', () => {
|
||||
it('renders nothing when mentions list is empty', async () => {
|
||||
render(DashboardMentions, { mentions: [] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a heading when mentions are present', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention()] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds link with commentId param when no annotationId', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: 'doc-1', referenceId: 'cmt-1' })]
|
||||
});
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toHaveAttribute('href', '/documents/doc-1?commentId=cmt-1');
|
||||
});
|
||||
|
||||
it('builds link with commentId and annotationId when annotationId is present', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: 'doc-2', referenceId: 'cmt-2', annotationId: 'ann-9' })]
|
||||
});
|
||||
const link = page.getByRole('link');
|
||||
await expect
|
||||
.element(link)
|
||||
.toHaveAttribute('href', '/documents/doc-2?commentId=cmt-2&annotationId=ann-9');
|
||||
});
|
||||
|
||||
it('shows actor name in each row', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ actorName: 'Maria Müller' })] });
|
||||
await expect.element(page.getByText('Maria Müller')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "replied" label for REPLY type', async () => {
|
||||
render(DashboardMentions, { mentions: [makeMention({ type: 'REPLY' })] });
|
||||
const widget = page.getByTestId('dashboard-mentions');
|
||||
await expect.element(widget).toBeInTheDocument();
|
||||
const link = page.getByRole('link');
|
||||
await expect.element(link).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a span instead of a link when documentId is absent', async () => {
|
||||
render(DashboardMentions, {
|
||||
mentions: [makeMention({ documentId: undefined, actorName: 'Lena Bauer' })]
|
||||
});
|
||||
await expect.element(page.getByText('Lena Bauer')).toBeInTheDocument();
|
||||
const links = page.getByRole('link');
|
||||
await expect.element(links).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type Document = {
|
||||
id: string;
|
||||
@@ -9,11 +10,14 @@ type Document = {
|
||||
sender?: { id: string; firstName: string; lastName: string };
|
||||
};
|
||||
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
|
||||
interface Props {
|
||||
recentDocs: Document[];
|
||||
stats?: StatsDTO | null;
|
||||
}
|
||||
|
||||
let { recentDocs }: Props = $props();
|
||||
let { recentDocs, stats = null }: Props = $props();
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
// updatedAt is a full ISO datetime — no T12:00:00 noon-anchor needed here
|
||||
@@ -31,7 +35,10 @@ function formatDate(dateStr: string): string {
|
||||
{m.dashboard_recent_heading()}
|
||||
</h2>
|
||||
{#each recentDocs as doc (doc.id)}
|
||||
<div class="flex items-center justify-between border-b border-line py-2 last:border-0">
|
||||
<div
|
||||
data-testid="doc-row-{doc.id}"
|
||||
class="flex min-h-[44px] items-center justify-between border-b border-line py-2 last:border-0"
|
||||
>
|
||||
<a
|
||||
href="/documents/{doc.id}"
|
||||
class="font-serif text-lg text-ink hover:text-ink-2 hover:underline"
|
||||
@@ -48,5 +55,12 @@ function formatDate(dateStr: string): string {
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if stats?.totalDocuments != null}
|
||||
<p data-testid="dashboard-stats-footnote" class="mt-4 font-sans text-sm text-ink-3">
|
||||
{stats.totalDocuments}
|
||||
{m.dashboard_stats_documents()} · {stats.totalPersons}
|
||||
{m.dashboard_stats_persons()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -55,3 +55,40 @@ describe('DashboardRecentDocuments', () => {
|
||||
await expect.element(dateEl).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardRecentDocuments — stats footnote', () => {
|
||||
it('renders stats footnote when stats.totalDocuments is provided', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: { totalDocuments: 248, totalPersons: 34 }
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits stats footnote when stats is null', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: null
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "0 Documents" when totalDocuments is 0', async () => {
|
||||
render(DashboardRecentDocuments, {
|
||||
recentDocs: [makeDoc('d1', 'Taufschein')],
|
||||
stats: { totalDocuments: 0, totalPersons: 0 }
|
||||
});
|
||||
const footnote = page.getByTestId('dashboard-stats-footnote');
|
||||
await expect.element(footnote).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashboardRecentDocuments — touch targets', () => {
|
||||
it('each doc row has min-h-[44px] class for WCAG touch target', async () => {
|
||||
render(DashboardRecentDocuments, { recentDocs: [makeDoc('d1', 'Taufschein')] });
|
||||
const row = page.getByTestId('doc-row-d1');
|
||||
await expect.element(row).toHaveClass('min-h-[44px]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,63 @@ export function germanToIso(german: string): string {
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a raw date string into German DD.MM.YYYY format.
|
||||
*
|
||||
* Handles two modes:
|
||||
* - Pure digit stream (no dots): auto-inserts dots after position 2 and 4
|
||||
* - Manual dot entry: preserves user-typed dots, pads single-digit day/month,
|
||||
* and overflows extra digits from day→month and month→year
|
||||
*/
|
||||
export function formatGermanDateInput(raw: string): string {
|
||||
if (!raw.includes('.')) {
|
||||
const digits = raw.replace(/\D/g, '').slice(0, 8);
|
||||
if (digits.length <= 2) return digits;
|
||||
if (digits.length <= 4) return `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
return `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
|
||||
const trailingDot = raw.endsWith('.');
|
||||
const parts = raw.split('.').map((p) => p.replace(/\D/g, ''));
|
||||
|
||||
let day = parts[0] ?? '';
|
||||
let month = parts[1] ?? '';
|
||||
let year = parts[2] ?? '';
|
||||
|
||||
let dayOverflowed = false;
|
||||
if (day.length > 2) {
|
||||
month = day.slice(2) + month;
|
||||
day = day.slice(0, 2);
|
||||
dayOverflowed = true;
|
||||
}
|
||||
|
||||
let monthOverflowed = false;
|
||||
if (month.length > 2) {
|
||||
year = month.slice(2) + year;
|
||||
month = month.slice(0, 2);
|
||||
monthOverflowed = true;
|
||||
}
|
||||
|
||||
year = year.slice(0, 4);
|
||||
|
||||
const afterDay = !dayOverflowed && parts.length >= 2;
|
||||
|
||||
if (day.length === 1 && (month || (trailingDot && !dayOverflowed))) {
|
||||
day = '0' + day;
|
||||
}
|
||||
if (month.length === 1 && (year || (trailingDot && afterDay && !monthOverflowed))) {
|
||||
month = '0' + month;
|
||||
}
|
||||
|
||||
if (year) return `${day}.${month}.${year}`;
|
||||
if (month) {
|
||||
const dot2 = trailingDot && afterDay && !monthOverflowed ? '.' : '';
|
||||
return `${day}.${month}${dot2}`;
|
||||
}
|
||||
const dot1 = trailingDot && !dayOverflowed ? '.' : '';
|
||||
return `${day}${dot1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a date input event for German-format date fields (DD.MM.YYYY).
|
||||
* Strips non-digits, formats with dots, mutates the input's displayed value,
|
||||
@@ -38,15 +95,7 @@ export function germanToIso(german: string): string {
|
||||
*/
|
||||
export function handleGermanDateInput(e: Event): { display: string; iso: string } {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const digits = input.value.replace(/\D/g, '').slice(0, 8);
|
||||
let display: string;
|
||||
if (digits.length <= 2) {
|
||||
display = digits;
|
||||
} else if (digits.length <= 4) {
|
||||
display = `${digits.slice(0, 2)}.${digits.slice(2)}`;
|
||||
} else {
|
||||
display = `${digits.slice(0, 2)}.${digits.slice(2, 4)}.${digits.slice(4)}`;
|
||||
}
|
||||
const display = formatGermanDateInput(input.value);
|
||||
input.value = display;
|
||||
return { display, iso: germanToIso(display) };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createApiClient } from '$lib/api.server';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type IncompleteDocumentDTO = components['schemas']['IncompleteDocumentDTO'];
|
||||
type NotificationDTO = components['schemas']['NotificationDTO'];
|
||||
type StatsDTO = components['schemas']['StatsDTO'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
export async function load({ url, fetch }) {
|
||||
@@ -55,19 +55,19 @@ export async function load({ url, fetch }) {
|
||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
||||
|
||||
// Dashboard widgets — failures are isolated and don't crash the page
|
||||
let mentions: NotificationDTO[] = [];
|
||||
let stats: StatsDTO | null = null;
|
||||
let incompleteDocs: IncompleteDocumentDTO[] = [];
|
||||
let recentDocs: Document[] = [];
|
||||
|
||||
if (isDashboard) {
|
||||
const [mentionsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
||||
api.GET('/api/notifications', { params: { query: { size: 5 } } }),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 5 } } }),
|
||||
const [statsResult, incompleteResult, recentResult] = await Promise.allSettled([
|
||||
api.GET('/api/stats'),
|
||||
api.GET('/api/documents/incomplete', { params: { query: { size: 3 } } }),
|
||||
api.GET('/api/documents/recent-activity', { params: { query: { size: 5 } } })
|
||||
]);
|
||||
|
||||
if (mentionsResult.status === 'fulfilled' && mentionsResult.value.response.ok) {
|
||||
mentions = mentionsResult.value.data?.content ?? [];
|
||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||
stats = statsResult.value.data ?? null;
|
||||
}
|
||||
if (incompleteResult.status === 'fulfilled' && incompleteResult.value.response.ok) {
|
||||
incompleteDocs = incompleteResult.value.data ?? [];
|
||||
@@ -80,7 +80,7 @@ export async function load({ url, fetch }) {
|
||||
return {
|
||||
isDashboard,
|
||||
documents,
|
||||
mentions,
|
||||
stats,
|
||||
incompleteDocs,
|
||||
recentDocs,
|
||||
initialValues: {
|
||||
@@ -96,7 +96,7 @@ export async function load({ url, fetch }) {
|
||||
return {
|
||||
isDashboard,
|
||||
documents: [],
|
||||
mentions: [],
|
||||
stats: null,
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
|
||||
@@ -6,7 +6,6 @@ import SearchFilterBar from './SearchFilterBar.svelte';
|
||||
import DropZone from './DropZone.svelte';
|
||||
import DocumentList from './DocumentList.svelte';
|
||||
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
||||
import DashboardMentions from '$lib/components/DashboardMentions.svelte';
|
||||
import DashboardNeedsMetadata from '$lib/components/DashboardNeedsMetadata.svelte';
|
||||
import DashboardRecentDocuments from '$lib/components/DashboardRecentDocuments.svelte';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
@@ -69,6 +68,11 @@ $effect(() => {
|
||||
tagNames = data.filters?.tags || [];
|
||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
||||
});
|
||||
|
||||
// Right column is only rendered when there is something to show.
|
||||
// Omitting it prevents an empty 300px ghost column for read-only users
|
||||
// with a complete archive.
|
||||
const showRightColumn = $derived(data.canWrite || (data.incompleteDocs?.length ?? 0) > 0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -94,20 +98,25 @@ $effect(() => {
|
||||
{#if data.isDashboard}
|
||||
<DashboardResumeStrip />
|
||||
|
||||
{#if data.canWrite}
|
||||
<div class="mt-4">
|
||||
<DropZone />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Classic Split: right column first in DOM so it appears above recent docs on mobile.
|
||||
lg:order-last moves it back to the visual right on desktop. -->
|
||||
<!-- No items-start — CSS Grid stretch default makes both columns equal height -->
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 {showRightColumn ? 'lg:grid-cols-[1fr_300px]' : ''}">
|
||||
{#if showRightColumn}
|
||||
<div data-testid="dashboard-right-column" class="flex h-full flex-col gap-4 lg:order-last">
|
||||
{#if data.canWrite}
|
||||
<DropZone />
|
||||
{/if}
|
||||
<!-- flex-1 + min-h-0 fills remaining height after DropZone.
|
||||
min-h-0 overrides the default min-height:auto that prevents flex
|
||||
children from shrinking below their content size. -->
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="mt-6 grid gap-4 {(data.mentions?.length ?? 0) > 0 && (data.incompleteDocs?.length ?? 0) > 0 ? 'lg:grid-cols-2' : ''}"
|
||||
>
|
||||
<DashboardMentions mentions={data.mentions ?? []} />
|
||||
<DashboardNeedsMetadata incompleteDocs={data.incompleteDocs ?? []} />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} />
|
||||
<DashboardRecentDocuments recentDocs={data.recentDocs ?? []} stats={data.stats} />
|
||||
</div>
|
||||
{:else}
|
||||
<DocumentList documents={data.documents ?? []} canWrite={data.canWrite} error={data.error} />
|
||||
|
||||
@@ -109,10 +109,12 @@ describe('GroupsListPanel — collapse toggle', () => {
|
||||
});
|
||||
|
||||
it('persists collapse state using the groups-specific localStorage key', async () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(GroupsListPanel, { groups });
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true');
|
||||
await vi.waitFor(() =>
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_groups_list_collapsed', 'true')
|
||||
);
|
||||
setSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,10 +88,12 @@ describe('TagsListPanel — collapse toggle', () => {
|
||||
});
|
||||
|
||||
it('persists collapse state using the tags-specific localStorage key', async () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(TagsListPanel, { tags });
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true');
|
||||
await vi.waitFor(() =>
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_tags_list_collapsed', 'true')
|
||||
);
|
||||
setSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,10 +131,12 @@ describe('UsersListPanel — collapse toggle', () => {
|
||||
});
|
||||
|
||||
it('persists collapse state using the users-specific localStorage key', async () => {
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
render(UsersListPanel, { users });
|
||||
const setSpy = vi.spyOn(Storage.prototype, 'setItem');
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="Liste einklappen"]')!.click();
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true');
|
||||
await vi.waitFor(() =>
|
||||
expect(setSpy).toHaveBeenCalledWith('admin_users_list_collapsed', 'true')
|
||||
);
|
||||
setSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,9 +48,9 @@ const withDocs = {
|
||||
// ─── Empty state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Conversations page – empty state', () => {
|
||||
it('shows the "select two persons" prompt when no persons are selected', async () => {
|
||||
it('shows the empty-state heading when no persons are selected', async () => {
|
||||
render(Page, { data: baseData });
|
||||
await expect.element(page.getByText(/Wählen Sie zwei Personen aus/i)).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Korrespondenz durchsuchen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the swap button when no persons are selected', async () => {
|
||||
|
||||
@@ -10,7 +10,9 @@ afterEach(cleanup);
|
||||
describe('Login page – rendering', () => {
|
||||
it('renders the page title', async () => {
|
||||
render(LoginPage, {});
|
||||
await expect.element(page.getByRole('link', { name: 'Familienarchiv' })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: 'Familienarchiv' }).first())
|
||||
.toBeInTheDocument();
|
||||
await page.screenshot({ path: 'test-results/screenshots/login-default.png' });
|
||||
});
|
||||
|
||||
|
||||
@@ -22,14 +22,14 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
|
||||
// ─── dashboard mode (no search filters) ──────────────────────────────────────
|
||||
|
||||
describe('home page load — dashboard mode', () => {
|
||||
it('sets isDashboard true and fetches all three widget APIs', async () => {
|
||||
it('sets isDashboard true and fetches stats, incomplete, and recent APIs', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { content: [{ id: 'n1' }] }
|
||||
}) // notifications
|
||||
data: { totalDocuments: 42, totalPersons: 7 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd1' }] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [{ id: 'd2' }] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
@@ -39,17 +39,20 @@ describe('home page load — dashboard mode', () => {
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.isDashboard).toBe(true);
|
||||
expect(result.mentions).toHaveLength(1);
|
||||
expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
|
||||
expect(result.incompleteDocs).toHaveLength(1);
|
||||
expect(result.recentDocs).toHaveLength(1);
|
||||
expect(result.documents).toEqual([]);
|
||||
});
|
||||
|
||||
it('defaults mentions to [] when notifications API rejects', async () => {
|
||||
it('returns stats with totalDocuments from /api/stats', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockRejectedValueOnce(new Error('network')) // notifications
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { totalDocuments: 248, totalPersons: 34 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
@@ -58,7 +61,24 @@ describe('home page load — dashboard mode', () => {
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.mentions).toEqual([]);
|
||||
expect(result.stats?.totalDocuments).toBe(248);
|
||||
expect(result.stats?.totalPersons).toBe(34);
|
||||
});
|
||||
|
||||
it('returns stats: null when /api/stats rejects', async () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockRejectedValueOnce(new Error('network')) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
typeof createApiClient
|
||||
>);
|
||||
|
||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||
|
||||
expect(result.stats).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults incompleteDocs to [] when incomplete API rejects', async () => {
|
||||
@@ -81,7 +101,10 @@ describe('home page load — dashboard mode', () => {
|
||||
const mockGet = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: { content: [] } }) // notifications
|
||||
.mockResolvedValueOnce({
|
||||
response: { ok: true },
|
||||
data: { totalDocuments: 0, totalPersons: 0 }
|
||||
}) // stats
|
||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
||||
.mockRejectedValueOnce(new Error('network')); // recent
|
||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||
@@ -113,7 +136,7 @@ describe('home page load — search mode', () => {
|
||||
|
||||
expect(result.isDashboard).toBe(false);
|
||||
expect(result.documents).toHaveLength(1);
|
||||
expect(result.mentions).toEqual([]);
|
||||
expect(result.stats).toBeNull();
|
||||
expect(result.incompleteDocs).toEqual([]);
|
||||
expect(result.recentDocs).toEqual([]);
|
||||
// Only two API calls — no widget calls
|
||||
|
||||
@@ -21,8 +21,12 @@ const emptyData = {
|
||||
user: undefined,
|
||||
canWrite: true,
|
||||
canAnnotate: false,
|
||||
isDashboard: false,
|
||||
filters: { q: '', from: '', to: '', senderId: '', receiverId: '', tags: [] },
|
||||
documents: [],
|
||||
incompleteDocs: [],
|
||||
recentDocs: [],
|
||||
stats: null,
|
||||
incompleteCount: 0,
|
||||
initialValues: { senderName: '', receiverName: '' },
|
||||
error: null
|
||||
@@ -189,6 +193,42 @@ describe('Home page – search input keystroke preservation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Dashboard mode ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – dashboard mode', () => {
|
||||
const dashboardData = {
|
||||
...emptyData,
|
||||
isDashboard: true,
|
||||
canWrite: false,
|
||||
incompleteDocs: [],
|
||||
recentDocs: []
|
||||
};
|
||||
|
||||
it('hides the right column when canWrite is false and incompleteDocs is empty', async () => {
|
||||
render(Page, { data: dashboardData });
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the right column when canWrite is true', async () => {
|
||||
render(Page, { data: { ...dashboardData, canWrite: true } });
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the right column when incompleteDocs is non-empty', async () => {
|
||||
render(Page, {
|
||||
data: {
|
||||
...dashboardData,
|
||||
canWrite: false,
|
||||
incompleteDocs: [{ id: 'd1', title: 'Taufschein' }]
|
||||
}
|
||||
});
|
||||
const rightCol = page.getByTestId('dashboard-right-column');
|
||||
await expect.element(rightCol).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error state ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Home page – error state', () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('Persons page – rendering', () => {
|
||||
|
||||
it('shows alias in italic when provided', async () => {
|
||||
render(Page, { data: { ...emptyData, persons: [makePerson({ alias: 'Maxi' })] } });
|
||||
await expect.element(page.getByText('"Maxi"')).toBeInTheDocument();
|
||||
await expect.element(page.getByText('„Maxi"')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows life date range when birthYear is provided', async () => {
|
||||
|
||||
@@ -70,6 +70,7 @@ let {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="submit-password"
|
||||
class="mt-5 rounded-sm bg-primary px-5 py-2 font-sans text-xs font-bold tracking-widest text-primary-fg uppercase transition-opacity hover:opacity-80"
|
||||
>
|
||||
{m.btn_save()}
|
||||
|
||||
Reference in New Issue
Block a user