@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.
Files changed (154) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +3 -3
  3. package/scripts/copyAssets.mjs +27 -27
  4. package/src/autocomplete/Autocomplete.css +253 -253
  5. package/src/autocomplete/Autocomplete.tsx +320 -320
  6. package/src/autocomplete/AutocompleteFrame.tsx +168 -168
  7. package/src/autocomplete/Combobox.tsx +286 -286
  8. package/src/autocomplete/index.ts +4 -4
  9. package/src/autocomplete/types.ts +204 -204
  10. package/src/autocomplete/utils.ts +183 -183
  11. package/src/binding/BoundNumberInput.tsx +42 -42
  12. package/src/binding/BoundNumericTextField.tsx +102 -102
  13. package/src/binding/BoundTextField.tsx +49 -49
  14. package/src/binding/CancelButton.tsx +30 -30
  15. package/src/binding/CancelIconButton.tsx +30 -30
  16. package/src/binding/DataEditToolbar.css +23 -23
  17. package/src/binding/DataEditToolbar.tsx +84 -84
  18. package/src/binding/DeleteButton.tsx +35 -35
  19. package/src/binding/DeleteIconButton.tsx +35 -35
  20. package/src/binding/RefreshButton.tsx +31 -31
  21. package/src/binding/RefreshIconButton.tsx +33 -33
  22. package/src/binding/RefreshRowIconButton.tsx +33 -33
  23. package/src/binding/SaveButton.tsx +37 -37
  24. package/src/binding/SaveIconButton.tsx +38 -38
  25. package/src/binding/index.ts +20 -20
  26. package/src/container/Container.tsx +50 -50
  27. package/src/container/index.ts +1 -1
  28. package/src/cutoff/CutOffPicker.css +12 -12
  29. package/src/cutoff/CutOffPicker.tsx +165 -165
  30. package/src/cutoff/context.tsx +194 -194
  31. package/src/cutoff/dsCutOffDates.ts +21 -21
  32. package/src/cutoff/index.ts +4 -4
  33. package/src/cutoff/utils.test.ts +59 -59
  34. package/src/cutoff/utils.ts +276 -276
  35. package/src/filter/ChipListInput.tsx +79 -79
  36. package/src/filter/FieldFilterPanel.css +150 -150
  37. package/src/filter/FieldFilterPanel.tsx +309 -309
  38. package/src/filter/FilterBuilder.css +152 -152
  39. package/src/filter/FilterBuilder.test.tsx +153 -153
  40. package/src/filter/FilterBuilder.tsx +435 -435
  41. package/src/filter/FilterEditor.css +231 -231
  42. package/src/filter/FilterEditor.test.tsx +285 -285
  43. package/src/filter/FilterEditor.tsx +75 -75
  44. package/src/filter/FilterGroupEditor.tsx +160 -160
  45. package/src/filter/FilterNameDialog.css +22 -22
  46. package/src/filter/FilterNameDialog.tsx +81 -81
  47. package/src/filter/FilterRow.tsx +117 -117
  48. package/src/filter/FilterShareDialog.css +28 -28
  49. package/src/filter/FilterShareDialog.tsx +201 -201
  50. package/src/filter/FilterStringField.tsx +78 -78
  51. package/src/filter/FilterValueEditor.tsx +239 -239
  52. package/src/filter/SavedFilterTree.css +107 -107
  53. package/src/filter/SavedFilterTree.test.tsx +94 -94
  54. package/src/filter/SavedFilterTree.tsx +218 -218
  55. package/src/filter/fieldFilter.test.ts +158 -158
  56. package/src/filter/fieldFilter.ts +139 -139
  57. package/src/filter/index.ts +27 -27
  58. package/src/filter/types.ts +38 -38
  59. package/src/filter/useDistinctValues.test.ts +102 -102
  60. package/src/filter/useDistinctValues.ts +230 -230
  61. package/src/global.d.ts +11 -11
  62. package/src/grid/formatters.ts +23 -0
  63. package/src/grid/index.css +8 -2
  64. package/src/grid/index.tsx +73 -3
  65. package/src/grid/useAfColumns.tsx +28 -4
  66. package/src/grid/useAfGridApi.ts +3 -0
  67. package/src/grid/utcDate.test.ts +25 -0
  68. package/src/grid/utcDate.ts +28 -0
  69. package/src/grid/utcDateEditing.tsx +11 -0
  70. package/src/i18n.ts +13 -13
  71. package/src/index.ts +14 -14
  72. package/src/input/InputAdornments.css +80 -80
  73. package/src/input/InputAdornments.tsx +102 -102
  74. package/src/input/NumberInput.css +9 -9
  75. package/src/input/NumberInput.test.tsx +212 -212
  76. package/src/input/NumberInput.tsx +268 -268
  77. package/src/input/index.ts +3 -3
  78. package/src/input/numberFormat.test.ts +79 -79
  79. package/src/input/numberFormat.ts +94 -94
  80. package/src/layout/AppMenu.tsx +74 -74
  81. package/src/layout/AppMenuDrawer.css +71 -71
  82. package/src/layout/AppMenuDrawer.tsx +122 -122
  83. package/src/layout/AppMenuItem.css +6 -2
  84. package/src/layout/DialogMotion.css +79 -79
  85. package/src/layout/ErrorBoundary.tsx +162 -162
  86. package/src/layout/PageContainer.css +48 -48
  87. package/src/layout/PageContainer.tsx +41 -41
  88. package/src/layout/ResponsiveDialog.css +86 -86
  89. package/src/layout/ResponsiveDialog.tsx +55 -55
  90. package/src/layout/index.tsx +70 -70
  91. package/src/link/Link.tsx +23 -23
  92. package/src/link/index.ts +1 -1
  93. package/src/mdi/Mdi.css +8 -8
  94. package/src/mdi/Mdi.test.tsx +91 -91
  95. package/src/mdi/Mdi.tsx +141 -141
  96. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -47
  97. package/src/mdi/MdiContext.tsx +59 -59
  98. package/src/mdi/MdiDocumentContext.tsx +60 -60
  99. package/src/mdi/MdiPanel.tsx +61 -61
  100. package/src/mdi/MdiRestore.test.tsx +86 -86
  101. package/src/mdi/MdiTab.tsx +30 -30
  102. package/src/mdi/controller.test.ts +382 -382
  103. package/src/mdi/controller.ts +656 -656
  104. package/src/mdi/dockviewHost.ts +132 -132
  105. package/src/mdi/fakeHost.ts +200 -200
  106. package/src/mdi/host.ts +44 -44
  107. package/src/mdi/ids.test.ts +20 -20
  108. package/src/mdi/ids.ts +18 -18
  109. package/src/mdi/index.ts +31 -31
  110. package/src/mdi/layoutRestore.test.ts +156 -156
  111. package/src/mdi/layoutRestore.ts +311 -311
  112. package/src/mdi/layoutSpec.test.ts +143 -143
  113. package/src/mdi/layoutSpec.ts +214 -214
  114. package/src/mdi/layoutStore.test.ts +93 -93
  115. package/src/mdi/layoutStore.ts +99 -99
  116. package/src/mdi/memorySettingsStore.ts +21 -21
  117. package/src/mdi/registry.ts +34 -34
  118. package/src/mdi/testTypes.ts +37 -37
  119. package/src/mdi/theme.ts +22 -22
  120. package/src/mdi/types.ts +214 -214
  121. package/src/page/Page.css +57 -57
  122. package/src/page/PageBlock.css +64 -64
  123. package/src/page/PageBlock.tsx +93 -93
  124. package/src/page/PageHeader.css +88 -88
  125. package/src/page/PageHeader.tsx +115 -115
  126. package/src/page/PageTitle.css +5 -5
  127. package/src/page/PageTitle.tsx +21 -21
  128. package/src/page/index.ts +4 -4
  129. package/src/paper/Paper.tsx +52 -52
  130. package/src/progress/LinearProgress.css +121 -121
  131. package/src/progress/LinearProgress.tsx +106 -106
  132. package/src/progress/index.ts +1 -1
  133. package/src/report-downloader/ReportDownloader.css +67 -67
  134. package/src/report-downloader/components/StatusItem.tsx +87 -87
  135. package/src/report-downloader/components/StatusList.tsx +28 -28
  136. package/src/report-downloader/index.ts +41 -41
  137. package/src/report-downloader/status.tsx +92 -92
  138. package/src/sortable/DropIndicator.css +51 -51
  139. package/src/sortable/DropIndicator.tsx +27 -27
  140. package/src/sortable/SortableItemList.css +21 -21
  141. package/src/sortable/SortableItemList.tsx +264 -264
  142. package/src/sortable/index.ts +8 -8
  143. package/src/sortable/reorderSortOrder.ts +126 -126
  144. package/src/sortable/sortOrder.ts +101 -101
  145. package/src/sortable/types.ts +9 -9
  146. package/src/sortable/useSortableContainer.ts +53 -53
  147. package/src/sortable/useSortableItem.ts +95 -95
  148. package/src/sortable/useSortableList.ts +117 -117
  149. package/src/test/happy-dom-document-class.ts +24 -24
  150. package/src/test/setup.ts +27 -27
  151. package/src/theme/useDsColorScheme.ts +53 -53
  152. package/tsconfig.build.json +9 -9
  153. package/tsconfig.json +8 -8
  154. 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;
@@ -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
+ }
@@ -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-accent-base-default);
144
- --dsc-button-color--hover: var(--ds-color-accent-base-hover);
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
+ }
@@ -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,