@opencorestack/opengridx 3.3.0 → 3.5.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +10 -1
  3. package/dist/ai.cjs +2 -0
  4. package/dist/ai.cjs.map +1 -0
  5. package/dist/ai.d.ts +366 -0
  6. package/dist/ai.es.js +2 -0
  7. package/dist/ai.es.js.map +1 -0
  8. package/dist/index.d.ts +396 -6
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +8029 -6090
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -14
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +182 -4
  15. package/docs/README.md +5 -1
  16. package/docs/architecture/datagrid-orchestration.md +19 -5
  17. package/docs/components/filter-panel.md +1 -0
  18. package/docs/components/header.md +4 -1
  19. package/docs/components/toolbar.md +7 -0
  20. package/docs/customization/theming.md +12 -1
  21. package/docs/features/ai-toolkit.md +213 -0
  22. package/docs/features/cell-selection.md +8 -2
  23. package/docs/features/clipboard.md +88 -0
  24. package/docs/features/editing-reordering.md +50 -0
  25. package/docs/features/fill-handle.md +125 -0
  26. package/docs/features/filtering.md +3 -0
  27. package/docs/features/header-filters.md +138 -0
  28. package/docs/features/keyboard-navigation.md +31 -2
  29. package/docs/features/undo-redo.md +79 -0
  30. package/docs/roadmap.md +3 -4
  31. package/lib/ai/agentTools.ts +160 -0
  32. package/lib/ai/coerce.ts +90 -0
  33. package/lib/ai/columns.ts +90 -0
  34. package/lib/ai/index.ts +33 -0
  35. package/lib/ai/promptHandler.ts +109 -0
  36. package/lib/ai/schema.ts +214 -0
  37. package/lib/ai/types.ts +152 -0
  38. package/lib/ai/validate.ts +296 -0
  39. package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
  40. package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
  41. package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
  42. package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
  43. package/lib/components/Cell/Cell.css +33 -0
  44. package/lib/components/Cell/Cell.tsx +6 -2
  45. package/lib/components/Cell/GridEditInputCell.css +12 -4
  46. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  47. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  48. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  49. package/lib/components/DataGrid/DataGrid.tsx +138 -5
  50. package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
  51. package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
  52. package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
  53. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  54. package/lib/components/FilterPanel/FilterPanel.tsx +5 -11
  55. package/lib/components/Header/Header.tsx +44 -23
  56. package/lib/components/Header/headerColumnStyle.ts +28 -0
  57. package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
  58. package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
  59. package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
  60. package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
  61. package/lib/components/ListView/ListView.css +2 -4
  62. package/lib/components/ListView/ListViewRow.tsx +2 -2
  63. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  64. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  65. package/lib/components/Toolbar/GridToolbar.tsx +51 -0
  66. package/lib/components/ui/Checkbox.tsx +16 -5
  67. package/lib/components/ui/Input.css +43 -0
  68. package/lib/components/ui/Input.tsx +24 -5
  69. package/lib/hooks/core/gridApiPlaceholder.ts +16 -1
  70. package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
  71. package/lib/hooks/core/useDataGrid.ts +27 -0
  72. package/lib/hooks/core/useGridApiMethods.ts +39 -0
  73. package/lib/hooks/core/useGridAriaRows.ts +5 -3
  74. package/lib/hooks/core/useGridCellSelection.ts +107 -13
  75. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  76. package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
  77. package/lib/hooks/core/useGridControlledState.ts +27 -0
  78. package/lib/hooks/core/useGridFillHandle.ts +323 -0
  79. package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
  80. package/lib/hooks/core/useGridToolbarProps.ts +19 -0
  81. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  82. package/lib/hooks/features/useGridAiAssistant.ts +284 -0
  83. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  84. package/lib/hooks/features/useGridEditing.ts +24 -45
  85. package/lib/index.ts +10 -0
  86. package/lib/styles/opengridx.css +4 -1
  87. package/lib/theme/DataGridThemeProvider.tsx +2 -0
  88. package/lib/theme/types.ts +2 -0
  89. package/lib/types/index.ts +343 -1
  90. package/lib/utils/aiAssistant.ts +205 -0
  91. package/lib/utils/aiBrand.ts +17 -0
  92. package/lib/utils/cellSelection.ts +25 -5
  93. package/lib/utils/editing/commit.ts +69 -0
  94. package/lib/utils/editing/fill.ts +238 -0
  95. package/lib/utils/editing/targets.ts +109 -0
  96. package/lib/utils/filtering/index.ts +14 -0
  97. package/lib/utils/focus/index.ts +9 -3
  98. package/lib/utils/headerFilters.ts +189 -0
  99. package/lib/utils/parsing.ts +252 -0
  100. package/llms.txt +97 -3
  101. package/package.json +8 -2
