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.
|
- **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.
|
- 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
|
### 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.
|
- **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.
|
- **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
|
### 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.
|
- 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.
|
- 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.
|
||||||
- Belongs with the toast-component work above.
|
|
||||||
|
|
||||||
## Stores
|
## 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).
|
- 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.
|
- 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.
|
- 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.
|
- 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)
|
### 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).
|
- 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.
|
- 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).
|
- 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 { page } from '$app/state';
|
||||||
import { clearToken } from '$lib/api';
|
import { clearToken } from '$lib/api';
|
||||||
import { initAuth, isLoggedIn } from '$lib/auth.svelte';
|
import { initAuth, isLoggedIn } from '$lib/auth.svelte';
|
||||||
|
import Toaster from '$lib/Toaster.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
@@ -60,6 +61,8 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Toaster />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.safe-bottom {
|
.safe-bottom {
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
import { toast } from '$lib/toast.svelte';
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
await api(`/api/stores/${id}`, { method: 'DELETE' });
|
await api(`/api/stores/${id}`, { method: 'DELETE' });
|
||||||
stores = stores.filter((s) => s.id !== id);
|
stores = stores.filter((s) => s.id !== id);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e.message);
|
toast.error(e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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