@opencorestack/opengridx 3.7.0 → 3.8.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 (45) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/ai.cjs +3 -3
  3. package/dist/ai.cjs.map +1 -1
  4. package/dist/ai.es.js +3 -3
  5. package/dist/ai.es.js.map +1 -1
  6. package/dist/opengridx.css +1 -1
  7. package/dist/opengridx.es.js +4982 -4853
  8. package/dist/opengridx.es.js.map +1 -1
  9. package/dist/opengridx.umd.js +15 -15
  10. package/dist/opengridx.umd.js.map +1 -1
  11. package/docs/features/filtering.md +2 -0
  12. package/lib/components/DataGrid/DataGrid.css +0 -3
  13. package/lib/components/DataGrid/DataGrid.tsx +2 -4
  14. package/lib/components/DataGrid/GridListView.tsx +1 -1
  15. package/lib/components/DataGrid/GridPinnedRows.tsx +1 -1
  16. package/lib/components/DataGrid/GridVirtualRows.tsx +1 -1
  17. package/lib/components/ListView/ListView.css +4 -4
  18. package/lib/hooks/core/useDataGrid.ts +40 -35
  19. package/lib/hooks/core/useGridAggregationApi.ts +1 -1
  20. package/lib/hooks/core/useGridCellSelection.ts +3 -4
  21. package/lib/hooks/core/useGridClipboardPaste.ts +1 -1
  22. package/lib/hooks/core/useGridDetailPanel.ts +1 -1
  23. package/lib/hooks/core/useGridFillHandle.ts +1 -1
  24. package/lib/hooks/core/useGridHierarchy.ts +2 -2
  25. package/lib/hooks/core/useGridKeyboardNavigation.ts +1 -1
  26. package/lib/hooks/core/useGridLiveRowSelection.ts +9 -2
  27. package/lib/hooks/core/useGridRangeEditing.ts +1 -1
  28. package/lib/hooks/core/useGridRowInteractions.ts +3 -2
  29. package/lib/hooks/core/useLayout.ts +34 -24
  30. package/lib/hooks/features/useGridSpanning.ts +3 -3
  31. package/lib/hooks/useRowGrouping.ts +19 -10
  32. package/lib/hooks/useRowReorder.ts +9 -3
  33. package/lib/styles/base/reset.css +5 -3
  34. package/lib/types/state.ts +9 -0
  35. package/lib/utils/editing/targets.ts +2 -3
  36. package/lib/utils/export/exportShared.ts +22 -4
  37. package/lib/utils/export/index.ts +8 -4
  38. package/lib/utils/filtering/index.ts +8 -2
  39. package/lib/utils/gridApiRows.ts +1 -1
  40. package/lib/utils/grouping/groupedExportRows.ts +1 -1
  41. package/lib/utils/grouping/layeredMap.ts +52 -0
  42. package/lib/utils/parsing.ts +24 -1
  43. package/lib/utils/rowIndex.ts +67 -0
  44. package/lib/utils/values.ts +31 -0
  45. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  import type { GridAggregationModel, GridColDef, GridGroupedExportRow, GridRowId, GridRowModel } from '../../types';
2
2
  import { computeAggregations, formatAggregateForColumn } from '../aggregation';
3
- import { formatValueByType } from '../values';
3
+ import { formatValueByType, getCellValue } from '../values';
4
4
  import { isSystemField } from '../focus';
5
5
 
6
6
  /**
@@ -77,10 +77,12 @@ export function aggregationForExport<R extends GridRowModel>(
77
77
  return computeAggregations(exportedRows, aggregationModel, lookup, 'export');
78
78
  }
79
79
 
80
- /** The cell's raw value: `row[field]`, then the column's `valueGetter`. */
80
+ /**
81
+ * The cell's raw value, read like the grid reads it (`getCellValue`): a `valueGetter` that throws
82
+ * exports as an empty value instead of aborting the export.
83
+ */
81
84
  export function getRawExportValue<R extends GridRowModel>(row: R, col: GridColDef<R>): unknown {
82
- const value: unknown = (row as GridRowModel)[col.field];
83
- return col.valueGetter ? col.valueGetter({ row, field: col.field, value }) : value;
85
+ return getCellValue(row, col.field, col);
84
86
  }
