diff --git a/docs/patterns/06-friendly-errors.md b/docs/patterns/06-friendly-errors.md index 06e731d..d736963 100644 --- a/docs/patterns/06-friendly-errors.md +++ b/docs/patterns/06-friendly-errors.md @@ -2,7 +2,7 @@ > Backported from adaptive-learner. Template-neutral; adapt the names to your app. -**Status in this template:** the backend exception hierarchy (`MyAppError` and its subclasses) and the global exception handler are present in `backend/app/exceptions.py` and `backend/app/main.py`; the frontend `ApiError` class is present in `frontend/src/api/client.ts`. The `ui.errors.*` i18n mapping, the `friendlyError` helper, and the Dev-Mode toggle are not yet implemented. +**Status in this template:** **shipped.** On top of the backend exception hierarchy (`MyAppError` + global handler) and the frontend `ApiError` class, the friendly layer now ships: `utils/friendlyError.ts` maps an `ApiError` by status class to a calm message (resolved via the `ui.errors.*` keys with English fallbacks, so it works before an app adds catalog entries); `utils/devMode.ts` + a Settings > About toggle gate the raw technical detail (off by default); and `notify.error` shows the friendly message while the "Report issue" action still carries the full raw detail + stacktrace. A non-hook `translate()` accessor on `hooks/useI18n.ts` lets module-level code localize without a hook. ## Why diff --git a/frontend/src/components/about/About.module.css b/frontend/src/components/about/About.module.css index 11494a7..2153f36 100644 --- a/frontend/src/components/about/About.module.css +++ b/frontend/src/components/about/About.module.css @@ -67,3 +67,12 @@ font-size: 0.875rem; line-height: 1.5; } + +.toggleRow { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--text-secondary); + font-size: 0.875rem; + cursor: pointer; +} diff --git a/frontend/src/components/about/AboutTab.tsx b/frontend/src/components/about/AboutTab.tsx index 85edd9b..6743c72 100644 --- a/frontend/src/components/about/AboutTab.tsx +++ b/frontend/src/components/about/AboutTab.tsx @@ -9,6 +9,7 @@ import {BookOpen, Code2, ExternalLink, Scale} from "lucide-react"; import {useI18n} from "../../hooks/useI18n"; import VersionSection from "./VersionSection"; import ShareAppSection from "./ShareAppSection"; +import DevModeSection from "./DevModeSection"; import UpdateCheckControl from "../UpdateCheckControl"; import styles from "./About.module.css"; @@ -97,6 +98,8 @@ export default function AboutTab() { )}

