@olenbetong/appframe-ds 0.7.0 → 0.9.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 (76) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/package.json +52 -8
  3. package/scripts/copyAssets.mjs +13 -0
  4. package/src/AfLookup/LookupCombobox.css +1 -1
  5. package/src/AfLookup/LookupCombobox.tsx +3 -1
  6. package/src/autocomplete/Autocomplete.css +63 -42
  7. package/src/autocomplete/Autocomplete.tsx +2 -0
  8. package/src/autocomplete/AutocompleteFrame.tsx +21 -2
  9. package/src/autocomplete/Combobox.tsx +15 -2
  10. package/src/autocomplete/types.ts +4 -0
  11. package/src/container/Container.tsx +49 -0
  12. package/src/container/index.ts +1 -0
  13. package/src/filter/ChipListInput.tsx +79 -0
  14. package/src/filter/FieldFilterPanel.css +127 -0
  15. package/src/filter/FieldFilterPanel.tsx +309 -0
  16. package/src/filter/FilterBuilder.css +152 -0
  17. package/src/filter/FilterBuilder.test.tsx +153 -0
  18. package/src/filter/FilterBuilder.tsx +435 -0
  19. package/src/filter/FilterEditor.css +231 -0
  20. package/src/filter/FilterEditor.test.tsx +259 -0
  21. package/src/filter/FilterEditor.tsx +74 -0
  22. package/src/filter/FilterGroupEditor.tsx +160 -0
  23. package/src/filter/FilterNameDialog.css +22 -0
  24. package/src/filter/FilterNameDialog.tsx +81 -0
  25. package/src/filter/FilterRow.tsx +117 -0
  26. package/src/filter/FilterShareDialog.css +28 -0
  27. package/src/filter/FilterShareDialog.tsx +201 -0
  28. package/src/filter/FilterStringField.tsx +77 -0
  29. package/src/filter/FilterValueEditor.tsx +220 -0
  30. package/src/filter/SavedFilterTree.css +107 -0
  31. package/src/filter/SavedFilterTree.test.tsx +94 -0
  32. package/src/filter/SavedFilterTree.tsx +222 -0
  33. package/src/filter/fieldFilter.test.ts +158 -0
  34. package/src/filter/fieldFilter.ts +139 -0
  35. package/src/filter/index.ts +27 -0
  36. package/src/filter/types.ts +38 -0
  37. package/src/filter/useDistinctValues.test.ts +102 -0
  38. package/src/filter/useDistinctValues.ts +230 -0
  39. package/src/global.d.ts +11 -0
  40. package/src/grid/AfGridColumnsPanel.tsx +118 -0
  41. package/src/grid/AfGridContext.tsx +51 -0
  42. package/src/grid/AfGridError.tsx +46 -0
  43. package/src/grid/AfHeaderFilterCell.tsx +151 -0
  44. package/src/grid/AfHeaderFilterPanel.tsx +111 -0
  45. package/src/grid/Toolbar.tsx +233 -0
  46. package/src/grid/editing.ts +90 -0
  47. package/src/grid/filter.ts +78 -0
  48. package/src/grid/formatters.ts +48 -0
  49. package/src/grid/index.css +187 -0
  50. package/src/grid/index.tsx +376 -0
  51. package/src/grid/license.ts +48 -0
  52. package/src/grid/localization.ts +369 -0
  53. package/src/grid/slots/index.tsx +275 -0
  54. package/src/grid/slots/slots.css +53 -0
  55. package/src/grid/theme.tsx +185 -0
  56. package/src/grid/useAfColumns.tsx +313 -0
  57. package/src/grid/useAfCurrentIndex.ts +55 -0
  58. package/src/grid/useAfData.ts +20 -0
  59. package/src/grid/useAfFilter.ts +160 -0
  60. package/src/grid/useAfFilterFields.ts +55 -0
  61. package/src/grid/useAfGridApi.ts +66 -0
  62. package/src/grid/useAfKeyBindings.ts +36 -0
  63. package/src/grid/useAfPagination.ts +53 -0
  64. package/src/grid/useAfPersistedState.ts +222 -0
  65. package/src/grid/useAfRowEditModel.ts +137 -0
  66. package/src/grid/useAfSortModel.ts +72 -0
  67. package/src/index.ts +2 -0
  68. package/src/input/InputAdornments.tsx +6 -1
  69. package/src/paper/Paper.tsx +2 -0
  70. package/src/report-downloader/ReportDownloader.css +67 -0
  71. package/src/report-downloader/components/StatusItem.tsx +86 -0
  72. package/src/report-downloader/components/StatusList.tsx +28 -0
  73. package/src/report-downloader/index.ts +41 -0
  74. package/src/report-downloader/status.tsx +92 -0
  75. package/src/test/setup.ts +8 -0
  76. package/vitest.config.ts +10 -0
