@ornery/ui-grid-vanilla 0.1.10 → 1.0.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/README.md +196 -0
- package/demo/main.ts +0 -1
- package/dist/index.d.mts +575 -55
- package/dist/index.d.ts +575 -55
- package/dist/index.js +4894 -1503
- package/dist/index.mjs +4994 -1556
- 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 +206 -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 +815 -0
- package/src/exporter.test.ts +186 -0
- package/src/focus.ts +353 -0
- package/src/framework-slots.ts +440 -0
- package/src/grid-controller.ts +1402 -18
- 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 +741 -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/render.ts
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure markup builders used by the grid element's initial-render and patch
|
|
3
|
+
* paths. Every function here returns an HTML string — none of them touch
|
|
4
|
+
* the DOM or the element's scroll state. The element's top-level `render()`
|
|
5
|
+
* / `renderFull()` / `renderPatch()` orchestrators wire these into the
|
|
6
|
+
* actual mount pipeline.
|
|
7
|
+
*
|
|
8
|
+
* The element is passed in as the first argument; these functions read its
|
|
9
|
+
* internal state (controller / snapshot / focusedCell / …) directly.
|
|
10
|
+
* TypeScript's @internal JSDoc markers on those fields document the
|
|
11
|
+
* coupling — nothing outside this package should depend on the fields.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
SORT_DIRECTIONS,
|
|
16
|
+
buildGridHeaderContext,
|
|
17
|
+
canGridMoveColumns,
|
|
18
|
+
formatGridHeaderDisplayValue,
|
|
19
|
+
getCellValue,
|
|
20
|
+
type DisplayItem,
|
|
21
|
+
type GridColumnDef,
|
|
22
|
+
type GridOptions,
|
|
23
|
+
type GridRecord,
|
|
24
|
+
type GridRow,
|
|
25
|
+
} from '@ornery/ui-grid-core';
|
|
26
|
+
import type { GridControllerSnapshot } from './grid-controller';
|
|
27
|
+
import {
|
|
28
|
+
cellSlotNameForRow,
|
|
29
|
+
expandableRowSlotName,
|
|
30
|
+
filterSlotName,
|
|
31
|
+
groupRowSlotName,
|
|
32
|
+
headerSlotName,
|
|
33
|
+
} from './framework-slots';
|
|
34
|
+
import {
|
|
35
|
+
cellEditorMarkup,
|
|
36
|
+
cellValueMarkup,
|
|
37
|
+
defaultExpandableMarkup,
|
|
38
|
+
emptyDataMarkup,
|
|
39
|
+
expandToggleMarkup,
|
|
40
|
+
expandableRowMarkup,
|
|
41
|
+
resizerMarkup,
|
|
42
|
+
slotRegistryMarkup,
|
|
43
|
+
treeToggleMarkup,
|
|
44
|
+
} from './templates';
|
|
45
|
+
import type { UiGridStandaloneElement } from './ui-grid-standalone.element';
|
|
46
|
+
import { bodyCellClass, headerCellClass } from './utils/cell-class';
|
|
47
|
+
import { asGroupItem, isRowItem } from './utils/display-items';
|
|
48
|
+
import { escapeHtml } from './utils/dom';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Render a single column's header cell. Emits the selection-row-header
|
|
52
|
+
* variant, the framework-rendered `<slot>` variant, or the full
|
|
53
|
+
* header-cell markup with sort / group / pin / resize chrome.
|
|
54
|
+
*/
|
|
55
|
+
export function renderHeaderCell(
|
|
56
|
+
el: UiGridStandaloneElement,
|
|
57
|
+
column: GridColumnDef,
|
|
58
|
+
sortEnabled: boolean,
|
|
59
|
+
groupingEnabled: boolean,
|
|
60
|
+
pinningEnabled: boolean,
|
|
61
|
+
options: GridOptions,
|
|
62
|
+
): string {
|
|
63
|
+
const controller = el.controller!;
|
|
64
|
+
// Row-header selection column — emit a select-all checkbox instead of
|
|
65
|
+
// the normal column header controls.
|
|
66
|
+
if (column.name === 'selectionRowHeaderCol') {
|
|
67
|
+
const resolvedSel = controller.getResolvedSelectionOptions();
|
|
68
|
+
const selectAll = el.snapshot?.selectAll === true;
|
|
69
|
+
const showSelectAll = resolvedSel.enableSelectAll && resolvedSel.multiSelect;
|
|
70
|
+
const inner = showSelectAll
|
|
71
|
+
? `<button type="button" class="ui-grid-selection-select-all" data-action="select-all" aria-label="Select all rows"${selectAll ? ' aria-checked="true"' : ' aria-checked="false"'}><span class="ui-grid-selection-checkbox${selectAll ? ' ui-grid-selection-checkbox-checked' : ''}"></span></button>`
|
|
72
|
+
: '';
|
|
73
|
+
return `<ui-grid-header-cell class="header-cell ui-grid-selection-row-header" data-column="selectionRowHeaderCol">${inner}</ui-grid-header-cell>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Framework-rendered header: emit a bare cell with a slot placeholder.
|
|
77
|
+
// Sort / group / pin chrome is surrendered to the wrapper — consumers
|
|
78
|
+
// who just want to restyle the label should keep the default path.
|
|
79
|
+
if (el.frameworkSlots.hasHeader(column.name)) {
|
|
80
|
+
el.frameworkSlots.stageHeader(column);
|
|
81
|
+
const slotName = headerSlotName(column);
|
|
82
|
+
const isPinned = controller.isPinned(column);
|
|
83
|
+
const pinOffset = isPinned ? controller.pinnedOffset(column) : null;
|
|
84
|
+
const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
|
|
85
|
+
return `<ui-grid-header-cell class="header-cell"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''} data-column="${escapeHtml(column.name)}" data-pinned="${isPinned}" data-sticky-style="${escapeHtml(stickyStyle)}"><slot name="${escapeHtml(slotName)}"></slot></ui-grid-header-cell>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sortDirection = controller.getSortDirection(column);
|
|
89
|
+
const sortLabel = controller.sortButtonLabel(column);
|
|
90
|
+
const groupingLabel = controller.groupingButtonLabel(column);
|
|
91
|
+
const canSort = sortEnabled && controller.isColumnSortable(column);
|
|
92
|
+
const canGroup = groupingEnabled && column.enableGrouping !== false;
|
|
93
|
+
const canPin = pinningEnabled && controller.isColumnPinnable(column);
|
|
94
|
+
const isPinned = controller.isPinned(column);
|
|
95
|
+
const pinOffset = isPinned ? controller.pinnedOffset(column) : null;
|
|
96
|
+
const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
|
|
97
|
+
|
|
98
|
+
const sortIconKey =
|
|
99
|
+
sortDirection === SORT_DIRECTIONS.asc
|
|
100
|
+
? 'sortAsc'
|
|
101
|
+
: sortDirection === SORT_DIRECTIONS.desc
|
|
102
|
+
? 'sortDesc'
|
|
103
|
+
: 'sortNone';
|
|
104
|
+
|
|
105
|
+
const pinLabel = isPinned
|
|
106
|
+
? (el.snapshot?.labels.unpin ?? 'Unpin')
|
|
107
|
+
: (el.snapshot?.labels.pinColumn ?? 'Pin');
|
|
108
|
+
const canResize = controller.canResizeColumns();
|
|
109
|
+
const headerValue = escapeHtml(formatGridHeaderDisplayValue(buildGridHeaderContext(column)));
|
|
110
|
+
const resizerHtml = canResize
|
|
111
|
+
? resizerMarkup(escapeHtml(column.name), escapeHtml(headerValue))
|
|
112
|
+
: '';
|
|
113
|
+
|
|
114
|
+
const isPinnedLeftLast = controller.isPinnedLeftLast(column);
|
|
115
|
+
const isPinnedRightFirst = controller.isPinnedRightFirst(column);
|
|
116
|
+
const isPinMenuOpen = el.openPinMenuColumn === column.name;
|
|
117
|
+
const isDragTarget = el.dropTargetColumnName === column.name;
|
|
118
|
+
const isDragging = el.draggedColumnName === column.name;
|
|
119
|
+
const isDraggable = canGridMoveColumns(options);
|
|
120
|
+
const className = headerCellClass(
|
|
121
|
+
sortDirection !== SORT_DIRECTIONS.none,
|
|
122
|
+
isPinned,
|
|
123
|
+
isPinnedLeftLast,
|
|
124
|
+
isPinnedRightFirst,
|
|
125
|
+
isPinMenuOpen,
|
|
126
|
+
isDragTarget,
|
|
127
|
+
isDragging,
|
|
128
|
+
);
|
|
129
|
+
// className/style/draggable are pre-computed here so the <ui-grid-header-cell>
|
|
130
|
+
// upgrade is a no-op at parse time. `data-*` stay for event delegation.
|
|
131
|
+
return `<ui-grid-header-cell class="${className}"${isDraggable ? ' draggable="true"' : ''}${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''} data-column="${escapeHtml(column.name)}" data-sort-active="${sortDirection !== SORT_DIRECTIONS.none}" data-pinned="${isPinned}" data-pinned-left-last="${isPinnedLeftLast}" data-pinned-right-first="${isPinnedRightFirst}" data-pin-menu-open="${isPinMenuOpen}" data-drag-target="${isDragTarget}" data-dragging="${isDragging}" data-draggable="${isDraggable}" data-sticky-style="${escapeHtml(stickyStyle)}"><span class="header-label">${headerValue}</span><span class="header-actions">${sortEnabled ? `<button type="button" class="header-action" data-action="sort" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(sortLabel)}" ${canSort ? '' : 'disabled'}>${el.icons.renderControlIcon(sortIconKey)}<span class="sr-only">${escapeHtml(sortLabel)}</span></button>` : ''}${groupingEnabled ? `<button type="button" class="chip-action${controller.isColumnGrouped(column) ? ' chip-action-active' : ''}" data-action="group" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(groupingLabel)}" ${canGroup ? '' : 'disabled'}>${el.icons.renderControlIcon('group')}<span class="sr-only">${escapeHtml(groupingLabel)}</span></button>` : ''}${canPin ? `<div class="pin-control${isPinMenuOpen ? ' pin-control-open' : ''}"><button type="button" class="chip-action pin-trigger${isPinned ? ' chip-action-active' : ''}" data-action="pin-trigger" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(pinLabel)}">${el.icons.renderControlIcon('pin')}<span class="sr-only">${escapeHtml(pinLabel)}</span></button><div class="pin-menu" role="menu" aria-label="${escapeHtml(pinLabel)}"><button type="button" class="pin-menu-action" data-action="pin-left" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(el.snapshot?.labels.pinLeft ?? 'Pin left')}">${el.icons.renderIconWithClass('control-icon', 'pinLeft')}<span class="sr-only">${escapeHtml(el.snapshot?.labels.pinLeft ?? 'Pin left')}</span></button><button type="button" class="pin-menu-action" data-action="pin-right" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(el.snapshot?.labels.pinRight ?? 'Pin right')}">${el.icons.renderIconWithClass('control-icon', 'pinRight')}<span class="sr-only">${escapeHtml(el.snapshot?.labels.pinRight ?? 'Pin right')}</span></button></div></div>` : ''}</span>${resizerHtml}</ui-grid-header-cell>`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Render the filter-row cell for one column. Selection column gets a bare
|
|
135
|
+
* spacer div; framework-rendered columns get a `<slot>`; everything else
|
|
136
|
+
* renders a `<ui-grid-filter-cell>` component with data-attributes. */
|
|
137
|
+
export function renderFilterCell(el: UiGridStandaloneElement, column: GridColumnDef): string {
|
|
138
|
+
if (column.name === 'selectionRowHeaderCol') {
|
|
139
|
+
const pinOffset = el.controller!.isPinned(column) ? el.controller!.pinnedOffset(column) : null;
|
|
140
|
+
const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
|
|
141
|
+
return `<div class="filter-cell ui-grid-selection-row-header" data-column="selectionRowHeaderCol"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}></div>`;
|
|
142
|
+
}
|
|
143
|
+
const value = el.snapshot?.activeFilters[column.name] ?? '';
|
|
144
|
+
const controller = el.controller!;
|
|
145
|
+
const canFilter = controller.isColumnFilterable(column);
|
|
146
|
+
const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
|
|
147
|
+
const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
|
|
148
|
+
|
|
149
|
+
if (el.frameworkSlots.hasFilter(column.name)) {
|
|
150
|
+
el.frameworkSlots.stageFilter(column, value, controller.filterPlaceholder(column), !canFilter);
|
|
151
|
+
const slotName = filterSlotName(column);
|
|
152
|
+
return `<div class="filter-cell ui-grid-filter-container" data-column="${escapeHtml(column.name)}"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}><slot name="${escapeHtml(slotName)}"></slot></div>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return `<ui-grid-filter-cell data-column="${escapeHtml(column.name)}" data-value="${escapeHtml(value)}" data-placeholder="${escapeHtml(controller.filterPlaceholder(column))}" data-disabled="${!canFilter}" data-pinned="${controller.isPinned(column)}" data-pinned-left-last="${controller.isPinnedLeftLast(column)}" data-pinned-right-first="${controller.isPinnedRightFirst(column)}" data-sticky-style="${escapeHtml(stickyStyle)}"></ui-grid-filter-cell>`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Render one display-item: group row, expandable row, or the cells of a
|
|
159
|
+
* regular data row. */
|
|
160
|
+
export function renderDisplayItem(
|
|
161
|
+
el: UiGridStandaloneElement,
|
|
162
|
+
item: DisplayItem,
|
|
163
|
+
displayIndex: number,
|
|
164
|
+
): string {
|
|
165
|
+
if (!el.snapshot || !el.controller) return '';
|
|
166
|
+
|
|
167
|
+
if (item.kind === 'group') {
|
|
168
|
+
const group = asGroupItem(item);
|
|
169
|
+
if (el.frameworkSlots.hasGroupRow()) {
|
|
170
|
+
el.frameworkSlots.stageGroupRow(group);
|
|
171
|
+
const slotName = groupRowSlotName(group);
|
|
172
|
+
return `<div class="group-row ui-grid-row ui-grid-group-row" data-group="${escapeHtml(group.id)}" style="grid-column:1 / -1"><slot name="${escapeHtml(slotName)}"></slot></div>`;
|
|
173
|
+
}
|
|
174
|
+
const iconKey = group.collapsed ? 'groupCollapsed' : 'groupExpanded';
|
|
175
|
+
const icon = el.icons.resolve(iconKey);
|
|
176
|
+
const disclosureLabel = el.controller.groupDisclosureLabel(group);
|
|
177
|
+
return `<ui-grid-group-row data-action="toggle-group" data-group="${escapeHtml(group.id)}" data-collapsed="${group.collapsed ? 'true' : 'false'}" data-field="${escapeHtml(group.field)}" data-label="${escapeHtml(group.label)}" data-count="${group.count}" data-depth="${group.depth}" data-disclosure-label="${escapeHtml(disclosureLabel)}" data-icon-path="${icon.path}" data-icon-view-box="${icon.viewBox ?? '0 0 24 24'}" data-rows-suffix="${escapeHtml(el.snapshot.labels.groupRowsSuffix)}"></ui-grid-group-row>`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (item.kind === 'expandable') {
|
|
181
|
+
const row = (item as DisplayItem & { row: GridRow }).row;
|
|
182
|
+
return expandableRowMarkup(renderExpandableTemplate(el, row));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (!isRowItem(item)) return '';
|
|
186
|
+
|
|
187
|
+
return el.snapshot.visibleColumns
|
|
188
|
+
.map((column) => renderBodyCell(el, item.row, column, displayIndex))
|
|
189
|
+
.join('');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Render one body cell including its pin / focus / edit / validation
|
|
193
|
+
* chrome. The inner `.cell-content` is produced by `renderCellTemplate`. */
|
|
194
|
+
export function renderBodyCell(
|
|
195
|
+
el: UiGridStandaloneElement,
|
|
196
|
+
row: GridRow,
|
|
197
|
+
column: GridColumnDef,
|
|
198
|
+
displayIndex: number,
|
|
199
|
+
): string {
|
|
200
|
+
if (!el.snapshot || !el.controller) return '';
|
|
201
|
+
|
|
202
|
+
const controller = el.controller;
|
|
203
|
+
const rowId = row.id;
|
|
204
|
+
const columnName = column.name;
|
|
205
|
+
const editing = controller.isEditingCell(rowId, columnName);
|
|
206
|
+
const pinOffset = controller.isPinned(column) ? controller.pinnedOffset(column) : null;
|
|
207
|
+
const treeToggle = controller.showTreeToggle(row, column)
|
|
208
|
+
? (() => {
|
|
209
|
+
const treeIconKey = controller.isTreeRowExpanded(row) ? 'treeExpanded' : 'treeCollapsed';
|
|
210
|
+
const treeIcon = el.icons.resolve(treeIconKey);
|
|
211
|
+
return treeToggleMarkup(
|
|
212
|
+
escapeHtml(rowId),
|
|
213
|
+
escapeHtml(controller.treeToggleLabel(row)),
|
|
214
|
+
treeIcon.viewBox ?? '0 0 24 24',
|
|
215
|
+
treeIcon.path,
|
|
216
|
+
);
|
|
217
|
+
})()
|
|
218
|
+
: '';
|
|
219
|
+
const expandToggle = controller.showExpandToggle(row, column)
|
|
220
|
+
? (() => {
|
|
221
|
+
const expIconKey = row.expanded ? 'expandExpanded' : 'expandCollapsed';
|
|
222
|
+
const expIcon = el.icons.resolve(expIconKey);
|
|
223
|
+
return expandToggleMarkup(
|
|
224
|
+
escapeHtml(rowId),
|
|
225
|
+
escapeHtml(controller.expandToggleLabel(row)),
|
|
226
|
+
expIcon.viewBox ?? '0 0 24 24',
|
|
227
|
+
expIcon.path,
|
|
228
|
+
);
|
|
229
|
+
})()
|
|
230
|
+
: '';
|
|
231
|
+
|
|
232
|
+
const content = editing
|
|
233
|
+
? cellEditorMarkup(
|
|
234
|
+
escapeHtml(rowId),
|
|
235
|
+
escapeHtml(columnName),
|
|
236
|
+
escapeHtml(controller.editorInputType(column)),
|
|
237
|
+
escapeHtml(el.snapshot.editingValue),
|
|
238
|
+
)
|
|
239
|
+
: renderCellTemplate(el, row, column, displayIndex);
|
|
240
|
+
|
|
241
|
+
const isFocused =
|
|
242
|
+
el.focusedCell?.rowId === rowId && el.focusedCell.columnName === columnName;
|
|
243
|
+
const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
|
|
244
|
+
const isPinned = controller.isPinned(column);
|
|
245
|
+
const isPinnedLeftLast = controller.isPinnedLeftLast(column);
|
|
246
|
+
const isPinnedRightFirst = controller.isPinnedRightFirst(column);
|
|
247
|
+
const align = column.align ?? '';
|
|
248
|
+
const isRowSelected = el.snapshot?.selectedRowIds.has(rowId) ?? false;
|
|
249
|
+
const isRowFocused = el.snapshot?.focusedRowId === rowId;
|
|
250
|
+
const isCellInvalid = controller.gridApi.validate.isInvalid(row.entity, column);
|
|
251
|
+
const className = bodyCellClass(
|
|
252
|
+
displayIndex % 2 !== 0,
|
|
253
|
+
align,
|
|
254
|
+
isPinned,
|
|
255
|
+
isPinnedLeftLast,
|
|
256
|
+
isPinnedRightFirst,
|
|
257
|
+
isFocused,
|
|
258
|
+
editing,
|
|
259
|
+
isRowSelected,
|
|
260
|
+
isRowFocused,
|
|
261
|
+
row.isDirty,
|
|
262
|
+
row.isSaving,
|
|
263
|
+
row.isError,
|
|
264
|
+
isCellInvalid,
|
|
265
|
+
);
|
|
266
|
+
// When invalid, surface the joined validator messages as a title attr —
|
|
267
|
+
// matches the old `getTitleFormattedErrors` contract.
|
|
268
|
+
const titleAttr = isCellInvalid
|
|
269
|
+
? ` title="${escapeHtml(controller.gridApi.validate.getTitleFormattedErrors(row.entity, column))}"`
|
|
270
|
+
: '';
|
|
271
|
+
return `<ui-grid-body-cell class="${className}" tabindex="0"${stickyStyle ? ` style="${escapeHtml(stickyStyle)}"` : ''}${titleAttr} data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" data-odd="${displayIndex % 2 !== 0}" data-align="${escapeHtml(align)}" data-pinned="${isPinned}" data-pinned-left-last="${isPinnedLeftLast}" data-pinned-right-first="${isPinnedRightFirst}" data-focused="${isFocused}" data-editing="${editing}" data-sticky-style="${escapeHtml(stickyStyle)}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}<div class="cell-content">${content}</div></div></ui-grid-body-cell>`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Render the contents of `.cell-shell` (tree/expand toggles + the
|
|
275
|
+
* interpolated cell content). Used by the patch path to replace inner
|
|
276
|
+
* HTML without rebuilding the body-cell wrapper. */
|
|
277
|
+
export function renderCellShellContents(
|
|
278
|
+
el: UiGridStandaloneElement,
|
|
279
|
+
row: GridRow,
|
|
280
|
+
column: GridColumnDef,
|
|
281
|
+
displayIndex: number,
|
|
282
|
+
editing: boolean,
|
|
283
|
+
templateMarkupMap?: Map<string, string | null>,
|
|
284
|
+
): string {
|
|
285
|
+
const controller = el.controller!;
|
|
286
|
+
const rowId = row.id;
|
|
287
|
+
const columnName = column.name;
|
|
288
|
+
const treeToggle = controller.showTreeToggle(row, column)
|
|
289
|
+
? (() => {
|
|
290
|
+
const treeIconKey = controller.isTreeRowExpanded(row) ? 'treeExpanded' : 'treeCollapsed';
|
|
291
|
+
const treeIcon = el.icons.resolve(treeIconKey);
|
|
292
|
+
return treeToggleMarkup(
|
|
293
|
+
escapeHtml(rowId),
|
|
294
|
+
escapeHtml(controller.treeToggleLabel(row)),
|
|
295
|
+
treeIcon.viewBox ?? '0 0 24 24',
|
|
296
|
+
treeIcon.path,
|
|
297
|
+
);
|
|
298
|
+
})()
|
|
299
|
+
: '';
|
|
300
|
+
const expandToggle = controller.showExpandToggle(row, column)
|
|
301
|
+
? (() => {
|
|
302
|
+
const expIconKey = row.expanded ? 'expandExpanded' : 'expandCollapsed';
|
|
303
|
+
const expIcon = el.icons.resolve(expIconKey);
|
|
304
|
+
return expandToggleMarkup(
|
|
305
|
+
escapeHtml(rowId),
|
|
306
|
+
escapeHtml(controller.expandToggleLabel(row)),
|
|
307
|
+
expIcon.viewBox ?? '0 0 24 24',
|
|
308
|
+
expIcon.path,
|
|
309
|
+
);
|
|
310
|
+
})()
|
|
311
|
+
: '';
|
|
312
|
+
const content = editing
|
|
313
|
+
? cellEditorMarkup(
|
|
314
|
+
escapeHtml(rowId),
|
|
315
|
+
escapeHtml(columnName),
|
|
316
|
+
escapeHtml(controller.editorInputType(column)),
|
|
317
|
+
escapeHtml(el.snapshot?.editingValue ?? ''),
|
|
318
|
+
)
|
|
319
|
+
: templateMarkupMap
|
|
320
|
+
? renderCellTemplateFromMarkup(el, row, column, displayIndex, templateMarkupMap.get(columnName) ?? null)
|
|
321
|
+
: renderCellTemplate(el, row, column, displayIndex);
|
|
322
|
+
return `${treeToggle}${expandToggle}<div class="cell-content">${content}</div>`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Pagination footer markup. Reads page-size options + labels + icons off
|
|
326
|
+
* the snapshot; emits a `<ui-grid-pagination>` component with data-* attrs. */
|
|
327
|
+
export function renderPagination(
|
|
328
|
+
el: UiGridStandaloneElement,
|
|
329
|
+
snapshot: GridControllerSnapshot,
|
|
330
|
+
): string {
|
|
331
|
+
const pageSizes = snapshot.options.paginationPageSizes ?? [10, 25, 50, 100];
|
|
332
|
+
const prevIcon = el.icons.resolve('paginationPrev');
|
|
333
|
+
const nextIcon = el.icons.resolve('paginationNext');
|
|
334
|
+
return `<ui-grid-pagination data-range-label="${escapeHtml(`${snapshot.firstRowIndex + 1}-${snapshot.lastRowIndex + 1} of ${snapshot.pipeline.totalItems}`)}" data-current-page="${snapshot.currentPage}" data-total-pages="${snapshot.totalPages}" data-page-label="${escapeHtml(snapshot.labels.paginationPage)}" data-of-label="${escapeHtml(snapshot.labels.paginationOf)}" data-prev-label="${escapeHtml(snapshot.labels.paginationPrevious)}" data-next-label="${escapeHtml(snapshot.labels.paginationNext)}" data-rows-label="${escapeHtml(snapshot.labels.paginationRows)}" data-prev-icon-path="${prevIcon.path}" data-prev-icon-view-box="${prevIcon.viewBox ?? '0 0 24 24'}" data-next-icon-path="${nextIcon.path}" data-next-icon-view-box="${nextIcon.viewBox ?? '0 0 24 24'}" data-page-sizes="${escapeHtml(JSON.stringify(pageSizes))}" data-page-size="${snapshot.pageSize}" data-prev-disabled="${snapshot.currentPage <= 1}" data-next-disabled="${snapshot.currentPage >= snapshot.totalPages}"></ui-grid-pagination>`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Hidden `<slot>` registry block so the element's shadow DOM exposes a
|
|
338
|
+
* named slot for every visible column's default `<template slot="cell-…">`
|
|
339
|
+
* markup. Matches the old grid's slot-exposure contract. */
|
|
340
|
+
export function renderSlotRegistry(columns: readonly GridColumnDef[]): string {
|
|
341
|
+
const cellSlots = columns
|
|
342
|
+
.map((column) => `<slot name="${escapeHtml(`cell-${column.name}`)}"></slot>`)
|
|
343
|
+
.join('');
|
|
344
|
+
return slotRegistryMarkup(cellSlots);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Render the inner `.cell-content` of one cell — the value interpolated
|
|
348
|
+
* through a consumer template, or the default display value. */
|
|
349
|
+
export function renderCellTemplate(
|
|
350
|
+
el: UiGridStandaloneElement,
|
|
351
|
+
row: GridRow,
|
|
352
|
+
column: GridColumnDef,
|
|
353
|
+
displayIndex: number,
|
|
354
|
+
): string {
|
|
355
|
+
const templateMarkup = getTemplateMarkup(el, `cell-${column.name}`);
|
|
356
|
+
return renderCellTemplateFromMarkup(el, row, column, displayIndex, templateMarkup);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Render the inner `.cell-content` from a pre-fetched template markup
|
|
360
|
+
* string. Used by the patch path which resolves templates once per column
|
|
361
|
+
* and reuses the result across every cell. */
|
|
362
|
+
export function renderCellTemplateFromMarkup(
|
|
363
|
+
el: UiGridStandaloneElement,
|
|
364
|
+
row: GridRow,
|
|
365
|
+
column: GridColumnDef,
|
|
366
|
+
displayIndex: number,
|
|
367
|
+
templateMarkup: string | null,
|
|
368
|
+
): string {
|
|
369
|
+
// Special-case the selection row-header column — render a checkbox
|
|
370
|
+
// whose checked state tracks the row's isSelected. Matches the old
|
|
371
|
+
// selectionRowHeaderButtons template.
|
|
372
|
+
if (column.name === 'selectionRowHeaderCol') {
|
|
373
|
+
const checked = el.snapshot?.selectedRowIds.has(row.id) ?? false;
|
|
374
|
+
const disabled = row.enableSelection === false;
|
|
375
|
+
return `<span class="ui-grid-selection-row-header-buttons" role="checkbox" tabindex="-1"${checked ? ' aria-checked="true"' : ' aria-checked="false"'}${disabled ? ' aria-disabled="true"' : ''}><span class="ui-grid-selection-checkbox${checked ? ' ui-grid-selection-checkbox-checked' : ''}${disabled ? ' ui-grid-selection-checkbox-disabled' : ''}"></span></span>`;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Framework-rendered path: emit a per-cell slot placeholder and register
|
|
379
|
+
// the slot so the post-render diff includes it in `added` if new.
|
|
380
|
+
if (el.frameworkSlots.hasCell(column.name)) {
|
|
381
|
+
el.frameworkSlots.stageCell(column, row, rowIndexFor(el, row, displayIndex));
|
|
382
|
+
const slotName = cellSlotNameForRow(column, row);
|
|
383
|
+
return `<slot name="${escapeHtml(slotName)}"></slot>`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (!templateMarkup) {
|
|
387
|
+
return cellValueMarkup(escapeHtml(el.controller?.displayValue(row, column) ?? ''));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const rawRow = row.entity as GridRecord;
|
|
391
|
+
const rawValue = getCellValue(rawRow, column);
|
|
392
|
+
const valueText = rawValue == null ? '' : String(rawValue);
|
|
393
|
+
return el.interpolateTemplate(templateMarkup, {
|
|
394
|
+
$implicit: rawValue,
|
|
395
|
+
value: rawValue,
|
|
396
|
+
valueText,
|
|
397
|
+
valueLower: valueText.toLowerCase(),
|
|
398
|
+
row: rawRow,
|
|
399
|
+
column,
|
|
400
|
+
rowIndex: rowIndexFor(el, row, displayIndex),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Render the body of an expandable detail row — either a framework slot
|
|
405
|
+
* placeholder or the interpolated `<template slot="expandable-row">`. */
|
|
406
|
+
export function renderExpandableTemplate(
|
|
407
|
+
el: UiGridStandaloneElement,
|
|
408
|
+
row: GridRow,
|
|
409
|
+
): string {
|
|
410
|
+
if (el.frameworkSlots.hasExpandableRow()) {
|
|
411
|
+
el.frameworkSlots.stageExpandableRow(row, rowIndexFor(el, row));
|
|
412
|
+
const slotName = expandableRowSlotName(row);
|
|
413
|
+
return `<slot name="${escapeHtml(slotName)}"></slot>`;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const templateMarkup = getTemplateMarkup(el, 'expandable-row');
|
|
417
|
+
if (!templateMarkup) {
|
|
418
|
+
return defaultExpandableMarkup(escapeHtml(String(row.entity['name'] ?? row.id)));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return el.interpolateTemplate(templateMarkup, {
|
|
422
|
+
$implicit: row.entity,
|
|
423
|
+
row: row.entity,
|
|
424
|
+
expanded: row.expanded,
|
|
425
|
+
rowIndex: rowIndexFor(el, row),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Render the no-data empty-state panel. In framework-rendered mode,
|
|
430
|
+
* stages the singleton `empty` slot and emits a slot placeholder. */
|
|
431
|
+
export function renderEmptyState(
|
|
432
|
+
el: UiGridStandaloneElement,
|
|
433
|
+
headingRaw: string,
|
|
434
|
+
descriptionRaw: string,
|
|
435
|
+
): string {
|
|
436
|
+
if (el.frameworkSlots.hasEmptyState()) {
|
|
437
|
+
if (el.snapshot) {
|
|
438
|
+
el.frameworkSlots.stageEmptyState(headingRaw, descriptionRaw, el.snapshot.labels);
|
|
439
|
+
}
|
|
440
|
+
return `<div class="empty-state ui-grid-no-row-overlay"><slot name="empty"></slot></div>`;
|
|
441
|
+
}
|
|
442
|
+
return emptyDataMarkup(escapeHtml(headingRaw), escapeHtml(descriptionRaw));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Read a consumer-provided `<template slot="…">` out of the element's
|
|
446
|
+
* light DOM. Returns the template's innerHTML or null when absent. */
|
|
447
|
+
export function getTemplateMarkup(
|
|
448
|
+
el: UiGridStandaloneElement,
|
|
449
|
+
slotName: string,
|
|
450
|
+
): string | null {
|
|
451
|
+
const template = el.querySelector<HTMLTemplateElement>(`template[slot="${slotName}"]`);
|
|
452
|
+
return template?.innerHTML ?? null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Resolve the visible-rows index for a given row, falling back to the
|
|
456
|
+
* render-pass displayIndex when the row isn't in `visibleRows`. */
|
|
457
|
+
export function rowIndexFor(
|
|
458
|
+
el: UiGridStandaloneElement,
|
|
459
|
+
row: GridRow,
|
|
460
|
+
fallback = 0,
|
|
461
|
+
): number {
|
|
462
|
+
return (
|
|
463
|
+
el.snapshot?.pipeline.visibleRows.findIndex((candidate) => candidate.id === row.id) ?? fallback
|
|
464
|
+
);
|
|
465
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { clearRustWasmGridEngine } from '@ornery/ui-grid-core';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
mountVanillaUiGrid,
|
|
7
|
+
type GridOptions,
|
|
8
|
+
type UiGridApi,
|
|
9
|
+
type VanillaUiGridElement,
|
|
10
|
+
} from './index';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Row-edit integration tests. Ports the behaviour from ui.grid.rowEdit
|
|
14
|
+
* onto the vanilla grid. The controller subscribes to `edit.afterCellEdit`,
|
|
15
|
+
* flips the row dirty, and raises `rowEdit.saveRow` once the debounce
|
|
16
|
+
* elapses (or `flushDirtyRows()` is called). Consumers resolve / reject
|
|
17
|
+
* the save promise they hand back via `setSavePromise`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
async function waitFor<T>(resolve: () => T | null | undefined, timeoutMs = 5000): Promise<T> {
|
|
21
|
+
const startedAt = Date.now();
|
|
22
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
23
|
+
const value = resolve();
|
|
24
|
+
if (value) return value;
|
|
25
|
+
await new Promise((r) => window.setTimeout(r, 10));
|
|
26
|
+
}
|
|
27
|
+
throw new Error('Timed out waiting for expected state');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TAG = 'ui-grid-element-vanilla-row-edit-test';
|
|
31
|
+
|
|
32
|
+
interface Row extends Record<string, unknown> {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
status: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeData(): Row[] {
|
|
39
|
+
return [
|
|
40
|
+
{ id: 'r1', name: 'Alpha', status: 'Active' },
|
|
41
|
+
{ id: 'r2', name: 'Beta', status: 'Pilot' },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function baseOptions(overrides: Partial<GridOptions> = {}): GridOptions {
|
|
46
|
+
let capturedApi: UiGridApi | undefined;
|
|
47
|
+
const options: GridOptions = {
|
|
48
|
+
id: 'row-edit-grid',
|
|
49
|
+
data: makeData(),
|
|
50
|
+
columnDefs: [
|
|
51
|
+
{ name: 'name', displayName: 'Name', enableCellEdit: true },
|
|
52
|
+
{ name: 'status', displayName: 'Status', enableCellEdit: true },
|
|
53
|
+
],
|
|
54
|
+
enableCellEdit: true,
|
|
55
|
+
rowIdentity: (entity) => String((entity as Row).id),
|
|
56
|
+
rowEditWaitInterval: 50, // short interval so tests don't linger.
|
|
57
|
+
onRegisterApi: (api) => {
|
|
58
|
+
capturedApi = api as UiGridApi;
|
|
59
|
+
},
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
Object.defineProperty(options, '__api', { enumerable: false, get: () => capturedApi });
|
|
63
|
+
return options;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getApi(options: GridOptions): UiGridApi {
|
|
67
|
+
const api = (options as unknown as { __api: UiGridApi | undefined }).__api;
|
|
68
|
+
if (!api) throw new Error('Grid API was never registered');
|
|
69
|
+
return api;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function mountGrid(options: GridOptions): Promise<{
|
|
73
|
+
grid: VanillaUiGridElement;
|
|
74
|
+
shadow: ShadowRoot;
|
|
75
|
+
}> {
|
|
76
|
+
const target = document.getElementById('app')!;
|
|
77
|
+
const grid = await mountVanillaUiGrid(target, options, undefined, TAG);
|
|
78
|
+
const shadow = await waitFor(() => grid.shadowRoot);
|
|
79
|
+
await waitFor(() => shadow.querySelector('.body-cell[data-row="r1"]'));
|
|
80
|
+
return { grid, shadow };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('vanilla grid row-edit', () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
document.body.innerHTML = '<div id="app"></div>';
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
clearRustWasmGridEngine();
|
|
90
|
+
document.body.innerHTML = '';
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('marks a row dirty after an edit commits', async () => {
|
|
94
|
+
const options = baseOptions();
|
|
95
|
+
await mountGrid(options);
|
|
96
|
+
const api = getApi(options);
|
|
97
|
+
// Programmatic edit — begin, set a value, commit.
|
|
98
|
+
const row = options.data[0]! as unknown as Record<string, unknown>;
|
|
99
|
+
api.edit.raise.afterCellEdit(row, options.columnDefs[0]!, 'Alpha!', 'Alpha');
|
|
100
|
+
expect(api.rowEdit.getDirtyRows().length).toBe(1);
|
|
101
|
+
expect(api.rowEdit.getDirtyRows()[0]!.entity).toBe(row);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('raises saveRow after the wait interval elapses', async () => {
|
|
105
|
+
const options = baseOptions();
|
|
106
|
+
await mountGrid(options);
|
|
107
|
+
const api = getApi(options);
|
|
108
|
+
const handler = vi.fn<[Record<string, unknown>], void>();
|
|
109
|
+
api.rowEdit.on.saveRow(handler);
|
|
110
|
+
const row = options.data[0]! as unknown as Record<string, unknown>;
|
|
111
|
+
api.edit.raise.afterCellEdit(row, options.columnDefs[0]!, 'Alpha!', 'Alpha');
|
|
112
|
+
await new Promise((r) => window.setTimeout(r, 80));
|
|
113
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
114
|
+
expect(handler).toHaveBeenCalledWith(row);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('does not auto-save when rowEditWaitInterval is -1 (manual flush)', async () => {
|
|
118
|
+
const options = baseOptions({ rowEditWaitInterval: -1 });
|
|
119
|
+
await mountGrid(options);
|
|
120
|
+
const api = getApi(options);
|
|
121
|
+
const handler = vi.fn();
|
|
122
|
+
api.rowEdit.on.saveRow(handler);
|
|
123
|
+
const row = options.data[0]! as unknown as Record<string, unknown>;
|
|
124
|
+
api.edit.raise.afterCellEdit(row, options.columnDefs[0]!, 'Alpha!', 'Alpha');
|
|
125
|
+
await new Promise((r) => window.setTimeout(r, 80));
|
|
126
|
+
expect(handler).not.toHaveBeenCalled();
|
|
127
|
+
|
|
128
|
+
// flushDirtyRows triggers the save explicitly.
|
|
129
|
+
await api.rowEdit.flushDirtyRows();
|
|
130
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('clears dirty state after a successful save promise resolves', async () => {
|
|
134
|
+
const options = baseOptions();
|
|
135
|
+
await mountGrid(options);
|
|
136
|
+
const api = getApi(options);
|
|
137
|
+
let resolveSave: (() => void) | null = null;
|
|
138
|
+
api.rowEdit.on.saveRow((rowEntity) => {
|
|
139
|
+
api.rowEdit.setSavePromise(
|
|
140
|
+
rowEntity,
|
|
141
|
+
new Promise<void>((resolve) => {
|
|
142
|
+
resolveSave = resolve;
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
const row = options.data[0]! as unknown as Record<string, unknown>;
|
|
147
|
+
api.edit.raise.afterCellEdit(row, options.columnDefs[0]!, 'Alpha!', 'Alpha');
|
|
148
|
+
await new Promise((r) => window.setTimeout(r, 80));
|
|
149
|
+
expect(api.rowEdit.getDirtyRows().length).toBe(1);
|
|
150
|
+
// Resolve the consumer's save promise.
|
|
151
|
+
resolveSave!();
|
|
152
|
+
await Promise.resolve();
|
|
153
|
+
await Promise.resolve();
|
|
154
|
+
expect(api.rowEdit.getDirtyRows().length).toBe(0);
|
|
155
|
+
expect(api.rowEdit.getErrorRows().length).toBe(0);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('moves the row into error state when the save promise rejects', async () => {
|
|
159
|
+
const options = baseOptions();
|
|
160
|
+
await mountGrid(options);
|
|
161
|
+
const api = getApi(options);
|
|
162
|
+
api.rowEdit.on.saveRow((rowEntity) => {
|
|
163
|
+
api.rowEdit.setSavePromise(rowEntity, Promise.reject(new Error('nope')));
|
|
164
|
+
});
|
|
165
|
+
const row = options.data[0]! as unknown as Record<string, unknown>;
|
|
166
|
+
api.edit.raise.afterCellEdit(row, options.columnDefs[0]!, 'Alpha!', 'Alpha');
|
|
167
|
+
await new Promise((r) => window.setTimeout(r, 80));
|
|
168
|
+
await Promise.resolve();
|
|
169
|
+
await Promise.resolve();
|
|
170
|
+
expect(api.rowEdit.getErrorRows().length).toBe(1);
|
|
171
|
+
expect(api.rowEdit.getDirtyRows().length).toBe(1); // stays dirty so retry works
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('setRowsDirty flips rows dirty without a prior edit', async () => {
|
|
175
|
+
const options = baseOptions({ rowEditWaitInterval: -1 });
|
|
176
|
+
await mountGrid(options);
|
|
177
|
+
const api = getApi(options);
|
|
178
|
+
api.rowEdit.setRowsDirty([options.data[0]! as unknown as Record<string, unknown>]);
|
|
179
|
+
expect(api.rowEdit.getDirtyRows().length).toBe(1);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('setRowsClean clears dirty + error flags', async () => {
|
|
183
|
+
const options = baseOptions({ rowEditWaitInterval: -1 });
|
|
184
|
+
await mountGrid(options);
|
|
185
|
+
const api = getApi(options);
|
|
186
|
+
const row = options.data[0]! as unknown as Record<string, unknown>;
|
|
187
|
+
api.rowEdit.setRowsDirty([row]);
|
|
188
|
+
api.rowEdit.setRowsClean([row]);
|
|
189
|
+
expect(api.rowEdit.getDirtyRows().length).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
});
|