@opencorestack/opengridx 2.1.0 → 3.0.1
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/CHANGELOG.md +399 -0
- package/README.md +95 -72
- package/dist/index.d.ts +590 -131
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +7891 -5631
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +17 -23
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +348 -166
- package/docs/README.md +6 -1
- package/docs/architecture/datagrid-orchestration.md +81 -0
- package/docs/architecture/grid-row-meta.md +102 -0
- package/docs/architecture/use-grid-columns.md +128 -0
- package/docs/architecture/use-grid-controlled-state.md +157 -0
- package/docs/architecture/use-grid-row-pipeline.md +120 -0
- package/docs/architecture/use-grid-scroll-sync.md +111 -0
- package/docs/architecture/use-grid-state-snapshot.md +90 -0
- package/docs/architecture/use-grid-virtualization.md +115 -0
- package/docs/architecture/use-grid-visible-rows.md +85 -0
- package/docs/assets/banner-alt.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/hero.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/components/aggregation-footer.md +15 -10
- package/docs/components/cell.md +16 -6
- package/docs/components/column-group-header.md +55 -28
- package/docs/components/column-resize.md +17 -13
- package/docs/components/column-visibility.md +6 -7
- package/docs/components/datagrid.md +15 -13
- package/docs/components/empty-state.md +2 -1
- package/docs/components/error-overlay.md +12 -7
- package/docs/components/filter-panel.md +28 -8
- package/docs/components/header.md +28 -9
- package/docs/components/pagination.md +14 -8
- package/docs/components/row.md +21 -5
- package/docs/components/toolbar.md +27 -9
- package/docs/components/tooltip.md +16 -7
- package/docs/customization/slots-api.md +90 -38
- package/docs/customization/theming.md +66 -18
- package/docs/features/aggregation-pivot.md +75 -9
- package/docs/features/cell-spanning.md +80 -125
- package/docs/features/clipboard.md +41 -16
- package/docs/features/custom-pagination.md +38 -18
- package/docs/features/data-source.md +36 -13
- package/docs/features/editing-reordering.md +125 -16
- package/docs/features/export-guide.md +126 -39
- package/docs/features/filtering.md +44 -6
- package/docs/features/infinite-scroll.md +54 -46
- package/docs/features/keyboard-navigation.md +83 -0
- package/docs/features/list-view.md +58 -5
- package/docs/features/loading-states.md +18 -20
- package/docs/features/master-detail.md +18 -5
- package/docs/features/pdf-export.md +72 -19
- package/docs/features/pinning.md +24 -5
- package/docs/features/selection.md +50 -10
- package/docs/features/sorting-pagination.md +64 -14
- package/docs/features/state-persistence.md +78 -10
- package/docs/features/toolbar-customization.md +26 -17
- package/docs/features/tree-data-grouping.md +36 -8
- package/docs/features/virtualization.md +37 -14
- package/docs/migration/v1-to-v2.md +115 -0
- package/docs/migration/v2-to-v3.md +1233 -0
- package/docs/performance.md +124 -0
- package/docs/roadmap.md +5 -5
- package/lib/components/Cell/Cell.css +17 -1
- package/lib/components/Cell/Cell.tsx +129 -57
- package/lib/components/Cell/CellErrorBoundary.tsx +12 -6
- package/lib/components/Cell/GridEditInputCell.tsx +114 -41
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.css +7 -1
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.css +15 -2
- package/lib/components/ColumnResizeHandle/ColumnResizeHandle.tsx +104 -38
- package/lib/components/ColumnResizeHandle/clampResizeWidth.ts +13 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +6 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +21 -13
- package/lib/components/DataGrid/DataGrid.css +85 -12
- package/lib/components/DataGrid/DataGrid.tsx +582 -876
- package/lib/components/DataGrid/GridAggregationFooter.tsx +53 -17
- package/lib/components/DataGrid/GridErrorOverlay.tsx +15 -12
- package/lib/components/DataGrid/GridListView.tsx +165 -24
- package/lib/components/DataGrid/GridLiveRegion.tsx +31 -0
- package/lib/components/DataGrid/GridLoadingOverlay.tsx +27 -0
- package/lib/components/DataGrid/GridPaginationArea.tsx +44 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +62 -11
- package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +40 -38
- package/lib/components/DataGrid/GridToolbarSlot.tsx +51 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +40 -24
- package/lib/components/DetailPanel/DetailPanel.css +0 -6
- package/lib/components/DetailPanel/DetailPanel.tsx +34 -5
- package/lib/components/DetailPanel/DetailPanelContent.tsx +17 -0
- package/lib/components/FilterPanel/FilterPanel.css +13 -2
- package/lib/components/FilterPanel/FilterPanel.tsx +272 -99
- package/lib/components/Header/ColumnMenu.tsx +91 -39
- package/lib/components/Header/Header.css +6 -3
- package/lib/components/Header/Header.tsx +181 -160
- package/lib/components/ListView/ListView.css +25 -0
- package/lib/components/ListView/ListViewRow.tsx +76 -10
- package/lib/components/Pagination/Pagination.tsx +15 -7
- package/lib/components/Row/Row.tsx +177 -77
- package/lib/components/Toolbar/GlobalSearch.css +6 -3
- package/lib/components/Toolbar/GlobalSearch.tsx +26 -27
- package/lib/components/Toolbar/GridToolbar.css +12 -11
- package/lib/components/Toolbar/GridToolbar.tsx +133 -89
- package/lib/components/Toolbar/PivotPanel.tsx +89 -49
- package/lib/components/Tooltip/Tooltip.css +20 -0
- package/lib/components/Tooltip/Tooltip.tsx +68 -35
- package/lib/components/ui/Button.tsx +3 -0
- package/lib/components/ui/Checkbox.tsx +2 -1
- package/lib/components/ui/ExpandIcon.css +0 -9
- package/lib/components/ui/ExpandIcon.tsx +4 -1
- package/lib/hooks/core/gridApiPlaceholder.ts +34 -0
- package/lib/hooks/core/gridToolbarHostContext.ts +14 -0
- package/lib/hooks/core/useDataGrid.ts +115 -173
- package/lib/hooks/core/useGridAggregationApi.ts +72 -0
- package/lib/hooks/core/useGridApiMethods.ts +115 -0
- package/lib/hooks/core/useGridApiRef.ts +19 -4
- package/lib/hooks/core/useGridApiRefBinding.ts +18 -0
- package/lib/hooks/core/useGridAriaRows.ts +38 -0
- package/lib/hooks/core/useGridClipboardApi.ts +38 -0
- package/lib/hooks/core/useGridColumnLookup.ts +30 -0
- package/lib/hooks/core/useGridColumns.tsx +279 -76
- package/lib/hooks/core/useGridColumnsPanel.ts +77 -0
- package/lib/hooks/core/useGridControlledState.ts +99 -6
- package/lib/hooks/core/useGridDetailPanel.ts +51 -0
- package/lib/hooks/core/useGridDevWarnings.ts +104 -5
- package/lib/hooks/core/useGridFocusHandlers.ts +50 -0
- package/lib/hooks/core/useGridGroupingColumn.ts +62 -0
- package/lib/hooks/core/useGridHeaderHandlers.ts +92 -0
- package/lib/hooks/core/useGridHierarchy.ts +81 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +447 -243
- package/lib/hooks/core/useGridLiveRowSelection.ts +67 -0
- package/lib/hooks/core/useGridPageCorrection.ts +55 -0
- package/lib/hooks/core/useGridRowIdOf.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +81 -0
- package/lib/hooks/core/useGridRowPipeline.ts +69 -39
- package/lib/hooks/core/useGridRowSelection.ts +91 -0
- package/lib/hooks/core/useGridScrollSync.ts +80 -32
- package/lib/hooks/core/useGridScrollToIndexesApi.ts +45 -0
- package/lib/hooks/core/useGridStateSnapshot.ts +40 -5
- package/lib/hooks/core/useGridThemeDimensions.ts +49 -0
- package/lib/hooks/core/useGridToolbarProps.ts +97 -0
- package/lib/hooks/core/useGridViewport.ts +38 -0
- package/lib/hooks/core/useGridViewportSize.ts +26 -0
- package/lib/hooks/core/useGridVisibleRows.ts +17 -5
- package/lib/hooks/core/useLayout.ts +215 -195
- package/lib/hooks/core/useRowsScrollEndLatch.ts +41 -0
- package/lib/hooks/core/useStableColumns.ts +34 -0
- package/lib/hooks/features/useAggregation.ts +82 -51
- package/lib/hooks/features/useColumnGroupReorderGuard.ts +34 -0
- package/lib/hooks/features/useDetailPanelHeights.ts +29 -0
- package/lib/hooks/features/useGridClipboard.ts +133 -73
- package/lib/hooks/features/useGridDataSource.ts +354 -128
- package/lib/hooks/features/useGridEditing.ts +211 -51
- package/lib/hooks/features/useGridPivot.ts +92 -0
- package/lib/hooks/features/useGridSpanRenderWindow.ts +104 -0
- package/lib/hooks/features/useGridSpanning.ts +410 -306
- package/lib/hooks/features/usePivot.ts +0 -0
- package/lib/hooks/features/useServerTreeChildren.ts +50 -0
- package/lib/hooks/useColumnReorder.ts +38 -44
- package/lib/hooks/useRowGrouping.ts +298 -160
- package/lib/hooks/useRowReorder.ts +62 -31
- package/lib/hooks/useTreeData.ts +287 -220
- package/lib/index.ts +28 -2
- package/lib/state/index.ts +1 -0
- package/lib/state/types.ts +7 -1
- package/lib/state/useGridStateStorage.ts +88 -29
- package/lib/theme/DataGridThemeProvider.tsx +143 -38
- package/lib/theme/gridThemeContext.ts +5 -0
- package/lib/theme/index.ts +5 -0
- package/lib/theme/presets.ts +5 -4
- package/lib/theme/types.ts +40 -2
- package/lib/types/index.ts +313 -116
- package/lib/utils/aggregation/index.ts +119 -24
- package/lib/utils/aria/index.ts +40 -0
- package/lib/utils/columnGroups.ts +110 -0
- package/lib/utils/columnLookup.ts +33 -0
- package/lib/utils/columnWidth.ts +22 -0
- package/lib/utils/dataSource/index.ts +46 -0
- package/lib/utils/detailPanel.ts +66 -0
- package/lib/utils/dragData.ts +44 -0
- package/lib/utils/editing/index.ts +30 -0
- package/lib/utils/export/exportShared.ts +197 -0
- package/lib/utils/export/exportToExcelAdvanced.ts +278 -139
- package/lib/utils/export/exportToPdf.ts +143 -92
- package/lib/utils/export/groupLabel.ts +14 -0
- package/lib/utils/export/index.ts +278 -272
- package/lib/utils/export/pdfText.ts +50 -0
- package/lib/utils/filtering/index.ts +277 -148
- package/lib/utils/focus/index.ts +115 -0
- package/lib/utils/gridApiRows.ts +35 -0
- package/lib/utils/gridModes.ts +77 -0
- package/lib/utils/grouping/groupedExportRows.ts +91 -0
- package/lib/utils/pagination/index.ts +56 -0
- package/lib/utils/pinning/index.ts +77 -33
- package/lib/utils/pivot/index.ts +368 -0
- package/lib/utils/scroll/index.ts +55 -5
- package/lib/utils/scroll/scrollEnd.ts +18 -0
- package/lib/utils/sorting/index.ts +154 -30
- package/lib/utils/syntheticRows.ts +12 -0
- package/lib/utils/values.ts +206 -0
- package/lib/utils/viewport.ts +14 -0
- package/llms.txt +261 -164
- package/package.json +13 -5
- package/lib/components/ColumnGroupHeader/ColumnGroupHeader.tsx +0 -220
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.test.tsx +0 -85
- package/lib/components/DataGrid/DataGrid.defects.test.tsx +0 -103
- package/lib/components/DataGrid/DataGrid.regression.test.tsx +0 -132
- package/lib/components/DataGrid/DataGrid.smoke.test.tsx +0 -58
- package/lib/components/DataGrid/DataGrid.v2.test.tsx +0 -127
- package/lib/components/DataGrid/DataGrid.virtualization.browser.test.tsx +0 -183
- package/lib/components/Header/Header.test.tsx +0 -135
- package/lib/components/Pagination/Pagination.test.tsx +0 -64
- package/lib/hooks/core/useDataGrid.regression.test.ts +0 -66
- package/lib/hooks/core/useGridControlledState.test.ts +0 -86
- package/lib/hooks/core/useGridKeyboardNavigation.test.ts +0 -176
- package/lib/hooks/core/useGridRowPipeline.test.ts +0 -117
- package/lib/hooks/features/useAggregation.test.ts +0 -175
- package/lib/hooks/features/usePivot.test.ts +0 -202
- package/lib/hooks/useRowGrouping.test.ts +0 -298
- package/lib/hooks/useTreeData.test.ts +0 -83
- package/lib/utils/aggregation/aggregation.test.ts +0 -16
- package/lib/utils/export/exportToExcelAdvanced.test.ts +0 -109
- package/lib/utils/export/exportToPdf.test.ts +0 -120
- package/lib/utils/export/groupedExport.test.ts +0 -144
- package/lib/utils/filtering/filtering.test.ts +0 -178
- package/lib/utils/sorting/sorting.test.ts +0 -88
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
2
1
|
import { useMemo, useEffect, useRef, useCallback, useState } from 'react';
|
|
3
2
|
import type {
|
|
4
3
|
GridRowModel,
|
|
4
|
+
GridValidRowModel,
|
|
5
5
|
GridColDef,
|
|
6
6
|
GridAggregationModel,
|
|
7
7
|
GridAggregationResult,
|
|
@@ -12,21 +12,11 @@ import type {
|
|
|
12
12
|
import { computeAggregations } from '../../utils/aggregation';
|
|
13
13
|
|
|
14
14
|
export type { BuiltInAggFn } from '../../utils/aggregation';
|
|
15
|
+
export { formatAggregationValue } from '../../utils/aggregation';
|
|
15
16
|
|
|
16
|
-
export
|
|
17
|
-
if (value == null) return '—';
|
|
18
|
-
if (typeof value === 'number') {
|
|
19
|
-
if (fnName === 'avg') {
|
|
20
|
-
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
21
|
-
}
|
|
22
|
-
return value.toLocaleString();
|
|
23
|
-
}
|
|
24
|
-
return String(value);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface UseAggregationParams<R extends GridRowModel> {
|
|
17
|
+
export interface UseAggregationParams<R extends GridValidRowModel = GridRowModel> {
|
|
28
18
|
rows: R[];
|
|
29
|
-
columns?: GridColDef[];
|
|
19
|
+
columns?: GridColDef<R>[];
|
|
30
20
|
aggregationModel: GridAggregationModel;
|
|
31
21
|
isServerSide: boolean;
|
|
32
22
|
dataSource?: GridDataSource<R>;
|
|
@@ -41,6 +31,37 @@ export interface UseAggregationReturn {
|
|
|
41
31
|
error: unknown;
|
|
42
32
|
}
|
|
43
33
|
|
|
34
|
+
const EMPTY_RESULT: GridAggregationResult = {};
|
|
35
|
+
|
|
36
|
+
interface ServerAggregationState {
|
|
37
|
+
/** The request (model, filter, sort) this outcome belongs to. */
|
|
38
|
+
key: string;
|
|
39
|
+
result: GridAggregationResult | null;
|
|
40
|
+
error: unknown;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* State for totals that arrive with a getRows response. The stored results are tagged with the
|
|
45
|
+
* aggregation model they were computed for and read back as `null` once the model changes, so the
|
|
46
|
+
* footer never shows a total under the wrong function.
|
|
47
|
+
*/
|
|
48
|
+
export function useServerAggregationResults(
|
|
49
|
+
aggregationModel: GridAggregationModel,
|
|
50
|
+
): [GridAggregationResult | null, (results: GridAggregationResult) => void] {
|
|
51
|
+
const modelKey = JSON.stringify(aggregationModel);
|
|
52
|
+
const [state, setState] = useState<{ modelKey: string; results: GridAggregationResult } | null>(null);
|
|
53
|
+
const setResults = useCallback(
|
|
54
|
+
(results: GridAggregationResult) => setState({ modelKey, results }),
|
|
55
|
+
[modelKey],
|
|
56
|
+
);
|
|
57
|
+
return [state && state.modelKey === modelKey ? state.results : null, setResults];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function useAggregation<R extends GridValidRowModel = GridRowModel>(params: UseAggregationParams<R>): UseAggregationReturn;
|
|
61
|
+
export function useAggregation<R extends GridValidRowModel = GridRowModel>(
|
|
62
|
+
params: Omit<UseAggregationParams<R>, 'columns'> & { columns?: GridColDef[] },
|
|
63
|
+
): UseAggregationReturn;
|
|
64
|
+
// Internally rows are read as GridRowModel records; the signature above is the public one.
|
|
44
65
|
export function useAggregation<R extends GridRowModel>(
|
|
45
66
|
params: UseAggregationParams<R>
|
|
46
67
|
): UseAggregationReturn {
|
|
@@ -55,6 +76,8 @@ export function useAggregation<R extends GridRowModel>(
|
|
|
55
76
|
serverAggregationResults,
|
|
56
77
|
} = params;
|
|
57
78
|
|
|
79
|
+
const hasModel = Object.keys(aggregationModel).length > 0;
|
|
80
|
+
|
|
58
81
|
const columnsLookup = useMemo(() => {
|
|
59
82
|
const map = new Map<string, GridColDef>();
|
|
60
83
|
(columns ?? []).forEach(col => map.set(col.field, col));
|
|
@@ -62,51 +85,59 @@ export function useAggregation<R extends GridRowModel>(
|
|
|
62
85
|
}, [columns]);
|
|
63
86
|
|
|
64
87
|
const clientResult = useMemo<GridAggregationResult>(() => {
|
|
65
|
-
if (isServerSide ||
|
|
88
|
+
if (isServerSide || !hasModel) return EMPTY_RESULT;
|
|
66
89
|
|
|
67
90
|
return computeAggregations(rows, aggregationModel, columnsLookup, 'useAggregation');
|
|
68
|
-
}, [rows, columnsLookup, aggregationModel, isServerSide]);
|
|
91
|
+
}, [rows, columnsLookup, aggregationModel, isServerSide, hasModel]);
|
|
92
|
+
|
|
93
|
+
// Requests are identified by content, so an inline `aggregationModel={{ salary: 'sum' }}` (a new
|
|
94
|
+
// object on every parent render) neither refetches nor drops the result it already has.
|
|
95
|
+
const requestKey = JSON.stringify([aggregationModel, filterModel ?? null, sortModel ?? []]);
|
|
96
|
+
const shouldFetch = isServerSide && hasModel && Boolean(dataSource?.getAggregations) && !serverAggregationResults;
|
|
69
97
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
98
|
+
const latestRequestRef = useRef({ aggregationModel, filterModel, sortModel });
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
latestRequestRef.current = { aggregationModel, filterModel, sortModel };
|
|
101
|
+
});
|
|
74
102
|
|
|
75
|
-
const
|
|
76
|
-
if (!dataSource?.getAggregations || Object.keys(aggregationModel).length === 0) return;
|
|
103
|
+
const [serverState, setServerState] = useState<ServerAggregationState | null>(null);
|
|
77
104
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const getAggregations = dataSource?.getAggregations;
|
|
107
|
+
if (!shouldFetch || !getAggregations) return;
|
|
81
108
|
|
|
109
|
+
let cancelled = false;
|
|
110
|
+
const request = latestRequestRef.current;
|
|
111
|
+
let pending: Promise<GridAggregationResult>;
|
|
82
112
|
try {
|
|
83
|
-
|
|
84
|
-
sortModel: sortModel ?? [],
|
|
85
|
-
filterModel: filterModel ?? { items: [] },
|
|
113
|
+
pending = Promise.resolve(getAggregations({
|
|
114
|
+
sortModel: request.sortModel ?? [],
|
|
115
|
+
filterModel: request.filterModel ?? { items: [] },
|
|
86
116
|
groupKeys: [],
|
|
87
|
-
aggregationModel,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
if (requestId === requestIdRef.current) {
|
|
91
|
-
setServerResult(result);
|
|
92
|
-
}
|
|
117
|
+
aggregationModel: request.aggregationModel,
|
|
118
|
+
}));
|
|
93
119
|
} catch (err) {
|
|
94
|
-
|
|
95
|
-
setError(err);
|
|
96
|
-
console.error('[useAggregation] Server aggregation error:', err);
|
|
97
|
-
}
|
|
98
|
-
} finally {
|
|
99
|
-
if (requestId === requestIdRef.current) {
|
|
100
|
-
setIsLoading(false);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
}, [dataSource, aggregationModel, filterModel, sortModel]);
|
|
104
|
-
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
if (isServerSide && dataSource?.getAggregations && !serverAggregationResults) {
|
|
107
|
-
fetchServerAggregations();
|
|
120
|
+
pending = Promise.reject(err);
|
|
108
121
|
}
|
|
109
|
-
|
|
122
|
+
pending
|
|
123
|
+
.then(
|
|
124
|
+
(result) => {
|
|
125
|
+
if (!cancelled) setServerState({ key: requestKey, result, error: null });
|
|
126
|
+
},
|
|
127
|
+
(err: unknown) => {
|
|
128
|
+
if (cancelled) return;
|
|
129
|
+
console.error('[useAggregation] Server aggregation error:', err);
|
|
130
|
+
setServerState({ key: requestKey, result: null, error: err });
|
|
131
|
+
},
|
|
132
|
+
);
|
|
133
|
+
return () => { cancelled = true; };
|
|
134
|
+
}, [shouldFetch, dataSource, requestKey]);
|
|
135
|
+
|
|
136
|
+
// An outcome only counts for the request it answered: while a new request is pending, or after it
|
|
137
|
+
// failed, there is no server result (the footer shows '—'), never the previous request's totals.
|
|
138
|
+
const current = serverState && serverState.key === requestKey ? serverState : null;
|
|
139
|
+
const isLoading = shouldFetch && current === null;
|
|
140
|
+
const error = shouldFetch ? current?.error ?? null : null;
|
|
110
141
|
|
|
111
142
|
const aggregationResult = useMemo<GridAggregationResult>(() => {
|
|
112
143
|
if (!isServerSide) return clientResult;
|
|
@@ -114,8 +145,8 @@ export function useAggregation<R extends GridRowModel>(
|
|
|
114
145
|
if (serverAggregationResults && Object.keys(serverAggregationResults).length > 0) {
|
|
115
146
|
return serverAggregationResults;
|
|
116
147
|
}
|
|
117
|
-
return
|
|
118
|
-
}, [isServerSide, clientResult, serverAggregationResults,
|
|
148
|
+
return current?.result ?? EMPTY_RESULT;
|
|
149
|
+
}, [isServerSide, clientResult, serverAggregationResults, current]);
|
|
119
150
|
|
|
120
151
|
return { aggregationResult, isLoading, error };
|
|
121
152
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { GridColumnGroupingModel } from '../../types';
|
|
3
|
+
import type { UseColumnReorderReturn } from '../useColumnReorder';
|
|
4
|
+
import { canReorderWithinColumnGroups } from '../../utils/columnGroups';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Header drag-reorder under `columnGroupingModel`: a column can be dropped only onto a column of the
|
|
8
|
+
* same innermost group (or, for an ungrouped column, onto another ungrouped column), so a drag can
|
|
9
|
+
* never split a group. A disallowed target is not a drop target (no dragover `preventDefault`, no
|
|
10
|
+
* highlight) and a drop on it only ends the drag.
|
|
11
|
+
*/
|
|
12
|
+
export function useColumnGroupReorderGuard(
|
|
13
|
+
columnGroupingModel: GridColumnGroupingModel | undefined,
|
|
14
|
+
handlers: UseColumnReorderReturn,
|
|
15
|
+
): UseColumnReorderReturn {
|
|
16
|
+
const { draggedColumn, onDragOver, onDrop, onDragEnd } = handlers;
|
|
17
|
+
|
|
18
|
+
const guarded = useMemo(() => {
|
|
19
|
+
if (!columnGroupingModel || columnGroupingModel.length === 0) return null;
|
|
20
|
+
const allowed = (target: string) =>
|
|
21
|
+
draggedColumn === null || canReorderWithinColumnGroups(columnGroupingModel, draggedColumn, target);
|
|
22
|
+
return {
|
|
23
|
+
onDragOver: onDragOver && ((field: string) => (allowed(field) ? onDragOver(field) : () => {})),
|
|
24
|
+
onDrop: onDrop && ((field: string) => (allowed(field)
|
|
25
|
+
? onDrop(field)
|
|
26
|
+
: (event: React.DragEvent) => {
|
|
27
|
+
event.preventDefault();
|
|
28
|
+
onDragEnd?.();
|
|
29
|
+
})),
|
|
30
|
+
};
|
|
31
|
+
}, [columnGroupingModel, draggedColumn, onDragOver, onDrop, onDragEnd]);
|
|
32
|
+
|
|
33
|
+
return guarded ? { ...handlers, ...guarded } : handlers;
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
import type { GridRowId } from '../../types';
|
|
3
|
+
|
|
4
|
+
export interface UseDetailPanelHeightsResult {
|
|
5
|
+
/** Rendered heights of `'auto'` detail panels, by row id. Pass to `useLayout`. */
|
|
6
|
+
detailPanelHeights: Map<GridRowId, number>;
|
|
7
|
+
/** Stable callback the panels report their rendered height through. */
|
|
8
|
+
reportDetailPanelHeight: (rowId: GridRowId, height: number) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Keeps the measured heights of `'auto'` detail panels, so the layout reserves what the panel
|
|
13
|
+
* really renders at instead of a guess. A height is kept after its panel collapses or scrolls out
|
|
14
|
+
* of the render window: it is the best estimate for the next time the panel is shown.
|
|
15
|
+
*/
|
|
16
|
+
export function useDetailPanelHeights(): UseDetailPanelHeightsResult {
|
|
17
|
+
const [detailPanelHeights, setDetailPanelHeights] = useState<Map<GridRowId, number>>(() => new Map());
|
|
18
|
+
|
|
19
|
+
const reportDetailPanelHeight = useCallback((rowId: GridRowId, height: number) => {
|
|
20
|
+
setDetailPanelHeights(prev => {
|
|
21
|
+
if (prev.get(rowId) === height) return prev;
|
|
22
|
+
const next = new Map(prev);
|
|
23
|
+
next.set(rowId, height);
|
|
24
|
+
return next;
|
|
25
|
+
});
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
return { detailPanelHeights, reportDetailPanelHeight };
|
|
29
|
+
}
|
|
@@ -1,117 +1,165 @@
|
|
|
1
|
-
import { useEffect, useCallback } from 'react';
|
|
1
|
+
import { useEffect, useCallback, useLayoutEffect, useRef } from 'react';
|
|
2
2
|
import type { GridColDef, GridRowModel, GridRowId } from '../../types';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
import { getCellValue } from '../../utils/values';
|
|
4
|
+
import { getExportColumns, formatExportValue } from '../../utils/export/exportShared';
|
|
5
|
+
import { CHECKBOX_FIELD, EXPAND_FIELD, REORDER_FIELD } from '../../utils/focus';
|
|
6
|
+
|
|
7
|
+
export interface UseGridClipboardProps {
|
|
8
|
+
/**
|
|
9
|
+
* The live selection. Read at copy time, so a copy made right after a selection change (from
|
|
10
|
+
* `onRowSelectionModelChange`, or after `apiRef.selectRows`) sees the new selection.
|
|
11
|
+
*/
|
|
12
|
+
getSelectedRowIds: () => Iterable<GridRowId>;
|
|
13
|
+
/** The columns on screen, in screen order (hidden columns left out). */
|
|
14
|
+
getColumns: () => GridColDef[];
|
|
15
|
+
/**
|
|
16
|
+
* Every data row the selection can refer to, in display order: pinned rows, rows on other
|
|
17
|
+
* pages and rows inside collapsed groups included; rows removed by the filter and synthetic
|
|
18
|
+
* group rows left out.
|
|
19
|
+
*/
|
|
20
|
+
getRows: () => GridRowModel[];
|
|
21
|
+
/** The grid's key for a row. Never the consumer's getRowId on a grid-made row. */
|
|
9
22
|
getRowId: (row: GridRowModel) => GridRowId;
|
|
23
|
+
/**
|
|
24
|
+
* Returns the grid's root element. When given, Ctrl/Cmd+C only copies while focus is inside it,
|
|
25
|
+
* so a page with several grids (or other content) is not affected by a grid that is not in use.
|
|
26
|
+
*/
|
|
27
|
+
getRootElement?: () => HTMLElement | null;
|
|
28
|
+
/** Turns the Ctrl/Cmd+C shortcut off. `copySelectedRows` still works. */
|
|
29
|
+
disableKeyboardShortcut?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Grid-made columns that never hold row data. */
|
|
33
|
+
const SYSTEM_FIELDS = new Set<string>([CHECKBOX_FIELD, EXPAND_FIELD, REORDER_FIELD]);
|
|
34
|
+
|
|
35
|
+
const TSV_SPECIAL = /[\t\n\r"]/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One TSV field as Excel, Google Sheets and LibreOffice read it: a field holding a tab, line break
|
|
39
|
+
* or double quote is wrapped in double quotes, with inner quotes doubled.
|
|
40
|
+
*/
|
|
41
|
+
export function escapeTsvField(text: string): string {
|
|
42
|
+
return TSV_SPECIAL.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The copied columns: the on-screen columns minus system columns and `exportable: false` ones. */
|
|
46
|
+
export function getClipboardColumns(columns: GridColDef[]): GridColDef[] {
|
|
47
|
+
return getExportColumns(columns).filter(col => !SYSTEM_FIELDS.has(col.field));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Header line plus one line per row, with values read and formatted the way the cells show them. */
|
|
51
|
+
export function buildClipboardTsv(rows: GridRowModel[], columns: GridColDef[]): string {
|
|
52
|
+
const header = columns.map(col => escapeTsvField(col.headerName || col.field)).join('\t');
|
|
53
|
+
const lines = rows.map(row =>
|
|
54
|
+
columns
|
|
55
|
+
.map(col => escapeTsvField(formatExportValue(row, col, getCellValue(row, col.field, col))))
|
|
56
|
+
.join('\t')
|
|
57
|
+
);
|
|
58
|
+
return [header, ...lines].join('\n');
|
|
10
59
|
}
|
|
11
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The textarea + execCommand fallback, for browsers without the async Clipboard API (plain http)
|
|
63
|
+
* or when it rejects. It has to focus the textarea, so focus and the page selection are put back
|
|
64
|
+
* afterwards: the focused grid cell (or button) keeps focus.
|
|
65
|
+
*/
|
|
12
66
|
function copyTextSynchronous(text: string): boolean {
|
|
67
|
+
if (typeof document.execCommand !== 'function') return false;
|
|
68
|
+
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
69
|
+
const selection = document.getSelection();
|
|
70
|
+
const previousRanges: Range[] = [];
|
|
71
|
+
if (selection) {
|
|
72
|
+
for (let i = 0; i < selection.rangeCount; i++) previousRanges.push(selection.getRangeAt(i));
|
|
73
|
+
}
|
|
74
|
+
|
|
13
75
|
const textarea = document.createElement('textarea');
|
|
14
76
|
textarea.value = text;
|
|
77
|
+
textarea.setAttribute('readonly', '');
|
|
78
|
+
textarea.setAttribute('aria-hidden', 'true');
|
|
15
79
|
textarea.style.cssText = 'position:fixed;top:-9999px;left:-9999px;opacity:0;';
|
|
16
80
|
document.body.appendChild(textarea);
|
|
17
|
-
textarea.focus();
|
|
18
|
-
textarea.select();
|
|
19
81
|
let success = false;
|
|
20
82
|
try {
|
|
83
|
+
textarea.focus({ preventScroll: true });
|
|
84
|
+
textarea.select();
|
|
21
85
|
success = document.execCommand('copy');
|
|
22
86
|
} finally {
|
|
23
87
|
document.body.removeChild(textarea);
|
|
88
|
+
if (previousFocus && previousFocus.isConnected) previousFocus.focus({ preventScroll: true });
|
|
89
|
+
if (selection) {
|
|
90
|
+
selection.removeAllRanges();
|
|
91
|
+
previousRanges.forEach(range => selection.addRange(range));
|
|
92
|
+
}
|
|
24
93
|
}
|
|
25
94
|
return success;
|
|
26
95
|
}
|
|
27
96
|
|
|
28
97
|
/**
|
|
29
|
-
* Writes text to clipboard
|
|
30
|
-
*
|
|
31
|
-
* and navigator.clipboard for modern browser support.
|
|
98
|
+
* Writes text to the clipboard: the async Clipboard API when there is one, the execCommand
|
|
99
|
+
* fallback when it is missing or rejects. Rejects when neither worked.
|
|
32
100
|
*/
|
|
33
101
|
async function writeToClipboard(text: string): Promise<void> {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
fallbackSuccess = copyTextSynchronous(text);
|
|
37
|
-
} catch {
|
|
38
|
-
// Ignore fallback errors
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
|
102
|
+
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
|
|
103
|
+
if (clipboard && typeof clipboard.writeText === 'function') {
|
|
42
104
|
try {
|
|
43
|
-
await
|
|
105
|
+
await clipboard.writeText(text);
|
|
44
106
|
return;
|
|
45
|
-
} catch (
|
|
46
|
-
if (
|
|
47
|
-
throw
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (copyTextSynchronous(text)) return;
|
|
109
|
+
throw error;
|
|
48
110
|
}
|
|
49
|
-
}
|
|
111
|
+
}
|
|
112
|
+
if (!copyTextSynchronous(text)) {
|
|
50
113
|
throw new Error('Clipboard API not available and fallback failed.');
|
|
51
114
|
}
|
|
52
115
|
}
|
|
53
116
|
|
|
117
|
+
/** Ctrl/Cmd+C, also with Caps Lock on ("C") and on non-Latin layouts (the key printed "c" is KeyC). */
|
|
118
|
+
function isCopyShortcut(event: KeyboardEvent): boolean {
|
|
119
|
+
if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return false;
|
|
120
|
+
const key = event.key ?? '';
|
|
121
|
+
if (key.toLowerCase() === 'c') return true;
|
|
122
|
+
// A layout whose KeyC types a non-Latin letter (Cyrillic, Greek, ...): browsers copy on KeyC.
|
|
123
|
+
// A Latin layout that puts another letter there (Dvorak) copies on its own "c" key instead.
|
|
124
|
+
return event.code === 'KeyC' && !/^[a-z]$/i.test(key);
|
|
125
|
+
}
|
|
126
|
+
|
|
54
127
|
/**
|
|
55
128
|
* Hook to handle clipboard operations.
|
|
56
129
|
* Copies selected rows as Tab-Separated Values (TSV) on Ctrl+C / Cmd+C.
|
|
57
130
|
* Also exposes `copySelectedRows` for programmatic use via apiRef.
|
|
58
131
|
*/
|
|
59
132
|
export function useGridClipboard(props: UseGridClipboardProps) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
133
|
+
const latestRef = useRef(props);
|
|
134
|
+
useLayoutEffect(() => {
|
|
135
|
+
latestRef.current = props;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/** Copies the selected rows. Resolves without writing when no selected row is found; rejects when the write fails. */
|
|
139
|
+
const copySelectedRows = useCallback(async (): Promise<void> => {
|
|
140
|
+
const { getSelectedRowIds, getColumns, getRows, getRowId } = latestRef.current;
|
|
141
|
+
const selected = new Set<GridRowId>(getSelectedRowIds());
|
|
142
|
+
if (selected.size === 0) return;
|
|
143
|
+
|
|
144
|
+
const rowsToCopy = getRows().filter(row => selected.has(getRowId(row)));
|
|
71
145
|
if (rowsToCopy.length === 0) return;
|
|
72
146
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
// Header row
|
|
77
|
-
const headerRow = visibleColumns
|
|
78
|
-
.map((col) => col.headerName || col.field)
|
|
79
|
-
.join('\t');
|
|
80
|
-
|
|
81
|
-
// Data rows
|
|
82
|
-
const dataRows = rowsToCopy
|
|
83
|
-
.map((row) =>
|
|
84
|
-
visibleColumns
|
|
85
|
-
.map((col) => {
|
|
86
|
-
const value = row[col.field];
|
|
87
|
-
if (col.valueFormatter) {
|
|
88
|
-
return col.valueFormatter({ value, row, field: col.field });
|
|
89
|
-
}
|
|
90
|
-
return value ?? '';
|
|
91
|
-
})
|
|
92
|
-
.join('\t')
|
|
93
|
-
)
|
|
94
|
-
.join('\n');
|
|
95
|
-
|
|
96
|
-
const fullContent = `${headerRow}\n${dataRows}`;
|
|
147
|
+
const text = buildClipboardTsv(rowsToCopy, getClipboardColumns(getColumns()));
|
|
148
|
+
await writeToClipboard(text);
|
|
149
|
+
}, []);
|
|
97
150
|
|
|
98
|
-
|
|
99
|
-
await writeToClipboard(fullContent);
|
|
100
|
-
|
|
101
|
-
} catch (err) {
|
|
102
|
-
console.error('[OpenGridX] Failed to copy to clipboard:', err);
|
|
103
|
-
}
|
|
104
|
-
}, [selectedRowIds, columns, getVisibleRows, getRowId]);
|
|
151
|
+
const disableKeyboardShortcut = Boolean(props.disableKeyboardShortcut);
|
|
105
152
|
|
|
106
|
-
// Keyboard shortcut: Ctrl+C / Cmd+C
|
|
153
|
+
// Keyboard shortcut: Ctrl+C / Cmd+C. Registered once; it reads the latest props through the ref.
|
|
107
154
|
useEffect(() => {
|
|
155
|
+
if (disableKeyboardShortcut) return;
|
|
108
156
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
109
|
-
if (
|
|
157
|
+
if (event.defaultPrevented || !isCopyShortcut(event)) return;
|
|
110
158
|
|
|
111
159
|
// Don't intercept when user is typing in an input / editor
|
|
112
|
-
const activeEl = document.activeElement as HTMLElement;
|
|
160
|
+
const activeEl = document.activeElement as HTMLElement | null;
|
|
113
161
|
const tag = activeEl?.tagName.toLowerCase();
|
|
114
|
-
const type = (activeEl as HTMLInputElement)?.type;
|
|
162
|
+
const type = (activeEl as HTMLInputElement | null)?.type;
|
|
115
163
|
|
|
116
164
|
// Skip capturing Ctrl+C if we are genuinely inside a text input
|
|
117
165
|
if (
|
|
@@ -122,12 +170,24 @@ export function useGridClipboard(props: UseGridClipboardProps) {
|
|
|
122
170
|
|
|
123
171
|
if (activeEl?.isContentEditable) return;
|
|
124
172
|
|
|
125
|
-
|
|
173
|
+
// Only the grid that has focus copies.
|
|
174
|
+
const { getRootElement } = latestRef.current;
|
|
175
|
+
if (getRootElement) {
|
|
176
|
+
const root = getRootElement();
|
|
177
|
+
if (!root || !activeEl || !root.contains(activeEl)) return;
|
|
178
|
+
}
|
|
179
|
+
// A text selection on the page is the user's copy target: leave it to the browser.
|
|
180
|
+
const selection = window.getSelection();
|
|
181
|
+
if (selection && !selection.isCollapsed && selection.toString() !== '') return;
|
|
182
|
+
|
|
183
|
+
copySelectedRows().catch((err: unknown) => {
|
|
184
|
+
console.error('[OpenGridX] Failed to copy to clipboard:', err);
|
|
185
|
+
});
|
|
126
186
|
};
|
|
127
187
|
|
|
128
188
|
window.addEventListener('keydown', handleKeyDown);
|
|
129
189
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
130
|
-
}, [copySelectedRows]);
|
|
190
|
+
}, [copySelectedRows, disableKeyboardShortcut]);
|
|
131
191
|
|
|
132
192
|
return { copySelectedRows };
|
|
133
193
|
}
|