mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-26 10:39:06 +01:00
feat(ux): replace individual save buttons with single "unsaved changes bar" (#1757)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -28,7 +29,7 @@
|
||||
function onDismiss() {
|
||||
const url = new URL(page.url);
|
||||
url.searchParams.delete('emailVerificationState');
|
||||
history.replaceState(null, '', url.toString());
|
||||
replaceState(url, page.state);
|
||||
emailVerificationState = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,10 +40,25 @@
|
||||
|
||||
let unit = $state<DurationUnit>(preferredUnit(input.value));
|
||||
let amount = $state(formatAmount(input.value / minutesPerUnit[unit]));
|
||||
// The minutes that `amount` and `unit` currently represent. Only a value that arrives from
|
||||
// outside (e.g. after discarding changes) re-derives them, so that switching the unit isn't
|
||||
// undone by the rounding of the displayed amount.
|
||||
let displayedMinutes = input.value;
|
||||
|
||||
$effect(() => {
|
||||
const minutes = input.value;
|
||||
if (Object.is(minutes, displayedMinutes)) return;
|
||||
|
||||
displayedMinutes = minutes;
|
||||
if (!Number.isFinite(minutes)) return;
|
||||
unit = preferredUnit(minutes);
|
||||
amount = formatAmount(minutes / minutesPerUnit[unit]);
|
||||
});
|
||||
|
||||
function updateAmount(event: Event) {
|
||||
amount = (event.currentTarget as HTMLInputElement).value;
|
||||
input.value = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
|
||||
displayedMinutes = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
|
||||
input.value = displayedMinutes;
|
||||
}
|
||||
|
||||
function updateUnit(value: string | undefined) {
|
||||
|
||||
@@ -85,7 +85,15 @@
|
||||
{@render children()}
|
||||
{:else if input}
|
||||
{#if type === 'date'}
|
||||
<DatePicker {id} bind:value={input.value as Date} />
|
||||
<DatePicker
|
||||
{id}
|
||||
bind:value={
|
||||
() => input.value as Date | undefined,
|
||||
(value) => {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
/>
|
||||
{:else}
|
||||
<Input
|
||||
aria-invalid={!!input.error}
|
||||
@@ -93,7 +101,12 @@
|
||||
{id}
|
||||
{placeholder}
|
||||
{type}
|
||||
bind:value={input.value}
|
||||
bind:value={
|
||||
() => input.value as string | number | undefined,
|
||||
(value) => {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
{disabled}
|
||||
oninput={(e) => onInput?.(e)}
|
||||
{readonly}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import WebAuthnService from '$lib/services/webauthn-service';
|
||||
import unsavedChangesStoreSvelte from '$lib/stores/unsaved-changes-store.svelte';
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
import { LucideLogOut, LucideUser } from '@lucide/svelte';
|
||||
@@ -11,7 +12,12 @@
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
async function logout() {
|
||||
await webauthnService.logout();
|
||||
if (!unsavedChangesStoreSvelte.hasChanges) {
|
||||
await webauthnService.logout();
|
||||
}
|
||||
|
||||
// Navigate regardless of unsaved changes status
|
||||
// If there are unsaved changes, the save changes bar will shake and navigation will be canceled
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" generics="T extends {id:string}">
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { page as currentPage } from '$app/state';
|
||||
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||
import * as Pagination from '$lib/components/ui/pagination';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
@@ -151,7 +153,7 @@
|
||||
function changePageState(page: number) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set(`${id}-page`, page.toString());
|
||||
history.replaceState(history.state, '', url.toString());
|
||||
replaceState(url, currentPage.state);
|
||||
requestOptions.pagination!.page = page;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { replaceState } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { cn } from '$lib/utils/style.js';
|
||||
import { Tabs as TabsPrimitive } from 'bits-ui';
|
||||
@@ -21,8 +22,8 @@
|
||||
});
|
||||
|
||||
function onTabChange(newValue: string) {
|
||||
if (useHash && page.url.hash !== newValue) {
|
||||
history.replaceState(history.state, '', location.pathname + location.search + `#${newValue}`);
|
||||
if (useHash && page.url.hash.substring(1) !== newValue) {
|
||||
replaceState(location.pathname + location.search + `#${newValue}`, page.state);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import unsavedChanges from '$lib/stores/unsaved-changes-store.svelte';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LucideCircleAlert } from '@lucide/svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
|
||||
const ROW_HEIGHT = 44;
|
||||
const BOTTOM_OFFSET = 20;
|
||||
const VIEWPORT_MARGIN = 16;
|
||||
const INSET = 18;
|
||||
const INSET_BUTTONS = 8;
|
||||
|
||||
const MOTION =
|
||||
'duration-[380ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none';
|
||||
const LAYER = 'absolute top-0 flex items-center whitespace-nowrap';
|
||||
|
||||
type BloomTint = 'pending' | 'success' | 'error';
|
||||
|
||||
let shake = $state(false);
|
||||
let bloom = $state<{ tint: BloomTint }>();
|
||||
let pillWidth = $state<number>();
|
||||
let compact = $state(false);
|
||||
let frame = $state<HTMLElement>();
|
||||
let pendingMessage = $state<HTMLElement>();
|
||||
let statusMessage = $state<HTMLElement>();
|
||||
let actions = $state<HTMLElement>();
|
||||
|
||||
const animate = $derived(!$appConfigStore?.disableAnimations);
|
||||
const status = $derived(unsavedChanges.status);
|
||||
const showingStatus = $derived(!!status);
|
||||
const showingActions = $derived(unsavedChanges.hasChanges);
|
||||
const barOpen = $derived(showingActions || showingStatus);
|
||||
|
||||
// Settled means in view; the other message waits a row height away
|
||||
const pendingSettled = $derived(!showingStatus && !compact);
|
||||
const statusSettled = $derived(showingStatus && !compact);
|
||||
|
||||
// A new object every time, so {#key} remounts the layer and replays the animation
|
||||
function flashBloom(tint: BloomTint) {
|
||||
bloom = { tint };
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (barOpen) flashBloom('pending');
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (status) flashBloom(status.type === 'error' ? 'error' : 'success');
|
||||
});
|
||||
|
||||
function measure() {
|
||||
const message = showingStatus ? statusMessage : pendingMessage;
|
||||
if (!message || !actions || !frame) return;
|
||||
|
||||
// offsetWidth ignores the scale the buttons carry while fading, and rounding the text up keeps its last glyph
|
||||
const end = showingActions ? actions.offsetWidth + INSET_BUTTONS : INSET;
|
||||
const roomy = INSET + Math.ceil(message.getBoundingClientRect().width) + end;
|
||||
|
||||
// An unmeasured frame reports no width, which must not read as "nothing fits"
|
||||
const available = frame.clientWidth ? frame.clientWidth - 2 * VIEWPORT_MARGIN : Infinity;
|
||||
|
||||
compact = showingActions && roomy > available;
|
||||
pillWidth = Math.min(compact ? actions.offsetWidth + 2 * INSET_BUTTONS : roomy, available);
|
||||
}
|
||||
|
||||
$effect(measure);
|
||||
|
||||
// Catches what no state change announces: a longer message, a font loading, the viewport resizing
|
||||
$effect(() => {
|
||||
const layers = [pendingMessage, statusMessage, actions, frame].filter((layer) => !!layer);
|
||||
if (layers.length === 0) return;
|
||||
|
||||
const observer = new ResizeObserver(measure);
|
||||
for (const layer of layers) observer.observe(layer);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function rise(_node: HTMLElement, { duration = 220 } = {}) {
|
||||
return {
|
||||
duration: animate ? duration : 0,
|
||||
easing: cubicOut,
|
||||
css: (t: number, u: number) =>
|
||||
`opacity: ${t}; transform: translateY(${u * 8}px) scale(${0.98 + t * 0.02})`
|
||||
};
|
||||
}
|
||||
|
||||
beforeNavigate((nav) => {
|
||||
if (!unsavedChanges.hasChanges) return;
|
||||
|
||||
// Cancelling a full page unload makes the browser show its own "leave site?" prompt.
|
||||
nav.cancel();
|
||||
if (nav.type === 'leave') return;
|
||||
|
||||
shake = true;
|
||||
flashBloom('pending');
|
||||
});
|
||||
|
||||
// A fresh edit supersedes any status message left over from a previous save.
|
||||
$effect(() => {
|
||||
if (unsavedChanges.hasChanges) unsavedChanges.clearStatus();
|
||||
});
|
||||
|
||||
function handleSaveShortcut(event: KeyboardEvent) {
|
||||
if (event.key.toLowerCase() !== 's' || !(event.metaKey || event.ctrlKey)) return;
|
||||
if (!unsavedChanges.hasChanges || unsavedChanges.saving) return;
|
||||
|
||||
event.preventDefault();
|
||||
void saveAll();
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
if ((await unsavedChanges.saveAll()) === 'invalid') await revealFirstInvalidField();
|
||||
}
|
||||
|
||||
// Brings the first field that failed validation into view, switching to the tab that contains
|
||||
// it if necessary, since the bar is global while the field may sit on a hidden tab.
|
||||
async function revealFirstInvalidField() {
|
||||
await tick();
|
||||
const field = document.querySelector<HTMLElement>(
|
||||
'[aria-invalid="true"], [data-slot="field-error"]'
|
||||
);
|
||||
if (!field) return;
|
||||
|
||||
for (
|
||||
let panel = field.closest<HTMLElement>('[role="tabpanel"]');
|
||||
panel;
|
||||
panel = panel.parentElement?.closest<HTMLElement>('[role="tabpanel"]') ?? null
|
||||
) {
|
||||
const value = panel.dataset.value;
|
||||
if (value === undefined) continue;
|
||||
panel
|
||||
.closest('[data-slot="tabs"]')
|
||||
?.querySelector<HTMLElement>(`[role="tab"][data-value="${CSS.escape(value)}"]`)
|
||||
?.click();
|
||||
}
|
||||
|
||||
await tick();
|
||||
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
field.focus({ preventScroll: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleSaveShortcut} />
|
||||
|
||||
<!-- Belongs to the message, so it leaves with it and never counts towards the buttons' width -->
|
||||
{#snippet divider()}
|
||||
<span class="bg-border ml-2.5 mr-2 h-4.5 w-px shrink-0"></span>
|
||||
{/snippet}
|
||||
|
||||
{#if barOpen}
|
||||
<div
|
||||
bind:this={frame}
|
||||
class={cn('fixed inset-x-0 z-50 flex', compact ? 'justify-end' : 'justify-center')}
|
||||
style:bottom="{BOTTOM_OFFSET}px"
|
||||
style:padding-inline="{VIEWPORT_MARGIN}px"
|
||||
transition:rise
|
||||
class:animate-shake={shake}
|
||||
onanimationend={() => (shake = false)}
|
||||
>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class={cn(
|
||||
'bg-popover text-popover-foreground shadow-raised dark:bg-secondary relative isolate max-w-full overflow-hidden rounded-full border',
|
||||
animate && `transition-[width] ${MOTION}`
|
||||
)}
|
||||
style:height="{ROW_HEIGHT}px"
|
||||
style:width={pillWidth ? `${pillWidth}px` : undefined}
|
||||
>
|
||||
{#if animate && bloom}
|
||||
{#key bloom}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="animate-bloom pointer-events-none absolute inset-0 -z-1 bloom-{bloom.tint}"
|
||||
></span>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
<span
|
||||
bind:this={pendingMessage}
|
||||
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
|
||||
style:height="{ROW_HEIGHT}px"
|
||||
style:left="{INSET}px"
|
||||
style:translate={pendingSettled ? '0' : `0 -${ROW_HEIGHT}px`}
|
||||
inert={!pendingSettled}
|
||||
>
|
||||
{#if unsavedChanges.dirtyCount > 1}
|
||||
<span
|
||||
class="bg-primary/15 text-foreground flex size-5 items-center justify-center rounded-full font-bold tabular-nums"
|
||||
>
|
||||
{unsavedChanges.dirtyCount}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="text-sm font-semibold tracking-tight">{m.you_have_unsaved_changes()}</span>
|
||||
{@render divider()}
|
||||
</span>
|
||||
|
||||
<span
|
||||
bind:this={statusMessage}
|
||||
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
|
||||
style:height="{ROW_HEIGHT}px"
|
||||
style:left="{INSET}px"
|
||||
style:translate={statusSettled ? '0' : `0 ${ROW_HEIGHT}px`}
|
||||
inert={!statusSettled}
|
||||
>
|
||||
{#if status?.type === 'error'}
|
||||
<LucideCircleAlert class="text-destructive size-4 shrink-0" />
|
||||
{/if}
|
||||
<span class="text-sm font-semibold tracking-tight">{status?.message ?? ''}</span>
|
||||
{#if showingActions}
|
||||
{@render divider()}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span
|
||||
bind:this={actions}
|
||||
class={cn(
|
||||
LAYER,
|
||||
animate && `transition-[opacity,scale] ${MOTION}`,
|
||||
!showingActions && 'scale-[0.96] opacity-0'
|
||||
)}
|
||||
style:height="{ROW_HEIGHT}px"
|
||||
style:right="{INSET_BUTTONS}px"
|
||||
inert={!showingActions}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="hover:bg-foreground/10 dark:hover:bg-foreground/10"
|
||||
disabled={unsavedChanges.saving}
|
||||
onclick={() => unsavedChanges.discardAll()}
|
||||
>
|
||||
{m.discard()}
|
||||
</Button>
|
||||
<Button class="ml-1.5" size="sm" isLoading={unsavedChanges.saving} onclick={saveAll}>
|
||||
{m.save()}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getAxiosErrorMessage } from '$lib/utils/error-util';
|
||||
import { isAxiosError } from 'axios';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
export interface UnsavedSection {
|
||||
readonly dirty: boolean;
|
||||
validate?: () => boolean;
|
||||
save: () => Promise<void>;
|
||||
discard: () => void;
|
||||
}
|
||||
|
||||
export type SaveResult = 'saved' | 'invalid' | 'failed';
|
||||
|
||||
const STATUS_DISPLAY_MS = 3000;
|
||||
|
||||
const sections = new SvelteSet<UnsavedSection>();
|
||||
let saving = $state(false);
|
||||
let status = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
let statusTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const dirtyCount = $derived([...sections].filter((section) => section.dirty).length);
|
||||
const hasChanges = $derived(dirtyCount > 0);
|
||||
|
||||
function register(section: UnsavedSection) {
|
||||
sections.add(section);
|
||||
}
|
||||
|
||||
function unregister(section: UnsavedSection) {
|
||||
sections.delete(section);
|
||||
}
|
||||
|
||||
function clearStatus() {
|
||||
clearTimeout(statusTimeout);
|
||||
status = null;
|
||||
}
|
||||
|
||||
function showStatus(type: 'success' | 'error', message: string) {
|
||||
clearTimeout(statusTimeout);
|
||||
status = { type, message };
|
||||
statusTimeout = setTimeout(() => (status = null), STATUS_DISPLAY_MS);
|
||||
}
|
||||
|
||||
function errorMessage(e: unknown) {
|
||||
if (isAxiosError(e)) return getAxiosErrorMessage(e);
|
||||
return e instanceof Error ? e.message : m.an_unknown_error_occurred();
|
||||
}
|
||||
|
||||
async function saveAll(): Promise<SaveResult> {
|
||||
const dirtySections = [...sections].filter((section) => section.dirty);
|
||||
if (dirtySections.length === 0) return 'saved';
|
||||
|
||||
// Every section is validated before anything is persisted, so that a validation error in one section can't leave the page half-saved.
|
||||
const allValid = dirtySections.map((section) => section.validate?.() ?? true).every(Boolean);
|
||||
if (!allValid) {
|
||||
showStatus('error', m.please_fix_the_errors_before_saving());
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
saving = true;
|
||||
let failure: { error: unknown } | undefined;
|
||||
// Saved one after another rather than in parallel: several sections of a page can target the same endpoint.
|
||||
for (const section of dirtySections) {
|
||||
try {
|
||||
await section.save();
|
||||
} catch (error) {
|
||||
failure ??= { error };
|
||||
}
|
||||
}
|
||||
saving = false;
|
||||
|
||||
if (failure) {
|
||||
showStatus('error', errorMessage(failure.error));
|
||||
return 'failed';
|
||||
}
|
||||
showStatus('success', m.changes_saved_successfully());
|
||||
return 'saved';
|
||||
}
|
||||
|
||||
function discardAll() {
|
||||
clearStatus();
|
||||
for (const section of sections) {
|
||||
if (section.dirty) section.discard();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
get hasChanges() {
|
||||
return hasChanges;
|
||||
},
|
||||
get dirtyCount() {
|
||||
return dirtyCount;
|
||||
},
|
||||
get saving() {
|
||||
return saving;
|
||||
},
|
||||
get status() {
|
||||
return status;
|
||||
},
|
||||
register,
|
||||
unregister,
|
||||
saveAll,
|
||||
discardAll,
|
||||
clearStatus
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { reactiveState } from '$lib/utils/reactive-state.svelte';
|
||||
import { get, writable } from 'svelte/store';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
@@ -12,22 +13,28 @@ type FormInputs<T> = {
|
||||
};
|
||||
|
||||
export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValues: z.infer<T>) {
|
||||
// Create a writable store for the inputs
|
||||
const inputsStore = writable<FormInputs<z.infer<T>>>(initializeInputs(initialValues));
|
||||
// The values the inputs are compared against and restored to. Kept as a private deep copy so
|
||||
// that editing an object or array field can't change what it is supposed to be compared with.
|
||||
let baseline: z.infer<T> = deepCopy(initialValues);
|
||||
|
||||
// The inputs are deeply reactive, so that field components can update them in place
|
||||
// (`input.value = ...`) and anything derived from them, such as the unsaved-changes
|
||||
// tracking, still re-evaluates.
|
||||
const inputsStore = writable<FormInputs<z.infer<T>>>(reactiveState(initializeInputs()));
|
||||
const errorsStore = writable<z.ZodError<any> | undefined>();
|
||||
|
||||
function initializeInputs(initialValues: z.infer<T>): FormInputs<z.infer<T>> {
|
||||
function initializeInputs(): FormInputs<z.infer<T>> {
|
||||
const inputs: FormInputs<z.infer<T>> = {} as FormInputs<z.infer<T>>;
|
||||
|
||||
const shape =
|
||||
schema instanceof z.ZodObject ? (schema.shape as Record<string, z.ZodTypeAny>) : {};
|
||||
|
||||
for (const key in initialValues) {
|
||||
if (Object.prototype.hasOwnProperty.call(initialValues, key)) {
|
||||
for (const key in baseline) {
|
||||
if (Object.prototype.hasOwnProperty.call(baseline, key)) {
|
||||
const fieldSchema = shape[key];
|
||||
|
||||
inputs[key as keyof z.infer<T>] = {
|
||||
value: initialValues[key as keyof z.infer<T>],
|
||||
value: deepCopy(baseline[key as keyof z.infer<T>]),
|
||||
error: null,
|
||||
required: fieldSchema ? isRequired(fieldSchema) : false
|
||||
};
|
||||
@@ -37,40 +44,31 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
}
|
||||
|
||||
function validate() {
|
||||
let success = true;
|
||||
inputsStore.update((inputs) => {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(inputs).map(([key, input]) => [key, input.value])
|
||||
);
|
||||
const inputs = get(inputsStore);
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(inputs).map(([key, input]) => [key, trimValue(input.value)])
|
||||
);
|
||||
const result = schema.safeParse(values);
|
||||
errorsStore.set(result.error);
|
||||
|
||||
const result = schema.safeParse(values);
|
||||
errorsStore.set(result.error);
|
||||
|
||||
if (!result.success) {
|
||||
success = false;
|
||||
for (const input of Object.keys(inputs)) {
|
||||
const error = result.error.issues.find((e) => e.path[0] === input);
|
||||
if (error) {
|
||||
inputs[input as keyof z.infer<T>].error = error.message;
|
||||
} else {
|
||||
inputs[input as keyof z.infer<T>].error = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const input of Object.keys(inputs)) {
|
||||
inputs[input as keyof z.infer<T>].error = null;
|
||||
}
|
||||
}
|
||||
// Set the error messages for each input based on the validation result
|
||||
for (const input of Object.keys(inputs)) {
|
||||
inputs[input as keyof z.infer<T>].error = result.success
|
||||
? null
|
||||
: (result.error.issues.find((e) => e.path[0] === input)?.message ?? null);
|
||||
}
|
||||
|
||||
// If the validation succeeded, update the baseline with the new valid values
|
||||
if (result.success) {
|
||||
for (const key in result.data) {
|
||||
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
|
||||
inputs[key as keyof z.infer<T>].value = result.data[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inputs;
|
||||
});
|
||||
return success ? data() : null;
|
||||
inputsStore.set(inputs);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
function data() {
|
||||
@@ -86,13 +84,37 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
return values;
|
||||
}
|
||||
|
||||
function isDirty() {
|
||||
const inputs = get(inputsStore);
|
||||
return Object.keys(inputs).some(
|
||||
(key) => !deepEqual(inputs[key as keyof z.infer<T>].value, baseline[key as keyof z.infer<T>])
|
||||
);
|
||||
}
|
||||
|
||||
// Moves the baseline forward, e.g. after a successful save whose data isn't reflected back
|
||||
// into a reactive prop that would otherwise rebuild the form.
|
||||
function commit(values: z.infer<T>) {
|
||||
baseline = { ...baseline, ...deepCopy(values) };
|
||||
// Notify subscribers, so that anything comparing the inputs against the baseline
|
||||
// (e.g. the unsaved-changes tracking) is re-evaluated.
|
||||
inputsStore.update((inputs) => inputs);
|
||||
}
|
||||
|
||||
function clearErrors() {
|
||||
errorsStore.set(undefined);
|
||||
inputsStore.update((inputs) => {
|
||||
for (const input of Object.values(inputs) as FormInput<unknown>[]) input.error = null;
|
||||
return inputs;
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
inputsStore.update((inputs) => {
|
||||
for (const input of Object.keys(inputs)) {
|
||||
const current = inputs[input as keyof z.infer<T>];
|
||||
inputs[input as keyof z.infer<T>] = {
|
||||
...current,
|
||||
value: initialValues[input as keyof z.infer<T>],
|
||||
value: deepCopy(baseline[input as keyof z.infer<T>]),
|
||||
error: null
|
||||
};
|
||||
}
|
||||
@@ -154,6 +176,57 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
data,
|
||||
validate,
|
||||
setValue,
|
||||
reset
|
||||
reset,
|
||||
clearErrors,
|
||||
isDirty,
|
||||
commit
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the properties declared by `schema` out of a larger object, e.g. to build a form for one
|
||||
* section of the application configuration.
|
||||
*/
|
||||
export function pickSchemaValues<T extends z.ZodObject>(schema: T, values: z.infer<T>): z.infer<T> {
|
||||
return Object.fromEntries(
|
||||
Object.keys(schema.shape).map((key) => [key, (values as Record<string, unknown>)[key]])
|
||||
) as z.infer<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a deep copy of the given value, ensuring that nested objects and arrays are also copied.
|
||||
*/
|
||||
export function deepCopy<T>(value: T): T {
|
||||
if (Array.isArray(value)) return value.map(deepCopy) as T;
|
||||
if (value instanceof Date) return new Date(value) as T;
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, deepCopy(item)])
|
||||
) as T;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a deep comparison between two values to determine if they are equivalent.
|
||||
*/
|
||||
export function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (Object.is(a, b)) return true;
|
||||
if (typeof a !== typeof b || a === null || b === null) return false;
|
||||
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
||||
return a.every((item, i) => deepEqual(item, b[i]));
|
||||
}
|
||||
|
||||
if (typeof a === 'object' && typeof b === 'object') {
|
||||
const aKeys = Object.keys(a as object);
|
||||
const bKeys = Object.keys(b as object);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
return aKeys.every((key) =>
|
||||
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Wraps a value in deeply reactive state, so that reads of it inside `$derived` and `$effect`
|
||||
* are tracked even when it is mutated in place afterwards. Lives in its own module because
|
||||
* runes are only available in `.svelte.ts` files.
|
||||
*/
|
||||
export function reactiveState<T>(value: T): T {
|
||||
const state = $state(value);
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import unsavedChanges, { type UnsavedSection } from '$lib/stores/unsaved-changes-store.svelte';
|
||||
import { deepCopy, deepEqual } from '$lib/utils/form-util';
|
||||
import { untrack } from 'svelte';
|
||||
import { fromStore, type Readable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Registers a section of a page (e.g. a card) with the unsaved-changes bar.
|
||||
* The section is unregistered when the calling component is destroyed.
|
||||
*/
|
||||
export function trackUnsavedSection(
|
||||
dirty: () => boolean,
|
||||
save: () => Promise<void>,
|
||||
discard: () => void,
|
||||
options: { validate?: () => boolean } = {}
|
||||
) {
|
||||
const isDirty = $derived.by(dirty);
|
||||
const section: UnsavedSection = {
|
||||
get dirty() {
|
||||
return isDirty;
|
||||
},
|
||||
validate: options.validate,
|
||||
save,
|
||||
discard
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
unsavedChanges.register(section);
|
||||
return () => unsavedChanges.unregister(section);
|
||||
});
|
||||
}
|
||||
|
||||
interface TrackedForm<T> {
|
||||
inputs: Readable<unknown>;
|
||||
isDirty: () => boolean;
|
||||
validate: () => T | null;
|
||||
commit: (data: T) => void;
|
||||
reset: () => void;
|
||||
clearErrors: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around `trackUnsavedSection` for forms built with `createForm`.
|
||||
*/
|
||||
export function trackFormChanges<T>(
|
||||
getForm: () => TrackedForm<T>,
|
||||
onSave: (data: T) => Promise<unknown>,
|
||||
extra: { dirty?: () => boolean; discard?: () => void; enabled?: () => boolean } = {}
|
||||
) {
|
||||
const inputs = $derived(fromStore(getForm().inputs));
|
||||
const isDirty = $derived.by(() => {
|
||||
// The inputs are deeply reactive, so edits are tracked through the reads in `isDirty()`.
|
||||
// Reading the store on top of that picks up `commit()`, which moves the baseline the
|
||||
// inputs are compared against without touching the inputs themselves.
|
||||
void inputs.current;
|
||||
if (extra.enabled && !extra.enabled()) return false;
|
||||
return getForm().isDirty() || (extra.dirty?.() ?? false);
|
||||
});
|
||||
|
||||
// Validation errors are only meaningful while there is something to save: a field that is
|
||||
// edited back to its saved value would otherwise keep showing the error of a failed save.
|
||||
$effect(() => {
|
||||
if (!isDirty) untrack(() => getForm().clearErrors());
|
||||
});
|
||||
|
||||
trackUnsavedSection(
|
||||
() => isDirty,
|
||||
async () => {
|
||||
const form = getForm();
|
||||
const data = form.validate();
|
||||
// The bar validates every section before saving, so this only happens if the inputs
|
||||
// changed in between.
|
||||
if (!data) throw new Error(m.please_fix_the_errors_before_saving());
|
||||
await onSave(data);
|
||||
form.commit(data);
|
||||
},
|
||||
() => {
|
||||
getForm().reset();
|
||||
extra.discard?.();
|
||||
},
|
||||
{ validate: () => getForm().validate() !== null }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper around `trackUnsavedSection` for a value that is edited in place rather
|
||||
* than through `createForm`, typically a list bound to a selection component.
|
||||
*/
|
||||
export function trackUnsavedValue<T>(
|
||||
read: () => T,
|
||||
write: (value: T) => void,
|
||||
save: (value: T) => Promise<unknown>
|
||||
) {
|
||||
let saved = $state.raw(deepCopy(read()));
|
||||
|
||||
// Reads again rather than reusing the value that was saved, so that a caller that replaces
|
||||
// the value with the server's response is taken into account.
|
||||
const markSaved = () => {
|
||||
saved = deepCopy(read());
|
||||
};
|
||||
|
||||
trackUnsavedSection(
|
||||
() => !deepEqual(read(), saved),
|
||||
async () => {
|
||||
await save(deepCopy(read()));
|
||||
markSaved();
|
||||
},
|
||||
() => write(deepCopy(saved))
|
||||
);
|
||||
|
||||
return { markSaved };
|
||||
}
|
||||
Reference in New Issue
Block a user