@olenbetong/appframe-ds 0.9.0 → 1.0.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 (70) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/package.json +22 -17
  3. package/scripts/copyAssets.mjs +3 -0
  4. package/src/autocomplete/Autocomplete.css +13 -0
  5. package/src/autocomplete/Autocomplete.tsx +3 -1
  6. package/src/autocomplete/Combobox.tsx +16 -3
  7. package/src/binding/BoundTextField.tsx +9 -1
  8. package/src/filter/FieldFilterPanel.css +25 -2
  9. package/src/grid/AfGridColumnsPanel.tsx +1 -1
  10. package/src/grid/AfGridContext.tsx +1 -1
  11. package/src/grid/AfGridError.tsx +1 -1
  12. package/src/grid/AfHeaderFilterCell.tsx +1 -1
  13. package/src/grid/Toolbar.tsx +1 -1
  14. package/src/grid/editing.ts +17 -7
  15. package/src/grid/filter.ts +1 -1
  16. package/src/grid/index.css +59 -1
  17. package/src/grid/index.tsx +126 -18
  18. package/src/grid/localization.ts +1 -1
  19. package/src/grid/slots/index.tsx +27 -2
  20. package/src/grid/theme.tsx +3 -50
  21. package/src/grid/useAfColumns.tsx +103 -45
  22. package/src/grid/useAfCurrentIndex.ts +13 -5
  23. package/src/grid/useAfGridApi.ts +6 -1
  24. package/src/grid/useAfKeyBindings.ts +1 -1
  25. package/src/grid/useAfNewItemRow.ts +258 -0
  26. package/src/grid/useAfPagination.ts +2 -2
  27. package/src/grid/useAfPersistedState.ts +9 -1
  28. package/src/grid/useAfRowEditModel.ts +20 -12
  29. package/src/grid/useAfRowGrouping.ts +47 -0
  30. package/src/grid/useAfServerAggregation.ts +145 -0
  31. package/src/grid/useAfSortModel.ts +15 -3
  32. package/src/layout/AppMenuDrawer.css +70 -0
  33. package/src/layout/AppMenuDrawer.tsx +122 -0
  34. package/src/layout/index.tsx +1 -0
  35. package/src/mdi/Mdi.css +138 -0
  36. package/src/mdi/Mdi.test.tsx +87 -0
  37. package/src/mdi/Mdi.tsx +141 -0
  38. package/src/mdi/MdiConfirmCloseDialog.tsx +47 -0
  39. package/src/mdi/MdiContext.tsx +59 -0
  40. package/src/mdi/MdiDocumentContext.tsx +60 -0
  41. package/src/mdi/MdiPanel.tsx +61 -0
  42. package/src/mdi/MdiRestore.test.tsx +82 -0
  43. package/src/mdi/MdiTab.tsx +30 -0
  44. package/src/mdi/controller.test.ts +382 -0
  45. package/src/mdi/controller.ts +656 -0
  46. package/src/mdi/dockviewHost.ts +132 -0
  47. package/src/mdi/fakeHost.ts +200 -0
  48. package/src/mdi/host.ts +44 -0
  49. package/src/mdi/ids.test.ts +20 -0
  50. package/src/mdi/ids.ts +18 -0
  51. package/src/mdi/index.ts +31 -0
  52. package/src/mdi/layoutRestore.test.ts +156 -0
  53. package/src/mdi/layoutRestore.ts +311 -0
  54. package/src/mdi/layoutSpec.test.ts +143 -0
  55. package/src/mdi/layoutSpec.ts +214 -0
  56. package/src/mdi/layoutStore.test.ts +93 -0
  57. package/src/mdi/layoutStore.ts +99 -0
  58. package/src/mdi/memorySettingsStore.ts +21 -0
  59. package/src/mdi/registry.ts +34 -0
  60. package/src/mdi/testTypes.ts +37 -0
  61. package/src/mdi/theme.ts +22 -0
  62. package/src/mdi/types.ts +214 -0
  63. package/src/page/Page.css +10 -2
  64. package/src/page/PageBlock.css +5 -0
  65. package/src/page/PageBlock.tsx +14 -0
  66. package/src/page/PageHeader.css +0 -60
  67. package/src/page/PageHeader.tsx +6 -74
  68. package/src/test/happy-dom-document-class.ts +24 -0
  69. package/src/test/setup.ts +4 -0
  70. package/src/theme/useDsColorScheme.ts +53 -0
