@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.
|
|
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.
|
|
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
|
|
247
|
+
const isResponse = isRouteErrorResponse(error);
|
|
133
248
|
|
|
134
249
|
useEffect(() => {
|
|
135
|
-
|
|
136
|
-
|
|
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">
|