@olenbetong/appframe-ds 1.6.0 → 1.7.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 +21 -0
- package/package.json +3 -3
- package/scripts/copyAssets.mjs +27 -27
- package/src/autocomplete/Autocomplete.css +253 -253
- package/src/autocomplete/Autocomplete.tsx +320 -320
- package/src/autocomplete/AutocompleteFrame.tsx +168 -168
- package/src/autocomplete/Combobox.tsx +286 -286
- package/src/autocomplete/index.ts +4 -4
- package/src/autocomplete/types.ts +204 -204
- package/src/autocomplete/utils.ts +183 -183
- package/src/binding/BoundNumberInput.tsx +42 -42
- package/src/binding/BoundNumericTextField.tsx +102 -102
- package/src/binding/BoundTextField.tsx +49 -49
- package/src/binding/CancelButton.tsx +30 -30
- package/src/binding/CancelIconButton.tsx +30 -30
- package/src/binding/DataEditToolbar.css +23 -23
- package/src/binding/DataEditToolbar.tsx +84 -84
- package/src/binding/DeleteButton.tsx +35 -35
- package/src/binding/DeleteIconButton.tsx +35 -35
- package/src/binding/RefreshButton.tsx +31 -31
- package/src/binding/RefreshIconButton.tsx +33 -33
- package/src/binding/RefreshRowIconButton.tsx +33 -33
- package/src/binding/SaveButton.tsx +37 -37
- package/src/binding/SaveIconButton.tsx +38 -38
- package/src/binding/index.ts +20 -20
- package/src/container/Container.tsx +50 -50
- package/src/container/index.ts +1 -1
- package/src/cutoff/CutOffPicker.css +12 -12
- package/src/cutoff/CutOffPicker.tsx +165 -165
- package/src/cutoff/context.tsx +194 -194
- package/src/cutoff/dsCutOffDates.ts +21 -21
- package/src/cutoff/index.ts +4 -4
- package/src/cutoff/utils.test.ts +59 -59
- package/src/cutoff/utils.ts +276 -276
- package/src/filter/ChipListInput.tsx +79 -79
- package/src/filter/FieldFilterPanel.css +150 -150
- package/src/filter/FieldFilterPanel.tsx +309 -309
- package/src/filter/FilterBuilder.css +152 -152
- package/src/filter/FilterBuilder.test.tsx +153 -153
- package/src/filter/FilterBuilder.tsx +435 -435
- package/src/filter/FilterEditor.css +231 -231
- package/src/filter/FilterEditor.test.tsx +285 -285
- package/src/filter/FilterEditor.tsx +75 -75
- package/src/filter/FilterGroupEditor.tsx +160 -160
- package/src/filter/FilterNameDialog.css +22 -22
- package/src/filter/FilterNameDialog.tsx +81 -81
- package/src/filter/FilterRow.tsx +117 -117
- package/src/filter/FilterShareDialog.css +28 -28
- package/src/filter/FilterShareDialog.tsx +201 -201
- package/src/filter/FilterStringField.tsx +78 -78
- package/src/filter/FilterValueEditor.tsx +239 -239
- package/src/filter/SavedFilterTree.css +107 -107
- package/src/filter/SavedFilterTree.test.tsx +94 -94
- package/src/filter/SavedFilterTree.tsx +218 -218
- package/src/filter/fieldFilter.test.ts +158 -158
- package/src/filter/fieldFilter.ts +139 -139
- package/src/filter/index.ts +27 -27
- package/src/filter/types.ts +38 -38
- package/src/filter/useDistinctValues.test.ts +102 -102
- package/src/filter/useDistinctValues.ts +230 -230
- package/src/global.d.ts +11 -11
- package/src/grid/formatters.ts +23 -0
- package/src/grid/index.css +8 -2
- package/src/grid/index.tsx +73 -3
- package/src/grid/useAfColumns.tsx +28 -4
- package/src/grid/useAfGridApi.ts +3 -0
- package/src/grid/utcDate.test.ts +25 -0
- package/src/grid/utcDate.ts +28 -0
- package/src/grid/utcDateEditing.tsx +11 -0
- package/src/i18n.ts +13 -13
- package/src/index.ts +14 -14
- package/src/input/InputAdornments.css +80 -80
- package/src/input/InputAdornments.tsx +102 -102
- package/src/input/NumberInput.css +9 -9
- package/src/input/NumberInput.test.tsx +212 -212
- package/src/input/NumberInput.tsx +268 -268
- package/src/input/index.ts +3 -3
- package/src/input/numberFormat.test.ts +79 -79
- package/src/input/numberFormat.ts +94 -94
- package/src/layout/AppMenu.tsx +74 -74
- package/src/layout/AppMenuDrawer.css +71 -71
- package/src/layout/AppMenuDrawer.tsx +122 -122
- package/src/layout/AppMenuItem.css +6 -2
- package/src/layout/DialogMotion.css +79 -79
- package/src/layout/ErrorBoundary.tsx +162 -162
- package/src/layout/PageContainer.css +48 -48
- package/src/layout/PageContainer.tsx +41 -41
- package/src/layout/ResponsiveDialog.css +86 -86
- package/src/layout/ResponsiveDialog.tsx +55 -55
- package/src/layout/index.tsx +70 -70
- package/src/link/Link.tsx +23 -23
- package/src/link/index.ts +1 -1
- package/src/mdi/Mdi.css +8 -8
- package/src/mdi/Mdi.test.tsx +91 -91
- package/src/mdi/Mdi.tsx +141 -141
- package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
- package/src/mdi/MdiContext.tsx +59 -59
- package/src/mdi/MdiDocumentContext.tsx +60 -60
- package/src/mdi/MdiPanel.tsx +61 -61
- package/src/mdi/MdiRestore.test.tsx +86 -86
- package/src/mdi/MdiTab.tsx +30 -30
- package/src/mdi/controller.test.ts +382 -382
- package/src/mdi/controller.ts +656 -656
- package/src/mdi/dockviewHost.ts +132 -132
- package/src/mdi/fakeHost.ts +200 -200
- package/src/mdi/host.ts +44 -44
- package/src/mdi/ids.test.ts +20 -20
- package/src/mdi/ids.ts +18 -18
- package/src/mdi/index.ts +31 -31
- package/src/mdi/layoutRestore.test.ts +156 -156
- package/src/mdi/layoutRestore.ts +311 -311
- package/src/mdi/layoutSpec.test.ts +143 -143
- package/src/mdi/layoutSpec.ts +214 -214
- package/src/mdi/layoutStore.test.ts +93 -93
- package/src/mdi/layoutStore.ts +99 -99
- package/src/mdi/memorySettingsStore.ts +21 -21
- package/src/mdi/registry.ts +34 -34
- package/src/mdi/testTypes.ts +37 -37
- package/src/mdi/theme.ts +22 -22
- package/src/mdi/types.ts +214 -214
- package/src/page/Page.css +57 -57
- package/src/page/PageBlock.css +64 -64
- package/src/page/PageBlock.tsx +93 -93
- package/src/page/PageHeader.css +88 -88
- package/src/page/PageHeader.tsx +115 -115
- package/src/page/PageTitle.css +5 -5
- package/src/page/PageTitle.tsx +21 -21
- package/src/page/index.ts +4 -4
- package/src/paper/Paper.tsx +52 -52
- package/src/progress/LinearProgress.css +121 -121
- package/src/progress/LinearProgress.tsx +106 -106
- package/src/progress/index.ts +1 -1
- package/src/report-downloader/ReportDownloader.css +67 -67
- package/src/report-downloader/components/StatusItem.tsx +87 -87
- package/src/report-downloader/components/StatusList.tsx +28 -28
- package/src/report-downloader/index.ts +41 -41
- package/src/report-downloader/status.tsx +92 -92
- package/src/sortable/DropIndicator.css +51 -51
- package/src/sortable/DropIndicator.tsx +27 -27
- package/src/sortable/SortableItemList.css +21 -21
- package/src/sortable/SortableItemList.tsx +264 -264
- package/src/sortable/index.ts +8 -8
- package/src/sortable/reorderSortOrder.ts +126 -126
- package/src/sortable/sortOrder.ts +101 -101
- package/src/sortable/types.ts +9 -9
- package/src/sortable/useSortableContainer.ts +53 -53
- package/src/sortable/useSortableItem.ts +95 -95
- package/src/sortable/useSortableList.ts +117 -117
- package/src/test/happy-dom-document-class.ts +24 -24
- package/src/test/setup.ts +27 -27
- package/src/theme/useDsColorScheme.ts +53 -53
- package/tsconfig.build.json +9 -9
- package/tsconfig.json +8 -8
- package/vitest.config.ts +10 -10
|
@@ -1,230 +1,230 @@
|
|
|
1
|
-
import type { FieldMetadata, FilterExpression, FilterTree } from "@olenbetong/appframe-core";
|
|
2
|
-
import { getValueType } from "@olenbetong/appframe-core";
|
|
3
|
-
import type { DataObject, Filter } from "@olenbetong/appframe-data";
|
|
4
|
-
import { useEffect, useMemo, useState } from "react";
|
|
5
|
-
|
|
6
|
-
import { getFieldExpressions } from "./fieldFilter.js";
|
|
7
|
-
|
|
8
|
-
export type DistinctValue = {
|
|
9
|
-
/** The raw value, as sent back in an `inlist` filter. */
|
|
10
|
-
value: string;
|
|
11
|
-
/** How many rows have this value, or null when counts are unavailable. */
|
|
12
|
-
count: number | null;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export type UseDistinctValuesResult = {
|
|
16
|
-
values: DistinctValue[];
|
|
17
|
-
loading: boolean;
|
|
18
|
-
error: string | null;
|
|
19
|
-
/** True when the list was truncated at `maxRecords`. */
|
|
20
|
-
truncated: boolean;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/** How many distinct values to load. The list is a picker, not a report. */
|
|
24
|
-
const MAX_DISTINCT_VALUES = 200;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Builds the retrieve payload for the distinct value list of a field.
|
|
28
|
-
*
|
|
29
|
-
* `groupBy` plus a `COUNT` aggregate gives values and counts in one round trip.
|
|
30
|
-
* The counted field must be a different field than the grouped one — asking for
|
|
31
|
-
* `COUNT` of the grouped field collapses into a single column server side.
|
|
32
|
-
*
|
|
33
|
-
* The list respects every other active filter but ignores the field's own
|
|
34
|
-
* criteria, so unchecking a value does not make the remaining values disappear.
|
|
35
|
-
*/
|
|
36
|
-
export function buildDistinctParameters({
|
|
37
|
-
field,
|
|
38
|
-
countField,
|
|
39
|
-
filter,
|
|
40
|
-
search,
|
|
41
|
-
withCounts,
|
|
42
|
-
}: {
|
|
43
|
-
field: FieldMetadata;
|
|
44
|
-
countField: string | null;
|
|
45
|
-
filter: FilterTree | null;
|
|
46
|
-
search?: string;
|
|
47
|
-
withCounts: boolean;
|
|
48
|
-
}) {
|
|
49
|
-
let filterObject = withoutField(filter, field.name);
|
|
50
|
-
|
|
51
|
-
if (search) {
|
|
52
|
-
let searchExpression: FilterExpression = {
|
|
53
|
-
type: "expression",
|
|
54
|
-
column: field.name,
|
|
55
|
-
operator: "contains",
|
|
56
|
-
value: search,
|
|
57
|
-
valueType: getValueType(field, "contains"),
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
filterObject = filterObject
|
|
61
|
-
? { type: "group", mode: "and", items: [filterObject, searchExpression] }
|
|
62
|
-
: { type: "group", mode: "and", items: [searchExpression] };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
let fields =
|
|
66
|
-
withCounts && countField
|
|
67
|
-
? [{ name: field.name }, { name: countField, aggregate: "COUNT" }]
|
|
68
|
-
: [{ name: field.name }];
|
|
69
|
-
|
|
70
|
-
return {
|
|
71
|
-
fields,
|
|
72
|
-
...(withCounts && countField ? { groupBy: [field.name] } : { distinctRows: true }),
|
|
73
|
-
filterObject,
|
|
74
|
-
maxRecords: MAX_DISTINCT_VALUES,
|
|
75
|
-
sortOrder: [{ [field.name]: "asc" }],
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Returns a copy of the filter with the field's own top level criteria removed,
|
|
81
|
-
* and null when nothing is left.
|
|
82
|
-
*/
|
|
83
|
-
function withoutField(filter: FilterTree | null, field: string): Filter {
|
|
84
|
-
if (!filter) return null;
|
|
85
|
-
|
|
86
|
-
let own = new Set<unknown>(getFieldExpressions(filter, field));
|
|
87
|
-
let items = filter.items.filter((item) => !own.has(item));
|
|
88
|
-
|
|
89
|
-
if (items.length === 0) return null;
|
|
90
|
-
|
|
91
|
-
return { type: "group", mode: filter.mode, items };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Whether a distinct value list makes sense for this field's type. */
|
|
95
|
-
export function supportsDistinctValues(field: FieldMetadata | undefined): boolean {
|
|
96
|
-
return field?.type === "string" || field?.type === "number" || field?.type === "uniqueidentifier";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Loads the distinct values of a field, with a row count per value.
|
|
101
|
-
*
|
|
102
|
-
* The query goes straight to the data object's data handler rather than through
|
|
103
|
-
* the data object, so the grid's own data and parameters are untouched.
|
|
104
|
-
*
|
|
105
|
-
* @param dataObject The grid's data object
|
|
106
|
-
* @param field The field to list values for
|
|
107
|
-
* @param options Current filter, search text, and whether to load at all
|
|
108
|
-
*/
|
|
109
|
-
export function useDistinctValues(
|
|
110
|
-
dataObject: DataObject<any>,
|
|
111
|
-
field: FieldMetadata | undefined,
|
|
112
|
-
{ filter, search, enabled = true }: { filter: FilterTree | null; search?: string; enabled?: boolean },
|
|
113
|
-
): UseDistinctValuesResult {
|
|
114
|
-
let [result, setResult] = useState<UseDistinctValuesResult>({
|
|
115
|
-
values: [],
|
|
116
|
-
loading: false,
|
|
117
|
-
error: null,
|
|
118
|
-
truncated: false,
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// The count column has to be a field other than the grouped one.
|
|
122
|
-
let countField = useMemo(() => {
|
|
123
|
-
if (!field) return null;
|
|
124
|
-
|
|
125
|
-
let lowerField = field.name.toLowerCase();
|
|
126
|
-
let names = dataObject
|
|
127
|
-
.getFields()
|
|
128
|
-
.map((definition) => definition.name)
|
|
129
|
-
.filter((name) => !!name && name.toLowerCase() !== lowerField);
|
|
130
|
-
|
|
131
|
-
return names.find((name) => name.toLowerCase() === "primkey") ?? names[0] ?? null;
|
|
132
|
-
}, [dataObject, field]);
|
|
133
|
-
|
|
134
|
-
// The filter is only used to scope the list, so re-running on every keystroke
|
|
135
|
-
// in an unrelated criterion is avoided by keying on its serialized form.
|
|
136
|
-
let filterKey = useMemo(() => JSON.stringify(filter ?? null), [filter]);
|
|
137
|
-
|
|
138
|
-
useEffect(() => {
|
|
139
|
-
if (!enabled || !field || !supportsDistinctValues(field)) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
let controller = new AbortController();
|
|
144
|
-
let cancelled = false;
|
|
145
|
-
|
|
146
|
-
setResult((current) => ({ ...current, loading: true, error: null }));
|
|
147
|
-
|
|
148
|
-
async function load() {
|
|
149
|
-
if (!field) return;
|
|
150
|
-
|
|
151
|
-
let parsedFilter: FilterTree | null = JSON.parse(filterKey);
|
|
152
|
-
|
|
153
|
-
async function run(withCounts: boolean) {
|
|
154
|
-
let parameters = buildDistinctParameters({
|
|
155
|
-
field: field as FieldMetadata,
|
|
156
|
-
countField,
|
|
157
|
-
filter: parsedFilter,
|
|
158
|
-
search,
|
|
159
|
-
withCounts,
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
return (await dataObject.dataHandler.retrieve(parameters as any, {
|
|
163
|
-
signal: controller.signal,
|
|
164
|
-
})) as any[];
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
try {
|
|
168
|
-
let withCounts = !!countField;
|
|
169
|
-
let rows: any[];
|
|
170
|
-
|
|
171
|
-
try {
|
|
172
|
-
rows = await run(withCounts);
|
|
173
|
-
} catch (cause) {
|
|
174
|
-
if (controller.signal.aborted || !withCounts) throw cause;
|
|
175
|
-
|
|
176
|
-
// Counts are best effort: some views cannot be grouped, so fall
|
|
177
|
-
// back to a plain distinct list rather than showing an error.
|
|
178
|
-
withCounts = false;
|
|
179
|
-
rows = await run(false);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (cancelled) return;
|
|
183
|
-
|
|
184
|
-
let values = rows
|
|
185
|
-
.map((row) => toDistinctValue(row, field as FieldMetadata, countField, withCounts))
|
|
186
|
-
.filter((value): value is DistinctValue => value !== null);
|
|
187
|
-
|
|
188
|
-
setResult({
|
|
189
|
-
values,
|
|
190
|
-
loading: false,
|
|
191
|
-
error: null,
|
|
192
|
-
truncated: values.length >= MAX_DISTINCT_VALUES,
|
|
193
|
-
});
|
|
194
|
-
} catch (cause) {
|
|
195
|
-
if (cancelled || controller.signal.aborted) return;
|
|
196
|
-
|
|
197
|
-
setResult({
|
|
198
|
-
values: [],
|
|
199
|
-
loading: false,
|
|
200
|
-
error: String((cause as Error)?.message ?? cause),
|
|
201
|
-
truncated: false,
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
void load();
|
|
207
|
-
|
|
208
|
-
return () => {
|
|
209
|
-
cancelled = true;
|
|
210
|
-
controller.abort();
|
|
211
|
-
};
|
|
212
|
-
}, [dataObject, field, countField, filterKey, search, enabled]);
|
|
213
|
-
|
|
214
|
-
return result;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function toDistinctValue(
|
|
218
|
-
row: Record<string, unknown>,
|
|
219
|
-
field: FieldMetadata,
|
|
220
|
-
countField: string | null,
|
|
221
|
-
withCounts: boolean,
|
|
222
|
-
): DistinctValue | null {
|
|
223
|
-
let raw = row[field.name];
|
|
224
|
-
|
|
225
|
-
if (raw === null || raw === undefined || raw === "") return null;
|
|
226
|
-
|
|
227
|
-
let count = withCounts && countField ? Number(row[countField]) : null;
|
|
228
|
-
|
|
229
|
-
return { value: String(raw), count: Number.isFinite(count as number) ? (count as number) : null };
|
|
230
|
-
}
|
|
1
|
+
import type { FieldMetadata, FilterExpression, FilterTree } from "@olenbetong/appframe-core";
|
|
2
|
+
import { getValueType } from "@olenbetong/appframe-core";
|
|
3
|
+
import type { DataObject, Filter } from "@olenbetong/appframe-data";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { getFieldExpressions } from "./fieldFilter.js";
|
|
7
|
+
|
|
8
|
+
export type DistinctValue = {
|
|
9
|
+
/** The raw value, as sent back in an `inlist` filter. */
|
|
10
|
+
value: string;
|
|
11
|
+
/** How many rows have this value, or null when counts are unavailable. */
|
|
12
|
+
count: number | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type UseDistinctValuesResult = {
|
|
16
|
+
values: DistinctValue[];
|
|
17
|
+
loading: boolean;
|
|
18
|
+
error: string | null;
|
|
19
|
+
/** True when the list was truncated at `maxRecords`. */
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** How many distinct values to load. The list is a picker, not a report. */
|
|
24
|
+
const MAX_DISTINCT_VALUES = 200;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Builds the retrieve payload for the distinct value list of a field.
|
|
28
|
+
*
|
|
29
|
+
* `groupBy` plus a `COUNT` aggregate gives values and counts in one round trip.
|
|
30
|
+
* The counted field must be a different field than the grouped one — asking for
|
|
31
|
+
* `COUNT` of the grouped field collapses into a single column server side.
|
|
32
|
+
*
|
|
33
|
+
* The list respects every other active filter but ignores the field's own
|
|
34
|
+
* criteria, so unchecking a value does not make the remaining values disappear.
|
|
35
|
+
*/
|
|
36
|
+
export function buildDistinctParameters({
|
|
37
|
+
field,
|
|
38
|
+
countField,
|
|
39
|
+
filter,
|
|
40
|
+
search,
|
|
41
|
+
withCounts,
|
|
42
|
+
}: {
|
|
43
|
+
field: FieldMetadata;
|
|
44
|
+
countField: string | null;
|
|
45
|
+
filter: FilterTree | null;
|
|
46
|
+
search?: string;
|
|
47
|
+
withCounts: boolean;
|
|
48
|
+
}) {
|
|
49
|
+
let filterObject = withoutField(filter, field.name);
|
|
50
|
+
|
|
51
|
+
if (search) {
|
|
52
|
+
let searchExpression: FilterExpression = {
|
|
53
|
+
type: "expression",
|
|
54
|
+
column: field.name,
|
|
55
|
+
operator: "contains",
|
|
56
|
+
value: search,
|
|
57
|
+
valueType: getValueType(field, "contains"),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
filterObject = filterObject
|
|
61
|
+
? { type: "group", mode: "and", items: [filterObject, searchExpression] }
|
|
62
|
+
: { type: "group", mode: "and", items: [searchExpression] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let fields =
|
|
66
|
+
withCounts && countField
|
|
67
|
+
? [{ name: field.name }, { name: countField, aggregate: "COUNT" }]
|
|
68
|
+
: [{ name: field.name }];
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
fields,
|
|
72
|
+
...(withCounts && countField ? { groupBy: [field.name] } : { distinctRows: true }),
|
|
73
|
+
filterObject,
|
|
74
|
+
maxRecords: MAX_DISTINCT_VALUES,
|
|
75
|
+
sortOrder: [{ [field.name]: "asc" }],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns a copy of the filter with the field's own top level criteria removed,
|
|
81
|
+
* and null when nothing is left.
|
|
82
|
+
*/
|
|
83
|
+
function withoutField(filter: FilterTree | null, field: string): Filter {
|
|
84
|
+
if (!filter) return null;
|
|
85
|
+
|
|
86
|
+
let own = new Set<unknown>(getFieldExpressions(filter, field));
|
|
87
|
+
let items = filter.items.filter((item) => !own.has(item));
|
|
88
|
+
|
|
89
|
+
if (items.length === 0) return null;
|
|
90
|
+
|
|
91
|
+
return { type: "group", mode: filter.mode, items };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether a distinct value list makes sense for this field's type. */
|
|
95
|
+
export function supportsDistinctValues(field: FieldMetadata | undefined): boolean {
|
|
96
|
+
return field?.type === "string" || field?.type === "number" || field?.type === "uniqueidentifier";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Loads the distinct values of a field, with a row count per value.
|
|
101
|
+
*
|
|
102
|
+
* The query goes straight to the data object's data handler rather than through
|
|
103
|
+
* the data object, so the grid's own data and parameters are untouched.
|
|
104
|
+
*
|
|
105
|
+
* @param dataObject The grid's data object
|
|
106
|
+
* @param field The field to list values for
|
|
107
|
+
* @param options Current filter, search text, and whether to load at all
|
|
108
|
+
*/
|
|
109
|
+
export function useDistinctValues(
|
|
110
|
+
dataObject: DataObject<any>,
|
|
111
|
+
field: FieldMetadata | undefined,
|
|
112
|
+
{ filter, search, enabled = true }: { filter: FilterTree | null; search?: string; enabled?: boolean },
|
|
113
|
+
): UseDistinctValuesResult {
|
|
114
|
+
let [result, setResult] = useState<UseDistinctValuesResult>({
|
|
115
|
+
values: [],
|
|
116
|
+
loading: false,
|
|
117
|
+
error: null,
|
|
118
|
+
truncated: false,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// The count column has to be a field other than the grouped one.
|
|
122
|
+
let countField = useMemo(() => {
|
|
123
|
+
if (!field) return null;
|
|
124
|
+
|
|
125
|
+
let lowerField = field.name.toLowerCase();
|
|
126
|
+
let names = dataObject
|
|
127
|
+
.getFields()
|
|
128
|
+
.map((definition) => definition.name)
|
|
129
|
+
.filter((name) => !!name && name.toLowerCase() !== lowerField);
|
|
130
|
+
|
|
131
|
+
return names.find((name) => name.toLowerCase() === "primkey") ?? names[0] ?? null;
|
|
132
|
+
}, [dataObject, field]);
|
|
133
|
+
|
|
134
|
+
// The filter is only used to scope the list, so re-running on every keystroke
|
|
135
|
+
// in an unrelated criterion is avoided by keying on its serialized form.
|
|
136
|
+
let filterKey = useMemo(() => JSON.stringify(filter ?? null), [filter]);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
if (!enabled || !field || !supportsDistinctValues(field)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let controller = new AbortController();
|
|
144
|
+
let cancelled = false;
|
|
145
|
+
|
|
146
|
+
setResult((current) => ({ ...current, loading: true, error: null }));
|
|
147
|
+
|
|
148
|
+
async function load() {
|
|
149
|
+
if (!field) return;
|
|
150
|
+
|
|
151
|
+
let parsedFilter: FilterTree | null = JSON.parse(filterKey);
|
|
152
|
+
|
|
153
|
+
async function run(withCounts: boolean) {
|
|
154
|
+
let parameters = buildDistinctParameters({
|
|
155
|
+
field: field as FieldMetadata,
|
|
156
|
+
countField,
|
|
157
|
+
filter: parsedFilter,
|
|
158
|
+
search,
|
|
159
|
+
withCounts,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return (await dataObject.dataHandler.retrieve(parameters as any, {
|
|
163
|
+
signal: controller.signal,
|
|
164
|
+
})) as any[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
let withCounts = !!countField;
|
|
169
|
+
let rows: any[];
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
rows = await run(withCounts);
|
|
173
|
+
} catch (cause) {
|
|
174
|
+
if (controller.signal.aborted || !withCounts) throw cause;
|
|
175
|
+
|
|
176
|
+
// Counts are best effort: some views cannot be grouped, so fall
|
|
177
|
+
// back to a plain distinct list rather than showing an error.
|
|
178
|
+
withCounts = false;
|
|
179
|
+
rows = await run(false);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (cancelled) return;
|
|
183
|
+
|
|
184
|
+
let values = rows
|
|
185
|
+
.map((row) => toDistinctValue(row, field as FieldMetadata, countField, withCounts))
|
|
186
|
+
.filter((value): value is DistinctValue => value !== null);
|
|
187
|
+
|
|
188
|
+
setResult({
|
|
189
|
+
values,
|
|
190
|
+
loading: false,
|
|
191
|
+
error: null,
|
|
192
|
+
truncated: values.length >= MAX_DISTINCT_VALUES,
|
|
193
|
+
});
|
|
194
|
+
} catch (cause) {
|
|
195
|
+
if (cancelled || controller.signal.aborted) return;
|
|
196
|
+
|
|
197
|
+
setResult({
|
|
198
|
+
values: [],
|
|
199
|
+
loading: false,
|
|
200
|
+
error: String((cause as Error)?.message ?? cause),
|
|
201
|
+
truncated: false,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
void load();
|
|
207
|
+
|
|
208
|
+
return () => {
|
|
209
|
+
cancelled = true;
|
|
210
|
+
controller.abort();
|
|
211
|
+
};
|
|
212
|
+
}, [dataObject, field, countField, filterKey, search, enabled]);
|
|
213
|
+
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function toDistinctValue(
|
|
218
|
+
row: Record<string, unknown>,
|
|
219
|
+
field: FieldMetadata,
|
|
220
|
+
countField: string | null,
|
|
221
|
+
withCounts: boolean,
|
|
222
|
+
): DistinctValue | null {
|
|
223
|
+
let raw = row[field.name];
|
|
224
|
+
|
|
225
|
+
if (raw === null || raw === undefined || raw === "") return null;
|
|
226
|
+
|
|
227
|
+
let count = withCounts && countField ? Number(row[countField]) : null;
|
|
228
|
+
|
|
229
|
+
return { value: String(raw), count: Number.isFinite(count as number) ? (count as number) : null };
|
|
230
|
+
}
|
package/src/global.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
/// <reference path="@olenbetong/appframe-core" />
|
|
2
|
-
/// <reference path="@olenbetong/appframe-data" />
|
|
3
|
-
|
|
4
|
-
declare module "*.css" {}
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The MUI X license key, replaced at build time by the `appframe()` Vite plugin.
|
|
8
|
-
* Declared as possibly undefined, because consumers are free to bundle this
|
|
9
|
-
* package without the define.
|
|
10
|
-
*/
|
|
11
|
-
declare const __MUI_X_LICENSE_KEY__: string | undefined;
|
|
1
|
+
/// <reference path="@olenbetong/appframe-core" />
|
|
2
|
+
/// <reference path="@olenbetong/appframe-data" />
|
|
3
|
+
|
|
4
|
+
declare module "*.css" {}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The MUI X license key, replaced at build time by the `appframe()` Vite plugin.
|
|
8
|
+
* Declared as possibly undefined, because consumers are free to bundle this
|
|
9
|
+
* package without the define.
|
|
10
|
+
*/
|
|
11
|
+
declare const __MUI_X_LICENSE_KEY__: string | undefined;
|
package/src/grid/formatters.ts
CHANGED
|
@@ -46,3 +46,26 @@ export function dateTimeValueFormatter(value: any) {
|
|
|
46
46
|
|
|
47
47
|
return value;
|
|
48
48
|
}
|
|
49
|
+
|
|
50
|
+
export const localDateFormatter = new Intl.DateTimeFormat(culture, {
|
|
51
|
+
year: "numeric",
|
|
52
|
+
month: "2-digit",
|
|
53
|
+
day: "2-digit",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
export const localDateTimeFormatter = new Intl.DateTimeFormat(culture, {
|
|
57
|
+
year: "numeric",
|
|
58
|
+
month: "2-digit",
|
|
59
|
+
day: "2-digit",
|
|
60
|
+
hour: "2-digit",
|
|
61
|
+
minute: "2-digit",
|
|
62
|
+
second: "2-digit",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export function localDateValueFormatter(value: any) {
|
|
66
|
+
return value && value instanceof Date ? localDateFormatter.format(value) : value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function localDateTimeValueFormatter(value: any) {
|
|
70
|
+
return value && value instanceof Date ? localDateTimeFormatter.format(value) : value;
|
|
71
|
+
}
|
package/src/grid/index.css
CHANGED
|
@@ -140,8 +140,8 @@
|
|
|
140
140
|
|
|
141
141
|
/* The funnel is highlighted while the column has criteria, as in the toolbar */
|
|
142
142
|
.AfGrid-headerFilterButton[data-active] {
|
|
143
|
-
--dsc-button-color: var(--ds-color-
|
|
144
|
-
--dsc-button-color--hover: var(--ds-color-
|
|
143
|
+
--dsc-button-color: var(--ds-color-brand-base-default);
|
|
144
|
+
--dsc-button-color--hover: var(--ds-color-brand-base-hover);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
147
|
.AfGrid-headerFilterEmpty {
|
|
@@ -243,3 +243,9 @@
|
|
|
243
243
|
.AfGrid-root .MuiDataGrid-cell a.ds-button {
|
|
244
244
|
text-decoration: none;
|
|
245
245
|
}
|
|
246
|
+
|
|
247
|
+
/* Group rows (row grouping): tinted and bold, so the subtotals read apart from the data rows */
|
|
248
|
+
.AfGrid-root .MuiDataGrid-row.AfGrid-groupRow {
|
|
249
|
+
background-color: var(--ds-color-neutral-background-tinted);
|
|
250
|
+
font-weight: 600;
|
|
251
|
+
}
|
package/src/grid/index.tsx
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
type GridAggregationFunction,
|
|
10
10
|
type GridApi,
|
|
11
11
|
type GridFilterModel,
|
|
12
|
+
type GridColSpanFn,
|
|
13
|
+
type GridGroupingColDefOverride,
|
|
14
|
+
type GridGroupingColDefOverrideParams,
|
|
12
15
|
type GridGroupNode,
|
|
13
16
|
type GridRowParams,
|
|
14
17
|
useGridApiRef,
|
|
@@ -17,9 +20,10 @@ import { type DataObject, uid } from "@olenbetong/appframe-data";
|
|
|
17
20
|
import { DataObjectProvider, useLoading } from "@olenbetong/appframe-react";
|
|
18
21
|
|
|
19
22
|
import clsx from "clsx";
|
|
20
|
-
import { useEffect, useMemo, useState } from "react";
|
|
23
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
21
24
|
import { FilterBuilder } from "../filter/index.js";
|
|
22
25
|
import { AfGridColumnsPanel } from "./AfGridColumnsPanel.js";
|
|
26
|
+
import { AfHeaderFilterCell } from "./AfHeaderFilterCell.js";
|
|
23
27
|
import { type AfGridContextValue, AfGridContextProvider } from "./AfGridContext.js";
|
|
24
28
|
import { AfGridError } from "./AfGridError.js";
|
|
25
29
|
import { registerLicense } from "./license.js";
|
|
@@ -141,6 +145,58 @@ export {
|
|
|
141
145
|
};
|
|
142
146
|
|
|
143
147
|
/** MUI's own default: the grand total in the footer, group subtotals inline. */
|
|
148
|
+
/**
|
|
149
|
+
* On a group row the grouping cell spans the following visible columns up to the first one that
|
|
150
|
+
* shows an aggregate, since those cells are empty there. Data rows span nothing.
|
|
151
|
+
*/
|
|
152
|
+
const spanGroupLabel: GridColSpanFn = (_value, row, column, apiRef) => {
|
|
153
|
+
// The callback is typed with the community API; this grid is premium, which has aggregation.
|
|
154
|
+
let api = apiRef.current as GridApi | null;
|
|
155
|
+
if (!api || api.getRowNode(api.getRowId(row))?.type !== "group") return 1;
|
|
156
|
+
|
|
157
|
+
let aggregated = api.state.aggregation?.model ?? {};
|
|
158
|
+
let columns = api.getVisibleColumns();
|
|
159
|
+
let index = columns.findIndex((c) => c.field === column.field);
|
|
160
|
+
let span = 1;
|
|
161
|
+
|
|
162
|
+
while (index + span < columns.length && !aggregated[columns[index + span].field]) {
|
|
163
|
+
span++;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return span;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* MUI generates the grouping column itself, so it never passes through `useAfColumns` and got
|
|
171
|
+
* MUI's own header filter. It gets ours instead, filtering on the grouped field when the rows are
|
|
172
|
+
* grouped by a single field, and an empty cell like any unfilterable column otherwise.
|
|
173
|
+
*
|
|
174
|
+
* On group rows the column spans the empty cells after it, so the group label is readable in a
|
|
175
|
+
* column no wider than its expand toggle. A grid can still set its own `colSpan`.
|
|
176
|
+
*/
|
|
177
|
+
function getGroupingColDef(
|
|
178
|
+
params: GridGroupingColDefOverrideParams,
|
|
179
|
+
groupingColDef: DataGridProps["groupingColDef"],
|
|
180
|
+
dataObject: DataObject<any>,
|
|
181
|
+
): GridGroupingColDefOverride {
|
|
182
|
+
let base = typeof groupingColDef === "function" ? groupingColDef(params) : groupingColDef;
|
|
183
|
+
let filterColumn = params.fields.length === 1 ? params.fields[0] : undefined;
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
...base,
|
|
187
|
+
colSpan: base?.colSpan ?? spanGroupLabel,
|
|
188
|
+
renderHeaderFilter:
|
|
189
|
+
base?.renderHeaderFilter ??
|
|
190
|
+
((props) => (
|
|
191
|
+
<AfHeaderFilterCell
|
|
192
|
+
{...props}
|
|
193
|
+
colDef={{ ...props.colDef, filterColumn } as AfGridColDef}
|
|
194
|
+
dataObject={dataObject}
|
|
195
|
+
/>
|
|
196
|
+
)),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
144
200
|
function getDefaultAggregationPosition(groupNode: GridGroupNode) {
|
|
145
201
|
return groupNode.depth === -1 ? "footer" : "inline";
|
|
146
202
|
}
|
|
@@ -241,6 +297,12 @@ export type AfGridProps = {
|
|
|
241
297
|
* where the client total is wrong.
|
|
242
298
|
*/
|
|
243
299
|
serverAggregation?: boolean;
|
|
300
|
+
/**
|
|
301
|
+
* Leave out MUI's loading overlay while the data object loads. For a grid that refreshes on a
|
|
302
|
+
* timer: the overlay covers the rows and blocks selecting text in them. The toolbar's refresh
|
|
303
|
+
* button still shows that the grid is loading.
|
|
304
|
+
*/
|
|
305
|
+
disableLoadingOverlay?: boolean;
|
|
244
306
|
} & Omit<
|
|
245
307
|
DataGridProps,
|
|
246
308
|
| "apiRef"
|
|
@@ -312,18 +374,25 @@ function AfGrid_(props: AfGridProps) {
|
|
|
312
374
|
pinnedRows,
|
|
313
375
|
getRowClassName,
|
|
314
376
|
serverAggregation = dataObject.options?.dynamicLoading === true,
|
|
377
|
+
disableLoadingOverlay = false,
|
|
315
378
|
...other
|
|
316
379
|
} = props;
|
|
317
380
|
let apiRefPriv = useGridApiRef();
|
|
318
381
|
let apiRef = apiRefProp ?? apiRefPriv;
|
|
319
382
|
useAfGridApi(props, apiRef);
|
|
383
|
+
// Group rows (row grouping) are marked so AfGrid's stylesheet can set them apart from the data rows.
|
|
384
|
+
let getRowClassNameWithGroups = useCallback<NonNullable<DataGridProps["getRowClassName"]>>(
|
|
385
|
+
(params) =>
|
|
386
|
+
clsx(getRowClassName?.(params), apiRef.current?.getRowNode(params.id)?.type === "group" && "AfGrid-groupRow"),
|
|
387
|
+
[getRowClassName, apiRef],
|
|
388
|
+
);
|
|
320
389
|
let newItemRow = useAfNewItemRow({
|
|
321
390
|
apiRef,
|
|
322
391
|
dataObject,
|
|
323
392
|
position: newItemRowPosition,
|
|
324
393
|
disableInsert,
|
|
325
394
|
pinnedRows,
|
|
326
|
-
getRowClassName,
|
|
395
|
+
getRowClassName: getRowClassNameWithGroups,
|
|
327
396
|
});
|
|
328
397
|
let initialState_ = useAfPersistedState({
|
|
329
398
|
apiRef,
|
|
@@ -431,7 +500,7 @@ function AfGrid_(props: AfGridProps) {
|
|
|
431
500
|
apiRef={apiRef}
|
|
432
501
|
rows={data}
|
|
433
502
|
columns={columns}
|
|
434
|
-
loading={loading}
|
|
503
|
+
loading={!disableLoadingOverlay && loading}
|
|
435
504
|
getRowId={(row) => row[uid] ?? row[idField]}
|
|
436
505
|
{...editProps}
|
|
437
506
|
{...newItemRow.gridProps}
|
|
@@ -463,6 +532,7 @@ function AfGrid_(props: AfGridProps) {
|
|
|
463
532
|
* so the default position is passed explicitly.
|
|
464
533
|
*/
|
|
465
534
|
getAggregationPosition={other.getAggregationPosition ?? getDefaultAggregationPosition}
|
|
535
|
+
groupingColDef={(params) => getGroupingColDef(params, other.groupingColDef, dataObject)}
|
|
466
536
|
localeText={{ ...other.localeText, ...AF_GRID_LOCALE_TEXT }}
|
|
467
537
|
slots={{
|
|
468
538
|
...dsBaseSlots,
|