+ + ); } diff --git a/frontend/src/components/about/DevModeSection.tsx b/frontend/src/components/about/DevModeSection.tsx new file mode 100644 index 0000000..2caa654 --- /dev/null +++ b/frontend/src/components/about/DevModeSection.tsx @@ -0,0 +1,39 @@ +/** + * Developer Mode toggle for the About tab (docs/patterns/06-friendly-errors.md). + * + * Off by default. When on, error toasts show the raw technical detail + * (status, endpoint, stacktrace) instead of the friendly user message. + */ +import {useId, useState} from "react"; +import {useI18n} from "../../hooks/useI18n"; +import {isDevMode, setDevMode} from "../../utils/devMode"; +import styles from "./About.module.css"; + +export default function DevModeSection() { + const {t} = useI18n(); + const [on, setOn] = useState(isDevMode()); + const id = useId(); + + const toggle = (next: boolean) => { + setDevMode(next); + setOn(next); + }; + + return ( +
+

{t("ui.about.devmode_title", "Developer Mode")}

+ +
+ ); +} diff --git a/frontend/src/hooks/useI18n.ts b/frontend/src/hooks/useI18n.ts index d570795..bfbc31e 100644 --- a/frontend/src/hooks/useI18n.ts +++ b/frontend/src/hooks/useI18n.ts @@ -34,6 +34,24 @@ const I18nContext = createContext(null); let cachedLang = DEFAULT_LANG; let cachedStrings: I18nStrings = deCatalog as I18nStrings; +/** + * Non-hook translation accessor for module-level code (e.g. utils/notify, + * utils/friendlyError) that cannot call the `useI18n` hook. Resolves against + * the catalog the provider has loaded; falls back to `fallback` or the key. + */ +export function translate(key: string, fallback?: string): string { + const parts = key.split("."); + let current: unknown = cachedStrings; + for (const part of parts) { + if (current && typeof current === "object" && part in (current as Record)) { + current = (current as Record)[part]; + } else { + return fallback ?? key; + } + } + return typeof current === "string" ? current : (fallback ?? key); +} + export function I18nProvider({children}: {children: ReactNode}) { const [strings, setStrings] = useState(cachedStrings); const [lang, setLangState] = useState(cachedLang || "de"); diff --git a/frontend/src/utils/devMode.test.ts b/frontend/src/utils/devMode.test.ts new file mode 100644 index 0000000..e04373b --- /dev/null +++ b/frontend/src/utils/devMode.test.ts @@ -0,0 +1,20 @@ +import {afterEach, describe, expect, it} from "vitest"; +import {isDevMode, setDevMode} from "./devMode"; + +afterEach(() => { + localStorage.clear(); +}); + +describe("devMode", () => { + it("is off by default", () => { + localStorage.clear(); + expect(isDevMode()).toBe(false); + }); + + it("persists on and off", () => { + setDevMode(true); + expect(isDevMode()).toBe(true); + setDevMode(false); + expect(isDevMode()).toBe(false); + }); +}); diff --git a/frontend/src/utils/devMode.ts b/frontend/src/utils/devMode.ts new file mode 100644 index 0000000..d0d2688 --- /dev/null +++ b/frontend/src/utils/devMode.ts @@ -0,0 +1,25 @@ +/** + * Developer Mode (docs/patterns/06-friendly-errors.md). + * + * Off by default. When ON, error toasts show the raw technical detail + * (status, endpoint, stacktrace) instead of the friendly user message - for + * debugging. Persisted in localStorage so it survives reloads. Toggle it + * from Settings > About. + */ +const KEY = "myapp.devMode"; + +export function isDevMode(): boolean { + try { + return localStorage.getItem(KEY) === "true"; + } catch { + return false; + } +} + +export function setDevMode(on: boolean): void { + try { + localStorage.setItem(KEY, on ? "true" : "false"); + } catch { + /* storage unavailable (private mode); dev mode just stays off */ + } +} diff --git a/frontend/src/utils/friendlyError.test.ts b/frontend/src/utils/friendlyError.test.ts new file mode 100644 index 0000000..f86d66f --- /dev/null +++ b/frontend/src/utils/friendlyError.test.ts @@ -0,0 +1,38 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import {ApiError} from "../api/client"; + +vi.mock("./devMode", () => ({isDevMode: vi.fn(() => false)})); +vi.mock("../hooks/useI18n", () => ({translate: (_key: string, fallback: string) => fallback})); + +import {friendlyMessage} from "./friendlyError"; +import {isDevMode} from "./devMode"; + +const apiErr = (status: number) => new ApiError(status, "raw backend detail", "/api/x", "GET"); + +afterEach(() => { + vi.clearAllMocks(); + vi.mocked(isDevMode).mockReturnValue(false); +}); + +describe("friendlyMessage", () => { + it("maps status classes to friendly text in production mode", () => { + expect(friendlyMessage("raw", apiErr(404)).toLowerCase()).toContain("find"); + expect(friendlyMessage("raw", apiErr(422)).toLowerCase()).toContain("information"); + expect(friendlyMessage("raw", apiErr(409)).toLowerCase()).toContain("conflict"); + expect(friendlyMessage("raw", apiErr(500)).toLowerCase()).toContain("our side"); + expect(friendlyMessage("raw", apiErr(0)).toLowerCase()).toContain("server"); + }); + + it("never returns the raw backend detail for an ApiError in production", () => { + expect(friendlyMessage("raw backend detail", apiErr(500))).not.toContain("raw backend detail"); + }); + + it("returns the raw message for a non-ApiError", () => { + expect(friendlyMessage("plain message")).toBe("plain message"); + }); + + it("returns the raw detail in Developer Mode", () => { + vi.mocked(isDevMode).mockReturnValue(true); + expect(friendlyMessage("raw backend detail", apiErr(500))).toBe("raw backend detail"); + }); +}); diff --git a/frontend/src/utils/friendlyError.ts b/frontend/src/utils/friendlyError.ts new file mode 100644 index 0000000..e873edd --- /dev/null +++ b/frontend/src/utils/friendlyError.ts @@ -0,0 +1,43 @@ +/** + * Friendly error messages (docs/patterns/06-friendly-errors.md). + * + * Production users should never see a raw HTTP `detail` or a stacktrace in a + * toast. This maps an {@link ApiError} to a calm, localized message by status + * class. Developer Mode (utils/devMode) bypasses the mapping and returns the + * raw detail so developers still get the actionable text. The "Report issue" + * action always carries the full technical detail regardless of mode. + * + * i18n: messages resolve via the non-hook `translate()` accessor against the + * `ui.errors.*` keys; the English fallbacks here are used until an app adds + * those keys to its catalogs (so the feature works with zero i18n setup). + */ +import {ApiError} from "../api/client"; +import {translate} from "../hooks/useI18n"; +import {isDevMode} from "./devMode"; + +function keyForStatus(status: number): {key: string; fallback: string} { + if (status === 404) return {key: "ui.errors.not_found", fallback: "We couldn't find what you were looking for."}; + if (status === 400 || status === 422) return {key: "ui.errors.validation", fallback: "Some of the information looks off. Please check and try again."}; + if (status === 409) return {key: "ui.errors.conflict", fallback: "That action conflicts with the current state. Please reload and retry."}; + if (status === 413) return {key: "ui.errors.too_large", fallback: "That file or request is too large."}; + if (status === 0) return {key: "ui.errors.network", fallback: "Can't reach the server. Check your connection and try again."}; + if (status >= 500) return {key: "ui.errors.server", fallback: "Something went wrong on our side. Please try again."}; + return {key: "ui.errors.unexpected", fallback: "Something unexpected happened. Please try again."}; +} + +/** + * Resolve the message to SHOW the user for a failed action. + * + * @param rawMessage the message the caller would have shown (usually + * `error.detail`); returned as-is in Developer Mode or for non-ApiErrors. + * @param error the caught error; when it's an ApiError and Developer Mode is + * off, a friendly status-mapped message is returned instead. + */ +export function friendlyMessage(rawMessage: string, error?: unknown): string { + if (isDevMode()) return rawMessage; + if (error instanceof ApiError) { + const {key, fallback} = keyForStatus(error.status); + return translate(key, fallback); + } + return rawMessage; +} diff --git a/frontend/src/utils/notify.ts b/frontend/src/utils/notify.ts index 4810c32..e818940 100644 --- a/frontend/src/utils/notify.ts +++ b/frontend/src/utils/notify.ts @@ -14,6 +14,7 @@ import React from 'react' import {toast} from 'react-toastify' import {ApiError} from '../api/client' +import {friendlyMessage} from './friendlyError' // Truncate the visible error message so the toast stays readable. // The full detail is still embedded in the ErrorReportDialog body. @@ -28,7 +29,7 @@ function truncateForDisplay(message: string): string { return message.slice(0, MAX_DISPLAY_LENGTH) + '...' } -function ErrorContent({message, apiError}: {message: string; apiError?: ApiError}) { +function ErrorContent({message, rawMessage, apiError}: {message: string; rawMessage?: string; apiError?: ApiError}) { return React.createElement( 'div', { @@ -63,7 +64,7 @@ function ErrorContent({message, apiError}: {message: string; apiError?: ApiError e.stopPropagation() // Dispatch a custom event that ErrorReportDialog listens for window.dispatchEvent(new CustomEvent('myapp:open-error-report', { - detail: {message, apiError}, + detail: {message: rawMessage ?? message, apiError}, })) }, style: { @@ -217,7 +218,10 @@ export const notify = { error: (message: string, apiError?: unknown) => { recordToast('error', message) const err = apiError instanceof ApiError ? apiError : undefined - return toast.error(React.createElement(ErrorContent, {message, apiError: err}), { + // Friendly message for users (raw detail only in Developer Mode); the + // raw message + apiError still flow to the "Report issue" dialog. + const display = friendlyMessage(message, apiError) + return toast.error(React.createElement(ErrorContent, {message: display, rawMessage: message, apiError: err}), { autoClose: 15000, closeOnClick: false, })