refactor(geschichte): extract delete handler to [id]/+page.svelte, pass via ondelete prop
Moves the confirm-then-delete flow out of StoryReader and JourneyReader into the single [id]/+page.svelte owner. Both reader components gain an optional ondelete prop — the delete button calls ondelete?.() so the handler is opt-in and never duplicated. Tests verify the prop is called on click. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
|
||||||
import { csrfFetch } from '$lib/shared/cookies';
|
|
||||||
import JourneyItemCard from './JourneyItemCard.svelte';
|
import JourneyItemCard from './JourneyItemCard.svelte';
|
||||||
import JourneyInterlude from './JourneyInterlude.svelte';
|
import JourneyInterlude from './JourneyInterlude.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
@@ -13,9 +10,10 @@ type JourneyItemView = components['schemas']['JourneyItemView'];
|
|||||||
interface Props {
|
interface Props {
|
||||||
geschichte: GeschichteView;
|
geschichte: GeschichteView;
|
||||||
canBlogWrite: boolean;
|
canBlogWrite: boolean;
|
||||||
|
ondelete?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { geschichte: g, canBlogWrite }: Props = $props();
|
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
|
||||||
|
|
||||||
// Render intro only when body is a non-empty, non-whitespace string.
|
// Render intro only when body is a non-empty, non-whitespace string.
|
||||||
const introText = $derived(g.body?.trim() ? g.body : null);
|
const introText = $derived(g.body?.trim() ? g.body : null);
|
||||||
@@ -27,23 +25,6 @@ const validItems = $derived(
|
|||||||
item.document != null || (item.note != null && item.note.trim().length > 0)
|
item.document != null || (item.note != null && item.note.trim().length > 0)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirm = getConfirmService();
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
const ok = await confirm.confirm({
|
|
||||||
title: m.geschichte_delete_confirm_title(),
|
|
||||||
body: m.geschichte_delete_confirm_body(),
|
|
||||||
confirmLabel: m.btn_delete(),
|
|
||||||
cancelLabel: m.btn_cancel(),
|
|
||||||
destructive: true
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
|
||||||
if (res.ok) {
|
|
||||||
goto('/geschichten');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if introText}
|
{#if introText}
|
||||||
@@ -80,7 +61,7 @@ async function handleDelete() {
|
|||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleDelete}
|
onclick={() => ondelete?.()}
|
||||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{m.btn_delete()}
|
{m.btn_delete()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
@@ -75,8 +75,11 @@ describe('JourneyReader', () => {
|
|||||||
props: { geschichte: baseGeschichte({ body: ' ' }), canBlogWrite: false }
|
props: { geschichte: baseGeschichte({ body: ' ' }), canBlogWrite: false }
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(document.body.textContent?.trim().replace(/\s+/g, ' ')).not.toContain(' ');
|
// Whitespace-only body must NOT produce a visible intro paragraph.
|
||||||
|
// The only rendered content should be the empty-state message.
|
||||||
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
await expect.element(page.getByTestId('journey-empty-state')).toBeVisible();
|
||||||
|
const paragraphs = document.querySelectorAll('p:not([data-testid])');
|
||||||
|
expect(paragraphs.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders empty-state message when items array is empty', async () => {
|
it('renders empty-state message when items array is empty', async () => {
|
||||||
@@ -148,6 +151,22 @@ describe('JourneyReader', () => {
|
|||||||
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
|
await expect.element(page.getByText('Diese Lesereise ist noch leer.')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clicking delete button calls ondelete prop', async () => {
|
||||||
|
const ondelete = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(JourneyReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: {
|
||||||
|
geschichte: baseGeschichte({ items: [docItem('i1', 'Brief', 0)] }),
|
||||||
|
canBlogWrite: true,
|
||||||
|
ondelete
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
|
||||||
|
|
||||||
|
expect(ondelete).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
|
it('XSS: Journey body is rendered as plaintext — injected payload does not execute', async () => {
|
||||||
// JourneyReader uses Svelte text interpolation, NOT {@html}.
|
// JourneyReader uses Svelte text interpolation, NOT {@html}.
|
||||||
render(JourneyReader, {
|
render(JourneyReader, {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { safeHtml } from '$lib/shared/utils/sanitize';
|
import { safeHtml } from '$lib/shared/utils/sanitize';
|
||||||
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
|
||||||
import { csrfFetch } from '$lib/shared/cookies';
|
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type GeschichteView = components['schemas']['GeschichteView'];
|
type GeschichteView = components['schemas']['GeschichteView'];
|
||||||
@@ -11,32 +8,16 @@ type GeschichteView = components['schemas']['GeschichteView'];
|
|||||||
interface Props {
|
interface Props {
|
||||||
geschichte: GeschichteView;
|
geschichte: GeschichteView;
|
||||||
canBlogWrite: boolean;
|
canBlogWrite: boolean;
|
||||||
|
ondelete?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { geschichte: g, canBlogWrite }: Props = $props();
|
let { geschichte: g, canBlogWrite, ondelete }: Props = $props();
|
||||||
|
|
||||||
const sanitized = $derived(safeHtml(g.body));
|
const sanitized = $derived(safeHtml(g.body));
|
||||||
|
|
||||||
const confirm = getConfirmService();
|
|
||||||
|
|
||||||
function personName(p: { firstName?: string; lastName?: string }): string {
|
function personName(p: { firstName?: string; lastName?: string }): string {
|
||||||
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
return [p.firstName, p.lastName].filter(Boolean).join(' ').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
const ok = await confirm.confirm({
|
|
||||||
title: m.geschichte_delete_confirm_title(),
|
|
||||||
body: m.geschichte_delete_confirm_body(),
|
|
||||||
confirmLabel: m.btn_delete(),
|
|
||||||
cancelLabel: m.btn_cancel(),
|
|
||||||
destructive: true
|
|
||||||
});
|
|
||||||
if (!ok) return;
|
|
||||||
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
|
||||||
if (res.ok) {
|
|
||||||
goto('/geschichten');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -65,7 +46,7 @@ async function handleDelete() {
|
|||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
href="/persons/{p.id}"
|
href="/persons/{p.id}"
|
||||||
class="inline-flex items-center rounded-full bg-muted px-3 py-1 font-sans text-sm text-ink hover:bg-accent-bg"
|
class="inline-flex min-h-[44px] items-center rounded-full bg-muted px-3 py-2.5 font-sans text-sm text-ink hover:bg-accent-bg focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{personName(p)}
|
{personName(p)}
|
||||||
</a>
|
</a>
|
||||||
@@ -111,7 +92,7 @@ async function handleDelete() {
|
|||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleDelete}
|
onclick={() => ondelete?.()}
|
||||||
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
class="inline-flex h-11 items-center rounded font-sans text-sm font-medium text-danger hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
|
||||||
>
|
>
|
||||||
{m.btn_delete()}
|
{m.btn_delete()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page } from 'vitest/browser';
|
import { page, userEvent } from 'vitest/browser';
|
||||||
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
import { createConfirmService, CONFIRM_KEY } from '$lib/shared/services/confirm.svelte.js';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
@@ -118,6 +118,18 @@ describe('StoryReader', () => {
|
|||||||
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
|
await expect.element(page.getByRole('button', { name: /löschen/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('clicking delete button calls ondelete prop', async () => {
|
||||||
|
const ondelete = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(StoryReader, {
|
||||||
|
context: ctx(),
|
||||||
|
props: { geschichte: baseGeschichte(), canBlogWrite: true, ondelete }
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(page.getByRole('button', { name: /löschen/i }));
|
||||||
|
|
||||||
|
expect(ondelete).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
|
it('XSS: Story body is sanitised — injected payload does not execute', async () => {
|
||||||
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
|
// StoryReader uses {@html safeHtml(g.body)} — DOMPurify must strip the payload.
|
||||||
render(StoryReader, {
|
render(StoryReader, {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { formatDate } from '$lib/shared/utils/date';
|
import { formatDate } from '$lib/shared/utils/date';
|
||||||
|
import { formatAuthorDisplayName } from '$lib/geschichte/utils';
|
||||||
|
import { getConfirmService } from '$lib/shared/services/confirm.svelte';
|
||||||
|
import { csrfFetch } from '$lib/shared/cookies';
|
||||||
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
import BackButton from '$lib/shared/primitives/BackButton.svelte';
|
||||||
import StoryReader from '$lib/geschichte/StoryReader.svelte';
|
import StoryReader from '$lib/geschichte/StoryReader.svelte';
|
||||||
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
|
import JourneyReader from '$lib/geschichte/JourneyReader.svelte';
|
||||||
@@ -16,8 +20,23 @@ const publishedAt = $derived.by(() => {
|
|||||||
return formatDate(g.publishedAt.slice(0, 10), 'long');
|
return formatDate(g.publishedAt.slice(0, 10), 'long');
|
||||||
});
|
});
|
||||||
|
|
||||||
function authorName(): string {
|
const authorName = $derived(formatAuthorDisplayName(g.author));
|
||||||
return g.author?.displayName ?? '';
|
|
||||||
|
const confirm = getConfirmService();
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
const ok = await confirm.confirm({
|
||||||
|
title: m.geschichte_delete_confirm_title(),
|
||||||
|
body: m.geschichte_delete_confirm_body(),
|
||||||
|
confirmLabel: m.btn_delete(),
|
||||||
|
cancelLabel: m.btn_cancel(),
|
||||||
|
destructive: true
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
const res = await csrfFetch(`/api/geschichten/${g.id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
goto('/geschichten');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,15 +60,15 @@ function authorName(): string {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="font-sans text-sm text-ink-3">
|
<p class="font-sans text-sm text-ink-3">
|
||||||
{authorName()}
|
{authorName}
|
||||||
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
{#if publishedAt}· {m.geschichten_published_on({ date: publishedAt })}{/if}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if isJourney}
|
{#if isJourney}
|
||||||
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} />
|
<JourneyReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
||||||
{:else}
|
{:else}
|
||||||
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} />
|
<StoryReader geschichte={g} canBlogWrite={data.canBlogWrite} ondelete={handleDelete} />
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user