@@ -0,0 +1,138 @@
1
+ # Header Filters
2
+
3
+ *Since v3.5.0*
4
+
5
+ A filter row under the column headers, as in Excel's AutoFilter row: each column gets a small control (a text box, a number or date box, or a select), an operator button and a clear button. It reads and writes the grid's one `filterModel`, so the [filter panel](../components/filter-panel.md), the quick filter, `apiRef.setFilterModel` and server-side filtering keep working alongside it.
6
+
7
+ ```tsx
8
+ import { DataGrid } from '@opencorestack/opengridx';
9
+
10
+ <DataGrid
11
+ rows={rows}
12
+ columns={columns}
13
+ headerFilters
14
+ height={500}
15
+ />
16
+ ```
17
+
18
+ The row is off unless `headerFilters` is set. Without it the grid renders and behaves exactly as before.
19
+
20
+ ## Controls per column type
21
+
22
+ | Column `type` | Control | Starting operator |
23
+ | :--- | :--- | :--- |
24
+ | `string` (default) | text box (`Input`) | `contains` |
25
+ | `number` | text box (`Input`, decimal keyboard on touch devices) | `=` |
26
+ | `date` | date box (`Input type="date"`) | `is` |
27
+ | `boolean` | select: Any / Yes / No | `is` |
28
+ | `singleSelect` | select of `valueOptions` (Any + every option) | `is` |
29
+ | `image` | none (empty cell) | — |
30
+
31
+ - The **operator button** left of the box shows the current operator as a symbol (`∋` contains, `=`, `≥`, `≠` …) and opens a menu of the operators the column type allows (the same list as the filter panel). `isEmpty` / `isNotEmpty` need no value: the box is replaced by the operator's name.
32
+ - The **clear button** (×) appears while the column has a filter and removes it.
33
+ - Typing is **debounced by 300 ms**, as in the filter panel. Selects apply at once. Typing that is still waiting is committed if the column scrolls out of the render window or the row is turned off.
34
+ - A `singleSelect` column without `valueOptions` gets a text box.
35
+
36
+ ### Column options
37
+
38
+ | `GridColDef` field | Description |
39
+ | :--- | :--- |
40
+ | `headerFilter?: boolean` | `false` leaves the column's cell empty. Columns with `filterable: false` and `type: 'image'` have no control either. |
41
+ | `headerFilterOperator?: GridFilterOperator` | The operator a new filter starts with, instead of the type's default (for example `'>='` for amounts, `'startsWith'` for codes). |
42
+
43
+ ### Grid props
44
+
45
+ | Prop | Type | Default | Description |
46
+ | :--- | :--- | :--- | :--- |
47
+ | `headerFilters` | `boolean` | `false` | Renders the filter row. |
48
+ | `headerFilterHeight` | `number` | `40` | Height of the filter row in pixels (sets `--ogx-header-filter-height`). |
49
+
50
+ ## One shared filter model
51
+
52
+ The row does not keep a model of its own. Each cell **owns one root-level item**, the one with `id: 'header:<field>'`:
53
+
54
+ ```ts
55
+ // After typing "glo" under Customer and choosing "≥ 100" under Total:
56
+ {
57
+ items: [
58
+ { id: 'header:customer', field: 'customer', operator: 'contains', value: 'glo' },
59
+ { id: 'header:total', field: 'total', operator: '>=', value: '100' },
60
+ ],
61
+ }
62
+ ```
63
+
64
+ - A cell adds its item at the end of `items`, updates it in place and removes it when cleared. Every other item and group, `logicOperator` and `quickFilterValues` pass through untouched.
65
+ - Values are stored as the filter panel stores them: typed text as a string (number operators parse it), dates as `YYYY-MM-DD`, booleans as `true` / `false`, `singleSelect` values as the option's `value` (a one-item list for `isAnyOf`).
66
+ - The filter panel shows and edits the same item: it edits the first root item of each column and keeps its `id`, so a header filter changed in the panel is still the header filter.
67
+ - An item with an empty value does not filter. An empty box with the starting operator removes the item; with another operator the item stays, so the chosen operator is kept while the value is retyped.
68
+
69
+ ### Models the row cannot show
70
+
71
+ The row never rewrites a model it cannot represent. A cell shows a read-only **"Custom filter"** label instead of its control when:
72
+
73
+ - the root `logicOperator` is `'or'` (every cell: adding an item would widen the OR);
74
+ - the column's field appears inside a filter group;
75
+ - the column has a root item that is not `header:<field>` (one added by the filter panel, `apiRef`, an AI assistant or your own code), or more than one root item.
76
+
77
+ With a built-in `GridToolbar` in `slots.toolbar`, the label is a button that opens the toolbar's filter panel, where the condition can be edited. Without one it is plain text. Clearing the model (the panel's *Clear all*, `setFilterModel({ items: [] })`) brings the controls back.
78
+
79
+ ## Server-side filtering
80
+
81
+ With `filterMode="server"` the grid does not filter rows itself; header filter changes reach `onFilterModelChange` (or the `dataSource`'s `getRows`) like any other filter change. Items are tagged `header:<field>`, which the server can ignore.
82
+
83
+ ```tsx
84
+ <DataGrid
85
+ rows={pageRows}
86
+ columns={columns}
87
+ headerFilters
88
+ filterMode="server"
89
+ filterModel={filterModel}
90
+ onFilterModelChange={(model) => { setFilterModel(model); refetch(model); }}
91
+ />
92
+ ```
93
+
94
+ ## Keyboard and screen readers
95
+
96
+ The filter row is a header row (`role="row"`) between the column headers and the first data row. Its cells are `role="columnheader"`, labelled **"Filter &lt;header name&gt;"**, with the same `aria-colindex` as their column; `aria-rowcount` and the rows' `aria-rowindex` count it.
97
+
98
+ | Key | On a filter cell |
99
+ | :--- | :--- |
100
+ | ArrowDown on a column header | Moves to that column's filter cell |
101
+ | ArrowDown / ArrowUp | First data row / the column header |
102
+ | ArrowLeft / ArrowRight, Home / End | Along the filter row |
103
+ | Enter or Space | Focuses the cell's control ("Custom filter": opens the filter panel) |
104
+ | Typing a character | Starts a new value in the text box (text and number columns) |
105
+ | Alt+ArrowDown or Ctrl/Cmd+Enter | Opens the operator menu (arrows move, Enter picks, Escape closes) |
106
+ | Delete / Backspace | Clears the column's filter |
107
+
108
+ Inside a control, arrow keys edit the text (or move through a select) and do not move the grid's focus, as in a cell editor. **Escape** returns focus to the filter cell. **Tab** leaves the grid: the grid stays a single Tab stop and every control in the row has `tabIndex={-1}`.
109
+
110
+ ## Layout
111
+
112
+ - The row sits inside the sticky header block (`.ogx__header-wrap`), under any column group rows and the column header row, so it stays at the top while rows scroll.
113
+ - Each cell takes its column's width, flex and sticky offset from the same calculation as the column header, so filter cells line up with their columns with pinned columns, column virtualization and horizontal scrolling.
114
+ - Scrolling a row into view (keyboard navigation, `apiRef.scrollToIndexes`, cell range auto-scroll) measures the sticky header, so rows are never left under the taller header.
115
+ - With `pivotMode` active the row is not rendered: pivot columns are generated and are not the fields the filter model names.
116
+
117
+ ## Styling
118
+
119
+ | Class | Element |
120
+ | :--- | :--- |
121
+ | `ogx__header-filter-row` | The row |
122
+ | `ogx__header-filter-cell` | Every cell, plus `--focused`, `--active` (has a filter), `--custom` ("Custom filter"), `--empty` (no control), `--system` (under the checkbox, expand and reorder columns), `--pinned`, `--pinned-left`, `--pinned-right`, `--pinned-left-last`, `--pinned-right-first` |
123
+ | `ogx__header-filter-operator` | The operator button (`--open` while its menu is open) |
124
+ | `ogx__header-filter-menu` | The operator menu (also `ogx-column-menu`, so it looks like the column menu) |
125
+ | `ogx__header-filter-input-wrapper` / `ogx__header-filter-input` | The `Input` wrapper and its `<input>` |
126
+ | `ogx__header-filter-select` | The boolean and `singleSelect` select |
127
+ | `ogx__header-filter-operator-label` | The operator name shown for `isEmpty` / `isNotEmpty` |
128
+ | `ogx__header-filter-clear` | The clear button |
129
+ | `ogx__header-filter-custom` | The "Custom filter" label or button |
130
+
131
+ The row uses the header's theme variables (`--ogx-grid-header-background`, `--ogx-grid-border-color`, `--ogx-grid-header-text`, `--ogx-grid-header-hover-background`) and the shared input variables, so every built-in theme and dark mode style it without new variables. `--ogx-header-filter-height` holds its height.
132
+
133
+ ## See also
134
+
135
+ - [Filtering & Search](filtering.md) — operators, AND/OR groups and the quick filter
136
+ - [Filter Panel](../components/filter-panel.md)
137
+ - [Keyboard & Accessibility](keyboard-navigation.md)
138
+ - Demo: *Header Filters* (`/header-filters`)
@@ -37,6 +37,20 @@ With [`cellSelection`](cell-selection.md) on (v3.3), on a data cell:
37
37
  | **Ctrl+Shift+Home** / **Ctrl+Shift+End** (Cmd on macOS) | Extend to the first / last cell of the page. |
38
38
  | **Shift+PageUp** / **Shift+PageDown** | Extend one page up / down. |
39
39
  | **Escape** | Collapse the range to the anchor (when not editing). |
40
+ | **Ctrl+V** (Cmd+V) | Paste tab-separated text into the editable cells from the range's top-left cell (v3.4, see [Clipboard](clipboard.md#-paste-from-excel-and-google-sheets-v34)). Off with `disableClipboardPaste`. |
41
+ | **Delete** / **Backspace** | On a range of more than one cell: empty its editable cells in one edit (v3.4). Off with `disableRangeClear`. |
42
+ | **Ctrl+D** (Cmd+D) | Fill down: copy the range's top row into the rows below it, in one edit (v3.5, see [Fill Handle](fill-handle.md)). The browser's bookmark shortcut does not fire. Off with `disableFillHandle`. |
43
+ | **Ctrl+R** (Cmd+R) | Fill right: copy the range's left column into the columns right of it, in one edit (v3.5). The browser's reload shortcut does not fire. Off with `disableFillHandle`. |
44
+ | **Escape** during a fill-handle drag | Cancel the fill: nothing is written and the range goes back (v3.5). |
45
+
46
+ With `undoRedo` on (v3.4, see [Undo & Redo](undo-redo.md)), while focus is on a cell and no editor is open:
47
+
48
+ | Key | Action |
49
+ | :--- | :--- |
50
+ | **Ctrl+Z** (Cmd+Z) | Undo the last edit, paste or range clear. |
51
+ | **Ctrl+Shift+Z** (Cmd+Shift+Z) or **Ctrl+Y** | Redo. |
52
+
53
+ Inside an open editor these keys belong to the input (its own paste and undo).
40
54
 
41
55
  A plain arrow key after a range collapses it to the newly focused cell. Shift+Space still selects the row.
42
56
 
@@ -59,10 +73,25 @@ When an edit ends, focus returns to the edited cell unless you moved it elsewher
59
73
  | **Enter** / **Space** | Sort the column (asc → desc → none), replacing the sort model; with `multiSort`, or with **Shift** held, the column is added to (or updated or removed from) the sort model instead, as a header click or shift-click does. On the select-all header: select or clear all rows. |
60
74
  | **Alt+ArrowDown** or **Ctrl+Enter** (Cmd+Enter) | Open the column menu. |
61
75
  | **Alt+ArrowRight** / **Alt+ArrowLeft** | Widen / narrow the column by 10px (with **Shift**: 50px). Not for `resizable: false` columns. |
62
- | **ArrowDown** | Move to the first row. |
76
+ | **ArrowDown** | Move to the first row; with `headerFilters`, to the column's filter cell. |
63
77
 
64
78
  In the column menu, **ArrowUp** / **ArrowDown** / **Home** / **End** move between items, **Enter** / **Space** choose one, and **Escape** or **Tab** closes the menu. Focus returns to the column header.
65
79
 
80
+ ### Header filter row
81
+
82
+ With `headerFilters` (v3.5, see [Header Filters](header-filters.md)) the filter row sits between the column headers and the first row. Its cells are navigation stops like any other cell:
83
+
84
+ | Key | Action |
85
+ | :--- | :--- |
86
+ | **ArrowDown** / **ArrowUp** | To the first row / back to the column header. Arrow keys from the first row go up to the filter row. |
87
+ | **ArrowLeft** / **ArrowRight**, **Home** / **End** | Along the filter row. |
88
+ | **Enter** / **Space** | Focus the cell's text box or select. On "Custom filter": open the toolbar's filter panel. |
89
+ | Typing a character | Start a new value in the text box (text and number columns). |
90
+ | **Alt+ArrowDown** or **Ctrl+Enter** (Cmd+Enter) | Open the operator menu; it works like the column menu. |
91
+ | **Delete** / **Backspace** | Clear the column's filter. |
92
+
93
+ Inside the text box or select, the arrow keys belong to the control, as in a cell editor. **Escape** returns focus to the filter cell; **Tab** leaves the grid (the controls are not Tab stops).
94
+
66
95
  ### List view
67
96
 
68
97
  In `listView`, focus moves between whole rows instead of cells:
@@ -84,7 +113,7 @@ Keys pressed in content your `renderCell` puts in a row (inputs, buttons) are le
84
113
  | Element | Attributes |
85
114
  | :--- | :--- |
86
115
  | Viewport | `role="grid"`, `aria-rowcount`, `aria-colcount`, `aria-multiselectable` (true when several rows can be selected), `aria-busy`. |
87
- | Header rows | `role="row"` with `aria-rowindex`: column-group rows first, then the column header row. |
116
+ | Header rows | `role="row"` with `aria-rowindex`: column-group rows first, then the column header row, then the header filter row (`headerFilters`), whose cells are `role="columnheader"` labelled "Filter &lt;header&gt;". |
88
117
  | Data rows | `role="row"`, `aria-rowindex` counted across the whole dataset (page 2 continues after page 1; bottom-pinned rows come after every page), `aria-selected`. Tree-data and row-grouping rows add `aria-level`; rows with children add `aria-expanded`. |
89
118
  | Cells and headers | `aria-colindex`, identical for a header and the body cells below it. The system columns (reorder handle, detail-panel toggle, checkbox) come first. |
90
119
  | Sorted headers | Only the primary sort column has `aria-sort="ascending"` / `"descending"`; with several sort keys every sorted header also gets an `aria-description` such as "Sorted descending, sort priority 2 of 2". |
@@ -0,0 +1,79 @@
1
+ # Undo & Redo
2
+
3
+ `undoRedo` (v3.4) lets users undo and redo their cell edits, pastes and range clears. It is off by default: your app may already own Ctrl+Z, and undo writes data. Demo: **Undo & Redo** (`/undo-redo`).
4
+
5
+ ```tsx
6
+ import { DataGrid, useGridApiRef, type GridHistoryChangeParams } from '@opencorestack/opengridx';
7
+
8
+ function Budget({ rows, columns, saveRow }) {
9
+ const apiRef = useGridApiRef();
10
+ const [history, setHistory] = useState<GridHistoryChangeParams>({ canUndo: false, canRedo: false, size: 0 });
11
+
12
+ return (
13
+ <>
14
+ <button disabled={!history.canUndo} onClick={() => apiRef.current.undo()}>Undo</button>
15
+ <button disabled={!history.canRedo} onClick={() => apiRef.current.redo()}>Redo</button>
16
+ <DataGrid
17
+ rows={rows}
18
+ columns={columns}
19
+ apiRef={apiRef}
20
+ cellSelection // for paste and range clear; undo works without it
21
+ undoRedo // or { limit: 50 }; default 100 actions
22
+ onHistoryChange={setHistory}
23
+ processRowUpdate={saveRow} // undo and redo save through it too
24
+ />
25
+ </>
26
+ );
27
+ }
28
+ ```
29
+
30
+ ## Keys
31
+
32
+ | Key | Action |
33
+ | :--- | :--- |
34
+ | **Ctrl+Z** (Cmd+Z) | Undo |
35
+ | **Ctrl+Shift+Z** (Cmd+Shift+Z) or **Ctrl+Y** | Redo |
36
+
37
+ Only while focus is in the grid (a cell) and no editor is open. Inside an editor, a text field you render in a cell, or anywhere else on the page, the keys keep their native meaning (the input's own undo). After an undo or redo the affected cells become the selection (their bounding rectangle, with `cellSelection`; otherwise focus moves to the first one) and the first one is scrolled into view.
38
+
39
+ ## What is recorded
40
+
41
+ Each committed action is a list of cell changes `{ id, field, before, after }`:
42
+
43
+ | Action | Recorded |
44
+ | :--- | :--- |
45
+ | A single-cell edit | 1 change |
46
+ | A paste or a range clear | Every cell that was written (skipped cells are not) |
47
+ | Values `processRowUpdate` changed itself (a recomputed `total`) | Not recorded: only the fields the user changed. Your recomputation runs again on undo |
48
+
49
+ An action is recorded **only after `processRowUpdate` succeeded**, with `after` read from the stored row through `valueGetter`. A row that `processRowUpdate` rejected is not recorded, so history never claims a change your app refused. A change whose stored value equals the old one (for example normalised back by `processRowUpdate`) is not recorded either.
50
+
51
+ A new action after an undo clears the redo stack. Beyond `limit` actions the oldest is dropped.
52
+
53
+ **Not undoable:** sorting, filtering, column changes, row reordering, row selection and cell selection. These are view state, not data.
54
+
55
+ ## How undo writes
56
+
57
+ Undo writes the `before` values as a new batch edit, so `valueSetter` and `processRowUpdate` run as for any edit and your app persists the undo the way it persists edits. Redo writes the `after` values. One `processRowUpdate` call per row; rows run in parallel; a row that fails is reported to `onProcessRowUpdateError`, keeps its part of the action on the stack to try again, and does not stop the others.
58
+
59
+ ### Stale entries
60
+
61
+ If a cell no longer holds its recorded `after` value (it was edited again, or the `rows` prop brought new data), undo **skips that cell** with reason `'changed'` and applies the rest. A row that no longer exists is skipped with reason `'missing'`. Values are compared, not row objects, so an app that re-fetches its rows after saving keeps a working history. Redo checks the same way against the value undo wrote.
62
+
63
+ ```ts
64
+ const result = await apiRef.current.undo();
65
+ // { updated: [3, 7], failed: [], skipped: [{ id: 5, field: 'price', reason: 'changed' }] }
66
+ ```
67
+
68
+ ## API
69
+
70
+ | Member | Description |
71
+ | :--- | :--- |
72
+ | `undoRedo?: boolean \| { limit?: number }` | Turns history on. Default `false`; `limit` default 100. Turning it off clears the history. |
73
+ | `onHistoryChange?({ canUndo, canRedo, size })` | Fired when what can be undone or redone changes. `size` is the number of actions that can be undone. |
74
+ | `apiRef.current.undo()` | Undoes the last action. Resolves with a `GridBatchEditResult` once every row has settled. |
75
+ | `apiRef.current.redo()` | Redoes the last undone action. |
76
+ | `apiRef.current.canUndo()` / `canRedo()` | Whether there is something to undo / redo. |
77
+ | `apiRef.current.clearHistory()` | Forgets every action. |
78
+
79
+ Undo and redo calls run one after another, each on the rows the previous one stored.
package/docs/roadmap.md CHANGED
@@ -83,8 +83,8 @@ This document tracks the current status of features in `OpenGridX` and outlines
83
83
  | Release | Target | Headline | Also in it |
84
84
  | :--- | :--- | :--- | :--- |
85
85
  | **3.3.0** ✅ | shipped 7 Oct | **Cell range selection** (opt-in `cellSelection` prop): mouse drag, Shift+click, Shift+arrows; Ctrl+C / Cmd+C copies the block as TSV | Selection status bar (count, sum, average of selected cells); wrapped header text; React Compiler compatibility check |
86
- | **3.4.0** | 20 Nov | **Paste from Excel / Google Sheets** into the selection, through `processRowUpdate` (values parsed per column type, non-editable cells skipped, `onClipboardPasteError`). **Undo / redo** for cell edits and pastes (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y, `apiRef.undo()` / `redo()`) | **AI toolkit, part 1** in a new `@opencorestack/opengridx/ai` entry point: `getGridAiSchema(columns)` (JSON Schema of the filter, sort, grouping, aggregation, pivot and visibility models the columns allow) and `validateGridAiState(json, columns)` (drops unknown fields and operators, coerces values, returns errors) |
87
- | **3.5.0** | 15 Dec | **AI toolkit, part 2**: `aiAssistant={{ onPrompt }}` prompt panel that calls the app's own model, shows the proposed changes as removable chips and applies them with undo; `createGridAgentTools(apiRef)` (plain tool objects for CopilotKit, the Vercel AI SDK or WebMCP) | Header filter row; **fill handle** as a stretch goal (moves to Q1 2027 if 3.3–3.4 run late) |
86
+ | **3.4.0** ✅ | shipped 8 Oct | **Paste from Excel / Google Sheets** into the selection, through `processRowUpdate` (values parsed per column type or `valueParser`, non-editable cells skipped, `onClipboardPaste` summary); **Delete clears a range**; built-in inputs and editors use the shared `Input` / `Checkbox` (`Input variant="cell"`). **Undo / redo** for cell edits and pastes (Ctrl+Z, Ctrl+Shift+Z / Ctrl+Y, `apiRef.undo()` / `redo()`) | **AI toolkit, part 1** in a new `@opencorestack/opengridx/ai` entry point: `getGridAiSchema(columns)` (JSON Schema of the filter, sort, grouping, aggregation, pivot and visibility models the columns allow) and `validateGridAiState(json, columns)` (drops unknown fields and operators, coerces values, returns errors) |
87
+ | **3.5.0** ✅ | shipped 9 Oct | **AI toolkit, part 2**: `aiAssistant={{ onPrompt }}` Ask AI panel (chips, Undo, voice) that calls the app's own model through `createGridAiPromptHandler`; only validated replies apply; `createGridAgentTools(apiRef, columns)` (plain tool objects for CopilotKit, the Vercel AI SDK, provider tool calling or WebMCP) | **Header filter row** (`headerFilters`); **fill handle** (series, Alt copy, Ctrl/Cmd+D / R); `rowGroupingModel` controlled/uncontrolled pair |
88
88
  | Freeze | 19 Dec – 5 Jan | Bug fixes only | — |
89
89
 
90
90
  ### Across the quarter
@@ -106,7 +106,7 @@ This document tracks the current status of features in `OpenGridX` and outlines
106
106
 
107
107
  * OpenGridX never calls an AI service and never bundles an AI SDK. The app passes a function that calls whichever model it uses.
108
108
  * By default only column definitions and grid state are sent, never row data. Example values are opt-in per column and documented as real data.
109
- * Model output is always validated against the schema and shown to the user before it changes the grid, with one-click undo.
109
+ * Model output is always validated against the schema (the grid applies only a validated, branded result), and every change it makes is shown as a removable chip with one-click undo.
110
110
  * The schema carries a `schemaVersion`, since apps will cache prompts against it.
111
111
 
112
112
  ### Risks
@@ -118,7 +118,6 @@ This document tracks the current status of features in `OpenGridX` and outlines
118
118
  ## 🔭 Next (Q1 2027)
119
119
 
120
120
  * **Row reordering inside tree data and row groups**, touch-friendly (MUI #4821, 405 👍, not in MUI X).
121
- * **Fill handle**, if it does not make 3.5.0.
122
121
  * **Find & highlight** (Ctrl+F through cells), **right-click context menu**, **Excel-style value (set) filter**.
123
122
  * **Calculated columns** (`[price] * [qty]` defined by users).
124
123
  * **Interactive pivot builder**: drag-and-drop panel for pivot and grouping.
@@ -0,0 +1,160 @@
1
+ // createGridAgentTools: plain tool objects ({ name, description, inputSchema, execute }) that let an AI
2
+ // agent read and change the grid through its apiRef. No SDK: they fit the Vercel AI SDK, CopilotKit,
3
+ // OpenAI or Anthropic tool calling and WebMCP. Every input is validated; execute never throws.
4
+ import type { GridAiState, GridRowModel } from '../types';
5
+ import { getCellValue } from '../utils/values';
6
+
7
+ type CellColumn = Parameters<typeof getCellValue>[2];
8
+ import { hasOwn, isPlainObject, PART_KEYS, usableColumns } from './columns';
9
+ import { getGridAiSchema } from './schema';
10
+ import type {
11
+ GridAgentApi,
12
+ GridAgentTool,
13
+ GridAgentToolResult,
14
+ GridAgentToolsOptions,
15
+ GridAiColumn,
16
+ GridAiJsonSchema,
17
+ GridAiPart,
18
+ } from './types';
19
+ import { validateGridAiState } from './validate';
20
+
21
+ const DEFAULT_MAX_ROWS = 20;
22
+ const MAX_ROWS_LIMIT = 1000;
23
+
24
+ const SET_TOOLS: { name: string; part: GridAiPart; description: string }[] = [
25
+ { name: 'set_filter', part: 'filter', description: 'Replace the filter model of the grid (filters and quick-filter words).' },
26
+ { name: 'set_sort', part: 'sort', description: 'Replace the sort model of the grid, most significant field first. An empty list clears sorting.' },
27
+ { name: 'set_grouping', part: 'grouping', description: 'Group the rows by these fields, outermost first. An empty list removes grouping.' },
28
+ { name: 'set_aggregation', part: 'aggregation', description: 'Replace the summaries: field to aggregation function. An empty object removes them.' },
29
+ { name: 'set_column_visibility', part: 'columnVisibility', description: 'Show (true) or hide (false) columns. Columns not listed keep their visibility.' },
30
+ ];
31
+
32
+ type ApiRef = { readonly current: GridAgentApi | null | undefined };
33
+
34
+ function failure(message: string, path = ''): GridAgentToolResult {
35
+ return { ok: false, errors: [{ path, message }] };
36
+ }
37
+
38
+ /** Runs a tool body; a throw becomes `{ ok: false }`. */
39
+ async function run(body: () => GridAgentToolResult): Promise<GridAgentToolResult> {
40
+ try {
41
+ return body();
42
+ } catch (error) {
43
+ return failure(error instanceof Error && error.message ? error.message : 'The tool failed');
44
+ }
45
+ }
46
+
47
+ function apiOf(apiRef: ApiRef): GridAgentApi | null {
48
+ const api = apiRef?.current;
49
+ return api && typeof api.getGridAiState === 'function' ? api : null;
50
+ }
51
+
52
+ /** A JSON-safe cell value: primitives as they are, a Date as an ISO string, anything else null. */
53
+ function jsonValue(value: unknown): unknown {
54
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value.toISOString();
55
+ if (typeof value === 'number') return Number.isFinite(value) ? value : null;
56
+ return typeof value === 'string' || typeof value === 'boolean' ? value : null;
57
+ }
58
+
59
+ function setTool(apiRef: ApiRef, columns: readonly GridAiColumn[], name: string, part: GridAiPart, description: string): GridAgentTool | null {
60
+ const key = PART_KEYS[part] as keyof GridAiState;
61
+ const schema = getGridAiSchema(columns, { include: [part] });
62
+ if (!hasOwn(schema.properties, key)) return null;
63
+ const { $schema: _version, schemaVersion: _schemaVersion, title: _title, description: _description, ...inputSchema } = schema;
64
+ return {
65
+ name,
66
+ description,
67
+ inputSchema: { ...inputSchema, required: [key] },
68
+ execute: (input) => run(() => {
69
+ const api = apiOf(apiRef);
70
+ if (!api) return failure('The grid is not ready');
71
+ if (!isPlainObject(input) || !hasOwn(input, key)) return failure(`Expected an object with "${key}"`, key);
72
+ const { state, errors } = validateGridAiState({ [key]: input[key] }, columns, { include: [part] });
73
+ // All or nothing: the agent gets the errors back and can correct its call.
74
+ if (errors.length > 0) return { ok: false, errors };
75
+ const applied: GridAiState = {};
76
+ if (state.filterModel) api.setFilterModel(applied.filterModel = state.filterModel);
77
+ if (state.sortModel) api.setSortModel(applied.sortModel = state.sortModel);
78
+ if (state.rowGroupingModel) api.setRowGroupingModel(applied.rowGroupingModel = state.rowGroupingModel);
79
+ if (state.aggregationModel) api.setAggregationModel(applied.aggregationModel = state.aggregationModel);
80
+ if (state.columnVisibilityModel) {
81
+ applied.columnVisibilityModel = { ...api.getGridAiState().columnVisibilityModel, ...state.columnVisibilityModel };
82
+ api.setColumnVisibilityModel(applied.columnVisibilityModel);
83
+ }
84
+ return { ok: true, applied };
85
+ }),
86
+ };
87
+ }
88
+
89
+ const NO_INPUT: GridAiJsonSchema = { type: 'object', properties: {}, additionalProperties: false };
90
+
91
+ /**
92
+ * Tools for an AI agent: `get_grid_state`, `set_filter`, `set_sort`, `set_grouping`, `set_aggregation`,
93
+ * `set_column_visibility`, `clear_filters`, and `get_row_summary` only with `allowRowAccess: true` (the
94
+ * one tool that sends row values). Each tool is `{ name, description, inputSchema, execute }`; inputs are
95
+ * checked with `validateGridAiState` and applied through `apiRef` only when valid. `execute` never throws.
96
+ * @since v3.5
97
+ */
98
+ export function createGridAgentTools<C extends GridAiColumn>(
99
+ apiRef: ApiRef,
100
+ columns: readonly C[],
101
+ options: GridAgentToolsOptions = {},
102
+ ): GridAgentTool[] {
103
+ const cols = usableColumns(columns);
104
+ const tools: GridAgentTool[] = [{
105
+ name: 'get_grid_state',
106
+ description: 'Read the current filter, sort, grouping, aggregation, pivot and column visibility of the grid, and its columns.',
107
+ inputSchema: NO_INPUT,
108
+ execute: () => run(() => {
109
+ const api = apiOf(apiRef);
110
+ if (!api) return failure('The grid is not ready');
111
+ return {
112
+ ok: true,
113
+ state: api.getGridAiState(),
114
+ columns: cols.map((c) => ({ field: c.field, headerName: typeof c.headerName === 'string' ? c.headerName : c.field, type: typeof c.type === 'string' ? c.type : 'string' })),
115
+ };
116
+ }),
117
+ }];
118
+ for (const { name, part, description } of SET_TOOLS) {
119
+ const tool = setTool(apiRef, cols, name, part, description);
120
+ if (tool) tools.push(tool);
121
+ }
122
+ tools.push({
123
+ name: 'clear_filters',
124
+ description: 'Remove every filter and the quick-filter words.',
125
+ inputSchema: NO_INPUT,
126
+ execute: () => run(() => {
127
+ const api = apiOf(apiRef);
128
+ if (!api) return failure('The grid is not ready');
129
+ const filterModel = { items: [] };
130
+ api.setFilterModel(filterModel);
131
+ return { ok: true, applied: { filterModel } };
132
+ }),
133
+ });
134
+ if (options.allowRowAccess === true) {
135
+ const maxRows = typeof options.maxRows === 'number' && options.maxRows >= 1 ? Math.min(Math.floor(options.maxRows), MAX_ROWS_LIMIT) : DEFAULT_MAX_ROWS;
136
+ const allowed = Array.isArray(options.fields) ? cols.filter((c) => options.fields?.includes(c.field)) : cols;
137
+ tools.push({
138
+ name: 'get_row_summary',
139
+ description: `Count the rows that pass the current filter and read the first ones (at most ${maxRows}, fields: ${allowed.map((c) => c.field).join(', ')}).`,
140
+ inputSchema: { type: 'object', properties: { limit: { type: 'integer', minimum: 1, maximum: maxRows } }, additionalProperties: false },
141
+ execute: (input) => run(() => {
142
+ const api = apiOf(apiRef);
143
+ if (!api) return failure('The grid is not ready');
144
+ const raw = isPlainObject(input) && hasOwn(input, 'limit') ? input.limit : maxRows;
145
+ const limit = typeof raw === 'number' && raw >= 1 ? Math.min(Math.floor(raw), maxRows) : maxRows;
146
+ const all = api.getAllFilteredRows();
147
+ const list = Array.isArray(all) ? all : [];
148
+ const rows = list.slice(0, limit).map((row) => {
149
+ const out: Record<string, unknown> = {};
150
+ if (!isPlainObject(row)) return out;
151
+ // Through the column's valueGetter, as the grid shows the cell.
152
+ for (const col of allowed) out[col.field] = jsonValue(getCellValue(row as GridRowModel, col.field, col as CellColumn));
153
+ return out;
154
+ });
155
+ return { ok: true, rowCount: list.length, rows };
156
+ }),
157
+ });
158
+ }
159
+ return tools;
160
+ }
@@ -0,0 +1,90 @@
1
+ // Value coercion for the AI validator. Text goes through the same parsers as clipboard paste
2
+ // (`lib/utils/parsing.ts`), so a value the grid accepts from Excel is accepted from a model and the
3
+ // other way round. Model output is JSON, so typed values (a number, a boolean, a Date) are taken as
4
+ // they are. Empty input is valid: null, or '' for text columns.
5
+ import {
6
+ parseBooleanText,
7
+ parseDateText,
8
+ parseNumberText,
9
+ parseSingleSelectText,
10
+ type GridParsedValue,
11
+ } from '../utils/parsing';
12
+
13
+ export type CoerceResult<T = unknown> = { ok: true; value: T } | { ok: false; reason: 'invalidValue' };
14
+
15
+ /** The column properties coercion reads. */
16
+ export interface CoerceColumn {
17
+ type?: string;
18
+ valueOptions?: readonly (string | number | { value: unknown; label: string })[];
19
+ }
20
+
21
+ const ok = <T>(value: T): CoerceResult<T> => ({ ok: true, value });
22
+ const INVALID: CoerceResult<never> = { ok: false, reason: 'invalidValue' };
23
+
24
+ const fromParsed = <T>(parsed: GridParsedValue): CoerceResult<T> =>
25
+ parsed.ok ? ok(parsed.value as T) : INVALID;
26
+
27
+ function isBlank(text: unknown): boolean {
28
+ return text == null || (typeof text === 'string' && text.trim() === '');
29
+ }
30
+
31
+ /** `string` and `image` columns: text as is. Numbers and booleans become their text. */
32
+ export function coerceString(text: unknown): CoerceResult<string> {
33
+ if (text == null) return ok('');
34
+ if (typeof text === 'string') return ok(text);
35
+ if (typeof text === 'number' || typeof text === 'boolean') return ok(String(text));
36
+ return INVALID;
37
+ }
38
+
39
+ /** `number` columns: a finite number, or text in the paste format (`1,234.5`, `12%`, `$40`). */
40
+ export function coerceNumber(text: unknown): CoerceResult<number | null> {
41
+ if (isBlank(text)) return ok(null);
42
+ if (typeof text === 'number') return Number.isFinite(text) ? ok(text) : INVALID;
43
+ if (typeof text !== 'string') return INVALID;
44
+ return fromParsed(parseNumberText(text));
45
+ }
46
+
47
+ /** `date` columns: a Date, epoch milliseconds, or text in the paste format (ISO `2026-10-07` …). */
48
+ export function coerceDate(text: unknown): CoerceResult<Date | null> {
49
+ if (isBlank(text)) return ok(null);
50
+ if (text instanceof Date) return Number.isNaN(text.getTime()) ? INVALID : ok(text);
51
+ if (typeof text === 'number') return Number.isFinite(text) ? ok(new Date(text)) : INVALID;
52
+ if (typeof text !== 'string') return INVALID;
53
+ return fromParsed(parseDateText(text));
54
+ }
55
+
56
+ /** `boolean` columns: a boolean, or `true`/`false`, `yes`/`no`, `1`/`0` (case-insensitive). */
57
+ export function coerceBoolean(text: unknown): CoerceResult<boolean | null> {
58
+ if (isBlank(text)) return ok(null);
59
+ if (typeof text === 'boolean') return ok(text);
60
+ if (typeof text !== 'string' && typeof text !== 'number') return INVALID;
61
+ return fromParsed(parseBooleanText(String(text)));
62
+ }
63
+
64
+ /**
65
+ * `singleSelect` columns: an option value or label, case-insensitive; returns the option's value.
66
+ * Without `valueOptions` the model has nothing to choose from, so any scalar is kept as it is.
67
+ */
68
+ export function coerceSingleSelect(text: unknown, colDef: CoerceColumn): CoerceResult<unknown> {
69
+ if (isBlank(text)) return ok(null);
70
+ if (typeof text !== 'string' && typeof text !== 'number' && typeof text !== 'boolean') return INVALID;
71
+ const options = colDef.valueOptions;
72
+ if (!options || options.length === 0) return ok(text);
73
+ return fromParsed(parseSingleSelectText(String(text), options));
74
+ }
75
+
76
+ /** Coerces `text` for the column's `type`. */
77
+ export function coerceValue(text: unknown, colDef: CoerceColumn): CoerceResult<unknown> {
78
+ switch (colDef.type) {
79
+ case 'number':
80
+ return coerceNumber(text);
81
+ case 'date':
82
+ return coerceDate(text);
83
+ case 'boolean':
84
+ return coerceBoolean(text);
85
+ case 'singleSelect':
86
+ return coerceSingleSelect(text, colDef);
87
+ default:
88
+ return coerceString(text);
89
+ }
90
+ }
@@ -0,0 +1,90 @@
1
+ // Which columns may take part in each part of the state. The schema and the validator both read
2
+ // these, so the model is never offered a field the validator would drop.
3
+ import { getOperatorsForType } from '../utils/filtering';
4
+ import type { GridFilterOperator, GridPivotAggFn } from '../types';
5
+ import type { GridAiColumn, GridAiPart, GridAiPartOptions } from './types';
6
+
7
+ /** The built-in aggregation functions (`AGGREGATION_FUNCTIONS` in lib/utils/aggregation). */
8
+ export const AGGREGATION_FNS: readonly string[] = ['sum', 'avg', 'count', 'min', 'max', 'unique'];
9
+ /** The functions a pivot value field takes (`GridPivotAggFn`). */
10
+ export const PIVOT_FNS: readonly GridPivotAggFn[] = ['sum', 'avg', 'count', 'min', 'max'];
11
+ /** Operators without a value. */
12
+ export const NO_VALUE_OPS: readonly string[] = ['isEmpty', 'isNotEmpty'];
13
+ /** Deepest nesting of filter groups. */
14
+ export const MAX_FILTER_DEPTH = 3;
15
+
16
+ export const ALL_PARTS: readonly GridAiPart[] = ['filter', 'sort', 'grouping', 'aggregation', 'pivot', 'columnVisibility'];
17
+
18
+ /** State key of each part, in output order. */
19
+ export const PART_KEYS: Record<GridAiPart, string> = {
20
+ filter: 'filterModel',
21
+ sort: 'sortModel',
22
+ grouping: 'rowGroupingModel',
23
+ aggregation: 'aggregationModel',
24
+ pivot: 'pivotModel',
25
+ columnVisibility: 'columnVisibilityModel',
26
+ };
27
+
28
+ export function hasOwn(obj: object, key: string): boolean {
29
+ return Object.prototype.hasOwnProperty.call(obj, key);
30
+ }
31
+
32
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+
36
+ export function selectedParts(options: GridAiPartOptions | undefined): GridAiPart[] {
37
+ const include = Array.isArray(options?.include) ? options.include : ALL_PARTS;
38
+ const exclude = Array.isArray(options?.exclude) ? options.exclude : [];
39
+ return ALL_PARTS.filter((part) => include.includes(part) && !exclude.includes(part));
40
+ }
41
+
42
+ /**
43
+ * The usable columns, first one per field. System columns (`__check__`, `__group__`, …), spacers
44
+ * and fields that are not plain strings are skipped.
45
+ */
46
+ export function usableColumns(columns: readonly GridAiColumn[] | unknown): GridAiColumn[] {
47
+ if (!Array.isArray(columns)) return [];
48
+ const seen = new Set<string>();
49
+ const out: GridAiColumn[] = [];
50
+ for (const col of columns as unknown[]) {
51
+ if (!isPlainObject(col)) continue;
52
+ const field = col.field;
53
+ if (typeof field !== 'string' || field === '' || field.startsWith('__') || col.isSpacer === true || seen.has(field)) continue;
54
+ seen.add(field);
55
+ out.push(col as unknown as GridAiColumn);
56
+ }
57
+ return out;
58
+ }
59
+
60
+ export function operatorsOf(col: GridAiColumn): GridFilterOperator[] {
61
+ return getOperatorsForType(col.type);
62
+ }
63
+
64
+ export const isFilterable = (col: GridAiColumn): boolean => col.filterable !== false;
65
+ export const isSortable = (col: GridAiColumn): boolean => col.sortable !== false;
66
+ export const isGroupable = (col: GridAiColumn): boolean => col.groupable !== false;
67
+ export const isHideable = (col: GridAiColumn): boolean => col.hideable !== false;
68
+
69
+ /**
70
+ * Aggregation functions for a column, as the Summaries panel offers them: every built-in function
71
+ * for number columns (or `aggregable: true`), `count` for the others, never with `aggregable: false`,
72
+ * and limited by `availableAggregationFunctions`.
73
+ */
74
+ export function aggregationFnsOf(col: GridAiColumn, fns: readonly string[] = AGGREGATION_FNS): string[] {
75
+ if (col.aggregable === false) return [];
76
+ const base = col.type === 'number' || col.aggregable === true ? fns : fns.filter((fn) => fn === 'count');
77
+ const allowed = col.availableAggregationFunctions;
78
+ return Array.isArray(allowed) ? base.filter((fn) => allowed.includes(fn)) : [...base];
79
+ }
80
+
81
+ /** JSON-safe option values of a singleSelect column, in order, without duplicates. */
82
+ export function optionValues(col: GridAiColumn): (string | number)[] {
83
+ const out: (string | number)[] = [];
84
+ if (!Array.isArray(col.valueOptions)) return out;
85
+ for (const option of col.valueOptions) {
86
+ const value: unknown = option !== null && typeof option === 'object' ? option.value : option;
87
+ if ((typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value))) && !out.includes(value)) out.push(value);
88
+ }
89
+ return out;
90
+ }