feat(frontend): simplify homepage to pure dashboard — remove search/filter dual-mode
The homepage now always renders the dashboard. Search and browse moves to the dedicated /documents route (upcoming). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,85 +3,41 @@ import { createApiClient } from '$lib/api.server';
|
|||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type StatsDTO = components['schemas']['StatsDTO'];
|
type StatsDTO = components['schemas']['StatsDTO'];
|
||||||
type Document = components['schemas']['Document'];
|
|
||||||
type SearchMatchData = components['schemas']['SearchMatchData'];
|
|
||||||
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
type TranscriptionQueueItemDTO = components['schemas']['TranscriptionQueueItemDTO'];
|
||||||
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
|
type TranscriptionWeeklyStatsDTO = components['schemas']['TranscriptionWeeklyStatsDTO'];
|
||||||
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
type DashboardResumeDTO = components['schemas']['DashboardResumeDTO'];
|
||||||
type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
type DashboardPulseDTO = components['schemas']['DashboardPulseDTO'];
|
||||||
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
type ActivityFeedItemDTO = components['schemas']['ActivityFeedItemDTO'];
|
||||||
|
|
||||||
export async function load({ url, fetch }) {
|
export async function load({ fetch }) {
|
||||||
const q = url.searchParams.get('q') || '';
|
|
||||||
const from = url.searchParams.get('from') || '';
|
|
||||||
const to = url.searchParams.get('to') || '';
|
|
||||||
const senderId = url.searchParams.get('senderId') || '';
|
|
||||||
const receiverId = url.searchParams.get('receiverId') || '';
|
|
||||||
const tags = url.searchParams.getAll('tag');
|
|
||||||
const VALID_SORTS = ['DATE', 'TITLE', 'SENDER', 'RECEIVER', 'UPLOAD_DATE'] as const;
|
|
||||||
type ValidSort = (typeof VALID_SORTS)[number];
|
|
||||||
const rawSort = url.searchParams.get('sort') ?? 'DATE';
|
|
||||||
const sort: ValidSort = (VALID_SORTS as readonly string[]).includes(rawSort)
|
|
||||||
? (rawSort as ValidSort)
|
|
||||||
: 'DATE';
|
|
||||||
const VALID_DIRS = ['asc', 'desc'] as const;
|
|
||||||
type ValidDir = (typeof VALID_DIRS)[number];
|
|
||||||
const rawDir = url.searchParams.get('dir') ?? 'desc';
|
|
||||||
const dir: ValidDir = (VALID_DIRS as readonly string[]).includes(rawDir)
|
|
||||||
? (rawDir as ValidDir)
|
|
||||||
: 'desc';
|
|
||||||
const tagQ = url.searchParams.get('tagQ') || '';
|
|
||||||
const tagOp = url.searchParams.get('tagOp') === 'OR' ? 'OR' : 'AND';
|
|
||||||
|
|
||||||
const isDashboard = !q && !from && !to && !senderId && !receiverId && !tags.length && !tagQ;
|
|
||||||
|
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [docsResult, personsResult] = await Promise.all([
|
const personsResult = await api.GET('/api/persons');
|
||||||
isDashboard
|
|
||||||
? Promise.resolve(null)
|
|
||||||
: api.GET('/api/documents/search', {
|
|
||||||
params: {
|
|
||||||
query: {
|
|
||||||
q: q || undefined,
|
|
||||||
from: from || undefined,
|
|
||||||
to: to || undefined,
|
|
||||||
senderId: senderId || undefined,
|
|
||||||
receiverId: receiverId || undefined,
|
|
||||||
tag: tags.length ? tags : undefined,
|
|
||||||
tagQ: tagQ && !tags.length ? tagQ : undefined,
|
|
||||||
tagOp: tagOp === 'OR' ? 'OR' : undefined,
|
|
||||||
sort,
|
|
||||||
dir: dir || undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
api.GET('/api/persons')
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (personsResult.response.status === 401) {
|
if (personsResult.response.status === 401) {
|
||||||
throw redirect(302, '/login');
|
throw redirect(302, '/login');
|
||||||
}
|
}
|
||||||
if (docsResult && docsResult.response.status === 401) {
|
|
||||||
throw redirect(302, '/login');
|
|
||||||
}
|
|
||||||
const searchResult = docsResult?.data as {
|
|
||||||
documents?: Document[];
|
|
||||||
total?: number;
|
|
||||||
matchData?: Record<string, SearchMatchData>;
|
|
||||||
} | null;
|
|
||||||
const documents: Document[] = searchResult?.documents ?? [];
|
|
||||||
const total: number = searchResult?.total ?? 0;
|
|
||||||
const matchData: Record<string, SearchMatchData> = searchResult?.matchData ?? {};
|
|
||||||
const allPersons = (personsResult.data ?? []) as {
|
|
||||||
id: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
const senderObj = allPersons.find((p) => p.id === senderId);
|
const [
|
||||||
const receiverObj = allPersons.find((p) => p.id === receiverId);
|
statsResult,
|
||||||
|
resumeResult,
|
||||||
|
pulseResult,
|
||||||
|
activityResult,
|
||||||
|
segmentationResult,
|
||||||
|
transcriptionResult,
|
||||||
|
readyResult,
|
||||||
|
weeklyStatsResult
|
||||||
|
] = await Promise.allSettled([
|
||||||
|
api.GET('/api/stats'),
|
||||||
|
api.GET('/api/dashboard/resume'),
|
||||||
|
api.GET('/api/dashboard/pulse'),
|
||||||
|
api.GET('/api/dashboard/activity', { params: { query: { limit: 7 } } }),
|
||||||
|
api.GET('/api/transcription/segmentation-queue'),
|
||||||
|
api.GET('/api/transcription/transcription-queue'),
|
||||||
|
api.GET('/api/transcription/ready-to-read'),
|
||||||
|
api.GET('/api/transcription/weekly-stats')
|
||||||
|
]);
|
||||||
|
|
||||||
let stats: StatsDTO | null = null;
|
let stats: StatsDTO | null = null;
|
||||||
let resumeDoc: DashboardResumeDTO | null = null;
|
let resumeDoc: DashboardResumeDTO | null = null;
|
||||||
@@ -92,58 +48,32 @@ export async function load({ url, fetch }) {
|
|||||||
let readyDocs: TranscriptionQueueItemDTO[] = [];
|
let readyDocs: TranscriptionQueueItemDTO[] = [];
|
||||||
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
|
let weeklyStats: TranscriptionWeeklyStatsDTO | null = null;
|
||||||
|
|
||||||
if (isDashboard) {
|
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
||||||
const [
|
stats = statsResult.value.data ?? null;
|
||||||
statsResult,
|
}
|
||||||
resumeResult,
|
if (resumeResult.status === 'fulfilled' && resumeResult.value.response.ok) {
|
||||||
pulseResult,
|
resumeDoc = (resumeResult.value.data as DashboardResumeDTO) ?? null;
|
||||||
activityResult,
|
}
|
||||||
segmentationResult,
|
if (pulseResult.status === 'fulfilled' && pulseResult.value.response.ok) {
|
||||||
transcriptionResult,
|
pulse = (pulseResult.value.data as DashboardPulseDTO) ?? null;
|
||||||
readyResult,
|
}
|
||||||
weeklyStatsResult
|
if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) {
|
||||||
] = await Promise.allSettled([
|
activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? [];
|
||||||
api.GET('/api/stats'),
|
}
|
||||||
api.GET('/api/dashboard/resume'),
|
if (segmentationResult.status === 'fulfilled' && segmentationResult.value.response.ok) {
|
||||||
api.GET('/api/dashboard/pulse'),
|
segmentationDocs = (segmentationResult.value.data ?? []) as TranscriptionQueueItemDTO[];
|
||||||
api.GET('/api/dashboard/activity', { params: { query: { limit: 7 } } }),
|
}
|
||||||
api.GET('/api/transcription/segmentation-queue'),
|
if (transcriptionResult.status === 'fulfilled' && transcriptionResult.value.response.ok) {
|
||||||
api.GET('/api/transcription/transcription-queue'),
|
transcriptionDocs = (transcriptionResult.value.data ?? []) as TranscriptionQueueItemDTO[];
|
||||||
api.GET('/api/transcription/ready-to-read'),
|
}
|
||||||
api.GET('/api/transcription/weekly-stats')
|
if (readyResult.status === 'fulfilled' && readyResult.value.response.ok) {
|
||||||
]);
|
readyDocs = (readyResult.value.data ?? []) as TranscriptionQueueItemDTO[];
|
||||||
|
}
|
||||||
if (statsResult.status === 'fulfilled' && statsResult.value.response.ok) {
|
if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) {
|
||||||
stats = statsResult.value.data ?? null;
|
weeklyStats = weeklyStatsResult.value.data ?? null;
|
||||||
}
|
|
||||||
if (resumeResult.status === 'fulfilled' && resumeResult.value.response.ok) {
|
|
||||||
resumeDoc = (resumeResult.value.data as DashboardResumeDTO) ?? null;
|
|
||||||
}
|
|
||||||
if (pulseResult.status === 'fulfilled' && pulseResult.value.response.ok) {
|
|
||||||
pulse = (pulseResult.value.data as DashboardPulseDTO) ?? null;
|
|
||||||
}
|
|
||||||
if (activityResult.status === 'fulfilled' && activityResult.value.response.ok) {
|
|
||||||
activityFeed = (activityResult.value.data as ActivityFeedItemDTO[]) ?? [];
|
|
||||||
}
|
|
||||||
if (segmentationResult.status === 'fulfilled' && segmentationResult.value.response.ok) {
|
|
||||||
segmentationDocs = (segmentationResult.value.data ?? []) as TranscriptionQueueItemDTO[];
|
|
||||||
}
|
|
||||||
if (transcriptionResult.status === 'fulfilled' && transcriptionResult.value.response.ok) {
|
|
||||||
transcriptionDocs = (transcriptionResult.value.data ?? []) as TranscriptionQueueItemDTO[];
|
|
||||||
}
|
|
||||||
if (readyResult.status === 'fulfilled' && readyResult.value.response.ok) {
|
|
||||||
readyDocs = (readyResult.value.data ?? []) as TranscriptionQueueItemDTO[];
|
|
||||||
}
|
|
||||||
if (weeklyStatsResult.status === 'fulfilled' && weeklyStatsResult.value.response.ok) {
|
|
||||||
weeklyStats = weeklyStatsResult.value.data ?? null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDashboard,
|
|
||||||
documents,
|
|
||||||
total,
|
|
||||||
matchData,
|
|
||||||
stats,
|
stats,
|
||||||
resumeDoc,
|
resumeDoc,
|
||||||
pulse,
|
pulse,
|
||||||
@@ -152,21 +82,12 @@ export async function load({ url, fetch }) {
|
|||||||
transcriptionDocs,
|
transcriptionDocs,
|
||||||
readyDocs,
|
readyDocs,
|
||||||
weeklyStats,
|
weeklyStats,
|
||||||
initialValues: {
|
|
||||||
senderName: senderObj ? `${senderObj.firstName} ${senderObj.lastName}`.trim() : '',
|
|
||||||
receiverName: receiverObj ? `${receiverObj.firstName} ${receiverObj.lastName}`.trim() : ''
|
|
||||||
},
|
|
||||||
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp },
|
|
||||||
error: null as string | null
|
error: null as string | null
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if ((e as { status?: number }).status) throw e;
|
if ((e as { status?: number }).status) throw e;
|
||||||
console.error('Error loading data:', e);
|
console.error('Error loading data:', e);
|
||||||
return {
|
return {
|
||||||
isDashboard,
|
|
||||||
documents: [],
|
|
||||||
total: 0,
|
|
||||||
matchData: {} as Record<string, SearchMatchData>,
|
|
||||||
stats: null,
|
stats: null,
|
||||||
resumeDoc: null,
|
resumeDoc: null,
|
||||||
pulse: null,
|
pulse: null,
|
||||||
@@ -175,8 +96,6 @@ export async function load({ url, fetch }) {
|
|||||||
transcriptionDocs: [],
|
transcriptionDocs: [],
|
||||||
readyDocs: [],
|
readyDocs: [],
|
||||||
weeklyStats: null,
|
weeklyStats: null,
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
|
||||||
filters: { q, from, to, senderId, receiverId, tags, sort, dir, tagQ, tagOp },
|
|
||||||
error: 'Daten konnten nicht geladen werden.' as string | null
|
error: 'Daten konnten nicht geladen werden.' as string | null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,94 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { navigating } from '$app/state';
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { SvelteURLSearchParams } from 'svelte/reactivity';
|
|
||||||
import SearchFilterBar from './SearchFilterBar.svelte';
|
|
||||||
import DropZone from './DropZone.svelte';
|
import DropZone from './DropZone.svelte';
|
||||||
import DocumentList from './DocumentList.svelte';
|
|
||||||
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
import DashboardResumeStrip from '$lib/components/DashboardResumeStrip.svelte';
|
||||||
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
|
import MissionControlStrip from '$lib/components/MissionControlStrip.svelte';
|
||||||
import DashboardFamilyPulse from '$lib/components/DashboardFamilyPulse.svelte';
|
import DashboardFamilyPulse from '$lib/components/DashboardFamilyPulse.svelte';
|
||||||
import DashboardActivityFeed from '$lib/components/DashboardActivityFeed.svelte';
|
import DashboardActivityFeed from '$lib/components/DashboardActivityFeed.svelte';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
let q = $state(untrack(() => data.filters?.q || ''));
|
|
||||||
let qFocused = $state(false);
|
|
||||||
let from = $state(untrack(() => data.filters?.from || ''));
|
|
||||||
let to = $state(untrack(() => data.filters?.to || ''));
|
|
||||||
let senderId = $state(untrack(() => data.filters?.senderId || ''));
|
|
||||||
let receiverId = $state(untrack(() => data.filters?.receiverId || ''));
|
|
||||||
let tagNames = $state<{ name: string; id?: string; color?: string; parentId?: string }[]>(
|
|
||||||
untrack(() => (data.filters?.tags || []).map((name: string) => ({ name })))
|
|
||||||
);
|
|
||||||
let sort = $state(untrack(() => data.filters?.sort || 'DATE'));
|
|
||||||
let dir = $state(untrack(() => data.filters?.dir || 'desc'));
|
|
||||||
let tagQ = $state(untrack(() => data.filters?.tagQ || ''));
|
|
||||||
let tagOperator = $state<'AND' | 'OR'>(
|
|
||||||
untrack(() => (data.filters?.tagOp as 'AND' | 'OR') || 'AND')
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasAdvancedFilters = (filters: typeof data.filters) =>
|
|
||||||
(filters?.tags?.length ?? 0) > 0 ||
|
|
||||||
!!filters?.senderId ||
|
|
||||||
!!filters?.receiverId ||
|
|
||||||
!!filters?.from ||
|
|
||||||
!!filters?.to;
|
|
||||||
|
|
||||||
let showAdvanced = $state(untrack(() => hasAdvancedFilters(data.filters)));
|
|
||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout>;
|
|
||||||
|
|
||||||
function triggerSearch() {
|
|
||||||
const params = new SvelteURLSearchParams();
|
|
||||||
if (q) params.set('q', q);
|
|
||||||
if (from) params.set('from', from);
|
|
||||||
if (to) params.set('to', to);
|
|
||||||
if (senderId) params.set('senderId', senderId);
|
|
||||||
if (receiverId) params.set('receiverId', receiverId);
|
|
||||||
if (tagNames) tagNames.forEach((tag) => params.append('tag', tag.name));
|
|
||||||
if (sort) params.set('sort', sort);
|
|
||||||
if (dir) params.set('dir', dir);
|
|
||||||
if (tagQ) params.set('tagQ', tagQ);
|
|
||||||
if (tagOperator === 'OR') params.set('tagOp', 'OR');
|
|
||||||
goto(`/?${params.toString()}`, { keepFocus: true, noScroll: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTextSearch() {
|
|
||||||
clearTimeout(searchTimer);
|
|
||||||
searchTimer = setTimeout(() => triggerSearch(), 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImmediateSearch() {
|
|
||||||
clearTimeout(searchTimer);
|
|
||||||
triggerSearch();
|
|
||||||
}
|
|
||||||
|
|
||||||
let prevTagStr = untrack(() => tagNames.map((t) => t.name).join(','));
|
|
||||||
$effect(() => {
|
|
||||||
const cur = tagNames.map((t) => t.name).join(',');
|
|
||||||
if (cur !== prevTagStr) {
|
|
||||||
prevTagStr = cur;
|
|
||||||
triggerSearch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!qFocused) q = data.filters?.q || '';
|
|
||||||
from = data.filters?.from || '';
|
|
||||||
to = data.filters?.to || '';
|
|
||||||
senderId = data.filters?.senderId || '';
|
|
||||||
receiverId = data.filters?.receiverId || '';
|
|
||||||
tagNames = (data.filters?.tags || []).map((name: string) => ({ name }));
|
|
||||||
sort = data.filters?.sort || 'DATE';
|
|
||||||
dir = data.filters?.dir || 'desc';
|
|
||||||
tagQ = data.filters?.tagQ || '';
|
|
||||||
tagOperator = (data.filters?.tagOp as 'AND' | 'OR') || 'AND';
|
|
||||||
if (hasAdvancedFilters(data.filters)) showAdvanced = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const greetingText = $derived.by(() => {
|
const greetingText = $derived.by(() => {
|
||||||
const name = data?.user?.firstName ?? '';
|
const name = data?.user?.firstName ?? '';
|
||||||
const h = new Date().getHours();
|
const h = new Date().getHours();
|
||||||
@@ -103,68 +22,35 @@ const greetingText = $derived.by(() => {
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
|
<main class="mx-auto max-w-7xl px-4 py-8 font-sans sm:px-6 lg:px-8">
|
||||||
<SearchFilterBar
|
{#if data?.user}
|
||||||
bind:q={q}
|
<div class="mb-6">
|
||||||
bind:from={from}
|
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
|
||||||
bind:to={to}
|
|
||||||
bind:senderId={senderId}
|
|
||||||
bind:receiverId={receiverId}
|
|
||||||
bind:tagNames={tagNames}
|
|
||||||
bind:showAdvanced={showAdvanced}
|
|
||||||
bind:sort={sort}
|
|
||||||
bind:dir={dir}
|
|
||||||
bind:tagQ={tagQ}
|
|
||||||
bind:tagOperator={tagOperator}
|
|
||||||
initialSenderName={data.initialValues?.senderName}
|
|
||||||
initialReceiverName={data.initialValues?.receiverName}
|
|
||||||
isLoading={navigating.to !== null}
|
|
||||||
onSearch={handleTextSearch}
|
|
||||||
onSearchImmediate={handleImmediateSearch}
|
|
||||||
onfocus={() => (qFocused = true)}
|
|
||||||
onblur={() => (qFocused = false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if data.isDashboard}
|
|
||||||
{#if data?.user}
|
|
||||||
<div class="mb-6">
|
|
||||||
<h1 class="font-serif text-[2rem] text-ink">{greetingText}</h1>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
|
||||||
<div class="flex flex-col gap-5">
|
|
||||||
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
|
||||||
|
|
||||||
<section aria-label={m.dashboard_mission_caption()}>
|
|
||||||
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
|
||||||
{m.dashboard_mission_caption()}
|
|
||||||
</h2>
|
|
||||||
<MissionControlStrip
|
|
||||||
segmentationDocs={data.segmentationDocs ?? []}
|
|
||||||
transcriptionDocs={data.transcriptionDocs ?? []}
|
|
||||||
readyDocs={data.readyDocs ?? []}
|
|
||||||
weeklyStats={data.weeklyStats ?? null}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
|
||||||
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
|
||||||
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
|
||||||
{#if data.canWrite}
|
|
||||||
<DropZone />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<DocumentList
|
|
||||||
documents={data.documents ?? []}
|
|
||||||
canWrite={data.canWrite}
|
|
||||||
error={data.error}
|
|
||||||
total={data.total ?? 0}
|
|
||||||
q={q}
|
|
||||||
sort={sort}
|
|
||||||
matchData={data.matchData ?? {}}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
|
||||||
|
<div class="flex flex-col gap-5">
|
||||||
|
<DashboardResumeStrip resumeDoc={data.resumeDoc ?? null} />
|
||||||
|
|
||||||
|
<section aria-label={m.dashboard_mission_caption()}>
|
||||||
|
<h2 class="mb-3 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.dashboard_mission_caption()}
|
||||||
|
</h2>
|
||||||
|
<MissionControlStrip
|
||||||
|
segmentationDocs={data.segmentationDocs ?? []}
|
||||||
|
transcriptionDocs={data.transcriptionDocs ?? []}
|
||||||
|
readyDocs={data.readyDocs ?? []}
|
||||||
|
weeklyStats={data.weeklyStats ?? null}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-5 lg:sticky lg:top-[80px]">
|
||||||
|
<DashboardFamilyPulse pulse={data.pulse ?? null} />
|
||||||
|
<DashboardActivityFeed feed={data.activityFeed ?? []} />
|
||||||
|
{#if data.canWrite}
|
||||||
|
<DropZone />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -19,10 +19,42 @@ function makeUrl(params: Record<string, string | string[]> = {}) {
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── dashboard mode (no search filters) ──────────────────────────────────────
|
// ─── always-dashboard behaviour ───────────────────────────────────────────────
|
||||||
|
|
||||||
describe('home page load — dashboard mode', () => {
|
it('never calls /api/documents/search regardless of URL params', async () => {
|
||||||
it('sets isDashboard true and fetches stats, resume, pulse, and activity APIs', async () => {
|
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
await load({
|
||||||
|
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
|
||||||
|
fetch: vi.fn() as unknown as typeof fetch
|
||||||
|
});
|
||||||
|
|
||||||
|
const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]);
|
||||||
|
expect(calledEndpoints).not.toContain('/api/documents/search');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('always fetches dashboard data regardless of URL params', async () => {
|
||||||
|
const mockGet = vi.fn().mockResolvedValue({ response: { ok: true, status: 200 }, data: null });
|
||||||
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
|
typeof createApiClient
|
||||||
|
>);
|
||||||
|
|
||||||
|
await load({ url: makeUrl({ q: 'Urlaub' }), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
|
|
||||||
|
const calledEndpoints = mockGet.mock.calls.map((c: unknown[]) => c[0]);
|
||||||
|
expect(calledEndpoints).toContain('/api/stats');
|
||||||
|
expect(calledEndpoints).toContain('/api/dashboard/resume');
|
||||||
|
expect(calledEndpoints).toContain('/api/dashboard/pulse');
|
||||||
|
expect(calledEndpoints).toContain('/api/dashboard/activity');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── dashboard mode ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('home page load — dashboard', () => {
|
||||||
|
it('fetches stats, resume, pulse, and activity APIs', async () => {
|
||||||
const mockGet = vi
|
const mockGet = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||||
@@ -67,13 +99,11 @@ describe('home page load — dashboard mode', () => {
|
|||||||
|
|
||||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
|
|
||||||
expect(result.isDashboard).toBe(true);
|
|
||||||
expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
|
expect(result.stats).toEqual({ totalDocuments: 42, totalPersons: 7 });
|
||||||
expect(result.resumeDoc).not.toBeNull();
|
expect(result.resumeDoc).not.toBeNull();
|
||||||
expect(result.resumeDoc?.totalBlocks).toBe(2);
|
expect(result.resumeDoc?.totalBlocks).toBe(2);
|
||||||
expect(result.pulse).not.toBeNull();
|
expect(result.pulse).not.toBeNull();
|
||||||
expect(result.activityFeed).toEqual([]);
|
expect(result.activityFeed).toEqual([]);
|
||||||
expect(result.documents).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns stats with totalDocuments from /api/stats', async () => {
|
it('returns stats with totalDocuments from /api/stats', async () => {
|
||||||
@@ -109,8 +139,8 @@ describe('home page load — dashboard mode', () => {
|
|||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }) // persons
|
||||||
.mockRejectedValueOnce(new Error('network')) // stats
|
.mockRejectedValueOnce(new Error('network')) // stats
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // incomplete
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] }) // resume
|
||||||
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // recent
|
.mockResolvedValueOnce({ response: { ok: true }, data: [] }); // pulse
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
||||||
typeof createApiClient
|
typeof createApiClient
|
||||||
>);
|
>);
|
||||||
@@ -158,123 +188,6 @@ describe('home page load — dashboard mode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── search mode (any filter active) ─────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('home page load — search mode', () => {
|
|
||||||
it('sets isDashboard false and skips widget APIs when q is set', async () => {
|
|
||||||
const mockGet = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { documents: [{ id: 'd1' }], total: 1 }
|
|
||||||
}) // search docs
|
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
|
||||||
typeof createApiClient
|
|
||||||
>);
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
url: makeUrl({ q: 'Urlaub' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.isDashboard).toBe(false);
|
|
||||||
expect(result.documents).toHaveLength(1);
|
|
||||||
expect(result.stats).toBeNull();
|
|
||||||
expect(result.resumeDoc).toBeNull();
|
|
||||||
expect(result.activityFeed).toEqual([]);
|
|
||||||
// Only two API calls — no widget calls
|
|
||||||
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes search params from the URL to the documents API', async () => {
|
|
||||||
const mockGet = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { documents: [], total: 0 }
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
|
||||||
typeof createApiClient
|
|
||||||
>);
|
|
||||||
|
|
||||||
await load({
|
|
||||||
url: makeUrl({ q: 'Urlaub', from: '2020-01-01' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstCall = mockGet.mock.calls[0];
|
|
||||||
expect(firstCall[1].params.query.q).toBe('Urlaub');
|
|
||||||
expect(firstCall[1].params.query.from).toBe('2020-01-01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets isDashboard false when only tagQ is set', async () => {
|
|
||||||
const mockGet = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { documents: [{ id: 'd1' }], total: 1 }
|
|
||||||
}) // search docs
|
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] }); // persons
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
|
||||||
typeof createApiClient
|
|
||||||
>);
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
url: makeUrl({ tagQ: 'fam' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.isDashboard).toBe(false);
|
|
||||||
expect(result.documents).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes sort, dir, and tagQ params to the documents API', async () => {
|
|
||||||
const mockGet = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { documents: [], total: 0 }
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
|
||||||
typeof createApiClient
|
|
||||||
>);
|
|
||||||
|
|
||||||
await load({
|
|
||||||
url: makeUrl({ q: 'test', sort: 'TITLE', dir: 'asc', tagQ: 'fam' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstCall = mockGet.mock.calls[0];
|
|
||||||
expect(firstCall[1].params.query.sort).toBe('TITLE');
|
|
||||||
expect(firstCall[1].params.query.dir).toBe('asc');
|
|
||||||
expect(firstCall[1].params.query.tagQ).toBe('fam');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns total from the DocumentSearchResult envelope', async () => {
|
|
||||||
const mockGet = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { documents: [{ id: 'd1' }], total: 42 }
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ response: { ok: true, status: 200 }, data: [] });
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({ GET: mockGet } as ReturnType<
|
|
||||||
typeof createApiClient
|
|
||||||
>);
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
url: makeUrl({ q: 'test' }),
|
|
||||||
fetch: vi.fn() as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.documents).toHaveLength(1);
|
|
||||||
expect(result.total).toBe(42);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── 401 redirect ─────────────────────────────────────────────────────────────
|
// ─── 401 redirect ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('home page load — auth redirect', () => {
|
describe('home page load — auth redirect', () => {
|
||||||
@@ -300,6 +213,5 @@ describe('home page load — network error fallback', () => {
|
|||||||
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
const result = await load({ url: makeUrl(), fetch: vi.fn() as unknown as typeof fetch });
|
||||||
|
|
||||||
expect(result.error).toBe('Daten konnten nicht geladen werden.');
|
expect(result.error).toBe('Daten konnten nicht geladen werden.');
|
||||||
expect(result.documents).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,48 +3,18 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
|||||||
import { page } from 'vitest/browser';
|
import { page } from 'vitest/browser';
|
||||||
import Page from './+page.svelte';
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
afterEach(cleanup);
|
||||||
|
|
||||||
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
vi.mock('$app/navigation', () => ({ goto: vi.fn(), invalidateAll: vi.fn() }));
|
||||||
|
|
||||||
// Silence fetch calls from PersonTypeahead when advanced filters are open
|
const baseData = {
|
||||||
vi.stubGlobal(
|
user: { firstName: 'Max' },
|
||||||
'fetch',
|
|
||||||
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
|
||||||
);
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
// ─── Test data ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const emptyData = {
|
|
||||||
user: undefined,
|
|
||||||
canWrite: true,
|
canWrite: true,
|
||||||
canAnnotate: false,
|
canAnnotate: false,
|
||||||
isDashboard: false,
|
|
||||||
filters: {
|
|
||||||
q: '',
|
|
||||||
from: '',
|
|
||||||
to: '',
|
|
||||||
senderId: '',
|
|
||||||
receiverId: '',
|
|
||||||
tags: [],
|
|
||||||
sort: 'DATE' as const,
|
|
||||||
dir: 'desc' as const,
|
|
||||||
tagQ: '',
|
|
||||||
tagOp: 'AND'
|
|
||||||
},
|
|
||||||
documents: [],
|
|
||||||
total: 0,
|
|
||||||
matchData: {} as Record<
|
|
||||||
string,
|
|
||||||
import('$lib/generated/api').components['schemas']['SearchMatchData']
|
|
||||||
>,
|
|
||||||
resumeDoc: null,
|
resumeDoc: null,
|
||||||
pulse: null,
|
pulse: null,
|
||||||
activityFeed: [],
|
activityFeed: [],
|
||||||
stats: null,
|
stats: null,
|
||||||
initialValues: { senderName: '', receiverName: '' },
|
|
||||||
segmentationDocs: [],
|
segmentationDocs: [],
|
||||||
transcriptionDocs: [],
|
transcriptionDocs: [],
|
||||||
readyDocs: [],
|
readyDocs: [],
|
||||||
@@ -52,194 +22,22 @@ const emptyData = {
|
|||||||
error: null
|
error: null
|
||||||
};
|
};
|
||||||
|
|
||||||
const makeDoc = (overrides = {}) => ({
|
// ─── Dashboard layout ─────────────────────────────────────────────────────────
|
||||||
id: '1',
|
|
||||||
title: 'Testbrief',
|
|
||||||
originalFilename: 'testbrief.pdf',
|
|
||||||
status: 'UPLOADED' as const,
|
|
||||||
documentDate: '2024-03-15',
|
|
||||||
location: 'Berlin',
|
|
||||||
sender: {
|
|
||||||
id: 'p1',
|
|
||||||
firstName: 'Max',
|
|
||||||
lastName: 'Mustermann',
|
|
||||||
displayName: 'Max Mustermann',
|
|
||||||
personType: 'PERSON' as const
|
|
||||||
},
|
|
||||||
receivers: [
|
|
||||||
{
|
|
||||||
id: 'p2',
|
|
||||||
firstName: 'Anna',
|
|
||||||
lastName: 'Musterfrau',
|
|
||||||
displayName: 'Anna Musterfrau',
|
|
||||||
personType: 'PERSON' as const
|
|
||||||
}
|
|
||||||
],
|
|
||||||
tags: [{ id: 't1', name: 'Familie' }],
|
|
||||||
filePath: '/files/testbrief.pdf',
|
|
||||||
createdAt: '2024-03-15T10:00:00Z',
|
|
||||||
updatedAt: '2024-03-15T10:00:00Z',
|
|
||||||
...overrides
|
|
||||||
});
|
|
||||||
|
|
||||||
const dataWithDocs = { ...emptyData, documents: [makeDoc()] };
|
describe('Home page – dashboard layout', () => {
|
||||||
|
it('does not render a search input', async () => {
|
||||||
// ─── Search bar ───────────────────────────────────────────────────────────────
|
render(Page, { data: baseData });
|
||||||
|
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
|
||||||
describe('Home page – search bar', () => {
|
await expect.element(input).not.toBeInTheDocument();
|
||||||
it('renders the full-text search input', async () => {
|
|
||||||
render(Page, { data: emptyData });
|
|
||||||
await expect
|
|
||||||
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
await page.screenshot({ path: 'test-results/screenshots/home-default.png' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the filter toggle button', async () => {
|
it('renders a greeting for the logged-in user', async () => {
|
||||||
render(Page, { data: emptyData });
|
render(Page, { data: baseData });
|
||||||
// Use exact match to avoid collision with the empty-state "Alle Filter löschen" button
|
await expect.element(page.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||||
await expect
|
|
||||||
.element(page.getByRole('button', { name: 'Filter', exact: true }))
|
|
||||||
.toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the reset link pointing to /', async () => {
|
it('renders resume strip empty state when resumeDoc is null', async () => {
|
||||||
render(Page, { data: emptyData });
|
render(Page, { data: baseData });
|
||||||
const resetLink = page.getByTitle('Filter zurücksetzen');
|
|
||||||
await expect.element(resetLink).toBeInTheDocument();
|
|
||||||
await expect.element(resetLink).toHaveAttribute('href', '/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('pre-fills the search input from filters.q', async () => {
|
|
||||||
render(Page, { data: { ...emptyData, filters: { ...emptyData.filters, q: 'Urlaub' } } });
|
|
||||||
await expect
|
|
||||||
.element(page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026'))
|
|
||||||
.toHaveValue('Urlaub');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Advanced filters ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Home page – advanced filters', () => {
|
|
||||||
it('hides the advanced filters by default', async () => {
|
|
||||||
render(Page, { data: emptyData });
|
|
||||||
// Date inputs are inside the {#if showAdvanced} block → not in DOM
|
|
||||||
await tick();
|
|
||||||
expect(document.querySelector('input[id="from"]')).toBeNull();
|
|
||||||
expect(document.querySelector('input[id="to"]')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('toggles the advanced filter panel open on button click', async () => {
|
|
||||||
render(Page, { data: emptyData });
|
|
||||||
await page.getByRole('button', { name: 'Filter', exact: true }).click();
|
|
||||||
await tick();
|
|
||||||
expect(document.querySelector('input[id="from"]')).not.toBeNull();
|
|
||||||
expect(document.querySelector('input[id="to"]')).not.toBeNull();
|
|
||||||
await page.screenshot({ path: 'test-results/screenshots/home-filters-open.png' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collapses the advanced filter panel on second click', async () => {
|
|
||||||
render(Page, { data: emptyData });
|
|
||||||
const btn = page.getByRole('button', { name: 'Filter', exact: true });
|
|
||||||
await btn.click();
|
|
||||||
// Wait for the input to appear before clicking again
|
|
||||||
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
|
|
||||||
await btn.click();
|
|
||||||
// Wait for slide transition to finish
|
|
||||||
await expect.element(page.getByText('Schlagworte')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the tag filter section when filters are open', async () => {
|
|
||||||
render(Page, { data: emptyData });
|
|
||||||
await page.getByRole('button', { name: 'Filter', exact: true }).click();
|
|
||||||
await expect.element(page.getByText('Schlagworte')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Document list ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Home page – document list', () => {
|
|
||||||
it('shows empty state when there are no documents', async () => {
|
|
||||||
render(Page, { data: emptyData });
|
|
||||||
await expect.element(page.getByText('Keine Dokumente gefunden')).toBeInTheDocument();
|
|
||||||
await page.screenshot({ path: 'test-results/screenshots/home-empty-state.png' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a document with title, date, location, sender and receiver', async () => {
|
|
||||||
render(Page, { data: dataWithDocs });
|
|
||||||
await expect.element(page.getByText('Testbrief')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText('15. März 2024')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText('Berlin')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText('Max Mustermann')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByText('Anna Musterfrau')).toBeInTheDocument();
|
|
||||||
await page.screenshot({ path: 'test-results/screenshots/home-with-documents.png' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders a tag chip for each document tag', async () => {
|
|
||||||
render(Page, { data: dataWithDocs });
|
|
||||||
await expect.element(page.getByRole('button', { name: 'Familie' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders "Unbekannt" for sender when sender is null', async () => {
|
|
||||||
const data = { ...emptyData, documents: [makeDoc({ sender: null })] };
|
|
||||||
render(Page, { data });
|
|
||||||
await expect.element(page.getByText('Unbekannt')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders original filename when title is empty', async () => {
|
|
||||||
const data = { ...emptyData, documents: [makeDoc({ title: null })] };
|
|
||||||
render(Page, { data });
|
|
||||||
await expect.element(page.getByText('testbrief.pdf')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('links each document to its detail page', async () => {
|
|
||||||
render(Page, { data: dataWithDocs });
|
|
||||||
const link = page.getByRole('link', { name: /Testbrief/ });
|
|
||||||
await expect.element(link).toHaveAttribute('href', '/documents/1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders the "Neues Dokument" link', async () => {
|
|
||||||
render(Page, { data: emptyData });
|
|
||||||
const link = page.getByRole('link', { name: /Neues Dokument/i });
|
|
||||||
await expect.element(link).toBeInTheDocument();
|
|
||||||
await expect.element(link).toHaveAttribute('href', '/documents/new');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Keystroke preservation (issue #34) ──────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Home page – search input keystroke preservation', () => {
|
|
||||||
it('does not overwrite the search input while the user is focused and stale data arrives', async () => {
|
|
||||||
const { rerender } = render(Page, { data: emptyData });
|
|
||||||
|
|
||||||
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen\u2026');
|
|
||||||
|
|
||||||
// User types "abc" — input is focused
|
|
||||||
await input.click();
|
|
||||||
await input.fill('abc');
|
|
||||||
|
|
||||||
// Simulate a navigation completing with stale data (q='a') while the user is still typing
|
|
||||||
await rerender({ data: { ...emptyData, filters: { ...emptyData.filters, q: 'a' } } });
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
// Input must still show what the user typed, not the stale URL value
|
|
||||||
await expect.element(input).toHaveValue('abc');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Dashboard mode ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Home page – dashboard mode', () => {
|
|
||||||
const dashboardData = {
|
|
||||||
...emptyData,
|
|
||||||
isDashboard: true,
|
|
||||||
resumeDoc: null,
|
|
||||||
pulse: null,
|
|
||||||
activityFeed: []
|
|
||||||
};
|
|
||||||
|
|
||||||
it('renders empty state when resumeDoc is null', async () => {
|
|
||||||
render(Page, { data: dashboardData });
|
|
||||||
const empty = page.getByTestId('resume-strip-empty');
|
const empty = page.getByTestId('resume-strip-empty');
|
||||||
await expect.element(empty).toBeInTheDocument();
|
await expect.element(empty).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -255,53 +53,18 @@ describe('Home page – dashboard mode', () => {
|
|||||||
pct: 33,
|
pct: 33,
|
||||||
collaborators: []
|
collaborators: []
|
||||||
};
|
};
|
||||||
render(Page, { data: { ...dashboardData, resumeDoc: resume } });
|
render(Page, { data: { ...baseData, resumeDoc: resume } });
|
||||||
const strip = page.getByTestId('resume-strip');
|
const strip = page.getByTestId('resume-strip');
|
||||||
await expect.element(strip).toBeInTheDocument();
|
await expect.element(strip).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Error state ──────────────────────────────────────────────────────────────
|
it('shows drop zone when canWrite is true', async () => {
|
||||||
|
render(Page, { data: { ...baseData, canWrite: true } });
|
||||||
describe('Home page – error state', () => {
|
await expect.element(page.getByText(/Dateien auf einmal hochladen/i)).toBeInTheDocument();
|
||||||
it('shows the error message when data.error is set', async () => {
|
|
||||||
const data = { ...emptyData, error: 'Daten konnten nicht geladen werden.' };
|
|
||||||
render(Page, { data });
|
|
||||||
await expect.element(page.getByText('Daten konnten nicht geladen werden.')).toBeInTheDocument();
|
|
||||||
await page.screenshot({ path: 'test-results/screenshots/home-error.png' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Loading spinner ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Home page – loading spinner', () => {
|
|
||||||
it('does not show spinner by default', async () => {
|
|
||||||
render(Page, { data: emptyData });
|
|
||||||
const spinner = page.getByRole('status');
|
|
||||||
await expect.element(spinner).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Sort controls ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Home page – sort controls', () => {
|
|
||||||
it('pre-fills sort from filters.sort', async () => {
|
|
||||||
const data = {
|
|
||||||
...emptyData,
|
|
||||||
filters: { ...emptyData.filters, sort: 'TITLE', dir: 'asc', tagQ: '' }
|
|
||||||
};
|
|
||||||
render(Page, { data });
|
|
||||||
const select = page.getByRole('combobox');
|
|
||||||
await expect.element(select).toHaveValue('TITLE');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders direction toggle with asc indicator when dir is asc', async () => {
|
it('hides drop zone when canWrite is false', async () => {
|
||||||
const data = {
|
render(Page, { data: { ...baseData, canWrite: false } });
|
||||||
...emptyData,
|
await expect.element(page.getByText(/Dateien auf einmal hochladen/i)).not.toBeInTheDocument();
|
||||||
filters: { ...emptyData.filters, sort: 'DATE', dir: 'asc', tagQ: '' }
|
|
||||||
};
|
|
||||||
render(Page, { data });
|
|
||||||
const btn = page.getByRole('button', { name: /aufsteigend/i });
|
|
||||||
await expect.element(btn).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user