@opencorestack/opengridx 3.3.0 → 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 +31 -0
- package/README.md +4 -1
- 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 +148 -2
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +6084 -5523
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +17 -15
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +98 -1
- package/docs/README.md +3 -1
- package/docs/architecture/datagrid-orchestration.md +10 -1
- package/docs/customization/theming.md +9 -0
- package/docs/features/ai-toolkit.md +119 -0
- package/docs/features/clipboard.md +88 -0
- package/docs/features/editing-reordering.md +50 -0
- package/docs/features/keyboard-navigation.md +11 -0
- package/docs/features/undo-redo.md +79 -0
- package/docs/roadmap.md +1 -1
- 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 +5 -0
- package/lib/components/Cell/GridEditInputCell.css +12 -4
- package/lib/components/Cell/GridEditInputCell.tsx +19 -12
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.tsx +62 -2
- package/lib/components/FilterPanel/FilterPanel.css +14 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +3 -2
- package/lib/components/ListView/ListView.css +2 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Toolbar/GlobalSearch.css +16 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
- package/lib/components/ui/Checkbox.tsx +16 -5
- package/lib/components/ui/Input.css +43 -0
- package/lib/components/ui/Input.tsx +24 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +8 -1
- package/lib/hooks/core/useDataGrid.ts +27 -0
- package/lib/hooks/core/useGridCellSelection.ts +30 -2
- package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
- package/lib/hooks/core/useGridUndoRedo.ts +220 -0
- package/lib/hooks/features/useGridBatchEdit.ts +217 -0
- package/lib/hooks/features/useGridEditing.ts +24 -45
- package/lib/index.ts +7 -0
- package/lib/styles/opengridx.css +2 -1
- package/lib/types/index.ts +124 -0
- package/lib/utils/editing/commit.ts +69 -0
- package/lib/utils/editing/targets.ts +109 -0
- package/lib/utils/parsing.ts +252 -0
- package/llms.txt +55 -1
- package/package.json +8 -2
package/lib/types/index.ts
CHANGED
|
@@ -178,6 +178,14 @@ export interface GridColDef<R extends GridValidRowModel = GridRowModel> {
|
|
|
178
178
|
* @since v3.0
|
|
179
179
|
*/
|
|
180
180
|
valueSetter?(params: GridValueSetterParams<R>): R;
|
|
181
|
+
/**
|
|
182
|
+
* Turns pasted text into this column's value (clipboard paste), instead of the default parser for
|
|
183
|
+
* the column `type` (see `docs/features/clipboard.md`). Use it for locales such as `1.234,5` or for
|
|
184
|
+
* custom codes. Throw (or return `undefined`) to reject the text: the cell is skipped with reason
|
|
185
|
+
* `'invalidValue'`. The value then goes through `valueSetter` and `processRowUpdate` like an edit.
|
|
186
|
+
* @since v3.4
|
|
187
|
+
*/
|
|
188
|
+
valueParser?(text: string, params: GridValueParserParams<R>): unknown;
|
|
181
189
|
/**
|
|
182
190
|
* Custom ascending comparator for client-side sorting by this column, used instead of the built-in
|
|
183
191
|
* type-aware comparison. `v1` / `v2` are the cell values (`valueGetter` applied) and **include
|
|
@@ -240,6 +248,16 @@ export interface GridColDef<R extends GridValidRowModel = GridRowModel> {
|
|
|
240
248
|
* @default true
|
|
241
249
|
*/
|
|
242
250
|
exportable?: boolean;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Example values for this column, put into the schema from `getGridAiSchema`
|
|
254
|
+
* (`@opencorestack/opengridx/ai`) to help a language model write filters ("Acme" → `customer`).
|
|
255
|
+
* **These values are sent to the model.** The grid never reads row data for the schema; only what
|
|
256
|
+
* you list here leaves the app, so list nothing private. Strings, numbers and booleans are used
|
|
257
|
+
* (at most 10); other values are ignored. Not used by the grid itself.
|
|
258
|
+
* @since v3.4
|
|
259
|
+
*/
|
|
260
|
+
aiExamples?: unknown[];
|
|
243
261
|
}
|
|
244
262
|
|
|
245
263
|
/** Column definition used exclusively in List View mode. */
|
|
@@ -300,6 +318,14 @@ export interface GridValueGetterParams<R extends GridValidRowModel = GridRowMode
|
|
|
300
318
|
value: unknown;
|
|
301
319
|
}
|
|
302
320
|
|
|
321
|
+
/** Parameters passed to `GridColDef.valueParser`. @since v3.4 */
|
|
322
|
+
export interface GridValueParserParams<R extends GridValidRowModel = GridRowModel> {
|
|
323
|
+
/** The row the text is pasted into, as stored now. */
|
|
324
|
+
row: R;
|
|
325
|
+
field: string;
|
|
326
|
+
colDef: GridColDef<R>;
|
|
327
|
+
}
|
|
328
|
+
|
|
303
329
|
/**
|
|
304
330
|
* Parameters passed to the `valueSetter` function.
|
|
305
331
|
* @since v3.0
|
|
@@ -803,6 +829,54 @@ export interface GridRowScrollEndParams {
|
|
|
803
829
|
viewportHeight: number;
|
|
804
830
|
}
|
|
805
831
|
|
|
832
|
+
/**
|
|
833
|
+
* Why a cell of a batch edit (paste, range clear, undo, redo) was not written:
|
|
834
|
+
* - `'notEditable'`: the column is not `editable`, `isCellEditable` returned false, or the row is a
|
|
835
|
+
* synthetic row (group header, subtotal, tree auto-parent, pivot Grand Total);
|
|
836
|
+
* - `'invalidValue'`: the pasted text does not parse for the column type (or `valueParser` rejected it);
|
|
837
|
+
* - `'changed'`: undo/redo only, the cell no longer holds the value the history recorded;
|
|
838
|
+
* - `'missing'`: undo/redo only, the row is gone.
|
|
839
|
+
* @since v3.4
|
|
840
|
+
*/
|
|
841
|
+
export type GridEditSkipReason = 'notEditable' | 'invalidValue' | 'changed' | 'missing';
|
|
842
|
+
|
|
843
|
+
/** What a batch edit (paste, range clear, undo, redo) did. @since v3.4 */
|
|
844
|
+
export interface GridBatchEditResult {
|
|
845
|
+
/** Rows whose `processRowUpdate` succeeded and that were stored, in the order they were first touched. */
|
|
846
|
+
updated: GridRowId[];
|
|
847
|
+
/** Rows whose `valueSetter` or `processRowUpdate` threw or rejected (also reported to `onProcessRowUpdateError`). */
|
|
848
|
+
failed: { id: GridRowId; error: unknown }[];
|
|
849
|
+
/** Cells that were not written, with the reason. */
|
|
850
|
+
skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[];
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** `onClipboardPaste` details: the batch edit result plus the text that was pasted. @since v3.4 */
|
|
854
|
+
export interface GridClipboardPasteResult extends GridBatchEditResult {
|
|
855
|
+
text: string;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** `onBeforeClipboardPaste` params. @since v3.4 */
|
|
859
|
+
export interface GridBeforeClipboardPasteParams {
|
|
860
|
+
/** The clipboard text (`text/plain`). */
|
|
861
|
+
text: string;
|
|
862
|
+
/** The cell the paste starts from (the range's top-left cell, or the focused cell). */
|
|
863
|
+
anchor: GridCellCoordinates | null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** `onHistoryChange` params. @since v3.4 */
|
|
867
|
+
export interface GridHistoryChangeParams {
|
|
868
|
+
canUndo: boolean;
|
|
869
|
+
canRedo: boolean;
|
|
870
|
+
/** Number of actions that can be undone. */
|
|
871
|
+
size: number;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** `undoRedo` options. @since v3.4 */
|
|
875
|
+
export interface GridUndoRedoOptions {
|
|
876
|
+
/** How many actions are kept; the oldest is dropped beyond it. Default: 100. */
|
|
877
|
+
limit?: number;
|
|
878
|
+
}
|
|
879
|
+
|
|
806
880
|
/** Overrideable user-visible strings for internationalisation. All fields are optional; defaults match the built-in English strings. */
|
|
807
881
|
export interface GridLocaleText {
|
|
808
882
|
/** Label before the rows-per-page select. Default: `"Rows per page:"` */
|
|
@@ -939,6 +1013,39 @@ export interface DataGridProps<R extends GridValidRowModel = GridRowModel> {
|
|
|
939
1013
|
*/
|
|
940
1014
|
showCellSelectionStats?: boolean;
|
|
941
1015
|
|
|
1016
|
+
/**
|
|
1017
|
+
* With `cellSelection` on, Ctrl/Cmd+V pastes tab-separated text (Excel, Google Sheets) into the
|
|
1018
|
+
* editable cells from the range's top-left cell (or the focused cell), through `valueSetter` and
|
|
1019
|
+
* `processRowUpdate`. `true` turns the shortcut off; `apiRef.pasteText()` still works. Default: false.
|
|
1020
|
+
* @since v3.4
|
|
1021
|
+
*/
|
|
1022
|
+
disableClipboardPaste?: boolean;
|
|
1023
|
+
/**
|
|
1024
|
+
* Fired after every paste with the result (`updated` rows, `failed` rows, `skipped` cells and why)
|
|
1025
|
+
* and the pasted text. @since v3.4
|
|
1026
|
+
*/
|
|
1027
|
+
onClipboardPaste?: (result: GridClipboardPasteResult) => void;
|
|
1028
|
+
/**
|
|
1029
|
+
* Called before a paste is applied. Return `false` to cancel it, or a string to paste instead of the
|
|
1030
|
+
* clipboard text. A throw cancels the paste. @since v3.4
|
|
1031
|
+
*/
|
|
1032
|
+
onBeforeClipboardPaste?: (params: GridBeforeClipboardPasteParams) => boolean | string;
|
|
1033
|
+
/**
|
|
1034
|
+
* With `cellSelection` on, Delete / Backspace on a range of more than one cell empties its editable
|
|
1035
|
+
* cells (`null`, or `''` in string columns) as one edit. `true` turns that off. Default: false.
|
|
1036
|
+
* @since v3.4
|
|
1037
|
+
*/
|
|
1038
|
+
disableRangeClear?: boolean;
|
|
1039
|
+
/**
|
|
1040
|
+
* Undo and redo of cell edits, pastes and range clears: Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z and Ctrl+Y while
|
|
1041
|
+
* focus is in the grid and no editor is open, and `apiRef.undo()` / `redo()`. Undo and redo write
|
|
1042
|
+
* through `valueSetter` and `processRowUpdate`, like an edit. `{ limit }` sets how many actions are
|
|
1043
|
+
* kept (default 100). Default: false. @since v3.4
|
|
1044
|
+
*/
|
|
1045
|
+
undoRedo?: boolean | GridUndoRedoOptions;
|
|
1046
|
+
/** Fired when what can be undone or redone changes (`undoRedo`). @since v3.4 */
|
|
1047
|
+
onHistoryChange?: (params: GridHistoryChangeParams) => void;
|
|
1048
|
+
|
|
942
1049
|
/** Loading state. With no rows the body shows skeleton rows (or `slots.loadingOverlay`); with rows, they stay and a progress bar (or `slots.loadingOverlay`) is shown over them. */
|
|
943
1050
|
loading?: boolean;
|
|
944
1051
|
/** Visual density of the grid. */
|
|
@@ -1328,4 +1435,21 @@ export interface GridApi<R extends GridValidRowModel = GridRowModel> {
|
|
|
1328
1435
|
* copy; rejects when the clipboard write fails. @since v3.3
|
|
1329
1436
|
*/
|
|
1330
1437
|
copySelectedCells: () => Promise<void>;
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Pastes tab-separated text as Ctrl/Cmd+V does: from `anchor` (default: the range's top-left cell,
|
|
1441
|
+
* else the focused cell), with `onBeforeClipboardPaste` and `onClipboardPaste`. Works with
|
|
1442
|
+
* `disableClipboardPaste`. Resolves once every row's `processRowUpdate` has settled. @since v3.4
|
|
1443
|
+
*/
|
|
1444
|
+
pasteText: (text: string, anchor?: GridCellCoordinates) => Promise<GridBatchEditResult>;
|
|
1445
|
+
/** Undoes the last action (`undoRedo`). Resolves with what was written. @since v3.4 */
|
|
1446
|
+
undo: () => Promise<GridBatchEditResult>;
|
|
1447
|
+
/** Redoes the last undone action (`undoRedo`). @since v3.4 */
|
|
1448
|
+
redo: () => Promise<GridBatchEditResult>;
|
|
1449
|
+
/** Whether there is an action to undo. @since v3.4 */
|
|
1450
|
+
canUndo: () => boolean;
|
|
1451
|
+
/** Whether there is an undone action to redo. @since v3.4 */
|
|
1452
|
+
canRedo: () => boolean;
|
|
1453
|
+
/** Forgets every action (undo and redo). @since v3.4 */
|
|
1454
|
+
clearHistory: () => void;
|
|
1331
1455
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { GridColDef, GridRowId, GridRowModel } from '../../types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The steps every committed edit shares: a single-cell edit (`useGridEditing`) and a batch edit
|
|
5
|
+
* (`useGridBatchEdit`: paste, range clear, undo, redo) build the row the same way and report errors
|
|
6
|
+
* the same way.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A misuse of the editing contract (e.g. processRowUpdate returned nothing), as opposed to a consumer rejection. */
|
|
10
|
+
export class OpenGridXEditError extends Error {}
|
|
11
|
+
|
|
12
|
+
/** One committed cell change: the value before and the value stored after (both read through `getCellValue`). */
|
|
13
|
+
export interface GridCellChange {
|
|
14
|
+
id: GridRowId;
|
|
15
|
+
field: string;
|
|
16
|
+
before: unknown;
|
|
17
|
+
after: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cell values compare equal: dates by time, anything else with `Object.is`. */
|
|
21
|
+
export function isSameValue(a: unknown, b: unknown): boolean {
|
|
22
|
+
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
23
|
+
return Object.is(a, b);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
|
|
27
|
+
return typeof value === 'object' && value !== null && typeof (value as { then?: unknown }).then === 'function';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Sends an edit failure to `onProcessRowUpdateError`. Without one, a misuse of the contract is logged
|
|
32
|
+
* in development; a consumer rejection is the consumer's own and stays silent.
|
|
33
|
+
*/
|
|
34
|
+
export function reportEditError(error: unknown, onError: ((error: unknown) => void) | undefined): void {
|
|
35
|
+
if (onError) {
|
|
36
|
+
onError(error);
|
|
37
|
+
} else if (process.env.NODE_ENV !== 'production' && error instanceof OpenGridXEditError) {
|
|
38
|
+
console.error(error.message);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The error for a `processRowUpdate` that resolved to something other than a row object. */
|
|
43
|
+
export function invalidProcessedRowError(processed: unknown, outcome: string): OpenGridXEditError {
|
|
44
|
+
return new OpenGridXEditError(
|
|
45
|
+
`[OpenGridX] processRowUpdate must return the updated row object (or a Promise of it); ` +
|
|
46
|
+
`it returned ${processed === null ? 'null' : typeof processed}. ${outcome}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The row with one cell changed: through the column's `valueSetter`, else `row[field] = value`. */
|
|
51
|
+
export function buildEditedRow<R extends GridRowModel>(
|
|
52
|
+
row: R,
|
|
53
|
+
cell: { field: string; value: unknown },
|
|
54
|
+
colDef: GridColDef<R> | undefined,
|
|
55
|
+
warnedFields: Set<string>
|
|
56
|
+
): R {
|
|
57
|
+
if (colDef?.valueSetter) {
|
|
58
|
+
return colDef.valueSetter({ value: cell.value, row, field: cell.field });
|
|
59
|
+
}
|
|
60
|
+
if (colDef?.valueGetter && process.env.NODE_ENV !== 'production' && !warnedFields.has(cell.field)) {
|
|
61
|
+
warnedFields.add(cell.field);
|
|
62
|
+
console.warn(
|
|
63
|
+
`[OpenGridX] Column "${cell.field}" is editable and has a valueGetter but no valueSetter. ` +
|
|
64
|
+
`The edited value was written to row["${cell.field}"], which the valueGetter may ignore, so the cell ` +
|
|
65
|
+
'can keep showing the old value. Add a valueSetter that maps the value back onto the row.'
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return { ...row, [cell.field]: cell.value };
|
|
69
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { GridCellCoordinates, GridCellParams, GridColDef, GridRowId, GridRowMeta, GridRowModel } from '../../types';
|
|
2
|
+
import { isRangeColumnField } from '../cellSelection';
|
|
3
|
+
import type { CellRangeRect } from '../cellSelection';
|
|
4
|
+
import { isSystemField } from '../focus';
|
|
5
|
+
import { isSyntheticRowId } from '../syntheticRows';
|
|
6
|
+
import { resolveCellEditable } from './index';
|
|
7
|
+
import { getCellValue } from '../values';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Where batch edits (paste, range clear, undo/redo) land: the renderable rows and the range columns
|
|
11
|
+
* (data columns, no system or grouping column) in display order, the same coordinates as a cell range.
|
|
12
|
+
*/
|
|
13
|
+
export interface GridEditTargetContext<R extends GridRowModel> {
|
|
14
|
+
rows: readonly R[];
|
|
15
|
+
rowIds: GridRowId[];
|
|
16
|
+
rowIndexById: Map<GridRowId, number>;
|
|
17
|
+
/** Range columns in screen order. */
|
|
18
|
+
columns: GridColDef<R>[];
|
|
19
|
+
colIndexByField: Map<string, number>;
|
|
20
|
+
/** Number of system columns before the data columns (for `isCellEditable`'s `colIndex`). */
|
|
21
|
+
dataColumnOffset: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function buildEditTargetContext<R extends GridRowModel>(
|
|
25
|
+
rows: readonly R[],
|
|
26
|
+
getRowId: (row: R) => GridRowId,
|
|
27
|
+
navigationColumns: ReadonlyArray<GridColDef<R>>,
|
|
28
|
+
): GridEditTargetContext<R> {
|
|
29
|
+
const rowIds = rows.map(getRowId);
|
|
30
|
+
const rowIndexById = new Map<GridRowId, number>();
|
|
31
|
+
rowIds.forEach((id, index) => { if (!rowIndexById.has(id)) rowIndexById.set(id, index); });
|
|
32
|
+
const columns = navigationColumns.filter(c => isRangeColumnField(c.field));
|
|
33
|
+
const colIndexByField = new Map<string, number>();
|
|
34
|
+
columns.forEach((c, index) => { colIndexByField.set(c.field, index); });
|
|
35
|
+
const dataColumnOffset = navigationColumns.filter(c => isSystemField(c.field)).length;
|
|
36
|
+
return { rows, rowIds, rowIndexById, columns, colIndexByField, dataColumnOffset };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface GridEditTargetRules<R extends GridRowModel> {
|
|
40
|
+
isCellEditable?: (params: GridCellParams<R>) => boolean;
|
|
41
|
+
rowMetaMap?: ReadonlyMap<GridRowId, GridRowMeta>;
|
|
42
|
+
/** For a cell covered by a span, the origin cell (`null` otherwise). */
|
|
43
|
+
getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether a batch edit may write the cell at display position (r, c): a data row (not synthetic),
|
|
48
|
+
* not covered by a span whose origin is another cell, and editable by the same rule as double-click.
|
|
49
|
+
*/
|
|
50
|
+
export function isEditTargetWritable<R extends GridRowModel>(
|
|
51
|
+
ctx: GridEditTargetContext<R>,
|
|
52
|
+
rules: GridEditTargetRules<R>,
|
|
53
|
+
row: R,
|
|
54
|
+
r: number,
|
|
55
|
+
c: number,
|
|
56
|
+
): boolean {
|
|
57
|
+
const id = ctx.rowIds[r];
|
|
58
|
+
const colDef = ctx.columns[c];
|
|
59
|
+
if (!colDef || isSyntheticRowId(id, rules.rowMetaMap)) return false;
|
|
60
|
+
const origin = rules.getSpanOrigin?.(id, colDef.field);
|
|
61
|
+
if (origin && (origin.rowId !== id || origin.field !== colDef.field)) return false;
|
|
62
|
+
return resolveCellEditable({
|
|
63
|
+
row,
|
|
64
|
+
field: colDef.field,
|
|
65
|
+
value: getCellValue(row, colDef.field, colDef),
|
|
66
|
+
colDef,
|
|
67
|
+
rowIndex: r,
|
|
68
|
+
colIndex: ctx.dataColumnOffset + c,
|
|
69
|
+
rowMeta: rules.rowMetaMap?.get(id),
|
|
70
|
+
}, rules.isCellEditable);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The rectangle around the displayed cells among `cells`, or null when none is displayed. */
|
|
74
|
+
export function boundingTargetRect<R extends GridRowModel>(
|
|
75
|
+
ctx: GridEditTargetContext<R>,
|
|
76
|
+
cells: ReadonlyArray<{ id: GridRowId; field: string }>,
|
|
77
|
+
): CellRangeRect | null {
|
|
78
|
+
let top = Infinity;
|
|
79
|
+
let bottom = -Infinity;
|
|
80
|
+
let left = Infinity;
|
|
81
|
+
let right = -Infinity;
|
|
82
|
+
for (const cell of cells) {
|
|
83
|
+
const r = ctx.rowIndexById.get(cell.id);
|
|
84
|
+
const c = ctx.colIndexByField.get(cell.field);
|
|
85
|
+
if (r === undefined || c === undefined) continue;
|
|
86
|
+
if (r < top) top = r;
|
|
87
|
+
if (r > bottom) bottom = r;
|
|
88
|
+
if (c < left) left = c;
|
|
89
|
+
if (c > right) right = c;
|
|
90
|
+
}
|
|
91
|
+
return top === Infinity ? null : { top, bottom, left, right };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The two corner cells of a rectangle. */
|
|
95
|
+
export function rectCorners<R extends GridRowModel>(
|
|
96
|
+
ctx: GridEditTargetContext<R>,
|
|
97
|
+
rect: CellRangeRect,
|
|
98
|
+
): { anchor: GridCellCoordinates; head: GridCellCoordinates } {
|
|
99
|
+
return {
|
|
100
|
+
anchor: { id: ctx.rowIds[rect.top], field: ctx.columns[rect.left].field },
|
|
101
|
+
head: { id: ctx.rowIds[rect.bottom], field: ctx.columns[rect.right].field },
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Columns whose empty value is `''` rather than `null`. */
|
|
106
|
+
export function isTextColumn(colDef: Pick<GridColDef, 'type'> | undefined): boolean {
|
|
107
|
+
const type = colDef?.type;
|
|
108
|
+
return type === undefined || type === 'string' || type === 'image';
|
|
109
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import type { GridColDef, GridValidRowModel } from '../types';
|
|
2
|
+
import { toDate } from './values';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Text → typed values: the TSV reader clipboard paste uses, and the per-column-type parsers paste,
|
|
6
|
+
* `GridColDef.valueParser` defaults and the `/ai` validator share. Pure and React-free.
|
|
7
|
+
*
|
|
8
|
+
* Every pattern here is linear (CodeQL scans every push): no adjacent quantifiers that can match the
|
|
9
|
+
* same characters.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A parsed value, or `{ ok: false }` when the text does not fit the column type. */
|
|
13
|
+
export type GridParsedValue = { ok: true; value: unknown } | { ok: false };
|
|
14
|
+
|
|
15
|
+
const INVALID: GridParsedValue = { ok: false };
|
|
16
|
+
const valid = (value: unknown): GridParsedValue => ({ ok: true, value });
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Splits tab-separated text the way Excel, Google Sheets and LibreOffice write it: tabs between
|
|
20
|
+
* columns, `\r\n`, `\n` or `\r` between rows. A field that starts with a double quote is quoted: it
|
|
21
|
+
* may hold tabs, line breaks and doubled quotes (`""` → `"`), the reverse of `escapeTsvField`. A
|
|
22
|
+
* quote inside an unquoted field is kept as is. A trailing empty line is ignored, so `"a\tb\n"` is one
|
|
23
|
+
* row. Empty text gives no rows.
|
|
24
|
+
*/
|
|
25
|
+
export function parseTsv(text: string): string[][] {
|
|
26
|
+
const rows: string[][] = [];
|
|
27
|
+
if (text === '') return rows;
|
|
28
|
+
let row: string[] = [];
|
|
29
|
+
let field = '';
|
|
30
|
+
let i = 0;
|
|
31
|
+
const n = text.length;
|
|
32
|
+
let fieldStart = true;
|
|
33
|
+
while (i < n) {
|
|
34
|
+
const ch = text[i];
|
|
35
|
+
if (fieldStart && ch === '"') {
|
|
36
|
+
// A quoted field: read to the closing quote, `""` being one quote.
|
|
37
|
+
let j = i + 1;
|
|
38
|
+
let value = '';
|
|
39
|
+
let closed = false;
|
|
40
|
+
while (j < n) {
|
|
41
|
+
const q = text[j];
|
|
42
|
+
if (q === '"') {
|
|
43
|
+
if (text[j + 1] === '"') {
|
|
44
|
+
value += '"';
|
|
45
|
+
j += 2;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
closed = true;
|
|
49
|
+
j += 1;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
value += q;
|
|
53
|
+
j += 1;
|
|
54
|
+
}
|
|
55
|
+
if (!closed) {
|
|
56
|
+
// No closing quote: not a quoted field after all, read the quote as text.
|
|
57
|
+
field += ch;
|
|
58
|
+
i += 1;
|
|
59
|
+
fieldStart = false;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
field += value;
|
|
63
|
+
i = j;
|
|
64
|
+
fieldStart = false;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
fieldStart = false;
|
|
68
|
+
if (ch === '\t') {
|
|
69
|
+
row.push(field);
|
|
70
|
+
field = '';
|
|
71
|
+
fieldStart = true;
|
|
72
|
+
i += 1;
|
|
73
|
+
} else if (ch === '\n' || ch === '\r') {
|
|
74
|
+
row.push(field);
|
|
75
|
+
rows.push(row);
|
|
76
|
+
row = [];
|
|
77
|
+
field = '';
|
|
78
|
+
fieldStart = true;
|
|
79
|
+
i += ch === '\r' && text[i + 1] === '\n' ? 2 : 1;
|
|
80
|
+
} else {
|
|
81
|
+
field += ch;
|
|
82
|
+
i += 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// The last line, unless the text ended with a line break (Excel always adds one).
|
|
86
|
+
if (!fieldStart || row.length > 0 || field !== '') {
|
|
87
|
+
row.push(field);
|
|
88
|
+
rows.push(row);
|
|
89
|
+
}
|
|
90
|
+
return rows;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const PLAIN_NUMBER = /^\d+(?:\.\d*)?(?:e[+-]?\d+)?$/i;
|
|
94
|
+
const LEADING_DOT_NUMBER = /^\.\d+(?:e[+-]?\d+)?$/i;
|
|
95
|
+
const GROUPED_NUMBER = /^\d{1,3}(?:,\d{3})+(?:\.\d*)?$/;
|
|
96
|
+
// Currency symbols (Unicode Sc) and every kind of space, including the no-break spaces Excel uses
|
|
97
|
+
// as a thousands separator.
|
|
98
|
+
const STRIPPED_CHARS = /[\p{Sc}\s]/gu;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A number as spreadsheets copy it: `1234.5`, `1,234.5` (comma thousands groups only), `-12`,
|
|
102
|
+
* `(12)` (accounting negative), `12%` (→ 0.12), `1e3`, with currency symbols and spaces stripped.
|
|
103
|
+
* Empty text is `null`. `1,5` is not accepted (it is ambiguous): use a `valueParser` for
|
|
104
|
+
* decimal-comma locales.
|
|
105
|
+
*/
|
|
106
|
+
export function parseNumberText(text: string): GridParsedValue {
|
|
107
|
+
let s = text.replace(STRIPPED_CHARS, '');
|
|
108
|
+
if (s === '') return text.trim() === '' ? valid(null) : INVALID;
|
|
109
|
+
let negative = false;
|
|
110
|
+
if (s.startsWith('(') && s.endsWith(')')) {
|
|
111
|
+
negative = true;
|
|
112
|
+
s = s.slice(1, -1);
|
|
113
|
+
}
|
|
114
|
+
if (s.startsWith('-') || s.startsWith('+') || s.startsWith('−')) {
|
|
115
|
+
if (s[0] !== '+') negative = !negative;
|
|
116
|
+
s = s.slice(1);
|
|
117
|
+
}
|
|
118
|
+
let percent = false;
|
|
119
|
+
if (s.endsWith('%')) {
|
|
120
|
+
percent = true;
|
|
121
|
+
s = s.slice(0, -1);
|
|
122
|
+
}
|
|
123
|
+
if (GROUPED_NUMBER.test(s)) s = s.split(',').join('');
|
|
124
|
+
else if (!PLAIN_NUMBER.test(s) && !LEADING_DOT_NUMBER.test(s)) return INVALID;
|
|
125
|
+
let value = Number(s);
|
|
126
|
+
if (!Number.isFinite(value)) return INVALID;
|
|
127
|
+
if (percent) value /= 100;
|
|
128
|
+
if (negative) value = -value;
|
|
129
|
+
return valid(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const ISO_DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
|
|
133
|
+
const pad2 = (n: number) => String(n).padStart(2, '0');
|
|
134
|
+
const localDay = (date: Date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A date: ISO `2026-10-07` (read as that local calendar day) or anything `Date.parse` accepts. Empty
|
|
138
|
+
* text is `null`. The result takes the kind of `current` (the value the cell holds now), as the date
|
|
139
|
+
* editor does: a timestamp stays a timestamp, a date string stays a `YYYY-MM-DD` string (an ISO
|
|
140
|
+
* date-time string keeps its time part); otherwise it is a `Date`.
|
|
141
|
+
*/
|
|
142
|
+
export function parseDateText(text: string, current?: unknown): GridParsedValue {
|
|
143
|
+
const trimmed = text.trim();
|
|
144
|
+
if (trimmed === '') return valid(null);
|
|
145
|
+
const date = toDate(trimmed);
|
|
146
|
+
if (!date) return INVALID;
|
|
147
|
+
if (typeof current === 'number') return valid(date.getTime());
|
|
148
|
+
if (typeof current === 'string') {
|
|
149
|
+
const day = localDay(date);
|
|
150
|
+
return valid(ISO_DATE_PREFIX.test(current) && current.length > 10 ? day + current.slice(10) : day);
|
|
151
|
+
}
|
|
152
|
+
return valid(date);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Text read as `true` / `false`, besides the grid's own `Yes` / `No` cell labels. Lower case. */
|
|
156
|
+
const TRUE_WORDS = new Set(['true', 'yes', '1']);
|
|
157
|
+
const FALSE_WORDS = new Set(['false', 'no', '0']);
|
|
158
|
+
|
|
159
|
+
export interface GridBooleanLabels {
|
|
160
|
+
/** Text shown for `true` (the grid shows `Yes`). */
|
|
161
|
+
trueLabel?: string;
|
|
162
|
+
/** Text shown for `false` (the grid shows `No`). */
|
|
163
|
+
falseLabel?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A boolean: `true`/`false`, `yes`/`no` (the grid's own `Yes` / `No` cell labels), `1`/`0` and any
|
|
168
|
+
* `labels` given, case-insensitive. Empty text is `null`.
|
|
169
|
+
*/
|
|
170
|
+
export function parseBooleanText(text: string, labels?: GridBooleanLabels): GridParsedValue {
|
|
171
|
+
const s = text.trim().toLowerCase();
|
|
172
|
+
if (s === '') return valid(null);
|
|
173
|
+
if (labels?.trueLabel !== undefined && s === labels.trueLabel.trim().toLowerCase()) return valid(true);
|
|
174
|
+
if (labels?.falseLabel !== undefined && s === labels.falseLabel.trim().toLowerCase()) return valid(false);
|
|
175
|
+
if (TRUE_WORDS.has(s)) return valid(true);
|
|
176
|
+
if (FALSE_WORDS.has(s)) return valid(false);
|
|
177
|
+
return INVALID;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type ValueOption = NonNullable<GridColDef['valueOptions']>[number];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* A `singleSelect` value: the option whose value or label matches the text, case-insensitive (values
|
|
184
|
+
* first). Returns the option's own value, so a numeric option stays a number. Empty text is `null`.
|
|
185
|
+
*/
|
|
186
|
+
export function parseSingleSelectText(text: string, options: readonly ValueOption[] | undefined): GridParsedValue {
|
|
187
|
+
const s = text.trim();
|
|
188
|
+
if (s === '') return valid(null);
|
|
189
|
+
if (!options || options.length === 0) return INVALID;
|
|
190
|
+
const lower = s.toLowerCase();
|
|
191
|
+
for (const option of options) {
|
|
192
|
+
const value = typeof option === 'object' ? option.value : option;
|
|
193
|
+
if (String(value).toLowerCase() === lower) return valid(value);
|
|
194
|
+
}
|
|
195
|
+
for (const option of options) {
|
|
196
|
+
if (typeof option === 'object' && option.label.toLowerCase() === lower) return valid(option.value);
|
|
197
|
+
}
|
|
198
|
+
return INVALID;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
type ParserColumn = Pick<GridColDef, 'type' | 'valueOptions'>;
|
|
202
|
+
|
|
203
|
+
export interface GridParseTextOptions {
|
|
204
|
+
/** The value the cell holds now: a parsed date takes its kind (see `parseDateText`). */
|
|
205
|
+
current?: unknown;
|
|
206
|
+
/** Extra boolean labels, besides `Yes` / `No`. */
|
|
207
|
+
booleanLabels?: GridBooleanLabels;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Parses cell text by the column `type`, without `valueParser`:
|
|
212
|
+
*
|
|
213
|
+
* | type | accepted |
|
|
214
|
+
* | :--- | :--- |
|
|
215
|
+
* | `string` (default), `image` | the text as is (an empty cell is `''`) |
|
|
216
|
+
* | `number` | `parseNumberText` |
|
|
217
|
+
* | `date` | `parseDateText` |
|
|
218
|
+
* | `boolean` | `parseBooleanText` |
|
|
219
|
+
* | `singleSelect` | `parseSingleSelectText` |
|
|
220
|
+
*
|
|
221
|
+
* An empty cell is `null` for every type but `string` / `image`.
|
|
222
|
+
*/
|
|
223
|
+
export function parseValueByType(text: string, colDef: ParserColumn | undefined, options?: GridParseTextOptions): GridParsedValue {
|
|
224
|
+
switch (colDef?.type) {
|
|
225
|
+
case 'number': return parseNumberText(text);
|
|
226
|
+
case 'date': return parseDateText(text, options?.current);
|
|
227
|
+
case 'boolean': return parseBooleanText(text, options?.booleanLabels);
|
|
228
|
+
case 'singleSelect': return parseSingleSelectText(text, colDef.valueOptions);
|
|
229
|
+
default: return valid(text);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Parses the text pasted into one cell: the column's `valueParser` when it has one (a throw, or a
|
|
235
|
+
* returned `undefined`, rejects the text), otherwise `parseValueByType`.
|
|
236
|
+
*/
|
|
237
|
+
export function parseCellText<R extends GridValidRowModel>(
|
|
238
|
+
text: string,
|
|
239
|
+
colDef: GridColDef<R>,
|
|
240
|
+
row: R,
|
|
241
|
+
options?: GridParseTextOptions,
|
|
242
|
+
): GridParsedValue {
|
|
243
|
+
const { valueParser } = colDef;
|
|
244
|
+
if (!valueParser) return parseValueByType(text, colDef as unknown as ParserColumn, options);
|
|
245
|
+
let value: unknown;
|
|
246
|
+
try {
|
|
247
|
+
value = valueParser(text, { row, field: colDef.field, colDef });
|
|
248
|
+
} catch {
|
|
249
|
+
return INVALID;
|
|
250
|
+
}
|
|
251
|
+
return value === undefined ? INVALID : valid(value);
|
|
252
|
+
}
|