@olenbetong/appframe-ds 1.12.0 → 1.13.1
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 +12 -0
- package/package.json +2 -2
- package/src/grid/slots/slots.css +25 -0
- package/src/layout/ErrorBoundary.css +51 -0
- package/src/layout/ErrorBoundary.test.tsx +53 -0
- package/src/layout/ErrorBoundary.tsx +128 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @olenbetong/appframe-ds
|
|
2
2
|
|
|
3
|
+
## 1.13.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6235575: Selects in a grid cell being edited no longer draw a field border and background; they fill the cell like the text editor.
|
|
8
|
+
|
|
9
|
+
## 1.13.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 1.12.0
|
|
4
16
|
|
|
5
17
|
### 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.1",
|
|
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",
|
package/src/grid/slots/slots.css
CHANGED
|
@@ -12,6 +12,31 @@
|
|
|
12
12
|
width: 100%;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/* In a cell being edited the select fills the cell like the grid's own text editor: the cell's
|
|
16
|
+
outline already marks the edit, so a field border inside it only looks cramped. */
|
|
17
|
+
.MuiDataGrid-cell--editing .AfGrid-select {
|
|
18
|
+
--dsc-input-background: transparent;
|
|
19
|
+
--dsc-input-border-color: transparent;
|
|
20
|
+
--dsc-input-border-radius: 0;
|
|
21
|
+
--dsc-input-outline-color--hover: transparent;
|
|
22
|
+
|
|
23
|
+
block-size: 100%;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* The open list takes its colours from the select, which is transparent here; without its own
|
|
27
|
+
background it falls back to white under the theme's text colour. */
|
|
28
|
+
.MuiDataGrid-cell--editing .AfGrid-select option {
|
|
29
|
+
background-color: var(--ds-color-neutral-surface-default);
|
|
30
|
+
color: var(--ds-color-neutral-text-default);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* The grid outlines the focused cell, as it does for the text editor; Designsystemet's focus
|
|
34
|
+
ring on top of it would read as a second, mismatched border. */
|
|
35
|
+
.MuiDataGrid-cell--editing .AfGrid-select:focus-visible {
|
|
36
|
+
outline: none;
|
|
37
|
+
box-shadow: none;
|
|
38
|
+
}
|
|
39
|
+
|
|
15
40
|
/* Sort-order and filter-count badges on header icon buttons: a compact number
|
|
16
41
|
pinned to the button corner instead of Designsystemet's default 1.5rem badge. */
|
|
17
42
|
.AfGrid-badge .ds-badge {
|
|
@@ -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">
|