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:
Marcel
2026-04-15 13:31:17 +02:00
parent 34e7436fdc
commit accfa5373e
6 changed files with 184 additions and 111 deletions

View File

@@ -1,16 +1,15 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.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();
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);
async function handleDelete() {
@@ -21,19 +20,8 @@ async function handleDelete() {
if (confirmed) deleteFormEl!.requestSubmit();
}
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
$effect(() => {
if (form?.success) {
isDirty = false;
showUnsavedWarning = false;
}
if (form?.success) unsaved.clearOnSuccess();
});
const STANDARD_PERMISSIONS = $derived([
@@ -84,23 +72,8 @@ const ADMIN_PERMISSIONS = $derived([
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning}
<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={() => {
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 unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
{#if form?.success}
<div
@@ -122,10 +95,7 @@ const ADMIN_PERMISSIONS = $derived([
method="POST"
action="?/update"
use:enhance
oninput={() => {
isDirty = true;
showUnsavedWarning = false;
}}
oninput={unsaved.markDirty}
>
<!-- Group name card -->
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">

View File

@@ -1,30 +1,18 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
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 deleteConfirmName = $state('');
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
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;
}
});
const unsaved = createUnsavedWarning();
$effect(() => {
if (form?.success) {
isDirty = false;
showUnsavedWarning = false;
}
if (form?.success) unsaved.clearOnSuccess();
});
</script>
@@ -53,23 +41,8 @@ $effect(() => {
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning}
<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={() => {
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 unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
{#if form?.success}
<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"
action="?/update"
use:enhance
oninput={() => {
isDirty = true;
showUnsavedWarning = false;
}}
oninput={unsaved.markDirty}
class="mb-5"
>
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">

View File

@@ -1,21 +1,20 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { beforeNavigate, goto } from '$app/navigation';
import { m } from '$lib/paraglide/messages.js';
import { getConfirmService } from '$lib/services/confirm.svelte.js';
import UserProfileSection from '$lib/components/user/UserProfileSection.svelte';
import UserGroupsSection from '$lib/components/user/UserGroupsSection.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();
const { confirm } = getConfirmService();
const unsaved = createUnsavedWarning();
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);
async function handleDelete() {
@@ -26,19 +25,8 @@ async function handleDelete() {
if (confirmed) deleteFormEl!.requestSubmit();
}
beforeNavigate(({ cancel, to }) => {
if (isDirty) {
cancel();
showUnsavedWarning = true;
discardTarget = to?.url.href ?? null;
}
});
$effect(() => {
if (form?.success) {
isDirty = false;
showUnsavedWarning = false;
}
if (form?.success) unsaved.clearOnSuccess();
});
</script>
@@ -76,23 +64,8 @@ $effect(() => {
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
{#if showUnsavedWarning}
<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={() => {
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 unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
{#if form?.success}
<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"
method="POST"
use:enhance
oninput={() => {
isDirty = true;
showUnsavedWarning = false;
}}
oninput={unsaved.markDirty}
class="space-y-5"
>
<!-- Profile card -->