From 604d4f6005f9f97abe648c40d4d4673a06299387 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 12:46:26 +0200 Subject: [PATCH] feat(web): Base UI toast region + global mutation feedback wiring (#47) Add a module-scope Base UI toast manager bridged to the QueryClient so every mutation can give consistent feedback. A MutationCache (extracted into a makeQueryClient() factory for test reuse) emits a catch-all, type-aware error toast (unless meta.suppressErrorToast) and an opt-in success toast (meta.successMessage), reading mutation.meta + i18n.t outside React. meta is type-checked via a react-query Register augmentation. ToastRegion is mounted app-wide in main.tsx. Adds a toast i18n namespace (en/sv parity) and a validated story. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/api/query-client.ts | 49 ++++++++++++++++++++++ web/src/api/react-query.d.ts | 14 +++++++ web/src/components/ui/toast.stories.tsx | 31 ++++++++++++++ web/src/components/ui/toast.tsx | 55 +++++++++++++++++++++++++ web/src/i18n/en.json | 9 ++++ web/src/i18n/sv.json | 9 ++++ web/src/main.tsx | 12 +++--- web/src/toast/toast-manager.ts | 9 ++++ 8 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 web/src/api/query-client.ts create mode 100644 web/src/api/react-query.d.ts create mode 100644 web/src/components/ui/toast.stories.tsx create mode 100644 web/src/components/ui/toast.tsx create mode 100644 web/src/toast/toast-manager.ts diff --git a/web/src/api/query-client.ts b/web/src/api/query-client.ts new file mode 100644 index 0000000..66e4d58 --- /dev/null +++ b/web/src/api/query-client.ts @@ -0,0 +1,49 @@ +import { + MutationCache, + QueryClient, + type MutationMeta, +} from "@tanstack/react-query"; + +import i18n from "../i18n"; +import { toastManager } from "../toast/toast-manager"; +import { HttpError, InUseError } from "./queries"; + +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"); +} + +/** Builds the app's QueryClient, including the MutationCache that bridges every + * mutation to the toast region (catch-all error toast + opt-in success toast). + * Shared by main.tsx and tests so the toast wiring stays consistent. */ +export function makeQueryClient(): QueryClient { + return 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), + }); + } + }, + }), + }); +} diff --git a/web/src/api/react-query.d.ts b/web/src/api/react-query.d.ts new file mode 100644 index 0000000..dd9231b --- /dev/null +++ b/web/src/api/react-query.d.ts @@ -0,0 +1,14 @@ +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; + }; + } +} diff --git a/web/src/components/ui/toast.stories.tsx b/web/src/components/ui/toast.stories.tsx new file mode 100644 index 0000000..83f2faf --- /dev/null +++ b/web/src/components/ui/toast.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { expect, within } from 'storybook/test' + +import { ToastRegion } from './toast' +import { toastManager } from '../../toast/toast-manager' + +const meta = { + component: ToastRegion, + tags: ['ai-generated'], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Success: Story = { + args: { children: null }, + play: async () => { + toastManager.add({ type: 'success', description: 'Saved' }) + await expect(await within(document.body).findByText('Saved')).toBeInTheDocument() + }, +} + +export const Error: Story = { + args: { children: null }, + play: async () => { + toastManager.add({ type: 'error', description: 'Something went wrong' }) + await expect( + await within(document.body).findByText('Something went wrong'), + ).toBeInTheDocument() + }, +} diff --git a/web/src/components/ui/toast.tsx b/web/src/components/ui/toast.tsx new file mode 100644 index 0000000..9b96274 --- /dev/null +++ b/web/src/components/ui/toast.tsx @@ -0,0 +1,55 @@ +import type * as React from "react"; +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) => ( + +
+ {toast.title && ( + + )} + +
+ + × + +
+ )); +} + +/** App-wide toast region: provides the external manager + a portaled viewport. */ +export function ToastRegion({ children }: { children: React.ReactNode }) { + return ( + + {children} + + + + + + + ); +} diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index 6ff7ab3..37260ff 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -59,5 +59,14 @@ "gateError": "Can't publish — required fields are missing.", "editLink": "Edit the record", "illegalError": "That visibility change isn't allowed." + }, + "toast": { + "created": "Created", + "saved": "Saved", + "updated": "Updated", + "deleted": "Deleted", + "renamed": "Renamed", + "published": "Visibility updated", + "error": "Something went wrong" } } diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index 74670d7..b29b767 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -59,5 +59,14 @@ "gateError": "Kan inte publicera — obligatoriska fält saknas.", "editLink": "Redigera posten", "illegalError": "Den synlighetsändringen är inte tillåten." + }, + "toast": { + "created": "Skapat", + "saved": "Sparat", + "updated": "Uppdaterat", + "deleted": "Borttaget", + "renamed": "Namn ändrat", + "published": "Synlighet uppdaterad", + "error": "Något gick fel" } } diff --git a/web/src/main.tsx b/web/src/main.tsx index 0b05e74..d5eb64a 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,21 +1,23 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import { App } from "./app"; import { ConfigProvider } from "./config/config-provider"; +import { makeQueryClient } from "./api/query-client"; +import { ToastRegion } from "./components/ui/toast"; import "./index.css"; import "./i18n"; -const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, -}); +const queryClient = makeQueryClient(); createRoot(document.getElementById("root")!).render( - + + + , diff --git a/web/src/toast/toast-manager.ts b/web/src/toast/toast-manager.ts new file mode 100644 index 0000000..519c9f4 --- /dev/null +++ b/web/src/toast/toast-manager.ts @@ -0,0 +1,9 @@ +import { Toast } from "@base-ui/react/toast"; + +/** A toast manager created outside React so non-React code (the QueryClient + * MutationCache) can add toasts. Passed to . + * + * Note: in @base-ui/react ^1.5 `createToastManager` is only exported through + * the `Toast` namespace (index.parts), not as a top-level named export of the + * `@base-ui/react/toast` subpath — importing it directly fails at runtime. */ +export const toastManager = Toast.createToastManager();