@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
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GridColDef,
|
|
3
|
+
GridFilterGroup,
|
|
4
|
+
GridFilterItem,
|
|
5
|
+
GridFilterModel,
|
|
6
|
+
GridPivotAggFn,
|
|
7
|
+
GridPivotModel,
|
|
8
|
+
GridPivotValueField,
|
|
9
|
+
GridRenderCellParams,
|
|
10
|
+
GridRowId,
|
|
11
|
+
GridRowModel,
|
|
12
|
+
GridSortItem,
|
|
13
|
+
} from '../../types';
|
|
14
|
+
import {
|
|
15
|
+
AGGREGATION_FUNCTIONS,
|
|
16
|
+
formatAggregateForColumn,
|
|
17
|
+
isAggregationAllowed,
|
|
18
|
+
isEmptyAggregateValue,
|
|
19
|
+
} from '../aggregation';
|
|
20
|
+
import { createRowFilter } from '../filtering';
|
|
21
|
+
import { compareValues } from '../sorting';
|
|
22
|
+
import { buildColumnLookup } from '../columnLookup';
|
|
23
|
+
import { getCellValue, getFormattedValue } from '../values';
|
|
24
|
+
|
|
25
|
+
/** Row id of the Grand Total row that closes every pivot. */
|
|
26
|
+
export const PIVOT_GRAND_TOTAL_ID = '__pivot_grand_total__';
|
|
27
|
+
/**
|
|
28
|
+
* Prefix of every pivot data row id. Pivot rows are keyed by their row-field values
|
|
29
|
+
* (`__pivot_row__:["North","Q1"]`), so their ids never collide with source row ids (a selection
|
|
30
|
+
* made on the source rows never marks an unrelated pivot row) and stay the same for the same
|
|
31
|
+
* group when the source data changes.
|
|
32
|
+
*/
|
|
33
|
+
export const PIVOT_ROW_ID_PREFIX = '__pivot_row__:';
|
|
34
|
+
const pivotRowId = (groupKey: string): string => `${PIVOT_ROW_ID_PREFIX}${JSON.stringify(groupKey.split(SEP_COL))}`;
|
|
35
|
+
const GRAND_TOTAL_LABEL = 'Grand Total';
|
|
36
|
+
|
|
37
|
+
// Joins the values of several column fields into one column key.
|
|
38
|
+
const SEP_COL = '\u0000';
|
|
39
|
+
// Separates the parts of a generated value-column field: `[colKey␟]field␟aggFn`.
|
|
40
|
+
export const PIVOT_FIELD_SEPARATOR = '\u001f';
|
|
41
|
+
// A row field named `id` cannot keep its label under `id`, which must stay the unique pivot row id.
|
|
42
|
+
const ID_LABEL_KEY = `${PIVOT_FIELD_SEPARATOR}id`;
|
|
43
|
+
const labelKey = (field: string) => (field === 'id' ? ID_LABEL_KEY : field);
|
|
44
|
+
|
|
45
|
+
export interface PivotResult {
|
|
46
|
+
/** Pivot data rows followed by the Grand Total row (omitted when there are no data rows). */
|
|
47
|
+
pivotRows: GridRowModel[];
|
|
48
|
+
/** Row-label columns (one per row field) followed by one value column per column key and value field. */
|
|
49
|
+
pivotColumns: GridColDef[];
|
|
50
|
+
/** The distinct column-field value combinations, in display order. */
|
|
51
|
+
colKeys: string[];
|
|
52
|
+
/** `false` when the model has no usable row field or value field. */
|
|
53
|
+
isValid: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PivotOptions {
|
|
57
|
+
/**
|
|
58
|
+
* Filter applied to the generated pivot rows (conditions on generated value columns). Rows it
|
|
59
|
+
* removes are also left out of the Grand Total, so the total always matches the rows shown.
|
|
60
|
+
*/
|
|
61
|
+
outputFilterModel?: GridFilterModel;
|
|
62
|
+
/** Sort applied to the pivot data rows. The Grand Total row always stays last. */
|
|
63
|
+
sortModel?: GridSortItem[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const EMPTY_PIVOT_RESULT: PivotResult = { pivotRows: [], pivotColumns: [], colKeys: [], isValid: false };
|
|
67
|
+
|
|
68
|
+
// Groups are keyed by each value's display string, as row grouping does, so 1 and '1' share a group;
|
|
69
|
+
// null, undefined and '' all key to '' and form one blank group.
|
|
70
|
+
const keyOf = (values: unknown[]): string => values.map((v) => String(v ?? '')).join(SEP_COL);
|
|
71
|
+
|
|
72
|
+
// The blank group is labelled null whichever blank value was seen first.
|
|
73
|
+
const normalizeLabel = (v: unknown): unknown => (v === undefined || v === '' ? null : v);
|
|
74
|
+
|
|
75
|
+
// Column-field values are ordered by their raw value: numbers and dates numerically, strings
|
|
76
|
+
// naturally ('9' before '10'), blanks last.
|
|
77
|
+
function compareDimension(a: unknown, b: unknown): number {
|
|
78
|
+
const aBlank = a == null;
|
|
79
|
+
const bBlank = b == null;
|
|
80
|
+
if (aBlank || bBlank) return aBlank === bBlank ? 0 : aBlank ? 1 : -1;
|
|
81
|
+
if (typeof a === 'number' && typeof b === 'number') return a - b;
|
|
82
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();
|
|
83
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') return Number(a) - Number(b);
|
|
84
|
+
return String(a).localeCompare(String(b), undefined, { numeric: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function compareTuples(a: unknown[], b: unknown[]): number {
|
|
88
|
+
for (let i = 0; i < a.length; i++) {
|
|
89
|
+
const c = compareDimension(a[i], b[i]);
|
|
90
|
+
if (c !== 0) return c;
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A number, or a Date for min / max of dates.
|
|
96
|
+
function aggregate(values: unknown[] | undefined, fn: GridPivotAggFn): number | Date | null {
|
|
97
|
+
if (!values || values.length === 0) return null;
|
|
98
|
+
const result = AGGREGATION_FUNCTIONS[fn](values);
|
|
99
|
+
return typeof result === 'number' || result instanceof Date ? result : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const isFilterGroup = (item: GridFilterItem | GridFilterGroup): item is GridFilterGroup =>
|
|
103
|
+
'logicOperator' in item && 'items' in item;
|
|
104
|
+
|
|
105
|
+
const hasFilterItems = (model?: GridFilterModel): model is GridFilterModel =>
|
|
106
|
+
Boolean(model && model.items && model.items.length > 0);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Splits a filter model for pivot mode. Conditions on generated value columns (fields the source
|
|
110
|
+
* columns do not have) can only be evaluated on pivot rows; everything else, including the quick
|
|
111
|
+
* filter and conditions on row-field columns, is applied to the source rows before pivoting.
|
|
112
|
+
*/
|
|
113
|
+
export function splitPivotFilterModel(
|
|
114
|
+
filterModel: GridFilterModel,
|
|
115
|
+
sourceFields: ReadonlySet<string>,
|
|
116
|
+
): { source: GridFilterModel; output: GridFilterModel } {
|
|
117
|
+
const isGenerated = (field: string) => !sourceFields.has(field) && field.includes(PIVOT_FIELD_SEPARATOR);
|
|
118
|
+
const targetsPivotRows = (item: GridFilterItem | GridFilterGroup): boolean =>
|
|
119
|
+
isFilterGroup(item)
|
|
120
|
+
? item.items.length > 0 && item.items.every(targetsPivotRows)
|
|
121
|
+
: isGenerated(item.field);
|
|
122
|
+
|
|
123
|
+
const sourceItems: (GridFilterItem | GridFilterGroup)[] = [];
|
|
124
|
+
const outputItems: (GridFilterItem | GridFilterGroup)[] = [];
|
|
125
|
+
for (const item of filterModel.items ?? []) {
|
|
126
|
+
(targetsPivotRows(item) ? outputItems : sourceItems).push(item);
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
source: { ...filterModel, items: sourceItems },
|
|
130
|
+
output: { items: outputItems, logicOperator: filterModel.logicOperator },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface GroupEntry {
|
|
135
|
+
labels: unknown[];
|
|
136
|
+
/** First source row of the group: the `row` given to the row-field column's formatter and renderer. */
|
|
137
|
+
sample: GridRowModel;
|
|
138
|
+
/** Column key → value field → raw values. */
|
|
139
|
+
cells: Map<string, Map<string, unknown[]>>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface ColumnKeyInfo {
|
|
143
|
+
values: unknown[];
|
|
144
|
+
/** First source row with this column key: the `row` given to the header label's formatter. */
|
|
145
|
+
sample?: GridRowModel;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Builds pivot rows and columns from source rows. Values are read through each column's
|
|
150
|
+
* `valueGetter`, aggregated with the shared `lib/utils/aggregation` functions, and formatted with
|
|
151
|
+
* `formatAggregateForColumn` (the source column's `valueFormatter` for sum / avg / min / max, with the
|
|
152
|
+
* pivot row as its `row`, as for group rows and exports). Row and column fields on a
|
|
153
|
+
* `groupable: false` column, and value fields on an `aggregable: false` column or whose function
|
|
154
|
+
* the column's `availableAggregationFunctions` does not allow, are skipped.
|
|
155
|
+
*/
|
|
156
|
+
export function computePivot(
|
|
157
|
+
rows: GridRowModel[],
|
|
158
|
+
columns: GridColDef[],
|
|
159
|
+
model: GridPivotModel,
|
|
160
|
+
options: PivotOptions = {},
|
|
161
|
+
): PivotResult {
|
|
162
|
+
const colDefMap = new Map(columns.map((c) => [c.field, c]));
|
|
163
|
+
const isGroupable = (f: string) => colDefMap.get(f)?.groupable !== false;
|
|
164
|
+
const rowFields = model.rowFields.filter(isGroupable);
|
|
165
|
+
const columnFields = model.columnFields.filter(isGroupable);
|
|
166
|
+
|
|
167
|
+
const seenValueFields = new Set<string>();
|
|
168
|
+
const valueFields: GridPivotValueField[] = model.valueFields.filter((vf) => {
|
|
169
|
+
if (!isAggregationAllowed(colDefMap.get(vf.field), vf.aggFn)) return false;
|
|
170
|
+
const id = `${vf.field}${PIVOT_FIELD_SEPARATOR}${vf.aggFn}`;
|
|
171
|
+
if (seenValueFields.has(id)) return false;
|
|
172
|
+
seenValueFields.add(id);
|
|
173
|
+
return true;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (rowFields.length === 0 || valueFields.length === 0) return EMPTY_PIVOT_RESULT;
|
|
177
|
+
|
|
178
|
+
const hasColFields = columnFields.length > 0;
|
|
179
|
+
// Raw values are collected once per distinct field, however many functions use it.
|
|
180
|
+
const valueSourceFields = Array.from(new Set(valueFields.map((vf) => vf.field)));
|
|
181
|
+
const get = (row: GridRowModel, field: string) => getCellValue(row, field, colDefMap.get(field));
|
|
182
|
+
|
|
183
|
+
const groups = new Map<string, GroupEntry>();
|
|
184
|
+
// Without column fields there is exactly one column key, present even when there is no data.
|
|
185
|
+
const colKeyInfo = new Map<string, ColumnKeyInfo>(hasColFields ? [] : [['', { values: [] }]]);
|
|
186
|
+
|
|
187
|
+
for (const row of rows) {
|
|
188
|
+
const labelValues = rowFields.map((f) => get(row, f));
|
|
189
|
+
const rk = keyOf(labelValues);
|
|
190
|
+
let entry = groups.get(rk);
|
|
191
|
+
if (!entry) {
|
|
192
|
+
entry = { labels: labelValues.map(normalizeLabel), sample: row, cells: new Map() };
|
|
193
|
+
groups.set(rk, entry);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const colValues = columnFields.map((f) => get(row, f));
|
|
197
|
+
const ck = hasColFields ? keyOf(colValues) : '';
|
|
198
|
+
const info = colKeyInfo.get(ck);
|
|
199
|
+
if (!info) colKeyInfo.set(ck, { values: colValues.map(normalizeLabel), sample: row });
|
|
200
|
+
else if (!info.sample) info.sample = row;
|
|
201
|
+
|
|
202
|
+
let cell = entry.cells.get(ck);
|
|
203
|
+
if (!cell) {
|
|
204
|
+
cell = new Map();
|
|
205
|
+
entry.cells.set(ck, cell);
|
|
206
|
+
}
|
|
207
|
+
for (const f of valueSourceFields) {
|
|
208
|
+
const v = get(row, f);
|
|
209
|
+
if (isEmptyAggregateValue(v)) continue;
|
|
210
|
+
let bucket = cell.get(f);
|
|
211
|
+
if (!bucket) {
|
|
212
|
+
bucket = [];
|
|
213
|
+
cell.set(f, bucket);
|
|
214
|
+
}
|
|
215
|
+
bucket.push(v);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const colKeys = Array.from(colKeyInfo.keys()).sort((a, b) =>
|
|
220
|
+
compareTuples(colKeyInfo.get(a)!.values, colKeyInfo.get(b)!.values));
|
|
221
|
+
|
|
222
|
+
const cellField = (ck: string, vf: GridPivotValueField) => hasColFields
|
|
223
|
+
? `${ck}${PIVOT_FIELD_SEPARATOR}${vf.field}${PIVOT_FIELD_SEPARATOR}${vf.aggFn}`
|
|
224
|
+
: `${vf.field}${PIVOT_FIELD_SEPARATOR}${vf.aggFn}`;
|
|
225
|
+
|
|
226
|
+
// ── Data rows ────────────────────────────────────────────────────────────
|
|
227
|
+
const entryByRowId = new Map<GridRowId, GroupEntry>();
|
|
228
|
+
let kept: { row: GridRowModel; entry: GroupEntry }[] = [];
|
|
229
|
+
for (const [groupKey, entry] of groups) {
|
|
230
|
+
const row: GridRowModel = { id: pivotRowId(groupKey) };
|
|
231
|
+
rowFields.forEach((f, i) => { row[labelKey(f)] = entry.labels[i]; });
|
|
232
|
+
row.id = pivotRowId(groupKey);
|
|
233
|
+
for (const ck of colKeys) {
|
|
234
|
+
const cell = entry.cells.get(ck);
|
|
235
|
+
for (const vf of valueFields) row[cellField(ck, vf)] = aggregate(cell?.get(vf.field), vf.aggFn);
|
|
236
|
+
}
|
|
237
|
+
entryByRowId.set(row.id, entry);
|
|
238
|
+
kept.push({ row, entry });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Generated value columns are numeric (or dates for min / max of dates); row-label columns keep
|
|
242
|
+
// their source column's type, so filtering and sorting compare them the way the grid does.
|
|
243
|
+
const isRowField = (field: string) => rowFields.includes(field);
|
|
244
|
+
const typeOf = (field: string): GridColDef['type'] => (isRowField(field) ? colDefMap.get(field)?.type : 'number');
|
|
245
|
+
|
|
246
|
+
const { outputFilterModel, sortModel } = options;
|
|
247
|
+
if (hasFilterItems(outputFilterModel)) {
|
|
248
|
+
const generatedColumns: GridColDef[] = [];
|
|
249
|
+
for (const ck of colKeys) for (const vf of valueFields) generatedColumns.push({ field: cellField(ck, vf), type: 'number' });
|
|
250
|
+
const predicate = createRowFilter(outputFilterModel, buildColumnLookup(generatedColumns));
|
|
251
|
+
if (predicate) kept = kept.filter(({ row }) => predicate(row));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const sortValue = (row: GridRowModel, field: string) =>
|
|
255
|
+
field === 'id' && isRowField('id') ? row[ID_LABEL_KEY] : row[field];
|
|
256
|
+
if (sortModel && sortModel.length > 0) {
|
|
257
|
+
kept = [...kept].sort((a, b) => {
|
|
258
|
+
for (const item of sortModel) {
|
|
259
|
+
const c = compareValues(sortValue(a.row, item.field), sortValue(b.row, item.field), item.sort, typeOf(item.field));
|
|
260
|
+
if (c !== 0) return c;
|
|
261
|
+
}
|
|
262
|
+
return 0;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── Grand Total: aggregated from the raw values of the rows shown ────────
|
|
267
|
+
const pivotRows = kept.map((k) => k.row);
|
|
268
|
+
if (kept.length > 0) {
|
|
269
|
+
const total: GridRowModel = { id: PIVOT_GRAND_TOTAL_ID };
|
|
270
|
+
rowFields.forEach((f, i) => { total[labelKey(f)] = i === 0 ? GRAND_TOTAL_LABEL : ''; });
|
|
271
|
+
total.id = PIVOT_GRAND_TOTAL_ID;
|
|
272
|
+
for (const ck of colKeys) {
|
|
273
|
+
for (const f of valueSourceFields) {
|
|
274
|
+
const values: unknown[] = [];
|
|
275
|
+
for (const { entry } of kept) {
|
|
276
|
+
const bucket = entry.cells.get(ck)?.get(f);
|
|
277
|
+
if (bucket) for (const v of bucket) values.push(v);
|
|
278
|
+
}
|
|
279
|
+
for (const vf of valueFields) {
|
|
280
|
+
if (vf.field === f) total[cellField(ck, vf)] = aggregate(values, vf.aggFn);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
pivotRows.push(total);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Columns ──────────────────────────────────────────────────────────────
|
|
288
|
+
const firstSample = kept[0]?.entry.sample;
|
|
289
|
+
const groupSample = (id: GridRowId | undefined): GridRowModel | undefined =>
|
|
290
|
+
id === PIVOT_GRAND_TOTAL_ID ? firstSample : id === undefined ? undefined : entryByRowId.get(id)?.sample;
|
|
291
|
+
|
|
292
|
+
const pivotColumns: GridColDef[] = rowFields.map((f) => buildRowFieldColumn(f, colDefMap.get(f), groupSample));
|
|
293
|
+
|
|
294
|
+
for (const ck of colKeys) {
|
|
295
|
+
const info = colKeyInfo.get(ck)!;
|
|
296
|
+
const colLabel = hasColFields
|
|
297
|
+
? columnFields.map((f, i) => {
|
|
298
|
+
const def = colDefMap.get(f);
|
|
299
|
+
const shown = formatDimension(def, f, info.values[i], info.sample);
|
|
300
|
+
return def?.headerName ? `${def.headerName}: ${shown}` : shown;
|
|
301
|
+
}).join(' / ')
|
|
302
|
+
: '';
|
|
303
|
+
|
|
304
|
+
for (const vf of valueFields) {
|
|
305
|
+
const orig = colDefMap.get(vf.field);
|
|
306
|
+
const labelBase = vf.headerName ?? orig?.headerName ?? vf.field;
|
|
307
|
+
pivotColumns.push({
|
|
308
|
+
field: cellField(ck, vf),
|
|
309
|
+
headerName: colLabel ? `${colLabel} — ${labelBase} (${vf.aggFn})` : `${labelBase} (${vf.aggFn})`,
|
|
310
|
+
width: 140,
|
|
311
|
+
type: 'number',
|
|
312
|
+
align: 'right',
|
|
313
|
+
headerAlign: 'right',
|
|
314
|
+
sortable: true,
|
|
315
|
+
valueFormatter: ({ value, row }) => formatAggregateForColumn(value, vf.aggFn, orig, row),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return { pivotRows, pivotColumns, colKeys, isValid: true };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function formatDimension(def: GridColDef | undefined, field: string, value: unknown, sample?: GridRowModel): string {
|
|
324
|
+
const formatted = sample ? getFormattedValue(sample, field, value, def) : undefined;
|
|
325
|
+
if (formatted !== undefined) return formatted;
|
|
326
|
+
return value == null ? '' : String(value);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A row-label column keeps how the source column displays its values (formatter, renderer, type,
|
|
330
|
+
// alignment) and is always shown: the row fields are what the pivot rows are labelled by.
|
|
331
|
+
function buildRowFieldColumn(
|
|
332
|
+
field: string,
|
|
333
|
+
orig: GridColDef | undefined,
|
|
334
|
+
groupSample: (id: GridRowId | undefined) => GridRowModel | undefined,
|
|
335
|
+
): GridColDef {
|
|
336
|
+
const col: GridColDef = {
|
|
337
|
+
field,
|
|
338
|
+
headerName: orig?.headerName ?? field,
|
|
339
|
+
width: orig?.width ?? 140,
|
|
340
|
+
sortable: true,
|
|
341
|
+
hideable: false,
|
|
342
|
+
};
|
|
343
|
+
if (orig) {
|
|
344
|
+
if (orig.type !== undefined) col.type = orig.type;
|
|
345
|
+
if (orig.align !== undefined) col.align = orig.align;
|
|
346
|
+
if (orig.headerAlign !== undefined) col.headerAlign = orig.headerAlign;
|
|
347
|
+
if (orig.description !== undefined) col.description = orig.description;
|
|
348
|
+
if (orig.minWidth !== undefined) col.minWidth = orig.minWidth;
|
|
349
|
+
if (orig.maxWidth !== undefined) col.maxWidth = orig.maxWidth;
|
|
350
|
+
if (orig.valueOptions !== undefined) col.valueOptions = orig.valueOptions;
|
|
351
|
+
}
|
|
352
|
+
if (field === 'id') col.valueGetter = ({ row }) => row[ID_LABEL_KEY];
|
|
353
|
+
|
|
354
|
+
const isTotal = (row: GridRowModel | undefined) => row?.id === PIVOT_GRAND_TOTAL_ID;
|
|
355
|
+
const sourceFormatter = orig?.valueFormatter;
|
|
356
|
+
if (sourceFormatter) {
|
|
357
|
+
col.valueFormatter = ({ value, row }) => isTotal(row)
|
|
358
|
+
? String(value ?? '')
|
|
359
|
+
: sourceFormatter({ value, row: groupSample(row?.id) ?? row, field });
|
|
360
|
+
}
|
|
361
|
+
const sourceRenderer = orig?.renderCell;
|
|
362
|
+
if (sourceRenderer) {
|
|
363
|
+
col.renderCell = (params: GridRenderCellParams) => isTotal(params.row)
|
|
364
|
+
? (params.formattedValue ?? String(params.value ?? ''))
|
|
365
|
+
: sourceRenderer({ ...params, row: groupSample(params.row.id) ?? params.row });
|
|
366
|
+
}
|
|
367
|
+
return col;
|
|
368
|
+
}
|
|
@@ -1,13 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Height of the sticky block at the bottom of the viewport (bottom-pinned rows, their expanded
|
|
3
|
+
* detail panels and the aggregation footer), measured from the DOM.
|
|
4
|
+
*/
|
|
5
|
+
function stickyBottomHeight(viewport: HTMLElement): number {
|
|
6
|
+
const block = viewport.querySelector<HTMLElement>('.ogx__sticky-bottom');
|
|
7
|
+
return block ? block.getBoundingClientRect().height : 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
1
10
|
/**
|
|
2
11
|
* Vertically scrolls the grid viewport so the center (unpinned) row at `centerIndex`
|
|
3
|
-
* is fully visible below the sticky header / top-pinned rows and above the
|
|
4
|
-
* bottom-pinned rows. `cumulativeHeights` is the
|
|
12
|
+
* is fully visible below the sticky header / top-pinned rows and above the sticky
|
|
13
|
+
* bottom block (bottom-pinned rows and aggregation footer). `cumulativeHeights` is the
|
|
14
|
+
* unpinned-row layout; `pinnedBottomHeight` is a lower bound for the bottom block.
|
|
15
|
+
*
|
|
16
|
+
* `cumulativeHeights` includes each row's expanded detail panel. Pass `rowHeight` to bring
|
|
17
|
+
* only the row itself into view: otherwise a panel taller than the viewport pushes its own
|
|
18
|
+
* row out of view above the header.
|
|
5
19
|
*/
|
|
6
20
|
export function scrollRowIntoView(
|
|
7
21
|
viewport: HTMLElement,
|
|
8
22
|
centerIndex: number,
|
|
9
23
|
cumulativeHeights: number[],
|
|
10
24
|
pinnedBottomHeight: number,
|
|
25
|
+
rowHeight?: number,
|
|
11
26
|
): void {
|
|
12
27
|
if (centerIndex < 0 || centerIndex >= cumulativeHeights.length) return;
|
|
13
28
|
const virtualContainer = viewport.querySelector<HTMLElement>('.ogx__virtual-container');
|
|
@@ -17,12 +32,47 @@ export function scrollRowIntoView(
|
|
|
17
32
|
// Everything above the center rows is sticky, so it covers exactly this many
|
|
18
33
|
// pixels at the top of the viewport at any scroll position.
|
|
19
34
|
const stickyTop = virtualContainer.getBoundingClientRect().top - viewport.getBoundingClientRect().top + scrollTop;
|
|
35
|
+
const bottomCover = Math.max(pinnedBottomHeight, stickyBottomHeight(viewport));
|
|
20
36
|
const rowTop = stickyTop + (centerIndex === 0 ? 0 : cumulativeHeights[centerIndex - 1]);
|
|
21
|
-
const rowBottom =
|
|
37
|
+
const rowBottom = rowHeight !== undefined
|
|
38
|
+
? Math.min(rowTop + rowHeight, stickyTop + cumulativeHeights[centerIndex])
|
|
39
|
+
: stickyTop + cumulativeHeights[centerIndex];
|
|
22
40
|
|
|
23
41
|
if (rowTop < scrollTop + stickyTop) {
|
|
24
42
|
viewport.scrollTop = Math.max(0, rowTop - stickyTop);
|
|
25
|
-
} else if (rowBottom > scrollTop + clientHeight -
|
|
26
|
-
viewport.scrollTop = rowBottom - clientHeight +
|
|
43
|
+
} else if (rowBottom > scrollTop + clientHeight - bottomCover) {
|
|
44
|
+
viewport.scrollTop = rowBottom - clientHeight + bottomCover;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface GridColumnScrollMetrics {
|
|
49
|
+
/** Cumulative widths of the unpinned columns (layout). */
|
|
50
|
+
unpinnedAccWidths: number[];
|
|
51
|
+
leftWidth: number;
|
|
52
|
+
rightWidth: number;
|
|
53
|
+
/** Width of all system columns (they precede the data columns). */
|
|
54
|
+
systemColumnsWidth: number;
|
|
55
|
+
/** Width of the system columns that are sticky. */
|
|
56
|
+
pinnedSystemColumnsWidth: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Horizontally scrolls the viewport so the unpinned column at `unpinnedIndex` is fully visible
|
|
61
|
+
* between the sticky left block (pinned system columns + left-pinned columns) and the
|
|
62
|
+
* right-pinned columns.
|
|
63
|
+
*/
|
|
64
|
+
export function scrollColumnIntoView(viewport: HTMLElement, unpinnedIndex: number, metrics: GridColumnScrollMetrics): void {
|
|
65
|
+
const { unpinnedAccWidths, leftWidth, rightWidth, systemColumnsWidth, pinnedSystemColumnsWidth } = metrics;
|
|
66
|
+
if (unpinnedIndex < 0 || unpinnedIndex >= unpinnedAccWidths.length) return;
|
|
67
|
+
const contentStart = systemColumnsWidth + leftWidth;
|
|
68
|
+
const colLeft = contentStart + (unpinnedIndex > 0 ? unpinnedAccWidths[unpinnedIndex - 1] : 0);
|
|
69
|
+
const colRight = contentStart + unpinnedAccWidths[unpinnedIndex];
|
|
70
|
+
const stickyLeft = pinnedSystemColumnsWidth + leftWidth;
|
|
71
|
+
const { scrollLeft, clientWidth } = viewport;
|
|
72
|
+
|
|
73
|
+
if (colLeft < scrollLeft + stickyLeft) {
|
|
74
|
+
viewport.scrollLeft = Math.max(0, colLeft - stickyLeft);
|
|
75
|
+
} else if (colRight > scrollLeft + clientWidth - rightWidth) {
|
|
76
|
+
viewport.scrollLeft = colRight - clientWidth + rightWidth;
|
|
27
77
|
}
|
|
28
78
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GridRowScrollEndParams } from '../../types';
|
|
2
|
+
|
|
3
|
+
/** How close (px) to the bottom of a scroll container counts as the end for `onRowsScrollEnd`. */
|
|
4
|
+
export const ROWS_SCROLL_END_THRESHOLD = 100;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `onRowsScrollEnd` params for a scrolled row container, or null while it is further than
|
|
8
|
+
* ROWS_SCROLL_END_THRESHOLD from the bottom. Shared by the grid viewport and the list view.
|
|
9
|
+
*/
|
|
10
|
+
export function getRowsScrollEndParams(el: HTMLElement): GridRowScrollEndParams | null {
|
|
11
|
+
const { scrollTop, scrollHeight, clientHeight } = el;
|
|
12
|
+
if (scrollHeight - scrollTop - clientHeight >= ROWS_SCROLL_END_THRESHOLD) return null;
|
|
13
|
+
return {
|
|
14
|
+
visibleTop: scrollTop,
|
|
15
|
+
visibleBottom: scrollTop + clientHeight,
|
|
16
|
+
viewportHeight: clientHeight,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -1,55 +1,179 @@
|
|
|
1
1
|
|
|
2
|
-
import type { GridRowModel, GridSortItem } from '../../types';
|
|
2
|
+
import type { GridColDef, GridRowModel, GridSortDirection, GridSortItem } from '../../types';
|
|
3
|
+
import type { GridColumnLookup } from '../columnLookup';
|
|
4
|
+
import { getCellValue, toDate, toNumber } from '../values';
|
|
3
5
|
|
|
4
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Language-aware string order: accented letters sort next to their base letter instead of after
|
|
8
|
+
* 'z', case is ignored, and digit runs compare by value ('item9' before 'item10').
|
|
9
|
+
*/
|
|
10
|
+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'accent' });
|
|
5
11
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
// Values of different kinds are ordered by kind first, so the comparator stays transitive when a
|
|
13
|
+
// column mixes, say, numbers and strings.
|
|
14
|
+
const RANK_NUMBER = 0;
|
|
15
|
+
const RANK_DATE = 1;
|
|
16
|
+
const RANK_BOOLEAN = 2;
|
|
17
|
+
const RANK_STRING = 3;
|
|
18
|
+
const RANK_OTHER = 4;
|
|
9
19
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
/** A normalized sort value. null is the empty bucket (null, undefined, NaN, Invalid Date, unparsable). */
|
|
21
|
+
type SortKey = { rank: number; num: number; str: string } | null;
|
|
22
|
+
|
|
23
|
+
function numberKey(rank: number, num: number): SortKey {
|
|
24
|
+
return { rank, num, str: '' };
|
|
25
|
+
}
|
|
13
26
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Normalize a cell value for sorting. `type: 'number'` columns coerce numeric strings ('10' sorts
|
|
29
|
+
* after '9'), `type: 'date'` columns parse date strings; values that cannot be read go to the empty
|
|
30
|
+
* bucket together with null.
|
|
31
|
+
*/
|
|
32
|
+
function toSortKey(value: unknown, type?: GridColDef['type']): SortKey {
|
|
33
|
+
if (value == null) return null;
|
|
34
|
+
if (type === 'number') {
|
|
35
|
+
const n = toNumber(value);
|
|
36
|
+
return n === null ? null : numberKey(RANK_NUMBER, n);
|
|
37
|
+
}
|
|
38
|
+
if (type === 'date') {
|
|
39
|
+
const date = toDate(value);
|
|
40
|
+
return date ? numberKey(RANK_DATE, date.getTime()) : null;
|
|
41
|
+
}
|
|
42
|
+
switch (typeof value) {
|
|
43
|
+
case 'number':
|
|
44
|
+
return Number.isNaN(value) ? null : numberKey(RANK_NUMBER, value);
|
|
45
|
+
case 'bigint':
|
|
46
|
+
return numberKey(RANK_NUMBER, Number(value));
|
|
47
|
+
case 'boolean':
|
|
48
|
+
return numberKey(RANK_BOOLEAN, value ? 1 : 0);
|
|
49
|
+
case 'string':
|
|
50
|
+
return { rank: RANK_STRING, num: 0, str: value };
|
|
51
|
+
default:
|
|
52
|
+
break;
|
|
18
53
|
}
|
|
54
|
+
if (value instanceof Date) {
|
|
55
|
+
const t = value.getTime();
|
|
56
|
+
return Number.isNaN(t) ? null : numberKey(RANK_DATE, t);
|
|
57
|
+
}
|
|
58
|
+
return { rank: RANK_OTHER, num: 0, str: String(value) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Ascending comparison of two keys. The empty bucket sorts after every value. Never returns NaN. */
|
|
62
|
+
function compareSortKeys(a: SortKey, b: SortKey): number {
|
|
63
|
+
if (a === null && b === null) return 0;
|
|
64
|
+
if (a === null) return 1;
|
|
65
|
+
if (b === null) return -1;
|
|
66
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
67
|
+
if (a.rank === RANK_STRING || a.rank === RANK_OTHER) return collator.compare(a.str, b.str);
|
|
68
|
+
return a.num < b.num ? -1 : a.num > b.num ? 1 : 0;
|
|
69
|
+
}
|
|
19
70
|
|
|
20
|
-
|
|
21
|
-
|
|
71
|
+
function applyDirection(comparison: number, direction: 'asc' | 'desc'): number {
|
|
72
|
+
if (comparison === 0) return 0;
|
|
73
|
+
return direction === 'asc' ? comparison : -comparison;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compare two cell values. Empty values (null, undefined, NaN, Invalid Date) sort last in 'asc'
|
|
78
|
+
* and first in 'desc'. Pass the column `type` to get numeric / date coercion of string values.
|
|
79
|
+
*/
|
|
80
|
+
export function compareValues(
|
|
81
|
+
a: unknown,
|
|
82
|
+
b: unknown,
|
|
83
|
+
direction: 'asc' | 'desc',
|
|
84
|
+
type?: GridColDef['type']
|
|
85
|
+
): number {
|
|
86
|
+
return applyDirection(compareSortKeys(toSortKey(a, type), toSortKey(b, type)), direction);
|
|
87
|
+
}
|
|
22
88
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Compare two rows by a sort model, reading cells through `valueGetter` and using each column's
|
|
91
|
+
* `type`. Returns 0 when every key is equal.
|
|
92
|
+
*/
|
|
93
|
+
export function compareRowsBySortModel(
|
|
94
|
+
a: GridRowModel,
|
|
95
|
+
b: GridRowModel,
|
|
96
|
+
sortModel: readonly GridSortItem[],
|
|
97
|
+
columns?: GridColumnLookup
|
|
98
|
+
): number {
|
|
99
|
+
for (const sortItem of sortModel) {
|
|
100
|
+
const colDef = columns?.byField.get(sortItem.field);
|
|
101
|
+
const comparison = compareValues(
|
|
102
|
+
getCellValue(a, sortItem.field, colDef),
|
|
103
|
+
getCellValue(b, sortItem.field, colDef),
|
|
104
|
+
sortItem.sort,
|
|
105
|
+
colDef?.type
|
|
106
|
+
);
|
|
107
|
+
if (comparison !== 0) return comparison;
|
|
27
108
|
}
|
|
109
|
+
return 0;
|
|
28
110
|
}
|
|
29
111
|
|
|
30
112
|
export function sortRows<R extends GridRowModel>(
|
|
31
113
|
rows: R[],
|
|
32
|
-
sortModel: GridSortItem[]
|
|
114
|
+
sortModel: GridSortItem[],
|
|
115
|
+
columns?: GridColumnLookup
|
|
33
116
|
): R[] {
|
|
34
117
|
if (sortModel.length === 0) {
|
|
35
118
|
return rows;
|
|
36
119
|
}
|
|
120
|
+
return sortItemsBySortModel(rows, sortModel, (row, sortItem) => {
|
|
121
|
+
const colDef = columns?.byField.get(sortItem.field);
|
|
122
|
+
return { value: getCellValue(row, sortItem.field, colDef), type: colDef?.type };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
37
125
|
|
|
38
|
-
|
|
126
|
+
/** The value an item sorts by for one sort key, and the column type used to compare it. */
|
|
127
|
+
export interface GridSortValue {
|
|
128
|
+
value: unknown;
|
|
129
|
+
type?: GridColDef['type'];
|
|
130
|
+
}
|
|
39
131
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
132
|
+
/**
|
|
133
|
+
* Stable sort of any items by a sort model. `readValue` gives the value (and column type) an item
|
|
134
|
+
* sorts by for each sort key; it is called once per item and key, not once per comparison. Used by
|
|
135
|
+
* the flat pipeline and by tree data / row grouping, whose synthetic group rows sort by their
|
|
136
|
+
* grouping value or label instead of a cell.
|
|
137
|
+
*/
|
|
138
|
+
export function sortItemsBySortModel<T>(
|
|
139
|
+
items: readonly T[],
|
|
140
|
+
sortModel: readonly GridSortItem[],
|
|
141
|
+
readValue: (item: T, sortItem: GridSortItem) => GridSortValue
|
|
142
|
+
): T[] {
|
|
143
|
+
if (sortModel.length === 0 || items.length < 2) {
|
|
144
|
+
return [...items];
|
|
145
|
+
}
|
|
44
146
|
|
|
45
|
-
|
|
147
|
+
// Read and normalize every sort value once, instead of calling valueGetter and parsing dates
|
|
148
|
+
// O(n log n) times inside the comparator.
|
|
149
|
+
const keys = sortModel.map(sortItem => items.map(item => {
|
|
150
|
+
const { value, type } = readValue(item, sortItem);
|
|
151
|
+
return toSortKey(value, type);
|
|
152
|
+
}));
|
|
46
153
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
154
|
+
const order = items.map((_, index) => index);
|
|
155
|
+
order.sort((ia, ib) => {
|
|
156
|
+
for (let k = 0; k < sortModel.length; k++) {
|
|
157
|
+
const comparison = applyDirection(compareSortKeys(keys[k][ia], keys[k][ib]), sortModel[k].sort);
|
|
158
|
+
if (comparison !== 0) return comparison;
|
|
50
159
|
}
|
|
51
|
-
return
|
|
160
|
+
return ia - ib;
|
|
52
161
|
});
|
|
53
162
|
|
|
54
|
-
return
|
|
163
|
+
return order.map(index => items[index]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The sort model after setting one column's direction while keeping the others: an existing key
|
|
168
|
+
* keeps its priority and only changes direction, a new key is appended, and `null` removes it.
|
|
169
|
+
*/
|
|
170
|
+
export function upsertSortItem(
|
|
171
|
+
sortModel: readonly GridSortItem[],
|
|
172
|
+
field: string,
|
|
173
|
+
direction: GridSortDirection
|
|
174
|
+
): GridSortItem[] {
|
|
175
|
+
if (!direction) return sortModel.filter(item => item.field !== field);
|
|
176
|
+
const index = sortModel.findIndex(item => item.field === field);
|
|
177
|
+
if (index === -1) return [...sortModel, { field, sort: direction }];
|
|
178
|
+
return sortModel.map((item, i) => (i === index ? { field, sort: direction } : item));
|
|
55
179
|
}
|