From ff3e8ff799d43a5b0d9f14667e549378ccc0e535 Mon Sep 17 00:00:00 2001 From: rickcedwhat-ai Date: Tue, 28 Jul 2026 16:31:51 -0400 Subject: [PATCH] feat(countRows): support optional filters and maxPages override countRows() now accepts optional filters and options ({ exact, maxPages }) to count only matching rows without materializing SmartRow objects. Closes #394 Co-Authored-By: Claude Opus 4.6 --- docs/.vitepress/tableresult-signatures.json | 4 +- docs/api/table-methods.md | 2 +- src/typeContext.ts | 7 +-- src/types.ts | 7 +-- src/useTable.ts | 28 ++++++++--- tests/functional-methods.spec.ts | 55 +++++++++++++++++++++ 6 files changed, 87 insertions(+), 16 deletions(-) diff --git a/docs/.vitepress/tableresult-signatures.json b/docs/.vitepress/tableresult-signatures.json index 4a0aaab9..811ff814 100644 --- a/docs/.vitepress/tableresult-signatures.json +++ b/docs/.vitepress/tableresult-signatures.json @@ -61,8 +61,8 @@ }, { "name": "countRows", - "signature": "countRows: () => Promise", - "comment": "/**\n* Counts the number of rows currently on the page.\n* Does not paginate.\n*/" + "signature": "countRows: (filters?: Record, options?: { exact?: boolean; maxPages?: number }) => Promise", + "comment": "/**\n* Counts the number of rows, optionally filtered.\n* Without arguments, counts all rows. With filters, counts only matching rows.\n* Paginates when pagination is configured.\n*/" }, { "name": "mapColumn", diff --git a/docs/api/table-methods.md b/docs/api/table-methods.md index 9a120e2d..421a2027 100644 --- a/docs/api/table-methods.md +++ b/docs/api/table-methods.md @@ -405,7 +405,7 @@ await table.scrollToColumn('Notes'); ### Signature ```typescript -countRows: () => Promise +countRows: (filters?: Record, options?: { exact?: boolean; maxPages?: number }) => Promise ``` diff --git a/src/typeContext.ts b/src/typeContext.ts index 699b6a69..7cd845e7 100644 --- a/src/typeContext.ts +++ b/src/typeContext.ts @@ -866,10 +866,11 @@ export interface TableResult extends AsyncIterable<{ row: SmartRow; scrollToColumn: (columnName: string) => Promise; /** - * Counts the number of rows currently on the page. - * Does not paginate. + * Counts the number of rows, optionally filtered. + * Without arguments, counts all rows. With filters, counts only matching rows. + * Paginates when pagination is configured. */ - countRows: () => Promise; + countRows: (filters?: Record, options?: { exact?: boolean; maxPages?: number }) => Promise; /** * Iterates over rows and extracts the value of a single column. diff --git a/src/types.ts b/src/types.ts index b5fbd522..e16c4e5b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -866,10 +866,11 @@ export interface TableResult extends AsyncIterable<{ row: SmartRow; scrollToColumn: (columnName: string) => Promise; /** - * Counts the number of rows currently on the page. - * Does not paginate. + * Counts the number of rows, optionally filtered. + * Without arguments, counts all rows. With filters, counts only matching rows. + * Paginates when pagination is configured. */ - countRows: () => Promise; + countRows: (filters?: Record, options?: { exact?: boolean; maxPages?: number }) => Promise; /** * Iterates over rows and extracts the value of a single column. diff --git a/src/useTable.ts b/src/useTable.ts index 11e0aca3..25f02377 100644 --- a/src/useTable.ts +++ b/src/useTable.ts @@ -322,27 +322,41 @@ export const useTable = (rootLocator: Locator, configOptions: TableConf return resolve(config.headerSelector as Selector, rootLocator).nth(idx); }, - countRows: async (): Promise => { + countRows: async (filters?: Record, options?: { exact?: boolean; maxPages?: number }): Promise => { if (tableState.empty) return 0; await _autoInit(); + const filtersRecord = (filters ?? {}) as Record; + const hasFilters = Object.keys(filtersRecord).length > 0; + const effectiveMaxPages = options?.maxPages ?? config.maxPages; const pag = config.strategies.pagination; - const hasPagination = config.maxPages > 1 && !!(pag?.goNext || pag?.goNextBulk); + const hasPagination = effectiveMaxPages > 1 && !!(pag?.goNext || pag?.goNextBulk); + + const resolveRows = () => { + let rows = resolve(config.rowSelector, rootLocator); + if (hasFilters) { + const map = tableMapper.getMapSync(); + if (!map) throw new Error('Initialization Error: Table map not available. Call "await table.init()" first.'); + rows = filterEngine.applyFilters(rows, filtersRecord, map, options?.exact ?? false, rootLocator.page(), rootLocator); + } + return rows; + }; + if (!hasPagination) { - log("countRows: counting rows in current viewport (no pagination)"); - return resolve(config.rowSelector, rootLocator).count(); + log(`countRows: counting rows in current viewport (no pagination)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`); + return resolveRows().count(); } - log(`countRows: paginating up to ${config.maxPages} page(s)`); + log(`countRows: paginating up to ${effectiveMaxPages} page(s)${hasFilters ? ` filters=${safeStringify(filtersRecord)}` : ''}`); const tracker = new ElementTracker('countRows'); let total = 0; let pagesScanned = 1; let paginationError: unknown; try { while (true) { - const newIndices = await tracker.getUnseenIndices(resolve(config.rowSelector, rootLocator)); + const newIndices = await tracker.getUnseenIndices(resolveRows()); total += newIndices.length; log(`countRows: page ${pagesScanned} — ${newIndices.length} row(s) (running total: ${total})`); - if (pagesScanned >= config.maxPages) break; + if (pagesScanned >= effectiveMaxPages) break; if (!await _advancePage(false)) break; pagesScanned++; } diff --git a/tests/functional-methods.spec.ts b/tests/functional-methods.spec.ts index 8a841bcb..4777f7e0 100644 --- a/tests/functional-methods.spec.ts +++ b/tests/functional-methods.spec.ts @@ -562,6 +562,61 @@ test.describe('new API additions', () => { expect(await page.locator('#current-page').innerText()).toBe('1'); }); + test('countRows with filters counts only matching rows across pages', async ({ page }) => { + await page.setContent(TABLE_HTML); + const table = useTable(page.locator('#tbl'), { + maxPages: 3, + strategies: { + pagination: Strategies.Pagination.click({ + next: () => page.locator('#next'), + first: () => page.locator('#first'), + }), + }, + }); + + expect(await table.countRows({ Status: 'Inactive' }, { exact: true })).toBe(3); + expect(await page.locator('#current-page').innerText()).toBe('1'); + }); + + test('countRows with filters on single page (no pagination)', async ({ page }) => { + await page.setContent(TABLE_HTML); + const table = useTable(page.locator('#tbl'), {}); + + expect(await table.countRows({ Status: 'Inactive' }, { exact: true })).toBe(1); + }); + + test('countRows with maxPages option limits pagination depth', async ({ page }) => { + await page.setContent(TABLE_HTML); + const table = useTable(page.locator('#tbl'), { + maxPages: 3, + strategies: { + pagination: Strategies.Pagination.click({ + next: () => page.locator('#next'), + first: () => page.locator('#first'), + }), + }, + }); + + expect(await table.countRows({ Status: 'Inactive' }, { exact: true, maxPages: 2 })).toBe(2); + expect(await page.locator('#current-page').innerText()).toBe('1'); + }); + + test('countRows with no matching filter returns 0', async ({ page }) => { + await page.setContent(TABLE_HTML); + const table = useTable(page.locator('#tbl'), { + maxPages: 3, + strategies: { + pagination: Strategies.Pagination.click({ + next: () => page.locator('#next'), + first: () => page.locator('#first'), + }), + }, + }); + + expect(await table.countRows({ Status: 'Deleted' })).toBe(0); + expect(await page.locator('#current-page').innerText()).toBe('1'); + }); + test('mapColumn collects values for a single column', async ({ page }) => { await page.setContent(TABLE_HTML); const table = makeTable(page);