@@ -0,0 +1,53 @@
1
+ .AfGrid-checkbox {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ }
5
+
6
+ .AfGrid-checkbox input {
7
+ cursor: pointer;
8
+ }
9
+
10
+ .AfGrid-textField,
11
+ .AfGrid-select {
12
+ width: 100%;
13
+ }
14
+
15
+ .AfGrid-badgeWrapper {
16
+ position: relative;
17
+ display: inline-flex;
18
+ }
19
+
20
+ .AfGrid-linearProgress {
21
+ position: relative;
22
+ width: 100%;
23
+ height: 4px;
24
+ overflow: hidden;
25
+ background: var(--ds-color-neutral-surface-tinted, rgb(0 0 0 / 10%));
26
+ }
27
+
28
+ .AfGrid-linearProgress::after {
29
+ position: absolute;
30
+ inset-block: 0;
31
+ inline-size: 40%;
32
+ background: var(--ds-color-brand-base-default, currentcolor);
33
+ animation: AfGrid-linearProgress 1.2s ease-in-out infinite;
34
+ content: "";
35
+ }
36
+
37
+ @keyframes AfGrid-linearProgress {
38
+ 0% {
39
+ inset-inline-start: -40%;
40
+ }
41
+
42
+ 100% {
43
+ inset-inline-start: 100%;
44
+ }
45
+ }
46
+
47
+ @media (prefers-reduced-motion: reduce) {
48
+ .AfGrid-linearProgress::after {
49
+ animation: none;
50
+ inset-inline-start: 0;
51
+ inline-size: 100%;
52
+ }
53
+ }
@@ -0,0 +1,185 @@
1
+ import { StyledEngineProvider, type Theme, ThemeProvider } from "@mui/material";
2
+ import { createTheme as muiCreateTheme } from "@mui/material/styles";
3
+ import { createContext, type ReactNode, use, useEffect, useState } from "react";
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));
28
+
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
+ }
56
+
57
+ function buildTheme(mode: "light" | "dark", element?: Element | null) {
58
+ let styles = getComputedStyle(element ?? document.body);
59
+ let get = (variable: string) => styles.getPropertyValue(variable).trim();
60
+ let isDark = mode === "dark";
61
+
62
+ return muiCreateTheme({
63
+ palette: {
64
+ mode,
65
+ primary: {
66
+ main: get("--ds-color-brand-base-default") || "#116069",
67
+ dark: get("--ds-color-brand-base-hover") || "#0d4b52",
68
+ contrastText: get("--ds-color-brand-base-contrast-default") || "#ffffff",
69
+ },
70
+ secondary: {
71
+ main: get("--ds-color-brand-light-base-default") || "#1a95a2",
72
+ },
73
+ error: {
74
+ main: get("--ds-color-danger-base-default") || (isDark ? "#ff8a80" : "#b3221a"),
75
+ },
76
+ warning: {
77
+ main: get("--ds-color-warning-base-default") || "#b8860b",
78
+ },
79
+ success: {
80
+ main: get("--ds-color-success-base-default") || "#2e7d32",
81
+ },
82
+ background: {
83
+ default: get("--ds-color-neutral-background-default") || (isDark ? "#17181a" : "#ffffff"),
84
+ paper: get("--ds-color-neutral-background-default") || (isDark ? "#17181a" : "#ffffff"),
85
+ },
86
+ text: {
87
+ primary: get("--ds-color-neutral-text-default") || (isDark ? "#e5e6e7" : "#292c30"),
88
+ secondary: get("--ds-color-neutral-text-subtle") || (isDark ? "#b3b5b7" : "#5b5f64"),
89
+ },
90
+ divider: get("--ds-color-neutral-border-subtle") || (isDark ? "#4e5052" : "#bbbcbd"),
91
+ },
92
+ typography: {
93
+ fontFamily: "var(--ds-font-family, inherit)",
94
+ },
95
+ components: {
96
+ MuiButton: {
97
+ styleOverrides: {
98
+ root: { borderRadius: "var(--ds-border-radius-md, 2px)" },
99
+ },
100
+ },
101
+ MuiFormLabel: {
102
+ styleOverrides: {
103
+ root: { color: get("--ds-color-neutral-text-subtle") || "rgba(0, 0, 0, 0.72)" },
104
+ },
105
+ },
106
+ },
107
+ });
108
+ }
109
+
110
+ const themeCache: Partial<Record<"light" | "dark", Theme>> = {};
111
+
112
+ /**
113
+ * Returns the MUI theme built from the Designsystemet tokens currently in
114
+ * effect. Pass `element` to read the tokens from a specific place in the DOM,
115
+ * which matters when the color scheme is scoped to a subtree. The theme is
116
+ * cached per color scheme at module level, so every grid on the page shares a
117
+ * single theme instance.
118
+ */
119
+ export function getDsMuiTheme(mode: "light" | "dark", element?: Element | null) {
120
+ let theme = themeCache[mode];
121
+
122
+ if (!theme) {
123
+ theme = buildTheme(mode, element);
124
+ themeCache[mode] = theme;
125
+ }
126
+
127
+ return theme;
128
+ }
129
+
130
+ /**
131
+ * Drops the cached themes, so they are rebuilt from the current token values the
132
+ * next time a grid renders. Only needed if the tokens themselves change at
133
+ * runtime (a brand switch, for example) — a color scheme change is handled by
134
+ * the per-scheme cache.
135
+ */
136
+ export function resetDsMuiTheme() {
137
+ delete themeCache.light;
138
+ delete themeCache.dark;
139
+ }
140
+
141
+ const ThemeProvidedContext = createContext(false);
142
+
143
+ export type AfGridThemeProviderProps = {
144
+ children: ReactNode;
145
+ };
146
+
147
+ /**
148
+ * Bridges Designsystemet's colour tokens into a MUI theme, so the data grid
149
+ * follows Designsystemet instead of the MUI defaults.
150
+ *
151
+ * `AfGrid` renders this itself, so apps only need it when they render other MUI
152
+ * components alongside the grid, or want a single provider for many grids.
153
+ * Nesting it is safe: the inner provider is a no-op.
154
+ */
155
+ export function AfGridThemeProvider({ children }: AfGridThemeProviderProps) {
156
+ let provided = use(ThemeProvidedContext);
157
+
158
+ // A display-less probe so the scheme and the tokens are read from this
159
+ // provider's position in the DOM rather than from `<body>`. Custom properties
160
+ // still resolve on a hidden element, and it takes part in no layout.
161
+ let [anchor, setAnchor] = useState<HTMLSpanElement | null>(null);
162
+ let colorScheme = useDsColorScheme(anchor);
163
+
164
+ // The stylesheets are imported from JS, so the custom properties aren't
165
+ // necessarily resolved during the first render. Rebuild after mount, and
166
+ // whenever the color scheme changes.
167
+ let [theme, setTheme] = useState(() => getDsMuiTheme(colorScheme, anchor));
168
+ useEffect(() => {
169
+ resetDsMuiTheme();
170
+ setTheme(getDsMuiTheme(colorScheme, anchor));
171
+ }, [colorScheme, anchor]);
172
+
173
+ if (provided) {
174
+ return children;
175
+ }
176
+
177
+ return (
178
+ <ThemeProvidedContext value={true}>
179
+ <span hidden ref={setAnchor} />
180
+ <StyledEngineProvider injectFirst>
181
+ <ThemeProvider theme={theme}>{children}</ThemeProvider>
182
+ </StyledEngineProvider>
183
+ </ThemeProvidedContext>
184
+ );
185
+ }
@@ -0,0 +1,313 @@
1
+ import {
2
+ GRID_CHECKBOX_SELECTION_FIELD,
3
+ GRID_DETAIL_PANEL_TOGGLE_FIELD,
4
+ GRID_TREE_DATA_GROUPING_FIELD,
5
+ GridActionsCellItem,
6
+ type GridActionsCellItemProps,
7
+ type GridAlignment,
8
+ type GridApi,
9
+ type GridColDef,
10
+ type GridColType,
11
+ type GridColumnVisibilityModel,
12
+ type GridRowId,
13
+ GridRowModes,
14
+ type GridRowParams,
15
+ gridEditRowsStateSelector,
16
+ useGridApiContext,
17
+ useGridSelector,
18
+ } from "@mui/x-data-grid-pro";
19
+ import { FloppydiskIcon, PencilIcon, TrashIcon, XMarkIcon } from "@navikt/aksel-icons";
20
+ import { getLocalizedString } from "@olenbetong/appframe-core";
21
+ import type { DataObject } from "@olenbetong/appframe-data";
22
+ import { usePermissions } from "@olenbetong/appframe-react";
23
+
24
+ import React, { useMemo } from "react";
25
+
26
+ import { AfHeaderFilterCell } from "./AfHeaderFilterCell.js";
27
+ import { deleteRow } from "./editing.js";
28
+ import { operators } from "./filter.js";
29
+ import { dateTimeValueFormatter, dateValueFormatter } from "./formatters.js";
30
+
31
+ export type AfGridColDef = GridColDef & {
32
+ sortColumn?: string;
33
+ filterColumn?: string;
34
+ /**
35
+ * Whether the column should be hidden by default
36
+ */
37
+ hide?: boolean;
38
+ /**
39
+ * Mark this column as calculated/virtual — it does not correspond to a field in
40
+ * the data object. Calculated columns are always non-editable and are excluded
41
+ * from server-side sort and filter operations.
42
+ */
43
+ calculated?: boolean;
44
+ };
45
+
46
+ export function getColumnVisibilityModel(columns: AfGridColumn[]): GridColumnVisibilityModel {
47
+ let visibility: GridColumnVisibilityModel = {};
48
+ for (let column of columns) {
49
+ if (typeof column !== "string" && column.hide) {
50
+ visibility[column.field] = false;
51
+ }
52
+ }
53
+ return visibility;
54
+ }
55
+
56
+ export function afColumnsToMuiColumn({
57
+ apiRef,
58
+ allowUpdate,
59
+ columnsProp,
60
+ dataObject,
61
+ }: useAfColumnsOptions & { allowDelete: boolean; allowUpdate: boolean }) {
62
+ let columns = columnsProp;
63
+ if (columns.length === 0) {
64
+ columns = dataObject.getFields().map((field) => field.name);
65
+ }
66
+
67
+ let sysColumns = [GRID_CHECKBOX_SELECTION_FIELD, GRID_DETAIL_PANEL_TOGGLE_FIELD, GRID_TREE_DATA_GROUPING_FIELD];
68
+ let muiColumns: GridColDef[] = [];
69
+ let currentColumns = apiRef.current?.getAllColumns?.();
70
+
71
+ for (let column of columnsProp) {
72
+ let fieldName = typeof column === "string" ? column : column.field;
73
+
74
+ if (sysColumns.includes(fieldName)) {
75
+ muiColumns.push(typeof column === "string" ? { field: column } : column);
76
+ } else {
77
+ let fieldDef = dataObject.getFields(fieldName);
78
+ let type = "string";
79
+ if (typeof column !== "string" && column.type) {
80
+ type = column.type;
81
+ } else if (fieldDef?.type) {
82
+ type = fieldDef.type;
83
+ }
84
+
85
+ if (type === "datetime") {
86
+ type = "dateTime";
87
+ }
88
+
89
+ if (type === "bigint") {
90
+ type = "number";
91
+ }
92
+
93
+ if (!muiColumnTypes.includes(type)) {
94
+ type = "string";
95
+ }
96
+
97
+ let align: GridAlignment;
98
+ switch (type) {
99
+ case "number":
100
+ align = "right";
101
+ break;
102
+ case "date":
103
+ case "dateTime":
104
+ align = "center";
105
+ break;
106
+ default:
107
+ align = "left";
108
+ break;
109
+ }
110
+
111
+ let valueFormatter: (value: any) => string | number | boolean | object | null | undefined = (value) => value;
112
+
113
+ if (type === "date") {
114
+ valueFormatter = dateValueFormatter;
115
+ } else if (type === "dateTime") {
116
+ valueFormatter = dateTimeValueFormatter;
117
+ }
118
+
119
+ let editable = allowUpdate && !fieldDef?.ignored && !fieldDef?.computed && !fieldDef?.joined;
120
+
121
+ // Calculated columns are never editable regardless of data object permissions
122
+ if (typeof column !== "string" && column.calculated) {
123
+ editable = false;
124
+ }
125
+
126
+ let currentWidth = currentColumns?.find((c) => c.field === fieldName)?.width;
127
+
128
+ let col: GridColDef;
129
+ if (typeof column === "string") {
130
+ col = {
131
+ field: column,
132
+ type: type as GridColType,
133
+ headerName: column,
134
+ valueFormatter,
135
+ editable,
136
+ align,
137
+ headerAlign: align,
138
+ width: currentWidth ?? 150,
139
+ };
140
+ } else {
141
+ let { hide, calculated, ...rest } = column;
142
+ col = {
143
+ headerName: fieldDef?.caption ?? fieldName,
144
+ editable,
145
+ type: type as GridColType,
146
+ valueFormatter,
147
+ align,
148
+ headerAlign: align,
149
+ ...rest,
150
+ width: currentWidth ?? rest.width ?? 150,
151
+ };
152
+ }
153
+
154
+ if ((operators as any)[type]) {
155
+ col.filterOperators = (operators as any)[type];
156
+ }
157
+
158
+ // The header filter cell is fully replaced by our own, which edits the
159
+ // shared filter tree instead of MUI's filter model.
160
+ if (col.filterable !== false && col.type !== "actions") {
161
+ col.renderHeaderFilter = (props) => <AfHeaderFilterCell {...props} dataObject={dataObject} />;
162
+ }
163
+
164
+ muiColumns.push(col);
165
+ }
166
+ }
167
+
168
+ return muiColumns;
169
+ }
170
+
171
+ const GridActionItem = React.forwardRef<
172
+ HTMLButtonElement,
173
+ GridActionsCellItemProps & {
174
+ rowId: GridRowId;
175
+ mode: GridRowModes;
176
+ dataObject: DataObject<any>;
177
+ requireUpdate?: boolean;
178
+ requireDelete?: boolean;
179
+ }
180
+ >(function GridActionItem({ dataObject, mode, rowId, requireDelete = false, requireUpdate = false, ...props }, ref) {
181
+ let apiRef = useGridApiContext();
182
+ let rowModesModel = useGridSelector(apiRef, gridEditRowsStateSelector);
183
+ let { allowUpdate, allowDelete } = usePermissions(dataObject);
184
+
185
+ let currentMode = typeof rowModesModel[rowId] === "undefined" ? GridRowModes.View : GridRowModes.Edit;
186
+
187
+ if (currentMode !== mode) {
188
+ return null;
189
+ }
190
+
191
+ if (requireDelete && !allowDelete) {
192
+ return null;
193
+ }
194
+
195
+ if (requireUpdate && !allowUpdate) {
196
+ return null;
197
+ }
198
+
199
+ return <GridActionsCellItem ref={ref} {...props} />;
200
+ });
201
+
202
+ export type AfGridColumn = string | AfGridColDef;
203
+ const muiColumnTypes = ["string", "number", "date", "dateTime", "boolean", "singleSelect", "actions"];
204
+
205
+ export type useAfColumnsOptions = {
206
+ apiRef: React.RefObject<GridApi | null>;
207
+ disableDelete?: boolean;
208
+ disableEdit?: boolean;
209
+ dataObject: DataObject<any>;
210
+ columnsProp: AfGridColumn[];
211
+ idField: string;
212
+ };
213
+
214
+ export function useAfColumns({
215
+ apiRef,
216
+ dataObject,
217
+ disableEdit,
218
+ disableDelete,
219
+ columnsProp,
220
+ idField,
221
+ }: useAfColumnsOptions): GridColDef[] {
222
+ let { allowUpdate, allowDelete } = usePermissions(dataObject);
223
+
224
+ return useMemo(() => {
225
+ let columns = afColumnsToMuiColumn({
226
+ allowDelete,
227
+ allowUpdate,
228
+ apiRef,
229
+ dataObject,
230
+ disableEdit,
231
+ disableDelete,
232
+ columnsProp,
233
+ idField,
234
+ });
235
+
236
+ const handleEditClick = (id: GridRowId) => (event: React.MouseEvent<any>) => {
237
+ event.stopPropagation();
238
+ apiRef.current?.startRowEditMode({ id });
239
+ };
240
+
241
+ const handleSaveClick = (id: GridRowId) => async (event: React.MouseEvent<any>) => {
242
+ event.stopPropagation();
243
+ apiRef.current?.stopRowEditMode({ id });
244
+ };
245
+
246
+ const handleDeleteClick = (id: GridRowId) => async (event: React.MouseEvent<any>) => {
247
+ event.stopPropagation();
248
+ await deleteRow({ apiRef, dataObject, id, idField });
249
+ };
250
+
251
+ const handleCancelClick = (id: GridRowId) => (event: React.MouseEvent<any>) => {
252
+ event.stopPropagation();
253
+ apiRef.current?.stopRowEditMode({ id, ignoreModifications: true });
254
+
255
+ const row = apiRef.current?.getRow(id);
256
+ if (row?.isNew) {
257
+ apiRef.current?.updateRows([{ [(af as any).data.uid]: id, id, _action: "delete" } as any]);
258
+ }
259
+ };
260
+
261
+ if (!columns.find((c) => c.type === "actions") && (allowDelete || allowUpdate)) {
262
+ columns.push({
263
+ field: "actions",
264
+ type: "actions",
265
+ 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
+ ],
308
+ });
309
+ }
310
+
311
+ return columns;
312
+ }, [columnsProp, disableDelete, disableEdit, allowUpdate, allowDelete, idField, dataObject, apiRef]);
313
+ }
@@ -0,0 +1,55 @@
1
+ import type { GridApi, GridCellParams } from "@mui/x-data-grid-pro";
2
+ import type { DataObject } from "@olenbetong/appframe-data";
3
+ import { useEvent } from "@olenbetong/appframe-react";
4
+
5
+ import { useEffect, useRef } from "react";
6
+
7
+ export type useAfCurrentIndexOptions = {
8
+ apiRef: React.RefObject<GridApi | null>;
9
+ dataObject: DataObject<any>;
10
+ enabled: boolean;
11
+ idField: string;
12
+ };
13
+
14
+ function getRowId(row: any, idField: string) {
15
+ if (!row) return row;
16
+ return row[af.data.uid] ?? row[idField];
17
+ }
18
+
19
+ export function useAfCurrentIndex({ apiRef, dataObject, idField, enabled }: useAfCurrentIndexOptions) {
20
+ let triggeredByFocusEvent = useRef(false);
21
+ let focusColumn = useRef<string>(null);
22
+
23
+ useEffect(() => {
24
+ function handleFocusChange(options: GridCellParams) {
25
+ focusColumn.current = options.field;
26
+
27
+ let currentId = getRowId(dataObject.currentRow(), idField);
28
+ if (currentId !== options.id) {
29
+ let newIndex = dataObject.findIndex((row) => getRowId(row, idField) === options.id);
30
+ if (newIndex >= 0) {
31
+ triggeredByFocusEvent.current = true;
32
+ dataObject.setCurrentIndex(newIndex);
33
+ }
34
+ }
35
+ }
36
+
37
+ if (enabled) {
38
+ if (!focusColumn.current) {
39
+ focusColumn.current = apiRef.current?.getVisibleColumns()[0]?.field ?? null;
40
+ }
41
+
42
+ return apiRef.current?.subscribeEvent("cellFocusIn", handleFocusChange);
43
+ }
44
+ }, [apiRef, dataObject, idField, enabled]);
45
+
46
+ useEvent(dataObject, "onCurrentIndexChanged", (event) => {
47
+ if (enabled && triggeredByFocusEvent.current === false) {
48
+ if (focusColumn.current && document.activeElement?.closest(".MuiDataGrid-cell")) {
49
+ apiRef.current?.setCellFocus(getRowId(dataObject.getData(event.detail), idField), focusColumn.current);
50
+ }
51
+ } else {
52
+ triggeredByFocusEvent.current = false;
53
+ }
54
+ });
55
+ }
@@ -0,0 +1,20 @@
1
+ import type { DataObject } from "@olenbetong/appframe-data";
2
+ import { useData } from "@olenbetong/appframe-react";
3
+
4
+ import { useMemo } from "react";
5
+
6
+ export function useAfData<T extends Record<string, unknown>, K = T[]>(
7
+ dataObject: DataObject<T>,
8
+ { getData }: { getData?: (data: T[]) => K },
9
+ ): K {
10
+ let rawData = useData(dataObject);
11
+ let data = useMemo(() => {
12
+ if (getData) {
13
+ return getData(rawData as T[]) as K;
14
+ }
15
+
16
+ return rawData as K;
17
+ }, [rawData, getData]);
18
+
19
+ return data;
20
+ }