refactor(unsaved): extract createUnsavedWarning hook and UnsavedWarningBanner
Move the identical isDirty / beforeNavigate / discard pattern out of the three admin detail pages (groups, tags, users) into a reusable createUnsavedWarning() hook and a UnsavedWarningBanner presentational component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
22
frontend/src/lib/components/UnsavedWarningBanner.svelte
Normal file
22
frontend/src/lib/components/UnsavedWarningBanner.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onDiscard: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onDiscard }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
<span>{m.admin_unsaved_warning()}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onDiscard}
|
||||||
|
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
{m.person_discard_changes()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Capture the beforeNavigate callback so tests can simulate navigation events
|
||||||
|
let registeredBeforeNavigate:
|
||||||
|
| ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
|
||||||
|
| null = null;
|
||||||
|
|
||||||
|
const mockGoto = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('$app/navigation', () => ({
|
||||||
|
beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
|
||||||
|
registeredBeforeNavigate = fn;
|
||||||
|
}),
|
||||||
|
goto: mockGoto
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createUnsavedWarning } = await import('../useUnsavedWarning.svelte');
|
||||||
|
|
||||||
|
function simulateNavigate(href: string | null = '/somewhere') {
|
||||||
|
const cancel = vi.fn();
|
||||||
|
registeredBeforeNavigate?.({
|
||||||
|
cancel,
|
||||||
|
to: href ? { url: { href } } : null
|
||||||
|
});
|
||||||
|
return cancel;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registeredBeforeNavigate = null;
|
||||||
|
mockGoto.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createUnsavedWarning', () => {
|
||||||
|
it('isDirty starts false', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
expect(w.isDirty).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markDirty sets isDirty to true', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
expect(w.isDirty).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markDirty hides any existing warning banner', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
// Simulate a navigation event that showed the banner
|
||||||
|
w.markDirty();
|
||||||
|
simulateNavigate();
|
||||||
|
expect(w.showUnsavedWarning).toBe(true);
|
||||||
|
// Typing again should hide the banner (form input re-triggers markDirty)
|
||||||
|
w.markDirty();
|
||||||
|
expect(w.showUnsavedWarning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beforeNavigate cancels and shows banner when dirty', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
const cancel = simulateNavigate('/admin/users');
|
||||||
|
expect(cancel).toHaveBeenCalled();
|
||||||
|
expect(w.showUnsavedWarning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beforeNavigate stores the target URL', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
simulateNavigate('/admin/users');
|
||||||
|
expect(w.discardTarget).toBe('/admin/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beforeNavigate does not cancel when not dirty', () => {
|
||||||
|
createUnsavedWarning();
|
||||||
|
const cancel = simulateNavigate('/admin/users');
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discard resets state and navigates to target', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
simulateNavigate('/admin/tags');
|
||||||
|
w.discard();
|
||||||
|
expect(w.isDirty).toBe(false);
|
||||||
|
expect(w.showUnsavedWarning).toBe(false);
|
||||||
|
expect(mockGoto).toHaveBeenCalledWith('/admin/tags');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearOnSuccess resets isDirty and warning', () => {
|
||||||
|
const w = createUnsavedWarning();
|
||||||
|
w.markDirty();
|
||||||
|
simulateNavigate('/somewhere');
|
||||||
|
w.clearOnSuccess();
|
||||||
|
expect(w.isDirty).toBe(false);
|
||||||
|
expect(w.showUnsavedWarning).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
frontend/src/lib/hooks/useUnsavedWarning.svelte.ts
Normal file
46
frontend/src/lib/hooks/useUnsavedWarning.svelte.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
|
export function createUnsavedWarning() {
|
||||||
|
let isDirty = $state(false);
|
||||||
|
let showUnsavedWarning = $state(false);
|
||||||
|
let discardTarget: string | null = $state(null);
|
||||||
|
|
||||||
|
beforeNavigate(({ cancel, to }) => {
|
||||||
|
if (isDirty) {
|
||||||
|
cancel();
|
||||||
|
showUnsavedWarning = true;
|
||||||
|
discardTarget = to?.url.href ?? null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
isDirty = true;
|
||||||
|
showUnsavedWarning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discard() {
|
||||||
|
isDirty = false;
|
||||||
|
showUnsavedWarning = false;
|
||||||
|
if (discardTarget) goto(discardTarget);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOnSuccess() {
|
||||||
|
isDirty = false;
|
||||||
|
showUnsavedWarning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get isDirty() {
|
||||||
|
return isDirty;
|
||||||
|
},
|
||||||
|
get showUnsavedWarning() {
|
||||||
|
return showUnsavedWarning;
|
||||||
|
},
|
||||||
|
get discardTarget() {
|
||||||
|
return discardTarget;
|
||||||
|
},
|
||||||
|
markDirty,
|
||||||
|
discard,
|
||||||
|
clearOnSuccess
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
const { confirm } = getConfirmService();
|
||||||
|
const unsaved = createUnsavedWarning();
|
||||||
|
|
||||||
let isDirty = $state(false);
|
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -21,19 +20,8 @@ async function handleDelete() {
|
|||||||
if (confirmed) deleteFormEl!.requestSubmit();
|
if (confirmed) deleteFormEl!.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const STANDARD_PERMISSIONS = $derived([
|
const STANDARD_PERMISSIONS = $derived([
|
||||||
@@ -84,23 +72,8 @@ const ADMIN_PERMISSIONS = $derived([
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div
|
<div
|
||||||
@@ -122,10 +95,7 @@ const ADMIN_PERMISSIONS = $derived([
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<!-- Group name card -->
|
<!-- Group name card -->
|
||||||
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
|
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||||
|
|||||||
@@ -1,30 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
|
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
let deleteConfirmName = $state('');
|
let deleteConfirmName = $state('');
|
||||||
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
||||||
|
|
||||||
let isDirty = $state(false);
|
const unsaved = createUnsavedWarning();
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -53,23 +41,8 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||||
@@ -88,10 +61,7 @@ $effect(() => {
|
|||||||
method="POST"
|
method="POST"
|
||||||
action="?/update"
|
action="?/update"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
>
|
>
|
||||||
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
|
||||||
|
|||||||
@@ -1,21 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import { beforeNavigate, goto } from '$app/navigation';
|
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
|
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
|
||||||
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
|
import UserGroupsSection from '$lib/components/user/UserGroupsSection.svelte';
|
||||||
import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte';
|
import UserPasswordSection from '$lib/components/user/UserPasswordSection.svelte';
|
||||||
|
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||||
|
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const { confirm } = getConfirmService();
|
const { confirm } = getConfirmService();
|
||||||
|
const unsaved = createUnsavedWarning();
|
||||||
|
|
||||||
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
|
const selectedGroupIds = $derived(data.editUser.groups?.map((g: { id: string }) => g.id) ?? []);
|
||||||
|
|
||||||
let isDirty = $state(false);
|
|
||||||
let showUnsavedWarning = $state(false);
|
|
||||||
let discardTarget: string | null = $state(null);
|
|
||||||
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
let deleteFormEl = $state<HTMLFormElement | null>(null);
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
@@ -26,19 +25,8 @@ async function handleDelete() {
|
|||||||
if (confirmed) deleteFormEl!.requestSubmit();
|
if (confirmed) deleteFormEl!.requestSubmit();
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeNavigate(({ cancel, to }) => {
|
|
||||||
if (isDirty) {
|
|
||||||
cancel();
|
|
||||||
showUnsavedWarning = true;
|
|
||||||
discardTarget = to?.url.href ?? null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (form?.success) {
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -76,23 +64,8 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
{#if showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<div
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
class="mb-5 flex items-center justify-between rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
<span>{m.admin_unsaved_warning()}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => {
|
|
||||||
isDirty = false;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
if (discardTarget) goto(discardTarget);
|
|
||||||
}}
|
|
||||||
class="ml-4 shrink-0 font-sans text-xs font-bold tracking-widest text-amber-800 uppercase hover:text-amber-900 dark:text-amber-300"
|
|
||||||
>
|
|
||||||
{m.person_discard_changes()}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if form?.success}
|
{#if form?.success}
|
||||||
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
<div class="mb-5 rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">
|
||||||
@@ -109,10 +82,7 @@ $effect(() => {
|
|||||||
id="edit-user-form"
|
id="edit-user-form"
|
||||||
method="POST"
|
method="POST"
|
||||||
use:enhance
|
use:enhance
|
||||||
oninput={() => {
|
oninput={unsaved.markDirty}
|
||||||
isDirty = true;
|
|
||||||
showUnsavedWarning = false;
|
|
||||||
}}
|
|
||||||
class="space-y-5"
|
class="space-y-5"
|
||||||
>
|
>
|
||||||
<!-- Profile card -->
|
<!-- Profile card -->
|
||||||
|
|||||||
Reference in New Issue
Block a user