feat(dirty): implement form dirty state tracking and navigation confirmation

This commit is contained in:
Sam Chau
2025-12-31 17:15:01 +10:30
parent 95795d5f0e
commit 0cdec6d19a
7 changed files with 302 additions and 77 deletions

View File

@@ -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<string, unknown>;
// Internal stores
const originalSnapshot = writable<FormData | null>(null);
const currentData = writable<FormData>({});
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<string, unknown>;
const bObj = b as Record<string, unknown>;
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<T extends FormData>(serverData: T) {
isNewMode.set(false);
originalSnapshot.set(structuredClone(serverData));
currentData.set(structuredClone(serverData));
}
/**
* Initialize for create mode - always dirty
*/
export function initCreate<T extends FormData>(defaults: T) {
isNewMode.set(true);
originalSnapshot.set(null);
currentData.set(structuredClone(defaults));
}
/**
* Update a single field
*/
export function update<T extends FormData, K extends keyof T>(field: K, value: T[K]) {
currentData.update((data) => ({ ...data, [field]: value }));
}
/**
* Reset snapshot after save + refetch from server
*/
export function resetFromServer<T extends FormData>(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<boolean> {
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;
}
}

View File

@@ -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);
}
</script>

View File

@@ -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) {

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
import Modal from './Modal.svelte';
import {
isDirty,
showModal,
confirmNavigation,
confirmDiscard,
cancelDiscard
} from '$lib/client/stores/dirty';
let pendingNavigationUrl: string | null = null;
beforeNavigate(async (navigation) => {
if ($isDirty) {
navigation.cancel();
pendingNavigationUrl = navigation.to?.url.pathname || null;
const shouldNavigate = await confirmNavigation();
if (shouldNavigate && pendingNavigationUrl) {
goto(pendingNavigationUrl);
}
pendingNavigationUrl = null;
}
});
</script>
<Modal
open={$showModal}
header="Unsaved Changes"
bodyMessage="You have unsaved changes. Are you sure you want to leave this page? Your changes will be lost."
confirmText="Discard Changes"
cancelText="Stay on Page"
confirmDanger={true}
on:confirm={() => confirmDiscard()}
on:cancel={() => cancelDiscard()}
/>

View File

@@ -1,20 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import DelayProfileForm from '../components/DelayProfileForm.svelte';
import DirtyModal from '$ui/modal/DirtyModal.svelte';
import type { PageData } from './$types';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles';
export let data: PageData;
// Form state initialized from data
let name = data.delayProfile.name;
let tags = data.delayProfile.tags.map((t) => t.name);
let preferredProtocol: PreferredProtocol = data.delayProfile.preferred_protocol;
let usenetDelay = data.delayProfile.usenet_delay ?? 0;
let torrentDelay = data.delayProfile.torrent_delay ?? 0;
let bypassIfHighestQuality = data.delayProfile.bypass_if_highest_quality;
let bypassIfAboveCfScore = data.delayProfile.bypass_if_above_custom_format_score;
let minimumCfScore = data.delayProfile.minimum_custom_format_score ?? 0;
// Build initial data from server
$: initialData = {
name: data.delayProfile.name,
tags: data.delayProfile.tags.map((t) => t.name),
preferredProtocol: data.delayProfile.preferred_protocol,
usenetDelay: data.delayProfile.usenet_delay ?? 0,
torrentDelay: data.delayProfile.torrent_delay ?? 0,
bypassIfHighestQuality: data.delayProfile.bypass_if_highest_quality,
bypassIfAboveCfScore: data.delayProfile.bypass_if_above_custom_format_score,
minimumCfScore: data.delayProfile.minimum_custom_format_score ?? 0
};
function handleCancel() {
goto(`/delay-profiles/${data.currentDatabase.id}`);
@@ -30,13 +32,8 @@
databaseName={data.currentDatabase.name}
canWriteToBase={data.canWriteToBase}
actionUrl="?/update"
bind:name
bind:tags
bind:preferredProtocol
bind:usenetDelay
bind:torrentDelay
bind:bypassIfHighestQuality
bind:bypassIfAboveCfScore
bind:minimumCfScore
{initialData}
onCancel={handleCancel}
/>
<DirtyModal />

View File

@@ -7,26 +7,53 @@
import { alertStore } from '$alerts/store';
import { Check, Save, Trash2, Loader2 } from 'lucide-svelte';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles';
import {
current,
isDirty,
initEdit,
initCreate,
update
} from '$lib/client/stores/dirty';
// Form data shape
interface DelayProfileFormData {
name: string;
tags: string[];
preferredProtocol: PreferredProtocol;
usenetDelay: number;
torrentDelay: number;
bypassIfHighestQuality: boolean;
bypassIfAboveCfScore: boolean;
minimumCfScore: number;
}
// Props
export let mode: 'create' | 'edit';
export let databaseName: string;
export let canWriteToBase: boolean = false;
export let actionUrl: string = '';
// Form data
export let name: string = '';
export let tags: string[] = [];
export let preferredProtocol: PreferredProtocol = 'prefer_usenet';
export let usenetDelay: number = 0;
export let torrentDelay: number = 0;
export let bypassIfHighestQuality: boolean = false;
export let bypassIfAboveCfScore: boolean = false;
export let minimumCfScore: number = 0;
export let initialData: DelayProfileFormData;
// Event handlers
export let onCancel: () => void;
const defaults: DelayProfileFormData = {
name: '',
tags: [],
preferredProtocol: 'prefer_usenet',
usenetDelay: 0,
torrentDelay: 0,
bypassIfHighestQuality: false,
bypassIfAboveCfScore: false,
minimumCfScore: 0
};
if (mode === 'create') {
initCreate(initialData ?? defaults);
} else {
initEdit(initialData);
}
// Loading states
let saving = false;
let deleting = false;
@@ -52,8 +79,8 @@
$: submitButtonText = mode === 'create' ? 'Create Profile' : 'Save Changes';
// Computed states based on protocol
$: showUsenetDelay = preferredProtocol !== 'only_torrent';
$: showTorrentDelay = preferredProtocol !== 'only_usenet';
$: showUsenetDelay = $current.preferredProtocol !== 'only_torrent';
$: showTorrentDelay = $current.preferredProtocol !== 'only_usenet';
const protocolOptions: { value: PreferredProtocol; label: string; description: string }[] = [
{ value: 'prefer_usenet', label: 'Prefer Usenet', description: 'Try Usenet first, fall back to Torrent' },
@@ -62,7 +89,7 @@
{ value: 'only_torrent', label: 'Only Torrent', description: 'Never use Usenet' }
];
$: isValid = name.trim() !== '' && tags.length > 0;
$: isValid = $current.name.trim() !== '' && $current.tags.length > 0;
async function handleSaveClick() {
if (canWriteToBase) {
@@ -121,6 +148,9 @@
alertStore.add('error', (result.data as { error?: string }).error || 'Operation failed');
} else if (result.type === 'redirect') {
alertStore.add('success', mode === 'create' ? 'Delay profile created!' : 'Delay profile updated!');
// Mark as clean so navigation guard doesn't trigger
// Don't call clear() - component is still mounted and needs valid data
initEdit($current as DelayProfileFormData);
}
await update();
saving = false;
@@ -128,13 +158,13 @@
}}
>
<!-- Hidden fields for form data -->
<input type="hidden" name="tags" value={JSON.stringify(tags)} />
<input type="hidden" name="preferredProtocol" value={preferredProtocol} />
<input type="hidden" name="usenetDelay" value={usenetDelay} />
<input type="hidden" name="torrentDelay" value={torrentDelay} />
<input type="hidden" name="bypassIfHighestQuality" value={bypassIfHighestQuality} />
<input type="hidden" name="bypassIfAboveCfScore" value={bypassIfAboveCfScore} />
<input type="hidden" name="minimumCfScore" value={minimumCfScore} />
<input type="hidden" name="tags" value={JSON.stringify($current.tags)} />
<input type="hidden" name="preferredProtocol" value={$current.preferredProtocol} />
<input type="hidden" name="usenetDelay" value={$current.usenetDelay} />
<input type="hidden" name="torrentDelay" value={$current.torrentDelay} />
<input type="hidden" name="bypassIfHighestQuality" value={$current.bypassIfHighestQuality} />
<input type="hidden" name="bypassIfAboveCfScore" value={$current.bypassIfAboveCfScore} />
<input type="hidden" name="minimumCfScore" value={$current.minimumCfScore} />
<input type="hidden" name="layer" value={selectedLayer} />
<!-- Basic Info Section -->
@@ -153,7 +183,8 @@
type="text"
id="name"
name="name"
bind:value={name}
value={$current.name}
oninput={(e) => update('name', e.currentTarget.value)}
placeholder="e.g., Standard Delay"
class="mt-1 block w-full rounded-lg border border-neutral-300 bg-white px-3 py-2 text-sm text-neutral-900 placeholder-neutral-400 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100 dark:placeholder-neutral-500"
/>
@@ -168,7 +199,11 @@
Delay profiles apply to items with matching tags
</p>
<div class="mt-2">
<TagInput bind:tags placeholder="Add tags..." />
<TagInput
tags={$current.tags}
onchange={(newTags) => update('tags', newTags)}
placeholder="Add tags..."
/>
</div>
</div>
</div>
@@ -184,15 +219,15 @@
{#each protocolOptions as option}
<button
type="button"
on:click={() => (preferredProtocol = option.value)}
class="flex items-center gap-3 rounded-lg border p-3 text-left transition-colors {preferredProtocol === option.value
onclick={() => update('preferredProtocol', option.value)}
class="flex items-center gap-3 rounded-lg border p-3 text-left transition-colors {$current.preferredProtocol === option.value
? 'border-accent-500 bg-accent-50 dark:border-accent-400 dark:bg-accent-950'
: 'border-neutral-200 bg-white hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600'}"
>
<div class="flex h-5 w-5 items-center justify-center rounded-full border-2 {preferredProtocol === option.value
<div class="flex h-5 w-5 items-center justify-center rounded-full border-2 {$current.preferredProtocol === option.value
? 'border-accent-500 bg-accent-500 dark:border-accent-400 dark:bg-accent-400'
: 'border-neutral-300 dark:border-neutral-600'}">
{#if preferredProtocol === option.value}
{#if $current.preferredProtocol === option.value}
<Check size={12} class="text-white" />
{/if}
</div>
@@ -224,7 +259,8 @@
<NumberInput
name="usenet-delay"
id="usenet-delay"
bind:value={usenetDelay}
value={$current.usenetDelay}
onchange={(v) => update('usenetDelay', v)}
min={0}
font="mono"
/>
@@ -241,7 +277,8 @@
<NumberInput
name="torrent-delay"
id="torrent-delay"
bind:value={torrentDelay}
value={$current.torrentDelay}
onchange={(v) => update('torrentDelay', v)}
min={0}
font="mono"
/>
@@ -265,7 +302,8 @@
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-neutral-200 bg-white p-3 transition-colors hover:border-neutral-300 dark:border-neutral-700 dark:bg-neutral-800 dark:hover:border-neutral-600">
<input
type="checkbox"
bind:checked={bypassIfHighestQuality}
checked={$current.bypassIfHighestQuality}
onchange={(e) => update('bypassIfHighestQuality', e.currentTarget.checked)}
class="h-4 w-4 rounded border-neutral-300 text-accent-600 focus:ring-accent-500 dark:border-neutral-600 dark:bg-neutral-700"
/>
<div>
@@ -279,7 +317,8 @@
<label class="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
bind:checked={bypassIfAboveCfScore}
checked={$current.bypassIfAboveCfScore}
onchange={(e) => update('bypassIfAboveCfScore', e.currentTarget.checked)}
class="h-4 w-4 rounded border-neutral-300 text-accent-600 focus:ring-accent-500 dark:border-neutral-600 dark:bg-neutral-700"
/>
<div>
@@ -288,7 +327,7 @@
</div>
</label>
{#if bypassIfAboveCfScore}
{#if $current.bypassIfAboveCfScore}
<div class="mt-3 pl-7">
<label for="min-cf-score" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
Minimum Score
@@ -297,7 +336,8 @@
<NumberInput
name="min-cf-score"
id="min-cf-score"
bind:value={minimumCfScore}
value={$current.minimumCfScore}
onchange={(v) => update('minimumCfScore', v)}
font="mono"
/>
</div>
@@ -314,7 +354,7 @@
{#if mode === 'edit'}
<button
type="button"
on:click={handleDeleteClick}
onclick={handleDeleteClick}
class="flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-700 transition-colors hover:bg-red-50 dark:border-red-700 dark:bg-neutral-900 dark:text-red-300 dark:hover:bg-red-900"
>
<Trash2 size={14} />
@@ -327,15 +367,15 @@
<div class="flex gap-3">
<button
type="button"
on:click={onCancel}
onclick={onCancel}
class="flex items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 py-2 text-sm font-medium text-neutral-700 transition-colors hover:bg-neutral-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
Cancel
</button>
<button
type="button"
disabled={saving || !isValid}
on:click={handleSaveClick}
disabled={saving || !isValid || !$isDirty}
onclick={handleSaveClick}
class="flex items-center gap-2 rounded-lg bg-accent-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent-500 dark:hover:bg-accent-600"
>
{#if saving}

View File

@@ -1,20 +1,22 @@
<script lang="ts">
import { goto } from '$app/navigation';
import DelayProfileForm from '../components/DelayProfileForm.svelte';
import DirtyModal from '$ui/modal/DirtyModal.svelte';
import type { PageData } from './$types';
import type { PreferredProtocol } from '$pcd/queries/delayProfiles';
export let data: PageData;
// Form state
let name = '';
let tags: string[] = [];
let preferredProtocol: PreferredProtocol = 'prefer_usenet';
let usenetDelay = 0;
let torrentDelay = 0;
let bypassIfHighestQuality = false;
let bypassIfAboveCfScore = false;
let minimumCfScore = 0;
// Default initial data for create mode
const initialData = {
name: '',
tags: [] as string[],
preferredProtocol: 'prefer_usenet' as const,
usenetDelay: 0,
torrentDelay: 0,
bypassIfHighestQuality: false,
bypassIfAboveCfScore: false,
minimumCfScore: 0
};
function handleCancel() {
goto(`/delay-profiles/${data.currentDatabase.id}`);
@@ -29,13 +31,8 @@
mode="create"
databaseName={data.currentDatabase.name}
canWriteToBase={data.canWriteToBase}
bind:name
bind:tags
bind:preferredProtocol
bind:usenetDelay
bind:torrentDelay
bind:bypassIfHighestQuality
bind:bypassIfAboveCfScore
bind:minimumCfScore
{initialData}
onCancel={handleCancel}
/>
<DirtyModal />