docs(plans): toast notifications + mutation feedback (#47)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 12:30:30 +02:00
parent 8eb527957b
commit 63bfff417b
@@ -0,0 +1,218 @@
# Toast Notifications + Consistent Mutation Feedback — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
**Goal:** Add a Base UI toast system bridged to the out-of-React `QueryClient`, so every mutation gives consistent feedback — a per-mutation success toast (opt-in via `meta.successMessage`) and a catch-all error toast (unless `meta.suppressErrorToast`) — while keeping the existing inline 422/409 UX.
**Architecture:** A module-scope `createToastManager()` is passed to a `<ToastRegion>` (`Toast.Provider` + portaled viewport) mounted app-wide, and `.add()`-ed from a `MutationCache` on the `QueryClient` (`onError`/`onSuccess` read `mutation.meta` + `i18n.t` outside React). The 18 mutation hooks declare `meta`. `meta` is type-checked via a react-query `Register` augmentation.
**Tech Stack:** React 19 + TS + pnpm, `@base-ui/react` toast (already a dep), TanStack Query, react-i18next, Vitest+RTL+MSW, Storybook 10.
**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity; no codename; portal queries via `within(document.body)`; `check:size` ≤ 180 KB gz.
**Spec:** `docs/superpowers/specs/2026-06-07-toast-notifications-design.md`
**Base UI Toast facts (validated from the d.ts):** `createToastManager()``{ add(opts) => id, close, update, promise }` (works outside React; `add({ title?, description?, type?, timeout?, priority? })`, `type` is a free-form string, re-`add` with same `id` updates in place). `Toast.Provider` accepts `toastManager`. Render: `const { toasts } = Toast.useToastManager(); toasts.map(t => <Toast.Root toast={t}>…)`. Parts: Provider / Viewport / Portal / Positioner / Root(requires `toast` prop) / Title(`<h2>`) / Description(`<p>`) / Close(`<button>`) / Action / Arrow. Title/Description read the toast's title/description from `ToastRootContext` (no children needed — **verify by running**). The wrapper pattern to mirror is `web/src/components/ui/alert-dialog.tsx`.
---
# Task 1: Toast infrastructure (manager, region, MutationCache wiring, meta typing, i18n, story)
**Files:** create `web/src/toast/toast-manager.ts`, `web/src/components/ui/toast.tsx`, `web/src/components/ui/toast.stories.tsx`, `web/src/api/react-query.d.ts`; modify `web/src/main.tsx`, `web/src/i18n/{en,sv}.json`.
- [ ] **Step 1: Module-scope manager** `web/src/toast/toast-manager.ts`:
```ts
import { createToastManager } from "@base-ui/react/toast";
/** A toast manager created outside React so non-React code (the QueryClient
* MutationCache) can add toasts. Passed to <Toast.Provider toastManager=…>. */
export const toastManager = createToastManager();
```
- [ ] **Step 2: `ui/toast.tsx`** — wrap the Base UI Toast parts (mirror `ui/alert-dialog.tsx`: `data-slot`, `cn()`), and export a `<ToastRegion>`:
```tsx
import { Toast as ToastPrimitive } from "@base-ui/react/toast";
import { cn } from "@/lib/utils";
import { toastManager } from "@/toast/toast-manager";
function ToastList() {
const { toasts } = ToastPrimitive.useToastManager();
return toasts.map((toast) => (
<ToastPrimitive.Root
key={toast.id}
toast={toast}
data-slot="toast"
className={cn(
"flex items-start gap-2 rounded-md border bg-white p-3 text-sm shadow-md",
toast.type === "error" && "border-red-300",
)}
>
<div className="flex-1">
{toast.title && <ToastPrimitive.Title data-slot="toast-title" className="font-medium" />}
<ToastPrimitive.Description data-slot="toast-description" className="text-neutral-700" />
</div>
<ToastPrimitive.Close
data-slot="toast-close"
aria-label="Close"
className="text-neutral-400 hover:text-neutral-700"
>
×
</ToastPrimitive.Close>
</ToastPrimitive.Root>
));
}
/** App-wide toast region: provides the external manager + a portaled viewport. */
export function ToastRegion({ children }: { children: React.ReactNode }) {
return (
<ToastPrimitive.Provider toastManager={toastManager}>
{children}
<ToastPrimitive.Portal>
<ToastPrimitive.Viewport className="fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
<ToastList />
</ToastPrimitive.Viewport>
</ToastPrimitive.Portal>
</ToastPrimitive.Provider>
);
}
```
**Validate by running** (first toast in the repo): confirm `Title`/`Description` auto-render the toast's `title`/`description` from context (if they DON'T, pass `{toast.title}`/`{toast.description}` as children); confirm `Viewport`/`Positioner` nesting (Base UI may require a `Toast.Positioner` inside the viewport per toast — adjust to the real API when the story runs). Keep the styled-by-`type` distinction.
- [ ] **Step 3: meta typing** `web/src/api/react-query.d.ts`:
```ts
import "@tanstack/react-query";
declare module "@tanstack/react-query" {
interface Register {
mutationMeta: {
/** i18n key for a success toast (opt-in). */
successMessage?: string;
/** i18n key overriding the default error toast message. */
errorMessage?: string;
/** Skip the global error toast (the component shows the error inline). */
suppressErrorToast?: boolean;
};
}
}
```
- [ ] **Step 4: Wire the `MutationCache`** in `web/src/main.tsx`. Import the manager, the i18n instance (the configured singleton — confirm the default export of `web/src/i18n`; the app already does `import "./i18n"`), and the typed errors:
```tsx
import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import i18n from "./i18n";
import { toastManager } from "./toast/toast-manager";
import { ToastRegion } from "./components/ui/toast";
import { InUseError, HttpError } from "./api/queries";
import type { MutationMeta } from "@tanstack/react-query"; // for the helper's param type
function mutationErrorMessage(error: unknown, meta: MutationMeta | undefined): string {
if (meta?.errorMessage) return i18n.t(meta.errorMessage);
if (error instanceof InUseError) return i18n.t("actions.inUse", { count: error.count });
if (error instanceof HttpError && error.status === 503) return i18n.t("search.unavailable");
return i18n.t("toast.error");
}
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: mutationErrorMessage(error, mutation.meta) });
},
onSuccess: (_data, _vars, _ctx, mutation) => {
if (mutation.meta?.successMessage) {
toastManager.add({ type: "success", description: i18n.t(mutation.meta.successMessage) });
}
},
}),
});
```
And mount the region around `<App/>`:
```tsx
<QueryClientProvider client={queryClient}>
<ConfigProvider>
<ToastRegion>
<App />
</ToastRegion>
</ConfigProvider>
</QueryClientProvider>
```
(If `i18n` has no default export, import the instance it does export, or `import i18n from "i18next"` only if that's the configured instance — use whatever `web/src/i18n` exports; the goal is the *configured* instance so `t` resolves the app's keys/language.)
- [ ] **Step 5: i18n** — add a `toast` namespace to **both** `en.json` + `sv.json`:
`{ "created": "Created"/"Skapat", "saved": "Saved"/"Sparat", "updated": "Updated"/"Uppdaterat", "deleted": "Deleted"/"Borttaget", "renamed": "Renamed"/"Namn ändrat", "published": "Visibility updated"/"Synlighet uppdaterad", "error": "Something went wrong"/"Något gick fel" }`.
- [ ] **Step 6: Story** `web/src/components/ui/toast.stories.tsx` — render `<ToastRegion>` and, in `play`, call `toastManager.add({ type: "success", description: "Saved" })` (and an error one), asserting the toast text appears (portal → `within(document.body)`). Mirror the established story format. This is the **validation** that the Base UI composition is correct — iterate until green.
- [ ] **Step 7:** `cd web && pnpm test -- toast && pnpm typecheck && pnpm lint`. The toast must actually render. **Commit** `feat(web): Base UI toast region + global mutation feedback wiring (#47)`.
---
# Task 2: Declare `meta` on the mutation hooks + integration tests
**Files:** `web/src/api/queries.ts`; a test (e.g. `web/src/objects/publish-control.test.tsx` or a new `web/src/api/mutation-feedback.test.tsx`).
Add a `meta` option to each `useMutation({...})` per the rule:
- **`meta.successMessage`** (a `toast.*` key) on every discrete user action.
- **`meta.suppressErrorToast: true`** on mutations whose consuming component **already renders the error inline** (so no double-report).
| Hook | `successMessage` | `suppressErrorToast` | Why suppress |
|---|---|---|---|
| `useCreateObject` | `toast.created` | yes | object form shows `form.rejected` inline |
| `useUpdateObject` | `toast.saved` | yes | object form inline |
| `useSetFields` | — (the create/update toast covers the save) | yes | 422 field-highlight inline; no own success toast to avoid a double "saved" |
| `useDeleteObject` | `toast.deleted` | yes | `DeleteObjectDialog` shows error inline |
| `useSetVisibility` | `toast.published` | yes | `publish-control` shows error inline |
| `useLogin` | — | yes | login page shows error inline |
| `useLogout` | — | — | fire-and-forget |
| `useCreateVocabulary` | `toast.created` | yes | vocab create form shows `form.rejected` |
| `useRenameVocabulary` | `toast.renamed` | yes | vocab rename shows `form.rejected` |
| `useDeleteVocabulary` | `toast.deleted` | yes | delete dialog inline (409) |
| `useAddTerm` | `toast.created` | yes | add-term form inline |
| `useUpdateTerm` | `toast.saved` | **no** | TermRow has no inline error → let the toast be the feedback |
| `useDeleteTerm` | `toast.deleted` | yes | delete dialog inline |
| `useCreateAuthority` | `toast.created` | yes | authority create form inline |
| `useUpdateAuthority` | `toast.saved` | **no** | AuthorityRow has no inline error |
| `useDeleteAuthority` | `toast.deleted` | yes | delete dialog inline |
| `useCreateFieldDefinition` | `toast.created` | yes | field form inline |
| `useUpdateFieldDefinition` | `toast.saved` | **no** | field-form edit may lack inline error |
| `useDeleteFieldDefinition` | `toast.deleted` | yes | delete dialog inline |
- [ ] **Step 1: VERIFY the suppress column per component.** For each hook, open its consumer and check whether it visibly renders `isError`/catches+shows the error. Set `suppressErrorToast` **iff** it does. (The table is the expected mapping; correct any row that doesn't match the actual component — the principle governs: suppress only when the error is already shown inline. Update the "Why" if you change a row.)
- [ ] **Step 2: Add `meta` to each hook.** E.g.:
```ts
return useMutation({
mutationFn: async (body: NewVocabularyRequest) => { ... },
onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
meta: { successMessage: "toast.created", suppressErrorToast: true },
});
```
Leave `mutationFn`/`onSuccess` unchanged; only add the `meta` line.
- [ ] **Step 3: Integration test** (`mutation-feedback.test.tsx`, RTL + MSW + the `renderApp` harness incl. `<ToastRegion>` — ensure the test render tree wraps with `ToastRegion` + the real `queryClient` MutationCache; if `renderApp` doesn't, add a variant that does, or test via `main`-equivalent providers):
- **Success:** perform a create-vocabulary action (or call a `meta.successMessage` mutation) → a "Created" toast appears (`within(document.body)`).
- **Error (catch-all):** MSW returns 500 for a non-suppressed mutation (e.g. `useUpdateTerm`) → an error toast appears.
- **Suppressed:** a `suppressErrorToast` mutation failing → **no** toast added (and its inline error still shows). Assert the toast region has no error toast.
- (Testing the MutationCache requires the real cache — construct a `QueryClient` with the same `mutationCache` config in the test wrapper, or export a `makeQueryClient()` factory from a shared module and use it in both `main.tsx` and tests. Prefer extracting the cache config into a small `web/src/api/query-client.ts` factory to avoid duplicating it in tests — do this if it keeps the test honest.)
- [ ] **Step 4:** `cd web && pnpm test && pnpm typecheck && pnpm lint`. All green. **Commit** `feat(web): per-mutation success/error toast metadata (#47)`.
---
# Task 3: Final verification
- [ ] `cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size` — all green; index ≤ 180 KB gz (Base UI toast adds to the always-loaded shell; report the number — if it pushes over, lazy-load is hard for a global region, so flag for a budget decision).
- [ ] `pnpm test -- i18n` (en/sv parity for `toast.*`); `git grep -in 'biggus\|dickus' -- web/src || echo CLEAN`; `git status --short` clean.
- [ ] **Manual smoke (recommended):** with the stack up, create a vocabulary → "Created" toast; trigger a failure (e.g. duplicate key) → error toast or the existing inline message (no double); delete a term in use → the dialog's "used by N" (no extra toast).
---
## Self-Review (completed)
**Spec coverage:** Base UI toast region + external manager (T1 S1S2, S6); global MutationCache onError catch-all + onSuccess meta-driven (T1 S4); meta typing (T1 S3); per-mutation meta (T2); inline 422/409 kept (suppress flags, T2); toast i18n + parity (T1 S5, T3); story (T1 S6); verification/bundle (T3). ✓ Out of scope (replace inline UX, undo/queued, read-error toasts) not included. ✓
**Placeholder scan:** concrete code for manager/region/cache/typing; the Base UI Title/Description auto-render + viewport nesting carry an explicit "validate by running" (novel primitive); the suppress mapping is a concrete table with a governing principle + a per-component verification step (not vague).
**Type consistency:** `meta` shape declared once (`react-query.d.ts`) and consumed in the MutationCache (T1) + set on hooks (T2); `mutationErrorMessage` uses the exported `InUseError`/`HttpError`; `toast.*` keys used in both the cache helper and the hook `meta`.
## Notes
- No new dependency (`@base-ui/react` present); bundle grows only by the toast primitive in the always-loaded region — watch `check:size` (budget 180).
- Re-`add` with the same id de-dupes/refreshes a toast — not used now, available if repeated errors get noisy.
- Extracting a `makeQueryClient()` factory (used by `main.tsx` + tests) keeps the toast wiring testable without duplicating the MutationCache config.