@alaarab/ogrid-react 2.0.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/dist/esm/components/BaseInlineCellEditor.js +112 -0
- package/dist/esm/components/CellErrorBoundary.js +43 -0
- package/dist/esm/components/EmptyState.js +19 -0
- package/dist/esm/components/GridContextMenu.js +35 -0
- package/dist/esm/components/MarchingAntsOverlay.js +110 -0
- package/dist/esm/components/OGridLayout.js +91 -0
- package/dist/esm/components/SideBar.js +122 -0
- package/dist/esm/components/StatusBar.js +6 -0
- package/dist/esm/hooks/index.js +25 -0
- package/dist/esm/hooks/useActiveCell.js +62 -0
- package/dist/esm/hooks/useCellEditing.js +15 -0
- package/dist/esm/hooks/useCellSelection.js +327 -0
- package/dist/esm/hooks/useClipboard.js +161 -0
- package/dist/esm/hooks/useColumnChooserState.js +62 -0
- package/dist/esm/hooks/useColumnHeaderFilterState.js +180 -0
- package/dist/esm/hooks/useColumnResize.js +92 -0
- package/dist/esm/hooks/useContextMenu.js +21 -0
- package/dist/esm/hooks/useDataGridState.js +313 -0
- package/dist/esm/hooks/useDateFilterState.js +34 -0
- package/dist/esm/hooks/useDebounce.js +35 -0
- package/dist/esm/hooks/useFillHandle.js +195 -0
- package/dist/esm/hooks/useFilterOptions.js +40 -0
- package/dist/esm/hooks/useInlineCellEditorState.js +44 -0
- package/dist/esm/hooks/useKeyboardNavigation.js +419 -0
- package/dist/esm/hooks/useLatestRef.js +11 -0
- package/dist/esm/hooks/useMultiSelectFilterState.js +59 -0
- package/dist/esm/hooks/useOGrid.js +465 -0
- package/dist/esm/hooks/usePeopleFilterState.js +68 -0
- package/dist/esm/hooks/useRichSelectState.js +58 -0
- package/dist/esm/hooks/useRowSelection.js +80 -0
- package/dist/esm/hooks/useSideBarState.js +39 -0
- package/dist/esm/hooks/useTableLayout.js +77 -0
- package/dist/esm/hooks/useTextFilterState.js +25 -0
- package/dist/esm/hooks/useUndoRedo.js +83 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/storybook/index.js +1 -0
- package/dist/esm/storybook/mockData.js +73 -0
- package/dist/esm/types/columnTypes.js +1 -0
- package/dist/esm/types/dataGridTypes.js +1 -0
- package/dist/esm/types/index.js +1 -0
- package/dist/esm/utils/dataGridViewModel.js +220 -0
- package/dist/esm/utils/gridRowComparator.js +2 -0
- package/dist/esm/utils/index.js +5 -0
- package/dist/types/components/BaseInlineCellEditor.d.ts +33 -0
- package/dist/types/components/CellErrorBoundary.d.ts +25 -0
- package/dist/types/components/EmptyState.d.ts +26 -0
- package/dist/types/components/GridContextMenu.d.ts +18 -0
- package/dist/types/components/MarchingAntsOverlay.d.ts +15 -0
- package/dist/types/components/OGridLayout.d.ts +37 -0
- package/dist/types/components/SideBar.d.ts +30 -0
- package/dist/types/components/StatusBar.d.ts +24 -0
- package/dist/types/hooks/index.d.ts +48 -0
- package/dist/types/hooks/useActiveCell.d.ts +13 -0
- package/dist/types/hooks/useCellEditing.d.ts +16 -0
- package/dist/types/hooks/useCellSelection.d.ts +22 -0
- package/dist/types/hooks/useClipboard.d.ts +30 -0
- package/dist/types/hooks/useColumnChooserState.d.ts +27 -0
- package/dist/types/hooks/useColumnHeaderFilterState.d.ts +73 -0
- package/dist/types/hooks/useColumnResize.d.ts +23 -0
- package/dist/types/hooks/useContextMenu.d.ts +19 -0
- package/dist/types/hooks/useDataGridState.d.ts +137 -0
- package/dist/types/hooks/useDateFilterState.d.ts +19 -0
- package/dist/types/hooks/useDebounce.d.ts +9 -0
- package/dist/types/hooks/useFillHandle.d.ts +33 -0
- package/dist/types/hooks/useFilterOptions.d.ts +16 -0
- package/dist/types/hooks/useInlineCellEditorState.d.ts +24 -0
- package/dist/types/hooks/useKeyboardNavigation.d.ts +47 -0
- package/dist/types/hooks/useLatestRef.d.ts +6 -0
- package/dist/types/hooks/useMultiSelectFilterState.d.ts +24 -0
- package/dist/types/hooks/useOGrid.d.ts +52 -0
- package/dist/types/hooks/usePeopleFilterState.d.ts +25 -0
- package/dist/types/hooks/useRichSelectState.d.ts +22 -0
- package/dist/types/hooks/useRowSelection.d.ts +22 -0
- package/dist/types/hooks/useSideBarState.d.ts +20 -0
- package/dist/types/hooks/useTableLayout.d.ts +27 -0
- package/dist/types/hooks/useTextFilterState.d.ts +16 -0
- package/dist/types/hooks/useUndoRedo.d.ts +23 -0
- package/dist/types/index.d.ts +23 -0
- package/dist/types/storybook/index.d.ts +2 -0
- package/dist/types/storybook/mockData.d.ts +37 -0
- package/dist/types/types/columnTypes.d.ts +25 -0
- package/dist/types/types/dataGridTypes.d.ts +152 -0
- package/dist/types/types/index.d.ts +3 -0
- package/dist/types/utils/dataGridViewModel.d.ts +161 -0
- package/dist/types/utils/gridRowComparator.d.ts +2 -0
- package/dist/types/utils/index.d.ts +6 -0
- package/package.json +46 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { useMemo, useCallback, useState, useEffect, useRef, useImperativeHandle, } from 'react';
|
|
2
|
+
import { mergeFilter, deriveFilterOptionsFromData, getMultiSelectFilterFields, flattenColumns, processClientSideData, } from '../utils';
|
|
3
|
+
import { useFilterOptions } from './useFilterOptions';
|
|
4
|
+
import { useSideBarState } from './useSideBarState';
|
|
5
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
6
|
+
const EMPTY_LOADING_OPTIONS = {};
|
|
7
|
+
/**
|
|
8
|
+
* Top-level orchestration hook for OGrid: manages pagination, sorting, filtering, column visibility, and sidebar.
|
|
9
|
+
* @param props - All OGrid props (columns, data, callbacks, feature flags).
|
|
10
|
+
* @param ref - Forwarded ref for imperative API (refresh, export, applyColumnState).
|
|
11
|
+
* @returns Grouped props for DataGridTable, pagination controls, column chooser, layout, and filters.
|
|
12
|
+
*/
|
|
13
|
+
export function useOGrid(props, ref) {
|
|
14
|
+
const { columns: columnsProp, getRowId, data, dataSource, page: controlledPage, pageSize: controlledPageSize, sort: controlledSort, filters: controlledFilters, visibleColumns: controlledVisibleColumns, isLoading: controlledLoading, onPageChange, onPageSizeChange, onSortChange, onFiltersChange, onVisibleColumnsChange, columnOrder, onColumnOrderChange, onColumnResized, onColumnPinned, freezeRows, freezeCols, defaultPageSize = DEFAULT_PAGE_SIZE, defaultSortBy, defaultSortDirection = 'asc', toolbar, toolbarBelow, emptyState, entityLabelPlural = 'items', className, layoutMode = 'fill', suppressHorizontalScroll, editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo, rowSelection = 'none', selectedRows, onSelectionChange, statusBar, pageSizeOptions, sideBar, onFirstDataRendered, onError, columnChooser: columnChooserProp, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, } = props;
|
|
15
|
+
// Resolve column chooser placement
|
|
16
|
+
const columnChooserPlacement = columnChooserProp === false ? 'none'
|
|
17
|
+
: columnChooserProp === 'sidebar' ? 'sidebar'
|
|
18
|
+
: 'toolbar';
|
|
19
|
+
const columns = useMemo(() => flattenColumns(columnsProp), [columnsProp]);
|
|
20
|
+
const isServerSide = dataSource != null;
|
|
21
|
+
const isClientSide = !isServerSide;
|
|
22
|
+
const [internalData, setInternalData] = useState([]);
|
|
23
|
+
const [internalLoading, setInternalLoading] = useState(false);
|
|
24
|
+
const displayData = data ?? internalData;
|
|
25
|
+
const displayLoading = controlledLoading ?? internalLoading;
|
|
26
|
+
const defaultSortField = defaultSortBy ?? columns[0]?.columnId ?? '';
|
|
27
|
+
const [internalPage, setInternalPage] = useState(1);
|
|
28
|
+
const [internalPageSize, setInternalPageSize] = useState(defaultPageSize);
|
|
29
|
+
const [internalSort, setInternalSort] = useState({
|
|
30
|
+
field: defaultSortField,
|
|
31
|
+
direction: defaultSortDirection,
|
|
32
|
+
});
|
|
33
|
+
const [internalFilters, setInternalFilters] = useState({});
|
|
34
|
+
const [internalVisibleColumns, setInternalVisibleColumns] = useState(() => {
|
|
35
|
+
const visible = columns
|
|
36
|
+
.filter((c) => c.defaultVisible !== false)
|
|
37
|
+
.map((c) => c.columnId);
|
|
38
|
+
return new Set(visible.length > 0 ? visible : columns.map((c) => c.columnId));
|
|
39
|
+
});
|
|
40
|
+
const [columnWidthOverrides, setColumnWidthOverrides] = useState({});
|
|
41
|
+
const [pinnedOverrides, setPinnedOverrides] = useState({});
|
|
42
|
+
const page = controlledPage ?? internalPage;
|
|
43
|
+
const pageSize = controlledPageSize ?? internalPageSize;
|
|
44
|
+
const sort = controlledSort ?? internalSort;
|
|
45
|
+
const filters = controlledFilters ?? internalFilters;
|
|
46
|
+
const visibleColumns = controlledVisibleColumns ?? internalVisibleColumns;
|
|
47
|
+
const setPage = useCallback((p) => {
|
|
48
|
+
if (controlledPage === undefined)
|
|
49
|
+
setInternalPage(p);
|
|
50
|
+
onPageChange?.(p);
|
|
51
|
+
}, [controlledPage, onPageChange]);
|
|
52
|
+
const setPageSize = useCallback((size) => {
|
|
53
|
+
if (controlledPageSize === undefined)
|
|
54
|
+
setInternalPageSize(size);
|
|
55
|
+
onPageSizeChange?.(size);
|
|
56
|
+
setPage(1);
|
|
57
|
+
}, [controlledPageSize, onPageSizeChange, setPage]);
|
|
58
|
+
const setSort = useCallback((s) => {
|
|
59
|
+
if (controlledSort === undefined)
|
|
60
|
+
setInternalSort(s);
|
|
61
|
+
onSortChange?.(s);
|
|
62
|
+
setPage(1);
|
|
63
|
+
}, [controlledSort, onSortChange, setPage]);
|
|
64
|
+
const setFilters = useCallback((f) => {
|
|
65
|
+
if (controlledFilters === undefined)
|
|
66
|
+
setInternalFilters(f);
|
|
67
|
+
onFiltersChange?.(f);
|
|
68
|
+
setPage(1);
|
|
69
|
+
}, [controlledFilters, onFiltersChange, setPage]);
|
|
70
|
+
const setVisibleColumns = useCallback((cols) => {
|
|
71
|
+
if (controlledVisibleColumns === undefined)
|
|
72
|
+
setInternalVisibleColumns(cols);
|
|
73
|
+
onVisibleColumnsChange?.(cols);
|
|
74
|
+
}, [controlledVisibleColumns, onVisibleColumnsChange]);
|
|
75
|
+
const handleSort = useCallback((columnKey) => {
|
|
76
|
+
setSort({
|
|
77
|
+
field: columnKey,
|
|
78
|
+
direction: sort.field === columnKey && sort.direction === 'asc' ? 'desc' : 'asc',
|
|
79
|
+
});
|
|
80
|
+
}, [sort, setSort]);
|
|
81
|
+
/** Single filter change handler — wraps discriminated FilterValue into mergeFilter. */
|
|
82
|
+
const handleFilterChange = useCallback((key, value) => {
|
|
83
|
+
setFilters(mergeFilter(filters, key, value));
|
|
84
|
+
}, [filters, setFilters]);
|
|
85
|
+
const handleVisibilityChange = useCallback((columnKey, isVisible) => {
|
|
86
|
+
const next = new Set(visibleColumns);
|
|
87
|
+
if (isVisible)
|
|
88
|
+
next.add(columnKey);
|
|
89
|
+
else
|
|
90
|
+
next.delete(columnKey);
|
|
91
|
+
setVisibleColumns(next);
|
|
92
|
+
}, [visibleColumns, setVisibleColumns]);
|
|
93
|
+
const [internalSelectedRows, setInternalSelectedRows] = useState(new Set());
|
|
94
|
+
const effectiveSelectedRows = selectedRows ?? internalSelectedRows;
|
|
95
|
+
const handleSelectionChange = useCallback((event) => {
|
|
96
|
+
if (selectedRows === undefined) {
|
|
97
|
+
setInternalSelectedRows(new Set(event.selectedRowIds));
|
|
98
|
+
}
|
|
99
|
+
onSelectionChange?.(event);
|
|
100
|
+
}, [selectedRows, onSelectionChange]);
|
|
101
|
+
const multiSelectFilterFields = useMemo(() => getMultiSelectFilterFields(columns), [columns]);
|
|
102
|
+
const filterOptionsSource = useMemo(() => dataSource ?? { fetchFilterOptions: undefined }, [dataSource]);
|
|
103
|
+
const { filterOptions: serverFilterOptions, loadingOptions: loadingFilterOptions } = useFilterOptions(filterOptionsSource, multiSelectFilterFields);
|
|
104
|
+
const hasServerFilterOptions = dataSource?.fetchFilterOptions != null;
|
|
105
|
+
const clientFilterOptions = useMemo(() => {
|
|
106
|
+
if (hasServerFilterOptions)
|
|
107
|
+
return serverFilterOptions;
|
|
108
|
+
return deriveFilterOptionsFromData(displayData, columns);
|
|
109
|
+
}, [hasServerFilterOptions, displayData, columns, serverFilterOptions]);
|
|
110
|
+
// --- Client-side filtering & sorting ---
|
|
111
|
+
const clientItemsAndTotal = useMemo(() => {
|
|
112
|
+
if (!isClientSide)
|
|
113
|
+
return null;
|
|
114
|
+
const rows = processClientSideData(displayData, columns, filters, sort.field, sort.direction);
|
|
115
|
+
const total = rows.length;
|
|
116
|
+
const start = (page - 1) * pageSize;
|
|
117
|
+
const paged = rows.slice(start, start + pageSize);
|
|
118
|
+
return { items: paged, totalCount: total };
|
|
119
|
+
}, [
|
|
120
|
+
isClientSide,
|
|
121
|
+
displayData,
|
|
122
|
+
columns,
|
|
123
|
+
filters,
|
|
124
|
+
sort.field,
|
|
125
|
+
sort.direction,
|
|
126
|
+
page,
|
|
127
|
+
pageSize,
|
|
128
|
+
]);
|
|
129
|
+
const [serverItems, setServerItems] = useState([]);
|
|
130
|
+
const [serverTotalCount, setServerTotalCount] = useState(0);
|
|
131
|
+
const [loading, setLoading] = useState(true);
|
|
132
|
+
const fetchIdRef = useRef(0);
|
|
133
|
+
// Ref counter to trigger server-side re-fetches
|
|
134
|
+
const refreshCounterRef = useRef(0);
|
|
135
|
+
const [refreshCounter, setRefreshCounter] = useState(0);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!isServerSide || !dataSource) {
|
|
138
|
+
if (!isServerSide)
|
|
139
|
+
setLoading(false);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const id = ++fetchIdRef.current;
|
|
143
|
+
setLoading(true);
|
|
144
|
+
dataSource
|
|
145
|
+
.fetchPage({
|
|
146
|
+
page,
|
|
147
|
+
pageSize,
|
|
148
|
+
sort: { field: sort.field, direction: sort.direction },
|
|
149
|
+
filters,
|
|
150
|
+
})
|
|
151
|
+
.then((res) => {
|
|
152
|
+
if (id !== fetchIdRef.current)
|
|
153
|
+
return;
|
|
154
|
+
setServerItems(res.items);
|
|
155
|
+
setServerTotalCount(res.totalCount);
|
|
156
|
+
})
|
|
157
|
+
.catch((err) => {
|
|
158
|
+
if (id !== fetchIdRef.current)
|
|
159
|
+
return;
|
|
160
|
+
onError?.(err);
|
|
161
|
+
setServerItems([]);
|
|
162
|
+
setServerTotalCount(0);
|
|
163
|
+
})
|
|
164
|
+
.finally(() => {
|
|
165
|
+
if (id === fetchIdRef.current)
|
|
166
|
+
setLoading(false);
|
|
167
|
+
});
|
|
168
|
+
}, [
|
|
169
|
+
isServerSide,
|
|
170
|
+
dataSource,
|
|
171
|
+
page,
|
|
172
|
+
pageSize,
|
|
173
|
+
sort.field,
|
|
174
|
+
sort.direction,
|
|
175
|
+
filters,
|
|
176
|
+
onError,
|
|
177
|
+
refreshCounter,
|
|
178
|
+
]);
|
|
179
|
+
const displayItems = isClientSide && clientItemsAndTotal
|
|
180
|
+
? clientItemsAndTotal.items
|
|
181
|
+
: serverItems;
|
|
182
|
+
const displayTotalCount = isClientSide && clientItemsAndTotal
|
|
183
|
+
? clientItemsAndTotal.totalCount
|
|
184
|
+
: serverTotalCount;
|
|
185
|
+
// Fire onFirstDataRendered once when the grid first has data
|
|
186
|
+
const firstDataRenderedRef = useRef(false);
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
if (!firstDataRenderedRef.current && displayItems.length > 0) {
|
|
189
|
+
firstDataRenderedRef.current = true;
|
|
190
|
+
onFirstDataRendered?.();
|
|
191
|
+
}
|
|
192
|
+
}, [displayItems.length, onFirstDataRendered]);
|
|
193
|
+
useImperativeHandle(ref, () => ({
|
|
194
|
+
setRowData: (d) => {
|
|
195
|
+
if (!isServerSide)
|
|
196
|
+
setInternalData(d);
|
|
197
|
+
},
|
|
198
|
+
setLoading: setInternalLoading,
|
|
199
|
+
getColumnState: () => ({
|
|
200
|
+
visibleColumns: Array.from(visibleColumns),
|
|
201
|
+
sort,
|
|
202
|
+
columnOrder: columnOrder ?? undefined,
|
|
203
|
+
columnWidths: Object.keys(columnWidthOverrides).length > 0 ? columnWidthOverrides : undefined,
|
|
204
|
+
filters: Object.keys(filters).length > 0 ? filters : undefined,
|
|
205
|
+
pinnedColumns: Object.keys(pinnedOverrides).length > 0 ? pinnedOverrides : undefined,
|
|
206
|
+
}),
|
|
207
|
+
applyColumnState: (state) => {
|
|
208
|
+
if (state.visibleColumns) {
|
|
209
|
+
setVisibleColumns(new Set(state.visibleColumns));
|
|
210
|
+
}
|
|
211
|
+
if (state.sort) {
|
|
212
|
+
setSort(state.sort);
|
|
213
|
+
}
|
|
214
|
+
if (state.columnOrder && onColumnOrderChange) {
|
|
215
|
+
onColumnOrderChange(state.columnOrder);
|
|
216
|
+
}
|
|
217
|
+
if (state.columnWidths) {
|
|
218
|
+
setColumnWidthOverrides(state.columnWidths);
|
|
219
|
+
}
|
|
220
|
+
if (state.filters) {
|
|
221
|
+
setFilters(state.filters);
|
|
222
|
+
}
|
|
223
|
+
if (state.pinnedColumns) {
|
|
224
|
+
setPinnedOverrides(state.pinnedColumns);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
setFilterModel: setFilters,
|
|
228
|
+
getSelectedRows: () => Array.from(effectiveSelectedRows),
|
|
229
|
+
setSelectedRows: (rowIds) => {
|
|
230
|
+
if (selectedRows === undefined)
|
|
231
|
+
setInternalSelectedRows(new Set(rowIds));
|
|
232
|
+
},
|
|
233
|
+
selectAll: () => {
|
|
234
|
+
const allIds = new Set(displayItems.map((item) => getRowId(item)));
|
|
235
|
+
if (selectedRows === undefined)
|
|
236
|
+
setInternalSelectedRows(allIds);
|
|
237
|
+
onSelectionChange?.({
|
|
238
|
+
selectedRowIds: Array.from(allIds),
|
|
239
|
+
selectedItems: displayItems,
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
deselectAll: () => {
|
|
243
|
+
if (selectedRows === undefined)
|
|
244
|
+
setInternalSelectedRows(new Set());
|
|
245
|
+
onSelectionChange?.({
|
|
246
|
+
selectedRowIds: [],
|
|
247
|
+
selectedItems: [],
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
clearFilters: () => setFilters({}),
|
|
251
|
+
clearSort: () => setSort({ field: defaultSortField, direction: defaultSortDirection }),
|
|
252
|
+
resetGridState: (options) => {
|
|
253
|
+
setFilters({});
|
|
254
|
+
setSort({ field: defaultSortField, direction: defaultSortDirection });
|
|
255
|
+
if (!options?.keepSelection) {
|
|
256
|
+
if (selectedRows === undefined)
|
|
257
|
+
setInternalSelectedRows(new Set());
|
|
258
|
+
onSelectionChange?.({ selectedRowIds: [], selectedItems: [] });
|
|
259
|
+
}
|
|
260
|
+
},
|
|
261
|
+
getDisplayedRows: () => displayItems,
|
|
262
|
+
refreshData: () => {
|
|
263
|
+
if (isServerSide) {
|
|
264
|
+
refreshCounterRef.current += 1;
|
|
265
|
+
setRefreshCounter(refreshCounterRef.current);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
}), [
|
|
269
|
+
visibleColumns,
|
|
270
|
+
sort,
|
|
271
|
+
columnOrder,
|
|
272
|
+
columnWidthOverrides,
|
|
273
|
+
pinnedOverrides,
|
|
274
|
+
filters,
|
|
275
|
+
setFilters,
|
|
276
|
+
setSort,
|
|
277
|
+
setVisibleColumns,
|
|
278
|
+
onColumnOrderChange,
|
|
279
|
+
isServerSide,
|
|
280
|
+
effectiveSelectedRows,
|
|
281
|
+
selectedRows,
|
|
282
|
+
displayItems,
|
|
283
|
+
getRowId,
|
|
284
|
+
onSelectionChange,
|
|
285
|
+
defaultSortField,
|
|
286
|
+
defaultSortDirection,
|
|
287
|
+
]);
|
|
288
|
+
// With discriminated union, any defined value is active (mergeFilter already strips empties)
|
|
289
|
+
const hasActiveFilters = useMemo(() => {
|
|
290
|
+
return Object.values(filters).some((v) => v !== undefined);
|
|
291
|
+
}, [filters]);
|
|
292
|
+
const columnChooserColumns = useMemo(() => columns.map((c) => ({
|
|
293
|
+
columnId: c.columnId,
|
|
294
|
+
name: c.name,
|
|
295
|
+
required: c.required === true,
|
|
296
|
+
})), [columns]);
|
|
297
|
+
const statusBarConfig = useMemo(() => {
|
|
298
|
+
if (!statusBar)
|
|
299
|
+
return undefined;
|
|
300
|
+
if (typeof statusBar === 'object')
|
|
301
|
+
return statusBar;
|
|
302
|
+
const totalData = isClientSide ? (data?.length ?? 0) : serverTotalCount;
|
|
303
|
+
const filteredData = displayTotalCount;
|
|
304
|
+
return {
|
|
305
|
+
totalCount: totalData,
|
|
306
|
+
filteredCount: hasActiveFilters ? filteredData : undefined,
|
|
307
|
+
selectedCount: effectiveSelectedRows.size,
|
|
308
|
+
suppressRowCount: true, // OGrid always has pagination which shows the total
|
|
309
|
+
};
|
|
310
|
+
}, [
|
|
311
|
+
statusBar,
|
|
312
|
+
isClientSide,
|
|
313
|
+
data,
|
|
314
|
+
serverTotalCount,
|
|
315
|
+
displayTotalCount,
|
|
316
|
+
hasActiveFilters,
|
|
317
|
+
effectiveSelectedRows.size,
|
|
318
|
+
]);
|
|
319
|
+
const handleColumnResized = useCallback((columnId, width) => {
|
|
320
|
+
setColumnWidthOverrides((prev) => ({ ...prev, [columnId]: width }));
|
|
321
|
+
onColumnResized?.(columnId, width);
|
|
322
|
+
}, [onColumnResized]);
|
|
323
|
+
const handleColumnPinned = useCallback((columnId, pinned) => {
|
|
324
|
+
setPinnedOverrides((prev) => {
|
|
325
|
+
if (pinned === null) {
|
|
326
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
327
|
+
const { [columnId]: _, ...rest } = prev;
|
|
328
|
+
return rest;
|
|
329
|
+
}
|
|
330
|
+
return { ...prev, [columnId]: pinned };
|
|
331
|
+
});
|
|
332
|
+
onColumnPinned?.(columnId, pinned);
|
|
333
|
+
}, [onColumnPinned]);
|
|
334
|
+
// --- Side bar ---
|
|
335
|
+
const sideBarState = useSideBarState({ config: sideBar });
|
|
336
|
+
const filterableColumns = useMemo(() => columns
|
|
337
|
+
.filter((c) => c.filterable && c.filterable.type)
|
|
338
|
+
.map((c) => ({
|
|
339
|
+
columnId: c.columnId,
|
|
340
|
+
name: c.name,
|
|
341
|
+
filterField: c.filterable.filterField ?? c.columnId,
|
|
342
|
+
filterType: c.filterable.type,
|
|
343
|
+
})), [columns]);
|
|
344
|
+
const sideBarProps = useMemo(() => {
|
|
345
|
+
if (!sideBarState.isEnabled)
|
|
346
|
+
return null;
|
|
347
|
+
return {
|
|
348
|
+
activePanel: sideBarState.activePanel,
|
|
349
|
+
onPanelChange: sideBarState.setActivePanel,
|
|
350
|
+
panels: sideBarState.panels,
|
|
351
|
+
position: sideBarState.position,
|
|
352
|
+
columns: columnChooserColumns,
|
|
353
|
+
visibleColumns,
|
|
354
|
+
onVisibilityChange: handleVisibilityChange,
|
|
355
|
+
onSetVisibleColumns: setVisibleColumns,
|
|
356
|
+
filterableColumns,
|
|
357
|
+
filters,
|
|
358
|
+
onFilterChange: handleFilterChange,
|
|
359
|
+
filterOptions: clientFilterOptions,
|
|
360
|
+
};
|
|
361
|
+
}, [
|
|
362
|
+
sideBarState.isEnabled,
|
|
363
|
+
sideBarState.activePanel,
|
|
364
|
+
sideBarState.setActivePanel,
|
|
365
|
+
sideBarState.panels,
|
|
366
|
+
sideBarState.position,
|
|
367
|
+
columnChooserColumns,
|
|
368
|
+
visibleColumns,
|
|
369
|
+
handleVisibilityChange,
|
|
370
|
+
setVisibleColumns,
|
|
371
|
+
filterableColumns,
|
|
372
|
+
filters,
|
|
373
|
+
handleFilterChange,
|
|
374
|
+
clientFilterOptions,
|
|
375
|
+
]);
|
|
376
|
+
const clearAllFilters = useCallback(() => setFilters({}), [setFilters]);
|
|
377
|
+
const isLoadingResolved = (isServerSide && loading) || displayLoading;
|
|
378
|
+
const dataGridProps = useMemo(() => ({
|
|
379
|
+
items: displayItems,
|
|
380
|
+
columns: columnsProp,
|
|
381
|
+
getRowId,
|
|
382
|
+
sortBy: sort.field,
|
|
383
|
+
sortDirection: sort.direction,
|
|
384
|
+
onColumnSort: handleSort,
|
|
385
|
+
visibleColumns,
|
|
386
|
+
columnOrder,
|
|
387
|
+
onColumnOrderChange,
|
|
388
|
+
onColumnResized: handleColumnResized,
|
|
389
|
+
onColumnPinned: handleColumnPinned,
|
|
390
|
+
pinnedColumns: pinnedOverrides,
|
|
391
|
+
initialColumnWidths: columnWidthOverrides,
|
|
392
|
+
freezeRows,
|
|
393
|
+
freezeCols,
|
|
394
|
+
editable,
|
|
395
|
+
cellSelection,
|
|
396
|
+
onCellValueChanged,
|
|
397
|
+
onUndo,
|
|
398
|
+
onRedo,
|
|
399
|
+
canUndo,
|
|
400
|
+
canRedo,
|
|
401
|
+
rowSelection,
|
|
402
|
+
selectedRows: effectiveSelectedRows,
|
|
403
|
+
onSelectionChange: handleSelectionChange,
|
|
404
|
+
statusBar: statusBarConfig,
|
|
405
|
+
isLoading: isLoadingResolved,
|
|
406
|
+
filters,
|
|
407
|
+
onFilterChange: handleFilterChange,
|
|
408
|
+
filterOptions: clientFilterOptions,
|
|
409
|
+
loadingFilterOptions: dataSource?.fetchFilterOptions ? loadingFilterOptions : EMPTY_LOADING_OPTIONS,
|
|
410
|
+
peopleSearch: dataSource?.searchPeople,
|
|
411
|
+
getUserByEmail: dataSource?.getUserByEmail,
|
|
412
|
+
layoutMode,
|
|
413
|
+
suppressHorizontalScroll,
|
|
414
|
+
'aria-label': ariaLabel,
|
|
415
|
+
'aria-labelledby': ariaLabelledBy,
|
|
416
|
+
emptyState: {
|
|
417
|
+
hasActiveFilters,
|
|
418
|
+
onClearAll: clearAllFilters,
|
|
419
|
+
message: emptyState?.message,
|
|
420
|
+
render: emptyState?.render,
|
|
421
|
+
},
|
|
422
|
+
}), [
|
|
423
|
+
displayItems, columnsProp, getRowId, sort.field, sort.direction, handleSort,
|
|
424
|
+
visibleColumns, columnOrder, onColumnOrderChange, handleColumnResized,
|
|
425
|
+
handleColumnPinned, pinnedOverrides, columnWidthOverrides, freezeRows, freezeCols,
|
|
426
|
+
editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo,
|
|
427
|
+
rowSelection, effectiveSelectedRows, handleSelectionChange, statusBarConfig,
|
|
428
|
+
isLoadingResolved, filters, handleFilterChange, clientFilterOptions, dataSource,
|
|
429
|
+
loadingFilterOptions, layoutMode, suppressHorizontalScroll, ariaLabel, ariaLabelledBy,
|
|
430
|
+
hasActiveFilters, clearAllFilters, emptyState,
|
|
431
|
+
]);
|
|
432
|
+
const pagination = useMemo(() => ({
|
|
433
|
+
page,
|
|
434
|
+
pageSize,
|
|
435
|
+
displayTotalCount,
|
|
436
|
+
setPage,
|
|
437
|
+
setPageSize,
|
|
438
|
+
pageSizeOptions,
|
|
439
|
+
entityLabelPlural,
|
|
440
|
+
}), [page, pageSize, displayTotalCount, setPage, setPageSize, pageSizeOptions, entityLabelPlural]);
|
|
441
|
+
const columnChooser = useMemo(() => ({
|
|
442
|
+
columns: columnChooserColumns,
|
|
443
|
+
visibleColumns,
|
|
444
|
+
onVisibilityChange: handleVisibilityChange,
|
|
445
|
+
placement: columnChooserPlacement,
|
|
446
|
+
}), [columnChooserColumns, visibleColumns, handleVisibilityChange, columnChooserPlacement]);
|
|
447
|
+
const layout = useMemo(() => ({
|
|
448
|
+
toolbar,
|
|
449
|
+
toolbarBelow,
|
|
450
|
+
className,
|
|
451
|
+
emptyState,
|
|
452
|
+
sideBarProps,
|
|
453
|
+
}), [toolbar, toolbarBelow, className, emptyState, sideBarProps]);
|
|
454
|
+
const filtersResult = useMemo(() => ({
|
|
455
|
+
hasActiveFilters,
|
|
456
|
+
setFilters,
|
|
457
|
+
}), [hasActiveFilters, setFilters]);
|
|
458
|
+
return {
|
|
459
|
+
dataGridProps,
|
|
460
|
+
pagination,
|
|
461
|
+
columnChooser,
|
|
462
|
+
layout,
|
|
463
|
+
filters: filtersResult,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* People filter state sub-hook for column header filters.
|
|
3
|
+
* Manages people search text, suggestions, loading state, input ref, and user select/clear handlers.
|
|
4
|
+
* Includes debounced people search effect.
|
|
5
|
+
*/
|
|
6
|
+
import { useState, useCallback, useEffect, useRef } from 'react';
|
|
7
|
+
const PEOPLE_SEARCH_DEBOUNCE_MS = 300;
|
|
8
|
+
export function usePeopleFilterState(params) {
|
|
9
|
+
const { onUserChange, peopleSearch, isFilterOpen, filterType } = params;
|
|
10
|
+
const peopleInputRef = useRef(null);
|
|
11
|
+
const peopleSearchTimeoutRef = useRef(undefined);
|
|
12
|
+
const [peopleSuggestions, setPeopleSuggestions] = useState([]);
|
|
13
|
+
const [isPeopleLoading, setIsPeopleLoading] = useState(false);
|
|
14
|
+
const [peopleSearchText, setPeopleSearchText] = useState('');
|
|
15
|
+
// Sync temp state when popover opens
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (isFilterOpen) {
|
|
18
|
+
setPeopleSearchText('');
|
|
19
|
+
setPeopleSuggestions([]);
|
|
20
|
+
if (filterType === 'people') {
|
|
21
|
+
setTimeout(() => peopleInputRef.current?.focus(), 50);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}, [isFilterOpen, filterType]);
|
|
25
|
+
// People search with debounce
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!peopleSearch || !isFilterOpen || filterType !== 'people')
|
|
28
|
+
return;
|
|
29
|
+
if (peopleSearchTimeoutRef.current)
|
|
30
|
+
window.clearTimeout(peopleSearchTimeoutRef.current);
|
|
31
|
+
if (!peopleSearchText.trim()) {
|
|
32
|
+
setPeopleSuggestions([]);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
setIsPeopleLoading(true);
|
|
36
|
+
peopleSearchTimeoutRef.current = window.setTimeout(async () => {
|
|
37
|
+
try {
|
|
38
|
+
const results = await peopleSearch(peopleSearchText);
|
|
39
|
+
setPeopleSuggestions(results.slice(0, 10));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
setPeopleSuggestions([]);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
setIsPeopleLoading(false);
|
|
46
|
+
}
|
|
47
|
+
}, PEOPLE_SEARCH_DEBOUNCE_MS);
|
|
48
|
+
return () => {
|
|
49
|
+
if (peopleSearchTimeoutRef.current)
|
|
50
|
+
window.clearTimeout(peopleSearchTimeoutRef.current);
|
|
51
|
+
};
|
|
52
|
+
}, [peopleSearchText, peopleSearch, isFilterOpen, filterType]);
|
|
53
|
+
const handleUserSelect = useCallback((user) => {
|
|
54
|
+
onUserChange?.(user);
|
|
55
|
+
}, [onUserChange]);
|
|
56
|
+
const handleClearUser = useCallback(() => {
|
|
57
|
+
onUserChange?.(undefined);
|
|
58
|
+
}, [onUserChange]);
|
|
59
|
+
return {
|
|
60
|
+
peopleSuggestions,
|
|
61
|
+
isPeopleLoading,
|
|
62
|
+
peopleSearchText,
|
|
63
|
+
setPeopleSearchText,
|
|
64
|
+
peopleInputRef,
|
|
65
|
+
handleUserSelect,
|
|
66
|
+
handleClearUser,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Manages searchable rich select editor state with keyboard navigation (arrow keys, enter, escape).
|
|
4
|
+
* @param params - Values, format function, initial value, and commit/cancel callbacks.
|
|
5
|
+
* @returns Search text, filtered values, highlighted index, keyboard handler, and select function.
|
|
6
|
+
*/
|
|
7
|
+
export function useRichSelectState(params) {
|
|
8
|
+
const { values, formatValue, onCommit, onCancel } = params;
|
|
9
|
+
const [searchText, setSearchText] = useState('');
|
|
10
|
+
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
11
|
+
const getDisplayText = useCallback((value) => {
|
|
12
|
+
if (formatValue)
|
|
13
|
+
return formatValue(value);
|
|
14
|
+
return value != null ? String(value) : '';
|
|
15
|
+
}, [formatValue]);
|
|
16
|
+
const filteredValues = useMemo(() => {
|
|
17
|
+
if (!searchText.trim())
|
|
18
|
+
return values;
|
|
19
|
+
const lower = searchText.toLowerCase();
|
|
20
|
+
return values.filter((v) => getDisplayText(v).toLowerCase().includes(lower));
|
|
21
|
+
}, [values, searchText, getDisplayText]);
|
|
22
|
+
const selectValue = useCallback((value) => {
|
|
23
|
+
onCommit(value);
|
|
24
|
+
}, [onCommit]);
|
|
25
|
+
const handleKeyDown = useCallback((e) => {
|
|
26
|
+
switch (e.key) {
|
|
27
|
+
case 'ArrowDown':
|
|
28
|
+
e.preventDefault();
|
|
29
|
+
setHighlightedIndex((prev) => Math.min(prev + 1, filteredValues.length - 1));
|
|
30
|
+
break;
|
|
31
|
+
case 'ArrowUp':
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
setHighlightedIndex((prev) => Math.max(prev - 1, 0));
|
|
34
|
+
break;
|
|
35
|
+
case 'Enter':
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
e.stopPropagation();
|
|
38
|
+
if (filteredValues.length > 0 && highlightedIndex < filteredValues.length) {
|
|
39
|
+
selectValue(filteredValues[highlightedIndex]);
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
case 'Escape':
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
e.stopPropagation();
|
|
45
|
+
onCancel();
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}, [filteredValues, highlightedIndex, selectValue, onCancel]);
|
|
49
|
+
return {
|
|
50
|
+
searchText,
|
|
51
|
+
setSearchText,
|
|
52
|
+
filteredValues,
|
|
53
|
+
highlightedIndex,
|
|
54
|
+
handleKeyDown,
|
|
55
|
+
selectValue,
|
|
56
|
+
getDisplayText,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useMemo } from 'react';
|
|
2
|
+
import { useLatestRef } from './useLatestRef';
|
|
3
|
+
/**
|
|
4
|
+
* Manages row selection state for single or multiple selection modes with shift-click range support.
|
|
5
|
+
* @param params - Items, getRowId, selection mode, controlled state, and selection change callback.
|
|
6
|
+
* @returns Selected row IDs, update function, checkbox handlers, and selection state booleans.
|
|
7
|
+
*/
|
|
8
|
+
export function useRowSelection(params) {
|
|
9
|
+
const { items, getRowId, rowSelection, controlledSelectedRows, onSelectionChange, } = params;
|
|
10
|
+
const [internalSelectedRows, setInternalSelectedRows] = useState(new Set());
|
|
11
|
+
const lastClickedRowRef = useRef(-1);
|
|
12
|
+
// Defensive: convert to Set if caller passes an array (e.g. from JSON state)
|
|
13
|
+
const selectedRowIds = useMemo(() => controlledSelectedRows != null
|
|
14
|
+
? controlledSelectedRows instanceof Set
|
|
15
|
+
? controlledSelectedRows
|
|
16
|
+
: new Set(controlledSelectedRows)
|
|
17
|
+
: internalSelectedRows, [controlledSelectedRows, internalSelectedRows]);
|
|
18
|
+
const updateSelection = useCallback((newSelectedIds) => {
|
|
19
|
+
if (controlledSelectedRows === undefined) {
|
|
20
|
+
setInternalSelectedRows(newSelectedIds);
|
|
21
|
+
}
|
|
22
|
+
onSelectionChange?.({
|
|
23
|
+
selectedRowIds: Array.from(newSelectedIds),
|
|
24
|
+
selectedItems: items.filter((item) => newSelectedIds.has(getRowId(item))),
|
|
25
|
+
});
|
|
26
|
+
}, [controlledSelectedRows, onSelectionChange, items, getRowId]);
|
|
27
|
+
// Read selectedRowIds via ref to avoid recreating this callback on every selection change
|
|
28
|
+
const selectedRowIdsRef = useLatestRef(selectedRowIds);
|
|
29
|
+
const itemsRef = useLatestRef(items);
|
|
30
|
+
const handleRowCheckboxChange = useCallback((rowId, checked, rowIndex, shiftKey) => {
|
|
31
|
+
if (rowSelection === 'single') {
|
|
32
|
+
updateSelection(checked ? new Set([rowId]) : new Set());
|
|
33
|
+
lastClickedRowRef.current = rowIndex;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const next = new Set(selectedRowIdsRef.current);
|
|
37
|
+
const currentItems = itemsRef.current;
|
|
38
|
+
if (shiftKey && lastClickedRowRef.current >= 0 && lastClickedRowRef.current !== rowIndex) {
|
|
39
|
+
const start = Math.min(lastClickedRowRef.current, rowIndex);
|
|
40
|
+
const end = Math.max(lastClickedRowRef.current, rowIndex);
|
|
41
|
+
for (let i = start; i <= end; i++) {
|
|
42
|
+
if (i < currentItems.length) {
|
|
43
|
+
const id = getRowId(currentItems[i]);
|
|
44
|
+
if (checked)
|
|
45
|
+
next.add(id);
|
|
46
|
+
else
|
|
47
|
+
next.delete(id);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
if (checked)
|
|
53
|
+
next.add(rowId);
|
|
54
|
+
else
|
|
55
|
+
next.delete(rowId);
|
|
56
|
+
}
|
|
57
|
+
lastClickedRowRef.current = rowIndex;
|
|
58
|
+
updateSelection(next);
|
|
59
|
+
},
|
|
60
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- itemsRef, selectedRowIdsRef are stable refs
|
|
61
|
+
[rowSelection, getRowId, updateSelection]);
|
|
62
|
+
const handleSelectAll = useCallback((checked) => {
|
|
63
|
+
if (checked) {
|
|
64
|
+
updateSelection(new Set(items.map((item) => getRowId(item))));
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
updateSelection(new Set());
|
|
68
|
+
}
|
|
69
|
+
}, [items, getRowId, updateSelection]);
|
|
70
|
+
const allSelected = useMemo(() => items.length > 0 && items.every((item) => selectedRowIds.has(getRowId(item))), [items, selectedRowIds, getRowId]);
|
|
71
|
+
const someSelected = useMemo(() => !allSelected && items.some((item) => selectedRowIds.has(getRowId(item))), [allSelected, items, selectedRowIds, getRowId]);
|
|
72
|
+
return {
|
|
73
|
+
selectedRowIds,
|
|
74
|
+
updateSelection,
|
|
75
|
+
handleRowCheckboxChange,
|
|
76
|
+
handleSelectAll,
|
|
77
|
+
allSelected,
|
|
78
|
+
someSelected,
|
|
79
|
+
};
|
|
80
|
+
}
|