fix(#248): add confirmation dialog before tag delete
TagDeleteGuard now calls confirm() (admin_tag_delete_confirm) before submitting — same pattern as document delete. Button changed to type=button with an async handler; page.svelte.spec.ts updated to pass ConfirmService context so TagDeleteGuard can initialise inside the page render. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
type FlatTag = {
|
||||
id: string;
|
||||
@@ -17,9 +18,17 @@ interface Props {
|
||||
|
||||
let { tag, allTags }: Props = $props();
|
||||
|
||||
let deleteForm: HTMLFormElement;
|
||||
const { confirm } = getConfirmService();
|
||||
|
||||
let selectedMode: 'single' | 'subtree' | null = $state(null);
|
||||
const canDelete = $derived(selectedMode !== null);
|
||||
|
||||
async function handleDelete() {
|
||||
const ok = await confirm({ title: m.admin_tag_delete_confirm(), destructive: true });
|
||||
if (ok) deleteForm.requestSubmit();
|
||||
}
|
||||
|
||||
// Count all descendants (recursive walk through allTags)
|
||||
const descendantCount = $derived.by(() => {
|
||||
let count = 0;
|
||||
@@ -87,12 +96,13 @@ const descendantCount = $derived.by(() => {
|
||||
</div>
|
||||
|
||||
<!-- Confirm form -->
|
||||
<form method="POST" action="?/delete" use:enhance>
|
||||
<form bind:this={deleteForm} method="POST" action="?/delete" use:enhance>
|
||||
<input type="hidden" name="deleteMode" value={selectedMode ?? ''} />
|
||||
<button
|
||||
type="submit"
|
||||
type="button"
|
||||
data-testid="delete-submit-btn"
|
||||
disabled={!canDelete}
|
||||
onclick={handleDelete}
|
||||
class="rounded-sm bg-danger px-4 py-2 font-sans text-xs font-bold tracking-widest text-danger-fg uppercase transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{selectedMode === 'subtree' ? m.admin_tag_delete_subtree_confirm_btn() : m.btn_delete()}
|
||||
|
||||
@@ -2,11 +2,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import TagDeleteGuard from './TagDeleteGuard.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderWithConfirm(props = { tag, allTags }) {
|
||||
const service = createConfirmService();
|
||||
const result = render(TagDeleteGuard, {
|
||||
props,
|
||||
context: new Map([[CONFIRM_KEY, service]])
|
||||
});
|
||||
return { ...result, service };
|
||||
}
|
||||
|
||||
const tag = { id: 't1', name: 'Familie', documentCount: 3 };
|
||||
const allTags = [
|
||||
{ id: 't1', name: 'Familie', documentCount: 3 },
|
||||
@@ -16,7 +26,7 @@ const allTags = [
|
||||
|
||||
describe('TagDeleteGuard', () => {
|
||||
it('renders two radio options (single and subtree)', async () => {
|
||||
render(TagDeleteGuard, { tag, allTags });
|
||||
renderWithConfirm();
|
||||
await expect.element(page.getByRole('radio', { name: /Nur dieses/i })).toBeInTheDocument();
|
||||
await expect
|
||||
.element(page.getByRole('radio', { name: /Gesamten Teilbaum/i }))
|
||||
@@ -24,24 +34,24 @@ describe('TagDeleteGuard', () => {
|
||||
});
|
||||
|
||||
it('delete button is disabled initially', async () => {
|
||||
render(TagDeleteGuard, { tag, allTags });
|
||||
renderWithConfirm();
|
||||
await expect.element(page.getByTestId('delete-submit-btn')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('delete button is enabled after selecting single radio', async () => {
|
||||
render(TagDeleteGuard, { tag, allTags });
|
||||
renderWithConfirm();
|
||||
await page.getByRole('radio', { name: /Nur dieses/i }).click();
|
||||
await expect.element(page.getByTestId('delete-submit-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('delete button is enabled after selecting subtree radio', async () => {
|
||||
render(TagDeleteGuard, { tag, allTags });
|
||||
renderWithConfirm();
|
||||
await page.getByRole('radio', { name: /Gesamten Teilbaum/i }).click();
|
||||
await expect.element(page.getByTestId('delete-submit-btn')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('delete button shows subtree-specific text when subtree mode is selected', async () => {
|
||||
render(TagDeleteGuard, { tag, allTags });
|
||||
renderWithConfirm();
|
||||
await page.getByRole('radio', { name: /Gesamten Teilbaum/i }).click();
|
||||
await expect
|
||||
.element(page.getByTestId('delete-submit-btn'))
|
||||
@@ -49,13 +59,52 @@ describe('TagDeleteGuard', () => {
|
||||
});
|
||||
|
||||
it('shows descendant count in impact summary', async () => {
|
||||
render(TagDeleteGuard, { tag, allTags });
|
||||
renderWithConfirm();
|
||||
// tag has 2 descendants (t2 and t3)
|
||||
await expect.element(page.getByText(/2 Untergeordnete/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows document count in impact summary', async () => {
|
||||
render(TagDeleteGuard, { tag, allTags });
|
||||
renderWithConfirm();
|
||||
await expect.element(page.getByText(/3 Dokument/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TagDeleteGuard – confirmation dialog', () => {
|
||||
it('opens confirm dialog when delete button is clicked', async () => {
|
||||
const { service } = renderWithConfirm();
|
||||
await page.getByRole('radio', { name: /Nur dieses/i }).click();
|
||||
await page.getByTestId('delete-submit-btn').click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
expect(service.options?.destructive).toBe(true);
|
||||
service.settle(false);
|
||||
});
|
||||
|
||||
it('submits the form when user confirms', async () => {
|
||||
const { service, container } = renderWithConfirm();
|
||||
const form = container.querySelector('form')!;
|
||||
const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => {});
|
||||
|
||||
await page.getByRole('radio', { name: /Nur dieses/i }).click();
|
||||
await page.getByTestId('delete-submit-btn').click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
service.settle(true);
|
||||
await vi.waitFor(() => expect(service.options).toBeNull());
|
||||
|
||||
expect(requestSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not submit when user cancels the dialog', async () => {
|
||||
const { service, container } = renderWithConfirm();
|
||||
const form = container.querySelector('form')!;
|
||||
const requestSubmit = vi.spyOn(form, 'requestSubmit').mockImplementation(() => {});
|
||||
|
||||
await page.getByRole('radio', { name: /Nur dieses/i }).click();
|
||||
await page.getByTestId('delete-submit-btn').click();
|
||||
await vi.waitFor(() => expect(service.options).not.toBeNull());
|
||||
service.settle(false);
|
||||
await vi.waitFor(() => expect(service.options).toBeNull());
|
||||
|
||||
expect(requestSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import Page from './+page.svelte';
|
||||
import { createConfirmService, CONFIRM_KEY } from '$lib/services/confirm.svelte.js';
|
||||
|
||||
vi.mock('$app/forms', () => ({ enhance: () => () => {} }));
|
||||
vi.mock('$app/navigation', () => ({
|
||||
@@ -32,24 +33,30 @@ const baseData = {
|
||||
}[]
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function renderPage(props: { data: any; form: null }) {
|
||||
const service = createConfirmService();
|
||||
return render(Page, { props, context: new Map([[CONFIRM_KEY, service]]) });
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Admin edit tag page – rendering', () => {
|
||||
it('renders the heading with tag name', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
await expect.element(page.getByText(/Schlagwort: Familie/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pre-fills the name input', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
const input = document.querySelector<HTMLInputElement>('input[name="name"]');
|
||||
expect(input?.value).toBe('Familie');
|
||||
});
|
||||
|
||||
it('renders the cancel link pointing to /admin/tags', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
await expect
|
||||
.element(page.getByRole('link', { name: /Abbrechen/i }))
|
||||
.toHaveAttribute('href', '/admin/tags');
|
||||
@@ -62,12 +69,12 @@ describe('Admin edit tag page – unsaved-changes guard', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('does not show unsaved warning initially', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
await expect.element(page.getByText(/ungespeicherte Änderungen/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancels navigation and shows warning when rename form is dirty', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||
|
||||
document
|
||||
@@ -82,7 +89,7 @@ describe('Admin edit tag page – unsaved-changes guard', () => {
|
||||
});
|
||||
|
||||
it('does not cancel navigation when form is clean', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||
|
||||
const cancel = vi.fn();
|
||||
@@ -92,7 +99,7 @@ describe('Admin edit tag page – unsaved-changes guard', () => {
|
||||
});
|
||||
|
||||
it('discard button calls goto with the target URL', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
const [callback] = vi.mocked(beforeNavigate).mock.calls[0];
|
||||
|
||||
document
|
||||
@@ -111,7 +118,7 @@ describe('Admin edit tag page – unsaved-changes guard', () => {
|
||||
|
||||
describe('Admin edit tag page – parent selector', () => {
|
||||
it('renders a TagParentPicker combobox', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
await expect
|
||||
.element(page.getByRole('combobox', { name: /Übergeordnetes Schlagwort/i }))
|
||||
.toBeInTheDocument();
|
||||
@@ -122,7 +129,7 @@ describe('Admin edit tag page – parent selector', () => {
|
||||
|
||||
describe('Admin edit tag page – color picker', () => {
|
||||
it('renders color picker when tag has no parent', async () => {
|
||||
render(Page, {
|
||||
renderPage({
|
||||
data: { tag: { id: 't1', name: 'Familie', parentId: undefined, documentCount: 0 }, tags: [] },
|
||||
form: null
|
||||
});
|
||||
@@ -130,7 +137,7 @@ describe('Admin edit tag page – color picker', () => {
|
||||
});
|
||||
|
||||
it('hides color picker when tag already has a parent', async () => {
|
||||
render(Page, {
|
||||
renderPage({
|
||||
data: {
|
||||
tag: { id: 't1', name: 'Familie', parentId: 't2', documentCount: 0 },
|
||||
tags: [{ id: 't2', name: 'Reise', documentCount: 0 }]
|
||||
@@ -141,7 +148,7 @@ describe('Admin edit tag page – color picker', () => {
|
||||
});
|
||||
|
||||
it('pre-selects the current tag color in the color picker', async () => {
|
||||
render(Page, {
|
||||
renderPage({
|
||||
data: { tag: { id: 't1', name: 'Familie', color: 'sage', documentCount: 0 }, tags: [] },
|
||||
form: null
|
||||
});
|
||||
@@ -154,12 +161,12 @@ describe('Admin edit tag page – color picker', () => {
|
||||
|
||||
describe('Admin edit tag page – merge success banner', () => {
|
||||
it('shows merge success banner when data.mergeSuccess is true', async () => {
|
||||
render(Page, { data: { ...baseData, mergeSuccess: true }, form: null });
|
||||
renderPage({ data: { ...baseData, mergeSuccess: true }, form: null });
|
||||
await expect.element(page.getByText(/Erfolgreich zusammengeführt/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show merge success banner when data.mergeSuccess is false', async () => {
|
||||
render(Page, { data: { ...baseData, mergeSuccess: false }, form: null });
|
||||
renderPage({ data: { ...baseData, mergeSuccess: false }, form: null });
|
||||
await expect.element(page.getByText(/Erfolgreich zusammengeführt/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -168,7 +175,7 @@ describe('Admin edit tag page – merge success banner', () => {
|
||||
|
||||
describe('Admin edit tag page – new components', () => {
|
||||
it('renders TagAncestry nav when tag has a parent', async () => {
|
||||
const { container } = render(Page, {
|
||||
const { container } = renderPage({
|
||||
data: {
|
||||
tag: { id: 't2', name: 'Kind', parentId: 't1', documentCount: 0 },
|
||||
tags: [
|
||||
@@ -182,17 +189,17 @@ describe('Admin edit tag page – new components', () => {
|
||||
});
|
||||
|
||||
it('does not render TagAncestry nav for root tag', async () => {
|
||||
const { container } = render(Page, { data: baseData, form: null });
|
||||
const { container } = renderPage({ data: baseData, form: null });
|
||||
expect(container.querySelector('nav')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders TagMergeZone with merge heading', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
await expect.element(page.getByText(/Zusammenführen/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders TagDeleteGuard with two radio options', async () => {
|
||||
render(Page, { data: baseData, form: null });
|
||||
renderPage({ data: baseData, form: null });
|
||||
const radios = document.querySelectorAll<HTMLInputElement>('input[type="radio"]');
|
||||
expect(radios.length).toBe(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user