diff --git a/packages/ui-patterns/README.md b/packages/ui-patterns/README.md index 582f55a..6dd3aba 100644 --- a/packages/ui-patterns/README.md +++ b/packages/ui-patterns/README.md @@ -28,6 +28,18 @@ blocks. pass a narrowed set where landing a user in a language silently would be wrong (e.g. incomplete native-app translations). +- **Error views** — the full-page fallbacks every app carries. + `UnexpectedErrorPage` says an unexpected error occurred, links to the + app's support site, offers a reload, and shows an optional error + reference (the Sentry event id) so a support request can be matched to + the report. `NotFoundPage` is the unknown-URL page with a link home. + Both sit on `ErrorPage`, the shared layout, which apps can use for their + own cases. `ErrorBoundary` catches render errors below it and renders a + fallback you supply; its `onError` callback is where the app reports the + error and can return the reference for the fallback to show. Route-level + `errorElement`s stay app-side (they need react-router's `useRouteError`) + and render the same views. + (`` needs nothing from this package: `@microbit/ui`'s `SharedUIProvider` keeps it in step with the locale automatically.) diff --git a/packages/ui-patterns/lang/ui.en.json b/packages/ui-patterns/lang/ui.en.json index fbc377c..53b08c6 100644 --- a/packages/ui-patterns/lang/ui.en.json +++ b/packages/ui-patterns/lang/ui.en.json @@ -1,4 +1,16 @@ { + "ui-patterns.copied-feedback": { + "defaultMessage": "Copied", + "description": "Accessible name of the copy button briefly after copying the error reference" + }, + "ui-patterns.copy-error-reference-action": { + "defaultMessage": "Copy error reference", + "description": "Accessible name of the button that copies the error reference" + }, + "ui-patterns.error-reference": { + "defaultMessage": "Error reference: {id}", + "description": "Shown on the unexpected error page. The id identifies the error report and can be quoted in support requests." + }, "ui-patterns.help-translate": { "defaultMessage": "Help translate", "description": "Language dialog link to the translation project" @@ -35,8 +47,28 @@ "defaultMessage": "Language not fully supported", "description": "Language support toast notification title" }, + "ui-patterns.not-found-home-link": { + "defaultMessage": "Go to home page", + "description": "Link text on the page not found page" + }, + "ui-patterns.not-found-title": { + "defaultMessage": "Page not found", + "description": "Title of the page shown for an unknown URL" + }, "ui-patterns.play-video": { "defaultMessage": "Play video: {title}", "description": "Accessible label for the play button shown over a video preview image; title is the video's title" + }, + "ui-patterns.reload-action": { + "defaultMessage": "Click to reload the page", + "description": "Button on the unexpected error page" + }, + "ui-patterns.support-request": { + "defaultMessage": "Please consider raising a support request.", + "description": "Text on the unexpected error page. The link opens the support site." + }, + "ui-patterns.unexpected-error-title": { + "defaultMessage": "An unexpected error occurred", + "description": "Title of the full-page view shown for an unrecoverable error" } } diff --git a/packages/ui-patterns/src/ErrorBoundary.tsx b/packages/ui-patterns/src/ErrorBoundary.tsx new file mode 100644 index 0000000..3ab1bea --- /dev/null +++ b/packages/ui-patterns/src/ErrorBoundary.tsx @@ -0,0 +1,61 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Component, ErrorInfo, ReactNode } from "react"; + +export interface ErrorBoundaryProps { + /** + * Called once for each error caught. Report the error here. Returning a + * string (typically the Sentry event id) passes it to the fallback as the + * reference to show the user. + */ + onError?: (error: unknown, errorInfo: ErrorInfo) => string | undefined | void; + /** + * Rendered in place of the children after an error. The reference is + * whatever `onError` returned, so it is undefined on the first render of + * the fallback and filled in on the next. + */ + fallback: (error: unknown, reference: string | undefined) => ReactNode; + children?: ReactNode; +} + +interface ErrorBoundaryState { + hasError: boolean; + error?: unknown; + reference?: string; +} + +/** + * Catches render errors below it. Pair it with react-router's + * `errorElement`, which also catches loader errors but not from inside a + * boundary like this one; both can render `UnexpectedErrorPage`. + */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: unknown): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + const reference = this.props.onError?.(error, errorInfo); + if (reference) { + this.setState({ reference }); + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback(this.state.error, this.state.reference); + } + return this.props.children; + } +} diff --git a/packages/ui-patterns/src/ErrorPage.tsx b/packages/ui-patterns/src/ErrorPage.tsx new file mode 100644 index 0000000..ceebd01 --- /dev/null +++ b/packages/ui-patterns/src/ErrorPage.tsx @@ -0,0 +1,54 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Heading, VStack } from "@microbit/ui"; +import { ReactNode, useEffect, useRef } from "react"; + +export interface ErrorPageProps { + title: ReactNode; + children?: ReactNode; +} + +/** + * Full-page layout for terminal states: a centred heading with the + * explanation and actions beneath it. `UnexpectedErrorPage` and + * `NotFoundPage` use it; apps can use it for their own cases. + * + * Focus moves to the heading on mount. These pages replace whatever the user + * was doing, often without any interaction of theirs, and without this a + * screen reader user is left on a focus target that no longer exists. + */ +export const ErrorPage = ({ title, children }: ErrorPageProps) => { + const headingRef = useRef(null); + useEffect(() => { + headingRef.current?.focus(); + }, []); + return ( + + + {title} + + + {children} + + + ); +}; diff --git a/packages/ui-patterns/src/NotFoundPage.tsx b/packages/ui-patterns/src/NotFoundPage.tsx new file mode 100644 index 0000000..3e8e4e1 --- /dev/null +++ b/packages/ui-patterns/src/NotFoundPage.tsx @@ -0,0 +1,36 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Link } from "@microbit/ui"; +import { ReactNode } from "react"; +import { FormattedMessage } from "react-intl"; +import { ErrorPage } from "./ErrorPage"; +import { uiPatternsMessage } from "./messages"; + +export interface NotFoundPageProps { + /** The app's home page. A full navigation, which is fine for a dead end. */ + homeUrl: string; + /** Replaces the default "Go to home page" link text. */ + homeLinkText?: ReactNode; +} + +/** + * The page shown for a URL the app does not recognise. + */ +export const NotFoundPage = ({ homeUrl, homeLinkText }: NotFoundPageProps) => ( + + } + > + + {homeLinkText ?? ( + + )} + + +); diff --git a/packages/ui-patterns/src/UnexpectedErrorPage.tsx b/packages/ui-patterns/src/UnexpectedErrorPage.tsx new file mode 100644 index 0000000..0360634 --- /dev/null +++ b/packages/ui-patterns/src/UnexpectedErrorPage.tsx @@ -0,0 +1,112 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { + Button, + Code, + ExternalLink, + HStack, + Icon, + IconButton, + Text, + useClipboard, +} from "@microbit/ui"; +import { ReactNode } from "react"; +import { RiCheckLine, RiFileCopyLine } from "react-icons/ri"; +import { FormattedMessage, useIntl } from "react-intl"; +import { ErrorPage } from "./ErrorPage"; +import { uiPatternsMessage } from "./messages"; + +export interface UnexpectedErrorPageProps { + /** Where "raising a support request" links to. */ + supportUrl: string; + /** + * Identifies the error report, typically the Sentry event id, so a support + * request quoting it can be matched to the report. Omit when the error was + * not reported. + */ + reference?: string; + /** Replaces the default action of reloading the page. */ + onReload?: () => void; + /** + * Extra content between the standard text and the reload button, for a + * recovery action the app can still offer. + */ + children?: ReactNode; +} + +const reloadPage = () => window.location.reload(); + +/** + * The page shown when the app cannot continue: an apology, a support link, + * the error reference and a reload button. + */ +export const UnexpectedErrorPage = ({ + supportUrl, + reference, + onReload = reloadPage, + children, +}: UnexpectedErrorPageProps) => ( + + } + > + + ( + + {chunks} + + ), + }} + /> + + {reference && } + {children} + + + + +); + +/** + * The reference with a copy button: an event id is too long to retype into + * a support form reliably. + */ +const ErrorReference = ({ reference }: { reference: string }) => { + const intl = useIntl(); + const { onCopy, hasCopied } = useClipboard(reference); + return ( + + + {reference} }} + /> + + + + + + ); +}; diff --git a/packages/ui-patterns/src/index.ts b/packages/ui-patterns/src/index.ts index 3f7d403..cbe58bd 100644 --- a/packages/ui-patterns/src/index.ts +++ b/packages/ui-patterns/src/index.ts @@ -7,7 +7,11 @@ * ui-patterns — higher-level patterns composed from @microbit/ui primitives, * shared across the micro:bit app family. */ +export * from "./ErrorBoundary"; +export * from "./ErrorPage"; export * from "./LanguageDialog"; +export * from "./NotFoundPage"; +export * from "./UnexpectedErrorPage"; export * from "./YoutubeVideoEmbed"; export * from "./get-default-language"; export * from "./languages"; diff --git a/packages/ui-patterns/stories/ErrorBoundary.stories.tsx b/packages/ui-patterns/stories/ErrorBoundary.stories.tsx new file mode 100644 index 0000000..f29d277 --- /dev/null +++ b/packages/ui-patterns/stories/ErrorBoundary.stories.tsx @@ -0,0 +1,54 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Button, Text, VStack } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useState } from "react"; +import { ErrorBoundary, UnexpectedErrorPage } from "../src"; + +const meta = { + title: "Patterns/ErrorBoundary", + component: ErrorBoundary, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +const Crasher = () => { + const [crash, setCrash] = useState(false); + if (crash) { + throw new Error("Deliberate render error"); + } + return ( + + Press the button to throw during render. + + + ); +}; + +/** + * The boundary calls `onError` once with the error, and whatever it returns + * (here a fake event id, in an app the Sentry event id) becomes the reference + * the fallback shows. + */ +export const WithUnexpectedErrorPage: Story = { + args: { + onError: (error) => { + console.error(error); + return "5f1e7a2c9b3d4e6f8a0b1c2d3e4f5a6b"; + }, + fallback: (_error, reference) => ( + alert("reload")} + /> + ), + children: , + }, +}; diff --git a/packages/ui-patterns/stories/ErrorPage.stories.tsx b/packages/ui-patterns/stories/ErrorPage.stories.tsx new file mode 100644 index 0000000..32d5689 --- /dev/null +++ b/packages/ui-patterns/stories/ErrorPage.stories.tsx @@ -0,0 +1,38 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Button, Text } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { ErrorPage } from "../src"; + +const meta = { + title: "Patterns/ErrorPage", + component: ErrorPage, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** + * The layout on its own, for an app's own terminal states. This is + * ml-trainer's pre-release page for a stored-data format change, which the + * apps that assert their IndexedDB schema on open all need. + */ +export const StorageVersionError: Story = { + args: { + title: "Breaking change to stored data", + children: ( + <> + + The storage format has changed in this pre-release version and the old + data format is not supported. + + + + ), + }, +}; diff --git a/packages/ui-patterns/stories/NotFoundPage.stories.tsx b/packages/ui-patterns/stories/NotFoundPage.stories.tsx new file mode 100644 index 0000000..deacb04 --- /dev/null +++ b/packages/ui-patterns/stories/NotFoundPage.stories.tsx @@ -0,0 +1,23 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { NotFoundPage } from "../src"; + +const meta = { + title: "Patterns/NotFoundPage", + component: NotFoundPage, + args: { homeUrl: "#" }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +/** For apps that name themselves in the link, as ml-trainer does. */ +export const WithAppName: Story = { + args: { homeLinkText: "micro:bit CreateAI home page" }, +}; diff --git a/packages/ui-patterns/stories/UnexpectedErrorPage.stories.tsx b/packages/ui-patterns/stories/UnexpectedErrorPage.stories.tsx new file mode 100644 index 0000000..742f53c --- /dev/null +++ b/packages/ui-patterns/stories/UnexpectedErrorPage.stories.tsx @@ -0,0 +1,46 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Button } from "@microbit/ui"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { UnexpectedErrorPage } from "../src"; + +const meta = { + title: "Patterns/UnexpectedErrorPage", + component: UnexpectedErrorPage, + args: { + supportUrl: "https://support.microbit.org/support/home/", + onReload: () => alert("reload"), + }, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +/** The reference is the Sentry event id, for quoting in support requests. */ +export const WithReference: Story = { + args: { + reference: "5f1e7a2c9b3d4e6f8a0b1c2d3e4f5a6b", + }, +}; + +/** When the error was not reported there is no reference to show. */ +export const WithoutReference: Story = {}; + +/** + * The children slot is for a recovery action the app can still offer after a + * render error, e.g. the Python Editor's file system outlives a crashed UI so + * the project can still be downloaded. + */ +export const WithRecoveryAction: Story = { + args: { + reference: "5f1e7a2c9b3d4e6f8a0b1c2d3e4f5a6b", + children: ( + + ), + }, +}; diff --git a/packages/ui-patterns/tests/ErrorPages.test.tsx b/packages/ui-patterns/tests/ErrorPages.test.tsx new file mode 100644 index 0000000..801a37b --- /dev/null +++ b/packages/ui-patterns/tests/ErrorPages.test.tsx @@ -0,0 +1,161 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { SharedUIProvider } from "@microbit/ui"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ReactNode } from "react"; +import { IntlProvider } from "react-intl"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ErrorBoundary, NotFoundPage, UnexpectedErrorPage } from "../src"; + +afterEach(cleanup); +afterEach(() => { + vi.restoreAllMocks(); +}); + +const Providers = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const supportUrl = "https://support.example.org/"; + +describe("UnexpectedErrorPage", () => { + it("explains, links to support and offers a reload", async () => { + const user = userEvent.setup(); + const onReload = vi.fn(); + render( + , + { + wrapper: Providers, + }, + ); + expect( + screen.getByRole("heading", { name: "An unexpected error occurred" }), + ).toBeDefined(); + const link = screen.getByRole("link", { + name: /raising a support request/, + }); + expect(link.href).toBe(supportUrl); + expect(link.target).toBe("_blank"); + expect(screen.queryByText(/Error reference/)).toBeNull(); + await user.click( + screen.getByRole("button", { name: "Click to reload the page" }), + ); + expect(onReload).toHaveBeenCalledOnce(); + }); + + it("focuses the heading on mount", () => { + render(, { + wrapper: Providers, + }); + expect(document.activeElement).toBe( + screen.getByRole("heading", { name: "An unexpected error occurred" }), + ); + }); + + it("shows the reference with a copy button when given", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + render(, { + wrapper: Providers, + }); + expect(screen.getByText(/Error reference/).textContent).toBe( + "Error reference: abc123", + ); + const copy = screen.getByRole("button", { name: "Copy error reference" }); + await user.click(copy); + expect(writeText).toHaveBeenCalledWith("abc123"); + expect(copy.getAttribute("aria-label")).toBe("Copied"); + }); +}); + +describe("NotFoundPage", () => { + it("links home with the default text", () => { + render(, { wrapper: Providers }); + expect( + screen.getByRole("heading", { name: "Page not found" }), + ).toBeDefined(); + const link = screen.getByRole("link", { + name: "Go to home page", + }); + expect(link.getAttribute("href")).toBe("/home"); + }); + + it("takes app-specific link text", () => { + render(, { + wrapper: Providers, + }); + expect( + screen.getByRole("link", { name: "CreateAI home page" }), + ).toBeDefined(); + }); +}); + +const Thrower = (): ReactNode => { + throw new Error("boom"); +}; + +// React reports a caught render error to console.error and jsdom reports it +// again as a window error event; silence both for the throwing cases. +const expectRenderError = () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const swallow = (e: ErrorEvent) => e.preventDefault(); + window.addEventListener("error", swallow); + return () => window.removeEventListener("error", swallow); +}; + +describe("ErrorBoundary", () => { + it("renders children when nothing throws", () => { + render( +

fallback

}> +

content

+
, + ); + expect(screen.getByText("content")).toBeDefined(); + }); + + it("reports the error and passes the returned reference to the fallback", () => { + const restore = expectRenderError(); + const onError = vi.fn().mockReturnValue("ref-1"); + render( + ( +

+ {(error as Error).message} {reference} +

+ )} + > + +
, + ); + expect(onError).toHaveBeenCalledOnce(); + expect((onError.mock.calls[0][0] as Error).message).toBe("boom"); + expect(screen.getByText("boom ref-1")).toBeDefined(); + restore(); + }); + + it("renders the fallback without a reference when onError returns nothing", () => { + const restore = expectRenderError(); + render( + ( +

fallback {reference ?? "no-reference"}

+ )} + > + +
, + ); + expect(screen.getByText("fallback no-reference")).toBeDefined(); + restore(); + }); +});