@rickcedwhat/playwright-smart-table 6.25.0-next.c784f2c → 6.25.0-next.c81cb3d
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/dist/engine/cellNavigation.d.ts +24 -0
- package/dist/engine/cellNavigation.js +326 -0
- package/dist/engine/countRows.d.ts +27 -0
- package/dist/engine/countRows.js +139 -0
- package/dist/engine/rowByIndex.d.ts +19 -0
- package/dist/engine/rowByIndex.js +70 -0
- package/dist/engine/rowSnapshot.d.ts +28 -0
- package/dist/engine/rowSnapshot.js +407 -0
- package/dist/engine/scanPages.d.ts +2 -2
- package/dist/engine/sorting.d.ts +11 -0
- package/dist/engine/sorting.js +51 -0
- package/dist/packageVersion.d.ts +1 -1
- package/dist/packageVersion.js +1 -1
- package/dist/smartRow.js +12 -692
- package/dist/strategies/columns.d.ts +1 -1
- package/dist/useTable.js +41 -239
- package/dist/utils/navigationBarrier.js +0 -1
- package/dist/utils/safeStringify.d.ts +2 -0
- package/dist/utils/safeStringify.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Locator, Page } from '@playwright/test';
|
|
2
|
+
import type { FinalTableConfig } from '../types';
|
|
3
|
+
import type { NavigationBarrier } from '../utils/navigationBarrier';
|
|
4
|
+
export interface NavigateToCellParams {
|
|
5
|
+
config: FinalTableConfig<any>;
|
|
6
|
+
rootLocator: Locator;
|
|
7
|
+
page: Page;
|
|
8
|
+
resolve: (item: any, parent: Locator | Page) => Locator;
|
|
9
|
+
getHeaders?: () => Promise<string[]>;
|
|
10
|
+
column: string;
|
|
11
|
+
index: number;
|
|
12
|
+
rowLocator: Locator;
|
|
13
|
+
rowIndex?: number;
|
|
14
|
+
renderWindowPosition?: boolean;
|
|
15
|
+
barrier?: NavigationBarrier;
|
|
16
|
+
allowMissingCell?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Navigates to a cell with active cell optimization.
|
|
20
|
+
* Uses `strategies.viewport` first (2D virtualized tables), then `strategies.navigation`
|
|
21
|
+
* primitives (goUp/goDown/goLeft/goRight, optional snap/seek/goHome).
|
|
22
|
+
* Returns the target cell locator after navigation.
|
|
23
|
+
*/
|
|
24
|
+
export declare const navigateToCell: (params: NavigateToCellParams) => Promise<Locator>;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.navigateToCell = void 0;
|
|
4
|
+
const rowResolution_1 = require("./rowResolution");
|
|
5
|
+
const debugUtils_1 = require("../utils/debugUtils");
|
|
6
|
+
const log = (config, msg) => (0, debugUtils_1.logDebug)(config, 'verbose', msg);
|
|
7
|
+
/**
|
|
8
|
+
* Navigates to a cell with active cell optimization.
|
|
9
|
+
* Uses `strategies.viewport` first (2D virtualized tables), then `strategies.navigation`
|
|
10
|
+
* primitives (goUp/goDown/goLeft/goRight, optional snap/seek/goHome).
|
|
11
|
+
* Returns the target cell locator after navigation.
|
|
12
|
+
*/
|
|
13
|
+
const navigateToCell = async (params) => {
|
|
14
|
+
var _a;
|
|
15
|
+
const { config, rootLocator, page, resolve, getHeaders, column, index, rowLocator, barrier, allowMissingCell } = params;
|
|
16
|
+
// getRowByIndex supplies a position in the mounted row set. Resolve it while the
|
|
17
|
+
// selected row is still mounted, before horizontal scrolling can evict it.
|
|
18
|
+
const rowIndex = params.renderWindowPosition && config.strategies.viewport && config.strategies.resolveRowIndex
|
|
19
|
+
? (_a = (await (0, rowResolution_1.resolveLogicalRowIndex)(rowLocator, config, () => undefined))) === null || _a === void 0 ? void 0 : _a.index
|
|
20
|
+
: params.rowIndex;
|
|
21
|
+
const rowLabel = typeof rowIndex === 'number' ? `row ${rowIndex}` : 'row ?';
|
|
22
|
+
log(config, `_navigateToCell: resolving column "${column}" (colIndex ${index}), ${rowLabel}`);
|
|
23
|
+
const getActiveCell = config.strategies.getActiveCell;
|
|
24
|
+
const readActiveCell = async () => getActiveCell ? getActiveCell({ config, root: rootLocator, page, resolve }) : null;
|
|
25
|
+
const isActiveTarget = (ac) => !!ac && ac.rowIndex === rowIndex && ac.columnIndex === index;
|
|
26
|
+
// Optimization: Check if we are ALREADY at the target cell
|
|
27
|
+
if (getActiveCell) {
|
|
28
|
+
const activeCell = await readActiveCell();
|
|
29
|
+
if (activeCell && isActiveTarget(activeCell)) {
|
|
30
|
+
log(config, `_navigateToCell: Already at target cell {row: ${rowIndex}, col: ${index}}`);
|
|
31
|
+
// Still participate in the barrier (no-op) so peers are not left waiting.
|
|
32
|
+
if (barrier)
|
|
33
|
+
await barrier.sync(index);
|
|
34
|
+
return activeCell.locator;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const getCellLocator = () => config.strategies.getCellLocator
|
|
38
|
+
? config.strategies.getCellLocator({ row: rowLocator, root: rootLocator, columnName: column, columnIndex: index, rowIndex, page, config })
|
|
39
|
+
: resolve(config.cellSelector, rowLocator).nth(index);
|
|
40
|
+
const nav = {
|
|
41
|
+
config, page, column, index, rowLocator, rowIndex, barrier, allowMissingCell, resolve,
|
|
42
|
+
context: { config, root: rootLocator, page, resolve, getHeaders },
|
|
43
|
+
readActiveCell,
|
|
44
|
+
isActiveTarget,
|
|
45
|
+
// Locator presence: virtualized grids (e.g. Glide) often attach the target `td` after scroll
|
|
46
|
+
// while focus id lags; do not require getActiveCell to match for this to count as reached.
|
|
47
|
+
targetReached: async () => isActiveTarget(await readActiveCell()) || (await getCellLocator().count() > 0),
|
|
48
|
+
getCellLocator,
|
|
49
|
+
};
|
|
50
|
+
// Viewport-oracle phase: direct positioning for 2D virtualized tables.
|
|
51
|
+
// Runs before navigation primitives; falls through to them if cell still not accessible.
|
|
52
|
+
const viewport = config.strategies.viewport;
|
|
53
|
+
if (viewport && typeof rowIndex === 'number') {
|
|
54
|
+
const viaViewport = await navigateViaViewport(nav, viewport, rowIndex);
|
|
55
|
+
if (viaViewport)
|
|
56
|
+
return viaViewport;
|
|
57
|
+
}
|
|
58
|
+
if (config.strategies.navigation) {
|
|
59
|
+
return navigateViaPrimitives(nav, config.strategies.navigation);
|
|
60
|
+
}
|
|
61
|
+
// No horizontal strategies configured; assume table is 1D or fully rendered,
|
|
62
|
+
// and let standard Playwright auto-waiting handle any lazy-loading delays.
|
|
63
|
+
return getCellLocator();
|
|
64
|
+
};
|
|
65
|
+
exports.navigateToCell = navigateToCell;
|
|
66
|
+
/** Checks in to the barrier without a move action so peers aren't left waiting. */
|
|
67
|
+
async function checkIn(nav) {
|
|
68
|
+
if (nav.barrier)
|
|
69
|
+
await nav.barrier.sync(nav.index);
|
|
70
|
+
}
|
|
71
|
+
/** Runs `action` once for the whole batch when a barrier is present, otherwise directly. */
|
|
72
|
+
async function coordinated(nav, action) {
|
|
73
|
+
return nav.barrier ? nav.barrier.sync(nav.index, action) : action();
|
|
74
|
+
}
|
|
75
|
+
const inRange = (value, range) => value >= range.first && value <= range.last;
|
|
76
|
+
/** True when the row is in the visible row range and the target cell is (or soon becomes) attached. */
|
|
77
|
+
async function isCellAttachedInView(nav, viewport, rowIndex) {
|
|
78
|
+
const rowRange = viewport.getVisibleRowRange ? await viewport.getVisibleRowRange(nav.context) : null;
|
|
79
|
+
if (rowRange && !inRange(rowIndex, rowRange))
|
|
80
|
+
return false;
|
|
81
|
+
if (await nav.targetReached())
|
|
82
|
+
return true;
|
|
83
|
+
try {
|
|
84
|
+
await nav.getCellLocator().waitFor({ state: 'attached', timeout: 500 });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
if ((error === null || error === void 0 ? void 0 : error.name) !== 'TimeoutError')
|
|
89
|
+
throw error;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Fast path: is the cell already readable without scrolling? Uses the column range oracle
|
|
95
|
+
* when available, else DOM presence. Rows do NOT sync at the barrier with a move action
|
|
96
|
+
* here — only scroll boundaries need coordination.
|
|
97
|
+
*/
|
|
98
|
+
async function readIfInView(nav, viewport, rowIndex) {
|
|
99
|
+
const { config, context, index } = nav;
|
|
100
|
+
if (!viewport.getVisibleColumnRange) {
|
|
101
|
+
// No range oracle — fall back to DOM count() check.
|
|
102
|
+
if (!await nav.targetReached())
|
|
103
|
+
return null;
|
|
104
|
+
await checkIn(nav);
|
|
105
|
+
return nav.getCellLocator();
|
|
106
|
+
}
|
|
107
|
+
const knownColRange = await viewport.getVisibleColumnRange(context);
|
|
108
|
+
if (!knownColRange || !inRange(index, knownColRange))
|
|
109
|
+
return null;
|
|
110
|
+
if (!await isCellAttachedInView(nav, viewport, rowIndex))
|
|
111
|
+
return null;
|
|
112
|
+
log(config, `_navigateToCell: col ${index} in visible range [${knownColRange.first}-${knownColRange.last}], reading directly`);
|
|
113
|
+
// Check in to the barrier without a moveAction — peers that need the scroll
|
|
114
|
+
// still supply their own action and will trigger it when all rows arrive.
|
|
115
|
+
await checkIn(nav);
|
|
116
|
+
return nav.getCellLocator();
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Viewport phase. Returns the cell locator, or null to fall through to navigation primitives
|
|
120
|
+
* (only when `strategies.navigation` is configured; otherwise throws on a miss).
|
|
121
|
+
*/
|
|
122
|
+
async function navigateViaViewport(nav, viewport, rowIndex) {
|
|
123
|
+
const { config, context, index } = nav;
|
|
124
|
+
const inView = await readIfInView(nav, viewport, rowIndex);
|
|
125
|
+
if (inView)
|
|
126
|
+
return inView;
|
|
127
|
+
// Column is out of view. Scroll to bring it in.
|
|
128
|
+
// In synchronized mode, the last row to arrive triggers the scroll once for all rows.
|
|
129
|
+
await coordinated(nav, async () => {
|
|
130
|
+
// Always scroll — scrollToColumn is idempotent (bounds-checks before acting).
|
|
131
|
+
// The trigger row may already have the cell via overscan, but other rows in the
|
|
132
|
+
// batch might not; skipping the scroll here would silently starve them.
|
|
133
|
+
log(config, `_navigateToCell: col ${index} out of view, scrolling`);
|
|
134
|
+
if (viewport.scrollToColumn) {
|
|
135
|
+
await viewport.scrollToColumn(context, index);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
await recoverEvictedRow(nav, viewport, rowIndex);
|
|
139
|
+
if (await nav.targetReached()) {
|
|
140
|
+
return nav.getCellLocator();
|
|
141
|
+
}
|
|
142
|
+
// Cell still not accessible — fall through to navigation primitives if configured.
|
|
143
|
+
if (config.strategies.navigation)
|
|
144
|
+
return null;
|
|
145
|
+
if (nav.allowMissingCell)
|
|
146
|
+
return nav.getCellLocator();
|
|
147
|
+
throw new Error(await describeViewportMiss(nav, viewport, rowIndex));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Per-row 2D recovery: horizontal scroll may evict rows from the vertical viewport
|
|
151
|
+
* (scrollbar layout shift) or rows may simply be outside the virtual row window.
|
|
152
|
+
* Recovery is serialized via the barrier's mutex so concurrent rows don't race on
|
|
153
|
+
* scrollTop — adjacent rows often appear in view for free after the previous recovery.
|
|
154
|
+
* scrollToRow must only adjust scrollTop (not scrollLeft) to preserve the column
|
|
155
|
+
* position established by scrollToColumn.
|
|
156
|
+
*/
|
|
157
|
+
async function recoverEvictedRow(nav, viewport, rowIndex) {
|
|
158
|
+
if (await nav.targetReached())
|
|
159
|
+
return;
|
|
160
|
+
const scrollToRow = viewport.scrollToRow;
|
|
161
|
+
if (!scrollToRow)
|
|
162
|
+
return;
|
|
163
|
+
const recover = async () => {
|
|
164
|
+
if (!await nav.targetReached()) {
|
|
165
|
+
log(nav.config, `_navigateToCell: row ${rowIndex} evicted by col scroll, recovering`);
|
|
166
|
+
await scrollToRow(nav.context, rowIndex);
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
if (nav.barrier) {
|
|
170
|
+
await nav.barrier.runExclusiveRecovery(recover);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
await recover();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async function visibleRanges(nav) {
|
|
177
|
+
const viewport = nav.config.strategies.viewport;
|
|
178
|
+
const colRange = (viewport === null || viewport === void 0 ? void 0 : viewport.getVisibleColumnRange) ? await viewport.getVisibleColumnRange(nav.context) : null;
|
|
179
|
+
const rowRange = (viewport === null || viewport === void 0 ? void 0 : viewport.getVisibleRowRange) ? await viewport.getVisibleRowRange(nav.context) : null;
|
|
180
|
+
return { colRange, rowRange };
|
|
181
|
+
}
|
|
182
|
+
async function describeViewportMiss(nav, viewport, rowIndex) {
|
|
183
|
+
const colRange = viewport.getVisibleColumnRange ? await viewport.getVisibleColumnRange(nav.context) : null;
|
|
184
|
+
const rowRange = viewport.getVisibleRowRange ? await viewport.getVisibleRowRange(nav.context) : null;
|
|
185
|
+
let errMsg = `SmartTable: could not reach cell for column "${nav.column}" (colIndex ${nav.index}) at row ${rowIndex}.\n`;
|
|
186
|
+
errMsg += colRange
|
|
187
|
+
? ` Visible column range: [${colRange.first}–${colRange.last}]. Column is out of view and no navigation fallback is configured.\n`
|
|
188
|
+
: ` Column is out of view and no navigation fallback is configured.\n`;
|
|
189
|
+
if (rowRange) {
|
|
190
|
+
errMsg += ` Visible row range: [${rowRange.first}–${rowRange.last}]. ${inRange(rowIndex, rowRange) ? 'Row is in view.' : 'Row is out of view.'}\n`;
|
|
191
|
+
}
|
|
192
|
+
errMsg += ` → Add a \`strategies.navigation\` or \`strategies.viewport.scrollToColumn\` to handle off-screen columns.`;
|
|
193
|
+
return errMsg;
|
|
194
|
+
}
|
|
195
|
+
/** Navigation-primitive phase (goUp/goDown/goLeft/goRight, optional snap/seek). */
|
|
196
|
+
async function navigateViaPrimitives(nav, primitives) {
|
|
197
|
+
const { rowIndex } = nav;
|
|
198
|
+
if (await nav.targetReached()) {
|
|
199
|
+
// Already there. If we have a barrier, check-in to stay in lock-step.
|
|
200
|
+
await checkIn(nav);
|
|
201
|
+
return nav.getCellLocator();
|
|
202
|
+
}
|
|
203
|
+
if (typeof rowIndex !== 'number') {
|
|
204
|
+
if (nav.allowMissingCell)
|
|
205
|
+
return nav.getCellLocator();
|
|
206
|
+
throw new Error('Row index is required for navigation');
|
|
207
|
+
}
|
|
208
|
+
const focused = await focusUnfocusedCell(nav);
|
|
209
|
+
if (focused)
|
|
210
|
+
return focused;
|
|
211
|
+
// Not there, perform (coordinated) navigation
|
|
212
|
+
const navResult = await coordinated(nav, () => stepToCell(nav, primitives, rowIndex));
|
|
213
|
+
return finishNavigation(nav, navResult, rowIndex);
|
|
214
|
+
}
|
|
215
|
+
/** Picks the cell after stepping: the one stepToCell found mid-navigation, else the final locator. */
|
|
216
|
+
async function finishNavigation(nav, navResult, rowIndex) {
|
|
217
|
+
if (navResult && typeof navResult.count === 'function' && await navResult.count() > 0) {
|
|
218
|
+
return navResult;
|
|
219
|
+
}
|
|
220
|
+
const finalCell = nav.getCellLocator();
|
|
221
|
+
if (await finalCell.count() > 0 || nav.allowMissingCell)
|
|
222
|
+
return finalCell;
|
|
223
|
+
const { colRange, rowRange } = await visibleRanges(nav);
|
|
224
|
+
let errMsg = `SmartTable: could not reach cell for column "${nav.column}" (colIndex ${nav.index}) at row ${rowIndex} after exhausting navigation strategies. Ensure navigation primitives are correctly implemented.\n`;
|
|
225
|
+
if (colRange)
|
|
226
|
+
errMsg += ` Visible column range: [${colRange.first}–${colRange.last}].\n`;
|
|
227
|
+
if (rowRange)
|
|
228
|
+
errMsg += ` Visible row range: [${rowRange.first}–${rowRange.last}].\n`;
|
|
229
|
+
throw new Error(errMsg);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* If getActiveCell is present but returns null (no focus), and the cell is in the DOM,
|
|
233
|
+
* focus it once before entering the navigation loop.
|
|
234
|
+
*/
|
|
235
|
+
async function focusUnfocusedCell(nav) {
|
|
236
|
+
if (!nav.config.strategies.getActiveCell)
|
|
237
|
+
return null;
|
|
238
|
+
if (await nav.readActiveCell())
|
|
239
|
+
return null;
|
|
240
|
+
const cell = nav.resolve(nav.config.cellSelector, nav.rowLocator).nth(nav.index);
|
|
241
|
+
if (await cell.count() === 0)
|
|
242
|
+
return null;
|
|
243
|
+
log(nav.config, `_navigateToCell: cell "${nav.column}" found but not focused; focusing.`);
|
|
244
|
+
await cell.focus();
|
|
245
|
+
// Re-check target
|
|
246
|
+
if (!await nav.targetReached())
|
|
247
|
+
return null;
|
|
248
|
+
await checkIn(nav);
|
|
249
|
+
return cell;
|
|
250
|
+
}
|
|
251
|
+
/** Tracks the active cell position while stepping (stays at the last known value when focus is unknown). */
|
|
252
|
+
class Position {
|
|
253
|
+
constructor(nav) {
|
|
254
|
+
this.nav = nav;
|
|
255
|
+
this.row = 0;
|
|
256
|
+
this.col = 0;
|
|
257
|
+
}
|
|
258
|
+
async sync() {
|
|
259
|
+
const ac = await this.nav.readActiveCell();
|
|
260
|
+
if (ac) {
|
|
261
|
+
this.row = ac.rowIndex;
|
|
262
|
+
this.col = ac.columnIndex;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** A configured non-negative wait, or the computed default. */
|
|
267
|
+
const waitOrDefault = (configured, computed) => (Number.isFinite(configured) && configured >= 0) ? configured : computed;
|
|
268
|
+
async function repeatStep(times, step) {
|
|
269
|
+
if (!step)
|
|
270
|
+
return;
|
|
271
|
+
for (let i = 0; i < times; i++)
|
|
272
|
+
await step();
|
|
273
|
+
}
|
|
274
|
+
/** Column-0 snap and long-distance seek, both owned by the navigation primitive. */
|
|
275
|
+
async function alignColumn(nav, primitives, pos) {
|
|
276
|
+
const { config, context, index } = nav;
|
|
277
|
+
// Core must not special-case canvas/Home — that was pre-viewport Glide glue.
|
|
278
|
+
if (index === 0 && primitives.snapFirstColumnIntoView) {
|
|
279
|
+
log(config, '_navigateToCell: snapFirstColumnIntoView for column index 0');
|
|
280
|
+
await primitives.snapFirstColumnIntoView(context);
|
|
281
|
+
await pos.sync();
|
|
282
|
+
}
|
|
283
|
+
if (Math.abs(index - pos.col) > 12 && primitives.seekColumnIndex) {
|
|
284
|
+
log(config, `_navigateToCell: seekColumnIndex approx for column ${index}`);
|
|
285
|
+
await primitives.seekColumnIndex(context, index);
|
|
286
|
+
await pos.sync();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* After horizontal steps, poll until getActiveCell / DOM presence confirms the target
|
|
291
|
+
* (a11y trees and virtual mounts often lag the scroll). Tune via navigation.maxWaitMs.
|
|
292
|
+
*/
|
|
293
|
+
async function pollForTarget(nav, primitives, horizontalSteps) {
|
|
294
|
+
const pollIntervalMs = 10;
|
|
295
|
+
const maxWaitMs = waitOrDefault(primitives.maxWaitMs, Math.min(6000, 250 + horizontalSteps * 25));
|
|
296
|
+
const start = Date.now();
|
|
297
|
+
while (Date.now() - start < maxWaitMs) {
|
|
298
|
+
const ac = await nav.readActiveCell();
|
|
299
|
+
if (ac && nav.isActiveTarget(ac))
|
|
300
|
+
return ac.locator;
|
|
301
|
+
if (await nav.targetReached())
|
|
302
|
+
break;
|
|
303
|
+
await nav.page.waitForTimeout(pollIntervalMs);
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
/** Steps with the navigation primitives from the active cell to the target, then waits for it. */
|
|
308
|
+
async function stepToCell(nav, primitives, rowIndex) {
|
|
309
|
+
const { context, index } = nav;
|
|
310
|
+
const pos = new Position(nav);
|
|
311
|
+
await pos.sync();
|
|
312
|
+
// Navigate vertically (tight loop)
|
|
313
|
+
const rDiff = rowIndex - pos.row;
|
|
314
|
+
const goVertical = rDiff > 0 ? primitives.goDown : primitives.goUp;
|
|
315
|
+
await repeatStep(Math.abs(rDiff), goVertical && (() => goVertical(context)));
|
|
316
|
+
await pos.sync();
|
|
317
|
+
await alignColumn(nav, primitives, pos);
|
|
318
|
+
const cDiff = index - pos.col;
|
|
319
|
+
const goHorizontal = cDiff > 0 ? primitives.goRight : primitives.goLeft;
|
|
320
|
+
await repeatStep(Math.abs(cDiff), goHorizontal && (() => goHorizontal(context)));
|
|
321
|
+
const horizontalSteps = Math.abs(cDiff);
|
|
322
|
+
if (horizontalSteps > 0) {
|
|
323
|
+
await nav.page.waitForTimeout(waitOrDefault(primitives.settleMs, Math.min(2500, 60 + horizontalSteps * 12)));
|
|
324
|
+
}
|
|
325
|
+
return pollForTarget(nav, primitives, horizontalSteps);
|
|
326
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Locator } from '@playwright/test';
|
|
2
|
+
import type { FilterValue, FinalTableConfig, TableContext } from '../types';
|
|
3
|
+
import type { FilterEngine } from '../filterEngine';
|
|
4
|
+
import type { TableMapper } from './tableMapper';
|
|
5
|
+
import type { RowFinder } from './rowFinder';
|
|
6
|
+
export interface CountRowsEnv<T = any> {
|
|
7
|
+
config: FinalTableConfig<T>;
|
|
8
|
+
rootLocator: Locator;
|
|
9
|
+
getRowLocators: () => Locator;
|
|
10
|
+
tableMapper: TableMapper;
|
|
11
|
+
filterEngine: FilterEngine;
|
|
12
|
+
rowFinder: RowFinder<T>;
|
|
13
|
+
advancePage: (useBulk: boolean) => Promise<boolean>;
|
|
14
|
+
getCurrentPageIndex: () => number;
|
|
15
|
+
getContext: () => TableContext;
|
|
16
|
+
/** Restores page 1 after a paginated count. */
|
|
17
|
+
reset: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Counts matching rows on the current page, or across pages when pagination and
|
|
21
|
+
* `maxPages > 1` allow it. A paginated count always resets to page 1 afterwards (#348).
|
|
22
|
+
* Callers must have initialized the table.
|
|
23
|
+
*/
|
|
24
|
+
export declare function countRows<T>(env: CountRowsEnv<T>, filters?: Record<string, FilterValue>, options?: {
|
|
25
|
+
exact?: boolean;
|
|
26
|
+
maxPages?: number;
|
|
27
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.countRows = countRows;
|
|
4
|
+
const scanPages_1 = require("./scanPages");
|
|
5
|
+
const elementTracker_1 = require("../utils/elementTracker");
|
|
6
|
+
const debugUtils_1 = require("../utils/debugUtils");
|
|
7
|
+
const loadingWait_1 = require("../utils/loadingWait");
|
|
8
|
+
const safeStringify_1 = require("../utils/safeStringify");
|
|
9
|
+
function log(config, msg) {
|
|
10
|
+
(0, debugUtils_1.logDebug)(config, 'verbose', msg);
|
|
11
|
+
}
|
|
12
|
+
/** Splits filters by how they're evaluated: DOM text, `columnOverrides.read`, or synthetic columns. */
|
|
13
|
+
function partitionFilters(config, filters) {
|
|
14
|
+
var _a, _b, _c;
|
|
15
|
+
const domFilters = {};
|
|
16
|
+
const overrideFilters = {};
|
|
17
|
+
const syntheticFilters = {};
|
|
18
|
+
for (const [key, value] of Object.entries(filters)) {
|
|
19
|
+
if ((_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[key]) {
|
|
20
|
+
syntheticFilters[key] = value;
|
|
21
|
+
}
|
|
22
|
+
else if ((_c = (_b = config.columnOverrides) === null || _b === void 0 ? void 0 : _b[key]) === null || _c === void 0 ? void 0 : _c.read) {
|
|
23
|
+
overrideFilters[key] = value;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
domFilters[key] = value;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { domFilters, overrideFilters, syntheticFilters };
|
|
30
|
+
}
|
|
31
|
+
function requireMap(tableMapper) {
|
|
32
|
+
const map = tableMapper.getMapSync();
|
|
33
|
+
if (!map)
|
|
34
|
+
throw new Error('Initialization Error: Table map not available. Call "await table.init()" first.');
|
|
35
|
+
return map;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Counts matching rows on the current page, or across pages when pagination and
|
|
39
|
+
* `maxPages > 1` allow it. A paginated count always resets to page 1 afterwards (#348).
|
|
40
|
+
* Callers must have initialized the table.
|
|
41
|
+
*/
|
|
42
|
+
async function countRows(env, filters, options) {
|
|
43
|
+
var _a;
|
|
44
|
+
var _b, _c;
|
|
45
|
+
const { config, rootLocator, tableMapper, rowFinder } = env;
|
|
46
|
+
const filtersRecord = filters !== null && filters !== void 0 ? filters : {};
|
|
47
|
+
const hasFilters = Object.keys(filtersRecord).length > 0;
|
|
48
|
+
const effectiveMaxPages = (_b = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _b !== void 0 ? _b : config.maxPages;
|
|
49
|
+
const pag = config.strategies.pagination;
|
|
50
|
+
const hasPagination = effectiveMaxPages > 1 && !!((pag === null || pag === void 0 ? void 0 : pag.goNext) || (pag === null || pag === void 0 ? void 0 : pag.goNextBulk));
|
|
51
|
+
const exact = (_c = options === null || options === void 0 ? void 0 : options.exact) !== null && _c !== void 0 ? _c : false;
|
|
52
|
+
const { domFilters, overrideFilters, syntheticFilters } = partitionFilters(config, filtersRecord);
|
|
53
|
+
const hasOverrides = Object.keys(overrideFilters).length > 0;
|
|
54
|
+
const hasSynthetics = Object.keys(syntheticFilters).length > 0;
|
|
55
|
+
const hasPostFilters = hasOverrides || hasSynthetics;
|
|
56
|
+
const resolveRows = () => {
|
|
57
|
+
const rows = env.getRowLocators();
|
|
58
|
+
if (Object.keys(domFilters).length === 0)
|
|
59
|
+
return rows;
|
|
60
|
+
return env.filterEngine.applyFilters(rows, domFilters, requireMap(tableMapper), exact, rootLocator.page(), rootLocator);
|
|
61
|
+
};
|
|
62
|
+
const countPostFilterMatches = async (candidates, indices) => {
|
|
63
|
+
if (!hasPostFilters)
|
|
64
|
+
return indices.length;
|
|
65
|
+
const map = requireMap(tableMapper);
|
|
66
|
+
let count = 0;
|
|
67
|
+
for (const idx of indices) {
|
|
68
|
+
const row = candidates[idx];
|
|
69
|
+
if (hasOverrides && !await rowFinder.matchesOverrideFilters(row, overrideFilters, map, exact))
|
|
70
|
+
continue;
|
|
71
|
+
if (hasSynthetics && !await rowFinder.matchesSyntheticFilters(row, syntheticFilters, map, exact))
|
|
72
|
+
continue;
|
|
73
|
+
count++;
|
|
74
|
+
}
|
|
75
|
+
return count;
|
|
76
|
+
};
|
|
77
|
+
const isTableLoading = (_a = config.strategies.loading) === null || _a === void 0 ? void 0 : _a.isTableLoading;
|
|
78
|
+
const waitForReady = async () => {
|
|
79
|
+
if (!isTableLoading)
|
|
80
|
+
return;
|
|
81
|
+
await (0, loadingWait_1.waitWhileTableLoading)(config, env.getContext(), rootLocator.page(), 'countRows');
|
|
82
|
+
};
|
|
83
|
+
await waitForReady();
|
|
84
|
+
const filterSuffix = hasFilters ? ` filters=${(0, safeStringify_1.safeStringify)(filtersRecord)}` : '';
|
|
85
|
+
if (!hasPagination) {
|
|
86
|
+
log(config, `countRows: counting rows in current viewport (no pagination)${filterSuffix}`);
|
|
87
|
+
if (!hasPostFilters)
|
|
88
|
+
return resolveRows().count();
|
|
89
|
+
const all = await resolveRows().all();
|
|
90
|
+
return countPostFilterMatches(all, all.map((_, i) => i));
|
|
91
|
+
}
|
|
92
|
+
log(config, `countRows: paginating up to ${effectiveMaxPages} page(s)${filterSuffix}`);
|
|
93
|
+
const tracker = new elementTracker_1.ElementTracker('countRows');
|
|
94
|
+
let total = 0;
|
|
95
|
+
let pagesScanned = 1;
|
|
96
|
+
let paginationError;
|
|
97
|
+
try {
|
|
98
|
+
const scanned = await (0, scanPages_1.scanPages)({
|
|
99
|
+
advancePage: env.advancePage,
|
|
100
|
+
getCurrentPageIndex: env.getCurrentPageIndex,
|
|
101
|
+
config,
|
|
102
|
+
waitForReady,
|
|
103
|
+
}, async ({ pagesScanned: pageNum }) => {
|
|
104
|
+
const rowLocators = resolveRows();
|
|
105
|
+
const newIndices = await tracker.getUnseenIndices(rowLocators);
|
|
106
|
+
const candidates = await rowLocators.all();
|
|
107
|
+
const matched = await countPostFilterMatches(candidates, newIndices);
|
|
108
|
+
total += matched;
|
|
109
|
+
log(config, `countRows: page ${pageNum} — ${matched} row(s) (running total: ${total})`);
|
|
110
|
+
return 'continue';
|
|
111
|
+
}, {
|
|
112
|
+
maxPages: effectiveMaxPages,
|
|
113
|
+
useBulk: false,
|
|
114
|
+
label: 'countRows',
|
|
115
|
+
finalScanOnEof: true,
|
|
116
|
+
});
|
|
117
|
+
pagesScanned = scanned.pagesScanned;
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
paginationError = e;
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await tracker.cleanup(rootLocator.page());
|
|
125
|
+
// Always restore page 1, even if pagination threw mid-count (#348), so the table
|
|
126
|
+
// stays usable for callers that catch the error. If reset() itself fails, only let
|
|
127
|
+
// it surface when pagination succeeded — otherwise preserve the original error.
|
|
128
|
+
log(config, `countRows: ${total} total row(s) across ${pagesScanned} page(s) — resetting`);
|
|
129
|
+
try {
|
|
130
|
+
await env.reset();
|
|
131
|
+
}
|
|
132
|
+
catch (resetError) {
|
|
133
|
+
if (paginationError === undefined)
|
|
134
|
+
throw resetError;
|
|
135
|
+
(0, debugUtils_1.logDebug)(config, 'error', 'countRows: reset() failed after pagination error', resetError);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return total;
|
|
139
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Locator } from '@playwright/test';
|
|
2
|
+
import type { FinalTableConfig, SmartRow, TableContext } from '../types';
|
|
3
|
+
export interface RowByIndexEnv<T = any> {
|
|
4
|
+
config: FinalTableConfig<T>;
|
|
5
|
+
getRowLocators: () => Locator;
|
|
6
|
+
getMap: () => Map<string, number>;
|
|
7
|
+
advancePage: (useBulk: boolean) => Promise<boolean>;
|
|
8
|
+
getCurrentPageIndex: () => number;
|
|
9
|
+
getContext: () => TableContext;
|
|
10
|
+
makeSmartRow: (rowLocator: Locator, map: Map<string, number>, rowIndex: number, tablePageIndex: number, healSelector?: string) => SmartRow<T>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Finds the row whose logical index (per `strategies.resolveRowIndex`) is `index`:
|
|
14
|
+
* already mounted, else via `viewport.scrollToRow`, else by advancing pages until it
|
|
15
|
+
* mounts (bounded by `maxPages`). Callers must have initialized the table.
|
|
16
|
+
*/
|
|
17
|
+
export declare function findRowByIndex<T>(env: RowByIndexEnv<T>, index: number, options?: {
|
|
18
|
+
maxPages?: number;
|
|
19
|
+
}): Promise<SmartRow<T>>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findRowByIndex = findRowByIndex;
|
|
4
|
+
const rowResolution_1 = require("./rowResolution");
|
|
5
|
+
const scanPages_1 = require("./scanPages");
|
|
6
|
+
const debugUtils_1 = require("../utils/debugUtils");
|
|
7
|
+
const safeStringify_1 = require("../utils/safeStringify");
|
|
8
|
+
/**
|
|
9
|
+
* Finds the row whose logical index (per `strategies.resolveRowIndex`) is `index`:
|
|
10
|
+
* already mounted, else via `viewport.scrollToRow`, else by advancing pages until it
|
|
11
|
+
* mounts (bounded by `maxPages`). Callers must have initialized the table.
|
|
12
|
+
*/
|
|
13
|
+
async function findRowByIndex(env, index, options) {
|
|
14
|
+
var _a;
|
|
15
|
+
const { config } = env;
|
|
16
|
+
(0, debugUtils_1.logDebug)(config, 'verbose', `findRowByIndex: index=${index} options=${(0, safeStringify_1.safeStringify)(options)}`);
|
|
17
|
+
const resolveRI = config.strategies.resolveRowIndex;
|
|
18
|
+
const viewport = config.strategies.viewport;
|
|
19
|
+
// Identifying a row by its logical index requires a resolveRowIndex strategy. Without one
|
|
20
|
+
// the library can only offer render-window-relative getRowByIndex, or filter-based findRow.
|
|
21
|
+
if (!resolveRI) {
|
|
22
|
+
throw new Error(`findRowByIndex(${index}) requires a strategies.resolveRowIndex to identify rows by their logical index. ` +
|
|
23
|
+
`Configure one (e.g. read the grid's row-index attribute), or use getRowByIndex() (current render window) / findRow(filters).`);
|
|
24
|
+
}
|
|
25
|
+
let foundSelector;
|
|
26
|
+
// The currently-mounted row whose logical index === index, if any.
|
|
27
|
+
const findMounted = async () => {
|
|
28
|
+
const rows = await env.getRowLocators().all();
|
|
29
|
+
for (const r of rows) {
|
|
30
|
+
const result = await resolveRI(r);
|
|
31
|
+
if (result === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
const normalized = (0, rowResolution_1.normalizeRowIndexResult)(result);
|
|
34
|
+
if (normalized.index === index) {
|
|
35
|
+
foundSelector = normalized.selector;
|
|
36
|
+
return r;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
};
|
|
41
|
+
// 1. Already mounted (no navigation needed)?
|
|
42
|
+
let loc = await findMounted();
|
|
43
|
+
// 2. Fast path: jump straight to it via the viewport's random-access scroll.
|
|
44
|
+
if (!loc && (viewport === null || viewport === void 0 ? void 0 : viewport.scrollToRow)) {
|
|
45
|
+
(0, debugUtils_1.logDebug)(config, 'verbose', `findRowByIndex: scrolling to row ${index}`);
|
|
46
|
+
await viewport.scrollToRow(env.getContext(), index);
|
|
47
|
+
loc = await findMounted();
|
|
48
|
+
}
|
|
49
|
+
// 3. Fallback: advance pages (on infinite-scroll tables, a "page" is a scroll step) until
|
|
50
|
+
// the row is mounted. Page 1 was already searched above.
|
|
51
|
+
const maxPages = (_a = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _a !== void 0 ? _a : config.maxPages;
|
|
52
|
+
const scanForMounted = async () => {
|
|
53
|
+
let hit = null;
|
|
54
|
+
await (0, scanPages_1.scanPages)({ advancePage: env.advancePage, getCurrentPageIndex: env.getCurrentPageIndex, config }, async ({ pagesScanned }) => {
|
|
55
|
+
if (pagesScanned > 1)
|
|
56
|
+
hit = await findMounted();
|
|
57
|
+
return hit ? 'stop' : 'continue';
|
|
58
|
+
}, { maxPages, useBulk: false, label: 'findRowByIndex', finalScanOnEof: false });
|
|
59
|
+
return hit;
|
|
60
|
+
};
|
|
61
|
+
if (!loc && config.strategies.pagination) {
|
|
62
|
+
loc = await scanForMounted();
|
|
63
|
+
}
|
|
64
|
+
if (!loc) {
|
|
65
|
+
throw new Error(`findRowByIndex(${index}): could not reach row ${index}. ` +
|
|
66
|
+
`Ensure a viewport.scrollToRow or pagination can bring it into view (searched up to maxPages=${maxPages}).`);
|
|
67
|
+
}
|
|
68
|
+
await (0, debugUtils_1.debugDelay)(config, 'findRow');
|
|
69
|
+
return env.makeSmartRow(loc, env.getMap(), index, env.getCurrentPageIndex(), foundSelector);
|
|
70
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Locator, Page } from '@playwright/test';
|
|
2
|
+
import type { FinalTableConfig, SmartRow, TableResult } from '../types';
|
|
3
|
+
import type { SmartRowInternals } from '../utils/smartRowInternals';
|
|
4
|
+
/** Everything `toJSON` needs from the SmartRow being read. */
|
|
5
|
+
export interface RowSnapshotEnv<T = any> {
|
|
6
|
+
config: FinalTableConfig<T>;
|
|
7
|
+
rootLocator: Locator;
|
|
8
|
+
resolve: (item: any, parent: Locator | Page) => Locator;
|
|
9
|
+
map: Map<string, number>;
|
|
10
|
+
row: SmartRow<T> & SmartRowInternals;
|
|
11
|
+
rowLocator: Locator;
|
|
12
|
+
rowIndex: number | undefined;
|
|
13
|
+
table: TableResult<T> | null;
|
|
14
|
+
renderWindowPosition: boolean;
|
|
15
|
+
}
|
|
16
|
+
/** `getHeaderCell` for strategy hooks: the table's when available, else an index lookup. */
|
|
17
|
+
export declare function headerCellGetter<T>(env: Pick<RowSnapshotEnv<T>, 'config' | 'rootLocator' | 'resolve' | 'map' | 'table'>): (columnName: string) => Promise<Locator>;
|
|
18
|
+
/**
|
|
19
|
+
* `toJSON({ atomic: true })`: snapshot the row in a single evaluate, then apply column
|
|
20
|
+
* overrides against a frozen reconstruction. Zero inter-column stagger — even in-place
|
|
21
|
+
* React re-renders can't affect it because overrides read from a detached clone.
|
|
22
|
+
*/
|
|
23
|
+
export declare function readRowAtomic<T>(env: RowSnapshotEnv<T>, columns?: string[]): Promise<T>;
|
|
24
|
+
/**
|
|
25
|
+
* Default `toJSON()`: reads each column from the live DOM, navigating to it first
|
|
26
|
+
* (keeps synchronized batches in lock-step) and re-pinning the row before each read.
|
|
27
|
+
*/
|
|
28
|
+
export declare function readRowLive<T>(env: RowSnapshotEnv<T>, columns?: string[]): Promise<T>;
|