Files
biggus-dickus/docs/superpowers/plans/2026-06-07-toast-notifications.md
T
2026-06-07 12:30:30 +02:00

219 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.