feat(topbar): add expandable metadata drawer with Details toggle (#175)
- DocumentMetadataDrawer: 3-column grid (≥1024px), single-column mobile
Shows document date, location, status, person cards, tag chips
Person names link to /persons/{id}, tags link to filtered search
Empty states for missing persons/tags, receiver cap with expand button
- DocumentTopBar: "Details" toggle button with animated SVG chevron
44×44px tap target, aria-expanded, Svelte slide transition
Semantic color tokens for dark mode compatibility
- Remove DocumentBottomPanel from document detail page
Bottom panel replaced by topbar drawer for metadata access
Simplify +page.server.ts (remove comments loading)
Update page.server.spec.ts for new load signature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,47 +0,0 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
|
||||||
import { page } from 'vitest/browser';
|
|
||||||
import DocumentBottomPanel from './DocumentBottomPanel.svelte';
|
|
||||||
import type { Comment } from '$lib/types';
|
|
||||||
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function makeComment(id: string): Comment {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
authorId: 'user-1',
|
|
||||||
authorName: 'Alice',
|
|
||||||
content: 'Hello',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
replies: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = { id: 'doc-1', title: 'Test' };
|
|
||||||
|
|
||||||
const baseProps = {
|
|
||||||
doc,
|
|
||||||
canComment: true,
|
|
||||||
currentUserId: 'user-1',
|
|
||||||
canAdmin: false,
|
|
||||||
height: 300,
|
|
||||||
activeTab: 'discussion' as const
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('DocumentBottomPanel – discussion badge', () => {
|
|
||||||
it('always shows a badge on the Discussion tab', async () => {
|
|
||||||
render(DocumentBottomPanel, { ...baseProps, comments: [], open: true });
|
|
||||||
await expect.element(page.getByTestId('discussion-count-badge')).toBeInTheDocument();
|
|
||||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows the correct count when comments exist', async () => {
|
|
||||||
render(DocumentBottomPanel, {
|
|
||||||
...baseProps,
|
|
||||||
comments: [makeComment('c-1'), makeComment('c-2')],
|
|
||||||
open: true
|
|
||||||
});
|
|
||||||
await expect.element(page.getByTestId('discussion-count-badge')).toHaveTextContent('2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
146
frontend/src/lib/components/DocumentMetadataDrawer.svelte
Normal file
146
frontend/src/lib/components/DocumentMetadataDrawer.svelte
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { formatDate } from '$lib/utils/date';
|
||||||
|
import { formatDocumentStatus } from '$lib/utils/documentStatusLabel';
|
||||||
|
import { personAvatarColor } from '$lib/utils/personFormat';
|
||||||
|
|
||||||
|
type Person = { id: string; firstName: string; lastName: string };
|
||||||
|
type Tag = { id: string; name: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
documentDate: string | null;
|
||||||
|
location: string | null;
|
||||||
|
status: string;
|
||||||
|
sender: Person | null;
|
||||||
|
receivers: Person[];
|
||||||
|
tags: Tag[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { documentDate, location, status, sender, receivers, tags }: Props = $props();
|
||||||
|
|
||||||
|
const VISIBLE_RECEIVER_LIMIT = 5;
|
||||||
|
|
||||||
|
const formattedDate = $derived(documentDate ? formatDate(documentDate) : '—');
|
||||||
|
const displayLocation = $derived(location ?? '—');
|
||||||
|
const statusLabel = $derived(formatDocumentStatus(status));
|
||||||
|
const visibleReceivers = $derived(receivers.slice(0, VISIBLE_RECEIVER_LIMIT));
|
||||||
|
const hiddenReceiverCount = $derived(Math.max(0, receivers.length - VISIBLE_RECEIVER_LIMIT));
|
||||||
|
const hasPersons = $derived(sender !== null || receivers.length > 0);
|
||||||
|
const hasTags = $derived(tags.length > 0);
|
||||||
|
|
||||||
|
let showAllReceivers = $state(false);
|
||||||
|
|
||||||
|
const displayedReceivers = $derived(showAllReceivers ? receivers : visibleReceivers);
|
||||||
|
|
||||||
|
function getInitials(person: Person): string {
|
||||||
|
return `${person.firstName.charAt(0)}${person.lastName.charAt(0)}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFullName(person: Person): string {
|
||||||
|
return `${person.firstName} ${person.lastName}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet personCard(person: Person)}
|
||||||
|
<a
|
||||||
|
href="/persons/{person.id}"
|
||||||
|
class="group flex items-center gap-2.5 rounded px-2 py-1.5 transition-colors hover:bg-muted"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-bold text-white"
|
||||||
|
style="background-color: {personAvatarColor(person.id)}"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{getInitials(person)}
|
||||||
|
</span>
|
||||||
|
<span class="font-serif text-sm text-ink">{getFullName(person)}</span>
|
||||||
|
</a>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="border-b border-line p-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<!-- Column 1: Details -->
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.doc_details_section_details()}
|
||||||
|
</h2>
|
||||||
|
<dl class="space-y-3 font-serif text-sm">
|
||||||
|
<div>
|
||||||
|
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_date()}</dt>
|
||||||
|
<dd class="text-ink">{formattedDate}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-sans text-xs font-medium text-ink-3">{m.form_label_location()}</dt>
|
||||||
|
<dd class="text-ink">{displayLocation}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_status()}</dt>
|
||||||
|
<dd class="text-ink">{statusLabel}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column 2: Personen -->
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.doc_details_section_persons()}
|
||||||
|
</h2>
|
||||||
|
{#if hasPersons}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#if sender}
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||||
|
{m.doc_details_field_sender()}
|
||||||
|
</p>
|
||||||
|
{@render personCard(sender)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if receivers.length > 0}
|
||||||
|
<div>
|
||||||
|
<p class="mb-1 font-sans text-xs font-medium text-ink-3">
|
||||||
|
{m.doc_details_field_receivers()}
|
||||||
|
</p>
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
{#each displayedReceivers as receiver (receiver.id)}
|
||||||
|
{@render personCard(receiver)}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if hiddenReceiverCount > 0 && !showAllReceivers}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (showAllReceivers = true)}
|
||||||
|
class="mt-1 px-2 font-sans text-xs font-medium text-ink-2 transition-colors hover:text-ink"
|
||||||
|
>
|
||||||
|
{m.doc_details_more_receivers({ count: hiddenReceiverCount })}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_persons()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Column 3: Schlagwoerter -->
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-4 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
|
{m.doc_details_section_tags()}
|
||||||
|
</h2>
|
||||||
|
{#if hasTags}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{#each tags as tag (tag.id)}
|
||||||
|
<a
|
||||||
|
href="/?tag={encodeURIComponent(tag.name)}"
|
||||||
|
class="rounded bg-muted px-2 py-0.5 text-xs font-bold tracking-wide text-ink uppercase transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="font-serif text-sm text-ink-3">{m.doc_details_no_tags()}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
import { formatDate } from '$lib/utils/personFormat';
|
import { formatDate } from '$lib/utils/personFormat';
|
||||||
import { clickOutside } from '$lib/actions/clickOutside';
|
import { clickOutside } from '$lib/actions/clickOutside';
|
||||||
import PersonChipRow from './PersonChipRow.svelte';
|
import PersonChipRow from './PersonChipRow.svelte';
|
||||||
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
import AnnotateHintStrip from './AnnotateHintStrip.svelte';
|
||||||
import OverflowPillButton from './OverflowPillButton.svelte';
|
import OverflowPillButton from './OverflowPillButton.svelte';
|
||||||
|
import DocumentMetadataDrawer from './DocumentMetadataDrawer.svelte';
|
||||||
|
|
||||||
type Person = { id: string; firstName: string; lastName: string };
|
type Person = { id: string; firstName: string; lastName: string };
|
||||||
|
type Tag = { id: string; name: string };
|
||||||
|
|
||||||
type Doc = {
|
type Doc = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -17,6 +20,9 @@ type Doc = {
|
|||||||
receivers?: Person[] | null;
|
receivers?: Person[] | null;
|
||||||
filePath?: string | null;
|
filePath?: string | null;
|
||||||
contentType?: string | null;
|
contentType?: string | null;
|
||||||
|
location?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
tags?: Tag[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -29,6 +35,8 @@ type Props = {
|
|||||||
|
|
||||||
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
|
let { doc, canWrite, canAnnotate, fileUrl, annotateMode = $bindable() }: Props = $props();
|
||||||
|
|
||||||
|
let detailsOpen = $state(false);
|
||||||
|
|
||||||
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
const isPdf = $derived(!!doc.filePath && doc.contentType?.startsWith('application/pdf'));
|
||||||
const receivers = $derived(doc.receivers ?? []);
|
const receivers = $derived(doc.receivers ?? []);
|
||||||
const extraCount = $derived(Math.max(0, receivers.length - 2));
|
const extraCount = $derived(Math.max(0, receivers.length - 2));
|
||||||
@@ -155,6 +163,27 @@ let mobileMenuOpen = $state(false);
|
|||||||
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
|
<OverflowPillButton extraCount={extraCount} persons={overflowPersons} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Details toggle -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (detailsOpen = !detailsOpen)}
|
||||||
|
aria-expanded={detailsOpen}
|
||||||
|
aria-label={m.doc_details_toggle()}
|
||||||
|
class="ml-2 inline-flex min-h-[44px] shrink-0 items-center gap-1 rounded border px-2 py-1 font-sans text-xs font-semibold transition-colors {detailsOpen ? 'border-primary bg-primary text-primary-fg' : 'border-line text-ink-2 hover:bg-muted hover:text-ink'}"
|
||||||
|
>
|
||||||
|
{m.doc_details_toggle()}
|
||||||
|
<svg
|
||||||
|
class="h-3.5 w-3.5 transition-transform duration-200 {detailsOpen ? 'rotate-180' : ''}"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Divider between metadata and actions -->
|
<!-- Divider between metadata and actions -->
|
||||||
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
|
<div class="mx-3 hidden h-6 w-px shrink-0 bg-line md:block"></div>
|
||||||
|
|
||||||
@@ -233,4 +262,18 @@ let mobileMenuOpen = $state(false);
|
|||||||
|
|
||||||
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
<!-- Hint strip — only when annotateMode, only at ≥768px -->
|
||||||
<AnnotateHintStrip annotateMode={annotateMode} />
|
<AnnotateHintStrip annotateMode={annotateMode} />
|
||||||
|
|
||||||
|
<!-- Metadata drawer -->
|
||||||
|
{#if detailsOpen}
|
||||||
|
<div transition:slide={{ duration: 200 }}>
|
||||||
|
<DocumentMetadataDrawer
|
||||||
|
documentDate={doc.documentDate ?? null}
|
||||||
|
location={doc.location ?? null}
|
||||||
|
status={doc.status ?? 'PLACEHOLDER'}
|
||||||
|
sender={doc.sender ?? null}
|
||||||
|
receivers={doc.receivers ? [...doc.receivers] : []}
|
||||||
|
tags={doc.tags ? [...doc.tags] : []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
import { env } from '$env/dynamic/private';
|
|
||||||
import { createApiClient } from '$lib/api.server';
|
import { createApiClient } from '$lib/api.server';
|
||||||
import { getErrorMessage } from '$lib/errors';
|
import { getErrorMessage } from '$lib/errors';
|
||||||
|
|
||||||
export async function load({ params, fetch }) {
|
export async function load({ params, fetch }) {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const api = createApiClient(fetch);
|
const api = createApiClient(fetch);
|
||||||
const base = env.API_INTERNAL_URL || 'http://localhost:8080';
|
|
||||||
|
|
||||||
const [docResult, commentsRes] = await Promise.all([
|
const docResult = await api.GET('/api/documents/{id}', { params: { path: { id } } });
|
||||||
api.GET('/api/documents/{id}', { params: { path: { id } } }),
|
|
||||||
fetch(`${base}/api/documents/${id}/comments`).catch(() => null)
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (docResult.response.status === 401) throw redirect(302, '/login');
|
if (docResult.response.status === 401) throw redirect(302, '/login');
|
||||||
|
|
||||||
@@ -20,14 +15,5 @@ export async function load({ params, fetch }) {
|
|||||||
throw error(docResult.response.status, getErrorMessage(code));
|
throw error(docResult.response.status, getErrorMessage(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
let comments: unknown[] = [];
|
return { document: docResult.data! };
|
||||||
if (commentsRes?.ok) {
|
|
||||||
try {
|
|
||||||
comments = await commentsRes.json();
|
|
||||||
} catch {
|
|
||||||
// ignore invalid response
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { document: docResult.data!, comments };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import { onMount } from 'svelte';
|
|||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
import DocumentTopBar from '$lib/components/DocumentTopBar.svelte';
|
||||||
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
import DocumentViewer from '$lib/components/DocumentViewer.svelte';
|
||||||
import DocumentBottomPanel from '$lib/components/DocumentBottomPanel.svelte';
|
|
||||||
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
import AnnotationSidePanel from '$lib/components/AnnotationSidePanel.svelte';
|
||||||
import type { DocumentPanelTab } from '$lib/types';
|
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -62,38 +60,17 @@ let annotateMode = $state(false);
|
|||||||
let activeAnnotationId = $state<string | null>(null);
|
let activeAnnotationId = $state<string | null>(null);
|
||||||
let activeAnnotationPage = $state<number | null>(null);
|
let activeAnnotationPage = $state<number | null>(null);
|
||||||
|
|
||||||
// Close the panel when entering annotate mode so the PDF is fully visible.
|
// ── Navigation / init ─────────────────────────────────────────────────────────
|
||||||
$effect(() => {
|
|
||||||
if (annotateMode) panelOpen = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Bottom panel state ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let panelOpen = $state(false);
|
|
||||||
let panelHeight = $state(0); // set to full height on mount
|
|
||||||
let navHeight = $state(0);
|
let navHeight = $state(0);
|
||||||
let activeTab = $state<DocumentPanelTab>('metadata');
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
navHeight = document.querySelector('header')?.getBoundingClientRect().height ?? 0;
|
||||||
|
|
||||||
const topbar = document.querySelector('[data-topbar]');
|
|
||||||
panelHeight = window.innerHeight - navHeight - (topbar?.getBoundingClientRect().height ?? 0);
|
|
||||||
|
|
||||||
if (targetAnnotationId) {
|
if (targetAnnotationId) {
|
||||||
// Deep-link into an annotation comment: open the side panel
|
|
||||||
activeAnnotationId = targetAnnotationId;
|
activeAnnotationId = targetAnnotationId;
|
||||||
} else if (targetCommentId) {
|
|
||||||
// Deep-link into a document-level comment: open discussion tab
|
|
||||||
panelOpen = true;
|
|
||||||
activeTab = 'discussion';
|
|
||||||
} else if (!doc?.filePath) {
|
|
||||||
// No file yet — open to metadata so the panel is immediately useful.
|
|
||||||
panelOpen = true;
|
|
||||||
activeTab = 'metadata';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track last-visited document for the dashboard resume strip
|
|
||||||
if (doc?.id) {
|
if (doc?.id) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
'familienarchiv.lastVisited',
|
'familienarchiv.lastVisited',
|
||||||
@@ -106,8 +83,6 @@ onMount(() => {
|
|||||||
if (activeAnnotationId) {
|
if (activeAnnotationId) {
|
||||||
activeAnnotationId = null;
|
activeAnnotationId = null;
|
||||||
activeAnnotationPage = null;
|
activeAnnotationPage = null;
|
||||||
} else if (panelOpen) {
|
|
||||||
panelOpen = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -160,16 +135,4 @@ onMount(() => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DocumentBottomPanel
|
|
||||||
doc={doc}
|
|
||||||
comments={(data.comments ?? []) as never[]}
|
|
||||||
canComment={canComment}
|
|
||||||
currentUserId={currentUserId}
|
|
||||||
canAdmin={canAdmin}
|
|
||||||
targetCommentId={targetCommentId}
|
|
||||||
bind:open={panelOpen}
|
|
||||||
bind:height={panelHeight}
|
|
||||||
bind:activeTab={activeTab}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,17 +8,10 @@ import { createApiClient } from '$lib/api.server';
|
|||||||
|
|
||||||
beforeEach(() => vi.clearAllMocks());
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
function makeCommentsResponse(comments: unknown[]) {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: vi.fn().mockResolvedValue(comments)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── happy path ───────────────────────────────────────────────────────────────
|
// ─── happy path ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('document detail load — happy path', () => {
|
describe('document detail load — happy path', () => {
|
||||||
it('returns document and comments on success', async () => {
|
it('returns document on success', async () => {
|
||||||
vi.mocked(createApiClient).mockReturnValue({
|
vi.mocked(createApiClient).mockReturnValue({
|
||||||
GET: vi.fn().mockResolvedValue({
|
GET: vi.fn().mockResolvedValue({
|
||||||
response: { ok: true, status: 200 },
|
response: { ok: true, status: 200 },
|
||||||
@@ -26,7 +19,7 @@ describe('document detail load — happy path', () => {
|
|||||||
})
|
})
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue(makeCommentsResponse([{ id: 'c1', body: 'Hi' }]));
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
const result = await load({
|
const result = await load({
|
||||||
params: { id: '123' },
|
params: { id: '123' },
|
||||||
@@ -34,45 +27,6 @@ describe('document detail load — happy path', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.document.title).toBe('Testbrief');
|
expect(result.document.title).toBe('Testbrief');
|
||||||
expect(result.comments).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty comments when the comments fetch fails', async () => {
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({
|
|
||||||
GET: vi.fn().mockResolvedValue({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { id: '123', title: 'Testbrief' }
|
|
||||||
})
|
|
||||||
} as ReturnType<typeof createApiClient>);
|
|
||||||
|
|
||||||
// fetch throws a network error for the comments endpoint
|
|
||||||
const mockFetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
params: { id: '123' },
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.document.title).toBe('Testbrief');
|
|
||||||
expect(result.comments).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns empty comments when the comments response is not ok', async () => {
|
|
||||||
vi.mocked(createApiClient).mockReturnValue({
|
|
||||||
GET: vi.fn().mockResolvedValue({
|
|
||||||
response: { ok: true, status: 200 },
|
|
||||||
data: { id: '123', title: 'Testbrief' }
|
|
||||||
})
|
|
||||||
} as ReturnType<typeof createApiClient>);
|
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
|
||||||
|
|
||||||
const result = await load({
|
|
||||||
params: { id: '123' },
|
|
||||||
fetch: mockFetch as unknown as typeof fetch
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.comments).toEqual([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,7 +41,7 @@ describe('document detail load — error paths', () => {
|
|||||||
})
|
})
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch })
|
load({ params: { id: 'missing' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
@@ -102,7 +56,7 @@ describe('document detail load — error paths', () => {
|
|||||||
})
|
})
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch })
|
load({ params: { id: 'secret' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
@@ -117,7 +71,7 @@ describe('document detail load — error paths', () => {
|
|||||||
})
|
})
|
||||||
} as ReturnType<typeof createApiClient>);
|
} as ReturnType<typeof createApiClient>);
|
||||||
|
|
||||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false });
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
|
load({ params: { id: 'any' }, fetch: mockFetch as unknown as typeof fetch })
|
||||||
|
|||||||
Reference in New Issue
Block a user