@olenbetong/appframe-ds 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @olenbetong/appframe-ds
2
2
 
3
+ ## 1.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 5a5da91: `RouteErrorPage` shows thrown error responses (404, 403, 401, 5xx and others) as a centred status page with an icon, a plain-language heading, the app's own `statusText` when it gives one, and a way out (back, start page or refresh). Only unexpected errors and 5xx responses are reported with `af.common.logError`; unexpected exceptions look as before.
8
+
3
9
  ## 1.12.0
4
10
 
5
11
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olenbetong/appframe-ds",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Components that bind Designsystemet components to Appframe data objects",
5
5
  "type": "module",
6
6
  "types": "./es/index.d.ts",
@@ -63,7 +63,7 @@
63
63
  "@atlaskit/pragmatic-drag-and-drop": "4.0.0",
64
64
  "@atlaskit/pragmatic-drag-and-drop-hitbox": "3.0.0",
65
65
  "@base-ui/react": "1.9.0",
66
- "@olenbetong/appframe-core": "^2.16.0",
66
+ "@olenbetong/appframe-core": "^2.16.1",
67
67
  "@olenbetong/appframe-data": "^1.10.3",
68
68
  "@olenbetong/appframe-react": "^2.2.8",
69
69
  "clsx": "^2.1.1",
@@ -99,3 +99,54 @@
99
99
  word-break: break-all;
100
100
  font-size: var(--ds-font-size-1, 0.75rem);
101
101
  }
