@opencorestack/opengridx 0.1.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/dist/index.d.ts +60 -41
- package/dist/opengridx.es.js +5271 -4660
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +12 -12
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/README.md +14 -0
- package/docs/components/aggregation-footer.md +48 -0
- package/docs/components/datagrid.md +3 -0
- package/docs/components/empty-state.md +50 -0
- package/docs/components/error-overlay.md +40 -0
- package/docs/roadmap.md +20 -1
- package/lib/components/Cell/Cell.tsx +61 -64
- package/lib/components/Cell/GridEditInputCell.tsx +4 -4
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.regression.test.tsx +132 -0
- package/lib/components/DataGrid/DataGrid.smoke.test.tsx +58 -0
- package/lib/components/DataGrid/DataGrid.tsx +396 -1654
- package/lib/components/DataGrid/GridAggregationFooter.tsx +68 -0
- package/lib/components/DataGrid/GridEmptyState.tsx +16 -0
- package/lib/components/DataGrid/GridErrorOverlay.tsx +33 -0
- package/lib/components/DataGrid/GridListView.tsx +116 -0
- package/lib/components/DataGrid/GridPinnedRows.tsx +98 -0
- package/lib/components/DataGrid/GridStandaloneColumnPanel.tsx +124 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +202 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +18 -10
- package/lib/components/Header/ColumnMenu.tsx +2 -2
- package/lib/components/Header/Header.tsx +4 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Row/Row.tsx +46 -45
- package/lib/components/SkeletonRow/SkeletonRow.tsx +9 -2
- package/lib/components/Toolbar/GlobalSearch.tsx +1 -1
- package/lib/components/Toolbar/GridToolbar.tsx +26 -5
- package/lib/components/Toolbar/PivotPanel.tsx +7 -2
- package/lib/components/ui/DragHandleIcon.tsx +1 -1
- package/lib/hooks/core/useDataGrid.regression.test.ts +66 -0
- package/lib/hooks/core/useDataGrid.ts +15 -35
- package/lib/hooks/core/useGridColumns.tsx +228 -0
- package/lib/hooks/core/useGridControlledState.ts +192 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +371 -0
- package/lib/hooks/core/useGridRowPipeline.ts +109 -0
- package/lib/hooks/core/useGridScrollSync.ts +52 -0
- package/lib/hooks/core/useGridStateSnapshot.ts +63 -0
- package/lib/hooks/core/useGridVirtualization.ts +186 -0
- package/lib/hooks/core/useGridVisibleRows.ts +66 -0
- package/lib/hooks/core/useLayout.ts +348 -0
- package/lib/hooks/features/useAggregation.test.ts +135 -0
- package/lib/hooks/features/useAggregation.ts +7 -7
- package/lib/hooks/features/useGridClipboard.ts +1 -1
- package/lib/hooks/features/useGridDataSource.ts +2 -2
- package/lib/hooks/features/useGridEditing.ts +31 -32
- package/lib/hooks/features/usePivot.test.ts +202 -0
- package/lib/hooks/features/usePivot.ts +0 -0
- package/lib/hooks/useColumnReorder.ts +1 -1
- package/lib/hooks/useRowGrouping.ts +14 -14
- package/lib/hooks/useTreeData.ts +1 -1
- package/lib/state/types.ts +1 -1
- package/lib/state/useGridStateStorage.ts +2 -2
- package/lib/types/index.ts +48 -32
- package/lib/utils/export/exportToExcelAdvanced.ts +14 -14
- package/lib/utils/export/index.ts +23 -18
- package/lib/utils/filtering/filtering.test.ts +178 -0
- package/lib/utils/filtering/index.ts +20 -20
- package/lib/utils/sorting/index.ts +1 -1
- package/lib/utils/sorting/sorting.test.ts +88 -0
- package/package.json +12 -5
- package/lib/utils/virtualization/index.ts +0 -106
package/docs/README.md
CHANGED
|
@@ -22,6 +22,9 @@ Detailed reference for the core structural components.
|
|
|
22
22
|
- **[Column Visibility](components/column-visibility.md)** - Managing visible fields
|
|
23
23
|
- **[Column Grouping](components/column-group-header.md)** - Multi-level nested headers
|
|
24
24
|
- **[Column Resizing](components/column-resize.md)** - Dynamic width controls
|
|
25
|
+
- **[Empty State](components/empty-state.md)** - No-rows overlay and `noRowsOverlay` slot
|
|
26
|
+
- **[Error Overlay](components/error-overlay.md)** - Data source error display
|
|
27
|
+
- **[Aggregation Footer](components/aggregation-footer.md)** - Sticky totals row
|
|
25
28
|
---
|
|
26
29
|
|
|
27
30
|
## 🚀 Features
|
|
@@ -55,6 +58,17 @@ Make the grid your own.
|
|
|
55
58
|
|
|
56
59
|
---
|
|
57
60
|
|
|
61
|
+
## 🏛️ Architecture — Internal Hooks
|
|
62
|
+
Reference for core hooks extracted from `DataGrid.tsx`. Intended for contributors.
|
|
63
|
+
|
|
64
|
+
- **[useGridControlledState](architecture/use-grid-controlled-state.md)** - Controlled/uncontrolled state for all 7 state pairs
|
|
65
|
+
- **[useGridRowPipeline](architecture/use-grid-row-pipeline.md)** - Filter → pin → sort → paginate row pipeline
|
|
66
|
+
- **[useGridVirtualization](architecture/use-grid-virtualization.md)** - Render context and scroll-position math
|
|
67
|
+
- **[useGridColumns](architecture/use-grid-columns.md)** - Column management: effective, ordered, visible, reorder handlers
|
|
68
|
+
- **[useGridVisibleRows](architecture/use-grid-visible-rows.md)** - Render window: pinned + virtual center merge
|
|
69
|
+
- **[useGridScrollSync](architecture/use-grid-scroll-sync.md)** - RAF-batched scroll tick, scrollPosRef, onRowsScrollEnd threshold
|
|
70
|
+
- **[useGridStateSnapshot](architecture/use-grid-state-snapshot.md)** - onStateChange callback delivery on sort/filter/pagination/column changes
|
|
71
|
+
|
|
58
72
|
## 🛠️ Project Meta
|
|
59
73
|
- **[Development Roadmap](roadmap.md)** - What's built and what's next
|
|
60
74
|
- **[Research Notes](research/FEATURE_CATALOG.md)** - Comparative analysis and design decisions
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# `<GridAggregationFooter />`
|
|
2
|
+
|
|
3
|
+
Internal component that renders the sticky aggregation totals row pinned to the bottom of the grid. Displays function name labels (e.g. `sum`, `avg`) alongside their computed values for each configured column.
|
|
4
|
+
|
|
5
|
+
## ⚙️ Props
|
|
6
|
+
|
|
7
|
+
| Prop | Type | Description |
|
|
8
|
+
| :--- | :--- | :--- |
|
|
9
|
+
| `columns` | `GridColDef[]` | Ordered list of visible column definitions used to lay out cells. |
|
|
10
|
+
| `aggregationModel` | `GridAggregationModel` | Maps field names to function names (`'sum' \| 'avg' \| 'count' \| 'min' \| 'max'`). |
|
|
11
|
+
| `aggregationResult` | `GridAggregationResult` | Computed values keyed by field name, produced by `useAggregation`. |
|
|
12
|
+
| `columnWidths` | `Record<string, number>` | Runtime pixel widths for each field, kept in sync with user resize actions. |
|
|
13
|
+
| `rowHeight` | `number` | Sets `min-height` on the footer row, matching the grid's configured row height. |
|
|
14
|
+
| `checkboxSelection` | `boolean` | When `true`, inserts a 48px spacer at the start to align with the checkbox column. |
|
|
15
|
+
| `hasDetailPanel` | `boolean` | When `true`, inserts a 48px spacer to align with the expand/collapse column. |
|
|
16
|
+
| `rowReordering` | `boolean` | When `true`, inserts a 48px spacer to align with the drag handle column. |
|
|
17
|
+
|
|
18
|
+
## 🔄 When it renders
|
|
19
|
+
|
|
20
|
+
`GridAggregationFooter` renders when `aggregationModel` has at least one entry and `aggregationResult` contains the corresponding computed values. The footer is sticky — it remains visible when the user scrolls vertically.
|
|
21
|
+
|
|
22
|
+
## 🔢 Value formatting
|
|
23
|
+
|
|
24
|
+
Values are formatted by `formatAggregationValue` from `useAggregation`:
|
|
25
|
+
- `count` → integer (e.g. `42`)
|
|
26
|
+
- `sum` / `avg` / `min` / `max` → `toLocaleString()` with up to 2 decimal places
|
|
27
|
+
|
|
28
|
+
## 🎨 Usage via DataGrid prop
|
|
29
|
+
|
|
30
|
+
```tsx
|
|
31
|
+
<DataGrid
|
|
32
|
+
aggregationModel={{
|
|
33
|
+
salary: 'sum',
|
|
34
|
+
age: 'avg',
|
|
35
|
+
department: 'count'
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The footer renders automatically. No additional configuration required.
|
|
41
|
+
|
|
42
|
+
## ♿ Accessibility
|
|
43
|
+
|
|
44
|
+
The footer row uses `role="row"` and `aria-label="Aggregation totals"`. Each cell uses `role="gridcell"`. The container uses `aria-live="polite"` so screen readers announce value updates when the data changes.
|
|
45
|
+
|
|
46
|
+
## 🔗 Related
|
|
47
|
+
- [DataGrid](datagrid.md)
|
|
48
|
+
- [Aggregation & Pivot](../features/aggregation-pivot.md)
|
|
@@ -68,3 +68,6 @@ Customize internal components using the `slots` prop.
|
|
|
68
68
|
- [Column Visibility](column-visibility.md)
|
|
69
69
|
- [Column Grouping](column-group-header.md)
|
|
70
70
|
- [Column Resizing](column-resize.md)
|
|
71
|
+
- [Empty State](empty-state.md)
|
|
72
|
+
- [Error Overlay](error-overlay.md)
|
|
73
|
+
- [Aggregation Footer](aggregation-footer.md)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# `<GridEmptyState />`
|
|
2
|
+
|
|
3
|
+
Internal component rendered when the grid has no rows to display. Replaces the row viewport with a centered icon and message.
|
|
4
|
+
|
|
5
|
+
## ⚙️ Props
|
|
6
|
+
|
|
7
|
+
| Prop | Type | Description |
|
|
8
|
+
| :--- | :--- | :--- |
|
|
9
|
+
| `noRowsLabel` | `string` | Text shown beneath the empty-state icon. Controlled by the `noRowsLabel` DataGrid prop. |
|
|
10
|
+
| `width` | `number` | Pixel width of the empty-state container, matched to the grid's total column width. |
|
|
11
|
+
|
|
12
|
+
## 🔄 When it renders
|
|
13
|
+
|
|
14
|
+
`GridEmptyState` renders when `allRenderableRows.length === 0` and the grid is not in a loading state. The loading skeleton takes precedence — if `loading={true}` the skeleton rows are shown instead.
|
|
15
|
+
|
|
16
|
+
## 🎨 Customizing via slot
|
|
17
|
+
|
|
18
|
+
Replace the default empty state with any React component using the `noRowsOverlay` slot:
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
function MyEmptyState() {
|
|
22
|
+
return (
|
|
23
|
+
<div style={{ textAlign: 'center', padding: '40px' }}>
|
|
24
|
+
<p>No results match your filter.</p>
|
|
25
|
+
<button onClick={clearFilters}>Clear filters</button>
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
<DataGrid
|
|
31
|
+
slots={{ noRowsOverlay: MyEmptyState }}
|
|
32
|
+
slotProps={{ noRowsOverlay: { /* custom props if needed */ } }}
|
|
33
|
+
/>
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 💬 Custom label (without a full slot replacement)
|
|
37
|
+
|
|
38
|
+
To only change the message text without replacing the whole component, use `noRowsLabel`:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
<DataGrid
|
|
42
|
+
noRowsLabel="No employees found for this department."
|
|
43
|
+
/>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Default value: `"No rows"`
|
|
47
|
+
|
|
48
|
+
## 🔗 Related
|
|
49
|
+
- [DataGrid](datagrid.md)
|
|
50
|
+
- [Loading States](../features/loading-states.md)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# `<GridErrorOverlay />`
|
|
2
|
+
|
|
3
|
+
Internal component rendered when a `GridDataSource` fetch fails. Displays an error icon, a human-readable message, and a **Retry** button that calls `window.location.reload()`.
|
|
4
|
+
|
|
5
|
+
## ⚙️ Props
|
|
6
|
+
|
|
7
|
+
| Prop | Type | Description |
|
|
8
|
+
| :--- | :--- | :--- |
|
|
9
|
+
| `error` | `{ message?: string } \| null` | The error object from `state.dataSource.error`. When `null` the component renders nothing (`null` return). |
|
|
10
|
+
|
|
11
|
+
## 🔄 When it renders
|
|
12
|
+
|
|
13
|
+
`GridErrorOverlay` only appears when using a server-side `dataSource` prop. When a fetch throws, DataGrid catches the error and stores it in internal state. The overlay replaces the row viewport until the user retries.
|
|
14
|
+
|
|
15
|
+
```tsx
|
|
16
|
+
<DataGrid
|
|
17
|
+
dataSource={{
|
|
18
|
+
getRows: async ({ filterModel, sortModel, paginationModel }) => {
|
|
19
|
+
const res = await fetch('/api/employees');
|
|
20
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
23
|
+
}}
|
|
24
|
+
/>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If `getRows` throws, the overlay renders with the thrown error's `message`.
|
|
28
|
+
|
|
29
|
+
## ♿ Accessibility
|
|
30
|
+
|
|
31
|
+
The overlay container uses `role="alert"` and `aria-live="assertive"` so screen readers announce the error immediately when it appears.
|
|
32
|
+
|
|
33
|
+
## 🎨 Customizing
|
|
34
|
+
|
|
35
|
+
`GridErrorOverlay` is not currently exposed as a configurable slot. To implement custom error handling, catch errors in your `dataSource.getRows` and manage the error display in your own component wrapping the grid.
|
|
36
|
+
|
|
37
|
+
## 🔗 Related
|
|
38
|
+
- [DataGrid](datagrid.md)
|
|
39
|
+
- [Data Source](../features/data-source.md)
|
|
40
|
+
- [Loading States](../features/loading-states.md)
|
package/docs/roadmap.md
CHANGED
|
@@ -54,6 +54,20 @@ This document tracks the current status of features in `OpenGridX` and outlines
|
|
|
54
54
|
* **Demo**: `AdvancedExcelExportDemo` in demo app (`Data Management` category).
|
|
55
55
|
* **Docs**: `docs/features/export-guide.md` fully updated with feature comparison table and all options.
|
|
56
56
|
|
|
57
|
+
### Code Quality & Architecture *(completed 2026-07-29 → 2026-07-30)*
|
|
58
|
+
* **DataGrid decomposition**: Extracted `useGridControlledState`, `useGridRowPipeline`, `useGridVirtualization`, `useGridScrollSync`, `useGridVisibleRows`, `useGridColumns` hooks, plus `GridEmptyState`, `GridErrorOverlay`, `GridAggregationFooter`, `GridVirtualRows`, `GridPinnedRows`, `GridStandaloneColumnPanel`, `GridListView` components from `DataGrid.tsx`. Each module has a single clear responsibility with typed params and return interfaces.
|
|
59
|
+
* **Full `any` elimination**: Every `any` across `lib/` replaced with proper types or `unknown`. `GridRowModel` index signature, all cell/value/error params, aggregation functions, slot types, and export utilities are now fully typed. Zero `eslint-disable` suppressions remain — every case fixed at the root.
|
|
60
|
+
* **Regression test suite**: 3 test files covering column-visibility-model persistence, pivot aggregation correctness including grand-total avg and null-value exclusion, and pagination rendering/row key stability.
|
|
61
|
+
* **Pivot null-value fix**: `Number(null) === 0` caused null numeric fields to be silently included as `0` in avg/min/max pivot aggregation. Both the per-row bucket loop and the grand-total bucket loop in `usePivot.ts` now guard with `raw != null` before pushing values.
|
|
62
|
+
|
|
63
|
+
### Bug Fixes *(completed 2026-07-30)*
|
|
64
|
+
* **`onRowClick` never fired when `onCellClick` was registered**: `Cell.tsx` called `e.stopPropagation()` on every cell click, silently preventing events from reaching the row's `onClick` handler. Removed — both callbacks now fire in natural bubble order.
|
|
65
|
+
* **Column visibility panel order not updating after reorder**: Panel received `effectiveColumns` (pre-ordering, definition order) instead of `orderedColumns`. Fixed — panel list now reflects the live reordered sequence.
|
|
66
|
+
* **Reset button ignoring column sequence**: Reset only restored visibility; column order was left wherever the user dragged it. Added `onColumnOrderReset` callback that restores `internalColumnOrder` to original definition order.
|
|
67
|
+
* **`FilterPanel` debounce stale-closure**: Debounce effect read `item`/`col.field`/`currentOperator` from stale closure, previously suppressed with `eslint-disable`. Replaced with `useLayoutEffect` refs-sync pattern.
|
|
68
|
+
* **`FilterPanelDemo` inaccessible**: Missing `slots={{ toolbar: GridToolbar }}` meant the filter panel had no UI entry point. Fixed; demo also migrated to `DocsLayout`.
|
|
69
|
+
* **`EventsDemo` dead handlers**: Fixed broken `onRowClick`, dead filter/column-order callbacks, and three `any`-typed handler signatures.
|
|
70
|
+
|
|
57
71
|
---
|
|
58
72
|
|
|
59
73
|
## 📅 Upcoming Features
|
|
@@ -66,6 +80,11 @@ This document tracks the current status of features in `OpenGridX` and outlines
|
|
|
66
80
|
### DX & Publishing
|
|
67
81
|
* **npm publish**: Finalize `package.json` metadata, `README.md`, and publish to npm registry.
|
|
68
82
|
|
|
83
|
+
### Demo Site
|
|
84
|
+
* **Full source viewer coverage**: Migrate all 32 examples to `DocsLayout` using Vite `?raw` auto-loading (eliminates string-literal source maintenance). See `docs/superpowers/plans/2026-07-29-demo-app-refactor.md`.
|
|
85
|
+
* **Syntax highlighting**: Prism.js token-based highlighting in the source viewer.
|
|
86
|
+
* **GitHub Pages deployment**: CI/CD via GitHub Actions (`base: '/OpenGridX/'` already configured in `vite.config.js`).
|
|
87
|
+
|
|
69
88
|
## ✅ Implemented Features (Recent)
|
|
70
|
-
* **
|
|
89
|
+
* **Demo app (partial)**: 32 interactive examples across 6 categories (Main features, Advanced features, Components, Customization, Tutorials, Resources) with categorized sidebar navigation and per-page Table of Contents. Source code viewer present on 6 of 32 examples. Syntax highlighting and public deployment are in the upcoming Demo Site section above.
|
|
71
90
|
* **Quickstart & Installation Guides**: Comprehensive onboarding documentation for developers.
|
|
@@ -7,9 +7,9 @@ import { GridEditInputCell } from './GridEditInputCell';
|
|
|
7
7
|
|
|
8
8
|
export interface CellProps<R extends GridRowModel = GridRowModel> {
|
|
9
9
|
onCellClick?: (params: GridCellParams<R>) => void;
|
|
10
|
-
onCellEditStart?: (field: string, value:
|
|
11
|
-
onCellValueChange?: (field: string, newValue:
|
|
12
|
-
value:
|
|
10
|
+
onCellEditStart?: (field: string, value: unknown) => void;
|
|
11
|
+
onCellValueChange?: (field: string, newValue: unknown) => void;
|
|
12
|
+
value: unknown;
|
|
13
13
|
row: R;
|
|
14
14
|
colDef: GridColDef<R>;
|
|
15
15
|
rowIndex: number;
|
|
@@ -27,7 +27,7 @@ export interface CellProps<R extends GridRowModel = GridRowModel> {
|
|
|
27
27
|
isEditing?: boolean;
|
|
28
28
|
onEditStart?: () => void;
|
|
29
29
|
onEditStop?: (cancel?: boolean) => void;
|
|
30
|
-
onValueChange?: (newValue:
|
|
30
|
+
onValueChange?: (newValue: unknown) => void;
|
|
31
31
|
|
|
32
32
|
colSpanInfo?: CellColSpanInfo;
|
|
33
33
|
rowSpan?: number;
|
|
@@ -62,40 +62,8 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
|
|
|
62
62
|
isHiddenByRowSpan
|
|
63
63
|
} = props;
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (isHiddenByRowSpan) {
|
|
71
|
-
|
|
72
|
-
return (
|
|
73
|
-
<div
|
|
74
|
-
className="ogx__cell ogx__cell--hidden"
|
|
75
|
-
style={{ width: width ?? colDef.width ?? 100 }}
|
|
76
|
-
role="presentation"
|
|
77
|
-
data-field={colDef.field}
|
|
78
|
-
data-colindex={colIndex}
|
|
79
|
-
/>
|
|
80
|
-
);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const handleClick = (e: React.MouseEvent) => {
|
|
84
|
-
if (onCellClick) {
|
|
85
|
-
e.stopPropagation();
|
|
86
|
-
onCellClick({
|
|
87
|
-
row,
|
|
88
|
-
field: colDef.field,
|
|
89
|
-
value,
|
|
90
|
-
colDef,
|
|
91
|
-
rowIndex,
|
|
92
|
-
colIndex
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
onClick?.(e);
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
const handleValueChange = React.useCallback((newValue: any) => {
|
|
65
|
+
// All hooks must come before any early returns (Rules of Hooks)
|
|
66
|
+
const handleValueChange = React.useCallback((newValue: unknown) => {
|
|
99
67
|
if (onCellValueChange) {
|
|
100
68
|
onCellValueChange(colDef.field, newValue);
|
|
101
69
|
} else {
|
|
@@ -103,17 +71,6 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
|
|
|
103
71
|
}
|
|
104
72
|
}, [onCellValueChange, onValueChange, colDef.field]);
|
|
105
73
|
|
|
106
|
-
const handleDoubleClick = (e: React.MouseEvent) => {
|
|
107
|
-
if (isEditable && (onEditStart || onCellEditStart)) {
|
|
108
|
-
e.stopPropagation();
|
|
109
|
-
if (onCellEditStart) {
|
|
110
|
-
onCellEditStart(colDef.field, value);
|
|
111
|
-
} else {
|
|
112
|
-
onEditStart?.();
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
74
|
const formattedValue = React.useMemo(() => {
|
|
118
75
|
if (colDef.valueFormatter) {
|
|
119
76
|
return colDef.valueFormatter({
|
|
@@ -130,7 +87,6 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
|
|
|
130
87
|
return String(value);
|
|
131
88
|
}, [value, row, colDef]);
|
|
132
89
|
|
|
133
|
-
// Render custom cell content OR Edit Content
|
|
134
90
|
const cellContent = React.useMemo(() => {
|
|
135
91
|
if (isEditing && (onCellValueChange || onValueChange) && onEditStop) {
|
|
136
92
|
if (colDef.renderEditCell) {
|
|
@@ -138,7 +94,7 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
|
|
|
138
94
|
value,
|
|
139
95
|
row,
|
|
140
96
|
field: colDef.field,
|
|
141
|
-
colDef
|
|
97
|
+
colDef,
|
|
142
98
|
rowIndex,
|
|
143
99
|
colIndex
|
|
144
100
|
});
|
|
@@ -148,7 +104,7 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
|
|
|
148
104
|
value={value}
|
|
149
105
|
row={row}
|
|
150
106
|
field={colDef.field}
|
|
151
|
-
colDef={colDef as
|
|
107
|
+
colDef={colDef as unknown as GridColDef}
|
|
152
108
|
rowIndex={rowIndex}
|
|
153
109
|
colIndex={colIndex}
|
|
154
110
|
onValueChange={handleValueChange}
|
|
@@ -163,7 +119,7 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
|
|
|
163
119
|
value,
|
|
164
120
|
row,
|
|
165
121
|
field: colDef.field,
|
|
166
|
-
colDef
|
|
122
|
+
colDef,
|
|
167
123
|
rowIndex,
|
|
168
124
|
colIndex
|
|
169
125
|
});
|
|
@@ -172,16 +128,65 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
|
|
|
172
128
|
return formattedValue;
|
|
173
129
|
}, [value, row, colDef, rowIndex, colIndex, formattedValue, isEditing, handleValueChange, onEditStop, onCellValueChange, onValueChange]);
|
|
174
130
|
|
|
175
|
-
const isPinned = pinnedPosition !== null && pinnedPosition !== undefined;
|
|
176
|
-
|
|
177
131
|
const resolvedCellClassName = React.useMemo(() => {
|
|
178
132
|
if (!colDef.cellClassName) return '';
|
|
179
133
|
if (typeof colDef.cellClassName === 'function') {
|
|
180
|
-
return colDef.cellClassName({ value, row, field: colDef.field, colDef
|
|
134
|
+
return colDef.cellClassName({ value, row, field: colDef.field, colDef, rowIndex, colIndex }) || '';
|
|
181
135
|
}
|
|
182
136
|
return colDef.cellClassName;
|
|
183
137
|
}, [colDef, value, row, rowIndex, colIndex]);
|
|
184
138
|
|
|
139
|
+
const cellRef = React.useRef<HTMLDivElement>(null);
|
|
140
|
+
|
|
141
|
+
React.useLayoutEffect(() => {
|
|
142
|
+
if (isFocused && cellRef.current) {
|
|
143
|
+
cellRef.current.focus({ preventScroll: true });
|
|
144
|
+
}
|
|
145
|
+
}, [isFocused]);
|
|
146
|
+
|
|
147
|
+
if (colSpanInfo?.spannedByColSpan) {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (isHiddenByRowSpan) {
|
|
152
|
+
return (
|
|
153
|
+
<div
|
|
154
|
+
className="ogx__cell ogx__cell--hidden"
|
|
155
|
+
style={{ width: width ?? colDef.width ?? 100 }}
|
|
156
|
+
role="presentation"
|
|
157
|
+
data-field={colDef.field}
|
|
158
|
+
data-colindex={colIndex}
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const handleClick = (e: React.MouseEvent) => {
|
|
164
|
+
if (onCellClick) {
|
|
165
|
+
onCellClick({
|
|
166
|
+
row,
|
|
167
|
+
field: colDef.field,
|
|
168
|
+
value,
|
|
169
|
+
colDef,
|
|
170
|
+
rowIndex,
|
|
171
|
+
colIndex
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
onClick?.(e);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handleDoubleClick = (e: React.MouseEvent) => {
|
|
178
|
+
if (isEditable && (onEditStart || onCellEditStart)) {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
if (onCellEditStart) {
|
|
181
|
+
onCellEditStart(colDef.field, value);
|
|
182
|
+
} else {
|
|
183
|
+
onEditStart?.();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const isPinned = pinnedPosition !== null && pinnedPosition !== undefined;
|
|
189
|
+
|
|
185
190
|
const classNames = [
|
|
186
191
|
'ogx__cell',
|
|
187
192
|
`ogx__cell--align-${colDef.align || 'left'}`,
|
|
@@ -230,14 +235,6 @@ function CellImpl<R extends GridRowModel = GridRowModel>(props: CellProps<R>) {
|
|
|
230
235
|
}
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
const cellRef = React.useRef<HTMLDivElement>(null);
|
|
234
|
-
|
|
235
|
-
React.useLayoutEffect(() => {
|
|
236
|
-
if (isFocused && cellRef.current) {
|
|
237
|
-
cellRef.current.focus({ preventScroll: true });
|
|
238
|
-
}
|
|
239
|
-
}, [isFocused]);
|
|
240
|
-
|
|
241
238
|
return (
|
|
242
239
|
<div
|
|
243
240
|
ref={cellRef}
|
|
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
2
2
|
import type { GridRenderCellParams } from '../../types';
|
|
3
3
|
|
|
4
4
|
export interface GridEditInputCellProps extends GridRenderCellParams {
|
|
5
|
-
onValueChange: (value:
|
|
5
|
+
onValueChange: (value: unknown) => void;
|
|
6
6
|
onCommit: () => void;
|
|
7
7
|
onCancel: () => void;
|
|
8
8
|
}
|
|
@@ -31,7 +31,7 @@ function TextEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputC
|
|
|
31
31
|
|
|
32
32
|
// ─── Number ───────────────────────────────────────────────────────────────────
|
|
33
33
|
function NumberEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
|
|
34
|
-
const [local, setLocal] = useState(value
|
|
34
|
+
const [local, setLocal] = useState<string | number>(typeof value === 'number' ? value : '');
|
|
35
35
|
const ref = useRef<HTMLInputElement>(null);
|
|
36
36
|
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
|
|
37
37
|
|
|
@@ -94,7 +94,7 @@ function SelectEditor({ value, colDef, onValueChange, onCommit, onCancel }: Grid
|
|
|
94
94
|
<select
|
|
95
95
|
ref={ref}
|
|
96
96
|
className="ogx__edit-select"
|
|
97
|
-
value={value ?? ''}
|
|
97
|
+
value={String(value ?? '')}
|
|
98
98
|
onChange={e => { onValueChange(e.target.value); }}
|
|
99
99
|
onBlur={onCommit}
|
|
100
100
|
onKeyDown={e => {
|
|
@@ -113,7 +113,7 @@ function SelectEditor({ value, colDef, onValueChange, onCommit, onCancel }: Grid
|
|
|
113
113
|
|
|
114
114
|
// ─── Date ─────────────────────────────────────────────────────────────────────
|
|
115
115
|
function DateEditor({ value, onValueChange, onCommit, onCancel }: GridEditInputCellProps) {
|
|
116
|
-
const [local, setLocal] = useState(value ?? '');
|
|
116
|
+
const [local, setLocal] = useState<string>(typeof value === 'string' ? value : String(value ?? ''));
|
|
117
117
|
const ref = useRef<HTMLInputElement>(null);
|
|
118
118
|
useEffect(() => { ref.current?.focus(); }, []);
|
|
119
119
|
|
|
@@ -10,6 +10,7 @@ export interface ColumnVisibilityPanelProps<R extends GridRowModel = GridRowMode
|
|
|
10
10
|
onShowAll: () => void;
|
|
11
11
|
onHideAll: () => void;
|
|
12
12
|
onColumnReorder?: (fromField: string, toField: string) => void;
|
|
13
|
+
onColumnOrderReset?: () => void;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function SearchIcon() {
|
|
@@ -43,7 +44,7 @@ function DragHandleIcon() {
|
|
|
43
44
|
export function ColumnVisibilityPanel<R extends GridRowModel = GridRowModel>(
|
|
44
45
|
props: ColumnVisibilityPanelProps<R>
|
|
45
46
|
) {
|
|
46
|
-
const { columns, visibleColumns, onVisibilityChange, onShowAll, onHideAll, onColumnReorder } = props;
|
|
47
|
+
const { columns, visibleColumns, onVisibilityChange, onShowAll, onHideAll, onColumnReorder, onColumnOrderReset } = props;
|
|
47
48
|
const [searchQuery, setSearchQuery] = useState('');
|
|
48
49
|
const [dragOverField, setDragOverField] = useState<string | null>(null);
|
|
49
50
|
const dragFieldRef = useRef<string | null>(null);
|
|
@@ -180,7 +181,7 @@ export function ColumnVisibilityPanel<R extends GridRowModel = GridRowModel>(
|
|
|
180
181
|
</label>
|
|
181
182
|
<button
|
|
182
183
|
className="ogx-column-visibility-panel__reset-btn"
|
|
183
|
-
onClick={onShowAll}
|
|
184
|
+
onClick={() => { onShowAll(); onColumnOrderReset?.(); }}
|
|
184
185
|
>
|
|
185
186
|
Reset
|
|
186
187
|
</button>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression tests for bugs fixed in the DataGrid render layer.
|
|
3
|
+
*
|
|
4
|
+
* Tests cover:
|
|
5
|
+
* - getVisibleRows() respects pagination (not returning all rows)
|
|
6
|
+
* - Row DOM nodes are not remounted when column widths change
|
|
7
|
+
*
|
|
8
|
+
* Not covered here (require a real browser / ResizeObserver):
|
|
9
|
+
* - translateY offset — depends on computed cumulativeHeights, which requires
|
|
10
|
+
* a real viewport; jsdom reports all dimensions as 0
|
|
11
|
+
* - scroll-ref RAF batching — requires scroll events that jsdom fires but
|
|
12
|
+
* requestAnimationFrame does not execute by default
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React from 'react';
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { render, act } from '@testing-library/react';
|
|
18
|
+
import { DataGrid } from './DataGrid';
|
|
19
|
+
import { useGridApiRef } from '../../hooks/core/useGridApiRef';
|
|
20
|
+
import type { GridColDef } from '../../types';
|
|
21
|
+
|
|
22
|
+
const COLS: GridColDef[] = [
|
|
23
|
+
{ field: 'id', headerName: 'ID', width: 80 },
|
|
24
|
+
{ field: 'name', headerName: 'Name', width: 150 },
|
|
25
|
+
{ field: 'age', headerName: 'Age', width: 100 },
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// 10 rows so pagination actually clips the visible set
|
|
29
|
+
const ROWS = Array.from({ length: 10 }, (_, i) => ({
|
|
30
|
+
id: i + 1,
|
|
31
|
+
name: `User ${i + 1}`,
|
|
32
|
+
age: 20 + i,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Bug: getVisibleRows() was returning sortedUnpinnedRows (all rows) regardless
|
|
37
|
+
// of whether pagination was active. Fixed: now returns paginatedUnpinnedRows
|
|
38
|
+
// when a paginationModel is set.
|
|
39
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
describe('getVisibleRows respects pagination', () => {
|
|
42
|
+
it('returns only the current page rows, not all rows', () => {
|
|
43
|
+
const assigningRef = React.createRef<import('../../types').GridApi>();
|
|
44
|
+
|
|
45
|
+
function GridWithRef() {
|
|
46
|
+
const ref = useGridApiRef();
|
|
47
|
+
// expose ref to test scope after render
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
(assigningRef as React.MutableRefObject<import('../../types').GridApi>).current = ref.current;
|
|
50
|
+
});
|
|
51
|
+
return (
|
|
52
|
+
<DataGrid
|
|
53
|
+
rows={ROWS}
|
|
54
|
+
columns={COLS}
|
|
55
|
+
apiRef={ref}
|
|
56
|
+
pagination
|
|
57
|
+
paginationModel={{ page: 0, pageSize: 3 }}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
act(() => {
|
|
63
|
+
render(<GridWithRef />);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// pageSize=3, total=10 → page 0 should expose 3 rows, not 10.
|
|
67
|
+
const visible = assigningRef.current?.getVisibleRows() ?? [];
|
|
68
|
+
expect(visible.length).toBe(3);
|
|
69
|
+
expect(visible.map(r => r.id)).toEqual([1, 2, 3]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('returns all rows when no pagination is provided', () => {
|
|
73
|
+
const assigningRef = React.createRef<import('../../types').GridApi>();
|
|
74
|
+
|
|
75
|
+
function GridNoPagination() {
|
|
76
|
+
const ref = useGridApiRef();
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
(assigningRef as React.MutableRefObject<import('../../types').GridApi>).current = ref.current;
|
|
79
|
+
});
|
|
80
|
+
return <DataGrid rows={ROWS} columns={COLS} apiRef={ref} />;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
act(() => {
|
|
84
|
+
render(<GridNoPagination />);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const visible = assigningRef.current?.getVisibleRows() ?? [];
|
|
88
|
+
// Without pagination the full sorted set is returned.
|
|
89
|
+
expect(visible.length).toBe(ROWS.length);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
94
|
+
// Bug: row keys were `${row.id}-${virtualization.totalWidth}`, so every column
|
|
95
|
+
// resize unmounted and remounted every DOM row node.
|
|
96
|
+
// Fixed: key is now simply `row.id`.
|
|
97
|
+
//
|
|
98
|
+
// Test strategy: capture DOM element identity before and after a column prop
|
|
99
|
+
// change. If the key includes totalWidth the elements will be new nodes; if
|
|
100
|
+
// the key is stable they will be the exact same DOM nodes.
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
describe('row DOM nodes are stable across column width changes', () => {
|
|
104
|
+
it('does not remount rows when column widths change', () => {
|
|
105
|
+
const { rerender, container } = render(
|
|
106
|
+
<DataGrid rows={ROWS.slice(0, 3)} columns={COLS} />
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Collect the row cell elements before the update.
|
|
110
|
+
const cellsBefore = Array.from(
|
|
111
|
+
container.querySelectorAll('[role="gridcell"]')
|
|
112
|
+
);
|
|
113
|
+
expect(cellsBefore.length).toBeGreaterThan(0);
|
|
114
|
+
|
|
115
|
+
// Update the Name column width — simulates a resize.
|
|
116
|
+
const updatedCols: GridColDef[] = COLS.map(c =>
|
|
117
|
+
c.field === 'name' ? { ...c, width: 250 } : c
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
rerender(<DataGrid rows={ROWS.slice(0, 3)} columns={updatedCols} />);
|
|
121
|
+
|
|
122
|
+
const cellsAfter = Array.from(
|
|
123
|
+
container.querySelectorAll('[role="gridcell"]')
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Same number of cells, and critically the same DOM node instances.
|
|
127
|
+
expect(cellsAfter.length).toBe(cellsBefore.length);
|
|
128
|
+
cellsBefore.forEach((node, i) => {
|
|
129
|
+
expect(cellsAfter[i]).toBe(node);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { render, screen } from '@testing-library/react';
|
|
4
|
+
import { DataGrid } from './DataGrid';
|
|
5
|
+
import type { GridColDef } from '../../types';
|
|
6
|
+
|
|
7
|
+
const COLUMNS: GridColDef[] = [
|
|
8
|
+
{ field: 'id', headerName: 'ID', width: 80 },
|
|
9
|
+
{ field: 'name', headerName: 'Name', width: 150 },
|
|
10
|
+
{ field: 'age', headerName: 'Age', width: 100, type: 'number' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const ROWS = [
|
|
14
|
+
{ id: 1, name: 'Alice', age: 30 },
|
|
15
|
+
{ id: 2, name: 'Bob', age: 25 },
|
|
16
|
+
{ id: 3, name: 'Charlie', age: 35 },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
describe('DataGrid — smoke tests', () => {
|
|
20
|
+
it('renders without throwing', () => {
|
|
21
|
+
expect(() =>
|
|
22
|
+
render(<DataGrid rows={ROWS} columns={COLUMNS} />)
|
|
23
|
+
).not.toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('renders column headers', () => {
|
|
27
|
+
render(<DataGrid rows={ROWS} columns={COLUMNS} />);
|
|
28
|
+
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByText('Age')).toBeInTheDocument();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders row data', () => {
|
|
33
|
+
render(<DataGrid rows={ROWS} columns={COLUMNS} />);
|
|
34
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
35
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
36
|
+
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('renders with empty rows without throwing', () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
render(<DataGrid rows={[]} columns={COLUMNS} />)
|
|
42
|
+
).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('renders with empty columns without throwing', () => {
|
|
46
|
+
expect(() =>
|
|
47
|
+
render(<DataGrid rows={ROWS} columns={[]} />)
|
|
48
|
+
).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('renders with checkboxSelection enabled', () => {
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<DataGrid rows={ROWS} columns={COLUMNS} checkboxSelection />
|
|
54
|
+
);
|
|
55
|
+
const checkboxes = container.querySelectorAll('input[type="checkbox"]');
|
|
56
|
+
expect(checkboxes.length).toBeGreaterThan(0);
|
|
57
|
+
});
|
|
58
|
+
});
|