@ornery/ui-grid-vanilla 0.1.10 → 1.0.1
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/AGENTS.md +70 -0
- package/README.md +196 -0
- package/demo/main.ts +0 -1
- package/dist/index.d.mts +582 -55
- package/dist/index.d.ts +582 -55
- package/dist/index.js +4959 -1429
- package/dist/index.mjs +5137 -1560
- package/package.json +11 -8
- package/scss-plugin.ts +65 -0
- package/src/adopt-styles.ts +39 -0
- package/src/attribute-bridge.ts +291 -0
- package/src/auto-height.test.ts +142 -0
- package/src/auto-resize.test.ts +207 -0
- package/src/cell-nav.test.ts +262 -0
- package/src/components/grid-body-cell/grid-body-cell.ts +23 -0
- package/src/components/grid-body-cell/index.ts +1 -0
- package/src/components/grid-cell-editor/grid-cell-editor.ts +104 -0
- package/src/components/grid-cell-editor/index.ts +1 -0
- package/src/components/grid-filter-cell/grid-filter-cell.scss +14 -0
- package/src/components/grid-filter-cell/grid-filter-cell.template.ts +20 -0
- package/src/components/grid-filter-cell/grid-filter-cell.ts +121 -0
- package/src/components/grid-filter-cell/index.ts +1 -0
- package/src/components/grid-group-row/grid-group-row.scss +24 -0
- package/src/components/grid-group-row/grid-group-row.template.ts +23 -0
- package/src/components/grid-group-row/grid-group-row.ts +94 -0
- package/src/components/grid-group-row/index.ts +1 -0
- package/src/components/grid-header-cell/grid-header-cell.ts +17 -0
- package/src/components/grid-header-cell/index.ts +1 -0
- package/src/components/grid-pagination/grid-pagination.scss +39 -0
- package/src/components/grid-pagination/grid-pagination.template.ts +40 -0
- package/src/components/grid-pagination/grid-pagination.ts +204 -0
- package/src/components/grid-pagination/index.ts +1 -0
- package/src/components/grid-template/grid-template.ts +166 -0
- package/src/components/grid-template/index.ts +1 -0
- package/src/events.ts +824 -0
- package/src/exporter.test.ts +186 -0
- package/src/focus.ts +379 -0
- package/src/framework-slots.ts +440 -0
- package/src/grid-controller.ts +1532 -21
- package/src/icons.ts +88 -0
- package/src/importer.test.ts +147 -0
- package/src/index.integration.test.ts +2 -2
- package/src/index.ts +9 -0
- package/src/infinite-scroll.test.ts +220 -0
- package/src/keyboard-navigation.test.ts +579 -0
- package/src/patch.ts +722 -0
- package/src/render.ts +465 -0
- package/src/row-edit.test.ts +191 -0
- package/src/row-selection.test.ts +550 -0
- package/src/save-state.test.ts +235 -0
- package/src/scss.d.ts +4 -0
- package/src/templates/index.ts +62 -0
- package/src/test-setup.ts +23 -0
- package/src/ui-grid-empty.template.ts +23 -0
- package/src/ui-grid-shell.scss +25 -0
- package/src/ui-grid-shell.template.ts +45 -0
- package/src/ui-grid-standalone.element.ts +751 -1112
- package/src/utils/cell-class.ts +70 -0
- package/src/utils/display-items.ts +15 -0
- package/src/utils/dom.ts +64 -0
- package/src/validate.test.ts +170 -0
- package/tsup.config.ts +10 -0
- package/vite.config.ts +10 -1
package/src/grid-controller.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
beginGridCellEditCommand,
|
|
4
4
|
buildGridRows,
|
|
5
5
|
buildGridCellContext,
|
|
6
|
+
buildInitialPinnedState,
|
|
6
7
|
cancelGridCellEditCommand,
|
|
7
8
|
canGridExpandRows,
|
|
8
9
|
canGridMoveColumns,
|
|
@@ -48,6 +49,64 @@ import {
|
|
|
48
49
|
pinGridColumnCommand,
|
|
49
50
|
resolveGridLabels,
|
|
50
51
|
resolveGridRowId as coreResolveGridRowId,
|
|
52
|
+
resolveGridSelectionOptions,
|
|
53
|
+
createGridSelectionState,
|
|
54
|
+
toggleGridRowSelection as coreToggleGridRowSelection,
|
|
55
|
+
shiftGridRowSelection as coreShiftGridRowSelection,
|
|
56
|
+
selectAllGridRows as coreSelectAllGridRows,
|
|
57
|
+
selectAllVisibleGridRows as coreSelectAllVisibleGridRows,
|
|
58
|
+
clearAllGridSelection as coreClearAllGridSelection,
|
|
59
|
+
findGridRowByKey as coreFindGridRowByKey,
|
|
60
|
+
reconcileGridSelection as coreReconcileGridSelection,
|
|
61
|
+
mapSelectedRowsToEntities as coreMapSelectedRowsToEntities,
|
|
62
|
+
buildGridCsv,
|
|
63
|
+
buildGridExcelSheetData,
|
|
64
|
+
buildGridPdfDocDefinition,
|
|
65
|
+
buildGridExporterMenuItems,
|
|
66
|
+
buildGridImporterMenuItems,
|
|
67
|
+
buildGridRowEditMenuItems,
|
|
68
|
+
type GridMenuItem,
|
|
69
|
+
resolveGridExporterExcelOptions,
|
|
70
|
+
resolveGridExporterOptions,
|
|
71
|
+
resolveGridExporterPdfOptions,
|
|
72
|
+
resolveExporterFilename,
|
|
73
|
+
downloadGridCsvFile,
|
|
74
|
+
sanitizeDownloadFilename,
|
|
75
|
+
createGridRowEditState,
|
|
76
|
+
markGridRowDirty,
|
|
77
|
+
markGridRowClean,
|
|
78
|
+
markGridRowSaving,
|
|
79
|
+
markGridRowError,
|
|
80
|
+
isGridRowEditTimerEnabled,
|
|
81
|
+
resolveGridRowEditWaitInterval,
|
|
82
|
+
resolveGridImporterOptions,
|
|
83
|
+
parseGridImporterJson,
|
|
84
|
+
parseGridImporterCsv,
|
|
85
|
+
buildGridImporterObjectsFromCsv,
|
|
86
|
+
buildGridImporterObjectsFromJson,
|
|
87
|
+
createGridValidatorRegistry,
|
|
88
|
+
runGridCellValidators,
|
|
89
|
+
validateAllGridRows,
|
|
90
|
+
isGridCellInvalid,
|
|
91
|
+
getGridCellErrorMessages,
|
|
92
|
+
GridValidatorRegistry,
|
|
93
|
+
type GridValidatorFactory,
|
|
94
|
+
type GridValidatorMessageFn,
|
|
95
|
+
type GridRowEditState,
|
|
96
|
+
type GridImporterOptions,
|
|
97
|
+
type GridExporterExcelSheetData,
|
|
98
|
+
type GridExporterMenuItem,
|
|
99
|
+
type GridExporterOptions,
|
|
100
|
+
type GridExporterColumnType,
|
|
101
|
+
type GridExporterPdfDocDefinition,
|
|
102
|
+
type GridExporterRowType,
|
|
103
|
+
type GridSelectionState,
|
|
104
|
+
type GridInfiniteScrollState,
|
|
105
|
+
maybeRequestInfiniteScrollData,
|
|
106
|
+
completeInfiniteScrollDataLoad,
|
|
107
|
+
resetInfiniteScrollState,
|
|
108
|
+
saveInfiniteScrollPercentage,
|
|
109
|
+
setInfiniteScrollDirectionsState,
|
|
51
110
|
seekGridPaginationCommand,
|
|
52
111
|
setGridPaginationPageSizeCommand,
|
|
53
112
|
setPathValue,
|
|
@@ -62,10 +121,12 @@ import {
|
|
|
62
121
|
type DisplayItem,
|
|
63
122
|
type GridCellPosition,
|
|
64
123
|
type GridColumnDef,
|
|
124
|
+
type GridRowColumn,
|
|
65
125
|
type GridLabels,
|
|
66
126
|
type GridOptions,
|
|
67
127
|
type GridRecord,
|
|
68
128
|
type GridRow,
|
|
129
|
+
type GridSavedState,
|
|
69
130
|
type PipelineResult,
|
|
70
131
|
type PinDirection,
|
|
71
132
|
type PinnedColumnState,
|
|
@@ -75,14 +136,31 @@ import {
|
|
|
75
136
|
type UiGridApi,
|
|
76
137
|
} from '@ornery/ui-grid-core';
|
|
77
138
|
|
|
139
|
+
/**
|
|
140
|
+
* Complete grid save state — parity with the old `ui.grid.saveState`
|
|
141
|
+
* module. Every field is optional so partial saves / restores work too.
|
|
142
|
+
*
|
|
143
|
+
* Opt-in/out behaviour mirrors the old module: by default save() serialises
|
|
144
|
+
* everything; consumers can disable per-field capture via
|
|
145
|
+
* `saveSort`/`saveFilter`/... options on `GridOptions`. Restore tolerates
|
|
146
|
+
* missing fields.
|
|
147
|
+
*/
|
|
78
148
|
export interface GridSaveState {
|
|
79
|
-
sortState
|
|
80
|
-
activeFilters
|
|
81
|
-
groupByColumns
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
149
|
+
sortState?: SortState;
|
|
150
|
+
activeFilters?: Record<string, string>;
|
|
151
|
+
groupByColumns?: string[];
|
|
152
|
+
collapsedGroups?: Record<string, boolean>;
|
|
153
|
+
pinnedColumns?: PinnedColumnState;
|
|
154
|
+
columnOrder?: string[];
|
|
155
|
+
columnWidthOverrides?: Record<string, string>;
|
|
156
|
+
currentPage?: number;
|
|
157
|
+
pageSize?: number;
|
|
158
|
+
selectedRowIds?: string[];
|
|
159
|
+
focusedCell?: GridCellPosition | null;
|
|
160
|
+
expandedRows?: Record<string, boolean>;
|
|
161
|
+
expandedTreeRows?: Record<string, boolean>;
|
|
162
|
+
scrollTop?: number;
|
|
163
|
+
scrollLeft?: number;
|
|
86
164
|
}
|
|
87
165
|
|
|
88
166
|
export interface GridControllerSnapshot {
|
|
@@ -103,6 +181,13 @@ export interface GridControllerSnapshot {
|
|
|
103
181
|
pageSize: number;
|
|
104
182
|
editingCell: GridCellPosition | null;
|
|
105
183
|
editingValue: string;
|
|
184
|
+
/** Row ids currently selected. A ReadonlySet for surgical DOM updates —
|
|
185
|
+
* the renderer toggles the .ui-grid-row-selected class on every cell
|
|
186
|
+
* whose data-row matches one of these ids. */
|
|
187
|
+
selectedRowIds: ReadonlySet<string>;
|
|
188
|
+
selectAll: boolean;
|
|
189
|
+
focusedRowId: string | null;
|
|
190
|
+
selectedCount: number;
|
|
106
191
|
}
|
|
107
192
|
|
|
108
193
|
export type GridControllerSubscriber = (snapshot: GridControllerSnapshot) => void;
|
|
@@ -116,8 +201,94 @@ function orderVisibleColumns(
|
|
|
116
201
|
.sort((left, right) => order.indexOf(left.name) - order.indexOf(right.name));
|
|
117
202
|
}
|
|
118
203
|
|
|
119
|
-
|
|
120
|
-
|
|
204
|
+
const DEFAULT_COL_MIN_WIDTH = 176;
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve column widths to fixed px values, mirroring the old ui-grid's
|
|
208
|
+
* `updateColumnWidths` / `drawnWidth` approach. Every cell (header, filter,
|
|
209
|
+
* body) gets the exact same px track size — alignment guaranteed regardless
|
|
210
|
+
* of container width or scrollbar presence.
|
|
211
|
+
*
|
|
212
|
+
* Algorithm (ported from GridRenderContainer.prototype.updateColumnWidths):
|
|
213
|
+
* 1. Fixed-px columns: use their declared width directly.
|
|
214
|
+
* 2. Percentage columns: resolve against availableWidth.
|
|
215
|
+
* 3. Flex columns (*, 1fr, minmax): divide remaining space equally.
|
|
216
|
+
* 4. Leftover/excess pixels distributed 1px at a time for pixel-perfect fit.
|
|
217
|
+
*/
|
|
218
|
+
function buildGridTemplateColumns(columns: readonly GridColumnDef[], viewportWidth?: number): string {
|
|
219
|
+
if (!viewportWidth || viewportWidth <= 0) {
|
|
220
|
+
return columns.map((column) => gridColumnWidth(column)).join(' ');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const availableWidth = viewportWidth;
|
|
224
|
+
const resolved: number[] = new Array(columns.length);
|
|
225
|
+
const flexIndices: number[] = [];
|
|
226
|
+
let usedWidth = 0;
|
|
227
|
+
|
|
228
|
+
for (let i = 0; i < columns.length; i++) {
|
|
229
|
+
const raw = columns[i].width;
|
|
230
|
+
const minWidth = columns[i].minWidth;
|
|
231
|
+
const maxWidth = columns[i].maxWidth ?? Infinity;
|
|
232
|
+
|
|
233
|
+
if (raw && raw.endsWith('px')) {
|
|
234
|
+
let px = parseInt(raw, 10);
|
|
235
|
+
if (minWidth != null) px = Math.max(minWidth, px);
|
|
236
|
+
px = Math.min(maxWidth, px);
|
|
237
|
+
resolved[i] = px;
|
|
238
|
+
usedWidth += px;
|
|
239
|
+
} else if (raw && raw.endsWith('%')) {
|
|
240
|
+
const pct = parseInt(raw.replace(/%/g, ''), 10);
|
|
241
|
+
let w = Math.round((pct / 100) * availableWidth);
|
|
242
|
+
if (minWidth != null) w = Math.max(minWidth, w);
|
|
243
|
+
w = Math.min(maxWidth, w);
|
|
244
|
+
resolved[i] = w;
|
|
245
|
+
usedWidth += w;
|
|
246
|
+
} else {
|
|
247
|
+
flexIndices.push(i);
|
|
248
|
+
resolved[i] = 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (flexIndices.length > 0) {
|
|
253
|
+
const remaining = Math.max(0, availableWidth - usedWidth);
|
|
254
|
+
const perFlex = Math.floor(remaining / flexIndices.length);
|
|
255
|
+
|
|
256
|
+
for (const idx of flexIndices) {
|
|
257
|
+
const minWidth = columns[idx].minWidth ?? DEFAULT_COL_MIN_WIDTH;
|
|
258
|
+
const maxWidth = columns[idx].maxWidth ?? Infinity;
|
|
259
|
+
const w = Math.max(minWidth, Math.min(maxWidth, perFlex));
|
|
260
|
+
resolved[idx] = w;
|
|
261
|
+
usedWidth += w;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Distribute leftover pixels one at a time (old grid's processColumnUpwards)
|
|
265
|
+
let leftover = availableWidth - usedWidth;
|
|
266
|
+
let changed = true;
|
|
267
|
+
while (leftover > 0 && changed) {
|
|
268
|
+
changed = false;
|
|
269
|
+
for (const idx of flexIndices) {
|
|
270
|
+
if (leftover <= 0) break;
|
|
271
|
+
const maxWidth = columns[idx].maxWidth ?? Infinity;
|
|
272
|
+
if (resolved[idx] < maxWidth) {
|
|
273
|
+
resolved[idx]++;
|
|
274
|
+
leftover--;
|
|
275
|
+
changed = true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return resolved.map((w) => `${w}px`).join(' ');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function pinnedStatesEqual(a: PinnedColumnState, b: PinnedColumnState): boolean {
|
|
285
|
+
const keysA = Object.keys(a);
|
|
286
|
+
const keysB = Object.keys(b);
|
|
287
|
+
if (keysA.length !== keysB.length) return false;
|
|
288
|
+
for (const key of keysA) {
|
|
289
|
+
if (a[key] !== b[key]) return false;
|
|
290
|
+
}
|
|
291
|
+
return true;
|
|
121
292
|
}
|
|
122
293
|
|
|
123
294
|
export class VanillaGridController {
|
|
@@ -128,6 +299,10 @@ export class VanillaGridController {
|
|
|
128
299
|
direction: SORT_DIRECTIONS.none,
|
|
129
300
|
};
|
|
130
301
|
private groupByColumns: string[] = [];
|
|
302
|
+
private lastOptionsGroupBy: string[] | undefined = undefined;
|
|
303
|
+
private lastOptionsPinnedState: PinnedColumnState | undefined = undefined;
|
|
304
|
+
private lastOptionsInfiniteScrollUp: boolean | undefined = undefined;
|
|
305
|
+
private lastOptionsInfiniteScrollDown: boolean | undefined = undefined;
|
|
131
306
|
private collapsedGroups: Record<string, boolean> = {};
|
|
132
307
|
private hiddenRowReasons: Record<string, string[]> = {};
|
|
133
308
|
private expandedRows: Record<string, boolean> = {};
|
|
@@ -135,10 +310,31 @@ export class VanillaGridController {
|
|
|
135
310
|
private pinnedColumns: PinnedColumnState = {};
|
|
136
311
|
private columnOrder: string[] = [];
|
|
137
312
|
private columnWidthOverrides: Record<string, string> = {};
|
|
313
|
+
private viewportWidth = 0;
|
|
138
314
|
private currentPage = 1;
|
|
139
315
|
private pageSize = 0;
|
|
140
316
|
private editingCell: GridCellPosition | null = null;
|
|
141
317
|
private editingValue = '';
|
|
318
|
+
private exporterOverrides: GridExporterOptions = {};
|
|
319
|
+
private validatorRegistry: GridValidatorRegistry;
|
|
320
|
+
private rowEditState: GridRowEditState = createGridRowEditState();
|
|
321
|
+
private rowEditTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
322
|
+
private rowEditSavePromiseOverrides = new Map<string, Promise<void>>();
|
|
323
|
+
private selectionState: GridSelectionState = createGridSelectionState();
|
|
324
|
+
/** cellNav state. Persists the last focused cell so the public API's
|
|
325
|
+
* getFocusedCell returns the right value across re-renders, and tracks
|
|
326
|
+
* the focused-cells history for getCurrentSelection. */
|
|
327
|
+
private cellNavLastRowCol: { rowId: string; columnName: string } | null = null;
|
|
328
|
+
private cellNavFocusedCells: { rowId: string; columnName: string }[] = [];
|
|
329
|
+
/** Infinite-scroll state. Direction flags seed from options; dataLoading
|
|
330
|
+
* + previousVisibleRows are runtime bookkeeping for saveScrollPercentage /
|
|
331
|
+
* dataRemovedTop / dataRemovedBottom. */
|
|
332
|
+
private infiniteScrollState: GridInfiniteScrollState = {
|
|
333
|
+
scrollUp: false,
|
|
334
|
+
scrollDown: true,
|
|
335
|
+
dataLoading: false,
|
|
336
|
+
previousVisibleRows: 0,
|
|
337
|
+
};
|
|
142
338
|
private pipeline: PipelineResult;
|
|
143
339
|
private labels: GridLabels;
|
|
144
340
|
private visibleColumns: GridColumnDef[] = [];
|
|
@@ -151,7 +347,23 @@ export class VanillaGridController {
|
|
|
151
347
|
constructor(options: GridOptions) {
|
|
152
348
|
this.options = options;
|
|
153
349
|
this.labels = resolveGridLabels(options.labels);
|
|
350
|
+
// Validator registry is built from the resolved labels so default
|
|
351
|
+
// validator messages ("Field required", etc.) come out localized
|
|
352
|
+
// without the consumer wiring anything up.
|
|
353
|
+
this.validatorRegistry = createGridValidatorRegistry(this.labels);
|
|
154
354
|
this.columnOrder = options.columnDefs.map((column) => column.name);
|
|
355
|
+
this.groupByColumns = options.grouping?.groupBy ? [...options.grouping.groupBy] : [];
|
|
356
|
+
this.lastOptionsGroupBy = options.grouping?.groupBy ? [...options.grouping.groupBy] : undefined;
|
|
357
|
+
this.pinnedColumns = buildInitialPinnedState(options.columnDefs);
|
|
358
|
+
const declaresPin = options.columnDefs.some((col) => col.pinnedLeft === true || col.pinnedRight === true);
|
|
359
|
+
this.lastOptionsPinnedState = declaresPin ? { ...this.pinnedColumns } : undefined;
|
|
360
|
+
this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
|
|
361
|
+
this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
|
|
362
|
+
|
|
363
|
+
this.infiniteScrollState = resetInfiniteScrollState(
|
|
364
|
+
options.infiniteScrollUp === true,
|
|
365
|
+
options.infiniteScrollDown !== false,
|
|
366
|
+
);
|
|
155
367
|
|
|
156
368
|
this.pipeline = {
|
|
157
369
|
visibleRows: [],
|
|
@@ -181,8 +393,17 @@ export class VanillaGridController {
|
|
|
181
393
|
toggleGrouping: (columnName) => this.toggleGrouping(columnName),
|
|
182
394
|
clearGrouping: () => this.clearGrouping(),
|
|
183
395
|
benchmark: (iterations) => this.benchmark(iterations),
|
|
184
|
-
exportCsv: () =>
|
|
185
|
-
|
|
396
|
+
exportCsv: (rowType, colType) => this.exportCsv(rowType, colType),
|
|
397
|
+
buildCsv: (rowType, colType) => this.buildCsv(rowType, colType),
|
|
398
|
+
pdfExport: (rowType, colType) => this.exportPdf(rowType, colType),
|
|
399
|
+
buildPdfDocDefinition: (rowType, colType) =>
|
|
400
|
+
this.buildPdfDocDefinition(rowType, colType),
|
|
401
|
+
excelExport: (rowType, colType) => this.exportExcel(rowType, colType),
|
|
402
|
+
buildExcelSheetData: (rowType, colType) => this.buildExcelSheetData(rowType, colType),
|
|
403
|
+
getExporterMenuItems: () => this.buildExporterMenuItems(),
|
|
404
|
+
getExporterOptions: () => ({ ...this.exporterOverrides, ...resolveGridExporterOptions(this.options) }),
|
|
405
|
+
setExporterOptions: (overrides) => {
|
|
406
|
+
this.exporterOverrides = { ...this.exporterOverrides, ...overrides };
|
|
186
407
|
},
|
|
187
408
|
paginationGetPage: () => this.getCurrentPage(),
|
|
188
409
|
paginationGetTotalPages: () => this.getTotalPages(),
|
|
@@ -225,14 +446,163 @@ export class VanillaGridController {
|
|
|
225
446
|
this.cancelCellEdit();
|
|
226
447
|
},
|
|
227
448
|
getEditingCell: () => this.editingCell,
|
|
449
|
+
toggleRowSelection: (rowEntity, evt) => this.toggleRowSelectionByEntity(rowEntity, evt),
|
|
450
|
+
selectRow: (rowEntity, evt) => this.selectRow(rowEntity, evt),
|
|
451
|
+
selectRowByVisibleIndex: (rowNum, evt) => this.selectRowByVisibleIndex(rowNum, evt),
|
|
452
|
+
selectRowByKey: (isInEntity, key, comparator, evt, lookInRows) =>
|
|
453
|
+
this.selectRowByKey(isInEntity, key, comparator, evt, lookInRows),
|
|
454
|
+
unSelectRow: (rowEntity, evt) => this.unSelectRow(rowEntity, evt),
|
|
455
|
+
unSelectRowByVisibleIndex: (rowNum, evt) => this.unSelectRowByVisibleIndex(rowNum, evt),
|
|
456
|
+
unSelectRowByKey: (isInEntity, key, comparator, evt, lookInRows) =>
|
|
457
|
+
this.unSelectRowByKey(isInEntity, key, comparator, evt, lookInRows),
|
|
458
|
+
selectAllRows: (evt) => this.selectAllRows(evt),
|
|
459
|
+
selectAllVisibleRows: (evt) => this.selectAllVisibleRows(evt),
|
|
460
|
+
clearSelectedRows: (evt) => this.clearSelectedRows(evt),
|
|
461
|
+
getSelectedRows: () => this.getSelectedRows(),
|
|
462
|
+
getUnSelectedRows: () => this.getUnSelectedRows(),
|
|
463
|
+
getSelectedGridRows: () => this.getSelectedGridRows(),
|
|
464
|
+
getUnSelectedGridRows: () => this.getUnSelectedGridRows(),
|
|
465
|
+
getSelectedCount: () => this.selectionState.selectedRowIds.size,
|
|
466
|
+
setMultiSelect: (multiSelect) => this.setMultiSelect(multiSelect),
|
|
467
|
+
setModifierKeysToMultiSelect: (value) => this.setModifierKeysToMultiSelect(value),
|
|
468
|
+
getSelectAllState: () => this.selectionState.selectAll,
|
|
469
|
+
shiftSelectRow: (rowEntity, evt) => this.shiftSelectRow(rowEntity, evt),
|
|
470
|
+
// cellNav bindings — thin wrappers over the same focus state the
|
|
471
|
+
// element uses. The element raises `cellNav.navigate` via
|
|
472
|
+
// controller.setCellNavFocus(); these methods let consumers inspect
|
|
473
|
+
// and re-drive that state.
|
|
474
|
+
scrollToFocus: (rowEntity, colDef) => this.cellNavScrollToFocus(rowEntity, colDef),
|
|
475
|
+
getFocusedCell: () => this.cellNavGetFocusedCell(),
|
|
476
|
+
getCurrentSelection: () => this.cellNavGetCurrentSelection(),
|
|
477
|
+
rowColSelectIndex: (rowCol) => this.cellNavRowColSelectIndex(rowCol),
|
|
478
|
+
// SaveState bindings — ports ui.grid.saveState public API.
|
|
479
|
+
saveState: () => this.getState() as GridSavedState,
|
|
480
|
+
restoreState: (state) => this.setState(state as Partial<GridSaveState>),
|
|
481
|
+
// Infinite-scroll bindings — ports ui.grid.infiniteScroll public API.
|
|
482
|
+
infiniteScrollDataLoaded: (scrollUp, scrollDown) =>
|
|
483
|
+
this.infiniteScrollDataLoaded(scrollUp, scrollDown),
|
|
484
|
+
infiniteScrollReset: (scrollUp, scrollDown) =>
|
|
485
|
+
this.infiniteScrollResetScroll(scrollUp, scrollDown),
|
|
486
|
+
infiniteScrollSaveScrollPercentage: () => this.infiniteScrollSavePercentage(),
|
|
487
|
+
infiniteScrollDataRemovedTop: (scrollUp, scrollDown) =>
|
|
488
|
+
this.infiniteScrollDataRemovedTop(scrollUp, scrollDown),
|
|
489
|
+
infiniteScrollDataRemovedBottom: (scrollUp, scrollDown) =>
|
|
490
|
+
this.infiniteScrollDataRemovedBottom(scrollUp, scrollDown),
|
|
491
|
+
infiniteScrollSetDirections: (scrollUp, scrollDown) =>
|
|
492
|
+
this.infiniteScrollSetDirections(scrollUp, scrollDown),
|
|
493
|
+
// Row-edit bindings — port of ui.grid.rowEdit public API.
|
|
494
|
+
rowEditSetSavePromise: (rowEntity, promise) =>
|
|
495
|
+
this.rowEditSetSavePromise(rowEntity, promise),
|
|
496
|
+
rowEditGetDirtyRows: () => this.rowEditGetDirtyRows(),
|
|
497
|
+
rowEditGetErrorRows: () => this.rowEditGetErrorRows(),
|
|
498
|
+
rowEditFlushDirtyRows: () => this.rowEditFlushDirtyRows(),
|
|
499
|
+
rowEditRetryErroredRows: () => this.rowEditRetryErroredRows(),
|
|
500
|
+
rowEditSetRowsDirty: (rowEntities) => this.rowEditSetRowsDirty(rowEntities),
|
|
501
|
+
rowEditSetRowsClean: (rowEntities) => this.rowEditSetRowsClean(rowEntities),
|
|
502
|
+
rowEditGetMenuItems: () => this.buildRowEditMenuItems(),
|
|
503
|
+
// Importer bindings — port of ui.grid.importer public API.
|
|
504
|
+
importerImportAFile: () => this.importerRequestFile(),
|
|
505
|
+
importerImportThisFile: (file) => this.importerImportThisFile(file),
|
|
506
|
+
importerImportText: (text, type) => this.importerImportText(text, type),
|
|
507
|
+
importerGetMenuItems: () => this.buildImporterMenuItems(),
|
|
508
|
+
// Validate bindings — port of ui.grid.validate public API.
|
|
509
|
+
validateIsInvalid: (rowEntity, colDef) => isGridCellInvalid(rowEntity, colDef),
|
|
510
|
+
validateGetErrorMessages: (rowEntity, colDef) =>
|
|
511
|
+
getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry),
|
|
512
|
+
validateGetFormattedErrors: (rowEntity, colDef) =>
|
|
513
|
+
this.validateGetFormattedErrors(rowEntity, colDef),
|
|
514
|
+
validateGetTitleFormattedErrors: (rowEntity, colDef) =>
|
|
515
|
+
this.validateGetTitleFormattedErrors(rowEntity, colDef),
|
|
516
|
+
validateRunValidators: (rowEntity, colDef, newValue, oldValue) =>
|
|
517
|
+
this.validateRunValidators(rowEntity, colDef, newValue, oldValue),
|
|
518
|
+
validateSetValidator: (name, factory, messageFn) =>
|
|
519
|
+
this.validatorRegistry.setValidator(name, factory, messageFn),
|
|
520
|
+
validateGetInvalidRows: () => this.validateGetInvalidRows(),
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Subscribe row-edit to the edit events so a committed change flips the
|
|
524
|
+
// row into dirty state + schedules a save, and cancelling restarts the
|
|
525
|
+
// timer when the row was already dirty.
|
|
526
|
+
this.gridApi.edit.on.afterCellEdit((rowEntity, col, newValue, previousValue) => {
|
|
527
|
+
const rowId = this.resolveRowId(rowEntity);
|
|
528
|
+
const gridRow = this.findRowById(rowId);
|
|
529
|
+
if (!gridRow) return;
|
|
530
|
+
// Auto-run validators when a column declares them. Ports the old
|
|
531
|
+
// ui.grid.validate → edit integration where afterCellEdit kicked
|
|
532
|
+
// runValidators on the same cell. Async validators settle later;
|
|
533
|
+
// the re-emit makes sure the UI picks up the resulting invalid
|
|
534
|
+
// flag once they do.
|
|
535
|
+
if (col?.validators) {
|
|
536
|
+
void this.validateRunValidators(rowEntity, col, newValue, previousValue).then(() => {
|
|
537
|
+
this.emit();
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
if (newValue === previousValue && !gridRow.isDirty) return;
|
|
541
|
+
markGridRowDirty(this.rowEditState, gridRow);
|
|
542
|
+
this.considerSetRowEditTimer(gridRow);
|
|
543
|
+
this.emit();
|
|
544
|
+
});
|
|
545
|
+
this.gridApi.edit.on.beginCellEdit((rowEntity) => {
|
|
546
|
+
const rowId = this.resolveRowId(rowEntity);
|
|
547
|
+
const gridRow = this.findRowById(rowId);
|
|
548
|
+
if (gridRow) this.cancelRowEditTimer(gridRow);
|
|
549
|
+
});
|
|
550
|
+
this.gridApi.edit.on.cancelCellEdit((rowEntity) => {
|
|
551
|
+
const rowId = this.resolveRowId(rowEntity);
|
|
552
|
+
const gridRow = this.findRowById(rowId);
|
|
553
|
+
if (gridRow) this.considerSetRowEditTimer(gridRow);
|
|
554
|
+
});
|
|
555
|
+
// Cellnav navigate — if the user leaves a dirty row, start its timer
|
|
556
|
+
// (matches old grid: a row only autosaves when focus moves off it or
|
|
557
|
+
// the debounce elapses).
|
|
558
|
+
this.gridApi.cellNav.on.navigate((_newRowCol, oldRowCol) => {
|
|
559
|
+
if (!oldRowCol) return;
|
|
560
|
+
this.considerSetRowEditTimer(oldRowCol.row);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// When the i18n service swaps the active language, re-resolve labels
|
|
564
|
+
// + re-register the default validators (their messages are locale-
|
|
565
|
+
// driven) + re-render so every cell/tooltip picks up the new strings.
|
|
566
|
+
this.disposeLanguageListener = this.gridApi.i18n.on.languageChanged(() => {
|
|
567
|
+
this.labels = resolveGridLabels(this.options.labels);
|
|
568
|
+
const rebuilt = createGridValidatorRegistry(this.labels);
|
|
569
|
+
for (const name of ['required', 'minLength', 'maxLength']) {
|
|
570
|
+
this.validatorRegistry.setValidator(
|
|
571
|
+
name,
|
|
572
|
+
(arg: unknown) => rebuilt.getValidator(name, arg),
|
|
573
|
+
(arg: unknown) => rebuilt.getMessage(name, arg),
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
this.refresh();
|
|
228
577
|
});
|
|
229
578
|
|
|
230
579
|
this.refresh();
|
|
231
580
|
}
|
|
232
581
|
|
|
582
|
+
/** Disposer for the i18n language-change listener — cleared when the
|
|
583
|
+
* controller is torn down (matches the pattern used for other global
|
|
584
|
+
* subscriptions). */
|
|
585
|
+
private disposeLanguageListener: (() => void) | null = null;
|
|
586
|
+
dispose(): void {
|
|
587
|
+
this.disposeLanguageListener?.();
|
|
588
|
+
this.disposeLanguageListener = null;
|
|
589
|
+
this.subscribers.clear();
|
|
590
|
+
}
|
|
591
|
+
|
|
233
592
|
setOptions(options: GridOptions): void {
|
|
234
593
|
this.options = options;
|
|
235
594
|
this.labels = resolveGridLabels(options.labels);
|
|
595
|
+
// Re-register the built-in validators so their messages track the
|
|
596
|
+
// refreshed locale. Consumer-registered validators survive untouched
|
|
597
|
+
// because we write them onto the same registry instance.
|
|
598
|
+
const rebuiltDefaults = createGridValidatorRegistry(this.labels);
|
|
599
|
+
for (const name of ['required', 'minLength', 'maxLength']) {
|
|
600
|
+
this.validatorRegistry.setValidator(
|
|
601
|
+
name,
|
|
602
|
+
(arg: unknown) => rebuiltDefaults.getValidator(name, arg),
|
|
603
|
+
(arg: unknown) => rebuiltDefaults.getMessage(name, arg),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
236
606
|
if (this.columnOrder.length === 0) {
|
|
237
607
|
this.columnOrder = options.columnDefs.map((column) => column.name);
|
|
238
608
|
} else {
|
|
@@ -245,6 +615,55 @@ export class VanillaGridController {
|
|
|
245
615
|
}
|
|
246
616
|
}
|
|
247
617
|
|
|
618
|
+
// Apply declarative grouping only when the consumer's value structurally
|
|
619
|
+
// changed from what was last provided via options. This lets interactive
|
|
620
|
+
// toggles persist across re-renders that pass the same grouping config.
|
|
621
|
+
if (options.grouping?.groupBy !== undefined) {
|
|
622
|
+
const incoming = options.grouping.groupBy;
|
|
623
|
+
const prev = this.lastOptionsGroupBy;
|
|
624
|
+
const changed = !prev || prev.length !== incoming.length || prev.some((col, i) => col !== incoming[i]);
|
|
625
|
+
if (changed) {
|
|
626
|
+
this.groupByColumns = [...incoming];
|
|
627
|
+
this.lastOptionsGroupBy = [...incoming];
|
|
628
|
+
}
|
|
629
|
+
} else if (this.lastOptionsGroupBy !== undefined) {
|
|
630
|
+
this.lastOptionsGroupBy = undefined;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Re-seed pinned state from columnDefs only when the declarative pin
|
|
634
|
+
// configuration structurally changed from the last options pass. This
|
|
635
|
+
// prevents interactive un-pin actions from being clobbered by re-renders
|
|
636
|
+
// that pass the same columnDefs.
|
|
637
|
+
const declaresPin = options.columnDefs.some(
|
|
638
|
+
(col) => col.pinnedLeft === true || col.pinnedRight === true,
|
|
639
|
+
);
|
|
640
|
+
if (declaresPin) {
|
|
641
|
+
const incoming = buildInitialPinnedState(options.columnDefs);
|
|
642
|
+
const prev = this.lastOptionsPinnedState;
|
|
643
|
+
if (!prev || !pinnedStatesEqual(prev, incoming)) {
|
|
644
|
+
this.pinnedColumns = incoming;
|
|
645
|
+
this.lastOptionsPinnedState = { ...incoming };
|
|
646
|
+
}
|
|
647
|
+
} else if (this.lastOptionsPinnedState !== undefined) {
|
|
648
|
+
this.lastOptionsPinnedState = undefined;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Re-seed infinite-scroll directions only when the values structurally
|
|
652
|
+
// changed from the last options pass.
|
|
653
|
+
if (options.infiniteScrollUp !== undefined || options.infiniteScrollDown !== undefined) {
|
|
654
|
+
const upChanged = options.infiniteScrollUp !== undefined && options.infiniteScrollUp !== this.lastOptionsInfiniteScrollUp;
|
|
655
|
+
const downChanged = options.infiniteScrollDown !== undefined && options.infiniteScrollDown !== this.lastOptionsInfiniteScrollDown;
|
|
656
|
+
if (upChanged || downChanged) {
|
|
657
|
+
this.infiniteScrollState = setInfiniteScrollDirectionsState(
|
|
658
|
+
this.infiniteScrollState,
|
|
659
|
+
options.infiniteScrollUp ?? this.infiniteScrollState.scrollUp,
|
|
660
|
+
options.infiniteScrollDown ?? this.infiniteScrollState.scrollDown,
|
|
661
|
+
);
|
|
662
|
+
this.lastOptionsInfiniteScrollUp = options.infiniteScrollUp;
|
|
663
|
+
this.lastOptionsInfiniteScrollDown = options.infiniteScrollDown;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
248
667
|
this.apiRegistered = false;
|
|
249
668
|
this.refresh();
|
|
250
669
|
}
|
|
@@ -288,7 +707,7 @@ export class VanillaGridController {
|
|
|
288
707
|
labels: this.labels,
|
|
289
708
|
rowSize: this.getRowSize(),
|
|
290
709
|
visibleColumns: this.visibleColumns,
|
|
291
|
-
gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns),
|
|
710
|
+
gridTemplateColumns: buildGridTemplateColumns(this.visibleColumns, this.viewportWidth),
|
|
292
711
|
pipeline: this.pipeline,
|
|
293
712
|
activeFilters: this.activeFilters,
|
|
294
713
|
sortState: this.sortState,
|
|
@@ -301,6 +720,10 @@ export class VanillaGridController {
|
|
|
301
720
|
pageSize: this.getEffectivePageSize(),
|
|
302
721
|
editingCell: this.editingCell,
|
|
303
722
|
editingValue: this.editingValue,
|
|
723
|
+
selectedRowIds: this.selectionState.selectedRowIds,
|
|
724
|
+
selectAll: this.selectionState.selectAll,
|
|
725
|
+
focusedRowId: this.selectionState.focusedRowId,
|
|
726
|
+
selectedCount: this.selectionState.selectedRowIds.size,
|
|
304
727
|
};
|
|
305
728
|
}
|
|
306
729
|
|
|
@@ -520,6 +943,7 @@ export class VanillaGridController {
|
|
|
520
943
|
row: GridRow | GridRecord | string,
|
|
521
944
|
columnName: string,
|
|
522
945
|
triggerEvent?: Event | KeyboardEvent | null,
|
|
946
|
+
initialValue?: string,
|
|
523
947
|
): void {
|
|
524
948
|
const rowId = this.resolveRowId(row);
|
|
525
949
|
const gridRow = this.findRowById(rowId);
|
|
@@ -545,6 +969,7 @@ export class VanillaGridController {
|
|
|
545
969
|
column,
|
|
546
970
|
getCellValue(gridRow.entity, column),
|
|
547
971
|
triggerEvent,
|
|
972
|
+
initialValue,
|
|
548
973
|
);
|
|
549
974
|
|
|
550
975
|
this.refresh();
|
|
@@ -556,6 +981,15 @@ export class VanillaGridController {
|
|
|
556
981
|
}
|
|
557
982
|
|
|
558
983
|
commitCellEdit(): void {
|
|
984
|
+
// Guard against re-entry: committing rebuilds the cell markup, which
|
|
985
|
+
// removes the focused <input> and fires blur — whose handler also tries
|
|
986
|
+
// to commit. Without this early-return, the nested render mutates DOM
|
|
987
|
+
// that the outer render hasn't finished unwinding yet, throwing
|
|
988
|
+
// "node to be removed is no longer a child of this node."
|
|
989
|
+
if (this.editingCell === null) {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
559
993
|
commitGridCellEditCommand(this.gridApi, {
|
|
560
994
|
getEditingCell: () => this.editingCell,
|
|
561
995
|
getEditingValue: () => this.editingValue,
|
|
@@ -576,6 +1010,10 @@ export class VanillaGridController {
|
|
|
576
1010
|
}
|
|
577
1011
|
|
|
578
1012
|
cancelCellEdit(): void {
|
|
1013
|
+
if (this.editingCell === null) {
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
579
1017
|
cancelGridCellEditCommand(this.gridApi, {
|
|
580
1018
|
getEditingCell: () => this.editingCell,
|
|
581
1019
|
setEditingCell: (next) => {
|
|
@@ -591,10 +1029,476 @@ export class VanillaGridController {
|
|
|
591
1029
|
this.refresh();
|
|
592
1030
|
}
|
|
593
1031
|
|
|
1032
|
+
// ---- Row selection -------------------------------------------------
|
|
1033
|
+
// Thin method layer on top of grid.core.selection. Each call mutates
|
|
1034
|
+
// this.selectionState + row instances in place, raises the appropriate
|
|
1035
|
+
// public-API event (single vs batch), then refreshes so the element
|
|
1036
|
+
// re-renders with the updated `isSelected` / `isFocused` flags.
|
|
1037
|
+
|
|
1038
|
+
/** Internal: run a core selection mutation, raise events, trigger a render. */
|
|
1039
|
+
private applySelectionChange(
|
|
1040
|
+
apply: () => { changed: GridRow[] },
|
|
1041
|
+
evt?: Event | null,
|
|
1042
|
+
): void {
|
|
1043
|
+
const { changed } = apply();
|
|
1044
|
+
if (changed.length === 0) {
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (this.options.enableSelectionBatchEvent !== false && changed.length > 1) {
|
|
1048
|
+
this.gridApi.selection.raise.rowSelectionChangedBatch(changed, evt);
|
|
1049
|
+
} else {
|
|
1050
|
+
for (const row of changed) {
|
|
1051
|
+
this.gridApi.selection.raise.rowSelectionChanged(row, evt);
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
this.refresh();
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
private allGridRows(): GridRow[] {
|
|
1058
|
+
// Use the cached pipeline row list when possible — it already reflects
|
|
1059
|
+
// `options.data`. Fall back to rebuilding when the pipeline is empty
|
|
1060
|
+
// (e.g. before first refresh).
|
|
1061
|
+
return this.pipeline.visibleRows.length > 0
|
|
1062
|
+
? this.pipeline.visibleRows
|
|
1063
|
+
: this.buildRowsFromData(this.options.data);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
toggleRowSelectionByEntity(rowEntity: GridRecord, evt?: Event | null): void {
|
|
1067
|
+
const row = this.findRowById(this.resolveRowId(rowEntity));
|
|
1068
|
+
if (!row) return;
|
|
1069
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1070
|
+
this.applySelectionChange(
|
|
1071
|
+
() =>
|
|
1072
|
+
coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
|
|
1073
|
+
multiSelect: resolved.multiSelect,
|
|
1074
|
+
noUnselect: resolved.noUnselect,
|
|
1075
|
+
}),
|
|
1076
|
+
evt,
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
selectRow(rowEntity: GridRecord, evt?: Event | null): void {
|
|
1081
|
+
const row = this.findRowById(this.resolveRowId(rowEntity));
|
|
1082
|
+
if (!row || row.isSelected) return;
|
|
1083
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1084
|
+
this.applySelectionChange(
|
|
1085
|
+
() =>
|
|
1086
|
+
coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
|
|
1087
|
+
multiSelect: resolved.multiSelect,
|
|
1088
|
+
noUnselect: resolved.noUnselect,
|
|
1089
|
+
}),
|
|
1090
|
+
evt,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
selectRowByVisibleIndex(rowNum: number, evt?: Event | null): void {
|
|
1095
|
+
const rows = this.pipeline.visibleRows;
|
|
1096
|
+
const row = rows[rowNum];
|
|
1097
|
+
if (!row || row.isSelected) return;
|
|
1098
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1099
|
+
this.applySelectionChange(
|
|
1100
|
+
() =>
|
|
1101
|
+
coreToggleGridRowSelection(this.selectionState, rows, row, {
|
|
1102
|
+
multiSelect: resolved.multiSelect,
|
|
1103
|
+
noUnselect: resolved.noUnselect,
|
|
1104
|
+
canBeInvisible: false,
|
|
1105
|
+
}),
|
|
1106
|
+
evt,
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
selectRowByKey(
|
|
1111
|
+
isInEntity: boolean,
|
|
1112
|
+
key: string,
|
|
1113
|
+
comparator: unknown,
|
|
1114
|
+
evt?: Event | null,
|
|
1115
|
+
lookInRows?: readonly GridRow[],
|
|
1116
|
+
): void {
|
|
1117
|
+
const rows = lookInRows ?? this.allGridRows();
|
|
1118
|
+
const row = coreFindGridRowByKey(rows, isInEntity, key, comparator);
|
|
1119
|
+
if (!row || row.isSelected) return;
|
|
1120
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1121
|
+
this.applySelectionChange(
|
|
1122
|
+
() =>
|
|
1123
|
+
coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
|
|
1124
|
+
multiSelect: resolved.multiSelect,
|
|
1125
|
+
noUnselect: resolved.noUnselect,
|
|
1126
|
+
canBeInvisible: false,
|
|
1127
|
+
}),
|
|
1128
|
+
evt,
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
unSelectRow(rowEntity: GridRecord, evt?: Event | null): void {
|
|
1133
|
+
const row = this.findRowById(this.resolveRowId(rowEntity));
|
|
1134
|
+
if (!row || !row.isSelected) return;
|
|
1135
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1136
|
+
this.applySelectionChange(
|
|
1137
|
+
() =>
|
|
1138
|
+
coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
|
|
1139
|
+
multiSelect: resolved.multiSelect,
|
|
1140
|
+
noUnselect: resolved.noUnselect,
|
|
1141
|
+
}),
|
|
1142
|
+
evt,
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
unSelectRowByVisibleIndex(rowNum: number, evt?: Event | null): void {
|
|
1147
|
+
const row = this.pipeline.visibleRows[rowNum];
|
|
1148
|
+
if (!row || !row.isSelected) return;
|
|
1149
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1150
|
+
this.applySelectionChange(
|
|
1151
|
+
() =>
|
|
1152
|
+
coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
|
|
1153
|
+
multiSelect: resolved.multiSelect,
|
|
1154
|
+
noUnselect: resolved.noUnselect,
|
|
1155
|
+
canBeInvisible: false,
|
|
1156
|
+
}),
|
|
1157
|
+
evt,
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
unSelectRowByKey(
|
|
1162
|
+
isInEntity: boolean,
|
|
1163
|
+
key: string,
|
|
1164
|
+
comparator: unknown,
|
|
1165
|
+
evt?: Event | null,
|
|
1166
|
+
lookInRows?: readonly GridRow[],
|
|
1167
|
+
): void {
|
|
1168
|
+
const rows = lookInRows ?? this.allGridRows();
|
|
1169
|
+
const row = coreFindGridRowByKey(rows, isInEntity, key, comparator);
|
|
1170
|
+
if (!row || !row.isSelected) return;
|
|
1171
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1172
|
+
this.applySelectionChange(
|
|
1173
|
+
() =>
|
|
1174
|
+
coreToggleGridRowSelection(this.selectionState, this.allGridRows(), row, {
|
|
1175
|
+
multiSelect: resolved.multiSelect,
|
|
1176
|
+
noUnselect: resolved.noUnselect,
|
|
1177
|
+
canBeInvisible: false,
|
|
1178
|
+
}),
|
|
1179
|
+
evt,
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
selectAllRows(evt?: Event | null): void {
|
|
1184
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1185
|
+
this.applySelectionChange(
|
|
1186
|
+
() =>
|
|
1187
|
+
coreSelectAllGridRows(this.selectionState, this.allGridRows(), {
|
|
1188
|
+
multiSelect: resolved.multiSelect,
|
|
1189
|
+
isRowSelectable: resolved.isRowSelectable,
|
|
1190
|
+
}),
|
|
1191
|
+
evt,
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
selectAllVisibleRows(evt?: Event | null): void {
|
|
1196
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1197
|
+
this.applySelectionChange(
|
|
1198
|
+
() =>
|
|
1199
|
+
coreSelectAllVisibleGridRows(this.selectionState, this.allGridRows(), {
|
|
1200
|
+
multiSelect: resolved.multiSelect,
|
|
1201
|
+
isRowSelectable: resolved.isRowSelectable,
|
|
1202
|
+
}),
|
|
1203
|
+
evt,
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
clearSelectedRows(evt?: Event | null): void {
|
|
1208
|
+
this.applySelectionChange(
|
|
1209
|
+
() => coreClearAllGridSelection(this.selectionState, this.allGridRows()),
|
|
1210
|
+
evt,
|
|
1211
|
+
);
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
shiftSelectRow(rowEntity: GridRecord, evt?: Event | null): void {
|
|
1215
|
+
const row = this.findRowById(this.resolveRowId(rowEntity));
|
|
1216
|
+
if (!row) return;
|
|
1217
|
+
const resolved = resolveGridSelectionOptions(this.options);
|
|
1218
|
+
this.applySelectionChange(
|
|
1219
|
+
() =>
|
|
1220
|
+
coreShiftGridRowSelection(this.selectionState, this.pipeline.visibleRows, row, {
|
|
1221
|
+
multiSelect: resolved.multiSelect,
|
|
1222
|
+
}),
|
|
1223
|
+
evt,
|
|
1224
|
+
);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
getSelectedRows(): GridRecord[] {
|
|
1228
|
+
return coreMapSelectedRowsToEntities(this.getSelectedGridRows());
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
getUnSelectedRows(): GridRecord[] {
|
|
1232
|
+
return coreMapSelectedRowsToEntities(this.getUnSelectedGridRows());
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
getSelectedGridRows(): GridRow[] {
|
|
1236
|
+
return this.allGridRows().filter((row) => row.isSelected);
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
getUnSelectedGridRows(): GridRow[] {
|
|
1240
|
+
return this.allGridRows().filter((row) => !row.isSelected);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
setMultiSelect(multiSelect: boolean): void {
|
|
1244
|
+
this.options = { ...this.options, multiSelect };
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
setModifierKeysToMultiSelect(value: boolean): void {
|
|
1248
|
+
this.options = { ...this.options, modifierKeysToMultiSelect: value };
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/** Focus a row by id — the old module's `row.setFocused(true)` with all
|
|
1252
|
+
* the book-keeping. Raises rowFocusChanged and refreshes. */
|
|
1253
|
+
setRowFocused(rowId: string, focused: boolean, evt?: Event | null): void {
|
|
1254
|
+
const row = this.findRowById(rowId);
|
|
1255
|
+
if (!row) return;
|
|
1256
|
+
const previousFocusedId = this.selectionState.focusedRowId;
|
|
1257
|
+
if (focused) {
|
|
1258
|
+
if (previousFocusedId && previousFocusedId !== rowId) {
|
|
1259
|
+
const previous = this.findRowById(previousFocusedId);
|
|
1260
|
+
previous?.setFocused(false);
|
|
1261
|
+
}
|
|
1262
|
+
row.setFocused(true);
|
|
1263
|
+
this.selectionState.focusedRowId = rowId;
|
|
1264
|
+
} else {
|
|
1265
|
+
row.setFocused(false);
|
|
1266
|
+
if (previousFocusedId === rowId) this.selectionState.focusedRowId = null;
|
|
1267
|
+
}
|
|
1268
|
+
this.gridApi.selection.raise.rowFocusChanged(row, evt);
|
|
1269
|
+
this.refresh();
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
getRowSelectionState(): Readonly<GridSelectionState> {
|
|
1273
|
+
return this.selectionState;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/** Resolved/defaulted selection options — exposed for the element layer
|
|
1277
|
+
* so it can decide whether to wire pointer handlers without duplicating
|
|
1278
|
+
* default logic. */
|
|
1279
|
+
getResolvedSelectionOptions(): ReturnType<typeof resolveGridSelectionOptions> {
|
|
1280
|
+
return resolveGridSelectionOptions(this.options);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
/** Public analogue of findRowById — the element uses this during drag
|
|
1284
|
+
* painting to resolve a row-id back to its entity without calling the
|
|
1285
|
+
* selection API methods that would double-raise events. */
|
|
1286
|
+
findRowByIdPublic(rowId: string): GridRow | null {
|
|
1287
|
+
return this.findRowById(rowId);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// ---- End row selection ---------------------------------------------
|
|
1291
|
+
|
|
1292
|
+
// ---- cellNav -------------------------------------------------------
|
|
1293
|
+
// State + accessors for the cellNav public API. Element owns the DOM
|
|
1294
|
+
// focus; controller owns the logical "last focused cell" and the
|
|
1295
|
+
// focused-cells history. Element calls setCellNavFocus() whenever the
|
|
1296
|
+
// active cell changes.
|
|
1297
|
+
|
|
1298
|
+
/** Record a navigation event. Pushes to the focused-cells history,
|
|
1299
|
+
* updates lastRowCol, and raises cellNav.navigate on the public API. */
|
|
1300
|
+
setCellNavFocus(
|
|
1301
|
+
rowId: string | null,
|
|
1302
|
+
columnName: string | null,
|
|
1303
|
+
opts: { appendToSelection?: boolean } = {},
|
|
1304
|
+
): void {
|
|
1305
|
+
const previous = this.cellNavLastRowCol;
|
|
1306
|
+
const previousRowCol = this.resolveCellNavRowCol(previous);
|
|
1307
|
+
if (rowId && columnName) {
|
|
1308
|
+
this.cellNavLastRowCol = { rowId, columnName };
|
|
1309
|
+
if (opts.appendToSelection) {
|
|
1310
|
+
this.cellNavFocusedCells = [
|
|
1311
|
+
...this.cellNavFocusedCells.filter(
|
|
1312
|
+
(entry) => !(entry.rowId === rowId && entry.columnName === columnName),
|
|
1313
|
+
),
|
|
1314
|
+
{ rowId, columnName },
|
|
1315
|
+
];
|
|
1316
|
+
} else {
|
|
1317
|
+
this.cellNavFocusedCells = [{ rowId, columnName }];
|
|
1318
|
+
}
|
|
1319
|
+
} else {
|
|
1320
|
+
this.cellNavLastRowCol = null;
|
|
1321
|
+
this.cellNavFocusedCells = [];
|
|
1322
|
+
}
|
|
1323
|
+
const nextRowCol = this.resolveCellNavRowCol(this.cellNavLastRowCol);
|
|
1324
|
+
this.gridApi.cellNav.raise.navigate(nextRowCol, previousRowCol);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Raise viewPortKeyDown / viewPortKeyPress on behalf of the element.
|
|
1328
|
+
* Element gates this with `options.keyDownOverrides`. */
|
|
1329
|
+
raiseCellNavKeyEvent(
|
|
1330
|
+
type: 'keydown' | 'keypress',
|
|
1331
|
+
event: KeyboardEvent,
|
|
1332
|
+
): void {
|
|
1333
|
+
const rowCol = this.resolveCellNavRowCol(this.cellNavLastRowCol);
|
|
1334
|
+
if (type === 'keydown') this.gridApi.cellNav.raise.viewPortKeyDown(event, rowCol);
|
|
1335
|
+
else this.gridApi.cellNav.raise.viewPortKeyPress(event, rowCol);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
private cellNavGetFocusedCell(): GridRowColumn | null {
|
|
1339
|
+
return this.resolveCellNavRowCol(this.cellNavLastRowCol);
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
private cellNavGetCurrentSelection(): GridRowColumn[] {
|
|
1343
|
+
const out: GridRowColumn[] = [];
|
|
1344
|
+
for (const entry of this.cellNavFocusedCells) {
|
|
1345
|
+
const rowCol = this.resolveCellNavRowCol(entry);
|
|
1346
|
+
if (rowCol) out.push(rowCol);
|
|
1347
|
+
}
|
|
1348
|
+
return out;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
private cellNavRowColSelectIndex(rowCol: GridRowColumn): number {
|
|
1352
|
+
for (let i = 0; i < this.cellNavFocusedCells.length; i++) {
|
|
1353
|
+
const entry = this.cellNavFocusedCells[i]!;
|
|
1354
|
+
if (entry.rowId === rowCol.row.id && entry.columnName === rowCol.col.name) {
|
|
1355
|
+
return i;
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
return -1;
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
private resolveCellNavRowCol(
|
|
1362
|
+
position: { rowId: string; columnName: string } | null,
|
|
1363
|
+
): GridRowColumn | null {
|
|
1364
|
+
if (!position) return null;
|
|
1365
|
+
const row = this.findRowById(position.rowId);
|
|
1366
|
+
const col = this.findColumnByName(position.columnName);
|
|
1367
|
+
if (!row || !col) return null;
|
|
1368
|
+
return { row, col };
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** cellNav.scrollToFocus — resolves rowEntity + colDef into a logical
|
|
1372
|
+
* focus and delegates to the element (if attached) for actual scrolling.
|
|
1373
|
+
* When the element isn't wired (e.g. bare controller in tests), the
|
|
1374
|
+
* state still updates and the promise resolves immediately. */
|
|
1375
|
+
private async cellNavScrollToFocus(
|
|
1376
|
+
rowEntity: GridRecord | null,
|
|
1377
|
+
colDef: GridColumnDef | null,
|
|
1378
|
+
): Promise<void> {
|
|
1379
|
+
const rowId = rowEntity ? this.resolveRowId(rowEntity) : null;
|
|
1380
|
+
const columnName = colDef?.name ?? null;
|
|
1381
|
+
this.setCellNavFocus(rowId, columnName);
|
|
1382
|
+
this.cellNavScrollRequest?.(rowId, columnName);
|
|
1383
|
+
return Promise.resolve();
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/** Element registers its scroll-and-focus handler here so the API
|
|
1387
|
+
* binding above can delegate DOM work. Controller stays DOM-free. */
|
|
1388
|
+
private cellNavScrollRequest: ((rowId: string | null, columnName: string | null) => void) | null = null;
|
|
1389
|
+
setCellNavScrollHandler(handler: ((rowId: string | null, columnName: string | null) => void) | null): void {
|
|
1390
|
+
this.cellNavScrollRequest = handler;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
/** Read-only accessor for the current options. Used by the element to
|
|
1394
|
+
* inspect keyDownOverrides / modifierKeysToMultiSelectCells without
|
|
1395
|
+
* coupling it to a private field. */
|
|
1396
|
+
getOptions(): GridOptions {
|
|
1397
|
+
return this.options;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// ---- End cellNav ---------------------------------------------------
|
|
1401
|
+
|
|
1402
|
+
// ---- Infinite scroll ----------------------------------------------
|
|
1403
|
+
// Thin wrappers over grid.core.infinite-scroll's pure helpers. The
|
|
1404
|
+
// element calls `evaluateInfiniteScroll` from its scroll-frame callback;
|
|
1405
|
+
// these helpers raise needLoadMoreData / needLoadMoreDataTop when the
|
|
1406
|
+
// user approaches the end/top of the dataset.
|
|
1407
|
+
|
|
1408
|
+
/** Evaluate whether the current scroll position should request more
|
|
1409
|
+
* data at the top or bottom. Called from the element's scroll frame. */
|
|
1410
|
+
evaluateInfiniteScroll(startIndex: number, visibleRows: number, viewportRows: number): void {
|
|
1411
|
+
if (this.options.enableInfiniteScroll === false) return;
|
|
1412
|
+
const threshold = this.options.infiniteScrollRowsFromEnd ?? 20;
|
|
1413
|
+
const { request, nextState } = maybeRequestInfiniteScrollData({
|
|
1414
|
+
state: this.infiniteScrollState,
|
|
1415
|
+
startIndex,
|
|
1416
|
+
visibleRows,
|
|
1417
|
+
viewportRows,
|
|
1418
|
+
threshold,
|
|
1419
|
+
});
|
|
1420
|
+
if (request === null) return;
|
|
1421
|
+
this.infiniteScrollState = nextState;
|
|
1422
|
+
if (request === 'top') this.gridApi.infiniteScroll.raise.needLoadMoreDataTop();
|
|
1423
|
+
else this.gridApi.infiniteScroll.raise.needLoadMoreData();
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
private infiniteScrollDataLoaded(scrollUp?: boolean, scrollDown?: boolean): Promise<void> {
|
|
1427
|
+
this.infiniteScrollState = completeInfiniteScrollDataLoad(
|
|
1428
|
+
this.infiniteScrollState,
|
|
1429
|
+
scrollUp ?? this.infiniteScrollState.scrollUp,
|
|
1430
|
+
scrollDown ?? this.infiniteScrollState.scrollDown,
|
|
1431
|
+
);
|
|
1432
|
+
this.refresh();
|
|
1433
|
+
return Promise.resolve();
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
private infiniteScrollResetScroll(scrollUp?: boolean, scrollDown?: boolean): void {
|
|
1437
|
+
this.infiniteScrollState = resetInfiniteScrollState(
|
|
1438
|
+
scrollUp ?? false,
|
|
1439
|
+
scrollDown ?? true,
|
|
1440
|
+
);
|
|
1441
|
+
this.infiniteScrollScrollToTopRequest?.();
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
private infiniteScrollSavePercentage(): void {
|
|
1445
|
+
this.infiniteScrollState = saveInfiniteScrollPercentage(
|
|
1446
|
+
this.infiniteScrollState,
|
|
1447
|
+
this.pipeline.visibleRows.length,
|
|
1448
|
+
);
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
private infiniteScrollDataRemovedTop(scrollUp?: boolean, scrollDown?: boolean): void {
|
|
1452
|
+
this.infiniteScrollState = setInfiniteScrollDirectionsState(
|
|
1453
|
+
this.infiniteScrollState,
|
|
1454
|
+
scrollUp ?? this.infiniteScrollState.scrollUp,
|
|
1455
|
+
scrollDown ?? this.infiniteScrollState.scrollDown,
|
|
1456
|
+
);
|
|
1457
|
+
this.refresh();
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
private infiniteScrollDataRemovedBottom(scrollUp?: boolean, scrollDown?: boolean): void {
|
|
1461
|
+
this.infiniteScrollState = setInfiniteScrollDirectionsState(
|
|
1462
|
+
this.infiniteScrollState,
|
|
1463
|
+
scrollUp ?? this.infiniteScrollState.scrollUp,
|
|
1464
|
+
scrollDown ?? this.infiniteScrollState.scrollDown,
|
|
1465
|
+
);
|
|
1466
|
+
this.refresh();
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
private infiniteScrollSetDirections(scrollUp: boolean, scrollDown: boolean): void {
|
|
1470
|
+
this.infiniteScrollState = setInfiniteScrollDirectionsState(
|
|
1471
|
+
this.infiniteScrollState,
|
|
1472
|
+
scrollUp,
|
|
1473
|
+
scrollDown,
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
/** Element registers a scroll-to-top handler here so resetScroll can
|
|
1478
|
+
* bring the viewport back to 0. Keeps the controller DOM-free. */
|
|
1479
|
+
private infiniteScrollScrollToTopRequest: (() => void) | null = null;
|
|
1480
|
+
setInfiniteScrollResetHandler(handler: (() => void) | null): void {
|
|
1481
|
+
this.infiniteScrollScrollToTopRequest = handler;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
getInfiniteScrollState(): Readonly<GridInfiniteScrollState> {
|
|
1485
|
+
return this.infiniteScrollState;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// ---- End infinite scroll ------------------------------------------
|
|
1489
|
+
|
|
594
1490
|
canResizeColumns(): boolean {
|
|
595
1491
|
return this.options.enableColumnResizing !== false;
|
|
596
1492
|
}
|
|
597
1493
|
|
|
1494
|
+
setViewportWidth(width: number): void {
|
|
1495
|
+
this.viewportWidth = Math.round(width);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
getViewportWidth(): number {
|
|
1499
|
+
return this.viewportWidth;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
598
1502
|
setColumnWidthOverride(columnName: string, widthPx: number): void {
|
|
599
1503
|
const nextWidth = `${Math.max(88, Math.round(widthPx))}px`;
|
|
600
1504
|
this.columnWidthOverrides = { ...this.columnWidthOverrides, [columnName]: nextWidth };
|
|
@@ -609,6 +1513,7 @@ export class VanillaGridController {
|
|
|
609
1513
|
this.visibleColumns.map((c) =>
|
|
610
1514
|
c.name === columnName ? { ...c, width: widthStr } : c,
|
|
611
1515
|
),
|
|
1516
|
+
this.viewportWidth,
|
|
612
1517
|
);
|
|
613
1518
|
}
|
|
614
1519
|
|
|
@@ -678,6 +1583,21 @@ export class VanillaGridController {
|
|
|
678
1583
|
return isColumnPinnable(this.options, column);
|
|
679
1584
|
}
|
|
680
1585
|
|
|
1586
|
+
/** True when the given column is editable in principle — does NOT evaluate
|
|
1587
|
+
* a per-row `cellEditableCondition`. Use isCellEditable for that. */
|
|
1588
|
+
isColumnEditable(column: GridColumnDef): boolean {
|
|
1589
|
+
return column.enableCellEdit ?? this.options.enableCellEdit ?? false;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/** True when the (row, column) cell is editable, respecting both the
|
|
1593
|
+
* column's `enableCellEdit` flag and any `cellEditableCondition`. */
|
|
1594
|
+
isCellEditable(row: GridRow, column: GridColumnDef): boolean {
|
|
1595
|
+
if (!this.isColumnEditable(column)) return false;
|
|
1596
|
+
const condition = column.cellEditableCondition ?? this.options.cellEditableCondition ?? true;
|
|
1597
|
+
if (typeof condition === 'boolean') return condition;
|
|
1598
|
+
return condition({ row: row.entity, column, rowIndex: row.index });
|
|
1599
|
+
}
|
|
1600
|
+
|
|
681
1601
|
isPinned(column: GridColumnDef): boolean {
|
|
682
1602
|
return this.pinnedColumns[column.name] !== undefined;
|
|
683
1603
|
}
|
|
@@ -740,27 +1660,121 @@ export class VanillaGridController {
|
|
|
740
1660
|
return this.editingCell?.rowId === rowId && this.editingCell?.columnName === columnName;
|
|
741
1661
|
}
|
|
742
1662
|
|
|
1663
|
+
/**
|
|
1664
|
+
* Serialise the current grid state. Mirrors `ui.grid.saveState.save`:
|
|
1665
|
+
* every field is gated by an `options.save*` flag with the old grid's
|
|
1666
|
+
* defaults. A missing flag keeps the matching field out of the result.
|
|
1667
|
+
*/
|
|
743
1668
|
getState(): GridSaveState {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
1669
|
+
const opts = this.options;
|
|
1670
|
+
const saveWidths = opts.saveWidths !== false;
|
|
1671
|
+
const saveOrder = opts.saveOrder !== false;
|
|
1672
|
+
const saveVisible = opts.saveVisible !== false;
|
|
1673
|
+
const saveSort = opts.saveSort !== false;
|
|
1674
|
+
const saveFilter = opts.saveFilter !== false;
|
|
1675
|
+
const saveSelection = opts.saveSelection !== false;
|
|
1676
|
+
const saveGrouping = opts.saveGrouping !== false;
|
|
1677
|
+
const saveGroupingExpandedStates = opts.saveGroupingExpandedStates === true;
|
|
1678
|
+
const savePinning = opts.savePinning !== false;
|
|
1679
|
+
const saveTreeView = opts.saveTreeView !== false;
|
|
1680
|
+
const savePagination = opts.savePagination !== false;
|
|
1681
|
+
const saveScroll = opts.saveScroll === true;
|
|
1682
|
+
const saveFocus = saveScroll ? false : opts.saveFocus !== false;
|
|
1683
|
+
void saveVisible; // Visible column state is derived from columnOrder + defs;
|
|
1684
|
+
// kept as an opt-flag for forward compat.
|
|
1685
|
+
|
|
1686
|
+
const state: GridSaveState = {};
|
|
1687
|
+
if (saveSort) state.sortState = { ...this.sortState };
|
|
1688
|
+
if (saveFilter) state.activeFilters = { ...this.activeFilters };
|
|
1689
|
+
if (saveGrouping) state.groupByColumns = [...this.groupByColumns];
|
|
1690
|
+
if (saveGrouping && saveGroupingExpandedStates) {
|
|
1691
|
+
state.collapsedGroups = { ...this.collapsedGroups };
|
|
1692
|
+
}
|
|
1693
|
+
if (savePinning) state.pinnedColumns = { ...this.pinnedColumns };
|
|
1694
|
+
if (saveOrder) state.columnOrder = [...this.columnOrder];
|
|
1695
|
+
if (saveWidths) state.columnWidthOverrides = { ...this.columnWidthOverrides };
|
|
1696
|
+
if (savePagination) {
|
|
1697
|
+
state.currentPage = this.currentPage;
|
|
1698
|
+
state.pageSize = this.pageSize;
|
|
1699
|
+
}
|
|
1700
|
+
if (saveSelection) {
|
|
1701
|
+
state.selectedRowIds = [...this.selectionState.selectedRowIds];
|
|
1702
|
+
}
|
|
1703
|
+
if (saveTreeView) {
|
|
1704
|
+
state.expandedRows = { ...this.expandedRows };
|
|
1705
|
+
state.expandedTreeRows = { ...this.expandedTreeRows };
|
|
1706
|
+
}
|
|
1707
|
+
if (saveFocus) {
|
|
1708
|
+
state.focusedCell = this.cellNavLastRowCol
|
|
1709
|
+
? {
|
|
1710
|
+
rowId: this.cellNavLastRowCol.rowId,
|
|
1711
|
+
columnName: this.cellNavLastRowCol.columnName,
|
|
1712
|
+
}
|
|
1713
|
+
: null;
|
|
1714
|
+
}
|
|
1715
|
+
if (saveScroll && this.saveStateScrollSnapshot) {
|
|
1716
|
+
const snap = this.saveStateScrollSnapshot();
|
|
1717
|
+
state.scrollTop = snap.scrollTop;
|
|
1718
|
+
state.scrollLeft = snap.scrollLeft;
|
|
1719
|
+
}
|
|
1720
|
+
return state;
|
|
753
1721
|
}
|
|
754
1722
|
|
|
1723
|
+
/**
|
|
1724
|
+
* Apply a previously saved state. All fields are optional — missing
|
|
1725
|
+
* fields are left at whatever the controller currently has. Matches the
|
|
1726
|
+
* old grid's `ui.grid.saveState.restore`.
|
|
1727
|
+
*/
|
|
755
1728
|
setState(state: Partial<GridSaveState>): void {
|
|
756
1729
|
if (state.sortState !== undefined) this.sortState = { ...state.sortState };
|
|
757
1730
|
if (state.activeFilters !== undefined) this.activeFilters = { ...state.activeFilters };
|
|
758
1731
|
if (state.groupByColumns !== undefined) this.groupByColumns = [...state.groupByColumns];
|
|
1732
|
+
if (state.collapsedGroups !== undefined) this.collapsedGroups = { ...state.collapsedGroups };
|
|
759
1733
|
if (state.pinnedColumns !== undefined) this.pinnedColumns = { ...state.pinnedColumns };
|
|
760
1734
|
if (state.columnOrder !== undefined) this.columnOrder = [...state.columnOrder];
|
|
1735
|
+
if (state.columnWidthOverrides !== undefined) this.columnWidthOverrides = { ...state.columnWidthOverrides };
|
|
761
1736
|
if (state.currentPage !== undefined) this.currentPage = state.currentPage;
|
|
762
1737
|
if (state.pageSize !== undefined) this.pageSize = state.pageSize;
|
|
1738
|
+
if (state.expandedRows !== undefined) this.expandedRows = { ...state.expandedRows };
|
|
1739
|
+
if (state.expandedTreeRows !== undefined) this.expandedTreeRows = { ...state.expandedTreeRows };
|
|
1740
|
+
if (state.selectedRowIds !== undefined) {
|
|
1741
|
+
// Rebuild the selection Set in place — selectedRowIds is readonly on
|
|
1742
|
+
// GridSelectionState so we mutate the existing collection rather
|
|
1743
|
+
// than replacing the reference.
|
|
1744
|
+
this.selectionState.selectedRowIds.clear();
|
|
1745
|
+
for (const id of state.selectedRowIds) this.selectionState.selectedRowIds.add(id);
|
|
1746
|
+
this.selectionState.selectAll = false;
|
|
1747
|
+
}
|
|
1748
|
+
if (state.focusedCell !== undefined) {
|
|
1749
|
+
if (state.focusedCell) {
|
|
1750
|
+
this.cellNavLastRowCol = {
|
|
1751
|
+
rowId: state.focusedCell.rowId,
|
|
1752
|
+
columnName: state.focusedCell.columnName,
|
|
1753
|
+
};
|
|
1754
|
+
this.cellNavFocusedCells = [{ ...this.cellNavLastRowCol }];
|
|
1755
|
+
} else {
|
|
1756
|
+
this.cellNavLastRowCol = null;
|
|
1757
|
+
this.cellNavFocusedCells = [];
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
763
1760
|
this.refresh();
|
|
1761
|
+
// Scroll restoration needs to run after render so the scroll container
|
|
1762
|
+
// exists and its scrollHeight is final.
|
|
1763
|
+
if (state.scrollTop !== undefined || state.scrollLeft !== undefined) {
|
|
1764
|
+
this.saveStateScrollRestore?.(state.scrollTop ?? 0, state.scrollLeft ?? 0);
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/** Element injects its scroll accessor so save() can capture scrollTop/
|
|
1769
|
+
* scrollLeft. Controller stays DOM-free. */
|
|
1770
|
+
private saveStateScrollSnapshot: (() => { scrollTop: number; scrollLeft: number }) | null = null;
|
|
1771
|
+
private saveStateScrollRestore: ((scrollTop: number, scrollLeft: number) => void) | null = null;
|
|
1772
|
+
setSaveStateScrollHandlers(
|
|
1773
|
+
snapshot: (() => { scrollTop: number; scrollLeft: number }) | null,
|
|
1774
|
+
restore: ((scrollTop: number, scrollLeft: number) => void) | null,
|
|
1775
|
+
): void {
|
|
1776
|
+
this.saveStateScrollSnapshot = snapshot;
|
|
1777
|
+
this.saveStateScrollRestore = restore;
|
|
764
1778
|
}
|
|
765
1779
|
|
|
766
1780
|
private refresh(): void {
|
|
@@ -790,6 +1804,33 @@ export class VanillaGridController {
|
|
|
790
1804
|
this.visibleColumns = applyWidthOverrides([...pinnedLeft, ...middleColumns, ...pinnedRight]);
|
|
791
1805
|
}
|
|
792
1806
|
|
|
1807
|
+
// Selection row-header column. Injected in front of everything else
|
|
1808
|
+
// (including left-pinned columns) so it always sits at the far left,
|
|
1809
|
+
// mirroring the old grid's addRowHeaderColumn(def, 0). The width is
|
|
1810
|
+
// configurable via selectionRowHeaderWidth.
|
|
1811
|
+
const resolvedSelection = resolveGridSelectionOptions(this.options);
|
|
1812
|
+
if (resolvedSelection.enableRowSelection && resolvedSelection.enableRowHeaderSelection) {
|
|
1813
|
+
const selectionCol: GridColumnDef = {
|
|
1814
|
+
name: 'selectionRowHeaderCol',
|
|
1815
|
+
displayName: '',
|
|
1816
|
+
width: `${resolvedSelection.selectionRowHeaderWidth}px`,
|
|
1817
|
+
minWidth: resolvedSelection.selectionRowHeaderWidth,
|
|
1818
|
+
maxWidth: resolvedSelection.selectionRowHeaderWidth,
|
|
1819
|
+
enableSorting: false,
|
|
1820
|
+
enableFiltering: false,
|
|
1821
|
+
enableGrouping: false,
|
|
1822
|
+
enableCellEdit: false,
|
|
1823
|
+
enablePinning: false,
|
|
1824
|
+
enableColumnResizing: false,
|
|
1825
|
+
enableColumnMoving: false,
|
|
1826
|
+
align: 'center',
|
|
1827
|
+
};
|
|
1828
|
+
// Dedupe if already present (setOptions could be re-entering).
|
|
1829
|
+
if (!this.visibleColumns.some((c) => c.name === 'selectionRowHeaderCol')) {
|
|
1830
|
+
this.visibleColumns = [selectionCol, ...this.visibleColumns];
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
793
1834
|
this.pipeline = defaultGridEngine.buildPipeline({
|
|
794
1835
|
options: this.options,
|
|
795
1836
|
columns: this.visibleColumns,
|
|
@@ -805,6 +1846,16 @@ export class VanillaGridController {
|
|
|
805
1846
|
rowSize: this.getRowSize(),
|
|
806
1847
|
});
|
|
807
1848
|
|
|
1849
|
+
// Re-apply the persisted selection/focus flags to the fresh row instances
|
|
1850
|
+
// produced by the pipeline. Without this, every refresh would wipe the
|
|
1851
|
+
// selected-row indicators because new GridRow objects default to
|
|
1852
|
+
// isSelected=false. Also evaluates isRowSelectable per row.
|
|
1853
|
+
coreReconcileGridSelection(
|
|
1854
|
+
this.selectionState,
|
|
1855
|
+
this.pipeline.visibleRows,
|
|
1856
|
+
this.options.isRowSelectable ?? null,
|
|
1857
|
+
);
|
|
1858
|
+
|
|
808
1859
|
if (!this.apiRegistered) {
|
|
809
1860
|
this.options.onRegisterApi?.(this.gridApi);
|
|
810
1861
|
this.apiRegistered = true;
|
|
@@ -821,6 +1872,12 @@ export class VanillaGridController {
|
|
|
821
1872
|
}
|
|
822
1873
|
|
|
823
1874
|
private findRowById(rowId: string): GridRow | null {
|
|
1875
|
+
// Prefer the pipeline's current row instances — they carry the live
|
|
1876
|
+
// isSelected / isFocused / enableSelection flags that callers mutate.
|
|
1877
|
+
// Fall back to a fresh build when the pipeline is still empty (before
|
|
1878
|
+
// the first refresh).
|
|
1879
|
+
const fromPipeline = coreFindGridRowById(this.pipeline.visibleRows, rowId);
|
|
1880
|
+
if (fromPipeline) return fromPipeline;
|
|
824
1881
|
return coreFindGridRowById(this.buildRowsFromData(this.options.data), rowId);
|
|
825
1882
|
}
|
|
826
1883
|
|
|
@@ -950,6 +2007,460 @@ export class VanillaGridController {
|
|
|
950
2007
|
this.gridApi.core.raise.benchmarkComplete(result);
|
|
951
2008
|
return result;
|
|
952
2009
|
}
|
|
2010
|
+
|
|
2011
|
+
/** Returns the rows that match the requested exporter row-type. Mirrors
|
|
2012
|
+
* the old grid's row selection logic: `all` uses the full data set (or
|
|
2013
|
+
* `exporterAllDataFn` when provided), `visible` uses the current pipeline
|
|
2014
|
+
* output, `selected` uses the selection state. */
|
|
2015
|
+
private exporterRowsFor(rowType: GridExporterRowType): readonly GridRow[] {
|
|
2016
|
+
if (rowType === 'visible') return this.pipeline.visibleRows;
|
|
2017
|
+
if (rowType === 'selected') return this.getSelectedGridRows();
|
|
2018
|
+
// 'all' — fall back to the full grid rows, including filtered-out ones.
|
|
2019
|
+
// The `exporterAllDataFn` escape hatch is left to the caller; we only
|
|
2020
|
+
// bundle the in-memory data here.
|
|
2021
|
+
return this.buildRowsFromData(this.options.data);
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
private resolveExporterOptions(): GridExporterOptions {
|
|
2025
|
+
return { ...resolveGridExporterOptions(this.options), ...this.exporterOverrides };
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
private buildCsv(
|
|
2029
|
+
rowType: GridExporterRowType = 'visible',
|
|
2030
|
+
colType: GridExporterColumnType = 'visible',
|
|
2031
|
+
): string {
|
|
2032
|
+
const exporterOptions = this.resolveExporterOptions();
|
|
2033
|
+
const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
|
|
2034
|
+
return buildGridCsv(columns, this.exporterRowsFor(rowType), exporterOptions, colType);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
private exportCsv(
|
|
2038
|
+
rowType: GridExporterRowType = 'visible',
|
|
2039
|
+
colType: GridExporterColumnType = 'visible',
|
|
2040
|
+
): void {
|
|
2041
|
+
const csv = this.buildCsv(rowType, colType);
|
|
2042
|
+
const resolved = this.resolveExporterOptions();
|
|
2043
|
+
const filename = sanitizeDownloadFilename(
|
|
2044
|
+
resolveExporterFilename(resolved.csvFilename, 'download.csv', rowType, colType),
|
|
2045
|
+
);
|
|
2046
|
+
downloadGridCsvFile(csv, filename);
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
private buildPdfDocDefinition(
|
|
2050
|
+
rowType: GridExporterRowType = 'visible',
|
|
2051
|
+
colType: GridExporterColumnType = 'visible',
|
|
2052
|
+
): GridExporterPdfDocDefinition {
|
|
2053
|
+
const exporterOptions = this.resolveExporterOptions();
|
|
2054
|
+
const pdfOptions = resolveGridExporterPdfOptions(this.options);
|
|
2055
|
+
const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
|
|
2056
|
+
return buildGridPdfDocDefinition(
|
|
2057
|
+
columns,
|
|
2058
|
+
this.exporterRowsFor(rowType),
|
|
2059
|
+
pdfOptions,
|
|
2060
|
+
exporterOptions,
|
|
2061
|
+
colType,
|
|
2062
|
+
);
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
private exportPdf(
|
|
2066
|
+
rowType: GridExporterRowType = 'visible',
|
|
2067
|
+
colType: GridExporterColumnType = 'visible',
|
|
2068
|
+
): GridExporterPdfDocDefinition {
|
|
2069
|
+
const doc = this.buildPdfDocDefinition(rowType, colType);
|
|
2070
|
+
// pdfMake is an optional global (consumers load it separately). When
|
|
2071
|
+
// present, open the generated PDF the same way the old module did;
|
|
2072
|
+
// when missing, just return the doc definition so the caller can
|
|
2073
|
+
// render it themselves. This mirrors `uiGridExporterService.pdfExport`.
|
|
2074
|
+
const win = typeof window !== 'undefined' ? (window as typeof window & {
|
|
2075
|
+
pdfMake?: { createPdf: (doc: GridExporterPdfDocDefinition) => { open: () => void; download: (filename: string) => void } };
|
|
2076
|
+
}) : undefined;
|
|
2077
|
+
if (win?.pdfMake) {
|
|
2078
|
+
const resolved = this.resolveExporterOptions();
|
|
2079
|
+
const pdfOpts = resolveGridExporterPdfOptions(this.options);
|
|
2080
|
+
const filename = resolveExporterFilename(
|
|
2081
|
+
pdfOpts.filename,
|
|
2082
|
+
'download.pdf',
|
|
2083
|
+
rowType,
|
|
2084
|
+
colType,
|
|
2085
|
+
);
|
|
2086
|
+
const pdf = win.pdfMake.createPdf(doc);
|
|
2087
|
+
// Prefer download when the consumer supplied a filename; otherwise
|
|
2088
|
+
// fall back to .open() which pops the pdf in a new browser tab.
|
|
2089
|
+
if (resolved.csvFilename || pdfOpts.filename) {
|
|
2090
|
+
pdf.download(filename);
|
|
2091
|
+
} else {
|
|
2092
|
+
pdf.open();
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
return doc;
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
private buildExcelSheetData(
|
|
2099
|
+
rowType: GridExporterRowType = 'visible',
|
|
2100
|
+
colType: GridExporterColumnType = 'visible',
|
|
2101
|
+
): GridExporterExcelSheetData {
|
|
2102
|
+
const exporterOptions = this.resolveExporterOptions();
|
|
2103
|
+
const columns = colType === 'all' ? this.options.columnDefs : this.visibleColumns;
|
|
2104
|
+
return buildGridExcelSheetData(columns, this.exporterRowsFor(rowType), exporterOptions, colType);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
private exportExcel(
|
|
2108
|
+
rowType: GridExporterRowType = 'visible',
|
|
2109
|
+
colType: GridExporterColumnType = 'visible',
|
|
2110
|
+
): GridExporterExcelSheetData {
|
|
2111
|
+
const sheetData = this.buildExcelSheetData(rowType, colType);
|
|
2112
|
+
// ExcelBuilder is an optional global (consumers load it separately).
|
|
2113
|
+
// When present, produce + download the xlsx the same way the old module
|
|
2114
|
+
// did; when missing, just return the raw sheet data.
|
|
2115
|
+
const win = typeof window !== 'undefined' ? (window as typeof window & {
|
|
2116
|
+
ExcelBuilder?: {
|
|
2117
|
+
Worksheet: new (config: { name: string }) => { setData: (data: unknown) => void };
|
|
2118
|
+
Workbook: new () => { addWorksheet: (sheet: unknown) => void };
|
|
2119
|
+
Builder: { createFile: (workbook: unknown, options: { type: 'blob' }) => Promise<Blob> };
|
|
2120
|
+
};
|
|
2121
|
+
}) : undefined;
|
|
2122
|
+
const ExcelBuilder = win?.ExcelBuilder;
|
|
2123
|
+
if (!ExcelBuilder) {
|
|
2124
|
+
return sheetData;
|
|
2125
|
+
}
|
|
2126
|
+
const excelOptions = resolveGridExporterExcelOptions(this.options);
|
|
2127
|
+
const sheetName = typeof excelOptions.sheetName === 'function'
|
|
2128
|
+
? excelOptions.sheetName(rowType, colType)
|
|
2129
|
+
: (excelOptions.sheetName ?? 'Sheet1');
|
|
2130
|
+
const filename = sanitizeDownloadFilename(
|
|
2131
|
+
resolveExporterFilename(excelOptions.filename, 'download.xlsx', rowType, colType),
|
|
2132
|
+
);
|
|
2133
|
+
const sheet = new ExcelBuilder.Worksheet({ name: sheetName });
|
|
2134
|
+
const workbook = new ExcelBuilder.Workbook();
|
|
2135
|
+
workbook.addWorksheet(sheet);
|
|
2136
|
+
sheet.setData(sheetData);
|
|
2137
|
+
void ExcelBuilder.Builder.createFile(workbook, { type: 'blob' }).then((result) => {
|
|
2138
|
+
if (typeof URL === 'undefined' || typeof document === 'undefined') return;
|
|
2139
|
+
const url = URL.createObjectURL(result);
|
|
2140
|
+
const link = document.createElement('a');
|
|
2141
|
+
link.href = url;
|
|
2142
|
+
link.download = filename;
|
|
2143
|
+
link.click();
|
|
2144
|
+
URL.revokeObjectURL(url);
|
|
2145
|
+
});
|
|
2146
|
+
return sheetData;
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// ---- Validate — ports ui.grid.validate ----------------------------------
|
|
2150
|
+
|
|
2151
|
+
/** Runs the validators declared on `colDef.validators` against the new
|
|
2152
|
+
* value and flips `$$invalid<col>` / `$$errors<col>` on the entity. On
|
|
2153
|
+
* failure raises `validate.validationFailed`. Async validators are
|
|
2154
|
+
* awaited so the caller can `await` for complete results. */
|
|
2155
|
+
private async validateRunValidators(
|
|
2156
|
+
rowEntity: GridRecord,
|
|
2157
|
+
colDef: GridColumnDef,
|
|
2158
|
+
newValue: unknown,
|
|
2159
|
+
oldValue: unknown,
|
|
2160
|
+
): Promise<string[]> {
|
|
2161
|
+
return runGridCellValidators(
|
|
2162
|
+
rowEntity,
|
|
2163
|
+
colDef,
|
|
2164
|
+
newValue,
|
|
2165
|
+
oldValue,
|
|
2166
|
+
this.validatorRegistry,
|
|
2167
|
+
(entity, def, newVal, oldVal) => {
|
|
2168
|
+
this.gridApi.validate.raise.validationFailed(entity, def, newVal, oldVal);
|
|
2169
|
+
},
|
|
2170
|
+
);
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
private validateGetFormattedErrors(rowEntity: GridRecord, colDef: GridColumnDef): string {
|
|
2174
|
+
const errors = getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry);
|
|
2175
|
+
if (errors.length === 0) return '';
|
|
2176
|
+
return `<p><b>${this.labels.validateError}</b></p>${errors.map((msg) => `${msg}<br/>`).join('')}`;
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
private validateGetTitleFormattedErrors(rowEntity: GridRecord, colDef: GridColumnDef): string {
|
|
2180
|
+
const errors = getGridCellErrorMessages(rowEntity, colDef, this.validatorRegistry);
|
|
2181
|
+
if (errors.length === 0) return '';
|
|
2182
|
+
return [this.labels.validateError, ...errors].join('\n');
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
private async validateGetInvalidRows(): Promise<GridRecord[]> {
|
|
2186
|
+
return validateAllGridRows(
|
|
2187
|
+
this.options.data,
|
|
2188
|
+
this.options.columnDefs,
|
|
2189
|
+
this.validatorRegistry,
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
/** Test hook — exposes the validator registry so consumers can inspect or
|
|
2194
|
+
* extend it outside of the gridApi surface (e.g. from a controller-level
|
|
2195
|
+
* plugin). */
|
|
2196
|
+
getValidatorRegistry(): GridValidatorRegistry {
|
|
2197
|
+
return this.validatorRegistry;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
// ---- Importer — ports ui.grid.importer ---------------------------------
|
|
2201
|
+
|
|
2202
|
+
/** Set by the element so `importAFile()` can trigger its file-picker flow.
|
|
2203
|
+
* Controller stays DOM-free: it just invokes whatever handler was wired. */
|
|
2204
|
+
private importerFilePickerRequest: (() => void) | null = null;
|
|
2205
|
+
setImporterFilePickerHandler(handler: (() => void) | null): void {
|
|
2206
|
+
this.importerFilePickerRequest = handler;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
private resolveImporterOptions(): GridImporterOptions {
|
|
2210
|
+
return resolveGridImporterOptions(this.options);
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
private importerRequestFile(): void {
|
|
2214
|
+
this.importerFilePickerRequest?.();
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
private importerDispatchObjects(objects: readonly GridRecord[]): void {
|
|
2218
|
+
if (objects.length === 0) return;
|
|
2219
|
+
const opts = this.resolveImporterOptions();
|
|
2220
|
+
if (opts.dataAddCallback) {
|
|
2221
|
+
opts.dataAddCallback(objects);
|
|
2222
|
+
} else {
|
|
2223
|
+
// No callback configured — append to the in-memory data array so the
|
|
2224
|
+
// grid reflects the imported rows immediately. Matches the
|
|
2225
|
+
// "importerDataAddCallback is required" hint from the old module
|
|
2226
|
+
// but without breaking the common case of a simple local data source.
|
|
2227
|
+
this.options = { ...this.options, data: [...this.options.data, ...objects] };
|
|
2228
|
+
this.refresh();
|
|
2229
|
+
}
|
|
2230
|
+
// If rowEdit is active, flip every freshly-added row dirty so the
|
|
2231
|
+
// consumer's saveRow hook sees them. Mirrors
|
|
2232
|
+
// `service.addObjects → rowEdit.setRowsDirty`.
|
|
2233
|
+
if (this.rowEditState.dirtyRowIds.size >= 0 && this.options.rowEditWaitInterval !== undefined) {
|
|
2234
|
+
// rowEdit is configured — queue the dirty marking on the next
|
|
2235
|
+
// refresh so the new rows are in the pipeline before we flip them.
|
|
2236
|
+
queueMicrotask(() => this.rowEditSetRowsDirty(objects));
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
private importerImportText(text: string, type?: 'json' | 'csv'): void {
|
|
2241
|
+
const opts = this.resolveImporterOptions();
|
|
2242
|
+
// Type inference: explicit `type` wins. Otherwise try JSON first; if
|
|
2243
|
+
// JSON.parse returns null (meaning invalidJson already reported) AND
|
|
2244
|
+
// the caller didn't force JSON, fall through to CSV. The old module
|
|
2245
|
+
// decided purely based on MIME type; `importText` is a newer entry
|
|
2246
|
+
// point we provide for drag/drop flows.
|
|
2247
|
+
const asJson = type === 'csv' ? null : parseGridImporterJson(text, { errorCallback: type === 'json' ? opts.errorCallback : undefined });
|
|
2248
|
+
if (asJson && asJson.length > 0) {
|
|
2249
|
+
const objects = buildGridImporterObjectsFromJson(asJson, opts);
|
|
2250
|
+
this.importerDispatchObjects(objects);
|
|
2251
|
+
return;
|
|
2252
|
+
}
|
|
2253
|
+
if (type === 'json') return;
|
|
2254
|
+
|
|
2255
|
+
const csv = parseGridImporterCsv(text);
|
|
2256
|
+
if (!csv) {
|
|
2257
|
+
opts.errorCallback?.(
|
|
2258
|
+
'importer.invalidCsv',
|
|
2259
|
+
'File could not be processed, is it valid csv? Content was: ',
|
|
2260
|
+
text,
|
|
2261
|
+
);
|
|
2262
|
+
return;
|
|
2263
|
+
}
|
|
2264
|
+
const objects = buildGridImporterObjectsFromCsv(csv, this.options.columnDefs, opts);
|
|
2265
|
+
if (!objects || objects.length === 0) {
|
|
2266
|
+
opts.errorCallback?.(
|
|
2267
|
+
'importer.noObjects',
|
|
2268
|
+
'Objects were not able to be derived, content was: ',
|
|
2269
|
+
text,
|
|
2270
|
+
);
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
this.importerDispatchObjects(objects);
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
private importerImportThisFile(file: File): Promise<void> {
|
|
2277
|
+
return new Promise((resolve) => {
|
|
2278
|
+
if (typeof FileReader === 'undefined') {
|
|
2279
|
+
resolve();
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
const reader = new FileReader();
|
|
2283
|
+
reader.onload = () => {
|
|
2284
|
+
const text = typeof reader.result === 'string' ? reader.result : '';
|
|
2285
|
+
// MIME sniff: old module switched on file.type, with `application/json`
|
|
2286
|
+
// as JSON and everything else as CSV. Preserve that default while
|
|
2287
|
+
// still letting the auto-detect path in `importerImportText` recover
|
|
2288
|
+
// when a consumer stores JSON with the wrong MIME.
|
|
2289
|
+
const type: 'json' | 'csv' = file.type === 'application/json' ? 'json' : 'csv';
|
|
2290
|
+
this.importerImportText(text, type);
|
|
2291
|
+
resolve();
|
|
2292
|
+
};
|
|
2293
|
+
reader.onerror = () => resolve();
|
|
2294
|
+
reader.readAsText(file);
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
private buildExporterMenuItems(): GridExporterMenuItem[] {
|
|
2299
|
+
// Pass the full resolved label bundle — the menu module pulls only
|
|
2300
|
+
// the `exporter*` keys it needs via `Partial<GridLabels>`.
|
|
2301
|
+
return buildGridExporterMenuItems(
|
|
2302
|
+
this.options,
|
|
2303
|
+
this.labels,
|
|
2304
|
+
{
|
|
2305
|
+
csvExport: (rowType, colType) => this.exportCsv(rowType, colType),
|
|
2306
|
+
pdfExport: (rowType, colType) => {
|
|
2307
|
+
this.exportPdf(rowType, colType);
|
|
2308
|
+
},
|
|
2309
|
+
excelExport: (rowType, colType) => {
|
|
2310
|
+
this.exportExcel(rowType, colType);
|
|
2311
|
+
},
|
|
2312
|
+
},
|
|
2313
|
+
() => this.selectionState.selectedRowIds.size > 0,
|
|
2314
|
+
);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
/** Menu items the importer contributes. Just the single "Import"
|
|
2318
|
+
* entry when the feature is enabled; returns an empty array otherwise. */
|
|
2319
|
+
private buildImporterMenuItems(): GridMenuItem[] {
|
|
2320
|
+
return buildGridImporterMenuItems(
|
|
2321
|
+
this.options,
|
|
2322
|
+
this.labels,
|
|
2323
|
+
{ importAFile: () => this.importerRequestFile() },
|
|
2324
|
+
);
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/** Menu items row-edit contributes: "Save changes" + "Retry errored
|
|
2328
|
+
* rows". Each entry is gated by both a gridOption flag and a runtime
|
|
2329
|
+
* predicate (`hasDirtyRows` / `hasErrorRows`) so the menu only surfaces
|
|
2330
|
+
* actions that have something to do. */
|
|
2331
|
+
private buildRowEditMenuItems(): GridMenuItem[] {
|
|
2332
|
+
return buildGridRowEditMenuItems(
|
|
2333
|
+
this.options,
|
|
2334
|
+
this.labels,
|
|
2335
|
+
{
|
|
2336
|
+
flushDirtyRows: () => this.rowEditFlushDirtyRows(),
|
|
2337
|
+
retryErroredRows: () => this.rowEditRetryErroredRows(),
|
|
2338
|
+
},
|
|
2339
|
+
{
|
|
2340
|
+
hasDirtyRows: () => this.rowEditState.dirtyRowIds.size > 0,
|
|
2341
|
+
hasErrorRows: () => this.rowEditState.errorRowIds.size > 0,
|
|
2342
|
+
},
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// ---- Row-edit — ports ui.grid.rowEdit -----------------------------------
|
|
2347
|
+
|
|
2348
|
+
private rowEditSetSavePromise(rowEntity: GridRecord, savePromise: Promise<void>): void {
|
|
2349
|
+
const rowId = this.resolveRowId(rowEntity);
|
|
2350
|
+
this.rowEditSavePromiseOverrides.set(rowId, savePromise);
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
private rowEditGetDirtyRows(): GridRow[] {
|
|
2354
|
+
return this.pipeline.visibleRows.filter((row) =>
|
|
2355
|
+
this.rowEditState.dirtyRowIds.has(row.id),
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
private rowEditGetErrorRows(): GridRow[] {
|
|
2360
|
+
return this.pipeline.visibleRows.filter((row) =>
|
|
2361
|
+
this.rowEditState.errorRowIds.has(row.id),
|
|
2362
|
+
);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
private rowEditFlushDirtyRows(): Promise<void> {
|
|
2366
|
+
const dirty = [...this.rowEditState.dirtyRowIds]
|
|
2367
|
+
.map((id) => this.findRowById(id))
|
|
2368
|
+
.filter((row): row is GridRow => row !== undefined);
|
|
2369
|
+
const promises = dirty.map((row) => this.runRowEditSave(row));
|
|
2370
|
+
return Promise.all(promises).then(() => undefined);
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/** Re-fires `saveRow` for every row currently in the error state. The
|
|
2374
|
+
* "Retry errored rows" menu entry funnels through here. Returns a
|
|
2375
|
+
* promise that resolves once every retry promise has settled. */
|
|
2376
|
+
private rowEditRetryErroredRows(): Promise<void> {
|
|
2377
|
+
const errored = [...this.rowEditState.errorRowIds]
|
|
2378
|
+
.map((id) => this.findRowById(id))
|
|
2379
|
+
.filter((row): row is GridRow => row !== undefined);
|
|
2380
|
+
const promises = errored.map((row) => this.runRowEditSave(row));
|
|
2381
|
+
return Promise.all(promises).then(() => undefined);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
private rowEditSetRowsDirty(rowEntities: readonly GridRecord[]): void {
|
|
2385
|
+
for (const entity of rowEntities) {
|
|
2386
|
+
const rowId = this.resolveRowId(entity);
|
|
2387
|
+
const row = this.findRowById(rowId);
|
|
2388
|
+
if (!row) continue;
|
|
2389
|
+
markGridRowDirty(this.rowEditState, row);
|
|
2390
|
+
this.considerSetRowEditTimer(row);
|
|
2391
|
+
}
|
|
2392
|
+
this.emit();
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
private rowEditSetRowsClean(rowEntities: readonly GridRecord[]): void {
|
|
2396
|
+
for (const entity of rowEntities) {
|
|
2397
|
+
const rowId = this.resolveRowId(entity);
|
|
2398
|
+
const row = this.findRowById(rowId);
|
|
2399
|
+
if (!row) continue;
|
|
2400
|
+
markGridRowClean(this.rowEditState, row);
|
|
2401
|
+
this.cancelRowEditTimer(row);
|
|
2402
|
+
}
|
|
2403
|
+
this.emit();
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
private considerSetRowEditTimer(row: GridRow): void {
|
|
2407
|
+
this.cancelRowEditTimer(row);
|
|
2408
|
+
if (!row.isDirty || row.isSaving) return;
|
|
2409
|
+
const waitInterval = this.options.rowEditWaitInterval;
|
|
2410
|
+
if (!isGridRowEditTimerEnabled(waitInterval)) return;
|
|
2411
|
+
const effective = resolveGridRowEditWaitInterval(waitInterval);
|
|
2412
|
+
const timer = setTimeout(() => {
|
|
2413
|
+
this.rowEditTimers.delete(row.id);
|
|
2414
|
+
void this.runRowEditSave(row);
|
|
2415
|
+
}, effective);
|
|
2416
|
+
this.rowEditTimers.set(row.id, timer);
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
private cancelRowEditTimer(row: GridRow): void {
|
|
2420
|
+
const timer = this.rowEditTimers.get(row.id);
|
|
2421
|
+
if (timer !== undefined) {
|
|
2422
|
+
clearTimeout(timer);
|
|
2423
|
+
this.rowEditTimers.delete(row.id);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
private runRowEditSave(row: GridRow): Promise<void> {
|
|
2428
|
+
if (row.isSaving) {
|
|
2429
|
+
// Re-entrance — return the in-flight promise so `flushDirtyRows`
|
|
2430
|
+
// still awaits the existing save. Matches the old module's
|
|
2431
|
+
// `rowEditSavePromise` short-circuit.
|
|
2432
|
+
return this.rowEditState.savePromises.get(row.id) ?? Promise.resolve();
|
|
2433
|
+
}
|
|
2434
|
+
markGridRowSaving(this.rowEditState, row);
|
|
2435
|
+
this.rowEditSavePromiseOverrides.delete(row.id);
|
|
2436
|
+
this.gridApi.rowEdit.raise.saveRow(row.entity);
|
|
2437
|
+
const savePromise =
|
|
2438
|
+
this.rowEditSavePromiseOverrides.get(row.id) ?? Promise.resolve();
|
|
2439
|
+
this.rowEditState.savePromises.set(row.id, savePromise);
|
|
2440
|
+
this.emit();
|
|
2441
|
+
return savePromise
|
|
2442
|
+
.then(() => {
|
|
2443
|
+
markGridRowClean(this.rowEditState, row);
|
|
2444
|
+
this.emit();
|
|
2445
|
+
})
|
|
2446
|
+
.catch(() => {
|
|
2447
|
+
markGridRowError(this.rowEditState, row);
|
|
2448
|
+
this.emit();
|
|
2449
|
+
});
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/** Test hook — reports the current row-edit dirty / error / saving state. */
|
|
2453
|
+
getRowEditState(): {
|
|
2454
|
+
dirtyRowIds: string[];
|
|
2455
|
+
errorRowIds: string[];
|
|
2456
|
+
savingRowIds: string[];
|
|
2457
|
+
} {
|
|
2458
|
+
return {
|
|
2459
|
+
dirtyRowIds: [...this.rowEditState.dirtyRowIds],
|
|
2460
|
+
errorRowIds: [...this.rowEditState.errorRowIds],
|
|
2461
|
+
savingRowIds: [...this.rowEditState.savingRowIds],
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
953
2464
|
}
|
|
954
2465
|
|
|
955
2466
|
export function createVanillaGridController(options: GridOptions): VanillaGridController {
|