102
+
103
+ .RouteErrorPage-status {
104
+ display: flex;
105
+ flex-direction: column;
106
+ align-items: center;
107
+ gap: var(--ds-size-3, 0.75rem);
108
+ max-width: 36rem;
109
+ margin: 0 auto;
110
+ padding: 12vh var(--ds-size-4, 1rem) var(--ds-size-8, 2rem);
111
+ text-align: center;
112
+ }
113
+
114
+ .RouteErrorPage-icon {
115
+ display: grid;
116
+ place-items: center;
117
+ width: 5rem;
118
+ height: 5rem;
119
+ margin-bottom: var(--ds-size-2, 0.5rem);
120
+ border-radius: 50%;
121
+ background: var(--ds-color-neutral-surface-tinted, rgb(0 0 0 / 0.05));
122
+ color: var(--ds-color-neutral-text-subtle, #555);
123
+ font-size: 2.75rem;
124
+ }
125
+
126
+ .RouteErrorPage-code {
127
+ margin: 0;
128
+ color: var(--ds-color-neutral-text-subtle, #555);
129
+ font-size: clamp(3.5rem, 12vw, 6rem);
130
+ font-weight: 700;
131
+ line-height: 1;
132
+ letter-spacing: 0.04em;
133
+ font-variant-numeric: tabular-nums;
134
+ }
135
+
136
+ .RouteErrorPage-status h1,
137
+ .RouteErrorPage-detail,
138
+ .RouteErrorPage-hint {
139
+ margin: 0;
140
+ }
141
+
142
+ .RouteErrorPage-hint {
143
+ color: var(--ds-color-neutral-text-subtle, #555);
144
+ }
145
+
146
+ .RouteErrorPage-actions {
147
+ display: flex;
148
+ flex-wrap: wrap;
149
+ justify-content: center;
150
+ gap: var(--ds-size-2, 0.5rem);
151
+ margin-top: var(--ds-size-4, 1rem);
152
+ }
@@ -0,0 +1,53 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { createMemoryRouter, RouterProvider } from "react-router";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { RouteErrorPage } from "./ErrorBoundary.js";
6
+
7
+ function renderThrowing(error: () => unknown) {
8
+ let router = createMemoryRouter([
9
+ {
10
+ path: "/",
11
+ loader: () => {
12
+ throw error();
13
+ },
14
+ Component: () => null,
15
+ errorElement: <RouteErrorPage />,
16
+ },
17
+ ]);
18
+ return render(<RouterProvider router={router} />);
19
+ }
20
+
21
+ describe("RouteErrorPage", () => {
22
+ let logError = vi.fn();
23
+ (globalThis as any).af = { common: { logError } };
24
+
25
+ afterEach(() => logError.mockClear());
26
+
27
+ it("shows a status page with the app's own text for a thrown 404", async () => {
28
+ renderThrowing(() => new Response("", { status: 404, statusText: "The invoice was not found" }));
29
+ expect(await screen.findByRole("heading", { name: "We could not find what you were looking for" })).toBeTruthy();
30
+ expect(screen.getByText("404")).toBeTruthy();
31
+ expect(screen.getByText("The invoice was not found")).toBeTruthy();
32
+ expect(screen.getByRole("button", { name: "Go to the start page" })).toBeTruthy();
33
+ expect(logError).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it("leaves out a standard reason phrase", async () => {
37
+ renderThrowing(() => new Response("", { status: 403, statusText: "Forbidden" }));
38
+ expect(await screen.findByRole("heading", { name: "You do not have access to this" })).toBeTruthy();
39
+ expect(screen.queryByText("Forbidden")).toBeNull();
40
+ });
41
+
42
+ it("offers a refresh and reports server errors", async () => {
43
+ renderThrowing(() => new Response("", { status: 500 }));
44
+ expect(await screen.findByRole("button", { name: "Refresh page" })).toBeTruthy();
45
+ expect(logError).toHaveBeenCalled();
46
+ });
47
+
48
+ it("shows an unexpected exception with its message", async () => {
49
+ renderThrowing(() => new TypeError("Boom"));
50
+ expect(await screen.findByText("Boom")).toBeTruthy();
51
+ expect(logError).toHaveBeenCalled();
52
+ });
53
+ });
@@ -3,8 +3,18 @@ import "./ErrorBoundary.css";
3
3
  import type { DataObject } from "@olenbetong/appframe-data";
4
4
  import { useError } from "@olenbetong/appframe-react";
5
5
 
6
+ import { Button, Heading, Paragraph } from "@digdir/designsystemet-react";
7
+ import {
8
+ ArrowLeftIcon,
9
+ CloudSlashIcon,
10
+ ExclamationmarkTriangleIcon,
11
+ FileSearchIcon,
12
+ PadlockLockedIcon,
13
+ PersonIcon,
14
+ } from "@navikt/aksel-icons";
15
+
6
16
  import { Component, useEffect } from "react";
7
- import { useAsyncError, useRouteError } from "react-router";
17
+ import { type ErrorResponse, isRouteErrorResponse, useAsyncError, useNavigate, useRouteError } from "react-router";
8
18
 
9
19
  import { PageContainer } from "./PageContainer.js";
10
20
  import { getLocalizedString } from "../i18n.js";
@@ -127,13 +137,127 @@ export function ErrorAlertFallback({ error, className }: ErrorAlertFallbackProps
127
137
 
128
138
  // ── RouteErrorPage ────────────────────────────────────────────────────────────
129
139
 
140
+ /** The reason phrases a server sends on its own; anything else in statusText was written for the user. */
141
+ const standardStatusTexts = new Set([
142
+ "Bad Request",
143
+ "Unauthorized",
144
+ "Forbidden",
145
+ "Not Found",
146
+ "Method Not Allowed",
147
+ "Conflict",
148
+ "Gone",
149
+ "Internal Server Error",
150
+ "Bad Gateway",
151
+ "Service Unavailable",
152
+ "Gateway Timeout",
153
+ ]);
154
+
155
+ type StatusPage = {
156
+ icon: React.ReactNode;
157
+ title: string;
158
+ message: string;
159
+ retry: boolean;
160
+ };
161
+
162
+ function statusPage(status: number): StatusPage {
163
+ if (status === 404 || status === 410) {
164
+ return {
165
+ icon: <FileSearchIcon aria-hidden />,
166
+ title: getLocalizedString("We could not find what you were looking for"),
167
+ message: getLocalizedString("It may have been moved or deleted, or the link may be wrong."),
168
+ retry: false,
169
+ };
170
+ }
171
+ if (status === 403) {
172
+ return {
173
+ icon: <PadlockLockedIcon aria-hidden />,
174
+ title: getLocalizedString("You do not have access to this"),
175
+ message: getLocalizedString("Contact the IT department if you need access."),
176
+ retry: false,
177
+ };
178
+ }
179
+ if (status === 401) {
180
+ return {
181
+ icon: <PersonIcon aria-hidden />,
182
+ title: getLocalizedString("You are not logged in"),
183
+ message: getLocalizedString("Refresh the page to log in again."),
184
+ retry: true,
185
+ };
186
+ }
187
+ if (status >= 500) {
188
+ return {
189
+ icon: <CloudSlashIcon aria-hidden />,
190
+ title: getLocalizedString("The server could not complete the request"),
191
+ message: getLocalizedString("Try again in a moment. If the error persists, contact the IT department."),
192
+ retry: true,
193
+ };
194
+ }
195
+ return {
196
+ icon: <ExclamationmarkTriangleIcon aria-hidden />,
197
+ title: getLocalizedString("The request could not be completed"),
198
+ message: getLocalizedString(
199
+ "Check what you entered and try again. If the error persists, contact the IT department.",
200
+ ),
201
+ retry: true,
202
+ };
203
+ }
204
+
205
+ /** A thrown `Response` or `data()`: a status page, with the app's own text when it gave one. */
206
+ function RouteErrorResponsePage({ error }: { error: ErrorResponse }) {
207
+ const navigate = useNavigate();
208
+ const page = statusPage(error.status);
209
+ const detail =
210
+ error.statusText && !standardStatusTexts.has(error.statusText)
211
+ ? error.statusText
212
+ : typeof error.data === "string" && error.data.trim()
213
+ ? error.data
214
+ : null;
215
+ const canGoBack = typeof window !== "undefined" && window.history.length > 1;
216
+
217
+ return (
218
+ <div className="RouteErrorPage-status">
219
+ <div className="RouteErrorPage-icon">{page.icon}</div>
220
+ <p className="RouteErrorPage-code">{error.status}</p>
221
+ <Heading level={1} data-size="md">
222
+ {page.title}
223
+ </Heading>
224
+ {detail && <Paragraph className="RouteErrorPage-detail">{detail}</Paragraph>}
225
+ <Paragraph data-size="sm" className="RouteErrorPage-hint">
226
+ {page.message}
227
+ </Paragraph>
228
+ <div className="RouteErrorPage-actions">
229
+ {canGoBack && (
230
+ <Button variant="secondary" onClick={() => navigate(-1)}>
231
+ <ArrowLeftIcon aria-hidden />
232
+ {getLocalizedString("Go back")}
233
+ </Button>
234
+ )}
235
+ {page.retry ? (
236
+ <Button onClick={() => window.location.reload()}>{getLocalizedString("Refresh page")}</Button>
237
+ ) : (
238
+ <Button onClick={() => navigate("/")}>{getLocalizedString("Go to the start page")}</Button>
239
+ )}
240
+ </div>
241
+ </div>
242
+ );
243
+ }
244
+
130
245
  export function RouteErrorPage() {
131
246
  const error = useRouteError();
132
- const { message, stackTrace, title } = parseError(error);
247
+ const isResponse = isRouteErrorResponse(error);
133
248
 
134
249
  useEffect(() => {
135
- (globalThis as any).af?.common?.logError?.(error);
136
- }, [error]);
250
+ // A missing page or a refused request is expected; only unexpected errors are worth reporting.
251
+ if (!isResponse || error.status >= 500) {
252
+ (globalThis as any).af?.common?.logError?.(error);
253
+ }
254
+ }, [error, isResponse]);
255
+
256
+ if (isResponse) {
257
+ return <RouteErrorResponsePage error={error} />;
258
+ }
259
+
260
+ const { message, stackTrace, title } = parseError(error);
137
261
 
138
262
  return (
139
263
  <PageContainer className="flow">