@olenbetong/appframe-ds 1.10.0 → 1.12.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 +20 -0
- package/package.json +8 -8
- package/src/filter/FilterBuilder.test.tsx +18 -0
- package/src/filter/FilterBuilder.tsx +24 -1
- package/src/filter/FilterGroupEditor.tsx +1 -0
- package/src/filter/FilterRow.tsx +5 -1
- package/src/grid/AfColumnMenuColumnsItem.tsx +30 -0
- package/src/grid/AfColumnMenuFilterItem.tsx +36 -0
- package/src/grid/AfGridContext.tsx +5 -2
- package/src/grid/Toolbar.tsx +2 -2
- 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 +54 -6
- package/src/grid/useAfAssistant.ts +195 -0
- package/src/grid/useAfNewItemRow.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @olenbetong/appframe-ds
|
|
2
2
|
|
|
3
|
+
## 1.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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`.
|
|
8
|
+
|
|
9
|
+
## 1.11.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- aeeb072: The grid's new item row is pinned at the top by default, instead of following the data object's `newRecordPosition`. At the bottom it sat at the foot of the viewport, far below the data on a short grid. Pass `newItemRow="bottom"` to keep it there.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 4c6a4e7: `endEdit` takes `{ newRecordPosition }` to place a record created from the new record first or last for that save only, overriding the data object's option. The grid uses it so a record saved from the new item row at the top is inserted first, where it was entered, instead of jumping to the end of the data.
|
|
18
|
+
- f4a37c0: The grid's column menu opens the grid's own panels instead of MUI's: Filter opens the filter builder with a new criterion for the column, its value focused (MUI's filter panel edited a filter the grid ignores), and Manage columns opens the grid's columns panel. `FilterBuilder` takes `autoFocusPath` to focus the value of a criterion when it opens.
|
|
19
|
+
- Updated dependencies [c7750da]
|
|
20
|
+
- Updated dependencies [4c6a4e7]
|
|
21
|
+
- @olenbetong/appframe-data@1.10.0
|
|
22
|
+
|
|
3
23
|
## 1.10.0
|
|
4
24
|
|
|
5
25
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/appframe-ds",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Components that bind Designsystemet components to Appframe data objects",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./es/index.d.ts",
|
|
@@ -64,16 +64,16 @@
|
|
|
64
64
|
"@atlaskit/pragmatic-drag-and-drop-hitbox": "3.0.0",
|
|
65
65
|
"@base-ui/react": "1.9.0",
|
|
66
66
|
"@olenbetong/appframe-core": "^2.16.0",
|
|
67
|
-
"@olenbetong/appframe-data": "^1.
|
|
67
|
+
"@olenbetong/appframe-data": "^1.10.3",
|
|
68
68
|
"@olenbetong/appframe-react": "^2.2.8",
|
|
69
69
|
"clsx": "^2.1.1",
|
|
70
|
-
"dockview-react": "8.
|
|
70
|
+
"dockview-react": "8.8.3",
|
|
71
71
|
"localforage": "^1.10.0",
|
|
72
72
|
"mitt": "^3.0.1",
|
|
73
73
|
"react": "19.3.1",
|
|
74
74
|
"react-dom": "19.3.1",
|
|
75
|
-
"react-window": "2.5.
|
|
76
|
-
"temporal-polyfill": "1.0.
|
|
75
|
+
"react-window": "2.5.3",
|
|
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",
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
"@types/react-dom": "19.3.0",
|
|
92
92
|
"happy-dom": "21.0.4",
|
|
93
93
|
"react-error-boundary": "6.1.6",
|
|
94
|
-
"react-router": "8.5.
|
|
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",
|
|
@@ -122,6 +122,24 @@ describe("FilterBuilder", () => {
|
|
|
122
122
|
);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it("focuses the value of the criterion at autoFocusPath", () => {
|
|
126
|
+
renderBuilder({
|
|
127
|
+
autoFocusPath: [1],
|
|
128
|
+
defaultValue: {
|
|
129
|
+
type: "group",
|
|
130
|
+
mode: "and",
|
|
131
|
+
items: [
|
|
132
|
+
{ type: "expression", column: "PersonID", operator: "like", value: "a", valueType: "string" },
|
|
133
|
+
{ type: "expression", column: "Age", operator: "equals", value: "", valueType: "number" },
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
let values = screen.getAllByLabelText("Value");
|
|
139
|
+
|
|
140
|
+
expect(document.activeElement).toBe(values[1]);
|
|
141
|
+
});
|
|
142
|
+
|
|
125
143
|
it("reports an empty filter as null", async () => {
|
|
126
144
|
let onApply = vi.fn();
|
|
127
145
|
renderBuilder({ onApply });
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
canShareSavedFilter,
|
|
9
9
|
createEmptyFilter,
|
|
10
10
|
findSavedFilter,
|
|
11
|
+
type FilterPath,
|
|
11
12
|
type FilterTree,
|
|
12
13
|
isEmptyFilter,
|
|
13
14
|
pruneFilter,
|
|
@@ -17,7 +18,7 @@ import { type NamedFilterType, setNamedFilter, useFilterConversion, useSavedFilt
|
|
|
17
18
|
import type { SaveFilterInput } from "@olenbetong/appframe-react";
|
|
18
19
|
import type { DataObject } from "@olenbetong/appframe-data";
|
|
19
20
|
import clsx from "clsx";
|
|
20
|
-
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
|
21
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
21
22
|
|
|
22
23
|
import { FilterEditor } from "./FilterEditor.js";
|
|
23
24
|
import { FilterNameDialog } from "./FilterNameDialog.js";
|
|
@@ -109,6 +110,11 @@ export type FilterBuilderProps = {
|
|
|
109
110
|
renderMain?: (editor: ReactNode) => ReactNode;
|
|
110
111
|
/** Hides the saved-filter list, leaving just the criteria editor. */
|
|
111
112
|
hideSavedFilters?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Path of the criterion whose value gets focus when the builder opens, for a caller that has
|
|
115
|
+
* just added it.
|
|
116
|
+
*/
|
|
117
|
+
autoFocusPath?: FilterPath | null;
|
|
112
118
|
className?: string;
|
|
113
119
|
"data-size"?: FilterEditorSize;
|
|
114
120
|
};
|
|
@@ -159,9 +165,25 @@ export function FilterBuilder({
|
|
|
159
165
|
actions,
|
|
160
166
|
renderMain,
|
|
161
167
|
hideSavedFilters,
|
|
168
|
+
autoFocusPath,
|
|
162
169
|
className,
|
|
163
170
|
"data-size": size,
|
|
164
171
|
}: FilterBuilderProps) {
|
|
172
|
+
let rootRef = useRef<HTMLDivElement>(null);
|
|
173
|
+
let autoFocusKey = autoFocusPath?.join(".");
|
|
174
|
+
|
|
175
|
+
/*
|
|
176
|
+
* Runs after the dialog's own effect has opened it, which focuses its first control. The value
|
|
177
|
+
* editor depends on the field and operator, so the first input in the row's value cell is used.
|
|
178
|
+
*/
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!open || autoFocusKey === undefined) return;
|
|
181
|
+
|
|
182
|
+
let row = rootRef.current?.querySelector(`.ObFilterEditor-row[data-path="${autoFocusKey}"]`);
|
|
183
|
+
let input = row?.querySelector<HTMLElement>(".ObFilterEditor-valueCell :is(input, select, textarea)");
|
|
184
|
+
|
|
185
|
+
input?.focus();
|
|
186
|
+
}, [open, autoFocusKey]);
|
|
165
187
|
let resolvedId = dbObjectId ?? viewName;
|
|
166
188
|
let { mine, shared, filters, loading, save, rename, remove, setCrossDomain } = useSavedFilters({
|
|
167
189
|
dbObjectId: reportId ? undefined : resolvedId,
|
|
@@ -299,6 +321,7 @@ export function FilterBuilder({
|
|
|
299
321
|
|
|
300
322
|
let content = (
|
|
301
323
|
<div
|
|
324
|
+
ref={rootRef}
|
|
302
325
|
className={clsx("ObFilterBuilder-root", hideSavedFilters && "ObFilterBuilder-root--noSavedFilters", className)}
|
|
303
326
|
data-size={size}
|
|
304
327
|
>
|
package/src/filter/FilterRow.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type FieldMetadata,
|
|
7
7
|
type FilterExpression,
|
|
8
8
|
type FilterOperator,
|
|
9
|
+
type FilterPath,
|
|
9
10
|
getOperatorLabel,
|
|
10
11
|
getOperatorsForField,
|
|
11
12
|
isRangeEnd,
|
|
@@ -34,6 +35,8 @@ export type FilterRowProps = {
|
|
|
34
35
|
fields: FieldMetadata[];
|
|
35
36
|
onChange: (expression: FilterExpression) => void;
|
|
36
37
|
onRemove: () => void;
|
|
38
|
+
/** Where the expression sits in the tree, written to `data-path` so a row can be found to focus it. */
|
|
39
|
+
path?: FilterPath;
|
|
37
40
|
getFieldOptions?: GetFieldOptions;
|
|
38
41
|
readOnly?: boolean;
|
|
39
42
|
"data-size"?: FilterEditorSize;
|
|
@@ -45,6 +48,7 @@ export function FilterRow({
|
|
|
45
48
|
fields,
|
|
46
49
|
onChange,
|
|
47
50
|
onRemove,
|
|
51
|
+
path,
|
|
48
52
|
getFieldOptions,
|
|
49
53
|
readOnly,
|
|
50
54
|
"data-size": size,
|
|
@@ -76,7 +80,7 @@ export function FilterRow({
|
|
|
76
80
|
}
|
|
77
81
|
|
|
78
82
|
return (
|
|
79
|
-
<div className="ObFilterEditor-row">
|
|
83
|
+
<div className="ObFilterEditor-row" data-path={path?.join(".")}>
|
|
80
84
|
<Combobox
|
|
81
85
|
className="ObFilterEditor-field"
|
|
82
86
|
data-size={size}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type GridColumnMenuItemProps, GridColumnMenuHideItem, useGridRootProps } from "@mui/x-data-grid-premium";
|
|
2
|
+
|
|
3
|
+
import { useOptionalAfGridContext } from "./AfGridContext.js";
|
|
4
|
+
import { getLocalizedString } from "../i18n.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The column menu's Hide and Manage columns items. MUI's Manage columns opens its own columns panel;
|
|
8
|
+
* this opens the grid's columns panel, the same one as the toolbar's Columns button.
|
|
9
|
+
*/
|
|
10
|
+
export function AfColumnMenuColumnsItem(props: GridColumnMenuItemProps) {
|
|
11
|
+
let rootProps = useGridRootProps();
|
|
12
|
+
let context = useOptionalAfGridContext();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<GridColumnMenuHideItem {...props} />
|
|
17
|
+
{context && !rootProps.disableColumnSelector && (
|
|
18
|
+
<rootProps.slots.baseMenuItem
|
|
19
|
+
onClick={(event: React.MouseEvent<HTMLElement>) => {
|
|
20
|
+
props.onClick(event);
|
|
21
|
+
context.openColumnsPanel();
|
|
22
|
+
}}
|
|
23
|
+
iconStart={<rootProps.slots.columnMenuManageColumnsIcon fontSize="small" />}
|
|
24
|
+
>
|
|
25
|
+
{getLocalizedString("Manage columns")}
|
|
26
|
+
</rootProps.slots.baseMenuItem>
|
|
27
|
+
)}
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type GridColumnMenuItemProps, useGridRootProps } from "@mui/x-data-grid-premium";
|
|
2
|
+
|
|
3
|
+
import { useOptionalAfGridContext } from "./AfGridContext.js";
|
|
4
|
+
import { findFilterField } from "./useAfFilter.js";
|
|
5
|
+
import type { AfGridColDef } from "./useAfColumns.js";
|
|
6
|
+
import { getLocalizedString } from "../i18n.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The column menu's Filter item. MUI's opens its own filter panel, which edits a filter model the
|
|
10
|
+
* grid never reads; this opens the filter builder with a new criterion for the column instead.
|
|
11
|
+
*/
|
|
12
|
+
export function AfColumnMenuFilterItem({ colDef, onClick }: GridColumnMenuItemProps) {
|
|
13
|
+
let rootProps = useGridRootProps();
|
|
14
|
+
let context = useOptionalAfGridContext();
|
|
15
|
+
let fieldName = (colDef as AfGridColDef).filterColumn ?? colDef.field;
|
|
16
|
+
|
|
17
|
+
if (!context || rootProps.disableColumnFilter || colDef.filterable === false || colDef.type === "actions") {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!findFilterField(context.filterFields, fieldName)) return null;
|
|
22
|
+
|
|
23
|
+
let { openFilterBuilder } = context;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<rootProps.slots.baseMenuItem
|
|
27
|
+
onClick={(event: React.MouseEvent<HTMLElement>) => {
|
|
28
|
+
onClick(event);
|
|
29
|
+
openFilterBuilder(fieldName);
|
|
30
|
+
}}
|
|
31
|
+
iconStart={<rootProps.slots.columnMenuFilterIcon fontSize="small" />}
|
|
32
|
+
>
|
|
33
|
+
{getLocalizedString("Filter")}
|
|
34
|
+
</rootProps.slots.baseMenuItem>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -8,8 +8,11 @@ export type AfGridContextValue = {
|
|
|
8
8
|
apiRef: React.RefObject<GridApi | null>;
|
|
9
9
|
/** Fields the filter builder can filter on, derived from the data object and the columns. */
|
|
10
10
|
filterFields: FieldMetadata[];
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Opens the filter builder dialog. Given a field, it adds an empty criterion for it first, unless
|
|
13
|
+
* the filter already has an unfinished one for that field.
|
|
14
|
+
*/
|
|
15
|
+
openFilterBuilder: (field?: string) => void;
|
|
13
16
|
/**
|
|
14
17
|
* The grid's filter tree. The header filters and the filter builder edit the
|
|
15
18
|
* same tree, so a criterion added in one shows up in the other.
|
package/src/grid/Toolbar.tsx
CHANGED
|
@@ -95,7 +95,7 @@ export function GridToolbarFilterButton({ iconOnly }: GridToolbarIconOnlyProps =
|
|
|
95
95
|
return (
|
|
96
96
|
<BadgePosition placement="top-right" overlap="circle">
|
|
97
97
|
{filterCount > 0 && <Badge data-color="brand" count={filterCount} aria-hidden="true" />}
|
|
98
|
-
<Button variant="tertiary" icon aria-label={name} title={name} onClick={openFilterBuilder}>
|
|
98
|
+
<Button variant="tertiary" icon aria-label={name} title={name} onClick={() => openFilterBuilder()}>
|
|
99
99
|
<FilterIcon aria-hidden />
|
|
100
100
|
</Button>
|
|
101
101
|
</BadgePosition>
|
|
@@ -103,7 +103,7 @@ export function GridToolbarFilterButton({ iconOnly }: GridToolbarIconOnlyProps =
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
return (
|
|
106
|
-
<Button variant="tertiary" onClick={openFilterBuilder}>
|
|
106
|
+
<Button variant="tertiary" onClick={() => openFilterBuilder()}>
|
|
107
107
|
<FilterIcon aria-hidden />
|
|
108
108
|
{label}
|
|
109
109
|
{filterCount > 0 ? ` (${filterCount})` : ""}
|
|
@@ -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
|
@@ -16,12 +16,15 @@ import {
|
|
|
16
16
|
type GridRowParams,
|
|
17
17
|
useGridApiRef,
|
|
18
18
|
} from "@mui/x-data-grid-premium";
|
|
19
|
+
import { createExpression, type FilterPath, isCompleteExpression, isExpression } from "@olenbetong/appframe-core";
|
|
19
20
|
import { type DataObject, uid } from "@olenbetong/appframe-data";
|
|
20
21
|
import { DataObjectProvider, useLoading } from "@olenbetong/appframe-react";
|
|
21
22
|
|
|
22
23
|
import clsx from "clsx";
|
|
23
24
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
24
25
|
import { FilterBuilder } from "../filter/index.js";
|
|
26
|
+
import { AfColumnMenuColumnsItem } from "./AfColumnMenuColumnsItem.js";
|
|
27
|
+
import { AfColumnMenuFilterItem } from "./AfColumnMenuFilterItem.js";
|
|
25
28
|
import { AfGridColumnsPanel } from "./AfGridColumnsPanel.js";
|
|
26
29
|
import { AfHeaderFilterCell } from "./AfHeaderFilterCell.js";
|
|
27
30
|
import { type AfGridContextValue, AfGridContextProvider } from "./AfGridContext.js";
|
|
@@ -51,8 +54,9 @@ import type { AfGridColumn } from "./useAfColumns.js";
|
|
|
51
54
|
import { type AfGridColDef, type AfGridRowAction, useAfColumns } from "./useAfColumns.js";
|
|
52
55
|
import { useAfCurrentIndex } from "./useAfCurrentIndex.js";
|
|
53
56
|
import { useAfData } from "./useAfData.js";
|
|
57
|
+
import { useAfAssistant } from "./useAfAssistant.js";
|
|
54
58
|
import { useAfFilterFields } from "./useAfFilterFields.js";
|
|
55
|
-
import { useAfFilter } from "./useAfFilter.js";
|
|
59
|
+
import { findFilterField, useAfFilter } from "./useAfFilter.js";
|
|
56
60
|
import { type AfGridApi, useAfGridApi } from "./useAfGridApi.js";
|
|
57
61
|
import { useAfKeyBindings } from "./useAfKeyBindings.js";
|
|
58
62
|
import { type AfGridNewItemRowPosition, NEW_ITEM_ROW_ID, isNewItemRow, useAfNewItemRow } from "./useAfNewItemRow.js";
|
|
@@ -270,7 +274,7 @@ export type AfGridProps = {
|
|
|
270
274
|
* Editing it moves the data object to the new record at index -1, and committing
|
|
271
275
|
* it saves the record. Only shown when the data object allows insert and
|
|
272
276
|
* `disableInsert` is not set. `none` turns it off, in which case the Add button
|
|
273
|
-
* appends an unsaved row to the data instead. Defaults to `
|
|
277
|
+
* appends an unsaved row to the data instead. Defaults to `top`.
|
|
274
278
|
*/
|
|
275
279
|
newItemRow?: AfGridNewItemRowPosition;
|
|
276
280
|
/**
|
|
@@ -308,6 +312,14 @@ export type AfGridProps = {
|
|
|
308
312
|
* button still shows that the grid is loading.
|
|
309
313
|
*/
|
|
310
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;
|
|
311
323
|
} & Omit<
|
|
312
324
|
DataGridProps,
|
|
313
325
|
| "apiRef"
|
|
@@ -371,15 +383,16 @@ function AfGrid_(props: AfGridProps) {
|
|
|
371
383
|
getData,
|
|
372
384
|
idField = "PrimKey",
|
|
373
385
|
id,
|
|
374
|
-
//
|
|
375
|
-
|
|
376
|
-
newItemRow: newItemRowPosition = dataObject.options?.newRecordPosition === "start" ? "top" : "bottom",
|
|
386
|
+
// At the bottom the row is pinned to the foot of the viewport, far below the data on a short grid
|
|
387
|
+
newItemRow: newItemRowPosition = "top",
|
|
377
388
|
persistenceModel,
|
|
378
389
|
initialState,
|
|
379
390
|
pinnedRows,
|
|
380
391
|
getRowClassName,
|
|
381
392
|
serverAggregation = dataObject.options?.dynamicLoading === true,
|
|
382
393
|
disableLoadingOverlay = false,
|
|
394
|
+
assistantLabel,
|
|
395
|
+
disableAssistant = false,
|
|
383
396
|
...other
|
|
384
397
|
} = props;
|
|
385
398
|
let apiRefPriv = useGridApiRef();
|
|
@@ -420,6 +433,8 @@ function AfGrid_(props: AfGridProps) {
|
|
|
420
433
|
let gridFilter = useAfFilter({ autoLoad, dataObject, id });
|
|
421
434
|
let [snackbar, setSnackbar] = useState<string | null>(null);
|
|
422
435
|
let [isFilterBuilderOpen, setFilterBuilderOpen] = useState(false);
|
|
436
|
+
// The criterion added from a column menu, so its value has focus when the builder opens.
|
|
437
|
+
let [filterBuilderFocus, setFilterBuilderFocus] = useState<FilterPath | null>(null);
|
|
423
438
|
let [isColumnsPanelOpen, setColumnsPanelOpen] = useState(false);
|
|
424
439
|
let editProps = useAfRowEditModel({ dataObject, disableEdit, idField, apiRef, newItemRow });
|
|
425
440
|
useAfKeepFocusAfterEdit(apiRef);
|
|
@@ -441,6 +456,16 @@ function AfGrid_(props: AfGridProps) {
|
|
|
441
456
|
getRowActions,
|
|
442
457
|
});
|
|
443
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
|
+
});
|
|
444
469
|
|
|
445
470
|
let keyEvents = useAfKeyBindings({ apiRef, dataObject });
|
|
446
471
|
let rootRef = useRef<HTMLDivElement>(null);
|
|
@@ -493,7 +518,21 @@ function AfGrid_(props: AfGridProps) {
|
|
|
493
518
|
apiRef,
|
|
494
519
|
filterFields,
|
|
495
520
|
...gridFilter,
|
|
496
|
-
openFilterBuilder: () =>
|
|
521
|
+
openFilterBuilder: (fieldName?: string) => {
|
|
522
|
+
let field = fieldName ? findFilterField(filterFields, fieldName) : undefined;
|
|
523
|
+
let { filter, setFilter } = gridFilter;
|
|
524
|
+
let draftIndex = filter.items.findIndex(
|
|
525
|
+
(item) => isExpression(item) && item.column === field?.name && !isCompleteExpression(item),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
if (field && draftIndex === -1) {
|
|
529
|
+
draftIndex = filter.items.length;
|
|
530
|
+
setFilter({ ...filter, items: [...filter.items, createExpression(field)] });
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
setFilterBuilderFocus(field ? [draftIndex] : null);
|
|
534
|
+
setFilterBuilderOpen(true);
|
|
535
|
+
},
|
|
497
536
|
openColumnsPanel: () => setColumnsPanelOpen(true),
|
|
498
537
|
}),
|
|
499
538
|
[id, apiRef, filterFields, gridFilter],
|
|
@@ -555,6 +594,14 @@ function AfGrid_(props: AfGridProps) {
|
|
|
555
594
|
initialState={initialState_}
|
|
556
595
|
slotProps={{
|
|
557
596
|
...other.slotProps,
|
|
597
|
+
columnMenu: {
|
|
598
|
+
...other.slotProps?.columnMenu,
|
|
599
|
+
slots: {
|
|
600
|
+
columnMenuColumnsItem: AfColumnMenuColumnsItem,
|
|
601
|
+
columnMenuFilterItem: AfColumnMenuFilterItem,
|
|
602
|
+
...other.slotProps?.columnMenu?.slots,
|
|
603
|
+
},
|
|
604
|
+
},
|
|
558
605
|
headerFilterCell: {
|
|
559
606
|
...other.slotProps?.headerFilterCell,
|
|
560
607
|
headerClassName: clsx(other.slotProps?.headerFilterCell?.headerClassName, "AfGrid-headerCell"),
|
|
@@ -573,6 +620,7 @@ function AfGrid_(props: AfGridProps) {
|
|
|
573
620
|
<FilterBuilder
|
|
574
621
|
variant="dialog"
|
|
575
622
|
open={isFilterBuilderOpen}
|
|
623
|
+
autoFocusPath={filterBuilderFocus}
|
|
576
624
|
onClose={() => setFilterBuilderOpen(false)}
|
|
577
625
|
fields={filterFields}
|
|
578
626
|
viewName={filterViewName}
|
|
@@ -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
|
+
}
|
|
@@ -152,7 +152,9 @@ export function useAfNewItemRow({
|
|
|
152
152
|
let focusBefore = gridFocusCellSelector(apiRef);
|
|
153
153
|
|
|
154
154
|
try {
|
|
155
|
-
|
|
155
|
+
// A record saved from a row above the data is inserted first, so it stays where it was
|
|
156
|
+
// entered; at the bottom the data object's own `newRecordPosition` decides.
|
|
157
|
+
await dataObject.endEdit(position === "top" ? { newRecordPosition: "start" } : undefined);
|
|
156
158
|
} catch (error) {
|
|
157
159
|
// The framework keeps a failed new record in storage as an unsaved row.
|
|
158
160
|
// The grid keeps the draft in the new item row instead, so the unsaved
|
|
@@ -185,7 +187,7 @@ export function useAfNewItemRow({
|
|
|
185
187
|
|
|
186
188
|
return blankRow;
|
|
187
189
|
},
|
|
188
|
-
[apiRef, dataObject, beginNewRecord, cancelNewRecord],
|
|
190
|
+
[apiRef, dataObject, position, beginNewRecord, cancelNewRecord],
|
|
189
191
|
);
|
|
190
192
|
|
|
191
193
|
useEffect(() => {
|