@@ -2,14 +2,17 @@ import "./index.css";
2
2
 
3
3
  import { Alert } from "@digdir/designsystemet-react";
4
4
  import {
5
- DataGridPro as DataGrid,
6
- type DataGridProProps as DataGridProps,
5
+ DataGridPremium as DataGrid,
6
+ type DataGridPremiumProps as DataGridProps,
7
+ GRID_AGGREGATION_FUNCTIONS,
8
+ GRID_ROOT_GROUP_ID,
9
+ type GridAggregationFunction,
7
10
  type GridApi,
8
11
  type GridFilterModel,
9
12
  useGridApiRef,
10
- } from "@mui/x-data-grid-pro";
13
+ } from "@mui/x-data-grid-premium";
11
14
  import { getLocalizedString } from "@olenbetong/appframe-core";
12
- import type { DataObject } from "@olenbetong/appframe-data";
15
+ import { type DataObject, uid } from "@olenbetong/appframe-data";
13
16
  import { DataObjectProvider, useLoading } from "@olenbetong/appframe-react";
14
17
 
15
18
  import clsx from "clsx";
@@ -45,6 +48,7 @@ import { useAfFilterFields } from "./useAfFilterFields.js";
45
48
  import { useAfFilter } from "./useAfFilter.js";
46
49
  import { type AfGridApi, useAfGridApi } from "./useAfGridApi.js";
47
50
  import { useAfKeyBindings } from "./useAfKeyBindings.js";
51
+ import { type AfGridNewItemRowPosition, NEW_ITEM_ROW_ID, isNewItemRow, useAfNewItemRow } from "./useAfNewItemRow.js";
48
52
  import { useAfPagination } from "./useAfPagination.js";
