Add reusable toast notification system
Introduces a runes-based toast module (`$lib/toast.svelte.ts`) with success/info/warning/error variants, auto-dismiss, optional action button, plus a `Toaster` viewport mounted in the root layout. Migrates the lone `alert(e.message)` call site (stores delete) to `toast.error()`. Backlog updated to remove the now-completed foundational item and rewrite dependent items to reference the shipped API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-13
@@ -15,14 +15,6 @@ Prerequisite for the product catalog and unit catalog work — both rely on a `F
|
||||
- **Bootstrap migration for the existing single-family deployment:** at upgrade time, auto-create one `Family` from the current `FAMILY_CODE` env value, assign all existing rows to it, and migrate the env var off. Flag explicitly in the runbook so deployment isn't a surprise.
|
||||
- Auth changes: JWT carries `FamilyId` claim. `OnTokenValidated` rejects tokens whose user has been removed from the family.
|
||||
|
||||
### Toast / notification component (foundational)
|
||||
- App has no toast system today; current error UX is `alert()` on `stores/+page.svelte` delete failures. Several tracked items depend on this:
|
||||
- Duplicate store name → toast (Stores section)
|
||||
- Block create-list when no stores → toast (Lists section)
|
||||
- Confirm-before-delete error surfacing (Stores section)
|
||||
- Build a small reusable Svelte component (success / info / warning / error variants, auto-dismiss with timeout, optional action button for "Undo"), expose via `$lib/toast.ts`.
|
||||
- Migrate existing `alert(e.message)` call sites as part of landing it.
|
||||
|
||||
### Account & session lifecycle
|
||||
- **Password reset.** Currently no flow, no email infrastructure. Required before multi-family launches publicly. Needs SMTP config, email template, single-use token table, rate limiting on the reset endpoint.
|
||||
- **JWT refresh tokens.** Today's tokens are signed with HS256 and (per `Program.cs`) likely have no refresh path; they expire and the user gets bounced to login. Add refresh tokens with rotation + revocation list.
|
||||
@@ -221,8 +213,7 @@ Replace free-form `Quantity` strings with a structured `(Quantity, UnitOfMeasure
|
||||
|
||||
### Block create-list flow when no stores exist
|
||||
- A shopping list requires a `Store` (see `ListSummary.store` and the `newStoreId` state in `lists/+page.svelte`), so the create-list flow shouldn't be available until at least one store exists.
|
||||
- Behavior: if user tries to open the create-list UI (or hits the create-list page directly via URL) with zero stores, surface a toast or modal that says "You need to create a store first" with a CTA linking to `/stores`. Don't render the empty/broken create form.
|
||||
- Belongs with the toast-component work above.
|
||||
- Behavior: if user tries to open the create-list UI (or hits the create-list page directly via URL) with zero stores, surface a `toast.warning()` (or modal) that says "You need to create a store first" with a CTA linking to `/stores`. Don't render the empty/broken create form.
|
||||
|
||||
## Stores
|
||||
|
||||
@@ -239,12 +230,11 @@ Replace free-form `Quantity` strings with a structured `(Quantity, UnitOfMeasure
|
||||
- Add a modal confirmation prompt before deleting a store (currently one click → gone, no undo).
|
||||
- Disallow deletion of a store that has any active shopping lists associated with it.
|
||||
- Backend status: `StoreEndpoints.cs` DELETE already returns 400 with `{error: "Store has shopping lists. Remove them first."}` if any list references the store. Consider switching to 409 Conflict for semantic correctness.
|
||||
- Frontend status: `stores/+page.svelte` `deleteStore` currently shows the backend message via `alert()`. Replace with the new confirmation modal that also surfaces this error inline.
|
||||
- Frontend status: `stores/+page.svelte` `deleteStore` currently surfaces the backend message via `toast.error()`. Replace with the new confirmation modal that also surfaces this error inline (or as a toast on confirm-action failure).
|
||||
- Open question: what counts as "active"? Backend currently blocks on *any* list. Decide whether archived/completed lists should still block deletion.
|
||||
|
||||
### Duplicate store name → 500 + silent frontend (bug)
|
||||
- Adding a store with a name that already exists triggers the unique constraint on `Store.Name`, which leaks out as a 500 from `StoreEndpoints.cs` POST (no exception handling).
|
||||
- Frontend `addStore` in `stores/+page.svelte` swallows the error — the form just sits there with no feedback.
|
||||
- Fix: backend should pre-check (or catch the unique-violation) and return 409 with a helpful message; frontend should display a **toast** (e.g. "A store named 'Kroger' already exists") rather than a silent failure or a blocking `alert()`.
|
||||
- Fix: backend should pre-check (or catch the unique-violation) and return 409 with a helpful message; frontend should display a `toast.error()` (e.g. "A store named 'Kroger' already exists") rather than failing silently.
|
||||
- Same pattern likely affects PUT (rename to an existing name).
|
||||
- Note: app does not currently have a toast system. This work probably needs a small reusable toast component before the duplicate-name handling can land — worth adopting it across the app (the existing `alert(e.message)` calls on delete failures should also move to toasts).
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { getToasts, dismissToast, type ToastVariant } from '$lib/toast.svelte';
|
||||
|
||||
const toasts = $derived(getToasts());
|
||||
|
||||
const variantClasses: Record<ToastVariant, string> = {
|
||||
success: 'bg-primary text-white',
|
||||
info: 'bg-gray-800 text-white',
|
||||
warning: 'bg-amber-500 text-white',
|
||||
error: 'bg-danger text-white'
|
||||
};
|
||||
|
||||
const roleFor = (variant: ToastVariant) =>
|
||||
variant === 'error' || variant === 'warning' ? 'alert' : 'status';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 bottom-20 z-50 flex flex-col items-center gap-2 px-4 toaster-safe"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
>
|
||||
{#each toasts as t (t.id)}
|
||||
<div
|
||||
role={roleFor(t.variant)}
|
||||
class="pointer-events-auto flex w-full max-w-md items-center gap-3 rounded-lg px-4 py-3 shadow-lg {variantClasses[
|
||||
t.variant
|
||||
]}"
|
||||
>
|
||||
<span class="flex-1 text-sm font-medium">{t.message}</span>
|
||||
{#if t.action}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
t.action!.onClick();
|
||||
dismissToast(t.id);
|
||||
}}
|
||||
class="rounded px-2 py-1 text-sm font-semibold underline-offset-2 hover:underline"
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Dismiss notification"
|
||||
onclick={() => dismissToast(t.id)}
|
||||
class="text-lg leading-none opacity-80 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toaster-safe {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
export type ToastVariant = 'success' | 'info' | 'warning' | 'error';
|
||||
|
||||
export interface ToastAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
variant: ToastVariant;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
export interface ToastOptions {
|
||||
variant?: ToastVariant;
|
||||
/** Auto-dismiss after this many ms. Pass 0 to keep the toast until dismissed manually. */
|
||||
duration?: number;
|
||||
action?: ToastAction;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION_MS = 4000;
|
||||
|
||||
let toasts = $state<Toast[]>([]);
|
||||
let nextId = 0;
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>();
|
||||
|
||||
export function getToasts(): Toast[] {
|
||||
return toasts;
|
||||
}
|
||||
|
||||
export function showToast(message: string, options: ToastOptions = {}): number {
|
||||
const id = ++nextId;
|
||||
toasts.push({
|
||||
id,
|
||||
message,
|
||||
variant: options.variant ?? 'info',
|
||||
action: options.action
|
||||
});
|
||||
|
||||
const duration = options.duration ?? DEFAULT_DURATION_MS;
|
||||
if (duration > 0) {
|
||||
const handle = setTimeout(() => dismissToast(id), duration);
|
||||
timers.set(id, handle);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
const handle = timers.get(id);
|
||||
if (handle !== undefined) {
|
||||
clearTimeout(handle);
|
||||
timers.delete(id);
|
||||
}
|
||||
toasts = toasts.filter((t) => t.id !== id);
|
||||
}
|
||||
|
||||
export function clearAllToasts(): void {
|
||||
for (const handle of timers.values()) clearTimeout(handle);
|
||||
timers.clear();
|
||||
toasts = [];
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
success: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
|
||||
showToast(message, { ...options, variant: 'success' }),
|
||||
info: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
|
||||
showToast(message, { ...options, variant: 'info' }),
|
||||
warning: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
|
||||
showToast(message, { ...options, variant: 'warning' }),
|
||||
error: (message: string, options: Omit<ToastOptions, 'variant'> = {}) =>
|
||||
showToast(message, { ...options, variant: 'error' }),
|
||||
dismiss: dismissToast
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { clearToken } from '$lib/api';
|
||||
import { initAuth, isLoggedIn } from '$lib/auth.svelte';
|
||||
import Toaster from '$lib/Toaster.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -60,6 +61,8 @@
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<Toaster />
|
||||
|
||||
<style>
|
||||
.safe-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
|
||||
interface Store {
|
||||
id: number;
|
||||
@@ -50,7 +51,7 @@
|
||||
await api(`/api/stores/${id}`, { method: 'DELETE' });
|
||||
stores = stores.filter((s) => s.id !== id);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
toast.error(e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
clearAllToasts,
|
||||
dismissToast,
|
||||
getToasts,
|
||||
showToast,
|
||||
toast
|
||||
} from '$lib/toast.svelte';
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllToasts();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('showToast()', () => {
|
||||
it('adds a toast with default info variant', () => {
|
||||
const id = showToast('hello');
|
||||
|
||||
const toasts = getToasts();
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0]).toMatchObject({ id, message: 'hello', variant: 'info' });
|
||||
});
|
||||
|
||||
it('returns unique ids per call', () => {
|
||||
const a = showToast('a');
|
||||
const b = showToast('b');
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it('auto-dismisses after the default duration', () => {
|
||||
showToast('temporary');
|
||||
|
||||
expect(getToasts()).toHaveLength(1);
|
||||
vi.advanceTimersByTime(4000);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('respects a custom duration', () => {
|
||||
showToast('quick', { duration: 1000 });
|
||||
|
||||
vi.advanceTimersByTime(999);
|
||||
expect(getToasts()).toHaveLength(1);
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not auto-dismiss when duration is 0', () => {
|
||||
showToast('persistent', { duration: 0 });
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(getToasts()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('attaches an action handler', () => {
|
||||
const onClick = vi.fn();
|
||||
showToast('undo me', { action: { label: 'Undo', onClick } });
|
||||
|
||||
const t = getToasts()[0];
|
||||
expect(t.action?.label).toBe('Undo');
|
||||
t.action?.onClick();
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dismissToast()', () => {
|
||||
it('removes the matching toast and cancels its timer', () => {
|
||||
const id = showToast('bye', { duration: 1000 });
|
||||
|
||||
dismissToast(id);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
|
||||
// If the timer were still active and tried to dismiss again, no harm — just verify state stable.
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a no-op for an unknown id', () => {
|
||||
showToast('still here');
|
||||
dismissToast(9999);
|
||||
expect(getToasts()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toast.* helpers', () => {
|
||||
it.each([
|
||||
['success', 'success'],
|
||||
['info', 'info'],
|
||||
['warning', 'warning'],
|
||||
['error', 'error']
|
||||
] as const)('toast.%s sets variant to %s', (method, variant) => {
|
||||
toast[method]('msg');
|
||||
expect(getToasts()[0].variant).toBe(variant);
|
||||
});
|
||||
|
||||
it('toast.dismiss removes the toast', () => {
|
||||
const id = toast.error('oops', { duration: 0 });
|
||||
toast.dismiss(id);
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAllToasts()', () => {
|
||||
it('removes every toast', () => {
|
||||
showToast('a');
|
||||
showToast('b');
|
||||
showToast('c');
|
||||
|
||||
clearAllToasts();
|
||||
expect(getToasts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user