From 0cdec6d19ab2d0de006f74caeab2684d0b25a39e Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Wed, 31 Dec 2025 17:15:01 +1030 Subject: [PATCH] feat(dirty): implement form dirty state tracking and navigation confirmation --- src/lib/client/stores/dirty.ts | 143 ++++++++++++++++++ src/lib/client/ui/form/NumberInput.svelte | 12 +- src/lib/client/ui/form/TagInput.svelte | 10 +- src/lib/client/ui/modal/DirtyModal.svelte | 36 +++++ .../[databaseId]/[id]/+page.svelte | 33 ++-- .../components/DelayProfileForm.svelte | 112 +++++++++----- .../[databaseId]/new/+page.svelte | 33 ++-- 7 files changed, 302 insertions(+), 77 deletions(-) create mode 100644 src/lib/client/stores/dirty.ts create mode 100644 src/lib/client/ui/modal/DirtyModal.svelte diff --git a/src/lib/client/stores/dirty.ts b/src/lib/client/stores/dirty.ts new file mode 100644 index 0000000..8f9b048 --- /dev/null +++ b/src/lib/client/stores/dirty.ts @@ -0,0 +1,143 @@ +/** + * Form dirty state tracking with snapshot comparison + * + * Stores original data and compares against current state. + * Change and change back = not dirty. + * New mode = always dirty. + */ + +import { writable, derived, get } from 'svelte/store'; + +type FormData = Record; + +// Internal stores +const originalSnapshot = writable(null); +const currentData = writable({}); +const isNewMode = writable(false); +const showWarningModal = writable(false); +let resolveNavigation: ((value: boolean) => void) | null = null; + +/** + * Deep equality check (order-sensitive for arrays) + */ +function deepEquals(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== typeof b) return false; + if (a === null || b === null) return a === b; + + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((item, i) => deepEquals(item, b[i])); + } + + if (typeof a === 'object' && typeof b === 'object') { + const aObj = a as Record; + const bObj = b as Record; + const aKeys = Object.keys(aObj); + const bKeys = Object.keys(bObj); + if (aKeys.length !== bKeys.length) return false; + return aKeys.every((key) => deepEquals(aObj[key], bObj[key])); + } + + return false; +} + +// Derived store for isDirty +export const isDirty = derived( + [originalSnapshot, currentData, isNewMode], + ([$original, $current, $isNew]) => { + if ($isNew) return true; + return !deepEquals($original, $current); + } +); + +// Export stores for reactive access +export const current = currentData; +export const showModal = showWarningModal; + +/** + * Initialize for edit mode - snapshot from server data + */ +export function initEdit(serverData: T) { + isNewMode.set(false); + originalSnapshot.set(structuredClone(serverData)); + currentData.set(structuredClone(serverData)); +} + +/** + * Initialize for create mode - always dirty + */ +export function initCreate(defaults: T) { + isNewMode.set(true); + originalSnapshot.set(null); + currentData.set(structuredClone(defaults)); +} + +/** + * Update a single field + */ +export function update(field: K, value: T[K]) { + currentData.update((data) => ({ ...data, [field]: value })); +} + +/** + * Reset snapshot after save + refetch from server + */ +export function resetFromServer(newServerData: T) { + isNewMode.set(false); + originalSnapshot.set(structuredClone(newServerData)); + currentData.set(structuredClone(newServerData)); +} + +/** + * Clear all state (call on unmount/navigation away) + */ +export function clear() { + isNewMode.set(false); + originalSnapshot.set(null); + currentData.set({}); +} + +/** + * Request navigation confirmation + * Returns promise that resolves to true if navigation should proceed + */ +export function confirmNavigation(): Promise { + if (!get(isDirty)) { + return Promise.resolve(true); + } + + showWarningModal.set(true); + + return new Promise((resolve) => { + resolveNavigation = resolve; + }); +} + +/** + * User confirmed discarding changes + */ +export function confirmDiscard() { + showWarningModal.set(false); + // Set original = current so isDirty becomes false, allowing navigation to proceed + isNewMode.set(false); + currentData.update((data) => { + originalSnapshot.set(structuredClone(data)); + return data; + }); + if (resolveNavigation) { + resolveNavigation(true); + resolveNavigation = null; + } +} + +/** + * User cancelled navigation (stay on page) + */ +export function cancelDiscard() { + showWarningModal.set(false); + if (resolveNavigation) { + resolveNavigation(false); + resolveNavigation = null; + } +} diff --git a/src/lib/client/ui/form/NumberInput.svelte b/src/lib/client/ui/form/NumberInput.svelte index 0fa4a22..83628e7 100644 --- a/src/lib/client/ui/form/NumberInput.svelte +++ b/src/lib/client/ui/form/NumberInput.svelte @@ -11,18 +11,24 @@ export let required: boolean = false; export let disabled: boolean = false; export let font: 'mono' | 'sans' | undefined = undefined; + export let onchange: ((value: number) => void) | undefined = undefined; $: fontClass = font === 'mono' ? 'font-mono' : font === 'sans' ? 'font-sans' : ''; + function updateValue(newValue: number) { + value = newValue; + onchange?.(newValue); + } + // Increment/decrement handlers function increment() { if (max !== undefined && value >= max) return; - value += step; + updateValue(value + step); } function decrement() { if (min !== undefined && value <= min) return; - value -= step; + updateValue(value - step); } // Validate on input @@ -42,7 +48,7 @@ newValue = max; } - value = newValue; + updateValue(newValue); } diff --git a/src/lib/client/ui/form/TagInput.svelte b/src/lib/client/ui/form/TagInput.svelte index 2ddfcd5..ca5c5dc 100644 --- a/src/lib/client/ui/form/TagInput.svelte +++ b/src/lib/client/ui/form/TagInput.svelte @@ -3,19 +3,25 @@ export let tags: string[] = []; export let placeholder = 'Type and press Enter to add tags'; + export let onchange: ((tags: string[]) => void) | undefined = undefined; let inputValue = ''; + function updateTags(newTags: string[]) { + tags = newTags; + onchange?.(newTags); + } + function addTag() { const trimmed = inputValue.trim(); if (trimmed && !tags.includes(trimmed)) { - tags = [...tags, trimmed]; + updateTags([...tags, trimmed]); inputValue = ''; } } function removeTag(index: number) { - tags = tags.filter((_, i) => i !== index); + updateTags(tags.filter((_, i) => i !== index)); } function handleKeydown(event: KeyboardEvent) { diff --git a/src/lib/client/ui/modal/DirtyModal.svelte b/src/lib/client/ui/modal/DirtyModal.svelte new file mode 100644 index 0000000..32179cb --- /dev/null +++ b/src/lib/client/ui/modal/DirtyModal.svelte @@ -0,0 +1,36 @@ + + + confirmDiscard()} + on:cancel={() => cancelDiscard()} +/> diff --git a/src/routes/delay-profiles/[databaseId]/[id]/+page.svelte b/src/routes/delay-profiles/[databaseId]/[id]/+page.svelte index a41e3c5..caa6329 100644 --- a/src/routes/delay-profiles/[databaseId]/[id]/+page.svelte +++ b/src/routes/delay-profiles/[databaseId]/[id]/+page.svelte @@ -1,20 +1,22 @@