49
53
  import {
50
54
  isPersistedStateLoaded,
@@ -53,10 +57,12 @@ import {
53
57
  useAfPersistedState,
54
58
  } from "./useAfPersistedState.js";
55
59
  import { useAfRowEditModel } from "./useAfRowEditModel.js";
60
+ import { useAfRowGrouping } from "./useAfRowGrouping.js";
61
+ import { useAfServerAggregation } from "./useAfServerAggregation.js";
56
62
  import { useAfSortModel } from "./useAfSortModel.js";
57
63
 
58
64
  export * from "./formatters.js";
59
- export * as MuiDataGrid from "@mui/x-data-grid-pro";
65
+ export * as MuiDataGrid from "@mui/x-data-grid-premium";
60
66
 
61
67
  registerLicense();
62
68
 
@@ -84,10 +90,21 @@ export const systemColumns = {
84
90
  UpdatedBy: privSysColumns[4],
85
91
  };
86
92
 
87
- export type { AfExcelExportOptions, AfGridApi, AfGridColDef, AfGridColumn, AfGridExcelExportOptions };
93
+ export type {
94
+ AfExcelExportOptions,
95
+ AfGridApi,
96
+ AfGridColDef,
97
+ AfGridColumn,
98
+ AfGridExcelExportOptions,
99
+ AfGridNewItemRowPosition,
100
+ };
101
+ export type { AfGridGrouping, AfGridGroupingProps } from "./useAfRowGrouping.js";
88
102
 
89
103
  export {
90
104
  AF_GRID_LOCALE_TEXT,
105
+ NEW_ITEM_ROW_ID,
106
+ isNewItemRow,
107
+ useAfNewItemRow,
91
108
  AfGridColumnsPanel,
92
109
  AfGridExcelExportMenuItem,
93
110
  AfGridThemeProvider,
@@ -109,6 +126,8 @@ export {
109
126
  useAfPagination,
110
127
  useAfPersistedState,
111
128
  useAfRowEditModel,
129
+ useAfRowGrouping,
130
+ useAfServerAggregation,
112
131
  useAfSortModel,
113
132
  };
114
133
 
@@ -162,6 +181,15 @@ export type AfGridProps = {
162
181
  * saved filters are unavailable.
163
182
  */
164
183
  filterViewName?: string;
184
+ /**
185
+ * Where to show the new item row: a non-data row pinned above or below the data
186
+ * that reads "Click here to add a new row" until the user starts editing it.
187
+ * Editing it moves the data object to the new record at index -1, and committing
188
+ * it saves the record. Only shown when the data object allows insert and
189
+ * `disableInsert` is not set. `none` turns it off, in which case the Add button
190
+ * appends an unsaved row to the data instead. Defaults to `bottom`.
191
+ */
192
+ newItemRow?: AfGridNewItemRowPosition;
165
193
  /**
166
194
  * A unique ID used to persist the grid state. It is better to use the layoutVersion
167
195
  * property if you would like to ignore any previously persisted state, as changing
@@ -174,6 +202,23 @@ export type AfGridProps = {
174
202
  * is used to configure which properties should be persisted.
175
203
  */
176
204
  persistenceModel?: StatePersistenceConfig;
205
+ /**
206
+ * Ask the server for the grand total instead of adding up the rows the grid is holding.
207
+ *
208
+ * MUI aggregates over the loaded rows, which on a dynamically loading data object is the
209
+ * current page — a sum that looks like a total and is not one. With this on, the grid runs a
210
+ * second retrieve under the same filters that returns only the aggregates, so the footer shows
211
+ * the total over the whole result. Subtotals on grouped rows stay client side, since a group
212
+ * only exists over the rows the grid has.
213
+ *
214
+ * `sum`, `avg`, `min` and `max` are resolved server side; `size` and any custom aggregation
215
+ * function stay on the client. If the view cannot be aggregated the grid falls back to the
216
+ * client total rather than failing.
217
+ *
218
+ * Defaults to true when the data object loads dynamically, since that is exactly the case
219
+ * where the client total is wrong.
220
+ */
221
+ serverAggregation?: boolean;
177
222
  } & Omit<
178
223
  DataGridProps,
179
224
  | "apiRef"
@@ -186,6 +231,10 @@ export type AfGridProps = {
186
231
  | "sortingMode"
187
232
  | "sortModel"
188
233
  | "onSortModelChange"
234
+ | "rowGroupingModel"
235
+ | "onRowGroupingModelChange"
236
+ | "aggregationModel"
237
+ | "onAggregationModelChange"
189
238
  | "editRowsModel"
190
239
  | "editMode"
191
240
  | "onRowEditCommit"
@@ -232,31 +281,57 @@ function AfGrid_(props: AfGridProps) {
232
281
  getData,
233
282
  idField = "PrimKey",
234
283
  id,
284
+ // Follows the data object's `newRecordPosition` when it has one (older data
285
+ // packages have none and append), so the row sits where the record will land
286
+ newItemRow: newItemRowPosition = dataObject.options?.newRecordPosition === "start" ? "top" : "bottom",
235
287
  persistenceModel,
236
288
  initialState,
289
+ pinnedRows,
290
+ getRowClassName,
291
+ serverAggregation = dataObject.options?.dynamicLoading === true,
237
292
  ...other
238
293
  } = props;
239
294
  let apiRefPriv = useGridApiRef();
240
295
  let apiRef = apiRefProp ?? apiRefPriv;
241
296
  useAfGridApi(props, apiRef);
297
+ let newItemRow = useAfNewItemRow({
298
+ apiRef,
299
+ dataObject,
300
+ position: newItemRowPosition,
301
+ disableInsert,
302
+ pinnedRows,
303
+ getRowClassName,
304
+ });
305
+ let initialState_ = useAfPersistedState({
306
+ apiRef,
307
+ columns: columnsProp,
308
+ id,
309
+ layoutVersion,
310
+ persistenceModel,
311
+ initialState,
312
+ });
313
+ // Seeded from the restored layout, so a persisted grouping survives a reload.
314
+ let grouping = useAfRowGrouping(initialState_);
242
315
  let loading = useLoading(dataObject);
243
316
  let paging = useAfPagination(dataObject);
244
317
  let sortModel = useAfSortModel({
245
318
  dataObject,
246
319
  columns: columnsProp,
247
320
  disableServerSorting,
321
+ isGrouping: grouping.isGrouping,
248
322
  });
249
323
  let gridFilter = useAfFilter({ autoLoad, dataObject, id });
250
324
  let [snackbar, setSnackbar] = useState<string | null>(null);
251
325
  let [isFilterBuilderOpen, setFilterBuilderOpen] = useState(false);
252
326
  let [isColumnsPanelOpen, setColumnsPanelOpen] = useState(false);
253
- let editProps = useAfRowEditModel({ dataObject, disableEdit, idField, apiRef });
327
+ let editProps = useAfRowEditModel({ dataObject, disableEdit, idField, apiRef, newItemRow });
254
328
  let data = useAfData(dataObject, { getData });
255
329
  useAfCurrentIndex({
256
330
  apiRef,
257
331
  dataObject,
258
332
  idField,
259
333
  enabled: !!bindIndex,
334
+ newItemRowId: newItemRow.enabled ? NEW_ITEM_ROW_ID : null,
260
335
  });
261
336
  let columns = useAfColumns({
262
337
  apiRef,
@@ -268,15 +343,6 @@ function AfGrid_(props: AfGridProps) {
268
343
  });
269
344
  let filterFields = useAfFilterFields(dataObject, columnsProp);
270
345
 
271
- let initialState_ = useAfPersistedState({
272
- apiRef,
273
- columns: columnsProp,
274
- id,
275
- layoutVersion,
276
- persistenceModel,
277
- initialState,
278
- });
279
-
280
346
  let keyEvents = useAfKeyBindings({ apiRef, dataObject });
281
347
 
282
348
  useEffect(() => {
@@ -287,6 +353,39 @@ function AfGrid_(props: AfGridProps) {
287
353
  return () => clearTimeout(timeout);
288
354
  }, [snackbar]);
289
355
 
356
+ let serverAggregates = useAfServerAggregation(
357
+ dataObject,
358
+ grouping.gridProps.aggregationModel,
359
+ serverAggregation && !grouping.isGrouping,
360
+ );
361
+
362
+ /*
363
+ * The grand total row asks for `groupId === GRID_ROOT_GROUP_ID`; every other call is a group
364
+ * subtotal, which only means anything over the rows the grid actually has. So the server value
365
+ * is substituted for the root and the built-in function is left to do the rest.
366
+ */
367
+ let aggregationFunctions = useMemo(() => {
368
+ if (!serverAggregation) return undefined;
369
+
370
+ let base = other.aggregationFunctions ?? GRID_AGGREGATION_FUNCTIONS;
371
+ let wrapped: Record<string, GridAggregationFunction> = {};
372
+
373
+ for (let [name, fn] of Object.entries(base) as Array<[string, GridAggregationFunction]>) {
374
+ wrapped[name] = {
375
+ ...fn,
376
+ apply: (params, api) => {
377
+ if (params.groupId === GRID_ROOT_GROUP_ID && params.field in serverAggregates.values) {
378
+ return serverAggregates.values[params.field];
379
+ }
380
+
381
+ return fn.apply(params, api);
382
+ },
383
+ };
384
+ }
385
+
386
+ return wrapped;
387
+ }, [serverAggregation, other.aggregationFunctions, serverAggregates.values]);
388
+
290
389
  let gridContext = useMemo<AfGridContextValue>(
291
390
  () => ({
292
391
  id,
@@ -302,14 +401,15 @@ function AfGrid_(props: AfGridProps) {
302
401
  return (
303
402
  <DataObjectProvider dataObject={dataObject}>
304
403
  <AfGridContextProvider value={gridContext}>
305
- <div className="AfGrid-root" {...keyEvents} tabIndex={-1}>
404
+ <div className="AfGrid-root" style={newItemRow.rootStyle} {...keyEvents} tabIndex={-1}>
306
405
  <DataGrid
307
406
  apiRef={apiRef}
308
407
  rows={data}
309
408
  columns={columns}
310
409
  loading={loading}
311
- getRowId={(row) => row[af.data.uid] ?? row[idField]}
410
+ getRowId={(row) => row[uid] ?? row[idField]}
312
411
  {...editProps}
412
+ {...newItemRow.gridProps}
313
413
  /*
314
414
  * Our own filter tree drives the data object, so MUI never
315
415
  * sees a filter model. The header filter cells are replaced
@@ -322,6 +422,14 @@ function AfGrid_(props: AfGridProps) {
322
422
  {...paging}
323
423
  showToolbar
324
424
  {...other}
425
+ {...grouping.gridProps}
426
+ /*
427
+ * Spread rather than passed, because the grid merges props over its defaults with
428
+ * `Object.assign`: an explicit `undefined` here would wipe out the built-in
429
+ * aggregation functions rather than fall back to them, and the grid then throws on
430
+ * the first render.
431
+ */
432
+ {...(aggregationFunctions ? { aggregationFunctions } : {})}
325
433
  localeText={{ ...other.localeText, ...AF_GRID_LOCALE_TEXT }}
326
434
  slots={{
327
435
  ...dsBaseSlots,
@@ -1,4 +1,4 @@
1
- import type { GridLocaleText } from "@mui/x-data-grid-pro";
1
+ import type { GridLocaleText } from "@mui/x-data-grid-premium";
2
2
  import { getLocalizedString, localize } from "@olenbetong/appframe-core";
3
3
 
4
4
  export type AfGridLocaleTextExt = GridLocaleText & {
@@ -13,7 +13,7 @@ import {
13
13
  Textfield,
14
14
  Tooltip,
15
15
  } from "@digdir/designsystemet-react";
16
- import type { GridSlotProps, GridSlotsComponent } from "@mui/x-data-grid-pro";
16
+ import type { GridSlotProps, GridSlotsComponent } from "@mui/x-data-grid-premium";
17
17
 
18
18
  import clsx from "clsx";
19
19
  import { type Ref, useCallback } from "react";
@@ -112,12 +112,37 @@ function BaseTooltip(props: GridSlotProps["baseTooltip"]) {
112
112
  let { children, title, enterDelay: _enterDelay, disableInteractive: _disableInteractive, ...other } = props;
113
113
  let content = tooltipContent(title);
114
114
 
115
+ // Designsystemet labels the tooltip host itself, and logs `Missing
116
+ // tabindex="0" attribute on:` whenever that host is not tabbable. In the grid
117
+ // it never is — the column header is the tab stop and moves focus with the
118
+ // arrow keys, so MUI gives the menu button `tabindex="-1"` and hangs the sort
119
+ // tooltip on a bare `<span>`. The tooltips still open, because Designsystemet
120
+ // resolves the host with `closest()` on focus and hover, so the warning is a
121
+ // false positive — and it is not gated on `NODE_ENV`, so it reaches
122
+ // production too.
123
+ //
124
+ // Setting the label ourselves is exactly what Designsystemet does a moment
125
+ // later (`aria-description` when the host has text, `aria-label` when it does
126
+ // not), and it makes Designsystemet skip that whole branch — silencing the
127
+ // warning without turning off `window.dsWarnings` for the entire app.
128
+ let ref = useCallback(
129
+ (element: HTMLElement | null) => {
130
+ if (!element || !content) {
131
+ return;
132
+ }
133
+
134
+ let hasText = element.getAttribute("role") !== "img" && !!element.textContent?.trim();
135
+ element.setAttribute(hasText ? "aria-description" : "aria-label", content);
136
+ },
137
+ [content],
138
+ );
139
+
115
140
  if (!content) {
116
141
  return children;
117
142
  }
118
143
 
119
144
  return (
120
- <Tooltip {...(other as any)} content={content}>
145
+ <Tooltip {...(other as any)} ref={ref} content={content}>
121
146
  {children}
122
147
  </Tooltip>
123
148
  );
@@ -2,57 +2,10 @@ import { StyledEngineProvider, type Theme, ThemeProvider } from "@mui/material";
2
2
  import { createTheme as muiCreateTheme } from "@mui/material/styles";
3
3
  import { createContext, type ReactNode, use, useEffect, useState } from "react";
4
4
 
5
- function getColorScheme(element?: Element | null): "light" | "dark" {
6
- let scoped = element?.closest<HTMLElement>("[data-color-scheme]");
7
- let scheme =
8
- scoped?.dataset.colorScheme ??
9
- document.body?.dataset.colorScheme ??
10
- document.documentElement?.dataset.colorScheme ??
11
- "light";
12
-
13
- if (scheme === "auto") {
14
- return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light";
15
- }
16
-
17
- return scheme === "dark" ? "dark" : "light";
18
- }
19
-
20
- /**
21
- * Tracks the color scheme Designsystemet is rendering with. The article template
22
- * sets `data-color-scheme` on `<body>`, but it may also be scoped to a subtree,
23
- * so resolve it from `element` upwards when one is given. The OS preference is
24
- * watched too, since it decides the result when the scheme is `auto`.
25
- */
26
- export function useDsColorScheme(element?: Element | null) {
27
- let [scheme, setScheme] = useState<"light" | "dark">(() => getColorScheme(element));
5
+ import { useDsColorScheme } from "../theme/useDsColorScheme.js";
28
6
 
29
- useEffect(() => {
30
- function update() {
31
- setScheme(getColorScheme(element));
32
- }
33
-
34
- update();
35
-
36
- // `data-color-scheme` may sit on any ancestor, so watch the whole document
37
- // for that one attribute rather than just `<html>` and `<body>`.
38
- let observer = new MutationObserver(update);
39
- observer.observe(document.documentElement, {
40
- attributes: true,
41
- attributeFilter: ["data-color-scheme"],
42
- subtree: true,
43
- });
44
-
45
- let media = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
46
- media?.addEventListener("change", update);
47
-
48
- return () => {
49
- observer.disconnect();
50
- media?.removeEventListener("change", update);
51
- };
52
- }, [element]);
53
-
54
- return scheme;
55
- }
7
+ // Lives in `../theme/` so MUI-free areas (the `mdi` subpath) can share it.
8
+ export { useDsColorScheme };
56
9
 
57
10
  function buildTheme(mode: "light" | "dark", element?: Element | null) {
58
11
  let styles = getComputedStyle(element ?? document.body);
@@ -1,4 +1,5 @@
1
1
  import {
2
+ GRID_AGGREGATION_ROOT_FOOTER_ROW_ID,
2
3
  GRID_CHECKBOX_SELECTION_FIELD,
3
4
  GRID_DETAIL_PANEL_TOGGLE_FIELD,
4
5
  GRID_TREE_DATA_GROUPING_FIELD,
@@ -12,13 +13,14 @@ import {
12
13
  type GridRowId,
13
14
  GridRowModes,
14
15
  type GridRowParams,
16
+ getAggregationFooterRowIdFromGroupId,
15
17
  gridEditRowsStateSelector,
16
18
  useGridApiContext,
17
19
  useGridSelector,
18
- } from "@mui/x-data-grid-pro";
20
+ } from "@mui/x-data-grid-premium";
19
21
  import { FloppydiskIcon, PencilIcon, TrashIcon, XMarkIcon } from "@navikt/aksel-icons";
20
22
  import { getLocalizedString } from "@olenbetong/appframe-core";
21
- import type { DataObject } from "@olenbetong/appframe-data";
23
+ import { type DataObject, uid } from "@olenbetong/appframe-data";
22
24
  import { usePermissions } from "@olenbetong/appframe-react";
23
25
 
24
26
  import React, { useMemo } from "react";
@@ -27,6 +29,10 @@ import { AfHeaderFilterCell } from "./AfHeaderFilterCell.js";
27
29
  import { deleteRow } from "./editing.js";
28
30
  import { operators } from "./filter.js";
29
31
  import { dateTimeValueFormatter, dateValueFormatter } from "./formatters.js";
32
+ import { isNewItemRow } from "./useAfNewItemRow.js";
33
+
34
+ /** `auto-generated-group-footer-`, without spelling it out: every group's total row shares it. */
35
+ const AGGREGATION_FOOTER_ID_PREFIX = String(getAggregationFooterRowIdFromGroupId(""));
30
36
 
31
37
  export type AfGridColDef = GridColDef & {
32
38
  sortColumn?: string;
@@ -43,6 +49,22 @@ export type AfGridColDef = GridColDef & {
43
49
  calculated?: boolean;
44
50
  };
45
51
 
52
+ /**
53
+ * Whether the cell belongs to a row the grid generated to hold totals rather than to a record.
54
+ *
55
+ * The row type is not enough on its own: the grand total is pinned to the bottom, so it arrives as
56
+ * a `pinnedRow` — the same type as the grid's own new-item row, whose `renderCell` must still run.
57
+ * Its id is the reliable marker, and MUI exports the constants for it.
58
+ */
59
+ function isAggregationRow(params: { id: GridRowId; rowNode?: { type?: string } }) {
60
+ if (params.rowNode?.type === "group") return true;
61
+
62
+ return (
63
+ typeof params.id === "string" &&
64
+ (params.id === GRID_AGGREGATION_ROOT_FOOTER_ROW_ID || params.id.startsWith(AGGREGATION_FOOTER_ID_PREFIX))
65
+ );
66
+ }
67
+
46
68
  export function getColumnVisibilityModel(columns: AfGridColumn[]): GridColumnVisibilityModel {
47
69
  let visibility: GridColumnVisibilityModel = {};
48
70
  for (let column of columns) {
@@ -155,6 +177,28 @@ export function afColumnsToMuiColumn({
155
177
  col.filterOperators = (operators as any)[type];
156
178
  }
157
179
 
180
+ /*
181
+ * A total or group row has no record behind it — the grid generates it — so a
182
+ * `renderCell` that reads its value off `params.row`, which most do, has nothing to
183
+ * read. Left alone that reads as a broken feature in two ways: turning on a sum shows
184
+ * a pinned row of empty cells, and every unaggregated column renders whatever its
185
+ * `renderCell` makes of an empty record (a status chip saying "Open", say).
186
+ *
187
+ * So the aggregated value is rendered where there is one, nothing where there is not,
188
+ * and `renderCell` is left to the data rows it was written for. A column that wants to
189
+ * shape its total formats it with `valueFormatter`, which the grid applies to
190
+ * aggregation cells too.
191
+ */
192
+ if (col.renderCell) {
193
+ let renderDataCell = col.renderCell;
194
+ col.renderCell = (params) => {
195
+ if (params.aggregation) return params.formattedValue ?? params.value ?? "";
196
+ if (isAggregationRow(params)) return "";
197
+
198
+ return renderDataCell(params);
199
+ };
200
+ }
201
+
158
202
  // The header filter cell is fully replaced by our own, which edits the
159
203
  // shared filter tree instead of MUI's filter model.
160
204
  if (col.filterable !== false && col.type !== "actions") {
@@ -254,7 +298,7 @@ export function useAfColumns({
254
298
 
255
299
  const row = apiRef.current?.getRow(id);
256
300
  if (row?.isNew) {
257
- apiRef.current?.updateRows([{ [(af as any).data.uid]: id, id, _action: "delete" } as any]);
301
+ apiRef.current?.updateRows([{ [uid]: id, id, _action: "delete" } as any]);
258
302
  }
259
303
  };
260
304
 
@@ -263,48 +307,62 @@ export function useAfColumns({
263
307
  field: "actions",
264
308
  type: "actions",
265
309
  headerName: getLocalizedString("Actions"),
266
- getActions: ({ id }: GridRowParams) => [
267
- <GridActionItem
268
- key="edit"
269
- icon={<PencilIcon aria-hidden />}
270
- label={getLocalizedString("Edit")}
271
- onClick={handleEditClick(id)}
272
- mode={GridRowModes.View}
273
- dataObject={dataObject}
274
- rowId={id}
275
- requireUpdate
276
- />,
277
- <GridActionItem
278
- key="delete"
279
- icon={<TrashIcon aria-hidden />}
280
- label={getLocalizedString("Delete")}
281
- onClick={handleDeleteClick(id)}
282
- mode={GridRowModes.View}
283
- dataObject={dataObject}
284
- rowId={id}
285
- requireDelete
286
- />,
287
- <GridActionItem
288
- key="save"
289
- icon={<FloppydiskIcon aria-hidden />}
290
- label={getLocalizedString("Save")}
291
- onClick={handleSaveClick(id)}
292
- mode={GridRowModes.Edit}
293
- dataObject={dataObject}
294
- rowId={id}
295
- requireUpdate
296
- />,
297
- <GridActionItem
298
- key="revert"
299
- icon={<XMarkIcon aria-hidden />}
300
- label={getLocalizedString("Cancel")}
301
- onClick={handleCancelClick(id)}
302
- mode={GridRowModes.Edit}
303
- dataObject={dataObject}
304
- rowId={id}
305
- requireUpdate
306
- />,
307
- ],
310
+ getActions: ({ id }: GridRowParams) => {
311
+ // The new item row only exists while inserting is allowed, so its save and
312
+ // cancel actions do not depend on the update permission, and it has nothing
313
+ // to edit or delete in view mode.
314
+ let isNewItem = isNewItemRow(id);
315
+ let actions = [
316
+ <GridActionItem
317
+ key="save"
318
+ icon={<FloppydiskIcon aria-hidden />}
319
+ label={getLocalizedString("Save")}
320
+ onClick={handleSaveClick(id)}
321
+ mode={GridRowModes.Edit}
322
+ dataObject={dataObject}
323
+ rowId={id}
324
+ requireUpdate={!isNewItem}
325
+ />,
326
+ <GridActionItem
327
+ key="revert"
328
+ icon={<XMarkIcon aria-hidden />}
329
+ label={getLocalizedString("Cancel")}
330
+ onClick={handleCancelClick(id)}
331
+ mode={GridRowModes.Edit}
332
+ dataObject={dataObject}
333
+ rowId={id}
334
+ requireUpdate={!isNewItem}
335
+ />,
336
+ ];
337
+
338
+ if (isNewItem) {
339
+ return actions;
340
+ }
341
+
342
+ return [
343
+ <GridActionItem
344
+ key="edit"
345
+ icon={<PencilIcon aria-hidden />}
346
+ label={getLocalizedString("Edit")}
347
+ onClick={handleEditClick(id)}
348
+ mode={GridRowModes.View}
349
+ dataObject={dataObject}
350
+ rowId={id}
351
+ requireUpdate
352
+ />,
353
+ <GridActionItem
354
+ key="delete"
355
+ icon={<TrashIcon aria-hidden />}
356
+ label={getLocalizedString("Delete")}
357
+ onClick={handleDeleteClick(id)}
358
+ mode={GridRowModes.View}
359
+ dataObject={dataObject}
360
+ rowId={id}
361
+ requireDelete
362
+ />,
363
+ ...actions,
364
+ ];
365
+ },
308
366
  });
309
367
  }
310
368
 
@@ -1,5 +1,5 @@
1
- import type { GridApi, GridCellParams } from "@mui/x-data-grid-pro";
2
- import type { DataObject } from "@olenbetong/appframe-data";
1
+ import type { GridApi, GridCellParams, GridRowId } from "@mui/x-data-grid-premium";
2
+ import { type DataObject, uid } from "@olenbetong/appframe-data";
3
3
  import { useEvent } from "@olenbetong/appframe-react";
4
4
 
5
5
  import { useEffect, useRef } from "react";
@@ -9,14 +9,19 @@ export type useAfCurrentIndexOptions = {
9
9
  dataObject: DataObject<any>;
10
10
  enabled: boolean;
11
11
  idField: string;
12
+ /**
13
+ * Id of the grid's new item row, if it has one. A current index of -1 then
14
+ * focuses that row, since the framework's new record has no row in the data.
15
+ */
16
+ newItemRowId?: GridRowId | null;
12
17
  };
13
18
 
14
19
  function getRowId(row: any, idField: string) {
15
20
  if (!row) return row;
16
- return row[af.data.uid] ?? row[idField];
21
+ return row[uid] ?? row[idField];
17
22
  }
18
23
 
19
- export function useAfCurrentIndex({ apiRef, dataObject, idField, enabled }: useAfCurrentIndexOptions) {
24
+ export function useAfCurrentIndex({ apiRef, dataObject, idField, enabled, newItemRowId }: useAfCurrentIndexOptions) {
20
25
  let triggeredByFocusEvent = useRef(false);
21
26
  let focusColumn = useRef<string>(null);
22
27
 
@@ -46,7 +51,10 @@ export function useAfCurrentIndex({ apiRef, dataObject, idField, enabled }: useA
46
51
  useEvent(dataObject, "onCurrentIndexChanged", (event) => {
47
52
  if (enabled && triggeredByFocusEvent.current === false) {
48
53
  if (focusColumn.current && document.activeElement?.closest(".MuiDataGrid-cell")) {
49
- apiRef.current?.setCellFocus(getRowId(dataObject.getData(event.detail), idField), focusColumn.current);
54
+ let rowId = event.detail === -1 ? newItemRowId : getRowId(dataObject.getData(event.detail), idField);
55
+ if (rowId != null) {
56
+ apiRef.current?.setCellFocus(rowId, focusColumn.current);
57
+ }
50
58
  }
51
59
  } else {
52
60
  triggeredByFocusEvent.current = false;
@@ -1,4 +1,4 @@
1
- import type { GridApi } from "@mui/x-data-grid-pro";
1
+ import type { GridApi } from "@mui/x-data-grid-premium";
2
2
  import type { DataObject } from "@olenbetong/appframe-data";
3
3
 
4
4
  import type React from "react";
@@ -11,6 +11,11 @@ import { afSortOrderToMuiSortModel } from "./useAfSortModel.js";
11
11
  export interface AfGridApi extends GridApi {
12
12
  getDataObject: () => DataObject<any> | undefined;
13
13
  resetLayout: () => void;
14
+ /**
15
+ * Focuses the new item row and starts editing it. Returns false when the grid
16
+ * has no new item row, because it is turned off or inserting is not allowed.
17
+ */
18
+ startNewItemRow?: () => boolean;
14
19
  }
15
20
 
16
21
  export function useAfGridApi(props: Partial<AfGridProps>, apiRef: React.RefObject<GridApi | null>) {
@@ -1,4 +1,4 @@
1
- import type { GridApi } from "@mui/x-data-grid-pro";
1
+ import type { GridApi } from "@mui/x-data-grid-premium";
2
2
  import type { DataObject } from "@olenbetong/appframe-data";
3
3
 
4
4
  import type React from "react";