8eb527957b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
7.5 KiB
Markdown
124 lines
7.5 KiB
Markdown
# Toast Notifications + Consistent Mutation Feedback — Design
|
|
|
|
**Date:** 2026-06-07
|
|
**Status:** Approved (brainstorming) — ready for implementation planning.
|
|
**Issue:** #47.
|
|
|
|
## Context
|
|
|
|
Mutations across the app communicate **success only** by side effects (navigation, a dialog
|
|
closing) — a curator doing bulk cataloguing can't tell whether a save landed, which invites
|
|
double-submits and re-checking. **Errors** are inconsistent: inline `<p role="alert">` in the
|
|
forms, in-dialog for deletes, three message kinds in `publish-control`, and some mutation
|
|
failures surface nowhere. There is no toast/notification system (no `toast`/`sonner` dep).
|
|
|
|
### Facts established during exploration
|
|
- The `QueryClient` is created at **module scope** in `web/src/main.tsx` (outside React), with
|
|
`defaultOptions.queries` only — no mutation defaults yet.
|
|
- **Base UI ships a `toast` primitive** (`@base-ui/react/toast`) — no new dependency. It exports
|
|
`createToastManager()`, an **out-of-React manager** you pass to `<Toast.Provider>` and can
|
|
`.add()` from anywhere — the clean bridge to the module-scope queryClient handlers.
|
|
- The `i18n` singleton (`web/src/i18n`) exposes `i18n.t(...)`, callable **outside React**.
|
|
- Existing typed mutation errors (`web/src/api/queries.ts`): `InUseError` (409 + count),
|
|
`FieldRejection` (422 field), `HttpError` (status), `VisibilityError`. The object form shows
|
|
422 as a field highlight; the delete dialogs catch errors and show them inline.
|
|
|
|
### Decisions (from brainstorming)
|
|
1. **Base UI Toast**, bridged via a module-scope `createToastManager()` (no new dep, consistent
|
|
with the combobox/tooltip/drawer wrappers).
|
|
2. **Wiring: global `MutationCache` + per-mutation `meta`.** A global `onError` is a catch-all
|
|
safety net (no silent failures); a global `onSuccess` shows a toast only when a mutation
|
|
declares `meta.successMessage`. Mutations that report errors inline opt out via
|
|
`meta.suppressErrorToast`.
|
|
3. **Keep inline field/dialog errors** (422 highlight, 409 "in use") — toasts add success
|
|
confirmations + a catch-all for otherwise-silent failures, not a replacement.
|
|
|
|
## Components
|
|
|
|
### `ui/toast.tsx` (new) + the manager
|
|
- Module scope (alongside the QueryClient — `main.tsx` or a small `web/src/toast/` module):
|
|
`export const toastManager = Toast.createToastManager();`
|
|
- `web/src/components/ui/toast.tsx` — wrap the Base UI Toast parts (Provider / Viewport /
|
|
Portal / Positioner / Root / Title / Description / Close) in the established `ui/*` style
|
|
(`data-slot`, `cn()`, mirror `ui/alert-dialog.tsx`). Provide a `<ToastRegion>` that renders
|
|
`Toast.Provider` (with `toastManager`) + a `Toast.Viewport` listing the active toasts (mapped
|
|
from `useToastManager().toasts`), styled as stacked cards (success vs error variant via the
|
|
toast's `type`/`data`). The viewport must be an `aria-live` region (Base UI handles the live
|
|
semantics; confirm). **Base UI Toast is novel in this repo → the exact part tree + manager API
|
|
(`createToastManager`, `manager.add({ title?, description, type })`, `useToastManager`) must be
|
|
validated by running** (as the combobox/drawer/tooltip were).
|
|
- Mount `<ToastRegion>` in `main.tsx` inside the provider tree (so it's on every screen).
|
|
|
|
### Global mutation feedback (`main.tsx` / queryClient)
|
|
```ts
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
|
|
mutationCache: new MutationCache({
|
|
onError: (error, _vars, _ctx, mutation) => {
|
|
if (mutation.meta?.suppressErrorToast) return;
|
|
toastManager.add({ type: "error", description: errorMessage(error, mutation) });
|
|
},
|
|
onSuccess: (_data, _vars, _ctx, mutation) => {
|
|
const key = mutation.meta?.successMessage;
|
|
if (key) toastManager.add({ type: "success", description: i18n.t(key) });
|
|
},
|
|
}),
|
|
});
|
|
```
|
|
- `errorMessage(error, mutation)`: `mutation.meta?.errorMessage ? i18n.t(...)` else a type-aware
|
|
default — `InUseError` → `i18n.t("actions.inUse", { count })`, `HttpError` 503 →
|
|
`i18n.t("search.unavailable")`, otherwise `i18n.t("toast.error")`.
|
|
- `meta` is typed via a module augmentation of `@tanstack/react-query`'s `Register` interface so
|
|
`meta.successMessage`/`meta.errorMessage`/`meta.suppressErrorToast` are type-checked (no
|
|
`any`).
|
|
|
|
### Per-mutation meta (`web/src/api/queries.ts`)
|
|
- **`meta.successMessage`** (a `toast.*` key) on the discrete actions: object create/update/
|
|
delete, set-visibility/publish, vocabulary create/rename/delete, term create/update/delete,
|
|
authority create/update/delete, field-definition create/update/delete.
|
|
- **`meta.suppressErrorToast: true`** on mutations that surface errors inline: the object form's
|
|
create/update + `useSetFields` (422 field highlight), `useLogin`, and the delete hooks (the
|
|
`DeleteConfirmDialog`/`DeleteObjectDialog` show the 409/rejection inline). These keep success
|
|
toasts.
|
|
|
|
## Data flow
|
|
A mutation runs → react-query's `MutationCache` fires the global `onSuccess`/`onError` → reads
|
|
`mutation.meta` → `toastManager.add(...)` (message via `i18n.t`) → `<ToastRegion>` (subscribed
|
|
to the manager) renders the toast in its portal/viewport → auto-dismisses (Base UI default) or
|
|
on Close. Inline field/dialog errors render as before (unaffected).
|
|
|
|
## Error handling / edges
|
|
- A mutation with neither `meta` nor inline handling → still gets the catch-all error toast on
|
|
failure (the safety net); silent success (no `successMessage`) is intentional where navigation
|
|
already signals it.
|
|
- No double-report: inline-handling mutations set `suppressErrorToast`.
|
|
- `i18n.t` outside React uses the singleton's current language (already synced via the locale
|
|
hook); a missing key falls back to the generic `toast.error`.
|
|
- Toasts stack + auto-dismiss; an error toast may stay longer / be dismissible (Base UI config).
|
|
|
|
## Testing
|
|
- **Vitest + RTL + MSW:** a mutation declaring `meta.successMessage` shows a success toast
|
|
(query `within(document.body)`); a failing mutation (MSW 500) shows an error toast; a
|
|
`suppressErrorToast` mutation does **not** add a toast on error (no double-report) while its
|
|
inline error still renders; the toast region is present and `aria-live`. Drive these through a
|
|
real screen action (e.g. create a vocabulary → "Created" toast; a 500 → error toast).
|
|
- **Storybook:** a `ui/toast` story rendering a success + an error toast (via the manager), per
|
|
the standing preference; verify the Base UI composition by running.
|
|
- Reuse existing screens' MSW handlers (`web/src/test/handlers.ts`).
|
|
|
|
## Acceptance criteria
|
|
1. A Base UI toast region is mounted app-wide; `toastManager` (out-of-React) is `.add`-able from
|
|
the queryClient handlers. No new npm dependency.
|
|
2. Global `MutationCache`: `onError` toasts a catch-all (type-aware) message unless
|
|
`meta.suppressErrorToast`; `onSuccess` toasts `meta.successMessage` when present.
|
|
3. The discrete CRUD/publish mutations declare `meta.successMessage`; inline-error mutations
|
|
(object form, login, deletes) set `meta.suppressErrorToast` and keep their inline UX.
|
|
4. `meta` is type-checked (react-query `Register` augmentation); no `any`/`eslint-disable`.
|
|
5. en/sv parity for the `toast.*` keys; a toast Storybook story; `pnpm typecheck`/`lint`/`test`/
|
|
`build` green; `check:size` within 180 KB gz; no codename.
|
|
|
|
## Out of scope → follow-ups
|
|
- Replacing the inline 422 field-highlight / 409 "in use" UX with toasts.
|
|
- Undo/action toasts, queued/grouped toasts, per-account toast preferences.
|
|
- Toasting query (read) errors — this milestone covers mutations (the silent-write problem).
|