@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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// validateGridAiState: the safety layer between a model's reply and the grid. Anything the columns
|
|
2
|
+
// do not allow is dropped with an error; values are coerced to the column type. Never throws.
|
|
3
|
+
// Only own keys of the input are read (never `__proto__`), and every key written to the output is a
|
|
4
|
+
// column field, so nothing in the reply can reach Object.prototype or the output's prototype.
|
|
5
|
+
import type {
|
|
6
|
+
GridAggregationModel,
|
|
7
|
+
GridColumnVisibilityModel,
|
|
8
|
+
GridFilterGroup,
|
|
9
|
+
GridFilterItem,
|
|
10
|
+
GridFilterModel,
|
|
11
|
+
GridFilterOperator,
|
|
12
|
+
GridPivotModel,
|
|
13
|
+
GridPivotValueField,
|
|
14
|
+
GridSortItem,
|
|
15
|
+
} from '../types';
|
|
16
|
+
import { toLocalDateString } from '../utils/values';
|
|
17
|
+
import { coerceBoolean, coerceValue } from './coerce';
|
|
18
|
+
import {
|
|
19
|
+
ALL_PARTS,
|
|
20
|
+
MAX_FILTER_DEPTH,
|
|
21
|
+
NO_VALUE_OPS,
|
|
22
|
+
PART_KEYS,
|
|
23
|
+
PIVOT_FNS,
|
|
24
|
+
aggregationFnsOf,
|
|
25
|
+
hasOwn,
|
|
26
|
+
isFilterable,
|
|
27
|
+
isGroupable,
|
|
28
|
+
isHideable,
|
|
29
|
+
isPlainObject,
|
|
30
|
+
isSortable,
|
|
31
|
+
operatorsOf,
|
|
32
|
+
selectedParts,
|
|
33
|
+
usableColumns,
|
|
34
|
+
} from './columns';
|
|
35
|
+
import type { GridAiColumn, GridAiPart, GridAiState, GridAiValidateOptions, GridAiValidationError, GridAiValidationResult } from './types';
|
|
36
|
+
|
|
37
|
+
/** Longest list read from the model output; the rest is dropped with one error. */
|
|
38
|
+
const MAX_LIST = 200;
|
|
39
|
+
/** Errors past this many are not listed, so a reply full of junk still gives a readable list. */
|
|
40
|
+
const MAX_ERRORS = 100;
|
|
41
|
+
|
|
42
|
+
type Allowed = (col: GridAiColumn) => boolean;
|
|
43
|
+
type FilterNode = GridFilterItem | GridFilterGroup;
|
|
44
|
+
|
|
45
|
+
interface Ctx {
|
|
46
|
+
errors: GridAiValidationError[];
|
|
47
|
+
byField: Map<string, GridAiColumn>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function fail(ctx: Ctx, path: string, message: string): null {
|
|
51
|
+
if (ctx.errors.length < MAX_ERRORS) ctx.errors.push({ path, message });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A short, safe rendering of a value for an error message. */
|
|
56
|
+
function show(value: unknown): string {
|
|
57
|
+
if (typeof value === 'string') return JSON.stringify(value.slice(0, 60));
|
|
58
|
+
return value === null || typeof value !== 'object' ? String(value) : Array.isArray(value) ? 'a list' : 'an object';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function lower(value: unknown): string | null {
|
|
62
|
+
return typeof value === 'string' ? value.trim().toLowerCase() : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Own enumerable keys of the input, without `__proto__`. */
|
|
66
|
+
function keysOf(obj: Record<string, unknown>): string[] {
|
|
67
|
+
return Object.keys(obj).filter((key) => key !== '__proto__');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function column(ctx: Ctx, value: unknown, path: string, allowed: Allowed, what: string): GridAiColumn | null {
|
|
71
|
+
const col = typeof value === 'string' ? ctx.byField.get(value) : undefined;
|
|
72
|
+
if (!col) return fail(ctx, path, `Unknown field ${show(value)}`);
|
|
73
|
+
return allowed(col) ? col : fail(ctx, path, `Field ${show(value)} cannot be ${what}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The first MAX_LIST entries of an array; a single value is read as a one-entry list. */
|
|
77
|
+
function list(ctx: Ctx, value: unknown, path: string): unknown[] {
|
|
78
|
+
if (value == null) return [];
|
|
79
|
+
const items = Array.isArray(value) ? value : [value];
|
|
80
|
+
if (items.length > MAX_LIST) fail(ctx, path, `Only the first ${MAX_LIST} entries are read`);
|
|
81
|
+
return items.slice(0, MAX_LIST);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The choice `value` names, case-insensitively, in the choice's own spelling. */
|
|
85
|
+
function oneOf(ctx: Ctx, value: unknown, path: string, choices: readonly string[]): string | null {
|
|
86
|
+
const v = lower(value);
|
|
87
|
+
return choices.find((c) => c.toLowerCase() === v) ?? fail(ctx, path, `${show(value)} is not one of ${choices.join(', ')}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One filter value, coerced; dates become `YYYY-MM-DD` as the filter panel writes them. */
|
|
91
|
+
function filterValue(ctx: Ctx, raw: unknown, col: GridAiColumn, path: string): { v: unknown } | null {
|
|
92
|
+
const result = coerceValue(raw, col);
|
|
93
|
+
if (!result.ok) return fail(ctx, path, `${show(raw)} is not a valid ${col.type ?? 'string'} value`);
|
|
94
|
+
const v = result.value instanceof Date ? toLocalDateString(result.value) : result.value;
|
|
95
|
+
return v == null || v === '' ? fail(ctx, path, 'Missing value') : { v };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function filterItem(ctx: Ctx, raw: Record<string, unknown>, path: string): GridFilterItem | null {
|
|
99
|
+
const col = column(ctx, raw.field, `${path}.field`, isFilterable, 'filtered');
|
|
100
|
+
if (!col) return null;
|
|
101
|
+
const operator = oneOf(ctx, raw.operator, `${path}.operator`, operatorsOf(col));
|
|
102
|
+
if (operator === null) return null;
|
|
103
|
+
const item: GridFilterItem = { field: col.field, operator: operator as GridFilterOperator };
|
|
104
|
+
if (NO_VALUE_OPS.includes(item.operator)) return item;
|
|
105
|
+
const rawValue = hasOwn(raw, 'value') ? raw.value : undefined;
|
|
106
|
+
if (item.operator === 'isAnyOf') {
|
|
107
|
+
const values: unknown[] = [];
|
|
108
|
+
list(ctx, rawValue, `${path}.value`).forEach((entry, i) => {
|
|
109
|
+
const r = filterValue(ctx, entry, col, `${path}.value[${i}]`);
|
|
110
|
+
if (r && !values.includes(r.v)) values.push(r.v);
|
|
111
|
+
});
|
|
112
|
+
if (values.length === 0) return fail(ctx, `${path}.value`, 'No valid values');
|
|
113
|
+
item.value = values;
|
|
114
|
+
return item;
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(rawValue)) return fail(ctx, `${path}.value`, 'Expected one value, not a list');
|
|
117
|
+
const r = filterValue(ctx, rawValue, col, `${path}.value`);
|
|
118
|
+
if (!r) return null;
|
|
119
|
+
item.value = r.v;
|
|
120
|
+
return item;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function logic(ctx: Ctx, raw: unknown, path: string): 'and' | 'or' | null {
|
|
124
|
+
return raw === undefined ? null : (oneOf(ctx, raw, path, ['and', 'or']) as 'and' | 'or' | null);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function filterItems(ctx: Ctx, raw: unknown, path: string, depth: number): FilterNode[] {
|
|
128
|
+
const out: FilterNode[] = [];
|
|
129
|
+
list(ctx, raw, path).forEach((entry, i) => {
|
|
130
|
+
const at = `${path}[${i}]`;
|
|
131
|
+
if (!isPlainObject(entry)) {
|
|
132
|
+
fail(ctx, at, `Expected a filter, got ${show(entry)}`);
|
|
133
|
+
} else if (hasOwn(entry, 'items') || hasOwn(entry, 'logicOperator')) {
|
|
134
|
+
if (depth >= MAX_FILTER_DEPTH) {
|
|
135
|
+
fail(ctx, at, `Groups nest at most ${MAX_FILTER_DEPTH} levels`);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const items = filterItems(ctx, entry.items, `${at}.items`, depth + 1);
|
|
139
|
+
if (items.length === 0) fail(ctx, at, 'Empty group removed');
|
|
140
|
+
else out.push({ logicOperator: logic(ctx, entry.logicOperator, `${at}.logicOperator`) ?? 'and', items });
|
|
141
|
+
} else {
|
|
142
|
+
const item = filterItem(ctx, entry, at);
|
|
143
|
+
if (item) out.push(item);
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function filterModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridFilterModel {
|
|
150
|
+
const model: GridFilterModel = {};
|
|
151
|
+
if (hasOwn(raw, 'items')) model.items = filterItems(ctx, raw.items, `${path}.items`, 0);
|
|
152
|
+
const op = logic(ctx, raw.logicOperator, `${path}.logicOperator`);
|
|
153
|
+
if (op) model.logicOperator = op;
|
|
154
|
+
if (hasOwn(raw, 'quickFilterValues')) {
|
|
155
|
+
model.quickFilterValues = [];
|
|
156
|
+
list(ctx, raw.quickFilterValues, `${path}.quickFilterValues`).forEach((entry, i) => {
|
|
157
|
+
const word = typeof entry === 'string' || typeof entry === 'number' ? String(entry).trim() : null;
|
|
158
|
+
if (word === null) fail(ctx, `${path}.quickFilterValues[${i}]`, `Expected text, got ${show(entry)}`);
|
|
159
|
+
else if (word !== '') model.quickFilterValues?.push(word);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return model;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sortModel(ctx: Ctx, raw: unknown, path: string): GridSortItem[] {
|
|
166
|
+
const out: GridSortItem[] = [];
|
|
167
|
+
list(ctx, raw, path).forEach((entry, i) => {
|
|
168
|
+
const at = `${path}[${i}]`;
|
|
169
|
+
if (!isPlainObject(entry)) return fail(ctx, at, `Expected { field, sort }, got ${show(entry)}`);
|
|
170
|
+
const col = column(ctx, entry.field, `${at}.field`, isSortable, 'sorted');
|
|
171
|
+
const sort = col && oneOf(ctx, entry.sort, `${at}.sort`, ['asc', 'desc']);
|
|
172
|
+
if (!col || !sort) return;
|
|
173
|
+
if (out.some((s) => s.field === col.field)) fail(ctx, at, `${show(col.field)} is sorted twice`);
|
|
174
|
+
else out.push({ field: col.field, sort: sort as 'asc' | 'desc' });
|
|
175
|
+
});
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function fieldList(ctx: Ctx, raw: unknown, path: string, allowed: Allowed, what: string): string[] {
|
|
180
|
+
const out: string[] = [];
|
|
181
|
+
list(ctx, raw, path).forEach((entry, i) => {
|
|
182
|
+
const col = column(ctx, entry, `${path}[${i}]`, allowed, what);
|
|
183
|
+
if (col && !out.includes(col.field)) out.push(col.field);
|
|
184
|
+
});
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const canAggregate: Allowed = (col) => aggregationFnsOf(col).length > 0;
|
|
189
|
+
const canPivot: Allowed = (col) => aggregationFnsOf(col, PIVOT_FNS).length > 0;
|
|
190
|
+
|
|
191
|
+
function aggregationModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridAggregationModel {
|
|
192
|
+
const model: GridAggregationModel = {};
|
|
193
|
+
for (const key of keysOf(raw)) {
|
|
194
|
+
// 'none' is how the grid spells "no aggregation": nothing to set.
|
|
195
|
+
if (lower(raw[key]) === 'none') continue;
|
|
196
|
+
const col = column(ctx, key, `${path}.${key}`, canAggregate, 'aggregated');
|
|
197
|
+
const fn = col && oneOf(ctx, raw[key], `${path}.${key}`, aggregationFnsOf(col));
|
|
198
|
+
if (col && fn) model[col.field] = fn;
|
|
199
|
+
}
|
|
200
|
+
return model;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function pivotModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridPivotModel {
|
|
204
|
+
const valueFields: GridPivotValueField[] = [];
|
|
205
|
+
list(ctx, raw.valueFields, `${path}.valueFields`).forEach((entry, i) => {
|
|
206
|
+
const at = `${path}.valueFields[${i}]`;
|
|
207
|
+
if (!isPlainObject(entry)) return fail(ctx, at, `Expected { field, aggFn }, got ${show(entry)}`);
|
|
208
|
+
const col = column(ctx, entry.field, `${at}.field`, canPivot, 'a pivot value');
|
|
209
|
+
const aggFn = col && (oneOf(ctx, entry.aggFn, `${at}.aggFn`, aggregationFnsOf(col, PIVOT_FNS)) as GridPivotValueField['aggFn'] | null);
|
|
210
|
+
if (col && aggFn && !valueFields.some((v) => v.field === col.field && v.aggFn === aggFn)) valueFields.push({ field: col.field, aggFn });
|
|
211
|
+
});
|
|
212
|
+
return {
|
|
213
|
+
rowFields: fieldList(ctx, raw.rowFields, `${path}.rowFields`, isGroupable, 'a pivot label'),
|
|
214
|
+
columnFields: fieldList(ctx, raw.columnFields, `${path}.columnFields`, isGroupable, 'a pivot label'),
|
|
215
|
+
valueFields,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function visibilityModel(ctx: Ctx, raw: Record<string, unknown>, path: string): GridColumnVisibilityModel {
|
|
220
|
+
const model: GridColumnVisibilityModel = {};
|
|
221
|
+
for (const key of keysOf(raw)) {
|
|
222
|
+
const col = column(ctx, key, `${path}.${key}`, isHideable, 'hidden');
|
|
223
|
+
const visible = coerceBoolean(raw[key]);
|
|
224
|
+
if (!col) continue;
|
|
225
|
+
if (visible.ok && visible.value !== null) model[col.field] = visible.value;
|
|
226
|
+
else fail(ctx, `${path}.${key}`, `Expected true or false, got ${show(raw[key])}`);
|
|
227
|
+
}
|
|
228
|
+
return model;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readPart(ctx: Ctx, part: GridAiPart, raw: unknown, path: string, state: GridAiState): void {
|
|
232
|
+
if (part === 'sort') state.sortModel = sortModel(ctx, raw, path);
|
|
233
|
+
else if (part === 'grouping') state.rowGroupingModel = fieldList(ctx, raw, path, isGroupable, 'grouped');
|
|
234
|
+
else if (!isPlainObject(raw)) fail(ctx, path, `Expected an object, got ${show(raw)}`);
|
|
235
|
+
else if (part === 'filter') state.filterModel = filterModel(ctx, raw, path);
|
|
236
|
+
else if (part === 'aggregation') state.aggregationModel = aggregationModel(ctx, raw, path);
|
|
237
|
+
else if (part === 'pivot') state.pivotModel = pivotModel(ctx, raw, path);
|
|
238
|
+
else state.columnVisibilityModel = visibilityModel(ctx, raw, path);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Checks a model's reply against the columns and returns the state that is safe to apply.
|
|
243
|
+
* `json` is an object or a JSON string (bad JSON gives one error and an empty state). Unknown
|
|
244
|
+
* parts, fields, operators and enum values are dropped, each with an error; values are coerced to
|
|
245
|
+
* the column type (`"1,200"` → 1200, a date → `"YYYY-MM-DD"`); filter groups left empty are
|
|
246
|
+
* removed. Never throws, and never returns a field the columns do not allow.
|
|
247
|
+
* @since v3.4
|
|
248
|
+
*/
|
|
249
|
+
export function validateGridAiState<C extends GridAiColumn>(
|
|
250
|
+
json: unknown,
|
|
251
|
+
columns: readonly C[],
|
|
252
|
+
options: GridAiValidateOptions = {},
|
|
253
|
+
): GridAiValidationResult {
|
|
254
|
+
const ctx: Ctx = { errors: [], byField: new Map() };
|
|
255
|
+
const state: GridAiState = {};
|
|
256
|
+
try {
|
|
257
|
+
for (const col of usableColumns(columns)) ctx.byField.set(col.field, col);
|
|
258
|
+
let input = json;
|
|
259
|
+
if (typeof json === 'string') {
|
|
260
|
+
try {
|
|
261
|
+
input = JSON.parse(json) as unknown;
|
|
262
|
+
} catch {
|
|
263
|
+
fail(ctx, '', 'Not valid JSON');
|
|
264
|
+
return { state, errors: ctx.errors };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (!isPlainObject(input)) {
|
|
268
|
+
fail(ctx, '', `Expected an object, got ${show(input)}`);
|
|
269
|
+
return { state, errors: ctx.errors };
|
|
270
|
+
}
|
|
271
|
+
const allowed = selectedParts(options);
|
|
272
|
+
for (const key of keysOf(input)) {
|
|
273
|
+
const part = ALL_PARTS.find((p) => PART_KEYS[p] === key);
|
|
274
|
+
if (key === 'schemaVersion') {
|
|
275
|
+
if (input[key] !== 1) fail(ctx, key, `Unsupported schemaVersion ${show(input[key])}`);
|
|
276
|
+
} else if (!part) fail(ctx, key, 'Unknown part');
|
|
277
|
+
else if (!allowed.includes(part)) fail(ctx, key, 'Part not allowed');
|
|
278
|
+
else readPart(ctx, part, input[key], key, state);
|
|
279
|
+
}
|
|
280
|
+
} catch {
|
|
281
|
+
// Defensive only: a getter on a hand-built input object could throw.
|
|
282
|
+
fail(ctx, '', 'Could not read the input');
|
|
283
|
+
return { state: {}, errors: ctx.errors };
|
|
284
|
+
}
|
|
285
|
+
return { state, errors: ctx.errors };
|
|
286
|
+
}
|
|
@@ -66,6 +66,11 @@
|
|
|
66
66
|
outline-offset: -2px;
|
|
67
67
|
background-color: var(--ogx-grid-background);
|
|
68
68
|
}
|
|
69
|
+
/* An <Input variant="cell"> editor (the built-in text/number/date ones, or a renderEditCell) takes
|
|
70
|
+
the full cell height, so its error edge and click area cover the cell. */
|
|
71
|
+
.ogx__cell--editing > .ogx__cell-content:has(.ogx-input-wrapper--cell) {
|
|
72
|
+
align-self: stretch;
|
|
73
|
+
}
|
|
69
74
|
.ogx__cell--align-left {
|
|
70
75
|
justify-content: flex-start;
|
|
71
76
|
text-align: left;
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
outline: none;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/* The built-in editors render through <Input variant="cell">: keep their own font, colour and
|
|
32
|
+
padding over the variant's inherited ones (this file loads after Input.css). */
|
|
33
|
+
.ogx-input-wrapper--cell .ogx__edit-input {
|
|
34
|
+
padding: 0 10px;
|
|
35
|
+
font-family: var(--ogx-font-family, inherit);
|
|
36
|
+
font-size: var(--ogx-grid-cell-font-size, 14px);
|
|
37
|
+
color: var(--ogx-grid-row-text, #1e293b);
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
/* ── Number input — hide spinners ────────────────────────────────────── */
|
|
32
41
|
.ogx__edit-input--number::-webkit-inner-spin-button,
|
|
33
42
|
.ogx__edit-input--number::-webkit-outer-spin-button {
|
|
@@ -54,9 +63,8 @@
|
|
|
54
63
|
outline: 2px solid var(--ogx-color-primary, #2563eb);
|
|
55
64
|
outline-offset: -2px;
|
|
56
65
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
/* The editor is the shared Checkbox: `.ogx__edit-checkbox` sits on its visually hidden <input>,
|
|
67
|
+
the drawn box is `.ogx-checkbox__box`. */
|
|
68
|
+
.ogx__edit-boolean .ogx-checkbox-wrapper {
|
|
60
69
|
cursor: pointer;
|
|
61
|
-
accent-color: var(--ogx-color-primary, #2563eb);
|
|
62
70
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
2
|
import type { KeyboardEvent } from 'react';
|
|
3
3
|
import type { GridColDef, GridRenderEditCellParams } from '../../types';
|
|
4
|
+
import { Input } from '../ui/Input';
|
|
5
|
+
import { Checkbox } from '../ui/Checkbox';
|
|
4
6
|
|
|
5
7
|
export type GridEditInputCellProps = GridRenderEditCellParams;
|
|
6
8
|
|
|
@@ -34,10 +36,11 @@ function TextEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEd
|
|
|
34
36
|
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
|
|
35
37
|
|
|
36
38
|
return (
|
|
37
|
-
<
|
|
39
|
+
<Input
|
|
38
40
|
ref={ref}
|
|
41
|
+
variant="cell"
|
|
39
42
|
type="text"
|
|
40
|
-
|
|
43
|
+
inputClassName="ogx__edit-input"
|
|
41
44
|
aria-label={editorLabel(colDef)}
|
|
42
45
|
value={local}
|
|
43
46
|
onChange={e => { setLocal(e.target.value); onValueChange(e.target.value); }}
|
|
@@ -54,10 +57,11 @@ function NumberEditor({ value, colDef, onValueChange, onCommit, onCancel }: Grid
|
|
|
54
57
|
useEffect(() => { ref.current?.focus(); ref.current?.select(); }, []);
|
|
55
58
|
|
|
56
59
|
return (
|
|
57
|
-
<
|
|
60
|
+
<Input
|
|
58
61
|
ref={ref}
|
|
62
|
+
variant="cell"
|
|
59
63
|
type="number"
|
|
60
|
-
|
|
64
|
+
inputClassName="ogx__edit-input ogx__edit-input--number"
|
|
61
65
|
aria-label={editorLabel(colDef)}
|
|
62
66
|
value={local}
|
|
63
67
|
onChange={e => {
|
|
@@ -83,16 +87,17 @@ function BooleanEditor({ value, colDef, onValueChange, onCommit, onCancel }: Gri
|
|
|
83
87
|
};
|
|
84
88
|
|
|
85
89
|
return (
|
|
86
|
-
|
|
87
|
-
|
|
90
|
+
// Safari does not focus checkboxes on click: mousedown moves focus to the cell, the blur
|
|
91
|
+
// commits the unchanged value and the editor unmounts before the click toggles it. The
|
|
92
|
+
// pointer lands on the drawn box (or around it), not the hidden input, so the whole editor
|
|
93
|
+
// keeps focus where it is.
|
|
94
|
+
<div className="ogx__edit-boolean" onMouseDown={(e) => e.preventDefault()}>
|
|
95
|
+
<Checkbox
|
|
88
96
|
ref={ref}
|
|
89
|
-
|
|
90
|
-
className="ogx__edit-checkbox"
|
|
97
|
+
inputClassName="ogx__edit-checkbox"
|
|
91
98
|
aria-label={editorLabel(colDef)}
|
|
92
99
|
checked={!!value}
|
|
93
100
|
onChange={handleChange}
|
|
94
|
-
// Safari does not focus checkboxes on click: mousedown moves focus to the cell, the
|
|
95
|
-
// blur commits the unchanged value and the editor unmounts before the click toggles it.
|
|
96
101
|
onMouseDown={(e) => e.preventDefault()}
|
|
97
102
|
onBlur={onCommit}
|
|
98
103
|
onKeyDown={makeKeyDownHandler(onCommit, onCancel, false)}
|
|
@@ -126,6 +131,7 @@ function SelectEditor({ value, colDef, onValueChange, onCommit, onCancel }: Grid
|
|
|
126
131
|
const showEmptyChoice = selectedIndex === -1;
|
|
127
132
|
|
|
128
133
|
return (
|
|
134
|
+
// Raw <select>: there is no shared Select component yet.
|
|
129
135
|
<select
|
|
130
136
|
ref={ref}
|
|
131
137
|
className="ogx__edit-select"
|
|
@@ -196,10 +202,11 @@ function DateEditor({ value, colDef, onValueChange, onCommit, onCancel }: GridEd
|
|
|
196
202
|
useEffect(() => { ref.current?.focus(); }, []);
|
|
197
203
|
|
|
198
204
|
return (
|
|
199
|
-
<
|
|
205
|
+
<Input
|
|
200
206
|
ref={ref}
|
|
207
|
+
variant="cell"
|
|
201
208
|
type="date"
|
|
202
|
-
|
|
209
|
+
inputClassName="ogx__edit-input"
|
|
203
210
|
aria-label={editorLabel(colDef)}
|
|
204
211
|
value={local}
|
|
205
212
|
onChange={e => { setLocal(e.target.value); onValueChange(fromDateInputValue(e.target.value, initialValue)); }}
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
.ogx-column-visibility-panel__search-input {
|
|
33
|
+
padding: revert;
|
|
34
|
+
line-height: revert;
|
|
33
35
|
border: none;
|
|
34
36
|
background: none;
|
|
35
37
|
outline: none;
|
|
@@ -39,6 +41,18 @@
|
|
|
39
41
|
flex: 1;
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/* The search box renders through the shared Input: the container draws the box. */
|
|
45
|
+
.ogx-column-visibility-panel__search-container .ogx-input-wrapper {
|
|
46
|
+
flex: 1;
|
|
47
|
+
border: none;
|
|
48
|
+
border-radius: 0;
|
|
49
|
+
background: transparent;
|
|
50
|
+
transition: none;
|
|
51
|
+
}
|
|
52
|
+
.ogx-column-visibility-panel__search-container .ogx-input-wrapper:focus-within {
|
|
53
|
+
box-shadow: none;
|
|
54
|
+
}
|
|
55
|
+
|
|
42
56
|
.ogx-column-visibility-panel__search-input::placeholder {
|
|
43
57
|
color: var(--ogx-overlay-text, var(--ogx-color-gray-400));
|
|
44
58
|
opacity: 0.6;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState, useMemo, useRef, useId } from 'react';
|
|
2
2
|
import { Checkbox } from '../ui/Checkbox';
|
|
3
|
+
import { Input } from '../ui/Input';
|
|
3
4
|
import type { GridColDef, GridRowModel, GridValidRowModel } from '../../types';
|
|
4
5
|
|
|
5
6
|
|
|
@@ -115,9 +116,9 @@ export function ColumnVisibilityPanel<R extends GridValidRowModel = GridRowModel
|
|
|
115
116
|
<div className="ogx-column-visibility-panel__search-section">
|
|
116
117
|
<div className="ogx-column-visibility-panel__search-container">
|
|
117
118
|
<SearchIcon />
|
|
118
|
-
<
|
|
119
|
+
<Input
|
|
119
120
|
autoComplete="off"
|
|
120
|
-
|
|
121
|
+
inputClassName="ogx-column-visibility-panel__search-input"
|
|
121
122
|
placeholder="Search"
|
|
122
123
|
value={searchQuery}
|
|
123
124
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
@@ -27,6 +27,9 @@ import { useDataGrid } from '../../hooks/core/useDataGrid';
|
|
|
27
27
|
import { useRowReorder } from '../../hooks/useRowReorder';
|
|
28
28
|
import { useGridHierarchy } from '../../hooks/core/useGridHierarchy';
|
|
29
29
|
import { useGridEditing } from '../../hooks/features/useGridEditing';
|
|
30
|
+
import { useGridBatchEdit } from '../../hooks/features/useGridBatchEdit';
|
|
31
|
+
import { useGridClipboardPaste } from '../../hooks/core/useGridClipboardPaste';
|
|
32
|
+
import { useGridUndoRedo, useGridEditCommitChannel, UNDO_HISTORY_LIMIT } from '../../hooks/core/useGridUndoRedo';
|
|
30
33
|
import { useGridSpanning } from '../../hooks/features/useGridSpanning';
|
|
31
34
|
import { useGridSpanRowWindow, useGridSpanColumnWindow } from '../../hooks/features/useGridSpanRenderWindow';
|
|
32
35
|
import { useColumnGroupReorderGuard } from '../../hooks/features/useColumnGroupReorderGuard';
|
|
@@ -90,6 +93,12 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
90
93
|
cellSelectionModel: propCellSelectionModel,
|
|
91
94
|
onCellSelectionModelChange,
|
|
92
95
|
showCellSelectionStats = false,
|
|
96
|
+
disableClipboardPaste = false,
|
|
97
|
+
disableRangeClear = false,
|
|
98
|
+
onClipboardPaste,
|
|
99
|
+
onBeforeClipboardPaste,
|
|
100
|
+
undoRedo = false,
|
|
101
|
+
onHistoryChange,
|
|
93
102
|
rowSelectionModel: propRowSelectionModel,
|
|
94
103
|
onRowSelectionModelChange: propOnRowSelectionModelChange,
|
|
95
104
|
onRowClick,
|
|
@@ -271,7 +280,7 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
271
280
|
syncRows: !dataSource,
|
|
272
281
|
});
|
|
273
282
|
const {
|
|
274
|
-
state, apiRef, setRows,
|
|
283
|
+
state, apiRef, setRows, replaceRows, setColumns, setDimensions, setDataSourceLoading, setDataSourceError,
|
|
275
284
|
setRowCount
|
|
276
285
|
} = gridData;
|
|
277
286
|
|
|
@@ -295,6 +304,18 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
295
304
|
getAggregationPosition, columnLookup, filterMode, sortingMode,
|
|
296
305
|
});
|
|
297
306
|
|
|
307
|
+
// Commits (single edits and batch edits) reach the undo/redo history created further down.
|
|
308
|
+
const editCommitChannel = useGridEditCommitChannel();
|
|
309
|
+
// Batch edits (paste, range clear, undo, redo): one processRowUpdate per row, one store update.
|
|
310
|
+
const batchEdit = useGridBatchEdit<R>({
|
|
311
|
+
rowsLookup: state.rows.idRowsLookup as unknown as ReadonlyMap<GridRowId, R>,
|
|
312
|
+
columns: activeColumns,
|
|
313
|
+
processRowUpdate,
|
|
314
|
+
onProcessRowUpdateError,
|
|
315
|
+
replaceRows: replaceRows as unknown as (rows: ReadonlyMap<GridRowId, R>) => void,
|
|
316
|
+
onCommitted: editCommitChannel.emit,
|
|
317
|
+
});
|
|
318
|
+
|
|
298
319
|
const editingHandlers = useGridEditing({
|
|
299
320
|
rows: effectiveRows,
|
|
300
321
|
getRowId: getRowIdOf,
|
|
@@ -306,8 +327,9 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
306
327
|
// after an awaited processRowUpdate, by which time the rows prop (or a dataSource
|
|
307
328
|
// fetch) may have replaced the rows this render saw. The returned row is stored as is
|
|
308
329
|
// (the grid never writes an id onto it), even if it does not carry its key itself.
|
|
309
|
-
|
|
330
|
+
batchEdit.storeRow(rowId, updatedRow);
|
|
310
331
|
},
|
|
332
|
+
onCommitted: (changes) => editCommitChannel.emit(changes, 'edit'),
|
|
311
333
|
});
|
|
312
334
|
|
|
313
335
|
// The selection without ids of removed rows. Only client-owned rows are pruned: a server page
|
|
@@ -590,6 +612,44 @@ export function DataGrid<R extends GridRowModel = GridRowModel>(props: DataGridP
|
|
|
590
612
|
containerRef,
|
|
591
613
|
});
|
|
592
614
|
|
|
615
|
+
// Ctrl/Cmd+V and Delete on a range (cellSelection); apiRef.pasteText.
|
|
616
|
+
useGridClipboardPaste<R>({
|
|
617
|
+
apiRef, containerRef, viewportRef,
|
|
618
|
+
selection: cellSelection,
|
|
619
|
+
disableClipboardPaste, disableRangeClear,
|
|
620
|
+
focusedCell,
|
|
621
|
+
allRenderableRows,
|
|
622
|
+
getRowId: getRowIdOf,
|
|
623
|
+
navigationColumns: navigationColumns as unknown as GridColDef<R>[],
|
|
624
|
+
isCellEditable,
|
|
625
|
+
rowMetaMap,
|
|
626
|
+
getSpanOrigin: spanning.getSpanOrigin,
|
|
627
|
+
isEditing: editingHandlers.editingCell !== null,
|
|
628
|
+
batchEdit,
|
|
629
|
+
onClipboardPaste,
|
|
630
|
+
onBeforeClipboardPaste,
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
// Undo / redo (undoRedo): history of committed edits, keys and apiRef methods.
|
|
634
|
+
useGridUndoRedo<R>({
|
|
635
|
+
apiRef,
|
|
636
|
+
enabled: Boolean(undoRedo),
|
|
637
|
+
limit: (typeof undoRedo === 'object' && undoRedo.limit) || UNDO_HISTORY_LIMIT,
|
|
638
|
+
onHistoryChange,
|
|
639
|
+
channel: editCommitChannel,
|
|
640
|
+
batchEdit,
|
|
641
|
+
isEditing: editingHandlers.editingCell !== null,
|
|
642
|
+
containerRef, viewportRef,
|
|
643
|
+
selection: cellSelection,
|
|
644
|
+
setFocusedCell,
|
|
645
|
+
allRenderableRows,
|
|
646
|
+
getRowId: getRowIdOf,
|
|
647
|
+
navigationColumns: navigationColumns as unknown as GridColDef<R>[],
|
|
648
|
+
layout,
|
|
649
|
+
rowHeight: effectiveRowHeight,
|
|
650
|
+
pinnedTopRowCount: pinnedTopRows.length,
|
|
651
|
+
});
|
|
652
|
+
|
|
593
653
|
const { handleHideColumn, handlePinColumn } = useGridColumnMenuHandlers({
|
|
594
654
|
columnVisibilityModel,
|
|
595
655
|
onColumnVisibilityModelChange: handleColumnVisibilityModelChange,
|
|
@@ -115,11 +115,25 @@
|
|
|
115
115
|
border-radius: var(--ogx-border-radius-sm);
|
|
116
116
|
font-family: var(--ogx-font-family);
|
|
117
117
|
font-size: var(--ogx-font-size-xs);
|
|
118
|
+
line-height: revert;
|
|
118
119
|
background: var(--ogx-grid-background);
|
|
119
120
|
color: var(--ogx-grid-row-text);
|
|
120
121
|
outline: none;
|
|
121
122
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
122
123
|
}
|
|
124
|
+
/* The text/date value box renders through the shared Input: the <input> keeps its own border,
|
|
125
|
+
so the wrapper draws nothing and only takes the input's place in the row. */
|
|
126
|
+
.ogx-filter__controls .ogx-input-wrapper {
|
|
127
|
+
flex: 1;
|
|
128
|
+
min-width: 60px;
|
|
129
|
+
border: none;
|
|
130
|
+
border-radius: 0;
|
|
131
|
+
background: transparent;
|
|
132
|
+
transition: none;
|
|
133
|
+
}
|
|
134
|
+
.ogx-filter__controls .ogx-input-wrapper:focus-within {
|
|
135
|
+
box-shadow: none;
|
|
136
|
+
}
|
|
123
137
|
.ogx-filter__value-input::placeholder {
|
|
124
138
|
color: var(--ogx-overlay-text, var(--ogx-color-gray-400));
|
|
125
139
|
opacity: 0.6;
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
} from '../../types';
|
|
9
9
|
import { getOperatorsForType, NO_VALUE_OPERATORS } from '../../utils/filtering';
|
|
10
10
|
import { toLocalDateString } from '../../utils/values';
|
|
11
|
+
import { Input } from '../ui/Input';
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
export interface FilterPanelProps {
|
|
@@ -251,10 +252,10 @@ const FilterRow: React.FC<{
|
|
|
251
252
|
);
|
|
252
253
|
} else if (showValue) {
|
|
253
254
|
valueControl = (
|
|
254
|
-
<
|
|
255
|
+
<Input
|
|
255
256
|
id={`${filterId}-val`}
|
|
256
257
|
name={`filter-val-${col.field}`}
|
|
257
|
-
|
|
258
|
+
inputClassName={valueClassName}
|
|
258
259
|
type={col.type === 'date' ? 'date' : 'text'}
|
|
259
260
|
value={draft ?? toInputText(item?.value, col)}
|
|
260
261
|
onChange={(e) => setDraft(e.target.value)}
|
|
@@ -65,11 +65,9 @@
|
|
|
65
65
|
border-right: var(--ogx-border-width-thin) solid var(--ogx-grid-border-color);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
height: 16px;
|
|
68
|
+
/* The row checkbox is the shared Checkbox (its <input> is visually hidden). */
|
|
69
|
+
.ogx-list-view__checkbox .ogx-checkbox-wrapper {
|
|
71
70
|
cursor: pointer;
|
|
72
|
-
accent-color: var(--ogx-color-primary);
|
|
73
71
|
}
|
|
74
72
|
|
|
75
73
|
/* ─── Cell content area ───────────────────────────────────────────────────── */
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { formatValueByType, getCellValue, getFormattedValue } from '../../utils/values';
|
|
3
3
|
import { ExpandIcon } from '../ui/ExpandIcon';
|
|
4
|
+
import { Checkbox } from '../ui/Checkbox';
|
|
4
5
|
import { CellErrorBoundary } from '../Cell/CellErrorBoundary';
|
|
5
6
|
import { isSyntheticRowId } from '../../utils/syntheticRows';
|
|
6
7
|
import type { GridColDef, GridRowModel, GridRowId, GridRowMeta, GridListViewColDef, GridRenderCellParams } from '../../types';
|
|
@@ -92,8 +93,7 @@ export function ListViewRow<R extends GridRowModel = GridRowModel>({
|
|
|
92
93
|
>
|
|
93
94
|
{checkboxSelection && (
|
|
94
95
|
<div className="ogx-list-view__checkbox" onClick={(e) => e.stopPropagation()}>
|
|
95
|
-
{!isSyntheticRow && <
|
|
96
|
-
type="checkbox"
|
|
96
|
+
{!isSyntheticRow && <Checkbox
|
|
97
97
|
checked={isSelected}
|
|
98
98
|
onChange={handleCheckboxChange}
|
|
99
99
|
aria-label={`Select row ${id}`}
|
|
@@ -74,6 +74,7 @@
|
|
|
74
74
|
font-family: var(--ogx-font-family, system-ui, sans-serif);
|
|
75
75
|
font-size: var(--ogx-font-size-sm, 13px);
|
|
76
76
|
color: var(--ogx-toolbar-input-text, var(--ogx-grid-row-text, #1e293b));
|
|
77
|
+
line-height: revert;
|
|
77
78
|
outline: none;
|
|
78
79
|
opacity: 0;
|
|
79
80
|
pointer-events: none;
|
|
@@ -88,6 +89,21 @@
|
|
|
88
89
|
font-size: var(--ogx-font-size-sm, 13px);
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
/* The input renders through the shared Input: its wrapper is the flex item and the container
|
|
93
|
+
draws the box, so the wrapper has none of its own. */
|
|
94
|
+
.ogx-global-search .ogx-input-wrapper {
|
|
95
|
+
flex: 1 1 auto;
|
|
96
|
+
min-width: 0;
|
|
97
|
+
height: 100%;
|
|
98
|
+
border: none;
|
|
99
|
+
border-radius: 0;
|
|
100
|
+
background: transparent;
|
|
101
|
+
transition: none;
|
|
102
|
+
}
|
|
103
|
+
.ogx-global-search .ogx-input-wrapper:focus-within {
|
|
104
|
+
box-shadow: none;
|
|
105
|
+
}
|
|
106
|
+
|
|
91
107
|
/* ─── Clear button ────────────────────────────────────────────────────────── */
|
|
92
108
|
.ogx-global-search__clear {
|
|
93
109
|
position: absolute;
|