@olenbetong/appframe-ds 1.11.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 +12 -0
- package/package.json +6 -6
- package/src/grid/assistant.test.ts +162 -0
- package/src/grid/assistant.ts +248 -0
- package/src/grid/assistantFilter.ts +97 -0
- package/src/grid/index.tsx +21 -0
- package/src/grid/useAfAssistant.ts +195 -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.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
|
+
|
|
9
|
+
## 1.12.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 407ab94: AfGrid offers the site assistant tools to read and change its layout, sort, filter and grouping and to read its rows, on pages where the assistant is switched on. New props `assistantLabel` and `disableAssistant`.
|
|
14
|
+
|
|
3
15
|
## 1.11.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.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,8 +63,8 @@
|
|
|
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.
|
|
67
|
-
"@olenbetong/appframe-data": "^1.10.
|
|
66
|
+
"@olenbetong/appframe-core": "^2.16.1",
|
|
67
|
+
"@olenbetong/appframe-data": "^1.10.3",
|
|
68
68
|
"@olenbetong/appframe-react": "^2.2.8",
|
|
69
69
|
"clsx": "^2.1.1",
|
|
70
70
|
"dockview-react": "8.8.3",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"react": "19.3.1",
|
|
74
74
|
"react-dom": "19.3.1",
|
|
75
75
|
"react-window": "2.5.3",
|
|
76
|
-
"temporal-polyfill": "1.0.
|
|
76
|
+
"temporal-polyfill": "1.0.8"
|
|
77
77
|
},
|
|
78
78
|
"devDependencies": {
|
|
79
79
|
"@digdir/designsystemet-react": "1.23.0",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"@mui/material": "9.5.0",
|
|
83
83
|
"@mui/x-data-grid-premium": "8.29.5",
|
|
84
84
|
"@mui/x-license": "8.29.3",
|
|
85
|
-
"@navikt/aksel-icons": "8.
|
|
85
|
+
"@navikt/aksel-icons": "8.22.1",
|
|
86
86
|
"@testing-library/dom": "^10.4.2",
|
|
87
87
|
"@testing-library/jest-dom": "^7.0.1",
|
|
88
88
|
"@testing-library/react": "16.3.3",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
"react-error-boundary": "6.1.6",
|
|
94
94
|
"react-router": "8.5.1",
|
|
95
95
|
"typescript": "7.0.2",
|
|
96
|
-
"vitest": "5.0
|
|
96
|
+
"vitest": "5.1.0"
|
|
97
97
|
},
|
|
98
98
|
"scripts": {
|
|
99
99
|
"build": "pnpm run build:tsc && pnpm test && pnpm run build:css",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import type { FieldMetadata } from "@olenbetong/appframe-core";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { type AssistantGrid, createGridTools, registerAssistantGrid } from "./assistant.js";
|
|
5
|
+
import { AssistantFilterError, toGridFilter } from "./assistantFilter.js";
|
|
6
|
+
|
|
7
|
+
const fields: FieldMetadata[] = [
|
|
8
|
+
{ name: "Customer", type: "string" },
|
|
9
|
+
{ name: "Amount", type: "number" },
|
|
10
|
+
{ name: "Delivered", type: "date" },
|
|
11
|
+
{ name: "Closed", type: "boolean" },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
describe("the assistant's grid filter", () => {
|
|
15
|
+
it("becomes the grid's filter tree, with the field's own name and value types", () => {
|
|
16
|
+
expect(
|
|
17
|
+
toGridFilter(
|
|
18
|
+
{
|
|
19
|
+
mode: "and",
|
|
20
|
+
items: [
|
|
21
|
+
{ column: "customer", operator: "contains", value: "Olen" },
|
|
22
|
+
{ column: "Amount", operator: "greaterThan", value: "1000" },
|
|
23
|
+
{ column: "Delivered", operator: "between", value: ["2026-01-01", "2026-06-30"] },
|
|
24
|
+
{ column: "Closed", operator: "isfalse" },
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
fields,
|
|
28
|
+
),
|
|
29
|
+
).toEqual({
|
|
30
|
+
type: "group",
|
|
31
|
+
mode: "and",
|
|
32
|
+
items: [
|
|
33
|
+
{ type: "expression", column: "Customer", operator: "contains", value: "Olen", valueType: "string" },
|
|
34
|
+
{ type: "expression", column: "Amount", operator: "greaterthan", value: 1000, valueType: "number" },
|
|
35
|
+
{
|
|
36
|
+
type: "expression",
|
|
37
|
+
column: "Delivered",
|
|
38
|
+
operator: "between",
|
|
39
|
+
value: ["2026-01-01", "2026-06-30"],
|
|
40
|
+
valueType: "array",
|
|
41
|
+
},
|
|
42
|
+
{ type: "expression", column: "Closed", operator: "isfalse", value: null, valueType: "null" },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("turns a relative date into a special value", () => {
|
|
48
|
+
let tree = toGridFilter({ column: "Delivered", operator: "greaterthanorequal", value: "lastweek" }, fields);
|
|
49
|
+
expect(tree.items[0]).toMatchObject({ value: "LastWeek", valueType: "special" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("keeps nested groups", () => {
|
|
53
|
+
let tree = toGridFilter(
|
|
54
|
+
{
|
|
55
|
+
mode: "or",
|
|
56
|
+
items: [
|
|
57
|
+
{ column: "Customer", operator: "equals", value: "A" },
|
|
58
|
+
{ mode: "and", items: [{ column: "Amount", operator: "lessthan", value: 5 }] },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
fields,
|
|
62
|
+
);
|
|
63
|
+
expect(tree.mode).toBe("or");
|
|
64
|
+
expect(tree.items[1]).toMatchObject({ type: "group", mode: "and" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("refuses unknown fields, wrong operators and missing values with a message to act on", () => {
|
|
68
|
+
expect(() => toGridFilter({ column: "Secret", operator: "equals", value: 1 }, fields)).toThrow(
|
|
69
|
+
AssistantFilterError,
|
|
70
|
+
);
|
|
71
|
+
expect(() => toGridFilter({ column: "Closed", operator: "contains", value: "x" }, fields)).toThrow(/It can use/);
|
|
72
|
+
expect(() => toGridFilter({ column: "Amount", operator: "equals" }, fields)).toThrow(/needs a value/);
|
|
73
|
+
expect(() => toGridFilter({ column: "Amount", operator: "between", value: [1] }, fields)).toThrow(/two values/);
|
|
74
|
+
expect(() => toGridFilter({ column: "Amount", operator: "equals", value: "many" }, fields)).toThrow(/is a number/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("the grid tools", () => {
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
delete (globalThis as { ob?: unknown }).ob;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function fakeGrid(label: string): AssistantGrid {
|
|
84
|
+
return {
|
|
85
|
+
label: () => label,
|
|
86
|
+
getLayout: vi.fn(() => ({ label })),
|
|
87
|
+
getRows: vi.fn((maxRows: number) => ({ maxRows })),
|
|
88
|
+
setColumns: vi.fn(),
|
|
89
|
+
sort: vi.fn(),
|
|
90
|
+
filter: vi.fn(),
|
|
91
|
+
group: vi.fn(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fakeChrome(enabled = true) {
|
|
96
|
+
let registered = new Map<string, { parameters?: any; handler: (args: any) => unknown }>();
|
|
97
|
+
(globalThis as { ob?: unknown }).ob = {
|
|
98
|
+
chrome: {
|
|
99
|
+
assistant: {
|
|
100
|
+
enabled,
|
|
101
|
+
registerTool: (tool: { name: string; parameters?: any; handler: (args: any) => unknown }) => {
|
|
102
|
+
registered.set(tool.name, tool);
|
|
103
|
+
return () => registered.delete(tool.name);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
return registered;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
it("registers one set of tools for all grids, naming them by label, and removes it with the last grid", async () => {
|
|
112
|
+
let registered = fakeChrome();
|
|
113
|
+
let offAll = [registerAssistantGrid(fakeGrid("Deliveries")), registerAssistantGrid(fakeGrid("Deliveries"))];
|
|
114
|
+
await Promise.resolve();
|
|
115
|
+
|
|
116
|
+
expect([...registered.keys()]).toEqual([
|
|
117
|
+
"get_grid_layout",
|
|
118
|
+
"get_grid_rows",
|
|
119
|
+
"set_grid_columns",
|
|
120
|
+
"sort_grid",
|
|
121
|
+
"filter_grid",
|
|
122
|
+
"group_grid",
|
|
123
|
+
]);
|
|
124
|
+
expect(registered.get("get_grid_layout")?.parameters.properties.grid.enum).toEqual([
|
|
125
|
+
"Deliveries",
|
|
126
|
+
"Deliveries (2)",
|
|
127
|
+
]);
|
|
128
|
+
expect(registered.get("get_grid_layout")?.handler({ grid: "Deliveries (2)" })).toEqual({ label: "Deliveries" });
|
|
129
|
+
expect(registered.get("get_grid_layout")?.handler({ grid: "Nope" })).toEqual({
|
|
130
|
+
error: expect.stringContaining("Deliveries, Deliveries (2)"),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
for (let off of offAll) off();
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
expect(registered.size).toBe(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("needs no grid name when there is one grid, and caps the rows", async () => {
|
|
139
|
+
let registered = fakeChrome();
|
|
140
|
+
let off = registerAssistantGrid(fakeGrid("Strings"));
|
|
141
|
+
await Promise.resolve();
|
|
142
|
+
expect(registered.get("get_grid_rows")?.handler({ maxRows: 5000 })).toEqual({ maxRows: 50 });
|
|
143
|
+
off();
|
|
144
|
+
await Promise.resolve();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("registers nothing when the assistant is off for the page", async () => {
|
|
148
|
+
let registered = fakeChrome(false);
|
|
149
|
+
let off = registerAssistantGrid(fakeGrid("Strings"));
|
|
150
|
+
await Promise.resolve();
|
|
151
|
+
expect(registered.size).toBe(0);
|
|
152
|
+
off();
|
|
153
|
+
await Promise.resolve();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("describes the filter syntax and the grids in the tools", () => {
|
|
157
|
+
let tools = createGridTools(["A", "B"]);
|
|
158
|
+
let filter = tools.find((tool) => tool.name === "filter_grid")!;
|
|
159
|
+
expect((filter.parameters as any).required).toEqual(["grid", "mode"]);
|
|
160
|
+
expect(tools[0].description).toContain("A, B");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { getLocalizedString } from "../i18n.js";
|
|
2
|
+
import { ASSISTANT_FILTER_SCHEMA, type AssistantFilterNode } from "./assistantFilter.js";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* The site assistant's tools for AfGrid: reading and changing the layout, sort, filter and grouping
|
|
6
|
+
* of the grids on the page, and reading the rows they show. One set of tools is registered while
|
|
7
|
+
* any grid is mounted, with a `grid` parameter naming the grid by its label, so a page with three
|
|
8
|
+
* grids does not send three sets with every question.
|
|
9
|
+
*
|
|
10
|
+
* The assistant belongs to the site chrome (synergi-chrome), which this package does not depend on:
|
|
11
|
+
* the tools go through `window.ob.chrome.assistant` when the page has it, and nothing happens
|
|
12
|
+
* otherwise. The tools change what the grid shows, never data, so they run without asking; the
|
|
13
|
+
* user can change any of it back in the grid.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** What a mounted grid offers the assistant. Every method reads the grid's current state. */
|
|
17
|
+
export type AssistantGrid = {
|
|
18
|
+
label: () => string;
|
|
19
|
+
getLayout: () => unknown;
|
|
20
|
+
getRows: (maxRows: number) => unknown;
|
|
21
|
+
setColumns: (change: { show?: string[]; hide?: string[]; order?: string[] }) => unknown;
|
|
22
|
+
sort: (sort: { field: string; direction?: "asc" | "desc" }[]) => unknown;
|
|
23
|
+
filter: (mode: "replace" | "add" | "clear", filter?: AssistantFilterNode) => unknown;
|
|
24
|
+
group: (change: { groupBy?: string[]; totals?: Record<string, string> }) => unknown;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ToolLike = {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
label?: string;
|
|
31
|
+
parameters?: Record<string, unknown>;
|
|
32
|
+
handler: (args: any) => unknown;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type AssistantApiLike = { readonly enabled: boolean; registerTool: (tool: ToolLike) => () => void };
|
|
36
|
+
|
|
37
|
+
/** The most rows `get_grid_rows` returns, so one call cannot fill the conversation. */
|
|
38
|
+
export const MAX_ASSISTANT_ROWS = 50;
|
|
39
|
+
|
|
40
|
+
function assistantApi(): AssistantApiLike | undefined {
|
|
41
|
+
return (globalThis as { ob?: { chrome?: { assistant?: AssistantApiLike } } }).ob?.chrome?.assistant;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const grids = new Set<AssistantGrid>();
|
|
45
|
+
let unregister: (() => void)[] = [];
|
|
46
|
+
let scheduled = false;
|
|
47
|
+
let waitingForChrome = false;
|
|
48
|
+
|
|
49
|
+
/** The grids by the label the assistant uses; a second grid with the same label gets " (2)". */
|
|
50
|
+
function gridsByLabel(): Map<string, AssistantGrid> {
|
|
51
|
+
let byLabel = new Map<string, AssistantGrid>();
|
|
52
|
+
for (let grid of grids) {
|
|
53
|
+
let base = grid.label().trim() || "Grid";
|
|
54
|
+
let label = base;
|
|
55
|
+
for (let n = 2; byLabel.has(label); n++) label = `${base} (${n})`;
|
|
56
|
+
byLabel.set(label, grid);
|
|
57
|
+
}
|
|
58
|
+
return byLabel;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function schedule() {
|
|
62
|
+
if (scheduled) return;
|
|
63
|
+
scheduled = true;
|
|
64
|
+
// Grids mounting in the same render are registered together.
|
|
65
|
+
queueMicrotask(() => {
|
|
66
|
+
scheduled = false;
|
|
67
|
+
refresh();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function refresh() {
|
|
72
|
+
for (let remove of unregister) remove();
|
|
73
|
+
unregister = [];
|
|
74
|
+
if (grids.size === 0) return;
|
|
75
|
+
|
|
76
|
+
let api = assistantApi();
|
|
77
|
+
if (!api) {
|
|
78
|
+
// The chrome mounts after the app on some pages; register once it is there.
|
|
79
|
+
if (!waitingForChrome && typeof window !== "undefined") {
|
|
80
|
+
waitingForChrome = true;
|
|
81
|
+
window.addEventListener(
|
|
82
|
+
"ob:chrome-ready",
|
|
83
|
+
() => {
|
|
84
|
+
waitingForChrome = false;
|
|
85
|
+
schedule();
|
|
86
|
+
},
|
|
87
|
+
{ once: true },
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!api.enabled) return;
|
|
93
|
+
|
|
94
|
+
let labels = [...gridsByLabel().keys()];
|
|
95
|
+
unregister = createGridTools(labels).map((tool) => api.registerTool(tool));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Adds a grid to the assistant's tools; returns a function that removes it. */
|
|
99
|
+
export function registerAssistantGrid(grid: AssistantGrid): () => void {
|
|
100
|
+
grids.add(grid);
|
|
101
|
+
schedule();
|
|
102
|
+
return () => {
|
|
103
|
+
grids.delete(grid);
|
|
104
|
+
schedule();
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Re-reads the labels, after a grid's label changed. */
|
|
109
|
+
export function refreshAssistantGrids() {
|
|
110
|
+
if (grids.size > 0) schedule();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function resolveGrid(name: unknown): AssistantGrid {
|
|
114
|
+
let byLabel = gridsByLabel();
|
|
115
|
+
if (byLabel.size === 1 && (name === undefined || name === null || name === "")) return [...byLabel.values()][0];
|
|
116
|
+
let grid = byLabel.get(String(name ?? ""));
|
|
117
|
+
if (!grid)
|
|
118
|
+
throw new Error(`No grid named "${String(name ?? "")}". The grids are: ${[...byLabel.keys()].join(", ")}.`);
|
|
119
|
+
return grid;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function withGrid<TArgs extends { grid?: string }>(run: (grid: AssistantGrid, args: TArgs) => unknown) {
|
|
123
|
+
return (args: TArgs) => {
|
|
124
|
+
try {
|
|
125
|
+
return run(resolveGrid(args?.grid), args ?? ({} as TArgs));
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The tools for the grids with these labels. Exported for tests. */
|
|
133
|
+
export function createGridTools(labels: string[]): ToolLike[] {
|
|
134
|
+
let gridParameter = {
|
|
135
|
+
grid: {
|
|
136
|
+
type: "string",
|
|
137
|
+
enum: labels,
|
|
138
|
+
description:
|
|
139
|
+
labels.length === 1 ? "The grid. Can be left out: there is one." : "Which grid on the page, by its label.",
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
let required = labels.length === 1 ? [] : ["grid"];
|
|
143
|
+
let fieldList = { type: "array", items: { type: "string" } };
|
|
144
|
+
|
|
145
|
+
return [
|
|
146
|
+
{
|
|
147
|
+
name: "get_grid_layout",
|
|
148
|
+
label: getLocalizedString("Read the grid's layout"),
|
|
149
|
+
description: `The layout of a grid on the page (${labels.join(", ")}): its columns (field, header, whether shown), the fields it can be filtered on, the sort, the filter, the grouping and totals, and how many rows it has. Read it before changing the grid.`,
|
|
150
|
+
parameters: { type: "object", properties: gridParameter, required },
|
|
151
|
+
handler: withGrid((grid) => grid.getLayout()),
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "get_grid_rows",
|
|
155
|
+
label: getLocalizedString("Read the rows in the grid"),
|
|
156
|
+
description: `The rows a grid shows, in its order and with its shown columns: the rows it has loaded (the current page when it pages), at most ${MAX_ASSISTANT_ROWS}. To look at other rows, filter or sort the grid first.`,
|
|
157
|
+
parameters: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
...gridParameter,
|
|
161
|
+
maxRows: { type: "number", description: `At most this many rows (default and limit ${MAX_ASSISTANT_ROWS}).` },
|
|
162
|
+
},
|
|
163
|
+
required,
|
|
164
|
+
},
|
|
165
|
+
handler: withGrid((grid, { maxRows }: { grid?: string; maxRows?: number }) =>
|
|
166
|
+
grid.getRows(Math.max(1, Math.min(MAX_ASSISTANT_ROWS, Number(maxRows) || MAX_ASSISTANT_ROWS))),
|
|
167
|
+
),
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
name: "set_grid_columns",
|
|
171
|
+
label: getLocalizedString("Change the grid's columns"),
|
|
172
|
+
description:
|
|
173
|
+
"Shows or hides a grid's columns, and moves them. `order` lists fields in the order they should come first; the rest keep their order after them. Use the fields from get_grid_layout.",
|
|
174
|
+
parameters: {
|
|
175
|
+
type: "object",
|
|
176
|
+
properties: { ...gridParameter, show: fieldList, hide: fieldList, order: fieldList },
|
|
177
|
+
required,
|
|
178
|
+
},
|
|
179
|
+
handler: withGrid((grid, args: { grid?: string; show?: string[]; hide?: string[]; order?: string[] }) =>
|
|
180
|
+
grid.setColumns(args),
|
|
181
|
+
),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "sort_grid",
|
|
185
|
+
label: getLocalizedString("Sort the grid"),
|
|
186
|
+
description: "Sorts a grid by one or more columns, first one first. An empty list removes the sort.",
|
|
187
|
+
parameters: {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
...gridParameter,
|
|
191
|
+
sort: {
|
|
192
|
+
type: "array",
|
|
193
|
+
items: {
|
|
194
|
+
type: "object",
|
|
195
|
+
properties: { field: { type: "string" }, direction: { type: "string", enum: ["asc", "desc"] } },
|
|
196
|
+
required: ["field"],
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
required: [...required, "sort"],
|
|
201
|
+
},
|
|
202
|
+
handler: withGrid((grid, { sort }: { grid?: string; sort?: { field: string; direction?: "asc" | "desc" }[] }) =>
|
|
203
|
+
grid.sort(Array.isArray(sort) ? sort : []),
|
|
204
|
+
),
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "filter_grid",
|
|
208
|
+
label: getLocalizedString("Filter the grid"),
|
|
209
|
+
description:
|
|
210
|
+
"Filters a grid on the server, like the grid's filter builder (which then shows it). `replace` swaps the filter for this one, `add` keeps the current filter and adds this one with AND, `clear` removes the filter. Columns are the filter fields from get_grid_layout, which may include fields that are not shown.",
|
|
211
|
+
parameters: {
|
|
212
|
+
type: "object",
|
|
213
|
+
properties: {
|
|
214
|
+
...gridParameter,
|
|
215
|
+
mode: { type: "string", enum: ["replace", "add", "clear"] },
|
|
216
|
+
filter: ASSISTANT_FILTER_SCHEMA,
|
|
217
|
+
},
|
|
218
|
+
required: [...required, "mode"],
|
|
219
|
+
},
|
|
220
|
+
handler: withGrid(
|
|
221
|
+
(grid, { mode, filter }: { grid?: string; mode?: "replace" | "add" | "clear"; filter?: AssistantFilterNode }) =>
|
|
222
|
+
grid.filter(mode ?? "replace", filter),
|
|
223
|
+
),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: "group_grid",
|
|
227
|
+
label: getLocalizedString("Group the grid"),
|
|
228
|
+
description:
|
|
229
|
+
"Groups a grid's rows by columns (an empty list removes the grouping) and sets totals per column: sum, avg, min, max or size (count). Grouping and totals cover the rows the grid has loaded. Leave out what should stay as it is.",
|
|
230
|
+
parameters: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
...gridParameter,
|
|
234
|
+
groupBy: fieldList,
|
|
235
|
+
totals: {
|
|
236
|
+
type: "object",
|
|
237
|
+
description: 'Field to total function, e.g. { "Amount": "sum" }. An empty object removes the totals.',
|
|
238
|
+
additionalProperties: { type: "string", enum: ["sum", "avg", "min", "max", "size"] },
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
required,
|
|
242
|
+
},
|
|
243
|
+
handler: withGrid((grid, args: { grid?: string; groupBy?: string[]; totals?: Record<string, string> }) =>
|
|
244
|
+
grid.group(args),
|
|
245
|
+
),
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type FieldMetadata,
|
|
3
|
+
type FilterExpression,
|
|
4
|
+
type FilterGroup,
|
|
5
|
+
getDynamicValuesForField,
|
|
6
|
+
getOperatorArity,
|
|
7
|
+
getOperatorsForField,
|
|
8
|
+
getValueType,
|
|
9
|
+
normalizeOperator,
|
|
10
|
+
} from "@olenbetong/appframe-core";
|
|
11
|
+
|
|
12
|
+
import { findFilterField } from "./useAfFilter.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A filter as the assistant writes it: groups of `column operator value`, without the type and
|
|
16
|
+
* value type fields the grid's filter tree carries. Converted with {@link toGridFilter}.
|
|
17
|
+
*/
|
|
18
|
+
export type AssistantFilterNode =
|
|
19
|
+
| { mode?: "and" | "or"; items: AssistantFilterNode[] }
|
|
20
|
+
| { column: string; operator: string; value?: unknown };
|
|
21
|
+
|
|
22
|
+
/** The JSON Schema the assistant is given for {@link AssistantFilterNode}. */
|
|
23
|
+
export const ASSISTANT_FILTER_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
description:
|
|
26
|
+
"A group: { mode: 'and' | 'or', items: [...] }, where each item is another group or a criterion { column, operator, value }. Operators: equals, notequals, greaterthan, greaterthanorequal, lessthan, lessthanorequal, contains, notcontains, beginswith, endswith, isnull, isnotnull, isblank, isnotblank, istrue, isfalse, inlist and notinlist (value is an array), between and notbetween (value is [from, to]). Dates use the same comparisons with 'YYYY-MM-DD' values, or a relative value such as Today, Yesterday, ThisWeek, LastWeek, ThisMonth, LastMonth, ThisYear or LastYear; whole periods also have operators without a value: istoday, isyesterday, islastweek, isearlierthisweek, isearlierthismonth, isearlierthisyear, ispriorthisyear, istomorrow, isnextweek. A text field can compare with CurrentUser. Only the operators a field allows are accepted; an error lists them.",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/** What went wrong in a filter, for the assistant to correct. */
|
|
30
|
+
export class AssistantFilterError extends Error {}
|
|
31
|
+
|
|
32
|
+
function isGroupNode(node: AssistantFilterNode): node is { mode?: "and" | "or"; items: AssistantFilterNode[] } {
|
|
33
|
+
return Array.isArray((node as { items?: unknown }).items);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function convertValue(
|
|
37
|
+
field: FieldMetadata,
|
|
38
|
+
operator: string,
|
|
39
|
+
value: unknown,
|
|
40
|
+
): Pick<FilterExpression, "value" | "valueType"> {
|
|
41
|
+
let arity = getOperatorArity(operator as never);
|
|
42
|
+
if (arity === "none") return { value: null, valueType: "null" };
|
|
43
|
+
|
|
44
|
+
let valueType = getValueType(field, operator as never);
|
|
45
|
+
if (arity === "list" || arity === "pair") {
|
|
46
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
47
|
+
throw new AssistantFilterError(`The operator ${operator} on ${field.name} needs a list of values.`);
|
|
48
|
+
}
|
|
49
|
+
if (arity === "pair" && value.length !== 2) {
|
|
50
|
+
throw new AssistantFilterError(`The operator ${operator} on ${field.name} needs two values: [from, to].`);
|
|
51
|
+
}
|
|
52
|
+
return { value: value.map((item) => (item === null ? null : String(item))) as string[], valueType: "array" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (value === undefined || value === null || value === "") {
|
|
56
|
+
throw new AssistantFilterError(`The operator ${operator} on ${field.name} needs a value.`);
|
|
57
|
+
}
|
|
58
|
+
let text = String(value);
|
|
59
|
+
let special = getDynamicValuesForField(field).find((name) => name.toLowerCase() === text.toLowerCase());
|
|
60
|
+
if (special) return { value: special, valueType: "special" };
|
|
61
|
+
if (valueType === "number") {
|
|
62
|
+
let number = Number(value);
|
|
63
|
+
if (Number.isNaN(number)) throw new AssistantFilterError(`${field.name} is a number; "${text}" is not.`);
|
|
64
|
+
return { value: number, valueType };
|
|
65
|
+
}
|
|
66
|
+
return { value: text, valueType };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Turns the assistant's filter into the grid's filter tree, checking every column against the
|
|
71
|
+
* grid's filter fields and every operator against the column's type. Throws an
|
|
72
|
+
* {@link AssistantFilterError} the assistant can act on.
|
|
73
|
+
*/
|
|
74
|
+
export function toGridFilter(node: AssistantFilterNode, fields: FieldMetadata[]): FilterGroup {
|
|
75
|
+
if (!isGroupNode(node)) return { type: "group", mode: "and", items: [toItem(node, fields)] };
|
|
76
|
+
return {
|
|
77
|
+
type: "group",
|
|
78
|
+
mode: node.mode === "or" ? "or" : "and",
|
|
79
|
+
items: node.items.map((item) => (isGroupNode(item) ? toGridFilter(item, fields) : toItem(item, fields))),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function toItem(
|
|
84
|
+
node: { column: string; operator: string; value?: unknown },
|
|
85
|
+
fields: FieldMetadata[],
|
|
86
|
+
): FilterExpression {
|
|
87
|
+
let field = node.column ? findFilterField(fields, node.column) : undefined;
|
|
88
|
+
if (!field || field.excludeFromFilter) {
|
|
89
|
+
throw new AssistantFilterError(`The grid cannot filter on "${node.column}". Use a field from get_grid_layout.`);
|
|
90
|
+
}
|
|
91
|
+
let operator = normalizeOperator(String(node.operator ?? ""));
|
|
92
|
+
let allowed = getOperatorsForField(field);
|
|
93
|
+
if (!allowed.includes(operator)) {
|
|
94
|
+
throw new AssistantFilterError(`${field.name} cannot use ${node.operator}. It can use: ${allowed.join(", ")}.`);
|
|
95
|
+
}
|
|
96
|
+
return { type: "expression", column: field.name, operator, ...convertValue(field, operator, node.value) };
|
|
97
|
+
}
|
package/src/grid/index.tsx
CHANGED
|
@@ -54,6 +54,7 @@ import type { AfGridColumn } from "./useAfColumns.js";
|
|
|
54
54
|
import { type AfGridColDef, type AfGridRowAction, useAfColumns } from "./useAfColumns.js";
|
|
55
55
|
import { useAfCurrentIndex } from "./useAfCurrentIndex.js";
|
|
56
56
|
import { useAfData } from "./useAfData.js";
|
|
57
|
+
import { useAfAssistant } from "./useAfAssistant.js";
|
|
57
58
|
import { useAfFilterFields } from "./useAfFilterFields.js";
|
|
58
59
|
import { findFilterField, useAfFilter } from "./useAfFilter.js";
|
|
59
60
|
import { type AfGridApi, useAfGridApi } from "./useAfGridApi.js";
|
|
@@ -311,6 +312,14 @@ export type AfGridProps = {
|
|
|
311
312
|
* button still shows that the grid is loading.
|
|
312
313
|
*/
|
|
313
314
|
disableLoadingOverlay?: boolean;
|
|
315
|
+
/**
|
|
316
|
+
* The grid's name for the site assistant ("Deliveries"), which can then read and change its
|
|
317
|
+
* layout, sort, filter and grouping and read its rows (see `assistant.ts`). Defaults to the id.
|
|
318
|
+
* The assistant only reaches the grid on pages where it is switched on.
|
|
319
|
+
*/
|
|
320
|
+
assistantLabel?: string;
|
|
321
|
+
/** Keeps the grid away from the site assistant. */
|
|
322
|
+
disableAssistant?: boolean;
|
|
314
323
|
} & Omit<
|
|
315
324
|
DataGridProps,
|
|
316
325
|
| "apiRef"
|
|
@@ -382,6 +391,8 @@ function AfGrid_(props: AfGridProps) {
|
|
|
382
391
|
getRowClassName,
|
|
383
392
|
serverAggregation = dataObject.options?.dynamicLoading === true,
|
|
384
393
|
disableLoadingOverlay = false,
|
|
394
|
+
assistantLabel,
|
|
395
|
+
disableAssistant = false,
|
|
385
396
|
...other
|
|
386
397
|
} = props;
|
|
387
398
|
let apiRefPriv = useGridApiRef();
|
|
@@ -445,6 +456,16 @@ function AfGrid_(props: AfGridProps) {
|
|
|
445
456
|
getRowActions,
|
|
446
457
|
});
|
|
447
458
|
let filterFields = useAfFilterFields(dataObject, columnsProp);
|
|
459
|
+
useAfAssistant({
|
|
460
|
+
id,
|
|
461
|
+
label: assistantLabel,
|
|
462
|
+
disabled: disableAssistant,
|
|
463
|
+
apiRef,
|
|
464
|
+
dataObject,
|
|
465
|
+
gridFilter,
|
|
466
|
+
filterFields,
|
|
467
|
+
grouping: grouping.gridProps,
|
|
468
|
+
});
|
|
448
469
|
|
|
449
470
|
let keyEvents = useAfKeyBindings({ apiRef, dataObject });
|
|
450
471
|
let rootRef = useRef<HTMLDivElement>(null);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { GridApi, GridColDef, GridSortModel } from "@mui/x-data-grid-premium";
|
|
2
|
+
import { describeFilter, type FieldMetadata, pruneFilter } from "@olenbetong/appframe-core";
|
|
3
|
+
import type { DataObject } from "@olenbetong/appframe-data";
|
|
4
|
+
import { type RefObject, useEffect, useLayoutEffect, useRef } from "react";
|
|
5
|
+
|
|
6
|
+
import { type AssistantGrid, refreshAssistantGrids, registerAssistantGrid } from "./assistant.js";
|
|
7
|
+
import { type AssistantFilterNode, toGridFilter } from "./assistantFilter.js";
|
|
8
|
+
import type { AfFilterApi } from "./useAfFilter.js";
|
|
9
|
+
import type { AfGridGroupingProps } from "./useAfRowGrouping.js";
|
|
10
|
+
|
|
11
|
+
type Options = {
|
|
12
|
+
id: string;
|
|
13
|
+
/** The grid's name for the assistant. Defaults to the grid id. */
|
|
14
|
+
label?: string;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
apiRef: RefObject<GridApi | null>;
|
|
17
|
+
dataObject: DataObject<any>;
|
|
18
|
+
gridFilter: AfFilterApi;
|
|
19
|
+
filterFields: FieldMetadata[];
|
|
20
|
+
grouping: AfGridGroupingProps;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const TOTALS = new Set(["sum", "avg", "min", "max", "size"]);
|
|
24
|
+
|
|
25
|
+
/** The grid's own columns: not the checkbox, row actions or the grouping column. */
|
|
26
|
+
function dataColumns(api: GridApi): GridColDef[] {
|
|
27
|
+
return api.getAllColumns().filter((column) => !column.field.startsWith("__") && column.type !== "actions");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function json(value: unknown) {
|
|
31
|
+
if (value instanceof Date) return value.toISOString();
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Offers the grid to the site assistant (see assistant.ts). The methods read the grid when called,
|
|
37
|
+
* through a ref updated after every render, so the tools always act on what the user sees.
|
|
38
|
+
*/
|
|
39
|
+
export function useAfAssistant(options: Options) {
|
|
40
|
+
let latest = useRef(options);
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
latest.current = options;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
let grid = useRef<AssistantGrid | null>(null);
|
|
46
|
+
grid.current ??= createAssistantGrid(() => latest.current);
|
|
47
|
+
|
|
48
|
+
let { disabled, label, id } = options;
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (disabled) return;
|
|
51
|
+
return registerAssistantGrid(grid.current!);
|
|
52
|
+
}, [disabled]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
refreshAssistantGrids();
|
|
56
|
+
}, [label, id]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function createAssistantGrid(get: () => Options): AssistantGrid {
|
|
60
|
+
function api(): GridApi {
|
|
61
|
+
let current = get().apiRef.current;
|
|
62
|
+
if (!current) throw new Error("The grid is not ready yet.");
|
|
63
|
+
return current;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function columnByField(field: string) {
|
|
67
|
+
let lower = String(field ?? "").toLowerCase();
|
|
68
|
+
let column = dataColumns(api()).find((item) => item.field.toLowerCase() === lower);
|
|
69
|
+
if (!column) throw new Error(`The grid has no column "${field}". Use a field from get_grid_layout.`);
|
|
70
|
+
return column;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function visibility(): Record<string, boolean> {
|
|
74
|
+
return { ...api().state.columns.columnVisibilityModel };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function describe() {
|
|
78
|
+
let { gridFilter, filterFields } = get();
|
|
79
|
+
let pruned = pruneFilter(gridFilter.filter);
|
|
80
|
+
return pruned ? describeFilter(pruned, { fields: filterFields }) : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
label: () => get().label || get().id,
|
|
85
|
+
|
|
86
|
+
getLayout() {
|
|
87
|
+
let { dataObject, filterFields, grouping } = get();
|
|
88
|
+
let shown = visibility();
|
|
89
|
+
let columns = dataColumns(api()).map((column) => ({
|
|
90
|
+
field: column.field,
|
|
91
|
+
header: column.headerName ?? column.field,
|
|
92
|
+
shown: shown[column.field] !== false,
|
|
93
|
+
}));
|
|
94
|
+
let columnFields = new Set(columns.map((column) => column.field.toLowerCase()));
|
|
95
|
+
return {
|
|
96
|
+
columns,
|
|
97
|
+
// Filtering reaches the view, so a field that is not a column can be filtered on too.
|
|
98
|
+
otherFilterFields: filterFields
|
|
99
|
+
.filter((field) => !field.excludeFromFilter && !columnFields.has(field.name.toLowerCase()))
|
|
100
|
+
.map((field) => ({ field: field.name, type: field.type })),
|
|
101
|
+
sort: api().getSortModel(),
|
|
102
|
+
filter: describe(),
|
|
103
|
+
groupBy: grouping.rowGroupingModel,
|
|
104
|
+
totals: grouping.aggregationModel,
|
|
105
|
+
rows: { loaded: dataObject.getData().length, total: dataObject.getDataLength() },
|
|
106
|
+
};
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
getRows(maxRows) {
|
|
110
|
+
let grid = api();
|
|
111
|
+
let shown = visibility();
|
|
112
|
+
let columns = dataColumns(grid).filter((column) => shown[column.field] !== false);
|
|
113
|
+
let rows = grid
|
|
114
|
+
.getSortedRowIds()
|
|
115
|
+
.filter((rowId) => grid.getRowNode(rowId)?.type === "leaf")
|
|
116
|
+
.slice(0, maxRows)
|
|
117
|
+
.map((rowId) => {
|
|
118
|
+
let row = grid.getRow(rowId);
|
|
119
|
+
return Object.fromEntries(columns.map((column) => [column.field, json(grid.getRowValue(row, column))]));
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
columns: columns.map((column) => ({ field: column.field, header: column.headerName ?? column.field })),
|
|
123
|
+
rows,
|
|
124
|
+
loaded: get().dataObject.getData().length,
|
|
125
|
+
total: get().dataObject.getDataLength(),
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
setColumns({ show = [], hide = [], order = [] }) {
|
|
130
|
+
let grid = api();
|
|
131
|
+
let model = visibility();
|
|
132
|
+
for (let field of show) model[columnByField(field).field] = true;
|
|
133
|
+
for (let field of hide) model[columnByField(field).field] = false;
|
|
134
|
+
grid.setColumnVisibilityModel(model);
|
|
135
|
+
|
|
136
|
+
if (order.length > 0) {
|
|
137
|
+
// After the checkbox and other built-in columns, which stay first.
|
|
138
|
+
let first = grid.getAllColumns().findIndex((column) => dataColumns(grid).includes(column));
|
|
139
|
+
order.forEach((field, index) => grid.setColumnIndex(columnByField(field).field, Math.max(0, first) + index));
|
|
140
|
+
}
|
|
141
|
+
return { columns: (this.getLayout() as { columns: unknown }).columns };
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
sort(sort) {
|
|
145
|
+
let model: GridSortModel = sort.map((item) => {
|
|
146
|
+
let column = columnByField(item.field);
|
|
147
|
+
if (column.sortable === false) throw new Error(`The grid cannot sort on ${column.field}.`);
|
|
148
|
+
return { field: column.field, sort: item.direction === "desc" ? "desc" : "asc" };
|
|
149
|
+
});
|
|
150
|
+
api().setSortModel(model);
|
|
151
|
+
return { sort: model };
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
filter(mode, filter) {
|
|
155
|
+
let { gridFilter, filterFields, id } = get();
|
|
156
|
+
if (mode === "clear") {
|
|
157
|
+
gridFilter.setFilter({ type: "group", name: id, mode: "and", items: [] });
|
|
158
|
+
return { filter: null };
|
|
159
|
+
}
|
|
160
|
+
if (!filter) throw new Error("Give a filter, or use mode 'clear'.");
|
|
161
|
+
let next = toGridFilter(filter as AssistantFilterNode, filterFields);
|
|
162
|
+
let current = pruneFilter(gridFilter.filter);
|
|
163
|
+
let tree =
|
|
164
|
+
mode === "add" && current
|
|
165
|
+
? { type: "group" as const, name: id, mode: "and" as const, items: [current, next] }
|
|
166
|
+
: { ...next, name: id };
|
|
167
|
+
gridFilter.setFilter(tree);
|
|
168
|
+
return {
|
|
169
|
+
filter: describeFilter(tree, { fields: filterFields }),
|
|
170
|
+
note: "The grid reloads with the new filter in a moment.",
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
group({ groupBy, totals }) {
|
|
175
|
+
let { grouping } = get();
|
|
176
|
+
if (groupBy) {
|
|
177
|
+
let fields = groupBy.map((field) => {
|
|
178
|
+
let column = columnByField(field);
|
|
179
|
+
if (column.groupable === false) throw new Error(`The grid cannot group by ${column.field}.`);
|
|
180
|
+
return column.field;
|
|
181
|
+
});
|
|
182
|
+
grouping.onRowGroupingModelChange(fields);
|
|
183
|
+
}
|
|
184
|
+
if (totals) {
|
|
185
|
+
let model: Record<string, string> = {};
|
|
186
|
+
for (let [field, total] of Object.entries(totals)) {
|
|
187
|
+
if (!TOTALS.has(total)) throw new Error(`Unknown total "${total}". Use sum, avg, min, max or size.`);
|
|
188
|
+
model[columnByField(field).field] = total;
|
|
189
|
+
}
|
|
190
|
+
grouping.onAggregationModelChange(model);
|
|
191
|
+
}
|
|
192
|
+
return { groupBy: groupBy ?? grouping.rowGroupingModel, totals: totals ?? grouping.aggregationModel };
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -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">
|