85
87
 
86
88
  /**
@@ -159,6 +161,22 @@ export function neutralizeFormula(text: string, fromNonText = false): string {
159
161
  return `'${text}`;
160
162
  }
161
163
 
164
+ /**
165
+ * Text that reads as a formatted number (digits, signs, separators, brackets, %, currency symbols),
166
+ * which no spreadsheet runs as a formula. One character class: linear on any input.
167
+ */
168
+ const NUMERIC_DISPLAY = /^[\d\s.,'%+\-()\p{Sc}]*$/u;
169
+
170
+ /**
171
+ * Whether a data cell's text may skip formula escaping: the grid formatted a number, date or
172
+ * boolean itself, or a `valueFormatter` turned such a value into plain numeric text ("-$3").
173
+ * Anything else a formatter writes is escaped like text, since it may come from user data.
174
+ */
175
+ export function isTrustedNonText(raw: unknown, text: string, hasFormatter: boolean): boolean {
176
+ if (!isNonTextValue(raw)) return false;
177
+ return !hasFormatter || NUMERIC_DISPLAY.test(text);
178
+ }
179
+
162
180
  /** Whether a raw value is a number, bigint, boolean or Date, whose formatted text is not user-typed. */
163
181
  export function isNonTextValue(value: unknown): boolean {
164
182
  return typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean' || value instanceof Date;
@@ -10,6 +10,7 @@ import {
10
10
  getRawExportValue,
11
11
  hasSelection,
12
12
  isNonTextValue,
13
+ isTrustedNonText,
13
14
  neutralizeFormula,
14
15
  pickExportedAggregates,
15
16
  rowsForExport,
@@ -55,11 +56,14 @@ export interface CsvExportOptions<R extends GridValidRowModel = GridRowModel> {
55
56
  interface ExportCell {
56
57
  text: string;
57
58
  raw: unknown;
59
+ /** The text came from the grid's own formatting of a number, date or boolean (see `isTrustedNonText`). */
60
+ fromNonText: boolean;
58
61
  }
59
62
 
60
63
  function exportCell<R extends GridRowModel>(row: R, col: GridColDef<R>): ExportCell {
61
64
  const raw = getRawExportValue(row, col);
62
- return { text: formatExportValue(row, col, raw), raw };
65
+ const text = formatExportValue(row, col, raw);
66
+ return { text, raw, fromNonText: isTrustedNonText(raw, text, !!col.valueFormatter) };
63
67
  }
64
68
 
65
69
  /** Aggregate text per export column for one subtotal / total record. */
@@ -111,7 +115,7 @@ export function exportToCsv<R extends GridRowModel>(
111
115
  const row = entry.row as R;
112
116
  const cells = exportColumns.map((col, i) => {
113
117
  const cell = exportCell(row, col);
114
- const text = escapeFormulas ? neutralizeFormula(cell.text, isNonTextValue(cell.raw)) : cell.text;
118
+ const text = escapeFormulas ? neutralizeFormula(cell.text, cell.fromNonText) : cell.text;
115
119
  return escapeCSV((i === 0 ? indent : '') + text, delimiter);
116
120
  });
117
121
  csvContent += cells.join(delimiter) + '\n';
@@ -138,7 +142,7 @@ export function exportToCsv<R extends GridRowModel>(
138
142
  rowsToExport.forEach(row => {
139
143
  const values = exportColumns.map(col => {
140
144
  const cell = exportCell(row, col);
141
- return field(cell.text, isNonTextValue(cell.raw));
145
+ return field(cell.text, cell.fromNonText);
142
146
  });
143
147
  csvContent += values.join(delimiter) + '\n';
144
148
  });
@@ -255,7 +259,7 @@ export function exportToExcel<R extends GridRowModel>(
255
259
  html += '<tbody>';
256
260
 
257
261
  const dataCell = (cell: ExportCell, col: GridColDef<R>, prefix = ''): string => {
258
- const content = prefix + cellText(cell.text, isNonTextValue(cell.raw));
262
+ const content = prefix + cellText(cell.text, cell.fromNonText);
259
263
  return isXlsTextCell(col, cell.raw) ? textTd(content) : `<td>${content}</td>`;
260
264
  };
261
265
 
@@ -1,7 +1,7 @@
1
1
 
2
2
  import type { GridRowModel, GridFilterItem, GridFilterModel, GridFilterGroup, GridFilterOperator, GridColDef } from '../../types';
3
3
  import type { GridColumnLookup } from '../columnLookup';
4
- import { getCellValue, getFormattedValue, toLocalDateString, toLocalDayKey, toNumber } from '../values';
4
+ import { getCellValue, formatValueByType, getFormattedValue, toLocalDateString, toLocalDayKey, toNumber } from '../values';
5
5
 
6
6
  /** Compares a cell value with the filter item's value. */
7
7
  export type GridFilterOperatorFn = (value: unknown, filterValue?: unknown) => boolean;
@@ -230,7 +230,13 @@ function getQuickFilterTexts(row: GridRowModel, columns: GridColumnLookup | unde
230
230
  for (const col of columns.quickFilterColumns) {
231
231
  const value = getCellValue(row, col.field, col);
232
232
  const formatted = getFormattedValue(row, col.field, value, col);
233
- if (formatted != null && formatted !== '') texts.push(String(formatted).toLowerCase());
233
+ if (formatted != null && formatted !== '') {
234
+ texts.push(String(formatted).toLowerCase());
235
+ } else if (col.type === 'date' || col.type === 'boolean' || col.type === 'singleSelect') {
236
+ // Without a formatter the grid shows a locale date, Yes / No or the option label: search that too.
237
+ const shown = formatValueByType(value, col);
238
+ if (shown !== '') texts.push(shown.toLowerCase());
239
+ }
234
240
  pushSearchText(texts, value);
235
241
  }
236
242
  return texts;
@@ -9,7 +9,7 @@ export interface CollectAllFilteredRowsParams<R extends GridRowModel> {
9
9
  /** Every unpinned row that passes the filter, sorted, across all pages. */
10
10
  sortedUnpinnedRows: R[];
11
11
  pinnedBottomRows: R[];
12
- rowMetaMap: Map<GridRowId, GridRowMeta>;
12
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
13
13
  getRowId: (row: GridRowModel) => GridRowId;
14
14
  /**
15
15
  * With a hierarchy: the rows that pass the filter (the set select-all and the footer use).
@@ -16,7 +16,7 @@ export interface BuildGroupedExportRowsParams<R extends GridRowModel> {
16
16
  */
17
17
  rows: R[];
18
18
  getRowId: (row: R) => GridRowId;
19
- rowMetaMap: Map<GridRowId, GridRowMeta>;
19
+ rowMetaMap: ReadonlyMap<GridRowId, GridRowMeta>;
20
20
  columns: GridColDef<R>[];
21
21
  aggregationModel: GridAggregationModel;
22
22
  /** The footer's grand total (already over the filtered data rows); omitted when null. */
@@ -0,0 +1,52 @@
1
+ /**
2
+ * A read-only view of two maps with disjoint keys, read as one: `top` first, then `base`. Row
3
+ * grouping keeps one entry per leaf row in `base` (built once per tree) and the group rows in
4
+ * `top` (rebuilt on every expand / collapse), so toggling a group at 1M rows does not copy a
5
+ * 1M-entry map. Iteration yields `base` entries, then `top` entries.
6
+ */
7
+ export class LayeredMap<K, V> implements ReadonlyMap<K, V> {
8
+ private readonly base: ReadonlyMap<K, V>;
9
+ private readonly top: ReadonlyMap<K, V>;
10
+
11
+ constructor(base: ReadonlyMap<K, V>, top: ReadonlyMap<K, V>) {
12
+ this.base = base;
13
+ this.top = top;
14
+ }
15
+
16
+ get size(): number {
17
+ return this.base.size + this.top.size;
18
+ }
19
+
20
+ get(key: K): V | undefined {
21
+ const value = this.top.get(key);
22
+ return value !== undefined || this.top.has(key) ? value : this.base.get(key);
23
+ }
24
+
25
+ has(key: K): boolean {
26
+ return this.top.has(key) || this.base.has(key);
27
+ }
28
+
29
+ forEach(callback: (value: V, key: K, map: ReadonlyMap<K, V>) => void, thisArg?: unknown): void {
30
+ this.base.forEach((value, key) => callback.call(thisArg, value, key, this));
31
+ this.top.forEach((value, key) => callback.call(thisArg, value, key, this));
32
+ }
33
+
34
+ *entries(): MapIterator<[K, V]> {
35
+ yield* this.base.entries();
36
+ yield* this.top.entries();
37
+ }
38
+
39
+ *keys(): MapIterator<K> {
40
+ yield* this.base.keys();
41
+ yield* this.top.keys();
42
+ }
43
+
44
+ *values(): MapIterator<V> {
45
+ yield* this.base.values();
46
+ yield* this.top.values();
47
+ }
48
+
49
+ [Symbol.iterator](): MapIterator<[K, V]> {
50
+ return this.entries();
51
+ }
52
+ }
@@ -131,6 +131,19 @@ export function parseNumberText(text: string): GridParsedValue {
131
131
 
132
132
  const ISO_DATE_PREFIX = /^(\d{4}-\d{2}-\d{2})/;
133
133
  const pad2 = (n: number) => String(n).padStart(2, '0');
134
+ /** Whether an ISO date-time string ends with `Z` or a `±hh:mm` / `±hhmm` offset. A loop, not a regex. */
135
+ function hasZone(text: string): boolean {
136
+ const end = text.trimEnd();
137
+ if (end.endsWith('Z') || end.endsWith('z')) return true;
138
+ const tIndex = end.indexOf('T');
139
+ if (tIndex < 0) return false;
140
+ for (let i = end.length - 1; i > tIndex; i--) {
141
+ const ch = end[i];
142
+ if (ch === '+' || ch === '-') return true;
143
+ if (!(ch >= '0' && ch <= '9') && ch !== ':') return false;
144
+ }
145
+ return false;
146
+ }
134
147
  const localDay = (date: Date) => `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
135
148
 
136
149
  /**
@@ -147,7 +160,17 @@ export function parseDateText(text: string, current?: unknown): GridParsedValue
147
160
  if (typeof current === 'number') return valid(date.getTime());
148
161
  if (typeof current === 'string') {
149
162
  const day = localDay(date);
150
- return valid(ISO_DATE_PREFIX.test(current) && current.length > 10 ? day + current.slice(10) : day);
163
+ if (!ISO_DATE_PREFIX.test(current) || current.length <= 10) return valid(day);
164
+ // A zoned date-time ("…Z", "…+05:30") is shown as a local day: keep its local time of day on
165
+ // the pasted day and write UTC, or west of UTC the cell would show the day before.
166
+ const held = hasZone(current) ? new Date(current) : null;
167
+ if (held && !Number.isNaN(held.getTime())) {
168
+ return valid(new Date(
169
+ date.getFullYear(), date.getMonth(), date.getDate(),
170
+ held.getHours(), held.getMinutes(), held.getSeconds(), held.getMilliseconds(),
171
+ ).toISOString());
172
+ }
173
+ return valid(day + current.slice(10));
151
174
  }
152
175
  return valid(date);
153
176
  }
@@ -0,0 +1,67 @@
1
+ import type { GridRowId } from '../types';
2
+
3
+ /** The ids of a row array in order, and each id's first index. */
4
+ export interface GridRowIndex {
5
+ rowIds: GridRowId[];
6
+ rowIndexById: Map<GridRowId, number>;
7
+ }
8
+
9
+ interface CacheEntry {
10
+ rows: readonly object[];
11
+ value: GridRowIndex;
12
+ }
13
+
14
+ // Last result per id resolver and view. The grid's resolver keeps its identity while the row store
15
+ // keeps its id maps (cell edits do), so the entry lives exactly as long as it can be reused.
16
+ const cache = new WeakMap<(row: never) => GridRowId, Map<string, CacheEntry>>();
17
+
18
+ function build<R extends object>(rows: readonly R[], getRowId: (row: R) => GridRowId): GridRowIndex {
19
+ const rowIds = new Array<GridRowId>(rows.length);
20
+ const rowIndexById = new Map<GridRowId, number>();
21
+ for (let index = 0; index < rows.length; index++) {
22
+ const id = getRowId(rows[index]);
23
+ rowIds[index] = id;
24
+ if (!rowIndexById.has(id)) rowIndexById.set(id, index);
25
+ }
26
+ return { rowIds, rowIndexById };
27
+ }
28
+
29
+ /**
30
+ * `rows.map(getRowId)` plus an id → first index map, reusing the previous result for the same
31
+ * resolver and `view` when the ids are unchanged position by position. A cell edit replaces row
32
+ * objects but never moves or re-keys rows, so after an edit only the replaced positions are
33
+ * resolved (a reference comparison per row instead of a map lookup per row, ~20× cheaper at 1M
34
+ * rows), and callers memoized on the returned object do not recompute. Treat the result as
35
+ * read-only: it may be shared.
36
+ */
37
+ export function getRowIndex<R extends object>(
38
+ rows: readonly R[],
39
+ getRowId: (row: R) => GridRowId,
40
+ view: string,
41
+ ): GridRowIndex {
42
+ let byView = cache.get(getRowId);
43
+ const previous = byView?.get(view);
44
+ if (previous && previous.rows.length === rows.length) {
45
+ const prevRows = previous.rows as readonly R[];
46
+ const prevIds = previous.value.rowIds;
47
+ let same = true;
48
+ for (let index = 0; index < rows.length; index++) {
49
+ const row = rows[index];
50
+ if (row !== prevRows[index] && getRowId(row) !== prevIds[index]) {
51
+ same = false;
52
+ break;
53
+ }
54
+ }
55
+ if (same) {
56
+ previous.rows = rows;
57
+ return previous.value;
58
+ }
59
+ }
60
+ const value = build(rows, getRowId);
61
+ if (!byView) {
62
+ byView = new Map();
63
+ cache.set(getRowId, byView);
64
+ }
65
+ byView.set(view, { rows, value });
66
+ return value;
67
+ }
@@ -132,6 +132,7 @@ export function toDate(value: unknown): Date | null {
132
132
  // Reject roll-overs such as 2024-02-31.
133
133
  if (date.getMonth() !== month || date.getDate() !== day) return null;
134
134
  } else {
135
+ if (!hasOnlyDateWords(trimmed)) return null;
135
136
  date = new Date(trimmed);
136
137
  }
137
138
  } else {
@@ -140,6 +141,36 @@ export function toDate(value: unknown): Date | null {
140
141
  return Number.isNaN(date.getTime()) ? null : date;
141
142
  }
142
143
 
144
+ const DATE_WORDS = [
145
+ 'january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october',
146
+ 'november', 'december', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
147
+ ];
148
+ const DATE_TOKENS = new Set(['t', 'z', 'am', 'pm', 'gmt', 'utc', 'sept']);
149
+
150
+ function isDateWord(word: string): boolean {
151
+ if (DATE_TOKENS.has(word)) return true;
152
+ return word.length >= 3 && DATE_WORDS.some(name => name.startsWith(word));
153
+ }
154
+
155
+ /**
156
+ * Whether every run of letters in `text` is a month or weekday name (or abbreviation), or one of
157
+ * T, Z, AM, PM, GMT, UTC. `Date.parse` accepts free text such as "hello 5" (as a 2001 date in V8),
158
+ * which must not become a date. A loop, not a regex.
159
+ */
160
+ function hasOnlyDateWords(text: string): boolean {
161
+ let word = '';
162
+ for (let i = 0; i <= text.length; i++) {
163
+ const ch = i < text.length ? text[i].toLowerCase() : '';
164
+ if (ch >= 'a' && ch <= 'z') {
165
+ word += ch;
166
+ continue;
167
+ }
168
+ if (word !== '' && !isDateWord(word)) return false;
169
+ word = '';
170
+ }
171
+ return true;
172
+ }
173
+
143
174
  /** A sortable integer for the local calendar day of a date (YYYYMMDD), or null when unparsable. */
144
175
  export function toLocalDayKey(value: unknown): number | null {
145
176
  const date = toDate(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencorestack/opengridx",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "description": "OpenGridX: High-performance React data infrastructure. Unlock advanced Row Grouping, Excel Export, and Column Pinning without the usual \"Pro\" gatekeeping. Built for speed, scale, and complete architectural freedom. Fully open, virtualization-ready, and feature-complete.",
5
5
  "type": "module",
6
6
  "main": "./dist/opengridx.umd.js",