Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/.vitepress/smartrow-signatures.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
"signature": "smartFill: (data: Partial<T> | Record<string, any>, options?: FillOptions) => Promise<void>",
"comment": "/**\n* Intelligently fills form fields in the row.\n* Automatically detects input types (text, select, checkbox, contenteditable).\n*\n* @param data - Column-value pairs to fill\n* @param options - Optional configuration\n* @param options.inputMappers - Custom input selectors per column\n* @example\n* // Auto-detection\n* await row.smartFill({ Name: 'John', Status: 'Active', Subscribe: true });\n*\n* // Custom input mappers\n* await row.smartFill(\n* { Name: 'John' },\n* { inputMappers: { Name: (cell) => cell.locator('.custom-input') } }\n* );\n*/"
},
{
"name": "getValue",
"signature": "getValue(column: string): Promise<string>",
"comment": "/**\n* Get the resolved value of any column — real, override, or synthetic.\n* @param column - Column name (case-sensitive)\n* @returns The column value as a string\n*/"
},
{
"name": "wasFound",
"signature": "wasFound(): boolean",
Expand Down
5 changes: 5 additions & 0 deletions docs/.vitepress/tableconfig-signatures.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
"signature": "columnOverrides?: Partial<Record<keyof T, ColumnOverride<T[keyof T]>>>",
"comment": "/**\n* Unified interface for reading and writing data to specific columns.\n* Overrides both default extraction (toJSON) and filling (smartFill) logic.\n*/"
},
{
"name": "syntheticColumns",
"signature": "syntheticColumns?: Record<string, SyntheticColumnDef<T>>",
"comment": "/**\n* Computed columns with no DOM presence. Each key becomes a virtual column name\n* available in `toJSON()`, `getValue()`, and `findRow()`/`findRows()` filters.\n* The `compute` function receives the full SmartRow and must only read real or\n* override columns (no chaining between synthetics).\n*/"
},
{
"name": "emptyState",
"signature": "emptyState?: Locator",
Expand Down
55 changes: 42 additions & 13 deletions src/engine/rowFinder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,21 @@ export class RowFinder<T = any> {
private splitFilters(filters: Record<string, FilterValue>): {
domFilters: Record<string, FilterValue>;
overrideFilters: Record<string, FilterValue>;
syntheticFilters: Record<string, FilterValue>;
} {
const domFilters: Record<string, FilterValue> = {};
const overrideFilters: Record<string, FilterValue> = {};
const syntheticFilters: Record<string, FilterValue> = {};
for (const [key, value] of Object.entries(filters)) {
if (this.config.columnOverrides?.[key as keyof T]?.read) {
if (this.config.syntheticColumns?.[key]) {
syntheticFilters[key] = value;
} else if (this.config.columnOverrides?.[key as keyof T]?.read) {
overrideFilters[key] = value;
} else {
domFilters[key] = value;
}
}
return { domFilters, overrideFilters };
return { domFilters, overrideFilters, syntheticFilters };
}

static matchReadValue(readValue: string, filterValue: FilterValue, exact: boolean): boolean {
Expand All @@ -58,7 +62,7 @@ export class RowFinder<T = any> {
return true;
}

private async matchesOverrideFilters(
async matchesOverrideFilters(
rowLocator: Locator,
overrideFilters: Record<string, FilterValue>,
map: Map<string, number>,
Expand Down Expand Up @@ -86,6 +90,21 @@ export class RowFinder<T = any> {
return true;
}

async matchesSyntheticFilters(
rowLocator: Locator,
syntheticFilters: Record<string, FilterValue>,
map: Map<string, number>,
exact: boolean
): Promise<boolean> {
const smartRow = this.makeSmartRow(rowLocator, map, undefined);
for (const [colName, filterValue] of Object.entries(syntheticFilters)) {
const def = this.config.syntheticColumns![colName];
const computedValue = String(await def.compute(smartRow));
if (!RowFinder.matchReadValue(computedValue, filterValue, exact)) return false;
}
return true;
}

public async findRow(
filters: Record<string, FilterValue>,
options: { exact?: boolean, maxPages?: number } = {}
Expand Down Expand Up @@ -129,8 +148,9 @@ export class RowFinder<T = any> {
const tracker = new ElementTracker('findRows');

try {
const { domFilters, overrideFilters } = this.splitFilters(filtersRecord);
const { domFilters, overrideFilters, syntheticFilters } = this.splitFilters(filtersRecord);
const hasOverrideFilters = Object.keys(overrideFilters).length > 0;
const hasSyntheticFilters = Object.keys(syntheticFilters).length > 0;

const collectMatches = async () => {
let rowLocators = this.resolve(this.config.rowSelector, this.rootLocator);
Expand Down Expand Up @@ -183,6 +203,11 @@ export class RowFinder<T = any> {
continue;
}

if (hasSyntheticFilters && !await this.matchesSyntheticFilters(currentRows[idx], syntheticFilters, map, options?.exact ?? false)) {
barrier?.markFinished();
continue;
}

allRows.push(smartRow);
added++;
}
Expand Down Expand Up @@ -249,8 +274,8 @@ export class RowFinder<T = any> {
}

const allRows = this.resolve(this.config.rowSelector, this.rootLocator);
const { domFilters, overrideFilters } = this.splitFilters(filters);
const hasOverrideFilters = Object.keys(overrideFilters).length > 0;
const { domFilters, overrideFilters, syntheticFilters } = this.splitFilters(filters);
const hasPostFilters = Object.keys(overrideFilters).length > 0 || Object.keys(syntheticFilters).length > 0;

let matchedRows = allRows;
if (Object.keys(domFilters).length > 0) {
Expand All @@ -264,21 +289,25 @@ export class RowFinder<T = any> {
);
}

if (!hasOverrideFilters) {
if (!hasPostFilters) {
const count = await matchedRows.count();
logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: Found ${count} matches.`);
if (count > 1) await this.throwIfAmbiguous(await matchedRows.all(), filters, map);
if (count === 1) return matchedRows.first();
} else {
const candidates = await matchedRows.all();
logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${candidates.length} DOM candidate(s), post-filtering with override columns`);
logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${candidates.length} DOM candidate(s), post-filtering with override/synthetic columns`);
const results = await Promise.all(
candidates.map(c => this.matchesOverrideFilters(c, overrideFilters, map, options.exact || false))
candidates.map(async c => {
if (Object.keys(overrideFilters).length > 0 && !await this.matchesOverrideFilters(c, overrideFilters, map, options.exact || false)) return false;
if (Object.keys(syntheticFilters).length > 0 && !await this.matchesSyntheticFilters(c, syntheticFilters, map, options.exact || false)) return false;
return true;
})
);
const overrideMatches = candidates.filter((_, i) => results[i]);
logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${overrideMatches.length} match(es) after override filter`);
if (overrideMatches.length > 1) await this.throwIfAmbiguous(overrideMatches, filters, map);
if (overrideMatches.length === 1) return overrideMatches[0];
const postFilterMatches = candidates.filter((_, i) => results[i]);
logDebug(this.config, 'verbose',`Page ${this.tableState.currentPageIndex}: ${postFilterMatches.length} match(es) after post-filter`);
if (postFilterMatches.length > 1) await this.throwIfAmbiguous(postFilterMatches, filters, map);
if (postFilterMatches.length === 1) return postFilterMatches[0];
}

if (pagesScanned < effectiveMaxPages) {
Expand Down
15 changes: 13 additions & 2 deletions src/engine/tableMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,19 @@ export class TableMapper {
const rawHeaders = await strategy(context);
const entries = await this.processHeaders(rawHeaders);

// Success
this._headerMap = new Map(entries);
// Success — validate before caching so a collision doesn't leave stale state
const headerMap = new Map(entries);

const syntheticNames = Object.keys(this.config.syntheticColumns ?? {});
const collisions = syntheticNames.filter(name => headerMap.has(name));
if (collisions.length > 0) {
throw new Error(
`Synthetic column name(s) collide with real header(s): ${collisions.join(', ')}. ` +
`Rename the synthetic column or use columnOverrides for columns that exist in the DOM.`
);
}

this._headerMap = headerMap;
this.log(`Mapped ${entries.length} columns: ${JSON.stringify(entries.map(e => e[0]))}`);
return this._headerMap;

Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export type {
GetCellLocatorFn,
GetActiveCellFn,
DebugConfig,
SyntheticColumnDef,
ColumnOverride,
ColumnOverrideReadContext,
} from './types';

// Export namespace-like strategy collections
Expand Down
74 changes: 72 additions & 2 deletions src/smartRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -375,9 +375,12 @@ const createSmartRow = <T = any>(

// Attach Methods
smart.getCell = (colName: string): SmartCell => {
if (config.syntheticColumns?.[colName]) {
throw new Error(`Column "${colName}" is synthetic (no DOM cell) — use getValue("${colName}") or toJSON() instead`);
}
const idx = map.get(colName);
if (idx === undefined) {
throw new Error(buildColumnNotFoundError(colName, Array.from(map.keys())));
throw new Error(buildColumnNotFoundError(colName, [...map.keys(), ...Object.keys(config.syntheticColumns ?? {})]));
}

let baseLocator: Locator;
Expand Down Expand Up @@ -438,6 +441,47 @@ const createSmartRow = <T = any>(
return !(smart as any)[SENTINEL_ROW];
};

smart.getValue = async (colName: string): Promise<string> => {
const syntheticDef = config.syntheticColumns?.[colName];
if (syntheticDef) {
const guardedRow = Object.create(smart) as typeof smart;
guardedRow.getValue = async (innerCol: string): Promise<string> => {
if (config.syntheticColumns?.[innerCol]) {
throw new Error(
`[SmartTable] Synthetic column "${innerCol}" cannot be read from inside another synthetic column's compute(). ` +
`Synthetic columns may only reference real or override columns.`
);
}
return smart.getValue(innerCol);
};
return String(await syntheticDef.compute(guardedRow));
}

const idx = map.get(colName);
if (idx === undefined) {
throw new Error(buildColumnNotFoundError(colName, [...map.keys(), ...Object.keys(config.syntheticColumns ?? {})]));
}

const columnOverride = config.columnOverrides?.[colName as keyof T];
const cell = config.strategies.getCellLocator
? config.strategies.getCellLocator({
row: rowLocator, root: rootLocator, columnName: colName,
columnIndex: idx, rowIndex, page: rootLocator.page(), config,
})
: resolve(config.cellSelector, rowLocator).nth(idx);

if (columnOverride?.read) {
const getCell = (name: string): Locator => {
const ci = map.get(name);
if (ci === undefined) throw new Error(`Column "${name}" not found`);
return resolve(config.cellSelector, rowLocator).nth(ci);
};
return String(await columnOverride.read(cell, { row: smart, columnName: colName, columnIndex: idx, getCell }));
}

return (await cell.innerText()).trim();
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

smart.toJSON = async (options?: { columns?: string[]; atomic?: boolean }): Promise<T> => {
// Atomic mode: snapshot the row in a single evaluate, then apply column overrides
// against a frozen reconstruction. Zero inter-column stagger — even in-place React
Expand Down Expand Up @@ -527,6 +571,17 @@ const createSmartRow = <T = any>(
result[col] = snapshot.cells[idx].text;
}
}

for (const [name, def] of Object.entries(config.syntheticColumns ?? {})) {
if (options?.columns && !options.columns.includes(name)) continue;
const snapshotRow = Object.create(smart) as typeof smart;
snapshotRow.getValue = async (col: string): Promise<string> => {
if (col in result) return String(result[col]);
return smart.getValue(col);
};
result[name] = String(await def.compute(snapshotRow));
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
return result as unknown as T;
} finally {
await cleanupSnapshot();
Expand Down Expand Up @@ -717,6 +772,17 @@ const createSmartRow = <T = any>(
result[col] = (text || '').trim();
}
}

for (const [name, def] of Object.entries(config.syntheticColumns ?? {})) {
if (options?.columns && !options.columns.includes(name)) continue;
const snapshotRow = Object.create(smart) as typeof smart;
snapshotRow.getValue = async (col: string): Promise<string> => {
if (col in result) return String(result[col]);
return smart.getValue(col);
};
result[name] = String(await def.compute(snapshotRow));
}

return result as unknown as T;
};

Expand All @@ -726,9 +792,13 @@ const createSmartRow = <T = any>(
for (const [colName, value] of Object.entries(data)) {
if (value === undefined) continue;

if (config.syntheticColumns?.[colName]) {
throw new Error(`Cannot fill synthetic column "${colName}" — it has no DOM cell`);
}

const colIdx = map.get(colName);
if (colIdx === undefined) {
throw new Error(buildColumnNotFoundError(colName, Array.from(map.keys())));
throw new Error(buildColumnNotFoundError(colName, [...map.keys(), ...Object.keys(config.syntheticColumns ?? {})]));
}

await _navigateToCell({
Expand Down
19 changes: 19 additions & 0 deletions src/typeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,13 @@ export type SmartRow<T = any> = Locator & {
*/
smartFill: (data: Partial<T> | Record<string, any>, options?: FillOptions) => Promise<void>;

/**
* Get the resolved value of any column — real, override, or synthetic.
* @param column - Column name (case-sensitive)
* @returns The column value as a string
*/
getValue(column: string): Promise<string>;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Returns whether the row exists in the DOM (i.e. is not a sentinel row).
*/
Expand Down Expand Up @@ -473,6 +480,10 @@ export interface ColumnOverrideReadContext {
getCell: (columnName: string) => Locator;
}

export interface SyntheticColumnDef<T = any> {
compute: (row: SmartRow<T>) => Promise<string | number> | string | number;
}

export interface ColumnOverride<TValue = any> {
/**
* How to extract the value from the cell.
Expand Down Expand Up @@ -685,6 +696,14 @@ export interface TableConfig<T = any> {
*/
columnOverrides?: Partial<Record<keyof T, ColumnOverride<T[keyof T]>>>;

/**
* Computed columns with no DOM presence. Each key becomes a virtual column name
* available in \`toJSON()\`, \`getValue()\`, and \`findRow()\`/\`findRows()\` filters.
* The \`compute\` function receives the full SmartRow and must only read real or
* override columns (no chaining between synthetics).
*/
syntheticColumns?: Record<string, SyntheticColumnDef<T>>;

/**
* Locator for an empty-state element that replaces the table when there are no results.
* If header resolution fails during init() and this locator is visible, init() succeeds
Expand Down
19 changes: 19 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,13 @@ export type SmartRow<T = any> = Locator & {
*/
smartFill: (data: Partial<T> | Record<string, any>, options?: FillOptions) => Promise<void>;

/**
* Get the resolved value of any column — real, override, or synthetic.
* @param column - Column name (case-sensitive)
* @returns The column value as a string
*/
getValue(column: string): Promise<string>;

/**
* Returns whether the row exists in the DOM (i.e. is not a sentinel row).
*/
Expand Down Expand Up @@ -470,6 +477,10 @@ export interface ColumnOverrideReadContext {
getCell: (columnName: string) => Locator;
}

export interface SyntheticColumnDef<T = any> {
compute: (row: SmartRow<T>) => Promise<string | number> | string | number;
}

export interface ColumnOverride<TValue = any> {
/**
* How to extract the value from the cell.
Expand Down Expand Up @@ -685,6 +696,14 @@ export interface TableConfig<T = any> {
*/
columnOverrides?: Partial<Record<keyof T, ColumnOverride<T[keyof T]>>>;

/**
* Computed columns with no DOM presence. Each key becomes a virtual column name
* available in `toJSON()`, `getValue()`, and `findRow()`/`findRows()` filters.
* The `compute` function receives the full SmartRow and must only read real or
* override columns (no chaining between synthetics).
*/
syntheticColumns?: Record<string, SyntheticColumnDef<T>>;

Comment thread
coderabbitai[bot] marked this conversation as resolved.
/**
* Locator for an empty-state element that replaces the table when there are no results.
* If header resolution fails during init() and this locator is visible, init() succeeds
Expand Down
Loading
Loading