@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
|
@@ -2,158 +2,296 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef,
|
|
|
2
2
|
import type {
|
|
3
3
|
GridFilterModel,
|
|
4
4
|
GridFilterItem,
|
|
5
|
+
GridFilterGroup,
|
|
5
6
|
GridColDef,
|
|
6
7
|
GridFilterOperator
|
|
7
8
|
} from '../../types';
|
|
8
|
-
import { getOperatorsForType } from '../../utils/filtering';
|
|
9
|
+
import { getOperatorsForType, NO_VALUE_OPERATORS } from '../../utils/filtering';
|
|
10
|
+
import { toLocalDateString } from '../../utils/values';
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
interface FilterPanelProps {
|
|
13
|
+
export interface FilterPanelProps {
|
|
12
14
|
filterModel: GridFilterModel;
|
|
13
15
|
columns: GridColDef[];
|
|
14
16
|
onFilterModelChange: (model: GridFilterModel) => void;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
type FilterEntry = GridFilterItem | GridFilterGroup;
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
+
const DEBOUNCE_MS = 300;
|
|
22
|
+
|
|
23
|
+
function isFilterItem(entry: FilterEntry): entry is GridFilterItem {
|
|
24
|
+
return 'field' in entry && !('items' in entry);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The root-level item the panel shows and edits for a column: the first one for that field. */
|
|
28
|
+
function getActiveFilter(items: readonly FilterEntry[], field: string): GridFilterItem | undefined {
|
|
29
|
+
return items.find((entry): entry is GridFilterItem => isFilterItem(entry) && entry.field === field);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sameValue(a: unknown, b: unknown): boolean {
|
|
33
|
+
if (Object.is(a, b)) return true;
|
|
34
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
35
|
+
if (Array.isArray(a) && Array.isArray(b)) return a.length === b.length && a.every((v, i) => sameValue(v, b[i]));
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function sameEntry(a: FilterEntry, b: FilterEntry): boolean {
|
|
40
|
+
if (a === b) return true;
|
|
41
|
+
if (isFilterItem(a) && isFilterItem(b)) {
|
|
42
|
+
return a.id === b.id && a.field === b.field && a.operator === b.operator && sameValue(a.value, b.value);
|
|
43
|
+
}
|
|
44
|
+
if (!isFilterItem(a) && !isFilterItem(b)) {
|
|
45
|
+
return a.id === b.id && a.logicOperator === b.logicOperator && sameEntries(a.items, b.items);
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sameEntries(a: readonly FilterEntry[], b: readonly FilterEntry[]): boolean {
|
|
51
|
+
if (a === b) return true;
|
|
52
|
+
return a.length === b.length && a.every((entry, i) => sameEntry(entry, b[i]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface ValueOption {
|
|
56
|
+
key: string;
|
|
57
|
+
value: unknown;
|
|
58
|
+
label: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getValueOptions(col: GridColDef): ValueOption[] {
|
|
62
|
+
return (col.valueOptions ?? []).map((option, index) => (
|
|
63
|
+
option !== null && typeof option === 'object'
|
|
64
|
+
? { key: String(index), value: option.value, label: option.label }
|
|
65
|
+
: { key: String(index), value: option, label: String(option) }
|
|
66
|
+
));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function matchesOption(option: ValueOption, value: unknown): boolean {
|
|
70
|
+
if (Object.is(option.value, value)) return true;
|
|
71
|
+
return value != null && String(option.value).toLowerCase() === String(value).toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Text shown in the value input for a committed value. Never used to emit anything. */
|
|
75
|
+
function toInputText(value: unknown, col: GridColDef): string {
|
|
76
|
+
if (value == null) return '';
|
|
77
|
+
if (col.type === 'date' || value instanceof Date) {
|
|
78
|
+
const day = toLocalDateString(value);
|
|
79
|
+
return day || (typeof value === 'string' ? value : '');
|
|
80
|
+
}
|
|
81
|
+
if (Array.isArray(value)) return value.map(v => String(v)).join(', ');
|
|
82
|
+
return String(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The item value for typed text. isAnyOf takes a comma-separated list. */
|
|
86
|
+
function fromInputText(text: string, operator: GridFilterOperator): unknown {
|
|
87
|
+
if (operator === 'isAnyOf') return text.split(',').map(part => part.trim()).filter(part => part !== '');
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Carry a value over to a newly picked operator: lists for isAnyOf, scalars for the rest. */
|
|
92
|
+
function convertValueForOperator(value: unknown, operator: GridFilterOperator): unknown {
|
|
93
|
+
if (NO_VALUE_OPERATORS.has(operator)) return undefined;
|
|
94
|
+
if (operator === 'isAnyOf') {
|
|
95
|
+
if (value == null || value === '') return [];
|
|
96
|
+
return Array.isArray(value) ? value : [value];
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(value)) return value.length > 0 ? value[0] : '';
|
|
99
|
+
return value ?? '';
|
|
21
100
|
}
|
|
22
101
|
|
|
23
102
|
const FilterRow: React.FC<{
|
|
24
103
|
col: GridColDef;
|
|
25
104
|
item: GridFilterItem | undefined;
|
|
105
|
+
/** Changes when the filter model is changed from outside the panel. */
|
|
106
|
+
resetToken: number;
|
|
26
107
|
onChange: (item: GridFilterItem | null) => void;
|
|
27
|
-
}> = ({ col, item, onChange }) => {
|
|
108
|
+
}> = ({ col, item, resetToken, onChange }) => {
|
|
28
109
|
const filterId = useId();
|
|
29
|
-
const
|
|
110
|
+
const typeOperators = getOperatorsForType(col.type);
|
|
111
|
+
const defaultOperator = typeOperators[0];
|
|
112
|
+
const currentOperator: GridFilterOperator = item?.operator ?? defaultOperator;
|
|
113
|
+
// An operator set programmatically that the column type does not list is still shown, so the
|
|
114
|
+
// select reflects the filter that is really applied.
|
|
115
|
+
const operators = typeOperators.includes(currentOperator) ? typeOperators : [currentOperator, ...typeOperators];
|
|
30
116
|
const isActive = !!item;
|
|
31
|
-
const
|
|
117
|
+
const label = col.headerName || col.field;
|
|
118
|
+
|
|
119
|
+
// Text typed but not committed yet (debounced). null while the input shows the committed value,
|
|
120
|
+
// so opening the panel never re-emits (and never stringifies) an existing value.
|
|
121
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
122
|
+
|
|
123
|
+
// An outside change (Clear all, programmatic reset) discards pending typing.
|
|
124
|
+
const [seenResetToken, setSeenResetToken] = useState(resetToken);
|
|
125
|
+
if (seenResetToken !== resetToken) {
|
|
126
|
+
setSeenResetToken(resetToken);
|
|
127
|
+
setDraft(null);
|
|
128
|
+
}
|
|
32
129
|
|
|
33
|
-
// Local input state to prevent full filter-panel re-render on every keystroke
|
|
34
|
-
const [localValue, setLocalValue] = useState<string>(item?.value != null ? String(item.value) : '');
|
|
35
130
|
const onChangeRef = useRef(onChange);
|
|
36
131
|
const itemRef = useRef(item);
|
|
37
|
-
const
|
|
38
|
-
const operatorRef = useRef(currentOperator);
|
|
132
|
+
const colRef = useRef(col);
|
|
39
133
|
useLayoutEffect(() => {
|
|
40
134
|
onChangeRef.current = onChange;
|
|
41
135
|
itemRef.current = item;
|
|
42
|
-
|
|
43
|
-
operatorRef.current = currentOperator;
|
|
136
|
+
colRef.current = col;
|
|
44
137
|
});
|
|
45
138
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
139
|
+
const buildItem = useCallback((operator: GridFilterOperator, value: unknown): GridFilterItem => ({
|
|
140
|
+
id: itemRef.current?.id ?? `${col.field}-${Date.now()}`,
|
|
141
|
+
field: col.field,
|
|
142
|
+
operator,
|
|
143
|
+
value,
|
|
144
|
+
}), [col.field]);
|
|
145
|
+
|
|
146
|
+
// Commits typed text unless it matches what the item already holds.
|
|
147
|
+
const commitDraft = useCallback((text: string) => {
|
|
148
|
+
const current = itemRef.current;
|
|
149
|
+
const unchanged = current ? text === toInputText(current.value, colRef.current) : text.trim() === '';
|
|
150
|
+
if (unchanged) return;
|
|
151
|
+
const operator = current?.operator ?? defaultOperator;
|
|
152
|
+
onChangeRef.current(buildItem(operator, fromInputText(text, operator)));
|
|
153
|
+
}, [defaultOperator, buildItem]);
|
|
154
|
+
|
|
155
|
+
// Text still waiting for the debounce; flushed when the panel closes (unmounts) meanwhile.
|
|
156
|
+
const pendingDraftRef = useRef<string | null>(null);
|
|
157
|
+
const commitDraftRef = useRef(commitDraft);
|
|
158
|
+
useLayoutEffect(() => {
|
|
159
|
+
pendingDraftRef.current = draft;
|
|
160
|
+
commitDraftRef.current = commitDraft;
|
|
161
|
+
});
|
|
162
|
+
useEffect(() => () => {
|
|
163
|
+
const pending = pendingDraftRef.current;
|
|
164
|
+
pendingDraftRef.current = null;
|
|
165
|
+
if (pending !== null) commitDraftRef.current(pending);
|
|
166
|
+
}, []);
|
|
54
167
|
|
|
55
|
-
// Debounce propagating the value upward (300ms)
|
|
56
168
|
useEffect(() => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const handleOperatorChange = (op: string) => {
|
|
73
|
-
const noValue = NO_VALUE_OPERATORS.has(op);
|
|
74
|
-
const newItem: GridFilterItem = {
|
|
75
|
-
id: item?.id ?? `${col.field}-${Date.now()}`,
|
|
76
|
-
field: col.field,
|
|
77
|
-
operator: op as GridFilterOperator,
|
|
78
|
-
value: noValue ? undefined : (item?.value ?? (col.type === 'boolean' ? 'true' : '')),
|
|
79
|
-
};
|
|
80
|
-
onChange(newItem);
|
|
169
|
+
if (draft === null) return;
|
|
170
|
+
const handle = setTimeout(() => {
|
|
171
|
+
pendingDraftRef.current = null;
|
|
172
|
+
commitDraft(draft);
|
|
173
|
+
setDraft(null);
|
|
174
|
+
}, DEBOUNCE_MS);
|
|
175
|
+
return () => clearTimeout(handle);
|
|
176
|
+
}, [draft, commitDraft]);
|
|
177
|
+
|
|
178
|
+
const handleOperatorChange = (op: GridFilterOperator) => {
|
|
179
|
+
// Keep what the user typed even if the debounce has not committed it yet.
|
|
180
|
+
const value = draft !== null ? fromInputText(draft, op) : item?.value;
|
|
181
|
+
setDraft(null);
|
|
182
|
+
onChange(buildItem(op, convertValueForOperator(value, op)));
|
|
81
183
|
};
|
|
82
184
|
|
|
83
185
|
const handleClear = () => {
|
|
84
|
-
|
|
186
|
+
setDraft(null);
|
|
85
187
|
onChange(null);
|
|
86
188
|
};
|
|
87
189
|
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
190
|
+
const showValue = !NO_VALUE_OPERATORS.has(currentOperator);
|
|
191
|
+
const valueOptions = getValueOptions(col);
|
|
192
|
+
const valueClassName = `ogx-filter__value-input${isActive ? ' ogx-filter__value-input--active' : ''}`;
|
|
193
|
+
const valueLabel = `Filter value for ${label}`;
|
|
194
|
+
|
|
195
|
+
let valueControl: React.ReactNode = null;
|
|
196
|
+
if (showValue && col.type === 'boolean') {
|
|
197
|
+
valueControl = (
|
|
198
|
+
<select
|
|
199
|
+
id={`${filterId}-val`}
|
|
200
|
+
name={`filter-val-${col.field}`}
|
|
201
|
+
className={`${valueClassName} ogx-filter__value-select`}
|
|
202
|
+
value={item?.value == null ? '' : String(item.value)}
|
|
203
|
+
onChange={(e) => {
|
|
204
|
+
if (e.target.value === '') onChange(null);
|
|
205
|
+
else onChange(buildItem(currentOperator, e.target.value));
|
|
206
|
+
}}
|
|
207
|
+
aria-label={valueLabel}
|
|
208
|
+
>
|
|
209
|
+
<option value="">Any</option>
|
|
210
|
+
<option value="true">true</option>
|
|
211
|
+
<option value="false">false</option>
|
|
212
|
+
</select>
|
|
213
|
+
);
|
|
214
|
+
} else if (showValue && col.type === 'singleSelect' && valueOptions.length > 0 && currentOperator === 'isAnyOf') {
|
|
215
|
+
const selected = Array.isArray(item?.value) ? item.value : item?.value == null || item.value === '' ? [] : [item.value];
|
|
216
|
+
valueControl = (
|
|
217
|
+
<select
|
|
218
|
+
id={`${filterId}-val`}
|
|
219
|
+
name={`filter-val-${col.field}`}
|
|
220
|
+
className={`${valueClassName} ogx-filter__value-multiselect`}
|
|
221
|
+
multiple
|
|
222
|
+
size={Math.min(valueOptions.length, 4)}
|
|
223
|
+
value={valueOptions.filter(o => selected.some(v => matchesOption(o, v))).map(o => o.key)}
|
|
224
|
+
onChange={(e) => {
|
|
225
|
+
const keys = new Set(Array.from(e.target.selectedOptions, option => option.value));
|
|
226
|
+
onChange(buildItem('isAnyOf', valueOptions.filter(o => keys.has(o.key)).map(o => o.value)));
|
|
227
|
+
}}
|
|
228
|
+
aria-label={valueLabel}
|
|
229
|
+
>
|
|
230
|
+
{valueOptions.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
|
|
231
|
+
</select>
|
|
232
|
+
);
|
|
233
|
+
} else if (showValue && col.type === 'singleSelect' && valueOptions.length > 0 && (currentOperator === 'is' || currentOperator === 'not')) {
|
|
234
|
+
const current = valueOptions.find(o => matchesOption(o, item?.value));
|
|
235
|
+
valueControl = (
|
|
236
|
+
<select
|
|
237
|
+
id={`${filterId}-val`}
|
|
238
|
+
name={`filter-val-${col.field}`}
|
|
239
|
+
className={`${valueClassName} ogx-filter__value-select`}
|
|
240
|
+
value={current?.key ?? ''}
|
|
241
|
+
onChange={(e) => {
|
|
242
|
+
const option = valueOptions.find(o => o.key === e.target.value);
|
|
243
|
+
onChange(buildItem(currentOperator, option ? option.value : ''));
|
|
244
|
+
}}
|
|
245
|
+
aria-label={valueLabel}
|
|
246
|
+
>
|
|
247
|
+
<option value="">Any</option>
|
|
248
|
+
{valueOptions.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
|
|
249
|
+
</select>
|
|
250
|
+
);
|
|
251
|
+
} else if (showValue) {
|
|
252
|
+
valueControl = (
|
|
253
|
+
<input
|
|
254
|
+
id={`${filterId}-val`}
|
|
255
|
+
name={`filter-val-${col.field}`}
|
|
256
|
+
className={valueClassName}
|
|
257
|
+
type={col.type === 'date' ? 'date' : 'text'}
|
|
258
|
+
value={draft ?? toInputText(item?.value, col)}
|
|
259
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
260
|
+
placeholder={col.type === 'number' ? 'Number…' : 'Value…'}
|
|
261
|
+
aria-label={valueLabel}
|
|
262
|
+
/>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
91
265
|
|
|
92
266
|
return (
|
|
93
267
|
<div className={`ogx-filter__row${isActive ? ' ogx-filter__row--active' : ''}`}>
|
|
94
|
-
{ }
|
|
95
268
|
<span className="ogx-filter__col-label" title={col.field}>
|
|
96
|
-
{
|
|
269
|
+
{label}
|
|
97
270
|
</span>
|
|
98
271
|
|
|
99
|
-
{ }
|
|
100
272
|
<div className="ogx-filter__controls">
|
|
101
|
-
{ }
|
|
102
273
|
<select
|
|
103
274
|
id={`${filterId}-op`}
|
|
104
275
|
name={`filter-op-${col.field}`}
|
|
105
276
|
className={`ogx-filter__op-select${isActive ? ' ogx-filter__op-select--active' : ''}`}
|
|
106
|
-
value={
|
|
107
|
-
onChange={(e) => handleOperatorChange(e.target.value)}
|
|
108
|
-
aria-label={`Filter operator for ${
|
|
277
|
+
value={currentOperator}
|
|
278
|
+
onChange={(e) => handleOperatorChange(e.target.value as GridFilterOperator)}
|
|
279
|
+
aria-label={`Filter operator for ${label}`}
|
|
109
280
|
>
|
|
110
281
|
{operators.map(op => (
|
|
111
282
|
<option key={op} value={op}>{op}</option>
|
|
112
283
|
))}
|
|
113
284
|
</select>
|
|
114
285
|
|
|
115
|
-
{
|
|
116
|
-
{showValue && isBoolean && (
|
|
117
|
-
<select
|
|
118
|
-
id={`${filterId}-val`}
|
|
119
|
-
name={`filter-val-${col.field}`}
|
|
120
|
-
className={`ogx-filter__value-input ogx-filter__value-select${isActive ? ' ogx-filter__value-input--active' : ''}`}
|
|
121
|
-
value={String(item?.value ?? 'true')}
|
|
122
|
-
onChange={(e) => {
|
|
123
|
-
setLocalValue(e.target.value);
|
|
124
|
-
onChange({
|
|
125
|
-
id: item?.id ?? `${col.field}-${Date.now()}`,
|
|
126
|
-
field: col.field,
|
|
127
|
-
operator: currentOperator as GridFilterOperator,
|
|
128
|
-
value: e.target.value,
|
|
129
|
-
});
|
|
130
|
-
}}
|
|
131
|
-
aria-label={`Filter value for ${col.headerName || col.field}`}
|
|
132
|
-
>
|
|
133
|
-
<option value="true">true</option>
|
|
134
|
-
<option value="false">false</option>
|
|
135
|
-
</select>
|
|
136
|
-
)}
|
|
137
|
-
|
|
138
|
-
{showValue && !isBoolean && (
|
|
139
|
-
<input
|
|
140
|
-
id={`${filterId}-val`}
|
|
141
|
-
name={`filter-val-${col.field}`}
|
|
142
|
-
className={`ogx-filter__value-input${isActive ? ' ogx-filter__value-input--active' : ''}`}
|
|
143
|
-
type="text"
|
|
144
|
-
value={localValue}
|
|
145
|
-
onChange={(e) => setLocalValue(e.target.value)}
|
|
146
|
-
placeholder={col.type === 'number' ? 'Number…' : 'Value…'}
|
|
147
|
-
aria-label={`Filter value for ${col.headerName || col.field}`}
|
|
148
|
-
/>
|
|
149
|
-
)}
|
|
286
|
+
{valueControl}
|
|
150
287
|
|
|
151
288
|
{/* Clear button — only when active */}
|
|
152
289
|
{isActive && (
|
|
153
290
|
<button
|
|
291
|
+
type="button"
|
|
154
292
|
className="ogx-filter__row-clear"
|
|
155
293
|
onClick={handleClear}
|
|
156
|
-
aria-label={`Clear filter for ${
|
|
294
|
+
aria-label={`Clear filter for ${label}`}
|
|
157
295
|
title="Clear"
|
|
158
296
|
>
|
|
159
297
|
×
|
|
@@ -167,12 +305,36 @@ const FilterRow: React.FC<{
|
|
|
167
305
|
// ── Root panel ────────────────────────────────────────────────────────────────
|
|
168
306
|
|
|
169
307
|
export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns, onFilterModelChange }) => {
|
|
170
|
-
const items = useMemo<
|
|
308
|
+
const items = useMemo<FilterEntry[]>(() => filterModel.items ?? [], [filterModel.items]);
|
|
171
309
|
const logicOperator = filterModel.logicOperator ?? 'and';
|
|
172
310
|
|
|
311
|
+
// Detect changes made outside the panel (Clear all, a programmatic model) so rows can drop
|
|
312
|
+
// typing that has not been committed yet. The panel's own edits are recognised by content,
|
|
313
|
+
// so a parent that re-creates an equal model object does not count as a change.
|
|
314
|
+
const [lastEmittedItems, setLastEmittedItems] = useState<FilterEntry[] | null>(null);
|
|
315
|
+
const [seenItems, setSeenItems] = useState(items);
|
|
316
|
+
const [resetToken, setResetToken] = useState(0);
|
|
317
|
+
if (seenItems !== items) {
|
|
318
|
+
setSeenItems(items);
|
|
319
|
+
const changed = !sameEntries(seenItems, items);
|
|
320
|
+
const own = lastEmittedItems !== null && sameEntries(lastEmittedItems, items);
|
|
321
|
+
if (changed && !own) setResetToken(token => token + 1);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Replace only the item the row displays. Groups and other items (including further
|
|
325
|
+
// conditions on the same field) pass through untouched.
|
|
173
326
|
const handleItemChange = useCallback((field: string, newItem: GridFilterItem | null) => {
|
|
174
|
-
const
|
|
175
|
-
|
|
327
|
+
const index = items.findIndex(entry => isFilterItem(entry) && entry.field === field);
|
|
328
|
+
let next: FilterEntry[];
|
|
329
|
+
if (index === -1) {
|
|
330
|
+
if (!newItem) return;
|
|
331
|
+
next = [...items, newItem];
|
|
332
|
+
} else if (newItem) {
|
|
333
|
+
next = items.map((entry, i) => (i === index ? newItem : entry));
|
|
334
|
+
} else {
|
|
335
|
+
next = items.filter((_, i) => i !== index);
|
|
336
|
+
}
|
|
337
|
+
setLastEmittedItems(next);
|
|
176
338
|
onFilterModelChange({ ...filterModel, items: next });
|
|
177
339
|
}, [items, filterModel, onFilterModelChange]);
|
|
178
340
|
|
|
@@ -181,14 +343,16 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns,
|
|
|
181
343
|
}, [filterModel, onFilterModelChange]);
|
|
182
344
|
|
|
183
345
|
const filterableColumns = columns.filter(col => col.filterable !== false);
|
|
346
|
+
const shownCount = filterableColumns.filter(col => getActiveFilter(items, col.field)).length;
|
|
347
|
+
const notShownCount = items.length - shownCount;
|
|
184
348
|
|
|
185
349
|
return (
|
|
186
350
|
<>
|
|
187
|
-
{ }
|
|
188
351
|
{items.length > 1 && (
|
|
189
352
|
<div className="ogx-filter__logic-bar">
|
|
190
353
|
<span className="ogx-filter__logic-label">Match:</span>
|
|
191
354
|
<button
|
|
355
|
+
type="button"
|
|
192
356
|
className={`ogx-filter__logic-pill${logicOperator === 'and' ? ' ogx-filter__logic-pill--active' : ''}`}
|
|
193
357
|
onClick={() => handleLogicChange('and')}
|
|
194
358
|
aria-pressed={logicOperator === 'and'}
|
|
@@ -196,6 +360,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns,
|
|
|
196
360
|
ALL (AND)
|
|
197
361
|
</button>
|
|
198
362
|
<button
|
|
363
|
+
type="button"
|
|
199
364
|
className={`ogx-filter__logic-pill${logicOperator === 'or' ? ' ogx-filter__logic-pill--active' : ''}`}
|
|
200
365
|
onClick={() => handleLogicChange('or')}
|
|
201
366
|
aria-pressed={logicOperator === 'or'}
|
|
@@ -205,7 +370,14 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns,
|
|
|
205
370
|
</div>
|
|
206
371
|
)}
|
|
207
372
|
|
|
208
|
-
{
|
|
373
|
+
{notShownCount > 0 && (
|
|
374
|
+
<div className="ogx-filter__hidden-note" role="note">
|
|
375
|
+
{notShownCount === 1
|
|
376
|
+
? '1 more condition (a filter group or a second condition on a column) is applied and not shown here.'
|
|
377
|
+
: `${notShownCount} more conditions (filter groups or further conditions on a column) are applied and not shown here.`}
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
|
|
209
381
|
{filterableColumns.length === 0 ? (
|
|
210
382
|
<div className="ogx-filter__empty">No filterable columns.</div>
|
|
211
383
|
) : (
|
|
@@ -214,6 +386,7 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filterModel, columns,
|
|
|
214
386
|
key={col.field}
|
|
215
387
|
col={col}
|
|
216
388
|
item={getActiveFilter(items, col.field)}
|
|
389
|
+
resetToken={resetToken}
|
|
217
390
|
onChange={(item) => handleItemChange(col.field, item)}
|
|
218
391
|
/>
|
|
219
392
|
))
|