Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/patterns/06-friendly-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/about/About.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 3 additions & 0 deletions frontend/src/components/about/AboutTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -97,6 +98,8 @@ export default function AboutTab() {
)}
</p>
</section>

<DevModeSection />
</div>
);
}
39 changes: 39 additions & 0 deletions frontend/src/components/about/DevModeSection.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section className={styles.section} data-testid="about-devmode">
<h3 className={styles.heading}>{t("ui.about.devmode_title", "Developer Mode")}</h3>
<label htmlFor={id} className={styles.toggleRow}>
<input
id={id}
type="checkbox"
checked={on}
onChange={(event) => toggle(event.target.checked)}
data-testid="devmode-toggle"
/>
<span>
{t("ui.about.devmode_label", "Show raw technical error details (for debugging)")}
</span>
</label>
</section>
);
}
18 changes: 18 additions & 0 deletions frontend/src/hooks/useI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ const I18nContext = createContext<I18nContextValue | null>(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<string, unknown>)) {
current = (current as Record<string, unknown>)[part];
} else {
return fallback ?? key;
}
}
return typeof current === "string" ? current : (fallback ?? key);
}

export function I18nProvider({children}: {children: ReactNode}) {
const [strings, setStrings] = useState<I18nStrings>(cachedStrings);
const [lang, setLangState] = useState(cachedLang || "de");
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/utils/devMode.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 25 additions & 0 deletions frontend/src/utils/devMode.ts
Original file line number Diff line number Diff line change
@@ -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 */
}
}
38 changes: 38 additions & 0 deletions frontend/src/utils/friendlyError.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
43 changes: 43 additions & 0 deletions frontend/src/utils/friendlyError.ts
Original file line number Diff line number Diff line change
@@ -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;
}
10 changes: 7 additions & 3 deletions frontend/src/utils/notify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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',
{
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
})
Expand Down