Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.5 KiB
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
QueryClientis created at module scope inweb/src/main.tsx(outside React), withdefaultOptions.queriesonly — no mutation defaults yet. - Base UI ships a
toastprimitive (@base-ui/react/toast) — no new dependency. It exportscreateToastManager(), 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
i18nsingleton (web/src/i18n) exposesi18n.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)
- Base UI Toast, bridged via a module-scope
createToastManager()(no new dep, consistent with the combobox/tooltip/drawer wrappers). - Wiring: global
MutationCache+ per-mutationmeta. A globalonErroris a catch-all safety net (no silent failures); a globalonSuccessshows a toast only when a mutation declaresmeta.successMessage. Mutations that report errors inline opt out viameta.suppressErrorToast. - 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.tsxor a smallweb/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 establishedui/*style (data-slot,cn(), mirrorui/alert-dialog.tsx). Provide a<ToastRegion>that rendersToast.Provider(withtoastManager) + aToast.Viewportlisting the active toasts (mapped fromuseToastManager().toasts), styled as stacked cards (success vs error variant via the toast'stype/data). The viewport must be anaria-liveregion (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>inmain.tsxinside the provider tree (so it's on every screen).
Global mutation feedback (main.tsx / queryClient)
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 }),HttpError503 →i18n.t("search.unavailable"), otherwisei18n.t("toast.error").metais typed via a module augmentation of@tanstack/react-query'sRegisterinterface someta.successMessage/meta.errorMessage/meta.suppressErrorToastare type-checked (noany).
Per-mutation meta (web/src/api/queries.ts)
meta.successMessage(atoast.*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: trueon mutations that surface errors inline: the object form's create/update +useSetFields(422 field highlight),useLogin, and the delete hooks (theDeleteConfirmDialog/DeleteObjectDialogshow 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
metanor inline handling → still gets the catch-all error toast on failure (the safety net); silent success (nosuccessMessage) is intentional where navigation already signals it. - No double-report: inline-handling mutations set
suppressErrorToast. i18n.toutside React uses the singleton's current language (already synced via the locale hook); a missing key falls back to the generictoast.error.- Toasts stack + auto-dismiss; an error toast may stay longer / be dismissible (Base UI config).
Testing
- Vitest + RTL + MSW: a mutation declaring
meta.successMessageshows a success toast (querywithin(document.body)); a failing mutation (MSW 500) shows an error toast; asuppressErrorToastmutation does not add a toast on error (no double-report) while its inline error still renders; the toast region is present andaria-live. Drive these through a real screen action (e.g. create a vocabulary → "Created" toast; a 500 → error toast). - Storybook: a
ui/toaststory 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
- A Base UI toast region is mounted app-wide;
toastManager(out-of-React) is.add-able from the queryClient handlers. No new npm dependency. - Global
MutationCache:onErrortoasts a catch-all (type-aware) message unlessmeta.suppressErrorToast;onSuccesstoastsmeta.successMessagewhen present. - The discrete CRUD/publish mutations declare
meta.successMessage; inline-error mutations (object form, login, deletes) setmeta.suppressErrorToastand keep their inline UX. metais type-checked (react-queryRegisteraugmentation); noany/eslint-disable.- en/sv parity for the
toast.*keys; a toast Storybook story;pnpm typecheck/lint/test/buildgreen;check:sizewithin 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).