@olenbetong/appframe-ds 0.8.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 (67) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/package.json +50 -6
  3. package/scripts/copyAssets.mjs +13 -0
  4. package/src/AfLookup/LookupCombobox.tsx +3 -1
  5. package/src/filter/ChipListInput.tsx +79 -0
  6. package/src/filter/FieldFilterPanel.css +127 -0
  7. package/src/filter/FieldFilterPanel.tsx +309 -0
  8. package/src/filter/FilterBuilder.css +152 -0
  9. package/src/filter/FilterBuilder.test.tsx +153 -0
  10. package/src/filter/FilterBuilder.tsx +435 -0
  11. package/src/filter/FilterEditor.css +231 -0
  12. package/src/filter/FilterEditor.test.tsx +259 -0
  13. package/src/filter/FilterEditor.tsx +74 -0
  14. package/src/filter/FilterGroupEditor.tsx +160 -0
  15. package/src/filter/FilterNameDialog.css +22 -0
  16. package/src/filter/FilterNameDialog.tsx +81 -0
  17. package/src/filter/FilterRow.tsx +117 -0
  18. package/src/filter/FilterShareDialog.css +28 -0
  19. package/src/filter/FilterShareDialog.tsx +201 -0
  20. package/src/filter/FilterStringField.tsx +77 -0
  21. package/src/filter/FilterValueEditor.tsx +220 -0
  22. package/src/filter/SavedFilterTree.css +107 -0
  23. package/src/filter/SavedFilterTree.test.tsx +94 -0
  24. package/src/filter/SavedFilterTree.tsx +222 -0
  25. package/src/filter/fieldFilter.test.ts +158 -0
  26. package/src/filter/fieldFilter.ts +139 -0
  27. package/src/filter/index.ts +27 -0
  28. package/src/filter/types.ts +38 -0
  29. package/src/filter/useDistinctValues.test.ts +102 -0
  30. package/src/filter/useDistinctValues.ts +230 -0
  31. package/src/global.d.ts +11 -0
  32. package/src/grid/AfGridColumnsPanel.tsx +118 -0
  33. package/src/grid/AfGridContext.tsx +51 -0
  34. package/src/grid/AfGridError.tsx +46 -0
  35. package/src/grid/AfHeaderFilterCell.tsx +151 -0
  36. package/src/grid/AfHeaderFilterPanel.tsx +111 -0
  37. package/src/grid/Toolbar.tsx +233 -0
  38. package/src/grid/editing.ts +90 -0
  39. package/src/grid/filter.ts +78 -0
  40. package/src/grid/formatters.ts +48 -0
  41. package/src/grid/index.css +187 -0
  42. package/src/grid/index.tsx +376 -0
  43. package/src/grid/license.ts +48 -0
  44. package/src/grid/localization.ts +369 -0
  45. package/src/grid/slots/index.tsx +275 -0
  46. package/src/grid/slots/slots.css +53 -0
  47. package/src/grid/theme.tsx +185 -0
  48. package/src/grid/useAfColumns.tsx +313 -0
  49. package/src/grid/useAfCurrentIndex.ts +55 -0
  50. package/src/grid/useAfData.ts +20 -0
  51. package/src/grid/useAfFilter.ts +160 -0
  52. package/src/grid/useAfFilterFields.ts +55 -0
  53. package/src/grid/useAfGridApi.ts +66 -0
  54. package/src/grid/useAfKeyBindings.ts +36 -0
  55. package/src/grid/useAfPagination.ts +53 -0
  56. package/src/grid/useAfPersistedState.ts +222 -0
  57. package/src/grid/useAfRowEditModel.ts +137 -0
  58. package/src/grid/useAfSortModel.ts +72 -0
  59. package/src/index.ts +1 -0
  60. package/src/input/InputAdornments.tsx +6 -1
  61. package/src/report-downloader/ReportDownloader.css +67 -0
  62. package/src/report-downloader/components/StatusItem.tsx +86 -0
  63. package/src/report-downloader/components/StatusList.tsx +28 -0
  64. package/src/report-downloader/index.ts +41 -0
  65. package/src/report-downloader/status.tsx +92 -0
  66. package/src/test/setup.ts +8 -0
  67. package/vitest.config.ts +10 -0
