refactor: move geschichte domain to lib/geschichte/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,329 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import PersonMultiSelect from './PersonMultiSelect.svelte';
|
||||
import DocumentMultiSelect from '$lib/document/DocumentMultiSelect.svelte';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
type Person = components['schemas']['Person'];
|
||||
type Document = components['schemas']['Document'];
|
||||
|
||||
interface Props {
|
||||
geschichte?: Geschichte | null;
|
||||
initialPersons?: Person[];
|
||||
initialDocuments?: Document[];
|
||||
onSubmit: (payload: {
|
||||
title: string;
|
||||
body: string;
|
||||
status: 'DRAFT' | 'PUBLISHED';
|
||||
personIds: string[];
|
||||
documentIds: string[];
|
||||
}) => Promise<void>;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
geschichte = null,
|
||||
initialPersons = [],
|
||||
initialDocuments = [],
|
||||
onSubmit,
|
||||
submitting = false
|
||||
}: Props = $props();
|
||||
|
||||
// Initial-state snapshot from incoming props. The editor owns these values
|
||||
// after mount; the parent should re-mount the component with a different
|
||||
// `geschichte` to reset (consistent with how form components in this codebase
|
||||
// behave — see DocumentEdit page).
|
||||
let title = $state(geschichte?.title ?? '');
|
||||
let body = $state(geschichte?.body ?? '');
|
||||
let status: 'DRAFT' | 'PUBLISHED' = $state(geschichte?.status ?? 'DRAFT');
|
||||
let selectedPersons: Person[] = $state(
|
||||
geschichte?.persons ? Array.from(geschichte.persons) : initialPersons
|
||||
);
|
||||
let selectedDocuments: Document[] = $state(
|
||||
geschichte?.documents ? Array.from(geschichte.documents) : initialDocuments
|
||||
);
|
||||
|
||||
let dirty = $state(false);
|
||||
let titleTouched = $state(false);
|
||||
|
||||
const titleEmpty = $derived(title.trim().length === 0);
|
||||
const isDraft = $derived(status === 'DRAFT');
|
||||
const showTitleError = $derived(titleEmpty && titleTouched);
|
||||
|
||||
let editorEl: HTMLDivElement;
|
||||
let editor: Editor | null = null;
|
||||
let toolbarVersion = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
editor = new Editor({
|
||||
element: editorEl,
|
||||
content: body,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3] },
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
blockquote: false,
|
||||
strike: false,
|
||||
horizontalRule: false,
|
||||
hardBreak: false
|
||||
})
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
role: 'textbox',
|
||||
'aria-multiline': 'true',
|
||||
'aria-label': m.geschichte_editor_body_placeholder(),
|
||||
class:
|
||||
'prose max-w-none focus:outline-none min-h-[260px] font-serif text-base leading-relaxed'
|
||||
}
|
||||
},
|
||||
onUpdate({ editor: ed }) {
|
||||
body = ed.getHTML();
|
||||
dirty = true;
|
||||
},
|
||||
onSelectionUpdate() {
|
||||
toolbarVersion++;
|
||||
},
|
||||
onTransaction() {
|
||||
toolbarVersion++;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
beforeNavigate(({ cancel }) => {
|
||||
if (dirty && !submitting) {
|
||||
const ok = window.confirm(m.geschichte_editor_unsaved_changes());
|
||||
if (!ok) cancel();
|
||||
}
|
||||
});
|
||||
|
||||
function handleTitleBlur() {
|
||||
titleTouched = true;
|
||||
}
|
||||
|
||||
function handleTitleInput() {
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
async function save(nextStatus: 'DRAFT' | 'PUBLISHED') {
|
||||
titleTouched = true;
|
||||
if (titleEmpty) return;
|
||||
await onSubmit({
|
||||
title: title.trim(),
|
||||
body,
|
||||
status: nextStatus,
|
||||
personIds: selectedPersons.map((p) => p.id!).filter(Boolean),
|
||||
documentIds: selectedDocuments.map((d) => d.id!).filter(Boolean)
|
||||
});
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
function isActive(name: string, attrs?: Record<string, unknown>): boolean {
|
||||
void toolbarVersion;
|
||||
return editor?.isActive(name, attrs) ?? false;
|
||||
}
|
||||
|
||||
function exec(action: () => void) {
|
||||
if (!editor) return;
|
||||
action();
|
||||
editor.commands.focus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[2fr_1fr]">
|
||||
<!-- Editor column -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={title}
|
||||
oninput={handleTitleInput}
|
||||
onblur={handleTitleBlur}
|
||||
placeholder={m.geschichte_editor_title_placeholder()}
|
||||
aria-invalid={showTitleError}
|
||||
aria-describedby={showTitleError ? 'title-error' : undefined}
|
||||
class="block w-full rounded border border-line bg-surface px-3 py-3 font-serif text-2xl font-bold text-ink placeholder:text-ink-3 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||
/>
|
||||
{#if showTitleError}
|
||||
<p id="title-error" class="mt-1 text-sm text-danger" role="alert">
|
||||
{m.geschichte_editor_title_required()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Formatierung"
|
||||
class="flex flex-wrap items-center gap-1 rounded border border-line bg-surface p-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => exec(() => editor!.chain().focus().toggleBold().run())}
|
||||
aria-label={m.geschichte_editor_toolbar_bold()}
|
||||
aria-pressed={isActive('bold')}
|
||||
title={m.geschichte_editor_toolbar_bold()}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => exec(() => editor!.chain().focus().toggleItalic().run())}
|
||||
aria-label={m.geschichte_editor_toolbar_italic()}
|
||||
aria-pressed={isActive('italic')}
|
||||
title={m.geschichte_editor_toolbar_italic()}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink italic hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||
>
|
||||
I
|
||||
</button>
|
||||
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 2 }).run())}
|
||||
aria-label={m.geschichte_editor_toolbar_h2()}
|
||||
aria-pressed={isActive('heading', { level: 2 })}
|
||||
title={m.geschichte_editor_toolbar_h2()}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => exec(() => editor!.chain().focus().toggleHeading({ level: 3 }).run())}
|
||||
aria-label={m.geschichte_editor_toolbar_h3()}
|
||||
aria-pressed={isActive('heading', { level: 3 })}
|
||||
title={m.geschichte_editor_toolbar_h3()}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-sm font-bold text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<span class="mx-1 h-6 w-px bg-line" aria-hidden="true"></span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => exec(() => editor!.chain().focus().toggleBulletList().run())}
|
||||
aria-label={m.geschichte_editor_toolbar_ul()}
|
||||
aria-pressed={isActive('bulletList')}
|
||||
title={m.geschichte_editor_toolbar_ul()}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||
>
|
||||
•
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => exec(() => editor!.chain().focus().toggleOrderedList().run())}
|
||||
aria-label={m.geschichte_editor_toolbar_ol()}
|
||||
aria-pressed={isActive('orderedList')}
|
||||
title={m.geschichte_editor_toolbar_ol()}
|
||||
class="inline-flex h-11 min-w-[44px] items-center justify-center rounded px-2 text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring aria-pressed:bg-accent-bg"
|
||||
>
|
||||
1.
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Editor surface -->
|
||||
<div
|
||||
class="rounded border border-line bg-surface p-4 shadow-sm focus-within:ring-2 focus-within:ring-focus-ring"
|
||||
>
|
||||
<div bind:this={editorEl} class="min-h-[260px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside class="flex flex-col gap-6">
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-1 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">Status</h2>
|
||||
<p class="mb-3">
|
||||
<span
|
||||
class="inline-flex items-center rounded px-2 py-1 font-sans text-xs font-bold tracking-widest uppercase {isDraft
|
||||
? 'bg-muted text-ink-2'
|
||||
: 'bg-accent-bg text-ink'}"
|
||||
>
|
||||
{isDraft
|
||||
? m.geschichte_editor_status_draft()
|
||||
: m.geschichte_editor_status_published()}
|
||||
</span>
|
||||
</p>
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft
|
||||
? m.geschichte_editor_status_draft_hint()
|
||||
: m.geschichte_editor_status_published_hint()}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichte_editor_personen_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_personen_hint()}</p>
|
||||
<PersonMultiSelect bind:selectedPersons={selectedPersons} />
|
||||
</section>
|
||||
|
||||
<section class="rounded border border-line bg-surface p-4 shadow-sm">
|
||||
<h2 class="mb-2 font-sans text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||
{m.geschichte_editor_dokumente_heading()}
|
||||
</h2>
|
||||
<p class="mb-3 font-sans text-xs text-ink-3">{m.geschichte_editor_dokumente_hint()}</p>
|
||||
<DocumentMultiSelect bind:selectedDocuments={selectedDocuments} />
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div
|
||||
class="sticky bottom-0 z-10 -mx-4 mt-6 flex flex-col gap-3 border-t border-line bg-surface px-6 py-4 shadow-[0_-2px_8px_rgba(0,0,0,0.06)] sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<p class="font-sans text-xs text-ink-3">
|
||||
{isDraft
|
||||
? m.geschichte_editor_save_hint_draft()
|
||||
: m.geschichte_editor_save_hint_published()}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
{#if isDraft}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-ink hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save_draft()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_publish()}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('DRAFT')}
|
||||
disabled={submitting}
|
||||
class="inline-flex h-11 items-center rounded border border-line bg-surface px-4 font-sans text-sm font-medium text-amber-700 hover:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_unpublish()}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => save('PUBLISHED')}
|
||||
disabled={submitting || titleEmpty}
|
||||
class="inline-flex h-11 items-center rounded bg-primary px-4 font-sans text-sm font-medium text-primary-fg hover:opacity-90 focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{m.geschichte_editor_save()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,150 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page, userEvent } from 'vitest/browser';
|
||||
import GeschichteEditor from './GeschichteEditor.svelte';
|
||||
|
||||
const personFactory = (id: string, displayName: string) => ({
|
||||
id,
|
||||
firstName: displayName.split(' ')[0],
|
||||
lastName: displayName.split(' ').slice(1).join(' ') || displayName,
|
||||
displayName,
|
||||
personType: 'PERSON' as const
|
||||
});
|
||||
|
||||
const docFactory = (id: string, title: string, date = '1882-01-01') => ({
|
||||
id,
|
||||
title,
|
||||
documentDate: date,
|
||||
originalFilename: `${title}.pdf`,
|
||||
status: 'UPLOADED' as const,
|
||||
metadataComplete: false,
|
||||
scriptType: 'UNKNOWN' as const,
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00'
|
||||
});
|
||||
|
||||
const draftFactory = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 'g1',
|
||||
title: 'Existing draft',
|
||||
body: '<p>Hello world</p>',
|
||||
status: 'DRAFT' as const,
|
||||
persons: [],
|
||||
documents: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
...overrides
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GeschichteEditor — title-required guard', () => {
|
||||
it('disables both DRAFT save buttons when the title is empty', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
const draft = await page.getByRole('button', { name: 'Entwurf speichern' }).element();
|
||||
const publish = await page.getByRole('button', { name: 'Veröffentlichen' }).element();
|
||||
expect(draft).toHaveProperty('disabled', true);
|
||||
expect(publish).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
it('shows the inline error after the title field is blurred while empty', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.click(page.getByPlaceholder('Titel der Geschichte'));
|
||||
await userEvent.tab(); // blur
|
||||
await expect.element(page.getByText('Bitte gib einen Titel ein.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — save bar adapts to status', () => {
|
||||
it('renders DRAFT mode buttons when no geschichte prop is supplied', async () => {
|
||||
render(GeschichteEditor, { onSubmit: vi.fn() });
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Entwurf speichern' }))
|
||||
.toBeInTheDocument();
|
||||
await expect.element(page.getByRole('button', { name: 'Veröffentlichen' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PUBLISHED mode buttons when geschichte.status is PUBLISHED', async () => {
|
||||
render(GeschichteEditor, {
|
||||
geschichte: draftFactory({ status: 'PUBLISHED', publishedAt: '2024-04-01T12:00:00' }),
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByRole('button', { name: 'Speichern' })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: 'Zurück zu Entwurf' }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — pre-fill', () => {
|
||||
it('renders initial persons as chips', async () => {
|
||||
render(GeschichteEditor, {
|
||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText('Franz Raddatz')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders initial documents as chips', async () => {
|
||||
render(GeschichteEditor, {
|
||||
initialDocuments: [docFactory('d1', 'Brief von Eugenie')],
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
await expect.element(page.getByText(/Brief von Eugenie/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('populates the title input from a geschichte prop', async () => {
|
||||
render(GeschichteEditor, {
|
||||
geschichte: draftFactory({ title: 'My existing story' }),
|
||||
onSubmit: vi.fn()
|
||||
});
|
||||
const input = await page.getByPlaceholder('Titel der Geschichte').element();
|
||||
expect((input as HTMLInputElement).value).toBe('My existing story');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeschichteEditor — onSubmit payload', () => {
|
||||
it('passes the trimmed title and DRAFT status when "Entwurf speichern" is clicked', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), ' My title ');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.title).toBe('My title');
|
||||
expect(payload.status).toBe('DRAFT');
|
||||
});
|
||||
|
||||
it('passes status=PUBLISHED when "Veröffentlichen" is clicked', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, { onSubmit });
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Veröffentlichen' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0][0].status).toBe('PUBLISHED');
|
||||
});
|
||||
|
||||
it('passes the personIds and documentIds from initial props through onSubmit', async () => {
|
||||
const onSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
render(GeschichteEditor, {
|
||||
initialPersons: [personFactory('p1', 'Franz Raddatz')],
|
||||
initialDocuments: [docFactory('d1', 'Brief A')],
|
||||
onSubmit
|
||||
});
|
||||
|
||||
await userEvent.fill(page.getByPlaceholder('Titel der Geschichte'), 'Story');
|
||||
await userEvent.click(page.getByRole('button', { name: 'Entwurf speichern' }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const payload = onSubmit.mock.calls[0][0];
|
||||
expect(payload.personIds).toEqual(['p1']);
|
||||
expect(payload.documentIds).toEqual(['d1']);
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import type { components } from '$lib/generated/api';
|
||||
import { plainExcerpt } from '$lib/utils/extractText';
|
||||
import { formatDate } from '$lib/utils/date';
|
||||
|
||||
type Geschichte = components['schemas']['Geschichte'];
|
||||
|
||||
interface Props {
|
||||
geschichten: Geschichte[];
|
||||
personId: string;
|
||||
personName: string;
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
let { geschichten, personId, personName, canWrite }: Props = $props();
|
||||
|
||||
const visible = $derived(geschichten.slice(0, 3));
|
||||
const hasOverflow = $derived(geschichten.length >= 3);
|
||||
|
||||
function formatPublishedDate(g: Geschichte): string | null {
|
||||
if (!g.publishedAt) return null;
|
||||
return formatDate(g.publishedAt.slice(0, 10), 'short');
|
||||
}
|
||||
|
||||
function authorName(g: Geschichte): string {
|
||||
const a = g.author;
|
||||
if (!a) return '';
|
||||
const full = [a.firstName, a.lastName].filter(Boolean).join(' ').trim();
|
||||
return full || a.email || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if geschichten.length > 0}
|
||||
<section
|
||||
aria-labelledby="geschichten-card-heading"
|
||||
class="rounded-sm border border-line bg-surface p-6 shadow-sm"
|
||||
>
|
||||
<header class="mb-5 flex items-center justify-between">
|
||||
<h2
|
||||
id="geschichten-card-heading"
|
||||
class="font-sans text-xs font-bold tracking-widest text-ink-2 uppercase"
|
||||
>
|
||||
{m.geschichten_card_heading()}
|
||||
</h2>
|
||||
{#if canWrite}
|
||||
<a
|
||||
href="/geschichten/new?personId={personId}"
|
||||
class="inline-flex items-center font-sans text-sm font-medium text-ink-2 hover:text-ink"
|
||||
>
|
||||
{m.geschichten_card_write_action()}
|
||||
</a>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<ul class="-mx-2">
|
||||
{#each visible as g (g.id)}
|
||||
<li>
|
||||
<a
|
||||
href="/geschichten/{g.id}"
|
||||
class="group flex flex-col gap-1 border-b border-line px-2 py-3 transition-colors last:border-b-0 hover:bg-muted"
|
||||
>
|
||||
<span class="font-serif text-base font-bold text-ink group-hover:underline">
|
||||
{g.title}
|
||||
</span>
|
||||
<span class="font-sans text-xs text-ink-3">
|
||||
{authorName(g)}
|
||||
{#if formatPublishedDate(g)}· {formatPublishedDate(g)}{/if}
|
||||
</span>
|
||||
{#if g.body}
|
||||
<span class="font-serif text-sm text-ink-2">{plainExcerpt(g.body, 80)}</span>
|
||||
{/if}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if hasOverflow}
|
||||
<footer class="mt-4 border-t border-line pt-3">
|
||||
<a
|
||||
href="/geschichten?personId={personId}"
|
||||
class="inline-flex items-center font-sans text-sm font-medium text-ink hover:underline"
|
||||
>
|
||||
{m.geschichten_card_show_all_for_person({ name: personName })} →
|
||||
</a>
|
||||
</footer>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import GeschichtenCard from './GeschichtenCard.svelte';
|
||||
|
||||
const makeStory = (id: string, title: string, body: string | null = '<p>Body</p>') => ({
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
status: 'PUBLISHED' as const,
|
||||
publishedAt: '2024-04-01T12:00:00',
|
||||
createdAt: '2024-03-01T12:00:00',
|
||||
updatedAt: '2024-04-01T12:00:00',
|
||||
persons: [],
|
||||
documents: [],
|
||||
author: {
|
||||
id: 'u1',
|
||||
email: 'marcel@example.com',
|
||||
firstName: 'Marcel',
|
||||
lastName: 'Raddatz',
|
||||
enabled: true,
|
||||
notifyOnReply: false,
|
||||
notifyOnMention: false,
|
||||
groups: [],
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
color: '#000'
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('GeschichtenCard', () => {
|
||||
it('renders nothing when geschichten is empty', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: true
|
||||
});
|
||||
// No heading, no list — the entire <section> should not exist
|
||||
expect(
|
||||
document.querySelector('section[aria-labelledby="geschichten-card-heading"]')
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the section heading and stories when geschichten is non-empty', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'Erinnerung an Franz')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
await expect.element(page.getByText('Geschichten')).toBeInTheDocument();
|
||||
// The whole row is one link to the story; matching on the title text via
|
||||
// a partial regex tolerates trailing author/date metadata in the
|
||||
// accessible name.
|
||||
const link = await page
|
||||
.getByRole('link', { name: /Erinnerung an Franz/ })
|
||||
.first()
|
||||
.element();
|
||||
expect(link.getAttribute('href')).toBe('/geschichten/g1');
|
||||
});
|
||||
|
||||
it('makes the entire story row a single clickable link', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A title', '<p>Some body excerpt text</p>')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
// The body-excerpt text is inside the same <a> as the title.
|
||||
const links = await page.getByRole('link', { name: /A title/ }).all();
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
const linkEl = await links[0].element();
|
||||
expect(linkEl.tagName).toBe('A');
|
||||
expect(linkEl.textContent).toContain('Some body excerpt text');
|
||||
});
|
||||
|
||||
it('hides the "+ Geschichte schreiben" link when canWrite is false', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A story')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
const writeLinks = await page.getByText(/Geschichte schreiben/).all();
|
||||
expect(writeLinks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the write-action link only when canWrite is true', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A story')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: true
|
||||
});
|
||||
const link = await page.getByRole('link', { name: /Geschichte schreiben/ }).element();
|
||||
expect(link.getAttribute('href')).toBe('/geschichten/new?personId=p1');
|
||||
});
|
||||
|
||||
it('hides the "Alle Geschichten zu …" footer link below the 3-story threshold', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
const overflow = await page.getByText(/Alle Geschichten zu/).all();
|
||||
expect(overflow).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows the footer link at the 3-story threshold (>= 3)', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [makeStory('g1', 'A'), makeStory('g2', 'B'), makeStory('g3', 'C')],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
const link = await page.getByRole('link', { name: /Alle Geschichten zu Franz/ }).element();
|
||||
expect(link.getAttribute('href')).toBe('/geschichten?personId=p1');
|
||||
});
|
||||
|
||||
it('renders a plain-text excerpt without HTML markup', async () => {
|
||||
render(GeschichtenCard, {
|
||||
geschichten: [
|
||||
makeStory(
|
||||
'g1',
|
||||
'Mit HTML',
|
||||
'<p>Plain <strong>bold</strong> story</p><script>alert(1)</script>'
|
||||
)
|
||||
],
|
||||
personId: 'p1',
|
||||
personName: 'Franz',
|
||||
canWrite: false
|
||||
});
|
||||
// Body excerpt appears once as plain text — no <strong> rendered, no script
|
||||
await expect.element(page.getByText(/Plain bold story/)).toBeInTheDocument();
|
||||
expect(document.body.innerHTML).not.toContain('<script>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user