@opencorestack/opengridx 3.2.2 → 3.4.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 +53 -0
- package/README.md +16 -2
- package/dist/ai.cjs +1 -0
- package/dist/ai.cjs.map +1 -0
- package/dist/ai.d.ts +166 -0
- package/dist/ai.es.js +1 -0
- package/dist/ai.es.js.map +1 -0
- package/dist/index.d.ts +321 -3
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +6483 -5291
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -13
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +132 -3
- package/docs/README.md +4 -1
- package/docs/architecture/datagrid-orchestration.md +23 -6
- package/docs/components/header.md +25 -0
- package/docs/customization/theming.md +12 -1
- package/docs/features/ai-toolkit.md +119 -0
- package/docs/features/cell-selection.md +131 -0
- package/docs/features/clipboard.md +90 -0
- package/docs/features/editing-reordering.md +50 -0
- package/docs/features/keyboard-navigation.md +25 -2
- package/docs/features/undo-redo.md +79 -0
- package/docs/roadmap.md +64 -7
- package/lib/ai/coerce.ts +90 -0
- package/lib/ai/columns.ts +90 -0
- package/lib/ai/index.ts +16 -0
- package/lib/ai/schema.ts +214 -0
- package/lib/ai/types.ts +88 -0
- package/lib/ai/validate.ts +286 -0
- package/lib/components/Cell/Cell.css +35 -0
- package/lib/components/Cell/Cell.tsx +25 -13
- package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
- package/lib/components/Cell/GridEditInputCell.css +12 -4
- package/lib/components/Cell/GridEditInputCell.tsx +19 -12
- package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
- package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
- package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.tsx +123 -3
- package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
- package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
- package/lib/components/FilterPanel/FilterPanel.css +14 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +5 -3
- package/lib/components/Header/Header.css +12 -0
- package/lib/components/Header/Header.tsx +17 -4
- package/lib/components/ListView/ListView.css +2 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Row/Row.tsx +38 -1
- package/lib/components/Toolbar/GlobalSearch.css +16 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
- package/lib/components/Toolbar/GridToolbar.tsx +10 -4
- package/lib/components/ui/Checkbox.tsx +26 -10
- package/lib/components/ui/Input.css +43 -0
- package/lib/components/ui/Input.tsx +24 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +14 -1
- package/lib/hooks/core/useDataGrid.ts +27 -0
- package/lib/hooks/core/useGridCellSelection.ts +631 -0
- package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
- package/lib/hooks/core/useGridClipboardApi.ts +8 -2
- package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
- package/lib/hooks/core/useGridControlledState.ts +38 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +8 -4
- package/lib/hooks/core/useGridUndoRedo.ts +220 -0
- package/lib/hooks/features/useAggregation.ts +8 -6
- package/lib/hooks/features/useGridBatchEdit.ts +217 -0
- package/lib/hooks/features/useGridClipboard.ts +47 -2
- package/lib/hooks/features/useGridEditing.ts +39 -51
- package/lib/index.ts +16 -0
- package/lib/styles/base/variables.css +6 -1
- package/lib/styles/opengridx.css +3 -1
- package/lib/theme/DataGridThemeProvider.tsx +4 -0
- package/lib/theme/presets.ts +8 -0
- package/lib/theme/types.ts +4 -0
- package/lib/types/index.ts +254 -0
- package/lib/utils/attempt.ts +14 -0
- package/lib/utils/cellSelection.ts +238 -0
- package/lib/utils/editing/commit.ts +69 -0
- package/lib/utils/editing/targets.ts +109 -0
- package/lib/utils/headerWrap.ts +23 -0
- package/lib/utils/parsing.ts +252 -0
- package/llms.txt +71 -2
- package/package.json +14 -4
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Cell Range Selection
|
|
2
|
+
|
|
3
|
+
*Since v3.3.0*
|
|
4
|
+
|
|
5
|
+
Users select a rectangle of cells the way they do in Excel or Google Sheets, by dragging, Shift+clicking or pressing Shift+arrow keys, and copy it with Ctrl/Cmd+C. An optional status bar shows the count, sum and average of the selection.
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { DataGrid } from '@opencorestack/opengridx';
|
|
9
|
+
|
|
10
|
+
<DataGrid
|
|
11
|
+
rows={rows}
|
|
12
|
+
columns={columns}
|
|
13
|
+
cellSelection
|
|
14
|
+
showCellSelectionStats
|
|
15
|
+
height={500}
|
|
16
|
+
/>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Everything is off unless `cellSelection` is set. Without it the grid behaves exactly as before.
|
|
20
|
+
|
|
21
|
+
## Gestures
|
|
22
|
+
|
|
23
|
+
| Input | Result |
|
|
24
|
+
| :--- | :--- |
|
|
25
|
+
| Click | Focus the cell. The range is that cell. A plain click still selects the row unless `disableRowSelectionOnClick` is set. |
|
|
26
|
+
| Press and drag | Range from the pressed cell to the cell under the pointer. Holding the pointer near or past an edge of the body scrolls the grid, over virtualized rows and columns. |
|
|
27
|
+
| Shift+click | Extend from the anchor (the active cell) to the clicked cell. |
|
|
28
|
+
| Shift+Arrow | Move the far corner (head) by one cell. It does **not** wrap to the next row, though plain arrows still do. |
|
|
29
|
+
| Shift+Home / Shift+End | Head to the first / last column of its row. |
|
|
30
|
+
| Ctrl+Shift+Home / End (Cmd on macOS) | Head to the first / last cell of the page. |
|
|
31
|
+
| Shift+PageUp / Shift+PageDown | Head one page up / down. |
|
|
32
|
+
| Ctrl/Cmd+A | Select every data cell on the page. The header checkbox still selects all rows. |
|
|
33
|
+
| Escape | Collapse the range to the anchor (when not editing). |
|
|
34
|
+
| Ctrl/Cmd+C | Copy the range as TSV. |
|
|
35
|
+
| Typing / Enter / F2 | Edit the anchor cell, as before. |
|
|
36
|
+
|
|
37
|
+
Drag and Shift ranges never change the **row** selection, and the checkbox column keeps selecting rows.
|
|
38
|
+
|
|
39
|
+
## The model
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
interface GridCellCoordinates { id: GridRowId; field: string }
|
|
43
|
+
interface GridCellRange { anchor: GridCellCoordinates; head: GridCellCoordinates }
|
|
44
|
+
type GridCellSelectionModel = readonly GridCellRange[]; // zero or one range in 3.3
|
|
45
|
+
type GridCellSelectionReason = 'pointer' | 'keyboard' | 'selectAll' | 'api' | 'clear' | 'dataChange';
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `anchor` is the active cell. It keeps DOM focus and it is where an edit starts. `head` is the corner that moves, and the one that is scrolled into view. A single focused cell is a range whose `anchor` equals its `head`. It is not drawn as a range, but it is what Ctrl+C copies.
|
|
49
|
+
- Corners are row **ids** and column **fields**, not indices. The rectangle is worked out from the current display order on every render, so after a sort or filter it follows the two corner rows (as Google Sheets does). If a corner's row or column is no longer displayed (filtered, on another page, removed or hidden), the range is cleared with `reason: 'dataChange'`.
|
|
50
|
+
- Controlled or uncontrolled, like the other models:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
const [model, setModel] = useState<GridCellSelectionModel>([]);
|
|
54
|
+
|
|
55
|
+
<DataGrid
|
|
56
|
+
cellSelection
|
|
57
|
+
cellSelectionModel={model}
|
|
58
|
+
onCellSelectionModelChange={(next, { reason }) => setModel(next)}
|
|
59
|
+
/>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Which cells take part
|
|
63
|
+
|
|
64
|
+
- **Columns:** only data columns, in screen order: left-pinned, then centre, then right-pinned. The checkbox, detail-panel, reorder and grouping (`__group__`) columns never join a range, and clicking them works as before.
|
|
65
|
+
- **Rows:** pinned top and bottom rows take part in screen order. Group headers, subtotals, tree auto-parents and the pivot Grand Total are highlighted when they fall inside the rectangle, but they are **skipped by copy** and by `getSelectedCells()`.
|
|
66
|
+
- **Spans:** a range that touches part of a `colSpan` or `rowSpan` grows to cover the whole span, repeatedly until no span crosses its edge (as Excel does with merged cells). In a copy the span's value sits at its origin and the covered positions are empty.
|
|
67
|
+
- **Pages:** a range covers the rows of the current page.
|
|
68
|
+
|
|
69
|
+
## Copy
|
|
70
|
+
|
|
71
|
+
With `cellSelection` on, Ctrl/Cmd+C copies the range (the focused cell when nothing else is selected):
|
|
72
|
+
|
|
73
|
+
- values formatted the way the cells show them (`valueGetter`, then `valueFormatter`);
|
|
74
|
+
- tabs between columns, line breaks between rows, **no header line**, and fields quoted where Excel needs it;
|
|
75
|
+
- `exportable: false` columns are copied when they are in the range, because the user chose them on screen.
|
|
76
|
+
|
|
77
|
+
Row copy stays available through `apiRef.current.copySelectedRows()`, which still includes the header line. `disableClipboardCopy` turns the shortcut off for both. Above 100,000 cells a development warning suggests `exportToCsv` instead. Nothing is truncated.
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
| Method | Description |
|
|
82
|
+
| :--- | :--- |
|
|
83
|
+
| `getCellSelectionModel()` | The current model (`[]` when `cellSelection` is off). |
|
|
84
|
+
| `setCellSelectionModel(model)` | Replace the model (only the first range is kept). Focus moves to its anchor. |
|
|
85
|
+
| `selectCellRange(anchor, head)` | Select the rectangle between two cells. |
|
|
86
|
+
| `clearCellSelection()` | Empty the selection (`reason: 'clear'`). |
|
|
87
|
+
| `getSelectedCells()` | `{ id, field, value }[]` of the range in display order, values read through `valueGetter`. Synthetic rows and span-covered positions are left out. |
|
|
88
|
+
| `copySelectedCells()` | Copy the range as TSV. Rejects when the clipboard write fails. |
|
|
89
|
+
|
|
90
|
+
The methods do nothing while `cellSelection` is off.
|
|
91
|
+
|
|
92
|
+
## Status bar
|
|
93
|
+
|
|
94
|
+
`showCellSelectionStats` adds a strip under the grid (above the pagination area). While more than one cell is selected it shows:
|
|
95
|
+
|
|
96
|
+
- **Count**: non-empty cells of data rows;
|
|
97
|
+
- **Sum** and **Average**: of the numeric cells. Both are hidden when there are none.
|
|
98
|
+
|
|
99
|
+
These use the shared aggregation functions. The strip keeps its height while one cell or none is selected, so the grid does not jump. Replace its content with `slots.cellSelectionStats`, which receives `GridCellSelectionStatsSlotProps` (`cellCount`, `rowCount`, `columnCount`, `count`, `numericCount`, `sum`, `average`, `apiRef`) plus `slotProps.cellSelectionStats`. The default component is exported as `CellSelectionStats`.
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
function MyStats({ count, sum }: GridCellSelectionStatsSlotProps) {
|
|
103
|
+
return <span>{count} cells, total {sum ?? 0}</span>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
<DataGrid cellSelection showCellSelectionStats slots={{ cellSelectionStats: MyStats }} />
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Styling
|
|
110
|
+
|
|
111
|
+
| Class / variable | Meaning |
|
|
112
|
+
| :--- | :--- |
|
|
113
|
+
| `ogx__cell--range` | Every cell in the range (tinted). |
|
|
114
|
+
| `ogx__cell--range-top` / `-bottom` / `-left` / `-right` | Edge cells. The outline is an inset `box-shadow`, so nothing moves. |
|
|
115
|
+
| `ogx--range-dragging` | On the grid root during a pointer drag (`user-select: none`). |
|
|
116
|
+
| `--ogx-range-background` | Range tint. Theme key `grid.rangeBackground`. |
|
|
117
|
+
| `--ogx-range-border` | Range outline. Theme key `grid.rangeBorder`. |
|
|
118
|
+
|
|
119
|
+
All five built-in themes define both variables. A custom `DataGridThemeProvider` theme derives them from `colors.primary` unless it sets them.
|
|
120
|
+
|
|
121
|
+
## Accessibility
|
|
122
|
+
|
|
123
|
+
- Cells in the range have `aria-selected="true"`, and the grid is `aria-multiselectable`.
|
|
124
|
+
- DOM focus stays on the anchor. The grid stays a single Tab stop.
|
|
125
|
+
- After each change the polite live region announces the size, for example "12 cells selected, 3 rows by 4 columns", debounced to 300 ms. Translate it with `localeText.cellSelectionAnnouncement(cells, rows, columns)`.
|
|
126
|
+
|
|
127
|
+
## Not in 3.3
|
|
128
|
+
|
|
129
|
+
Paste, Delete-to-clear and undo (planned for 3.4), the fill handle, several ranges at once (Ctrl+drag), touch selection (on touch a drag scrolls) and ranges across pages.
|
|
130
|
+
|
|
131
|
+
See also: [Clipboard](clipboard.md), [Keyboard Navigation](keyboard-navigation.md), [Theming](../customization/theming.md).
|
|
@@ -17,6 +17,8 @@ Selection and copying follow standard spreadsheet metaphors:
|
|
|
17
17
|
| :--- | :--- |
|
|
18
18
|
| `Ctrl + C` / `Cmd + C` | Copy currently selected rows to the clipboard. |
|
|
19
19
|
|
|
20
|
+
> **Cell ranges (v3.3)**: with `cellSelection` on, Ctrl/Cmd+C copies the selected cell range instead (the focused cell at minimum), with no header line. `apiRef.current.copySelectedRows()` still copies rows. See [Cell Range Selection](cell-selection.md).
|
|
21
|
+
|
|
20
22
|
> **Note**: The shortcut only copies while focus is inside the grid (a cell, a header, the toolbar), so with several grids on a page only the one in use copies, and Ctrl+C elsewhere on the page is never taken over (v3.0). It is also ignored while an input, textarea, or another editable element has focus, and while the page has a text selection, which the browser copies as usual. `apiRef.current.copySelectedRows()` is not affected by focus.
|
|
21
23
|
>
|
|
22
24
|
> The shortcut works with Caps Lock on and on non-Latin keyboard layouts (the key in the "C" position). Ctrl+Shift+C and Ctrl+Alt+C are not copy shortcuts and are left alone. Focus stays where it was after the copy.
|
|
@@ -99,3 +101,91 @@ ID Name Email Status
|
|
|
99
101
|
```
|
|
100
102
|
|
|
101
103
|
When pasted into Excel or Google Sheets, this content will automatically be distributed into the correct cells.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 📥 Paste from Excel and Google Sheets (v3.4)
|
|
108
|
+
|
|
109
|
+
With `cellSelection` on, **Ctrl/Cmd+V** pastes tab-separated text into the grid's **editable** cells. Each value is parsed for its column and saved like an edit: `valueSetter`, then `processRowUpdate` **once per row**, then the grid stores every row that succeeded in one update. Demo: **Paste from Excel** (`/clipboard-paste`).
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
<DataGrid
|
|
113
|
+
rows={rows}
|
|
114
|
+
columns={columns} // columns with editable: true
|
|
115
|
+
cellSelection
|
|
116
|
+
processRowUpdate={saveRow}
|
|
117
|
+
onClipboardPaste={({ updated, skipped, failed, text }) =>
|
|
118
|
+
toast(`${updated.length} rows updated, ${skipped.length} cells skipped`)}
|
|
119
|
+
/>
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- Paste works while focus is in the grid and no editor is open. In an open editor, Ctrl/Cmd+V is the input's own paste.
|
|
123
|
+
- The grid reads the `paste` event (`text/plain`), so the browser asks for no clipboard permission.
|
|
124
|
+
- `disableClipboardPaste` turns the shortcut off. `apiRef.current.pasteText(text, anchor?)` still works, for a toolbar "Paste" button that reads the clipboard itself.
|
|
125
|
+
|
|
126
|
+
### Where values land
|
|
127
|
+
|
|
128
|
+
| Clipboard | Selection | Result |
|
|
129
|
+
| :--- | :--- | :--- |
|
|
130
|
+
| One value | One cell | That cell |
|
|
131
|
+
| One value | A range | Every cell of the range (Excel's "fill selection") |
|
|
132
|
+
| A block | One cell | The block, from that cell to the right and down |
|
|
133
|
+
| A block | A range | The block from the range's top-left cell; cells past the range, the last row or the last column are dropped |
|
|
134
|
+
|
|
135
|
+
No rows are created. Only displayed rows are reached: rows on other pages and inside collapsed groups are not. After the paste the pasted area becomes the selection, and one Ctrl/Cmd+Z undoes all of it (with `undoRedo`).
|
|
136
|
+
|
|
137
|
+
### How text is parsed
|
|
138
|
+
|
|
139
|
+
The clipboard is read as Excel and Sheets write it: tabs between columns, `\r\n` or `\n` between rows, quoted fields that may hold tabs, line breaks and doubled quotes. A trailing empty line is ignored. Each cell is then parsed for its column:
|
|
140
|
+
|
|
141
|
+
| Column type | Accepted | Otherwise |
|
|
142
|
+
| :--- | :--- | :--- |
|
|
143
|
+
| `string` (default), `image` | The text as is (an `image` column takes the URL) | — |
|
|
144
|
+
| `number` | `1234.5`, `1,234.5`, `-12`, `(12)`, `12%` (→ 0.12), `1e3`; currency symbols and spaces stripped | skipped, `'invalidValue'` |
|
|
145
|
+
| `date` | ISO `2026-10-07` (that local day), and what `Date.parse` accepts. The value keeps the kind the cell holds: a `Date`, a timestamp, or a `YYYY-MM-DD` string | skipped |
|
|
146
|
+
| `boolean` | `true`/`false`, `yes`/`no` (the grid's `Yes`/`No` labels), `1`/`0`, any case | skipped |
|
|
147
|
+
| `singleSelect` | An option value or an option label, any case; the option's value is stored | skipped |
|
|
148
|
+
| An empty cell | `null` for number, date, boolean and singleSelect; `''` for string | — |
|
|
149
|
+
|
|
150
|
+
`1,5` is not read as a number: in a decimal-comma locale use a `valueParser`.
|
|
151
|
+
|
|
152
|
+
### `valueParser`
|
|
153
|
+
|
|
154
|
+
`GridColDef.valueParser(text, { row, field, colDef })` replaces the parser for one column. Throw (or return `undefined`) to reject the text: the cell is skipped with reason `'invalidValue'`.
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
{
|
|
158
|
+
field: 'price',
|
|
159
|
+
type: 'number',
|
|
160
|
+
editable: true,
|
|
161
|
+
// "1.234,5" → 1234.5
|
|
162
|
+
valueParser: (text) => {
|
|
163
|
+
const n = Number(text.replace(/[\s€]/g, '').split('.').join('').replace(',', '.'));
|
|
164
|
+
if (Number.isNaN(n)) throw new Error('not a number');
|
|
165
|
+
return n;
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### What is skipped
|
|
171
|
+
|
|
172
|
+
`result.skipped` lists every cell that was not written, with a reason:
|
|
173
|
+
|
|
174
|
+
| Reason | When |
|
|
175
|
+
| :--- | :--- |
|
|
176
|
+
| `'notEditable'` | The column is not `editable`, `isCellEditable` returned false, the row is a synthetic row (group header, subtotal, tree auto-parent, pivot Grand Total), or the cell is covered by a span |
|
|
177
|
+
| `'invalidValue'` | The text does not parse for the column type, or `valueParser` rejected it |
|
|
178
|
+
|
|
179
|
+
A row whose `valueSetter` or `processRowUpdate` throws or rejects is listed in `result.failed` and reported to `onProcessRowUpdateError`, as for a single edit; the other rows are still saved. Cells that already hold the pasted value are left alone.
|
|
180
|
+
|
|
181
|
+
### Events
|
|
182
|
+
|
|
183
|
+
- `onClipboardPaste(result)` fires after every paste, once every row has settled: `{ updated, failed, skipped, text }`.
|
|
184
|
+
- `onBeforeClipboardPaste({ text, anchor })` runs first. Return `false` to cancel the paste, or a string to paste instead (for example to strip a header line). A throw cancels the paste.
|
|
185
|
+
- Above 10,000 cells a paste logs a development warning.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## 🧹 Clear a range with Delete (v3.4)
|
|
190
|
+
|
|
191
|
+
With `cellSelection` on and a range of **more than one cell**, **Delete** or **Backspace** empties every editable cell of the range: `''` in string and image columns, `null` elsewhere. It is one batch edit through `valueSetter` and `processRowUpdate`, so one undo. Non-editable cells and synthetic rows are left alone. On a single cell nothing changes (Delete is not bound). `disableRangeClear` turns it off; `disableClipboardPaste` does not affect it.
|
|
@@ -115,6 +115,56 @@ const columns: GridColDef[] = [
|
|
|
115
115
|
|
|
116
116
|
Enter and Escape keydowns that bubble out of a custom editor also commit and cancel. An error thrown by `renderEditCell` is contained to its cell (the cell shows `⚠`), like `renderCell`.
|
|
117
117
|
|
|
118
|
+
### Building custom editors with `Input`
|
|
119
|
+
The built-in text, number and date editors are the exported `Input` component with `variant="cell"` (since v3.3.1). Use it in `renderEditCell` and a custom editor looks like the built-in ones: it fills the cell, has no border, background or focus ring of its own (the editing cell's outline shows), and uses the cell's font.
|
|
120
|
+
|
|
121
|
+
```tsx
|
|
122
|
+
import { useEffect, useRef, useState } from 'react';
|
|
123
|
+
import { Input } from '@opencorestack/opengridx';
|
|
124
|
+
import type { GridColDef, GridRenderEditCellParams } from '@opencorestack/opengridx';
|
|
125
|
+
|
|
126
|
+
const isAmount = (t: string) => t.trim() !== '' && Number(t) >= 0;
|
|
127
|
+
|
|
128
|
+
function PriceEditor({ value, onValueChange, onCommit, onCancel }: GridRenderEditCellParams) {
|
|
129
|
+
const [text, setText] = useState(String(value ?? ''));
|
|
130
|
+
const ref = useRef<HTMLInputElement>(null);
|
|
131
|
+
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
|
|
132
|
+
const valid = isAmount(text);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<Input
|
|
136
|
+
ref={ref} // reaches the <input>, on React 18 and 19
|
|
137
|
+
variant="cell"
|
|
138
|
+
startAdornment="$" // or endAdornment="kg"
|
|
139
|
+
error={!valid} // red inner edge + aria-invalid
|
|
140
|
+
inputMode="decimal"
|
|
141
|
+
value={text}
|
|
142
|
+
onChange={e => {
|
|
143
|
+
setText(e.target.value);
|
|
144
|
+
if (isAmount(e.target.value)) onValueChange(Number(e.target.value));
|
|
145
|
+
}}
|
|
146
|
+
onKeyDown={e => {
|
|
147
|
+
if (e.key === 'Enter') { e.stopPropagation(); if (valid) onCommit(); }
|
|
148
|
+
if (e.key === 'Escape') { e.stopPropagation(); onCancel(); }
|
|
149
|
+
}}
|
|
150
|
+
onBlur={() => (valid ? onCommit() : onCancel())}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const columns: GridColDef[] = [
|
|
156
|
+
{ field: 'price', type: 'number', editable: true, renderEditCell: params => <PriceEditor {...params} /> },
|
|
157
|
+
];
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- `ref` reaches the `<input>` (focus it and select its text when the editor opens, as the built-in editors do).
|
|
161
|
+
- `inputClassName` adds classes to the `<input>`; `className` goes to the `.ogx-input-wrapper` div around it.
|
|
162
|
+
- `error` adds `ogx-input-wrapper--error` (an inset `--ogx-color-error` edge in the cell variant, drawn over the cell outline) and `aria-invalid="true"`.
|
|
163
|
+
- Stop propagation of the Enter / Escape you handle yourself, or the grid also commits / cancels when they bubble out.
|
|
164
|
+
- The grid's boolean editor is the exported `Checkbox`; its `<input>` is visually hidden and `ref` / `inputClassName` reach it.
|
|
165
|
+
|
|
166
|
+
Live example: the *Custom Cell Editors* demo page (`/custom-editors`).
|
|
167
|
+
|
|
118
168
|
### Computed columns (`valueGetter` + `valueSetter`)
|
|
119
169
|
An editable column with a `valueGetter` edits the derived value. Give it a `valueSetter` so the commit can write the value back to the fields it comes from; otherwise the commit writes `row[field]`, which the `valueGetter` ignores, and a development warning is logged.
|
|
120
170
|
|
|
@@ -25,8 +25,31 @@ The grid follows the [WAI-ARIA data grid pattern](https://www.w3.org/WAI/ARIA/ap
|
|
|
25
25
|
| **Alt+ArrowRight** / **Alt+ArrowLeft** | Tree data / row grouping: expand / collapse the focused row. |
|
|
26
26
|
| **Shift+Space** | Select or deselect the focused row (when rows can be selected: `checkboxSelection`, or click selection not disabled). Synthetic group rows are not selectable. |
|
|
27
27
|
| **Space** | On the row checkbox: toggle the row. On the detail-panel toggle: expand or collapse the panel. Elsewhere it does nothing (it never scrolls the grid). |
|
|
28
|
-
| **Ctrl+A** (Cmd+A) | Select every row, when several rows can be selected. |
|
|
29
|
-
| **Ctrl+C** (Cmd+C) | Copy the selected rows (see [Clipboard](clipboard.md)). |
|
|
28
|
+
| **Ctrl+A** (Cmd+A) | Select every row, when several rows can be selected. With `cellSelection`: select every data cell of the page instead. |
|
|
29
|
+
| **Ctrl+C** (Cmd+C) | Copy the selected rows (see [Clipboard](clipboard.md)). With `cellSelection`: copy the cell range, or the focused cell. |
|
|
30
|
+
|
|
31
|
+
With [`cellSelection`](cell-selection.md) on (v3.3), on a data cell:
|
|
32
|
+
|
|
33
|
+
| Key | Action |
|
|
34
|
+
| :--- | :--- |
|
|
35
|
+
| **Shift+Arrow keys** | Extend the cell range by one cell. Unlike plain arrows they do not wrap to the next row. Focus stays on the anchor. |
|
|
36
|
+
| **Shift+Home** / **Shift+End** | Extend to the first / last column of the row. |
|
|
37
|
+
| **Ctrl+Shift+Home** / **Ctrl+Shift+End** (Cmd on macOS) | Extend to the first / last cell of the page. |
|
|
38
|
+
| **Shift+PageUp** / **Shift+PageDown** | Extend one page up / down. |
|
|
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
|
+
|
|
43
|
+
With `undoRedo` on (v3.4, see [Undo & Redo](undo-redo.md)), while focus is on a cell and no editor is open:
|
|
44
|
+
|
|
45
|
+
| Key | Action |
|
|
46
|
+
| :--- | :--- |
|
|
47
|
+
| **Ctrl+Z** (Cmd+Z) | Undo the last edit, paste or range clear. |
|
|
48
|
+
| **Ctrl+Shift+Z** (Cmd+Shift+Z) or **Ctrl+Y** | Redo. |
|
|
49
|
+
|
|
50
|
+
Inside an open editor these keys belong to the input (its own paste and undo).
|
|
51
|
+
|
|
52
|
+
A plain arrow key after a range collapses it to the newly focused cell. Shift+Space still selects the row.
|
|
30
53
|
|
|
31
54
|
Arrow, Home/End, PageUp/PageDown and Space are always consumed, so the viewport does not scroll natively when focus cannot move further.
|
|
32
55
|
|
|
@@ -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
|
@@ -70,18 +70,75 @@ This document tracks the current status of features in `OpenGridX` and outlines
|
|
|
70
70
|
|
|
71
71
|
---
|
|
72
72
|
|
|
73
|
-
##
|
|
73
|
+
## 🗓️ Q4 2026 Roadmap (October – December)
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
* **Interactive Pivot Builder**: Drag-and-drop UI for users to dynamically create pivot tables.
|
|
77
|
-
* **Advanced Charts Integration**: Inline sparklines and trend visualization.
|
|
75
|
+
**Theme: free spreadsheet editing, with AI you control.** Range selection, paste, undo/redo and a fill handle are paid features in MUI X Premium and AG Grid Enterprise, and clipboard paste (476 👍) and cell range selection (317 👍) are among the most-requested grid features on GitHub. The research behind this plan compared ten competing grids and their AI features.
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
**Team:** one maintainer plus an AI coding assistant. The plan is sized for that: one headline feature per release, and anything that slips moves to the next release instead of stretching the current one.
|
|
78
|
+
|
|
79
|
+
**Compatibility:** everything ships in 3.x minor releases. Each new interaction is opt-in through a prop, so existing grids behave exactly as before until the feature is enabled.
|
|
80
|
+
|
|
81
|
+
### Releases
|
|
82
|
+
|
|
83
|
+
| Release | Target | Headline | Also in it |
|
|
84
|
+
| :--- | :--- | :--- | :--- |
|
|
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** ✅ | 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** | 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) |
|
|
88
|
+
| Freeze | 19 Dec – 5 Jan | Bug fixes only | — |
|
|
89
|
+
|
|
90
|
+
### Across the quarter
|
|
91
|
+
|
|
92
|
+
* **Published benchmark**: reproducible 100k / 1M-row numbers (scroll frame rate, sort and filter time, memory) in `docs/performance.md`.
|
|
93
|
+
* **Next.js App Router guide**: using the grid from a `'use client'` component, with the stylesheet in `app/layout.tsx`.
|
|
94
|
+
* **RTL check**: verify right-to-left layouts and document the result.
|
|
95
|
+
* **Demo site**: source viewer on all examples (`DocsLayout` with Vite `?raw`) and syntax highlighting, done alongside each release's demo page.
|
|
96
|
+
|
|
97
|
+
### Definition of done for each feature
|
|
98
|
+
|
|
99
|
+
* A short design note in `docs/superpowers/specs/` before coding starts (API, keyboard behaviour, ARIA).
|
|
100
|
+
* Unit tests, browser tests in Chromium, Firefox and WebKit, and a scenario in the package smoke suite.
|
|
101
|
+
* Keyboard-only and screen-reader pass: the grid stays a single Tab stop and announces selection changes.
|
|
102
|
+
* API reference, feature guide, demo page, CHANGELOG and `llms.txt` updated (the wiki follows automatically).
|
|
103
|
+
* Bundle budget: the core bundle grows only by the feature itself; the `/ai` entry point has no dependencies and adds nothing when it is not imported.
|
|
104
|
+
|
|
105
|
+
### Rules for the AI toolkit
|
|
106
|
+
|
|
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
|
+
* 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.
|
|
110
|
+
* The schema carries a `schemaVersion`, since apps will cache prompts against it.
|
|
111
|
+
|
|
112
|
+
### Risks
|
|
113
|
+
|
|
114
|
+
* **Range selection touches focus and keyboard handling**, where most v3.0 fixes landed. It is opt-in and gets the longest test cycle; if it slips, 3.4.0 moves rather than shipping paste on an unstable base.
|
|
115
|
+
* **Undo must cover rejected updates**: a `processRowUpdate` that throws or a paste that partly fails must leave the history consistent.
|
|
116
|
+
* **Wrong AI filters** that look plausible: the validator, visible chips and undo are required, not optional.
|
|
117
|
+
|
|
118
|
+
## 🔭 Next (Q1 2027)
|
|
119
|
+
|
|
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
|
+
* **Find & highlight** (Ctrl+F through cells), **right-click context menu**, **Excel-style value (set) filter**.
|
|
123
|
+
* **Calculated columns** (`[price] * [qty]` defined by users).
|
|
124
|
+
* **Interactive pivot builder**: drag-and-drop panel for pivot and grouping.
|
|
125
|
+
* **Sparklines** and a charts adapter that passes grid state to any chart library.
|
|
126
|
+
* **AI toolkit, part 3**: smart paste (unstructured text → pending rows) and `apiRef.getAiContext()` for "summarise selection"; an `opengridx-mcp` docs server for coding assistants.
|
|
127
|
+
* **Tailwind / shadcn theme preset**, sticky header against page scroll.
|
|
128
|
+
|
|
129
|
+
### Not planned
|
|
130
|
+
|
|
131
|
+
* **Spreadsheet formulas** (`=SUM(A1:A9)`): a formula engine is a product of its own; calculated columns cover most of the need.
|
|
132
|
+
* **AI on every cell**: the cost grows with each row and results are not reproducible.
|
|
133
|
+
* **Built-in semantic search or anomaly detection**: large models and app-specific logic; better built by the app on top of the grid.
|
|
134
|
+
* **Scaled scrolling past ~645,000 rows** and **list-view virtualization**: revisit if users ask.
|
|
82
135
|
|
|
83
136
|
## ✅ Implemented Features (Recent)
|
|
84
137
|
|
|
138
|
+
### 3.x releases *(2026-09-29 → 2026-10-01)*
|
|
139
|
+
- **3.0.0** correctness release (rows never modified, typed generics, React 18 support), **3.0.1** fill-height layout fix, **3.1.0** `sortComparator` and column auto-size, **3.2.0** non-blocking PDF export, **3.2.1** Safari boolean editor fix, **3.2.2** linear-time export regexes. Details in [CHANGELOG.md](../CHANGELOG.md); upgrade guide in [migration/v2-to-v3.md](migration/v2-to-v3.md).
|
|
140
|
+
- Package smoke suite against the packed tarball in React 18 and 19 apps, pull-request CI, community files and a wiki generated from these docs.
|
|
141
|
+
|
|
85
142
|
### Consumer defect fixes *(v2.1.0, 2026-09-23)*
|
|
86
143
|
- Row-grouping subtotals now use the shared aggregation functions (nulls ignored, `unique` supported, `availableAggregationFunctions` honoured); `min`/`max` no longer overflow the call stack on very large datasets.
|
|
87
144
|
- Group and tree-data expansion state survives row updates, including server-side lazily loaded children.
|
package/lib/ai/coerce.ts
ADDED
|
@@ -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
|
+
}
|