@@ -0,0 +1,160 @@
1
+ import {
2
+ countExpressions,
3
+ createEmptyFilter,
4
+ type FieldMetadata,
5
+ type FilterExpression,
6
+ type FilterTree,
7
+ pruneFilter,
8
+ toFilterTree,
9
+ } from "@olenbetong/appframe-core";
10
+ import type { DataObject, Filter } from "@olenbetong/appframe-data";
11
+ import { deepEqual, useDebounce, useNamedFilter } from "@olenbetong/appframe-react";
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
+
14
+ import { getFieldExpressions, setFieldExpressions } from "../filter/fieldFilter.js";
15
+
16
+ /**
17
+ * The grid's filter, shared by the header filters and the filter builder.
18
+ *
19
+ * `filter` is the tree being edited and may contain incomplete criteria; only
20
+ * the pruned tree is ever sent to the server.
21
+ */
22
+ export type AfFilterApi = {
23
+ /** The filter tree currently being edited. */
24
+ filter: FilterTree;
25
+ /** Replaces the whole tree. Applied to the data object after a short debounce. */
26
+ setFilter: (filter: FilterTree) => void;
27
+ /** The field's top level criteria. See `getFieldExpressions`. */
28
+ getFieldCriteria: (field: string) => FilterExpression[];
29
+ /** Replaces the field's top level criteria. */
30
+ setFieldCriteria: (field: string, expressions: FilterExpression[]) => void;
31
+ /** Number of criteria in the whole tree, shown on the toolbar filter button. */
32
+ filterCount: number;
33
+ };
34
+
35
+ /**
36
+ * Finds the grid's own named slice within a (possibly combined) filter object.
37
+ * Falls back to the whole filter object when no named slice is present, which
38
+ * preserves the behaviour for filter objects set without a name.
39
+ */
40
+ function extractNamedSlice(filter: Filter, name: string): Filter {
41
+ if (!filter) return null;
42
+
43
+ if (filter.type === "group") {
44
+ if (filter.name === name) {
45
+ return filter;
46
+ }
47
+
48
+ let named = filter.items.find((item) => item.type === "group" && item.name === name);
49
+ if (named) {
50
+ return named;
51
+ }
52
+
53
+ let anyNamed = filter.items.some((item) => item.type === "group" && item.name != null);
54
+ if (anyNamed) {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ return filter;
60
+ }
61
+
62
+ /**
63
+ * Owns the grid's filter as a single `FilterTree` in one named filter slot.
64
+ *
65
+ * Both the header filters and the toolbar's filter builder edit this tree, so
66
+ * a criterion added in one is immediately visible in the other. MUI's own
67
+ * `GridFilterModel` is not used as a transport: it cannot represent nested
68
+ * groups or the full Appframe operator set, so mirroring the tree into it would
69
+ * lose information.
70
+ *
71
+ * @param dataObject Data object the filter is applied to
72
+ * @param id The grid id, used as the named filter slot name
73
+ * @param autoLoad Whether the data object should load if it has no data yet
74
+ */
75
+ export function useAfFilter({
76
+ autoLoad,
77
+ dataObject,
78
+ id,
79
+ }: {
80
+ autoLoad: boolean;
81
+ dataObject: DataObject<any>;
82
+ id: string;
83
+ }): AfFilterApi {
84
+ let defaultFilter = useMemo(
85
+ () => extractNamedSlice(dataObject.getParameter("filterObject") ?? null, id),
86
+ [dataObject, id],
87
+ );
88
+
89
+ // The named slot survives unmounting, so a grid that is navigated away from
90
+ // and back keeps its filter. `defaultFilter` only seeds the slot the first
91
+ // time it is registered.
92
+ let [applied, setApplied] = useNamedFilter(dataObject, id, {
93
+ type: "filterObject",
94
+ autoLoad,
95
+ defaultFilter,
96
+ });
97
+
98
+ let [filter, setFilter] = useState<FilterTree>(() => nameRoot(toFilterTree(applied), id));
99
+
100
+ // Applying on every keystroke would refresh the data object per character.
101
+ let debouncedFilter = useDebounce(filter, 400);
102
+ // The last filter the tree and the data object agreed on, so each direction
103
+ // of the sync below can tell a real change from an echo of its own change.
104
+ let syncedFilter = useRef<Filter>(applied);
105
+
106
+ // Tree -> data object
107
+ useEffect(() => {
108
+ let pruned = pruneFilter(debouncedFilter);
109
+ let next = pruned ? nameRoot(pruned, id) : null;
110
+
111
+ if (deepEqual(next, syncedFilter.current)) return;
112
+
113
+ syncedFilter.current = next;
114
+ setApplied(next);
115
+ }, [debouncedFilter, setApplied, id]);
116
+
117
+ // Data object -> tree, for filters changed by something other than the grid
118
+ useEffect(() => {
119
+ if (deepEqual(applied, syncedFilter.current)) return;
120
+
121
+ syncedFilter.current = applied;
122
+ setFilter(nameRoot(toFilterTree(applied), id));
123
+ }, [applied, id]);
124
+
125
+ let getFieldCriteria = useCallback((field: string) => getFieldExpressions(filter, field), [filter]);
126
+
127
+ let setFieldCriteria = useCallback(
128
+ (field: string, expressions: FilterExpression[]) =>
129
+ setFilter((current) => setFieldExpressions(current, field, expressions)),
130
+ [],
131
+ );
132
+
133
+ return useMemo(
134
+ () => ({
135
+ filter,
136
+ setFilter,
137
+ getFieldCriteria,
138
+ setFieldCriteria,
139
+ filterCount: countExpressions(filter),
140
+ }),
141
+ [filter, getFieldCriteria, setFieldCriteria],
142
+ );
143
+ }
144
+
145
+ /**
146
+ * The root group carries the slot name so a filter read back from the data
147
+ * object's combined `filterObject` can be recognised as this grid's.
148
+ */
149
+ function nameRoot(filter: FilterTree | null | undefined, id: string): FilterTree {
150
+ let tree = filter ?? createEmptyFilter();
151
+
152
+ return tree.name === id ? tree : { ...tree, name: id };
153
+ }
154
+
155
+ /** Looks up the metadata for a field, matching case-insensitively. */
156
+ export function findFilterField(fields: FieldMetadata[], name: string): FieldMetadata | undefined {
157
+ let lowerName = name.toLowerCase();
158
+
159
+ return fields.find((field) => field.name.toLowerCase() === lowerName);
160
+ }
@@ -0,0 +1,55 @@
1
+ import type { FieldMetadata, FilterFieldType } from "@olenbetong/appframe-core";
2
+ import type { DataObject } from "@olenbetong/appframe-data";
3
+ import { useMemo } from "react";
4
+
5
+ import type { AfGridColumn } from "./useAfColumns.js";
6
+
7
+ /** Data object field types that do not map onto a filter builder field type. */
8
+ const FIELD_TYPES: Record<string, FilterFieldType> = {
9
+ bigint: "number",
10
+ boolean: "boolean",
11
+ date: "date",
12
+ datetime: "datetime",
13
+ number: "number",
14
+ string: "string",
15
+ time: "string",
16
+ uniqueidentifier: "uniqueidentifier",
17
+ };
18
+
19
+ function toFilterFieldType(type: string | undefined): FilterFieldType {
20
+ return FIELD_TYPES[type?.toLowerCase() ?? ""] ?? "string";
21
+ }
22
+
23
+ /**
24
+ * The fields the filter builder offers, derived from the data object's own
25
+ * field definitions. Column definitions only contribute their header name as
26
+ * the caption, and `filterColumn` remaps a column onto the field it filters on.
27
+ *
28
+ * Fields that no column refers to are still offered, so the user can filter on
29
+ * data that is not displayed — the same as the Windows client.
30
+ */
31
+ export function useAfFilterFields(dataObject: DataObject<any>, columns: AfGridColumn[]): FieldMetadata[] {
32
+ return useMemo(() => {
33
+ let captions = new Map<string, string>();
34
+
35
+ for (let column of columns) {
36
+ if (typeof column === "string") continue;
37
+
38
+ let field = column.filterColumn ?? column.field;
39
+
40
+ if (column.filterable === false && !column.filterColumn) continue;
41
+ if (column.headerName) captions.set(field.toLowerCase(), column.headerName);
42
+ }
43
+
44
+ return dataObject
45
+ .getFields()
46
+ .filter((field) => !!field.name)
47
+ .map((field) => ({
48
+ name: field.name,
49
+ caption: captions.get(field.name.toLowerCase()) ?? field.caption ?? field.name,
50
+ type: toFilterFieldType(field.type),
51
+ nullable: field.nullable !== false,
52
+ }))
53
+ .sort((a, b) => a.caption.localeCompare(b.caption));
54
+ }, [dataObject, columns]);
55
+ }
@@ -0,0 +1,66 @@
1
+ import type { GridApi } from "@mui/x-data-grid-pro";
2
+ import type { DataObject } from "@olenbetong/appframe-data";
3
+
4
+ import type React from "react";
5
+ import { useCallback, useRef } from "react";
6
+
7
+ import type { AfGridProps } from "./index.js";
8
+ import { afColumnsToMuiColumn, getColumnVisibilityModel } from "./useAfColumns.js";
9
+ import { afSortOrderToMuiSortModel } from "./useAfSortModel.js";
10
+
11
+ export interface AfGridApi extends GridApi {
12
+ getDataObject: () => DataObject<any> | undefined;
13
+ resetLayout: () => void;
14
+ }
15
+
16
+ export function useAfGridApi(props: Partial<AfGridProps>, apiRef: React.RefObject<GridApi | null>) {
17
+ let propsRef = useRef<Partial<AfGridProps>>(props);
18
+ propsRef.current = props;
19
+
20
+ let getDataObject = useCallback(() => propsRef.current.dataObject, []);
21
+ let resetLayout = useCallback(() => {
22
+ if (propsRef.current.dataObject) {
23
+ let { disableDelete, disableEdit, idField = "PrimKey" } = propsRef.current;
24
+ apiRef.current?.updateColumns(
25
+ afColumnsToMuiColumn({
26
+ allowDelete: propsRef.current.dataObject.isDeleteAllowed(),
27
+ allowUpdate: propsRef.current.dataObject.isUpdateAllowed(),
28
+ disableDelete,
29
+ disableEdit,
30
+ idField,
31
+ apiRef,
32
+ columnsProp: propsRef.current.columns ?? [],
33
+ dataObject: propsRef.current.dataObject,
34
+ }),
35
+ );
36
+
37
+ if (propsRef.current.columns) {
38
+ propsRef.current.columns.forEach((column, index) => {
39
+ let field = typeof column === "string" ? column : column.field;
40
+ let width = typeof column === "string" ? 150 : (column.width ?? 150);
41
+ apiRef.current?.setColumnIndex(field, index);
42
+ apiRef.current?.setColumnWidth(field, width);
43
+ });
44
+ apiRef.current?.setColumnVisibilityModel({
45
+ ...getColumnVisibilityModel(propsRef.current.columns),
46
+ ...propsRef.current?.initialState?.columns?.columnVisibilityModel,
47
+ });
48
+ }
49
+
50
+ apiRef.current?.setDensity(propsRef.current.initialState?.density ?? "compact");
51
+ apiRef.current?.setPageSize(propsRef.current.dataObject.options.parameters.maxRecords ?? 100);
52
+ apiRef.current?.setSortModel(
53
+ afSortOrderToMuiSortModel(
54
+ propsRef.current.dataObject,
55
+ propsRef.current.dataObject.options.parameters.sortOrder,
56
+ ),
57
+ );
58
+ }
59
+ apiRef.current?.setPinnedColumns(propsRef.current.initialState?.pinnedColumns ?? {});
60
+ }, [apiRef]);
61
+
62
+ if (apiRef.current) {
63
+ (apiRef.current as AfGridApi).getDataObject = getDataObject;
64
+ (apiRef.current as AfGridApi).resetLayout = resetLayout;
65
+ }
66
+ }
@@ -0,0 +1,36 @@
1
+ import type { GridApi } from "@mui/x-data-grid-pro";
2
+ import type { DataObject } from "@olenbetong/appframe-data";
3
+
4
+ import type React from "react";
5
+ import { useCallback } from "react";
6
+
7
+ import { addRow } from "./editing.js";
8
+
9
+ export function useAfKeyBindings<T extends { PrimKey: string }>({
10
+ apiRef,
11
+ dataObject,
12
+ }: {
13
+ apiRef: React.RefObject<GridApi | null>;
14
+ dataObject: DataObject<T>;
15
+ }): Pick<React.HTMLProps<Element>, "onKeyDown" | "onKeyDownCapture" | "onKeyUp" | "onKeyUpCapture"> {
16
+ // oxlint-disable-next-line react-hooks/exhaustive-deps -- RefObject does not need to be in the dependency array
17
+ let onKeyUpCapture = useCallback(
18
+ (event: React.KeyboardEvent) => {
19
+ let handled = false;
20
+ if (["n", "N"].includes(event.key) && event.altKey && !event.shiftKey && !event.ctrlKey) {
21
+ handled = true;
22
+ addRow({ apiRef, dataObject });
23
+ }
24
+
25
+ if (handled) {
26
+ event.preventDefault();
27
+ event.stopPropagation();
28
+ }
29
+ },
30
+ [dataObject],
31
+ );
32
+
33
+ return {
34
+ onKeyUpCapture,
35
+ };
36
+ }
@@ -0,0 +1,53 @@
1
+ import type { DataGridProProps, GridPaginationModel } from "@mui/x-data-grid-pro";
2
+ import type { DataObject } from "@olenbetong/appframe-data";
3
+ import { useDataLength, usePaging, useParameter } from "@olenbetong/appframe-react";
4
+
5
+ import { useMemo } from "react";
6
+
7
+ /**
8
+ * If the data object is dynamic, returns the data grid properties needed
9
+ * to bind the pagination to the data object.
10
+ *
11
+ * @param dataObject Data object grid is used with
12
+ */
13
+ export function useAfPagination(dataObject: DataObject<any>): Partial<DataGridProProps> {
14
+ let paging = usePaging(dataObject);
15
+ let maxRecords = useParameter(dataObject, "maxRecords");
16
+ let dataLength = useDataLength(dataObject);
17
+
18
+ let paginationModel = useMemo(
19
+ () => ({
20
+ pageSize: maxRecords ?? 50,
21
+ page: paging.page || 0,
22
+ }),
23
+ [paging.page, maxRecords],
24
+ );
25
+
26
+ if (dataObject.isDynamicLoading()) {
27
+ return {
28
+ pagination: true,
29
+ paginationMode: "server",
30
+ paginationModel,
31
+ rowCount: dataLength,
32
+ pageSizeOptions: [10, 25, 50, 100, 250, 500, 1_000, 10_000],
33
+ onPaginationModelChange(newModel: GridPaginationModel) {
34
+ let newPageSize = newModel.pageSize ?? paginationModel.pageSize ?? 50;
35
+ if (newPageSize !== maxRecords) {
36
+ dataObject.setParameter("maxRecords", newPageSize);
37
+ // We don't want the pagination setup to load data when the grid is not set up to auto load
38
+ // data. To prevent that, we only refresh data if it is already loaded/loading.
39
+ if (dataObject.isDataLoaded() || dataObject.isDataLoading()) {
40
+ dataObject.refreshDataSource();
41
+ }
42
+ }
43
+
44
+ let newPage = typeof newModel.page === "number" && !Number.isNaN(newModel.page) ? newModel.page : paging.page;
45
+ if (newPage !== paging.page) {
46
+ paging.changePage(newPage);
47
+ }
48
+ },
49
+ };
50
+ }
51
+
52
+ return {};
53
+ }
@@ -0,0 +1,222 @@
1
+ import type { GridApi, GridInitialState, GridPaginationModel } from "@mui/x-data-grid-pro";
2
+
3
+ import localforage from "localforage";
4
+ import { useEffect, useState } from "react";
5
+
6
+ import type { AfGridProps } from "./index.js";
7
+ import { getColumnVisibilityModel } from "./useAfColumns.js";
8
+
9
+ const settingsStore = localforage.createInstance({
10
+ name: `AfGridLayouts.${globalThis.af?.userSession?.login ?? "anonymous"}`,
11
+ });
12
+
13
+ type GridSettings = {
14
+ layoutVersion: string | number;
15
+ state: GridInitialState;
16
+ };
17
+
18
+ const settingsCache: Record<string, GridSettings> = {};
19
+
20
+ async function persistState(
21
+ key: string,
22
+ layoutVersion: string | number,
23
+ state: GridInitialState | null | undefined,
24
+ config?: StatePersistenceConfig,
25
+ ) {
26
+ if (state) {
27
+ state = structuredClone(state);
28
+
29
+ let settings: GridSettings = {
30
+ layoutVersion,
31
+ state: removeNonPersistedState(state, config),
32
+ };
33
+
34
+ if (state.pagination?.paginationModel?.page !== undefined) {
35
+ delete state.pagination.paginationModel.page;
36
+ }
37
+
38
+ settingsCache[key] = settings;
39
+ await settingsStore.setItem<GridSettings>(key, settings);
40
+ }
41
+ }
42
+
43
+ function removeNonPersistedState(state: GridInitialState, config?: StatePersistenceConfig): GridInitialState {
44
+ const newState: GridInitialState = {};
45
+ let columns: GridInitialState["columns"] = {};
46
+ let filter: GridInitialState["filter"] = {};
47
+ let pagination: GridInitialState["pagination"] = { paginationModel: {} };
48
+ let sorting: GridInitialState["sorting"] = {};
49
+
50
+ // Persist the relevant state unless explicitly disabled in the config
51
+ if (config?.density !== false) {
52
+ newState.density = state.density;
53
+ }
54
+ if (config?.pinnedColumns !== false) {
55
+ newState.pinnedColumns = state.pinnedColumns;
56
+ }
57
+ if (config?.columns?.columnVisibilityModel !== false) {
58
+ columns.columnVisibilityModel = state.columns?.columnVisibilityModel;
59
+ }
60
+ if (config?.columns?.dimensions !== false) {
61
+ columns.dimensions = state.columns?.dimensions;
62
+ }
63
+ if (config?.columns?.orderedFields !== false) {
64
+ columns.orderedFields = state.columns?.orderedFields;
65
+ }
66
+ if (config?.pagination?.paginationModel.pageSize !== false) {
67
+ (pagination.paginationModel as Partial<GridPaginationModel>).pageSize = state.pagination?.paginationModel?.pageSize;
68
+ }
69
+ if (config?.preferencePanel === true) {
70
+ newState.preferencePanel = state.preferencePanel;
71
+ }
72
+
73
+ // Sort model, current page and filter model are not persisted by default
74
+ if (config?.sorting?.sortModel === true) {
75
+ sorting.sortModel = state.sorting?.sortModel;
76
+ }
77
+ if (config?.pagination?.paginationModel.page === true) {
78
+ (pagination.paginationModel as Partial<GridPaginationModel>).page = state.pagination?.paginationModel?.page;
79
+ }
80
+ if (config?.filter?.filterModel === true) {
81
+ filter.filterModel = state.filter?.filterModel;
82
+ }
83
+
84
+ return {
85
+ ...newState,
86
+ columns,
87
+ filter,
88
+ pagination,
89
+ sorting,
90
+ };
91
+ }
92
+
93
+ let isLoaded = false;
94
+ export const isPersistedStateLoaded = () => isLoaded;
95
+
96
+ type GetInitialStateProps = Pick<AfGridProps, "id" | "layoutVersion" | "columns" | "initialState" | "persistenceModel">;
97
+
98
+ function getInitialState({ columns, persistenceModel, id, initialState, layoutVersion }: GetInitialStateProps) {
99
+ let key = `${af.article.id}.${id}`;
100
+ let persistedState = settingsCache[key];
101
+
102
+ let state: GridInitialState =
103
+ persistedState?.layoutVersion === layoutVersion
104
+ ? removeNonPersistedState(persistedState.state, persistenceModel)
105
+ : {
106
+ density: "compact",
107
+ ...initialState,
108
+ };
109
+
110
+ if (!state.columns) {
111
+ state.columns = {};
112
+ }
113
+
114
+ if (!state.columns.columnVisibilityModel) {
115
+ state.columns.columnVisibilityModel = getColumnVisibilityModel(columns);
116
+ }
117
+
118
+ if (!state.columns.orderedFields) {
119
+ state.columns.orderedFields = columns
120
+ .filter((c) => typeof c === "string" || !c.hide)
121
+ .map((c) => (typeof c === "string" ? c : c.field));
122
+ } else {
123
+ // Append any columns added since the layout was saved that aren't already present
124
+ let known = new Set(state.columns.orderedFields);
125
+ for (let c of columns) {
126
+ let field = typeof c === "string" ? c : c.field;
127
+ let hidden = typeof c !== "string" && c.hide;
128
+ if (!hidden && !known.has(field)) {
129
+ state.columns.orderedFields.push(field);
130
+ }
131
+ }
132
+ }
133
+
134
+ return state;
135
+ }
136
+
137
+ // Load all article grid layouts even if the grid is not mounted yet.
138
+ // This ways the persisted state can be restored faster after the initial
139
+ // page load.
140
+ export const settingsLoaded = settingsStore
141
+ .keys()
142
+ .then((keys) =>
143
+ Promise.allSettled(
144
+ keys
145
+ .filter((key) => key.startsWith(af.article.id))
146
+ .map((key) =>
147
+ settingsStore.getItem<GridSettings>(key).then((settings) => {
148
+ if (settings && !settingsCache[key]) {
149
+ settingsCache[key] = settings;
150
+ }
151
+ }),
152
+ ),
153
+ ),
154
+ )
155
+ .then(() => {
156
+ isLoaded = true;
157
+ });
158
+
159
+ // Clean legacy state from local storage
160
+ setTimeout(() => {
161
+ for (let key of Object.keys(localStorage)) {
162
+ if (key.includes("AfGrid")) {
163
+ localStorage.removeItem(key);
164
+ }
165
+ }
166
+ }, 2000);
167
+
168
+ export type StatePersistenceConfig = {
169
+ density?: boolean;
170
+ pinnedColumns?: boolean;
171
+ columns?: {
172
+ columnVisibilityModel?: boolean;
173
+ dimensions?: boolean;
174
+ orderedFields?: boolean;
175
+ };
176
+ filter?: {
177
+ filterModel?: boolean;
178
+ };
179
+ pagination?: {
180
+ paginationModel: {
181
+ page?: boolean;
182
+ pageSize?: boolean;
183
+ };
184
+ };
185
+ preferencePanel?: boolean;
186
+ sorting?: {
187
+ sortModel: boolean;
188
+ };
189
+ };
190
+
191
+ /**
192
+ * On mount, load the persisted state from Indexed DB, and if the persisted
193
+ * layout version matches, restore the layout. Then listen to changes to the
194
+ * state, and store the latest version in Indexed DB.
195
+ */
196
+ export function useAfPersistedState(
197
+ props: { apiRef: React.RefObject<GridApi | null> } & Pick<
198
+ AfGridProps,
199
+ "id" | "layoutVersion" | "columns" | "initialState" | "persistenceModel"
200
+ >,
201
+ ): GridInitialState {
202
+ let { apiRef, columns, id, initialState, layoutVersion = 0, persistenceModel } = props;
203
+ let [initialState_] = useState(() => getInitialState({ columns, persistenceModel, id, initialState, layoutVersion }));
204
+
205
+ useEffect(() => {
206
+ let key = `${af.article.id}.${id}`;
207
+ let persistTimeout: any;
208
+
209
+ return apiRef.current?.subscribeEvent("stateChange", () => {
210
+ // Debounce the save, as state changes can be very frequent.
211
+ clearTimeout(persistTimeout);
212
+ persistTimeout = setTimeout(() => {
213
+ // Use optional chaining, as the component may unmount before the timeout
214
+ // fires.
215
+ let state = apiRef.current?.exportState();
216
+ persistState(key, layoutVersion, state, persistenceModel);
217
+ }, 300);
218
+ });
219
+ }, [apiRef, id, layoutVersion, persistenceModel]);
220
+
221
+ return initialState_;
222
+ }