From 46921d567ef40fb4271621c817ddce11163c8581 Mon Sep 17 00:00:00 2001 From: riccardoperra Date: Mon, 27 Jul 2026 20:04:57 +0200 Subject: [PATCH 1/6] fix: react adapter sync state --- .../react/basic-external-atoms/src/main.tsx | 18 +- .../tests/e2e/smoke.spec.ts | 27 + .../tests/e2e/smoke.spec.ts | 37 + packages/react-table/package.json | 4 +- packages/react-table/src/reactivity.ts | 116 ++- packages/react-table/src/useTable.ts | 57 +- packages/react-table/tests/test-setup.ts | 3 + packages/react-table/tests/useTable.test.tsx | 930 ++++++++++++++++++ packages/react-table/vite.config.ts | 1 + .../src/core/table/constructTable.ts | 30 +- .../src/core/table/coreTablesFeature.utils.ts | 41 +- .../tests/unit/core/tableAtoms.test.ts | 38 + pnpm-lock.yaml | 6 + 13 files changed, 1268 insertions(+), 40 deletions(-) create mode 100644 packages/react-table/tests/test-setup.ts create mode 100644 packages/react-table/tests/useTable.test.tsx diff --git a/examples/react/basic-external-atoms/src/main.tsx b/examples/react/basic-external-atoms/src/main.tsx index 6a1dd8a7c8..546512e67e 100644 --- a/examples/react/basic-external-atoms/src/main.tsx +++ b/examples/react/basic-external-atoms/src/main.tsx @@ -2,7 +2,7 @@ import React from 'react' import { TanStackDevtools } from '@tanstack/react-devtools' import ReactDOM from 'react-dom/client' import './index.css' -import { useCreateAtom, useSelector } from '@tanstack/react-store' +import { useCreateAtom } from '@tanstack/react-store' import { createColumnHelper, createPaginatedRowModel, @@ -79,14 +79,8 @@ function App() { pageSize: 10, }) - // Subscribe to each atom independently — fine-grained reactivity. - const sorting = useSelector(sortingAtom) - const pagination = useSelector(paginationAtom) - - console.log('sorting', sorting) - console.log('pagination', pagination) - - // Create the table and pass your per-slice external atoms. + // Pass the per-slice external atoms directly. The React adapter subscribes + // through table.store, so no separate useSelector call is needed here. const table = useTable( { key: 'basic-external-atoms', // needed for devtools @@ -185,7 +179,7 @@ function App() {
Page
- {(table.atoms.pagination.get().pageIndex + 1).toLocaleString()} of{' '} + {(table.state.pagination.pageIndex + 1).toLocaleString()} of{' '} {table.getPageCount().toLocaleString()}
@@ -195,7 +189,7 @@ function App() { type="number" min="1" max={table.getPageCount()} - defaultValue={table.atoms.pagination.get().pageIndex + 1} + defaultValue={table.state.pagination.pageIndex + 1} onChange={(e) => { const page = e.target.value ? Number(e.target.value) - 1 : 0 table.setPageIndex(page) @@ -204,7 +198,7 @@ function App() { />
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/basic-external-state/src/main.tsx b/examples/react/basic-external-state/src/main.tsx index 94631fbbe2..5fc16f303d 100644 --- a/examples/react/basic-external-state/src/main.tsx +++ b/examples/react/basic-external-state/src/main.tsx @@ -154,7 +154,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/basic-subscribe/src/main.tsx b/examples/react/basic-subscribe/src/main.tsx index e5901be486..79122c53c2 100644 --- a/examples/react/basic-subscribe/src/main.tsx +++ b/examples/react/basic-subscribe/src/main.tsx @@ -269,7 +269,7 @@ function App() {
) diff --git a/examples/react/column-ordering/src/main.tsx b/examples/react/column-ordering/src/main.tsx index 3fb1f8317e..022cc53c6a 100644 --- a/examples/react/column-ordering/src/main.tsx +++ b/examples/react/column-ordering/src/main.tsx @@ -183,7 +183,9 @@ function App() { ))} -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-ordering/tests/e2e/smoke.spec.ts b/examples/react/column-ordering/tests/e2e/smoke.spec.ts index 2eb199bf28..9ccdc15f89 100644 --- a/examples/react/column-ordering/tests/e2e/smoke.spec.ts +++ b/examples/react/column-ordering/tests/e2e/smoke.spec.ts @@ -1,10 +1,32 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** Leaf columns in their declared order. The toggle panel labels each by id. */ +const LEAF_COLUMNS = [ + 'firstName', + 'lastName', + 'age', + 'visits', + 'status', + 'progress', +] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +44,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +59,159 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() - return text?.replace(/\s+/g, ' ').trim() ?? '' +function shuffleButton(page: Page) { + return page.getByRole('button', { name: 'Shuffle Columns' }) } -test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) +function columnToggle(page: Page, label: string) { + return page.getByLabel(label, { exact: true }) +} + +/** Header rows run group to leaf, so the leaf row holds the visible columns. */ +function leafHeaderCells(page: Page) { + return page.locator('thead tr').last().locator('th') +} - try { - const table = page.locator('table').first() +async function readHeaderOrder(page: Page) { + const texts = await leafHeaderCells(page).allTextContents() + return texts.map((text) => text.trim()) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readState(page: Page) { + const text = await page.getByTestId('table-state').textContent() - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + return JSON.parse(text ?? '{}') as { + columnOrder?: Array + columnVisibility?: Record } +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() + return text?.replace(/\s+/g, ' ').trim() ?? '' +} + +test('renders the table without crashing', async ({ page }) => { + const errors = await openExample(page) + const table = page.locator('table').first() + + await expect(table).toBeVisible() + await expect(leafHeaderCells(page)).toHaveCount(LEAF_COLUMNS.length) + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(shuffleButton(page)).toBeVisible() + // No explicit order yet, so the columns render as declared. + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toEqual([]) + + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('shuffles the columns into a new order', async ({ page }) => { + const errors = await openExample(page) + const before = await readHeaderOrder(page) + + await shuffleButton(page).click() + + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toHaveLength(LEAF_COLUMNS.length) + + const after = await readHeaderOrder(page) + + // Shuffling permutes the columns: same set, different sequence. + expect([...after].sort()).toEqual([...before].sort()) + expect(after).not.toEqual(before) + + const order = (await readState(page)).columnOrder ?? [] + expect([...order].sort()).toEqual([...LEAF_COLUMNS].sort()) + + expect(errors).toEqual([]) +}) + +test('reorders the body cells to match the headers', async ({ page }) => { + const errors = await openExample(page) + + await shuffleButton(page).click() + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toHaveLength(LEAF_COLUMNS.length) + + const order = (await readState(page)).columnOrder ?? [] + const footerOrder = ( + await page.locator('tfoot tr').first().locator('th').allTextContents() + ).map((text) => text.trim()) + + // The footer renders raw column ids, so it must match the shuffled order + // exactly. That proves the cells moved with their headers. + expect(footerOrder).toEqual(order) + + expect(errors).toEqual([]) +}) + +test('keeps the shuffled order when a column is hidden', async ({ page }) => { + const errors = await openExample(page) + + await shuffleButton(page).click() + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toHaveLength(LEAF_COLUMNS.length) + + const order = (await readState(page)).columnOrder ?? [] + + await columnToggle(page, 'age').uncheck() + + await expect + .poll(async () => (await readState(page)).columnVisibility ?? {}) + .toEqual({ age: false }) + await expect(leafHeaderCells(page)).toHaveCount(LEAF_COLUMNS.length - 1) + + // Hiding removes a column from the render without disturbing the rest. + const footerOrder = ( + await page.locator('tfoot tr').first().locator('th').allTextContents() + ).map((text) => text.trim()) + expect(footerOrder).toEqual(order.filter((id) => id !== 'age')) + + expect(errors).toEqual([]) +}) + +test('keeps the shuffled order when data is regenerated', async ({ page }) => { + const errors = await openExample(page) + + await shuffleButton(page).click() + await expect + .poll(async () => (await readState(page)).columnOrder ?? []) + .toHaveLength(LEAF_COLUMNS.length) + + const order = await readHeaderOrder(page) + + // Column order lives in table state, so replacing the rows must not reset it. + await page.getByRole('button', { name: /^Regenerate Data$/i }).click() + + await expect(page.locator('tbody tr')).toHaveCount(20) + expect(await readHeaderOrder(page)).toEqual(order) + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-pinning-split/src/main.tsx b/examples/react/column-pinning-split/src/main.tsx index 43e10dfbd5..747de17796 100644 --- a/examples/react/column-pinning-split/src/main.tsx +++ b/examples/react/column-pinning-split/src/main.tsx @@ -359,7 +359,9 @@ function App() { -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-pinning-split/tests/e2e/smoke.spec.ts b/examples/react/column-pinning-split/tests/e2e/smoke.spec.ts index 404c500e1d..c101c0ca57 100644 --- a/examples/react/column-pinning-split/tests/e2e/smoke.spec.ts +++ b/examples/react/column-pinning-split/tests/e2e/smoke.spec.ts @@ -1,10 +1,29 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const LEAF_COUNT = 6 + +/** The three tables render the start pinned, unpinned and end pinned columns. */ +const START_TABLE = 0 +const CENTER_TABLE = 1 +const END_TABLE = 2 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +41,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +56,164 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +function tableAt(page: Page, index: number) { + return page.locator('table.outlined-table').nth(index) +} + +/** Header rows run group to leaf, so the leaf row holds the data columns. */ +function leafHeaderCells(page: Page, tableIndex: number) { + return tableAt(page, tableIndex).locator('thead tr').last().locator('th') +} + +function leafHeader(page: Page, tableIndex: number, name: string) { + return leafHeaderCells(page, tableIndex).filter({ hasText: name }) +} + +/** An unpinned header offers `<=` and `=>`; a pinned one swaps in `X`. */ +function pinButton( + page: Page, + tableIndex: number, + name: string, + label: '<=' | 'X' | '=>', +) { + return leafHeader(page, tableIndex, name).getByRole('button', { + name: label, + exact: true, + }) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnPinning(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnPinning?: { start?: Array; end?: Array } + } + + return { + start: state.columnPinning?.start ?? [], + end: state.columnPinning?.end ?? [], + } +} + +async function leafCount(page: Page, tableIndex: number) { + return leafHeaderCells(page, tableIndex).count() } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function getFirstBodyRowText(page: Page) { + const text = await tableAt(page, CENTER_TABLE) + .locator('tbody tr') + .first() + .textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) - try { - const table = page.locator('table').nth(1) + // One table per pinning region, even when two of them are empty. + await expect(page.locator('table.outlined-table')).toHaveCount(3) + expect(await readColumnPinning(page)).toEqual({ start: [], end: [] }) + // Nothing is pinned, so every column lives in the centre table. + expect(await leafCount(page, START_TABLE)).toBe(0) + expect(await leafCount(page, CENTER_TABLE)).toBe(LEAF_COUNT) + expect(await leafCount(page, END_TABLE)).toBe(0) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').nth(1) - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect( + tableAt(page, CENTER_TABLE).locator('tbody tr').first(), + ).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('moves a column into the start table when pinned', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, CENTER_TABLE, 'Visits', '<=').click() + + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + // The column leaves the centre table and appears in the start table, so the + // split is driven by pinning state rather than by a separate column list. + await expect.poll(() => leafCount(page, START_TABLE)).toBe(1) + expect(await leafCount(page, CENTER_TABLE)).toBe(LEAF_COUNT - 1) + await expect(leafHeaderCells(page, START_TABLE)).toContainText(['Visits']) + await expect(leafHeaderCells(page, CENTER_TABLE)).not.toContainText([ + 'Visits', + ]) + + expect(errors).toEqual([]) +}) + +test('moves a column into the end table when pinned', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, CENTER_TABLE, 'Age', '=>').click() + + await expect + .poll(async () => (await readColumnPinning(page)).end) + .toEqual(['age']) + await expect.poll(() => leafCount(page, END_TABLE)).toBe(1) + expect(await leafCount(page, CENTER_TABLE)).toBe(LEAF_COUNT - 1) + await expect(leafHeaderCells(page, END_TABLE)).toContainText(['Age']) + + expect(errors).toEqual([]) +}) + +test('returns a column to the centre table when unpinned', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, CENTER_TABLE, 'Visits', '<=').click() + await expect.poll(() => leafCount(page, START_TABLE)).toBe(1) + + await pinButton(page, START_TABLE, 'Visits', 'X').click() + + await expect + .poll(() => readColumnPinning(page)) + .toEqual({ start: [], end: [] }) + await expect.poll(() => leafCount(page, START_TABLE)).toBe(0) + expect(await leafCount(page, CENTER_TABLE)).toBe(LEAF_COUNT) + + expect(errors).toEqual([]) +}) + +test('keeps every table showing the same rows', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, CENTER_TABLE, 'Visits', '<=').click() + await expect.poll(() => leafCount(page, START_TABLE)).toBe(1) + await pinButton(page, CENTER_TABLE, 'Age', '=>').click() + await expect.poll(() => leafCount(page, END_TABLE)).toBe(1) + + // The three tables are one table split three ways, so their bodies must stay + // row for row aligned or the split would visibly tear. + const rowCounts = await Promise.all( + [START_TABLE, CENTER_TABLE, END_TABLE].map((index) => + tableAt(page, index).locator('tbody tr').count(), + ), + ) + + expect(rowCounts[0]).toBeGreaterThan(0) + expect(rowCounts[1]).toBe(rowCounts[0]) + expect(rowCounts[2]).toBe(rowCounts[0]) + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-pinning-sticky/src/main.tsx b/examples/react/column-pinning-sticky/src/main.tsx index 6f00a9eff0..f50d82a4d8 100644 --- a/examples/react/column-pinning-sticky/src/main.tsx +++ b/examples/react/column-pinning-sticky/src/main.tsx @@ -273,7 +273,9 @@ function App() { -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-pinning-sticky/tests/e2e/smoke.spec.ts b/examples/react/column-pinning-sticky/tests/e2e/smoke.spec.ts index 2eb199bf28..e8aa894e8d 100644 --- a/examples/react/column-pinning-sticky/tests/e2e/smoke.spec.ts +++ b/examples/react/column-pinning-sticky/tests/e2e/smoke.spec.ts @@ -1,10 +1,22 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +34,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +49,159 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +/** Header rows run group to leaf, so the leaf row holds the data columns. */ +function leafHeaderCells(page: Page) { + return page.locator('thead tr').last().locator('th') +} + +function leafHeader(page: Page, name: string) { + return leafHeaderCells(page).filter({ hasText: name }) +} + +/** An unpinned header offers `<=` and `=>`; a pinned one swaps in `X`. */ +function pinButton(page: Page, name: string, label: '<=' | 'X' | '=>') { + return leafHeader(page, name).getByRole('button', { + name: label, + exact: true, + }) +} + +function scrollContainer(page: Page) { + return page.locator('.table-container') +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnPinning(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnPinning?: { start?: Array; end?: Array } + } + + return { + start: state.columnPinning?.start ?? [], + end: state.columnPinning?.end ?? [], + } } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +/** + * Scrolls the container as far right as it goes and returns the distance + * travelled. How much room there is depends on the viewport, so the tests work + * from the real value rather than assuming one. + */ +async function scrollToEnd(page: Page) { + const maxScroll = await scrollContainer(page).evaluate( + (element) => element.scrollWidth - element.clientWidth, + ) + + expect(maxScroll).toBeGreaterThan(50) + + await scrollContainer(page).evaluate((element) => { + element.scrollLeft = element.scrollWidth + element.dispatchEvent(new Event('scroll')) + }) + + await expect + .poll(() => scrollContainer(page).evaluate((element) => element.scrollLeft)) + .toBeGreaterThan(maxScroll - 2) + + return maxScroll +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) - try { - const table = page.locator('table').first() + await expect(page.locator('table').first()).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(scrollContainer(page)).toBeVisible() + expect(await readColumnPinning(page)).toEqual({ start: [], end: [] }) + // Unpinned columns scroll with the rest, so they are not sticky. + await expect(leafHeaderCells(page).first()).toHaveCSS('position', 'relative') - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('makes a pinned column sticky', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + // Pinning switches the cell from relative to sticky and gives it an offset, + // which is what actually holds it in place while the rest scrolls. + await expect(leafHeader(page, 'Visits')).toHaveCSS('position', 'sticky') + await expect(leafHeader(page, 'Visits')).toHaveCSS('left', '0px') + + expect(errors).toEqual([]) +}) + +test('holds a start pinned column in place while scrolling', async ({ + page, +}) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + await expect(leafHeader(page, 'Visits')).toHaveCSS('position', 'sticky') + + const pinnedBefore = await leafHeader(page, 'Visits').boundingBox() + const unpinnedBefore = await leafHeader(page, 'Status').boundingBox() + + const scrolled = await scrollToEnd(page) + + const pinnedAfter = await leafHeader(page, 'Visits').boundingBox() + const unpinnedAfter = await leafHeader(page, 'Status').boundingBox() + + // The pinned header stays put while its unpinned neighbour travels left by + // the full scroll distance. That difference is the whole point of sticky. + expect(Math.abs((pinnedAfter?.x ?? 0) - (pinnedBefore?.x ?? 0))).toBeLessThan( + 2, + ) + expect((unpinnedBefore?.x ?? 0) - (unpinnedAfter?.x ?? 0)).toBeGreaterThan( + scrolled - 2, + ) + + expect(errors).toEqual([]) +}) + +test('unpins a column back into the scrolling area', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + await expect(leafHeader(page, 'Visits')).toHaveCSS('position', 'sticky') + + await pinButton(page, 'Visits', 'X').click() + + await expect + .poll(() => readColumnPinning(page)) + .toEqual({ start: [], end: [] }) + await expect(leafHeader(page, 'Visits')).toHaveCSS('position', 'relative') + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-pinning/src/main.tsx b/examples/react/column-pinning/src/main.tsx index f0786e79d6..d65a8a0fdf 100644 --- a/examples/react/column-pinning/src/main.tsx +++ b/examples/react/column-pinning/src/main.tsx @@ -224,7 +224,9 @@ function App() { -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-pinning/tests/e2e/smoke.spec.ts b/examples/react/column-pinning/tests/e2e/smoke.spec.ts index 2eb199bf28..9e087d6d16 100644 --- a/examples/react/column-pinning/tests/e2e/smoke.spec.ts +++ b/examples/react/column-pinning/tests/e2e/smoke.spec.ts @@ -1,10 +1,31 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const LEAF_HEADERS = [ + 'firstName', + 'Last Name', + 'Age', + 'Visits', + 'Status', + 'Profile Progress', +] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +43,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +58,180 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() - return text?.replace(/\s+/g, ' ').trim() ?? '' +/** Header rows run group to leaf, so the leaf row holds the data columns. */ +function leafHeaderCells(page: Page) { + return page.locator('thead tr').last().locator('th') } -test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) +function leafHeader(page: Page, name: string) { + return leafHeaderCells(page).filter({ hasText: name }) +} - try { - const table = page.locator('table').first() +/** An unpinned header offers `<=` and `=>`; a pinned one swaps in `X`. */ +function pinButton(page: Page, name: string, label: '<=' | 'X' | '=>') { + return leafHeader(page, name).getByRole('button', { + name: label, + exact: true, + }) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnPinning(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnPinning?: { start?: Array; end?: Array } + } - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + return { + start: state.columnPinning?.start ?? [], + end: state.columnPinning?.end ?? [], } +} + +async function readLeafOrder(page: Page) { + const texts = await leafHeaderCells(page).allTextContents() + // Strip the pin controls that share the header cell with its label. + return texts.map((text) => text.replace(/<=|=>|X/g, '').trim()) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() + return text?.replace(/\s+/g, ' ').trim() ?? '' +} + +test('renders the table without crashing', async ({ page }) => { + const errors = await openExample(page) + const table = page.locator('table').first() + + await expect(table).toBeVisible() + await expect(leafHeaderCells(page)).toHaveCount(LEAF_HEADERS.length) + expect(await readLeafOrder(page)).toEqual(LEAF_HEADERS) + expect(await readColumnPinning(page)).toEqual({ start: [], end: [] }) + // Nothing is pinned, so every header offers both directions and no unpin. + await expect(pinButton(page, 'Visits', '<=')).toBeVisible() + await expect(pinButton(page, 'Visits', '=>')).toBeVisible() + await expect(pinButton(page, 'Visits', 'X')).toHaveCount(0) + + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('pins a column to the start', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + // The pinned column jumps to the front, keeping the others in order. + expect(await readLeafOrder(page)).toEqual([ + 'Visits', + 'firstName', + 'Last Name', + 'Age', + 'Status', + 'Profile Progress', + ]) + // A pinned header drops the direction it already occupies and gains unpin. + await expect(pinButton(page, 'Visits', '<=')).toHaveCount(0) + await expect(pinButton(page, 'Visits', 'X')).toBeVisible() + await expect(pinButton(page, 'Visits', '=>')).toBeVisible() + + expect(errors).toEqual([]) +}) + +test('pins a column to the end', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Age', '=>').click() + + await expect + .poll(async () => (await readColumnPinning(page)).end) + .toEqual(['age']) + expect(await readLeafOrder(page)).toEqual([ + 'firstName', + 'Last Name', + 'Visits', + 'Status', + 'Profile Progress', + 'Age', + ]) + await expect(pinButton(page, 'Age', '=>')).toHaveCount(0) + await expect(pinButton(page, 'Age', 'X')).toBeVisible() + + expect(errors).toEqual([]) +}) + +test('unpins a column', async ({ page }) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + + await pinButton(page, 'Visits', 'X').click() + + await expect + .poll(() => readColumnPinning(page)) + .toEqual({ start: [], end: [] }) + // Unpinning restores the column to its declared position. + expect(await readLeafOrder(page)).toEqual(LEAF_HEADERS) + await expect(pinButton(page, 'Visits', 'X')).toHaveCount(0) + + expect(errors).toEqual([]) +}) + +test('pins several columns in the order they were clicked', async ({ + page, +}) => { + const errors = await openExample(page) + + await pinButton(page, 'Visits', '<=').click() + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits']) + + await pinButton(page, 'Status', '<=').click() + + await expect + .poll(async () => (await readColumnPinning(page)).start) + .toEqual(['visits', 'status']) + + await pinButton(page, 'Age', '=>').click() + + await expect + .poll(async () => (await readColumnPinning(page)).end) + .toEqual(['age']) + // Start pins lead, unpinned columns hold the middle, end pins trail. + expect(await readLeafOrder(page)).toEqual([ + 'Visits', + 'Status', + 'firstName', + 'Last Name', + 'Profile Progress', + 'Age', + ]) + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-resizing/src/main.tsx b/examples/react/column-resizing/src/main.tsx index c798ac9c3e..782ce37a6f 100644 --- a/examples/react/column-resizing/src/main.tsx +++ b/examples/react/column-resizing/src/main.tsx @@ -335,7 +335,9 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-sizing/src/main.tsx b/examples/react/column-sizing/src/main.tsx index a13c897f3e..b951a9397c 100644 --- a/examples/react/column-sizing/src/main.tsx +++ b/examples/react/column-sizing/src/main.tsx @@ -270,7 +270,9 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-sizing/tests/e2e/smoke.spec.ts b/examples/react/column-sizing/tests/e2e/smoke.spec.ts index 2eb199bf28..28f978689c 100644 --- a/examples/react/column-sizing/tests/e2e/smoke.spec.ts +++ b/examples/react/column-sizing/tests/e2e/smoke.spec.ts @@ -1,10 +1,34 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** Declared `size` per column, in render order. */ +const INITIAL_SIZES: Record = { + firstName: 120, + lastName: 120, + age: 100, + visits: 80, + status: 200, + progress: 200, +} + +const LEAF_COLUMNS = Object.keys(INITIAL_SIZES) + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +46,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +61,140 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +/** One number input per column in the Initial Column Sizes panel. */ +function sizeInput(page: Page, column: string) { + return page + .locator('label') + .filter({ hasText: new RegExp(`^${column}$`) }) + .locator('input.column-size-input') +} + +/** The example renders the same table three ways to prove they stay in sync. */ +function nativeHeader(page: Page, index: number) { + return page.locator('table thead th').nth(index) +} + +function relativeDivHeader(page: Page, index: number) { + return page.locator('.divTable').first().locator('.th').nth(index) +} + +function absoluteDivHeader(page: Page, index: number) { + return page.locator('.divTable').nth(1).locator('.th').nth(index) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnSizing(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnSizing?: Record + } + + return state.columnSizing ?? {} +} + +async function widthOf(page: Page, index: number) { + const box = await nativeHeader(page, index).boundingBox() + return box?.width ?? 0 } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) - try { - const table = page.locator('table').first() + await expect(page.locator('table').first()).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(20) + // No overrides applied yet, so sizes come straight from the column defs. + expect(await readColumnSizing(page)).toEqual({}) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) + + expect(errors).toEqual([]) +}) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() +test('renders each column at its declared size', async ({ page }) => { + const errors = await openExample(page) + + for (const [index, column] of LEAF_COLUMNS.entries()) { + const expected = INITIAL_SIZES[column] ?? 0 + await expect(sizeInput(page, column)).toHaveValue(String(expected)) + // Layout rounds, so allow a pixel of slack rather than an exact match. + expect(Math.abs((await widthOf(page, index)) - expected)).toBeLessThan(2) } + + expect(errors).toEqual([]) +}) + +test('resizes a column from the size panel', async ({ page }) => { + const errors = await openExample(page) + const before = await widthOf(page, 0) + + await sizeInput(page, 'firstName').fill('300') + + await expect.poll(() => readColumnSizing(page)).toEqual({ firstName: 300 }) + await expect.poll(() => widthOf(page, 0)).toBeGreaterThan(before + 100) + expect(Math.abs((await widthOf(page, 0)) - 300)).toBeLessThan(2) + + // Its neighbours keep their declared widths. + expect(Math.abs((await widthOf(page, 2)) - 100)).toBeLessThan(2) + + expect(errors).toEqual([]) +}) + +test('keeps all three table renderings the same width', async ({ page }) => { + const errors = await openExample(page) + + await sizeInput(page, 'status').fill('260') + + await expect.poll(() => readColumnSizing(page)).toEqual({ status: 260 }) + + // The native table, the relative div table and the absolutely positioned div + // table all read the same column size, so a change has to land in all three. + const native = await nativeHeader(page, 4).boundingBox() + const relative = await relativeDivHeader(page, 4).boundingBox() + const absolute = await absoluteDivHeader(page, 4).boundingBox() + + expect(Math.abs((native?.width ?? 0) - 260)).toBeLessThan(2) + expect(Math.abs((relative?.width ?? 0) - 260)).toBeLessThan(2) + expect(Math.abs((absolute?.width ?? 0) - 260)).toBeLessThan(2) + + expect(errors).toEqual([]) +}) + +test('keeps column sizes when data is regenerated', async ({ page }) => { + const errors = await openExample(page) + + await sizeInput(page, 'age').fill('180') + await expect.poll(() => readColumnSizing(page)).toEqual({ age: 180 }) + + // Sizing lives in table state, so replacing the rows must not reset it. + await page.getByRole('button', { name: /^Regenerate Data$/i }).click() + + await expect(page.locator('tbody tr')).toHaveCount(20) + expect(await readColumnSizing(page)).toEqual({ age: 180 }) + expect(Math.abs((await widthOf(page, 2)) - 180)).toBeLessThan(2) + + expect(errors).toEqual([]) }) diff --git a/examples/react/column-visibility/src/main.tsx b/examples/react/column-visibility/src/main.tsx index 8a90305995..6d44995a56 100644 --- a/examples/react/column-visibility/src/main.tsx +++ b/examples/react/column-visibility/src/main.tsx @@ -161,7 +161,9 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/column-visibility/tests/e2e/smoke.spec.ts b/examples/react/column-visibility/tests/e2e/smoke.spec.ts index 2eb199bf28..4741f78eaa 100644 --- a/examples/react/column-visibility/tests/e2e/smoke.spec.ts +++ b/examples/react/column-visibility/tests/e2e/smoke.spec.ts @@ -1,10 +1,42 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** Leaf columns in render order. The toggle panel labels each by column id. */ +const LEAF_COLUMNS = [ + 'firstName', + 'lastName', + 'age', + 'visits', + 'status', + 'progress', +] + +/** Leaf header text differs from column id wherever a custom header is set. */ +const LEAF_HEADERS = [ + 'firstName', + 'Last Name', + 'Age', + 'Visits', + 'Status', + 'Profile Progress', +] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +54,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +69,203 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +function columnToggle(page: Page, label: string) { + return page.getByLabel(label, { exact: true }) +} + +/** Header rows run group to leaf, so the leaf row is last. */ +function leafHeaderCells(page: Page) { + return page.locator('thead tr').last().locator('th') +} + +/** Footer groups are the header groups reversed, so the leaf row is first. */ +function leafFooterCells(page: Page) { + return page.locator('tfoot tr').first().locator('th') +} + +function groupHeaderCells(page: Page) { + return page.locator('thead tr').first().locator('th') +} + +function firstBodyRowCells(page: Page) { + return page.locator('tbody tr').first().locator('td') +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readColumnVisibility(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + columnVisibility?: Record + } + + return state.columnVisibility ?? {} +} + +async function expectColumnVisibility( + page: Page, + expected: Record, +) { + await expect.poll(() => readColumnVisibility(page)).toEqual(expected) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + // Two nested column groups produce three header rows. + await expect(page.locator('thead tr')).toHaveCount(3) + await expect(groupHeaderCells(page)).toHaveText(['Name', 'Info']) + await expect(leafHeaderCells(page)).toHaveText(LEAF_HEADERS) + await expect(leafFooterCells(page)).toHaveText(LEAF_COLUMNS) + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(firstBodyRowCells(page)).toHaveCount(6) + await expectColumnVisibility(page, {}) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(page.locator('tbody tr').first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await regenerateButton.click() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) - const firstRowBefore = await getFirstBodyRowText(table) + expect(errors).toEqual([]) +}) + +test('renders a toggle for every leaf column', async ({ page }) => { + const errors = await openExample(page) + + // One checkbox per leaf column plus the panel's Toggle All. + await expect(page.locator('.column-toggle-panel input')).toHaveCount(7) + await expect(page.locator('.column-toggle-row label')).toHaveText( + LEAF_COLUMNS, + ) - await regenerateButton.click() + await expect(columnToggle(page, 'Toggle All')).toBeChecked() - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + for (const column of LEAF_COLUMNS) { + await expect(columnToggle(page, column)).toBeChecked() } + + expect(errors).toEqual([]) +}) + +test('hides and shows an individual column', async ({ page }) => { + const errors = await openExample(page) + + await columnToggle(page, 'visits').uncheck() + + await expectColumnVisibility(page, { visits: false }) + await expect(leafHeaderCells(page)).toHaveText( + LEAF_HEADERS.filter((header) => header !== 'Visits'), + ) + await expect(firstBodyRowCells(page)).toHaveCount(5) + await expect(leafFooterCells(page)).toHaveCount(5) + // Toggle All is driven by `getIsAllColumnsVisible`, so it clears too. + await expect(columnToggle(page, 'Toggle All')).not.toBeChecked() + + await columnToggle(page, 'visits').check() + + // Re-showing records `true` rather than dropping the key. + await expectColumnVisibility(page, { visits: true }) + await expect(leafHeaderCells(page)).toHaveText(LEAF_HEADERS) + await expect(firstBodyRowCells(page)).toHaveCount(6) + await expect(columnToggle(page, 'Toggle All')).toBeChecked() + + expect(errors).toEqual([]) +}) + +test('removes a header group when all of its columns are hidden', async ({ + page, +}) => { + const errors = await openExample(page) + + await expect(groupHeaderCells(page)).toHaveText(['Name', 'Info']) + + await columnToggle(page, 'firstName').uncheck() + await expect(groupHeaderCells(page)).toHaveText(['Name', 'Info']) + + // The group header only disappears once its last child is hidden. + await columnToggle(page, 'lastName').uncheck() + await expect(groupHeaderCells(page)).toHaveText(['Info']) + await expect(leafHeaderCells(page)).toHaveText([ + 'Age', + 'Visits', + 'Status', + 'Profile Progress', + ]) + await expect(firstBodyRowCells(page)).toHaveCount(4) + + await columnToggle(page, 'firstName').check() + await expect(groupHeaderCells(page)).toHaveText(['Name', 'Info']) + + expect(errors).toEqual([]) +}) + +test('hides and shows every column with Toggle All', async ({ page }) => { + const errors = await openExample(page) + + await columnToggle(page, 'Toggle All').uncheck() + + await expectColumnVisibility( + page, + Object.fromEntries(LEAF_COLUMNS.map((column) => [column, false])), + ) + await expect(leafHeaderCells(page)).toHaveCount(0) + await expect(firstBodyRowCells(page)).toHaveCount(0) + // The rows survive; they simply have no visible cells left to render. + await expect(page.locator('tbody tr')).toHaveCount(20) + + for (const column of LEAF_COLUMNS) { + await expect(columnToggle(page, column)).not.toBeChecked() + } + + await columnToggle(page, 'Toggle All').check() + + await expectColumnVisibility( + page, + Object.fromEntries(LEAF_COLUMNS.map((column) => [column, true])), + ) + await expect(leafHeaderCells(page)).toHaveText(LEAF_HEADERS) + await expect(firstBodyRowCells(page)).toHaveCount(6) + + expect(errors).toEqual([]) +}) + +test('keeps column visibility when data is regenerated', async ({ page }) => { + const errors = await openExample(page) + + await columnToggle(page, 'age').uncheck() + await expect(firstBodyRowCells(page)).toHaveCount(5) + + // Visibility lives in table state, so replacing the rows must not reset it. + await page.getByRole('button', { name: /^Regenerate Data$/i }).click() + + await expectColumnVisibility(page, { age: false }) + await expect(columnToggle(page, 'age')).not.toBeChecked() + await expect(firstBodyRowCells(page)).toHaveCount(5) + await expect(leafHeaderCells(page)).toHaveText( + LEAF_HEADERS.filter((header) => header !== 'Age'), + ) + + expect(errors).toEqual([]) }) diff --git a/examples/react/custom-plugin/src/main.tsx b/examples/react/custom-plugin/src/main.tsx index 05a9956a1c..3a58ca25d0 100644 --- a/examples/react/custom-plugin/src/main.tsx +++ b/examples/react/custom-plugin/src/main.tsx @@ -354,7 +354,9 @@ function App() { Showing {table.getRowModel().rows.length.toLocaleString()} of{' '} {table.getRowCount().toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/expanding/src/main.tsx b/examples/react/expanding/src/main.tsx index a528b485bb..ac46ab7f11 100644 --- a/examples/react/expanding/src/main.tsx +++ b/examples/react/expanding/src/main.tsx @@ -215,7 +215,7 @@ function App() {
{table.getRowModel().rows.length.toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/expanding/tests/e2e/smoke.spec.ts b/examples/react/expanding/tests/e2e/smoke.spec.ts index 2eb199bf28..5dce0e5916 100644 --- a/examples/react/expanding/tests/e2e/smoke.spec.ts +++ b/examples/react/expanding/tests/e2e/smoke.spec.ts @@ -1,10 +1,26 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** `makeData(100, 5, 3)`: 100 roots, 5 children each, 3 grandchildren each. */ +const CHILDREN_PER_ROW = 5 +const PAGE_SIZE = 10 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +38,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +53,185 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +function bodyRows(page: Page) { + return page.locator('tbody tr') +} + +/** The expander lives in each row's first name cell, beside the checkbox. */ +function rowExpander(page: Page, index: number) { + return bodyRows(page).nth(index).getByRole('button') +} + +/** The wrapper whose padding encodes the row's depth. */ +function rowIndent(page: Page, index: number) { + return bodyRows(page).nth(index).locator('td').nth(1).locator('div').first() +} + +/** The expand-all control sits in the first name header. */ +function expandAllButton(page: Page) { + return page.locator('thead th').nth(1).getByRole('button') +} + +function rowCheckbox(page: Page, index: number) { + return bodyRows(page).nth(index).locator('input[type="checkbox"]').first() +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readState(page: Page) { + const text = await page.getByTestId('table-state').textContent() + + return JSON.parse(text ?? '{}') as { + expanded?: Record | true + rowSelection?: Record + } +} + +async function countExpanded(page: Page) { + const expanded = (await readState(page)).expanded + return expanded === true ? -1 : Object.keys(expanded ?? {}).length } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function countSelected(page: Page) { + return Object.keys((await readState(page)).rowSelection ?? {}).length +} + +async function getRowText(page: Page, index: number) { + const text = await bodyRows(page).nth(index).textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(bodyRows(page)).toHaveCount(PAGE_SIZE) + await expect.poll(async () => (await readState(page)).expanded).toEqual({}) + // Nothing is expanded yet, so every visible row is a root row. + await expect(rowExpander(page, 0)).toHaveText('👉') + await expect(rowIndent(page, 0)).toHaveCSS('padding-left', '0px') - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(bodyRows(page).first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getRowText(page, 0) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getRowText(page, 0)).not.toBe(firstRowBefore) + await expect(bodyRows(page)).toHaveCount(PAGE_SIZE) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('expands and collapses a single row', async ({ page }) => { + const errors = await openExample(page) + const secondRowBefore = await getRowText(page, 1) + + await rowExpander(page, 0).click() + + await expect(rowExpander(page, 0)).toHaveText('👇') + await expect.poll(() => countExpanded(page)).toBe(1) + + // Children are spliced in beneath their parent, but pagination still hands + // back one page, so the rows below are pushed out of the window instead. + await expect(bodyRows(page)).toHaveCount(PAGE_SIZE) + expect(await getRowText(page, 1)).not.toBe(secondRowBefore) + await expect(rowIndent(page, 1)).toHaveCSS('padding-left', '28px') + + await rowExpander(page, 0).click() + + await expect(rowExpander(page, 0)).toHaveText('👉') + await expect.poll(() => countExpanded(page)).toBe(0) + // Collapsing restores exactly the row that was displaced. + expect(await getRowText(page, 1)).toBe(secondRowBefore) + + expect(errors).toEqual([]) +}) + +test('indents each level of sub rows', async ({ page }) => { + const errors = await openExample(page) + + await rowExpander(page, 0).click() + await expect(rowExpander(page, 0)).toHaveText('👇') + + // Depth drives a 2rem indent per level, and this example sets the root font + // size to 14px, so each level adds 28px. + await expect(rowIndent(page, 0)).toHaveCSS('padding-left', '0px') + await expect(rowIndent(page, 1)).toHaveCSS('padding-left', '28px') + + await rowExpander(page, 1).click() + + await expect(rowExpander(page, 1)).toHaveText('👇') + await expect(rowIndent(page, 2)).toHaveCSS('padding-left', '56px') + await expect.poll(() => countExpanded(page)).toBe(2) + + expect(errors).toEqual([]) +}) + +test('expands every row from the header', async ({ page }) => { + const errors = await openExample(page) + + await expect(expandAllButton(page)).toHaveText('👉') + + await expandAllButton(page).click() + + await expect(expandAllButton(page)).toHaveText('👇') + // Expanding everything is stored as `true` rather than a map of row ids. + await expect.poll(async () => (await readState(page)).expanded).toBe(true) + // The first page is now a root followed by its own descendants. + await expect(rowIndent(page, 1)).toHaveCSS('padding-left', '28px') + await expect(rowIndent(page, 2)).toHaveCSS('padding-left', '56px') + + await expandAllButton(page).click() + + await expect(expandAllButton(page)).toHaveText('👉') + await expect(rowIndent(page, 1)).toHaveCSS('padding-left', '0px') + + expect(errors).toEqual([]) +}) + +test('marks rows without children as leaves', async ({ page }) => { + const errors = await openExample(page) + + await expandAllButton(page).click() + await expect(expandAllButton(page)).toHaveText('👇') + + // The third level has no children of its own, so it renders a leaf marker + // instead of an expander. + await expect(page.locator('tbody').getByText('🔵').first()).toBeVisible() + + expect(errors).toEqual([]) +}) + +test('selects sub rows along with their parent', async ({ page }) => { + const errors = await openExample(page) + + await rowExpander(page, 0).click() + await expect(rowExpander(page, 0)).toHaveText('👇') + + await rowCheckbox(page, 0).check() + + // Selecting a parent cascades to its whole subtree, so one click selects far + // more than one row. + await expect.poll(() => countSelected(page)).toBeGreaterThan(CHILDREN_PER_ROW) + await expect(rowCheckbox(page, 1)).toBeChecked() + + await rowCheckbox(page, 0).uncheck() + + await expect.poll(() => countSelected(page)).toBe(0) + await expect(rowCheckbox(page, 1)).not.toBeChecked() + + expect(errors).toEqual([]) }) diff --git a/examples/react/filters-faceted-bucketed/src/main.tsx b/examples/react/filters-faceted-bucketed/src/main.tsx index 1d3d0b2e59..917b8aeeb8 100644 --- a/examples/react/filters-faceted-bucketed/src/main.tsx +++ b/examples/react/filters-faceted-bucketed/src/main.tsx @@ -246,7 +246,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/filters-faceted/src/main.tsx b/examples/react/filters-faceted/src/main.tsx index 68cdecb4c7..56ff7f0e42 100644 --- a/examples/react/filters-faceted/src/main.tsx +++ b/examples/react/filters-faceted/src/main.tsx @@ -167,7 +167,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/filters-fuzzy/src/main.tsx b/examples/react/filters-fuzzy/src/main.tsx index 9ca37dfe9c..1ec05335b9 100644 --- a/examples/react/filters-fuzzy/src/main.tsx +++ b/examples/react/filters-fuzzy/src/main.tsx @@ -238,7 +238,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/filters/src/main.tsx b/examples/react/filters/src/main.tsx index b4bfa12353..230e598764 100644 --- a/examples/react/filters/src/main.tsx +++ b/examples/react/filters/src/main.tsx @@ -167,7 +167,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/filters/tests/e2e/smoke.spec.ts b/examples/react/filters/tests/e2e/smoke.spec.ts index 2eb199bf28..110155e473 100644 --- a/examples/react/filters/tests/e2e/smoke.spec.ts +++ b/examples/react/filters/tests/e2e/smoke.spec.ts @@ -1,10 +1,38 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const TOTAL_ROWS = 5_000 + +/** Header cell index per column, matching the column order in `src/main.tsx`. */ +const COLUMN = { + rowNumber: 0, + firstName: 1, + lastName: 2, + fullName: 3, + age: 4, + visits: 5, + status: 6, + progress: 7, +} as const + +const STATUSES = ['complicated', 'relationship', 'single'] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +50,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +65,280 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +function headerCell(page: Page, column: keyof typeof COLUMN) { + return page.locator('thead th').nth(COLUMN[column]) +} + +function textFilter(page: Page, column: 'firstName' | 'lastName' | 'fullName') { + return headerCell(page, column).locator('input[type="text"]') +} + +function rangeFilter( + page: Page, + column: 'age' | 'visits' | 'progress', + bound: 'Min' | 'Max', +) { + return headerCell(page, column).getByPlaceholder(bound) +} + +function statusFilter(page: Page) { + return headerCell(page, 'status').locator('select') +} + +/** The pre-paginated count, so it reflects filtering but not the page window. */ +function rowCountLine(page: Page) { + return page.locator('div').filter({ hasText: /^[\d,]+ Rows$/ }) +} + +async function readFilteredRowCount(page: Page) { + const text = await rowCountLine(page).textContent() + return Number((text ?? '').replace(/\D/g, '')) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readState(page: Page) { + const text = await page.getByTestId('table-state').textContent() + + return JSON.parse(text ?? '{}') as { + columnFilters?: Array<{ id: string; value: unknown }> + pagination?: { pageIndex: number; pageSize: number } + } +} + +async function readColumnFilters(page: Page) { + return (await readState(page)).columnFilters ?? [] } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function expectColumnFilters( + page: Page, + expected: Array<{ id: string; value: unknown }>, +) { + // Every filter control debounces by 500ms, so poll rather than sleeping. + await expect.poll(() => readColumnFilters(page)).toEqual(expected) +} + +async function readBodyColumn(page: Page, column: keyof typeof COLUMN) { + const cells = await page + .locator(`tbody tr td:nth-child(${COLUMN[column] + 1})`) + .allTextContents() + + return cells.map((cell) => cell.trim()) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(table.locator('thead th')).toHaveCount(8) + await expect(page.locator('tbody tr')).toHaveCount(10) + await expect(rowCountLine(page)).toHaveText('5,000 Rows') + await expectColumnFilters(page, []) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(page.locator('tbody tr').first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await regenerateButton.click() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(10) - const firstRowBefore = await getFirstBodyRowText(table) + expect(errors).toEqual([]) +}) + +test('renders a filter control for every filterable column', async ({ + page, +}) => { + const errors = await openExample(page) - await regenerateButton.click() + // Three text columns, three range columns with a Min and a Max, one select. + await expect(page.locator('thead input[type="text"]')).toHaveCount(3) + await expect(page.locator('thead input[type="number"]')).toHaveCount(6) + await expect(page.locator('thead select')).toHaveCount(1) + + await expect(textFilter(page, 'firstName')).toHaveAttribute( + 'placeholder', + 'Search...', + ) + await expect(statusFilter(page).locator('option')).toHaveText([ + 'All', + ...STATUSES, + ]) + + // The row number column has no accessor, so it cannot be filtered. + await expect( + headerCell(page, 'rowNumber').locator('input, select'), + ).toHaveCount(0) + + expect(errors).toEqual([]) +}) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() +test('filters by text after the debounce', async ({ page }) => { + const errors = await openExample(page) + + // A fragment of a name that is actually present, so a match is guaranteed + // even though the data is regenerated randomly on every load. + const firstNames = await readBodyColumn(page, 'firstName') + const fragment = (firstNames[0] ?? '').slice(0, 3).toLowerCase() + expect(fragment).not.toBe('') + + await textFilter(page, 'firstName').fill(fragment) + + await expectColumnFilters(page, [{ id: 'firstName', value: fragment }]) + + const filtered = await readFilteredRowCount(page) + expect(filtered).toBeGreaterThan(0) + expect(filtered).toBeLessThan(TOTAL_ROWS) + + for (const name of await readBodyColumn(page, 'firstName')) { + expect(name.toLowerCase()).toContain(fragment) } + + // Emptying a text filter drops it from state rather than keeping a blank. + await textFilter(page, 'firstName').fill('') + + await expectColumnFilters(page, []) + await expect(rowCountLine(page)).toHaveText('5,000 Rows') + + expect(errors).toEqual([]) +}) + +test('waits for the debounce before applying the filter', async ({ page }) => { + const errors = await openExample(page) + + await textFilter(page, 'firstName').fill('zzz') + + // The 500ms debounce has not elapsed, so state is still untouched. + expect(await readColumnFilters(page)).toEqual([]) + + await expectColumnFilters(page, [{ id: 'firstName', value: 'zzz' }]) + + expect(errors).toEqual([]) +}) + +test('filters a numeric column by range', async ({ page }) => { + const errors = await openExample(page) + + await rangeFilter(page, 'age', 'Min').fill('20') + + // An unset bound serialises as null, leaving the range open ended. + await expectColumnFilters(page, [{ id: 'age', value: ['20', null] }]) + + const minOnly = await readFilteredRowCount(page) + expect(minOnly).toBeLessThan(TOTAL_ROWS) + + for (const age of await readBodyColumn(page, 'age')) { + expect(Number(age)).toBeGreaterThanOrEqual(20) + } + + await rangeFilter(page, 'age', 'Max').fill('25') + + await expectColumnFilters(page, [{ id: 'age', value: ['20', '25'] }]) + expect(await readFilteredRowCount(page)).toBeLessThan(minOnly) + + for (const age of await readBodyColumn(page, 'age')) { + expect(Number(age)).toBeGreaterThanOrEqual(20) + expect(Number(age)).toBeLessThanOrEqual(25) + } + + // Clearing one bound keeps the filter, with that end left empty. + await rangeFilter(page, 'age', 'Min').fill('') + + await expectColumnFilters(page, [{ id: 'age', value: ['', '25'] }]) + + for (const age of await readBodyColumn(page, 'age')) { + expect(Number(age)).toBeLessThanOrEqual(25) + } + + await rangeFilter(page, 'age', 'Max').fill('') + + await expect.poll(() => readFilteredRowCount(page)).toBe(TOTAL_ROWS) + + expect(errors).toEqual([]) +}) + +test('filters by the status select', async ({ page }) => { + const errors = await openExample(page) + const counts: Array = [] + + for (const status of STATUSES) { + await statusFilter(page).selectOption(status) + + await expectColumnFilters(page, [{ id: 'status', value: status }]) + + for (const cell of await readBodyColumn(page, 'status')) { + expect(cell).toBe(status) + } + + counts.push(await readFilteredRowCount(page)) + } + + // Every row has exactly one of the three statuses, so the parts must make up + // the whole. This holds no matter what the random data happens to be. + expect(counts.reduce((total, count) => total + count, 0)).toBe(TOTAL_ROWS) + + // The All option carries an empty value, which removes the filter entirely. + await statusFilter(page).selectOption('') + + await expectColumnFilters(page, []) + await expect(rowCountLine(page)).toHaveText('5,000 Rows') + + expect(errors).toEqual([]) +}) + +test('combines filters and returns to the first page', async ({ page }) => { + const errors = await openExample(page) + + await page.getByRole('button', { name: '>', exact: true }).click() + await page.getByRole('button', { name: '>', exact: true }).click() + await expect + .poll(async () => (await readState(page)).pagination?.pageIndex) + .toBe(2) + + await statusFilter(page).selectOption('single') + + await expectColumnFilters(page, [{ id: 'status', value: 'single' }]) + // `autoResetPageIndex` defaults to true, so filtering rewinds to page one. + await expect + .poll(async () => (await readState(page)).pagination?.pageIndex) + .toBe(0) + + const statusOnly = await readFilteredRowCount(page) + + await rangeFilter(page, 'age', 'Min').fill('20') + + await expectColumnFilters(page, [ + { id: 'status', value: 'single' }, + { id: 'age', value: ['20', null] }, + ]) + // Filters intersect, so adding one can only narrow the result. + expect(await readFilteredRowCount(page)).toBeLessThanOrEqual(statusOnly) + + for (const cell of await readBodyColumn(page, 'status')) { + expect(cell).toBe('single') + } + + for (const age of await readBodyColumn(page, 'age')) { + expect(Number(age)).toBeGreaterThanOrEqual(20) + } + + expect(errors).toEqual([]) }) diff --git a/examples/react/grouped-aggregation/src/main.tsx b/examples/react/grouped-aggregation/src/main.tsx index fcbc460cab..9a9f749422 100644 --- a/examples/react/grouped-aggregation/src/main.tsx +++ b/examples/react/grouped-aggregation/src/main.tsx @@ -236,7 +236,7 @@ function App() {
{table.getRowModel().rows.length.toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/grouping/src/main.tsx b/examples/react/grouping/src/main.tsx index 008cf9cdc5..435c6cb8cf 100644 --- a/examples/react/grouping/src/main.tsx +++ b/examples/react/grouping/src/main.tsx @@ -184,7 +184,7 @@ function App() {
{table.getRowModel().rows.length.toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/grouping/tests/e2e/smoke.spec.ts b/examples/react/grouping/tests/e2e/smoke.spec.ts index 51cf203b2f..f3369f811a 100644 --- a/examples/react/grouping/tests/e2e/smoke.spec.ts +++ b/examples/react/grouping/tests/e2e/smoke.spec.ts @@ -1,10 +1,25 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const TOTAL_ROWS = 10_000 +const STATUS_COUNT = 3 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +37,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,53 +52,167 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() - return text?.replace(/\s+/g, ' ').trim() ?? '' +/** Each header carries a toggle: `👊` when ungrouped, `🛑(index)` when grouped. */ +function groupToggle(page: Page, header: string) { + return page + .locator('thead th') + .filter({ hasText: header }) + .getByRole('button') } -test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) +function bodyRows(page: Page) { + return page.locator('tbody tr') +} - try { - const table = page.locator('table').first() +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readState(page: Page) { + const text = await page.getByTestId('table-state').textContent() - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + return JSON.parse(text ?? '{}') as { + grouping?: Array + expanded?: Record | true } +} + +async function expectGrouping(page: Page, expected: Array) { + await expect + .poll(async () => (await readState(page)).grouping ?? []) + .toEqual(expected) +} + +/** The `(1,234)` leaf count rendered inside each group row's expander. */ +async function readGroupCounts(page: Page) { + const texts = await bodyRows(page).locator('td button').allTextContents() + + return texts.map((text) => { + const match = text.match(/\(([\d,]+)\)/) + return match ? Number((match[1] ?? '0').replace(/,/g, '')) : 0 + }) +} + +async function getFirstBodyRowText(page: Page) { + const text = await bodyRows(page).first().textContent() + return text?.replace(/\s+/g, ' ').trim() ?? '' +} + +test('renders the table without crashing', async ({ page }) => { + const errors = await openExample(page) + const table = page.locator('table').first() + + await expect(table).toBeVisible() + await expect(table.locator('thead th')).toHaveCount(6) + await expect(bodyRows(page).first()).toBeVisible() + await expectGrouping(page, []) + // Every header offers a grouping toggle, all of them ungrouped. + await expect(page.locator('thead th button')).toHaveCount(6) + await expect(groupToggle(page, 'Status')).toHaveText('👊') + + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) - - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) - - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(table.locator('tfoot')).toHaveCount(0) - await expect(table).not.toContainText('Grand Total') - await expect(regenerateButton).toBeVisible() - - const firstRowBefore = await getFirstBodyRowText(table) - - await regenerateButton.click() - - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(bodyRows(page).first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) + + await regenerateButton.click() + + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + + expect(errors).toEqual([]) +}) + +test('groups rows by a column', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + + await expectGrouping(page, ['status']) + // The toggle now reports this column's position in the grouping list. + await expect(groupToggle(page, 'Status')).toHaveText('🛑(0)') + + // Three statuses collapse ten thousand rows into three group rows. + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + await expect(bodyRows(page).first()).toContainText('👉') + await expect(bodyRows(page).first()).toContainText(/\([\d,]+\)/) + + expect(errors).toEqual([]) +}) + +test('group leaf counts add up to every row', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + await expectGrouping(page, ['status']) + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + // Grouping partitions the rows, so the parts must make up the whole. This + // holds no matter what the random data happens to be. + const counts = await readGroupCounts(page) + expect(counts).toHaveLength(STATUS_COUNT) + expect(counts.reduce((total, count) => total + count, 0)).toBe(TOTAL_ROWS) + + expect(errors).toEqual([]) +}) + +test('expands a group to reveal its leaf rows', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + await bodyRows(page).first().locator('td button').first().click() + + await expect(bodyRows(page).first()).toContainText('👇') + // Leaf rows now sit under the expanded group, so the page fills up. + await expect.poll(() => bodyRows(page).count()).toBeGreaterThan(STATUS_COUNT) + + await bodyRows(page).first().locator('td button').first().click() + + await expect(bodyRows(page).first()).toContainText('👉') + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + expect(errors).toEqual([]) +}) + +test('nests a second grouping level', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + await expectGrouping(page, ['status']) + + await groupToggle(page, 'Age').click() + + await expectGrouping(page, ['status', 'age']) + await expect(groupToggle(page, 'Status')).toHaveText('🛑(0)') + await expect(groupToggle(page, 'Age')).toHaveText('🛑(1)') + // The outer grouping still decides the top level row count. + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + expect(errors).toEqual([]) +}) + +test('ungroups a column', async ({ page }) => { + const errors = await openExample(page) + + await groupToggle(page, 'Status').click() + await expectGrouping(page, ['status']) + await expect(bodyRows(page)).toHaveCount(STATUS_COUNT) + + await groupToggle(page, 'Status').click() + + await expectGrouping(page, []) + await expect(groupToggle(page, 'Status')).toHaveText('👊') + // Back to flat rows, so the page fills up again. + await expect(bodyRows(page)).toHaveCount(10) + + expect(errors).toEqual([]) }) diff --git a/examples/react/header-groups/tests/e2e/smoke.spec.ts b/examples/react/header-groups/tests/e2e/smoke.spec.ts index 2eb199bf28..fa68964bb2 100644 --- a/examples/react/header-groups/tests/e2e/smoke.spec.ts +++ b/examples/react/header-groups/tests/e2e/smoke.spec.ts @@ -1,10 +1,40 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const LEAF_HEADERS = [ + 'firstName', + 'Last Name', + 'Age', + 'Visits', + 'Status', + 'Profile Progress', +] + +const LEAF_FOOTERS = [ + 'firstName', + 'lastName', + 'age', + 'visits', + 'status', + 'progress', +] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +52,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +67,112 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +function headerRow(page: Page, index: number) { + return page.locator('thead tr').nth(index).locator('th') +} + +function footerRow(page: Page, index: number) { + return page.locator('tfoot tr').nth(index).locator('th') +} + +async function readColSpans(page: Page, selector: string) { + return page + .locator(selector) + .evaluateAll((cells) => + cells.map((cell) => Number(cell.getAttribute('colspan') ?? '1')), + ) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(page.locator('tbody tr').first().locator('td')).toHaveCount( + LEAF_HEADERS.length, + ) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(page.locator('tbody tr').first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await regenerateButton.click() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(20) - const firstRowBefore = await getFirstBodyRowText(table) + expect(errors).toEqual([]) +}) + +test('nests the header groups three rows deep', async ({ page }) => { + const errors = await openExample(page) - await regenerateButton.click() + // Two top level groups, one of which nests a further group, so the deepest + // leaf needs three header rows. + await expect(page.locator('thead tr')).toHaveCount(3) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + await expect(headerRow(page, 0)).toHaveText(['Hello', 'Info']) + // Row two is mostly placeholders for the columns that have no middle group. + await expect(headerRow(page, 1)).toHaveText(['', '', '', 'More Info']) + await expect(headerRow(page, 2)).toHaveText(LEAF_HEADERS) + + expect(errors).toEqual([]) +}) + +test('spans each group across the columns beneath it', async ({ page }) => { + const errors = await openExample(page) + + // `Hello` covers two leaves, `Info` covers the other four. + expect(await readColSpans(page, 'thead tr:nth-child(1) th')).toEqual([2, 4]) + // `More Info` covers the last three of `Info`'s four leaves. + expect(await readColSpans(page, 'thead tr:nth-child(2) th')).toEqual([ + 1, 1, 1, 3, + ]) + expect(await readColSpans(page, 'thead tr:nth-child(3) th')).toEqual([ + 1, 1, 1, 1, 1, 1, + ]) + + // Every header row must cover the same total width as the body rows. + for (const row of [1, 2, 3]) { + const spans = await readColSpans(page, `thead tr:nth-child(${row}) th`) + expect(spans.reduce((total, span) => total + span, 0)).toBe( + LEAF_HEADERS.length, + ) } + + expect(errors).toEqual([]) +}) + +test('mirrors the header groups in the footer', async ({ page }) => { + const errors = await openExample(page) + + // Footer groups are the header groups reversed, so leaves come first. + await expect(page.locator('tfoot tr')).toHaveCount(3) + await expect(footerRow(page, 0)).toHaveText(LEAF_FOOTERS) + + // The `hello` group declares a header but no footer, so its footer cell is + // empty, while `Info` declares both. The cell is still rendered and still + // spans its columns, which is what keeps the footer aligned with the body. + await expect(footerRow(page, 2)).toHaveText(['', 'Info']) + expect(await readColSpans(page, 'tfoot tr:nth-child(3) th')).toEqual([2, 4]) + + expect(errors).toEqual([]) }) diff --git a/examples/react/kitchen-sink/src/routes/index.tsx b/examples/react/kitchen-sink/src/routes/index.tsx index 09728e678d..f2f5798ae4 100644 --- a/examples/react/kitchen-sink/src/routes/index.tsx +++ b/examples/react/kitchen-sink/src/routes/index.tsx @@ -189,7 +189,9 @@ function IndeterminateCheckbox({ if (typeof indeterminate === 'boolean') { ref.current.indeterminate = !rest.checked && indeterminate } - }, [ref, indeterminate]) + // `checked` belongs here too: `getIsSomePageRowsSelected` stays true when + // every page row is selected, so deselecting one only changes `checked`. + }, [ref, indeterminate, rest.checked]) return } @@ -945,7 +947,7 @@ function App() {
Table state (live) -
+          
             {JSON.stringify(table.state, null, 2)}
           
diff --git a/examples/react/pagination/src/main.tsx b/examples/react/pagination/src/main.tsx index f59c751e76..a186784444 100644 --- a/examples/react/pagination/src/main.tsx +++ b/examples/react/pagination/src/main.tsx @@ -178,7 +178,7 @@ function MyTable({ type="number" min="1" max={table.getPageCount()} - defaultValue={table.state.pagination.pageIndex + 1} + value={table.state.pagination.pageIndex + 1} onChange={(e) => { const page = e.target.value ? Number(e.target.value) - 1 : 0 table.setPageIndex(page) @@ -203,7 +203,9 @@ function MyTable({ Showing {table.getRowModel().rows.length.toLocaleString()} of{' '} {table.getRowCount().toLocaleString()} Rows
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/pagination/tests/e2e/smoke.spec.ts b/examples/react/pagination/tests/e2e/smoke.spec.ts index 2eb199bf28..727abfafa2 100644 --- a/examples/react/pagination/tests/e2e/smoke.spec.ts +++ b/examples/react/pagination/tests/e2e/smoke.spec.ts @@ -1,10 +1,26 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** `makeData(1_000)` at the default page size of 10 gives exactly 100 pages. */ +const TOTAL_ROWS = 1_000 +const PAGE_SIZES = [10, 20, 30, 40, 50] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +38,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +53,235 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +/** `<` is a prefix of `<<`, so every page button match has to be exact. */ +function pageButton(page: Page, label: '<<' | '<' | '>' | '>>') { + return page.getByRole('button', { name: label, exact: true }) +} + +/** The `{pageIndex + 1} of {pageCount}` readout. */ +function pageStatus(page: Page) { + return page.locator('.controls strong') +} + +function goToPageInput(page: Page) { + return page.getByRole('spinbutton') +} + +function pageSizeSelect(page: Page) { + return page.locator('.controls select') +} + +function rowCountLine(page: Page) { + return page.getByText(/Showing .* Rows/) +} + +function bodyRows(page: Page) { + return page.locator('tbody tr') +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readPagination(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + pagination?: { pageIndex: number; pageSize: number } + } + + return state.pagination +} + +async function expectPagination( + page: Page, + expected: { pageIndex: number; pageSize: number }, +) { + await expect.poll(() => readPagination(page)).toEqual(expected) +} + +async function getFirstBodyRowText(page: Page) { + const text = await bodyRows(page).first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(table.locator('thead th')).toHaveCount(7) + await expect(bodyRows(page)).toHaveCount(10) + await expect(rowCountLine(page)).toHaveText('Showing 10 of 1,000 Rows') + await expectPagination(page, { pageIndex: 0, pageSize: 10 }) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(bodyRows(page).first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(bodyRows(page)).toHaveCount(10) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('disables the first and previous buttons on the first page', async ({ + page, +}) => { + const errors = await openExample(page) + + await expect(pageStatus(page)).toHaveText('1 of 100') + await expect(pageButton(page, '<<')).toBeDisabled() + await expect(pageButton(page, '<')).toBeDisabled() + await expect(pageButton(page, '>')).toBeEnabled() + await expect(pageButton(page, '>>')).toBeEnabled() + + expect(errors).toEqual([]) +}) + +test('advances and rewinds one page at a time', async ({ page }) => { + const errors = await openExample(page) + + await expect(pageStatus(page)).toHaveText('1 of 100') + + await pageButton(page, '>').click() + await expect(pageStatus(page)).toHaveText('2 of 100') + await expectPagination(page, { pageIndex: 1, pageSize: 10 }) + // Reaching page two enables the backwards buttons. + await expect(pageButton(page, '<<')).toBeEnabled() + await expect(pageButton(page, '<')).toBeEnabled() + // The controlled input tracks the page rather than going stale. + await expect(goToPageInput(page)).toHaveValue('2') + + const secondPageFirstRow = await getFirstBodyRowText(page) + + await pageButton(page, '>').click() + await expect(pageStatus(page)).toHaveText('3 of 100') + + await pageButton(page, '<').click() + await expect(pageStatus(page)).toHaveText('2 of 100') + // Returning to a page shows the same rows, so paging does not reshuffle. + expect(await getFirstBodyRowText(page)).toBe(secondPageFirstRow) + + // The page window slides but its size and the total do not. + await expect(bodyRows(page)).toHaveCount(10) + await expect(rowCountLine(page)).toHaveText('Showing 10 of 1,000 Rows') + + expect(errors).toEqual([]) +}) + +test('jumps to the last and first pages', async ({ page }) => { + const errors = await openExample(page) + + await pageButton(page, '>>').click() + await expect(pageStatus(page)).toHaveText('100 of 100') + await expectPagination(page, { pageIndex: 99, pageSize: 10 }) + await expect(bodyRows(page)).toHaveCount(10) + await expect(pageButton(page, '>')).toBeDisabled() + await expect(pageButton(page, '>>')).toBeDisabled() + await expect(pageButton(page, '<<')).toBeEnabled() + await expect(pageButton(page, '<')).toBeEnabled() + + await pageButton(page, '<<').click() + await expect(pageStatus(page)).toHaveText('1 of 100') + await expectPagination(page, { pageIndex: 0, pageSize: 10 }) + await expect(pageButton(page, '<<')).toBeDisabled() + await expect(pageButton(page, '<')).toBeDisabled() + + expect(errors).toEqual([]) +}) + +test('goes to an arbitrary page via the Go to page input', async ({ page }) => { + const errors = await openExample(page) + const input = goToPageInput(page) + + await expect(input).toHaveAttribute('min', '1') + await expect(input).toHaveAttribute('max', '100') + await expect(input).toHaveValue('1') + + await input.fill('42') + await expect(pageStatus(page)).toHaveText('42 of 100') + await expectPagination(page, { pageIndex: 41, pageSize: 10 }) + + await input.fill('1') + await expect(pageStatus(page)).toHaveText('1 of 100') + await expect(pageButton(page, '<')).toBeDisabled() + + // Clearing the field falls back to the first page rather than NaN. + await input.fill('7') + await expect(pageStatus(page)).toHaveText('7 of 100') + await input.fill('') + await expectPagination(page, { pageIndex: 0, pageSize: 10 }) + await expect(pageStatus(page)).toHaveText('1 of 100') + + expect(errors).toEqual([]) +}) + +test('recomputes the page index when the page size changes', async ({ + page, +}) => { + const errors = await openExample(page) + const select = pageSizeSelect(page) + + await expect(select.locator('option')).toHaveText( + PAGE_SIZES.map((size) => `Show ${size}`), + ) + await expect(select).toHaveValue('10') + + await select.selectOption('20') + await expect(bodyRows(page)).toHaveCount(20) + await expect(pageStatus(page)).toHaveText('1 of 50') + await expect(rowCountLine(page)).toHaveText('Showing 20 of 1,000 Rows') + await expectPagination(page, { pageIndex: 0, pageSize: 20 }) + + await select.selectOption('50') + await expect(bodyRows(page)).toHaveCount(50) + await expect(pageStatus(page)).toHaveText('1 of 20') + await expect(rowCountLine(page)).toHaveText('Showing 50 of 1,000 Rows') + + // `setPageSize` keeps the row at the top of the page in view by deriving the + // new page index from the old top row index, rather than resetting to page 1. + await select.selectOption('10') + await goToPageInput(page).fill('5') + await expect(pageStatus(page)).toHaveText('5 of 100') + + // Top row index is 4 * 10 = 40, so 20 rows per page lands on page 3. + await select.selectOption('20') + await expect(pageStatus(page)).toHaveText('3 of 50') + await expectPagination(page, { pageIndex: 2, pageSize: 20 }) + + // Top row index is still 2 * 20 = 40, so 50 rows per page lands on page 1. + await select.selectOption('50') + await expect(pageStatus(page)).toHaveText('1 of 20') + await expectPagination(page, { pageIndex: 0, pageSize: 50 }) + + expect(errors).toEqual([]) +}) + +test('resets to the first page when data is regenerated', async ({ page }) => { + const errors = await openExample(page) + + await goToPageInput(page).fill('5') + await expect(pageStatus(page)).toHaveText('5 of 100') + + // `autoResetPageIndex` defaults to true, so new data returns to page one. + await page.getByRole('button', { name: /^Regenerate Data$/i }).click() + + await expect(pageStatus(page)).toHaveText('1 of 100') + await expectPagination(page, { pageIndex: 0, pageSize: 10 }) + await expect(rowCountLine(page)).toHaveText( + `Showing 10 of ${TOTAL_ROWS.toLocaleString('en-US')} Rows`, + ) + + expect(errors).toEqual([]) }) diff --git a/examples/react/row-dnd/src/main.tsx b/examples/react/row-dnd/src/main.tsx index ecb849aecb..810de8a79a 100644 --- a/examples/react/row-dnd/src/main.tsx +++ b/examples/react/row-dnd/src/main.tsx @@ -204,7 +204,9 @@ function App() { -
{JSON.stringify(table.state, null, 2)}
+
+          {JSON.stringify(table.state, null, 2)}
+        
) diff --git a/examples/react/row-pinning/src/main.tsx b/examples/react/row-pinning/src/main.tsx index a57af14f02..505a21e41d 100644 --- a/examples/react/row-pinning/src/main.tsx +++ b/examples/react/row-pinning/src/main.tsx @@ -255,7 +255,7 @@ function App() {
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/row-pinning/tests/e2e/smoke.spec.ts b/examples/react/row-pinning/tests/e2e/smoke.spec.ts index 2eb199bf28..8f0c6ce684 100644 --- a/examples/react/row-pinning/tests/e2e/smoke.spec.ts +++ b/examples/react/row-pinning/tests/e2e/smoke.spec.ts @@ -1,10 +1,29 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** + * `makeData(1_000, 2, 2)` gives each row two children and each of those two + * children, so pinning one row pins a subtree of seven while the include-leaf + * and include-parent options are on. + */ +const SUBTREE_SIZE = 7 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +41,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +56,182 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() - return text?.replace(/\s+/g, ' ').trim() ?? '' +function bodyRows(page: Page) { + return page.locator('tbody tr') } -test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) +/** + * The pin controls sit in each row's first cell. An unpinned row shows ⬆️ then + * ⬇️; a pinned row shows a single ❌. Locating by position avoids matching on + * emoji accessible names, which do not survive name normalisation. + */ +function pinControls(page: Page, rowIndex: number) { + return bodyRows(page).nth(rowIndex).locator('td').first().getByRole('button') +} + +function optionCheckbox(page: Page, label: RegExp) { + return page + .locator('.vertical-options > div') + .filter({ hasText: label }) + .locator('input[type="checkbox"]') +} - try { - const table = page.locator('table').first() +function pageButton(page: Page, label: '<<' | '<' | '>' | '>>') { + return page.getByRole('button', { name: label, exact: true }) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readRowPinning(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + rowPinning?: { top?: Array; bottom?: Array } + } - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + return { + top: state.rowPinning?.top ?? [], + bottom: state.rowPinning?.bottom ?? [], } +} + +async function getRowText(page: Page, index: number) { + const text = await bodyRows(page).nth(index).textContent() + return text?.replace(/\s+/g, ' ').trim() ?? '' +} + +test('renders the table without crashing', async ({ page }) => { + const errors = await openExample(page) + const table = page.locator('table').first() + + await expect(table).toBeVisible() + await expect(bodyRows(page).first()).toBeVisible() + expect(await readRowPinning(page)).toEqual({ top: [], bottom: [] }) + // An unpinned row offers both pin directions and no unpin control. + await expect(pinControls(page, 0)).toHaveCount(2) + await expect(pinControls(page, 0).nth(0)).toHaveText('⬆️') + await expect(pinControls(page, 0).nth(1)).toHaveText('⬇️') + + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(bodyRows(page).first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getRowText(page, 0) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getRowText(page, 0)).not.toBe(firstRowBefore) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('pins a row to the top', async ({ page }) => { + const errors = await openExample(page) + + await pinControls(page, 2).nth(0).click() + + // Include-leaf and include-parent are on by default, so the row arrives with + // its two children and their two children apiece. + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + expect((await readRowPinning(page)).top[0]).toBe('2') + expect((await readRowPinning(page)).bottom).toEqual([]) + + // The pinned row is lifted to the front and swaps its controls for an unpin. + await expect(pinControls(page, 0)).toHaveCount(1) + await expect(pinControls(page, 0)).toHaveText('❌') + + expect(errors).toEqual([]) +}) + +test('pins a row to the bottom', async ({ page }) => { + const errors = await openExample(page) + + await pinControls(page, 1).nth(1).click() + + await expect + .poll(async () => (await readRowPinning(page)).bottom) + .toHaveLength(SUBTREE_SIZE) + expect((await readRowPinning(page)).top).toEqual([]) + + // The pinned subtree now sits at the very end of the table body. + const lastIndex = (await bodyRows(page).count()) - 1 + await expect(pinControls(page, lastIndex)).toHaveText('❌') + await expect(pinControls(page, 0)).toHaveCount(2) + + expect(errors).toEqual([]) +}) + +test('unpins a row', async ({ page }) => { + const errors = await openExample(page) + + await pinControls(page, 2).nth(0).click() + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + + await pinControls(page, 0).click() + + // Unpinning the parent releases the descendants that were pinned with it. + await expect.poll(async () => (await readRowPinning(page)).top).toEqual([]) + await expect(pinControls(page, 0)).toHaveCount(2) + await expect(pinControls(page, 0).nth(0)).toHaveText('⬆️') + await expect(pinControls(page, 0).nth(1)).toHaveText('⬇️') + + expect(errors).toEqual([]) +}) + +test('keeps pinned rows visible across pages', async ({ page }) => { + const errors = await openExample(page) + + await pinControls(page, 2).nth(0).click() + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + + const pinnedText = await getRowText(page, 0) + + // `keepPinnedRows` is on by default, so paging away keeps the row on screen. + await pageButton(page, '>').click() + + await expect.poll(() => getRowText(page, 0)).toBe(pinnedText) + await expect(pinControls(page, 0)).toHaveText('❌') + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + + expect(errors).toEqual([]) +}) + +test('duplicates a pinned row when copying is enabled', async ({ page }) => { + const errors = await openExample(page) + const copyOption = optionCheckbox(page, /Duplicate\/Keep Pinned Rows/) + + await expect(copyOption).not.toBeChecked() + + await pinControls(page, 2).nth(0).click() + await expect + .poll(async () => (await readRowPinning(page)).top) + .toHaveLength(SUBTREE_SIZE) + + const rowsPinnedOnly = await bodyRows(page).count() + + await copyOption.check() + + // With copying on the row keeps its place in the body as well as its pinned + // slot, so the body grows rather than simply reordering. + await expect + .poll(() => bodyRows(page).count()) + .toBeGreaterThan(rowsPinnedOnly) + + expect(errors).toEqual([]) }) diff --git a/examples/react/row-selection/src/index.css b/examples/react/row-selection/src/index.css index 0a099f6d9f..1584912764 100644 --- a/examples/react/row-selection/src/index.css +++ b/examples/react/row-selection/src/index.css @@ -226,7 +226,8 @@ button[disabled] { } .sortable-header, -.sortable { +.sortable, +.selection-checkbox { cursor: pointer; user-select: none; } diff --git a/examples/react/row-selection/src/main.tsx b/examples/react/row-selection/src/main.tsx index f2e1c1f198..fcfe066e95 100644 --- a/examples/react/row-selection/src/main.tsx +++ b/examples/react/row-selection/src/main.tsx @@ -216,7 +216,7 @@ function App() {
-
-            {data.length < 1_001 && JSON.stringify(table.state, null, 2)}
+          
+            {JSON.stringify(table.state, null, 2)}
           
@@ -397,13 +397,15 @@ function IndeterminateCheckbox({ if (typeof indeterminate === 'boolean') { ref.current.indeterminate = !rest.checked && indeterminate } - }, [ref, indeterminate]) + // `checked` belongs here too: `getIsSomePageRowsSelected` stays true when + // every page row is selected, so deselecting one only changes `checked`. + }, [ref, indeterminate, rest.checked]) return ( ) diff --git a/examples/react/row-selection/tests/e2e/smoke.spec.ts b/examples/react/row-selection/tests/e2e/smoke.spec.ts index 2eb199bf28..dbce69e714 100644 --- a/examples/react/row-selection/tests/e2e/smoke.spec.ts +++ b/examples/react/row-selection/tests/e2e/smoke.spec.ts @@ -1,10 +1,26 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Locator, Page } from '@playwright/test' const exampleDir = path.resolve() +/** `makeData(1_000)` with a uuid `getRowId`, ten rows to a page. */ +const TOTAL_ROWS = '1,000' +const PAGE_SIZE = 10 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +38,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +53,258 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +function rowCheckboxes(page: Page) { + return page.locator('tbody tr td:first-child input[type="checkbox"]') +} + +/** Bound to `getIsAllRowsSelected`, so it spans every page. */ +function headerCheckbox(page: Page) { + return page.locator('thead th').first().locator('input[type="checkbox"]') +} + +/** Bound to `getIsAllPageRowsSelected`, so it spans the current page only. */ +function footerCheckbox(page: Page) { + return page.locator('tfoot input[type="checkbox"]') +} + +/** Anchored so the regex cannot also match an ancestor with extra text. */ +function selectionSummary(page: Page) { + return page + .locator('div') + .filter({ hasText: /^[\d,]+ of [\d,]+ Total Rows Selected$/ }) +} + +function pageRowsLabel(page: Page) { + return page.locator('tfoot td').nth(1) +} + +function globalFilterInput(page: Page) { + return page.getByPlaceholder('Search all columns...') +} + +function pageButton(page: Page, label: '<<' | '<' | '>' | '>>') { + return page.getByRole('button', { name: label, exact: true }) +} + +function isIndeterminate(checkbox: Locator) { + return checkbox.evaluate((el) => (el as HTMLInputElement).indeterminate) +} + +async function expectIndeterminate(checkbox: Locator, expected: boolean) { + await expect.poll(() => isIndeterminate(checkbox)).toBe(expected) +} + +/** Row ids are uuids, so only the number of selected keys is assertable. */ +async function readSelectionCount(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + rowSelection?: Record + } + + return Object.keys(state.rowSelection ?? {}).length } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function expectSelectionCount(page: Page, expected: number) { + await expect.poll(() => readSelectionCount(page)).toBe(expected) + await expect(selectionSummary(page)).toHaveText( + `${expected.toLocaleString('en-US')} of ${TOTAL_ROWS} Total Rows Selected`, + ) +} + +async function readGlobalFilter(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { globalFilter?: string } + + return state.globalFilter ?? '' +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(page.locator('tbody tr')).toHaveCount(PAGE_SIZE) + await expect(pageRowsLabel(page)).toHaveText(`Page Rows (${PAGE_SIZE})`) + await expectSelectionCount(page, 0) + await expect(headerCheckbox(page)).not.toBeChecked() + await expectIndeterminate(headerCheckbox(page), false) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) + + await expect(page.locator('tbody tr').first()).toBeVisible() + + const firstRowBefore = await getFirstBodyRowText(page) + + await regenerateButton.click() + + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(PAGE_SIZE) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + expect(errors).toEqual([]) +}) + +test('selects and deselects a single row', async ({ page }) => { + const errors = await openExample(page) + const firstRow = rowCheckboxes(page).first() + + await firstRow.check() + + await expect(firstRow).toBeChecked() + await expectSelectionCount(page, 1) + // One of many rows selected leaves both select-all boxes partially filled. + await expectIndeterminate(headerCheckbox(page), true) + await expectIndeterminate(footerCheckbox(page), true) + await expect(headerCheckbox(page)).not.toBeChecked() + + await firstRow.uncheck() + + await expect(firstRow).not.toBeChecked() + await expectSelectionCount(page, 0) + await expectIndeterminate(headerCheckbox(page), false) + await expectIndeterminate(footerCheckbox(page), false) + + expect(errors).toEqual([]) +}) + +test('selects an inclusive range with Shift-click', async ({ page }) => { + const errors = await openExample(page) + const checkboxes = rowCheckboxes(page) + + await checkboxes.nth(1).check() + await expectSelectionCount(page, 1) + + // The range covers both endpoints, so rows 1 through 5 is five rows. + await checkboxes.nth(5).click({ modifiers: ['Shift'] }) + + await expectSelectionCount(page, 5) + + for (const index of [1, 2, 3, 4, 5]) { + await expect(checkboxes.nth(index)).toBeChecked() + } + + // Rows outside the range are untouched. + await expect(checkboxes.nth(0)).not.toBeChecked() + await expect(checkboxes.nth(6)).not.toBeChecked() + + expect(errors).toEqual([]) +}) + +test('selects every row on the current page from the footer', async ({ + page, +}) => { + const errors = await openExample(page) + + await footerCheckbox(page).check() + + await expectSelectionCount(page, PAGE_SIZE) + await expect(footerCheckbox(page)).toBeChecked() + await expectIndeterminate(footerCheckbox(page), false) + // Ten of a thousand rows is still only a partial selection overall. + await expect(headerCheckbox(page)).not.toBeChecked() + await expectIndeterminate(headerCheckbox(page), true) + + for (let index = 0; index < PAGE_SIZE; index++) { + await expect(rowCheckboxes(page).nth(index)).toBeChecked() + } + + // Clearing a single row drops the footer back to a partial state. + await rowCheckboxes(page).nth(3).uncheck() + await expectSelectionCount(page, PAGE_SIZE - 1) + await expect(footerCheckbox(page)).not.toBeChecked() + await expectIndeterminate(footerCheckbox(page), true) - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + await footerCheckbox(page).check() + await expectSelectionCount(page, PAGE_SIZE) - const firstRowBefore = await getFirstBodyRowText(table) + expect(errors).toEqual([]) +}) + +test('selects every row across all pages from the header', async ({ page }) => { + const errors = await openExample(page) + + await headerCheckbox(page).check() - await regenerateButton.click() + await expectSelectionCount(page, 1_000) + await expect(headerCheckbox(page)).toBeChecked() + await expectIndeterminate(headerCheckbox(page), false) + await expect(footerCheckbox(page)).toBeChecked() - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() + // Selection is keyed by row id rather than position, so the next page's rows + // come back already selected. + await pageButton(page, '>').click() + + await expect(footerCheckbox(page)).toBeChecked() + await expectSelectionCount(page, 1_000) + + for (let index = 0; index < PAGE_SIZE; index++) { + await expect(rowCheckboxes(page).nth(index)).toBeChecked() } + + await headerCheckbox(page).uncheck() + await expectSelectionCount(page, 0) + + expect(errors).toEqual([]) +}) + +test('keeps selection when the global filter narrows the rows', async ({ + page, +}) => { + const errors = await openExample(page) + + await rowCheckboxes(page).first().check() + await expectSelectionCount(page, 1) + + // The first name of the row that was just selected, so it survives the + // filter. The input debounces, so poll the state rather than sleeping. + const firstName = + ( + await page.locator('tbody tr').first().locator('td').nth(1).textContent() + )?.trim() ?? '' + + await globalFilterInput(page).fill(firstName) + + await expect.poll(() => readGlobalFilter(page)).toBe(firstName) + await expect(page.locator('tbody tr').first()).toBeVisible() + // The summary counts pre-filtered rows, so the denominator does not move. + await expectSelectionCount(page, 1) + + await globalFilterInput(page).fill('') + + await expect.poll(() => readGlobalFilter(page)).toBe('') + await expectSelectionCount(page, 1) + + expect(errors).toEqual([]) +}) + +test('tracks the page size in the footer row count', async ({ page }) => { + const errors = await openExample(page) + const select = page.locator('.controls select') + + await expect(pageRowsLabel(page)).toHaveText('Page Rows (10)') + + await select.selectOption('20') + + await expect(page.locator('tbody tr')).toHaveCount(20) + await expect(pageRowsLabel(page)).toHaveText('Page Rows (20)') + + // Selecting the page now covers twenty rows rather than ten. + await footerCheckbox(page).check() + await expectSelectionCount(page, 20) + + expect(errors).toEqual([]) }) diff --git a/examples/react/sorting/src/main.tsx b/examples/react/sorting/src/main.tsx index 8b2e7915af..ae81a1a024 100644 --- a/examples/react/sorting/src/main.tsx +++ b/examples/react/sorting/src/main.tsx @@ -183,7 +183,9 @@ function App() {
{table.getRowModel().rows.length.toLocaleString()} Rows
{/* Store mode: dump full table state for debugging */} -
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/examples/react/sorting/tests/e2e/smoke.spec.ts b/examples/react/sorting/tests/e2e/smoke.spec.ts index 2eb199bf28..a840f850e3 100644 --- a/examples/react/sorting/tests/e2e/smoke.spec.ts +++ b/examples/react/sorting/tests/e2e/smoke.spec.ts @@ -1,10 +1,41 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +/** Body cell index per column, matching the column order in `src/main.tsx`. */ +const COLUMN = { + rowNumber: 0, + firstName: 1, + lastName: 2, + email: 3, + age: 4, + visits: 5, + status: 6, + progress: 7, + rank: 8, + createdAt: 9, +} as const + +type ColumnName = keyof typeof COLUMN + +/** `sortStatusFn` in `src/main.tsx` orders statuses this way, not alphabetically. */ +const STATUS_ORDER = ['single', 'complicated', 'relationship'] + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +53,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +68,281 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors +} + +/** The clickable header, which also carries the title and the sort glyph. */ +function sortToggle(page: Page, column: ColumnName) { + return page + .locator('thead th') + .nth(COLUMN[column]) + .locator('.sortable-header') +} + +/** Visible body cell text for one column, in render order. */ +async function readColumn(page: Page, column: ColumnName) { + const cells = await page + .locator(`tbody tr td:nth-child(${COLUMN[column] + 1})`) + .allTextContents() + + return cells.map((cell) => cell.trim()) } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +async function readNumericColumn(page: Page, column: ColumnName) { + return (await readColumn(page, column)).map(Number) +} + +/** Row data is random faker output, so `table.state` is the stable oracle. */ +async function readSorting(page: Page) { + const text = await page.getByTestId('table-state').textContent() + const state = JSON.parse(text ?? '{}') as { + sorting?: Array<{ id: string; desc: boolean }> + } + + return state.sorting ?? [] +} + +async function expectSorting( + page: Page, + expected: Array<{ id: string; desc: boolean }>, +) { + await expect.poll(() => readSorting(page)).toEqual(expected) +} + +async function getFirstBodyRowText(page: Page) { + const text = await page.locator('tbody tr').first().textContent() return text?.replace(/\s+/g, ' ').trim() ?? '' } test('renders the table without crashing', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const table = page.locator('table').first() - try { - const table = page.locator('table').first() + await expect(table).toBeVisible() + await expect(table.locator('thead th')).toHaveCount(10) + // The example renders 1,000 rows but only slices the first ten into the body. + await expect(table.locator('tbody tr')).toHaveCount(10) + await expect(page.getByText('1,000 Rows')).toBeVisible() + await expectSorting(page, []) - await expect(table).toBeVisible() - await expect(table.locator('thead th').first()).toBeVisible() - await expect(table.locator('tbody tr').first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) }) test('regenerates table data', async ({ page }) => { - const { errors, server } = await openExample(page) + const errors = await openExample(page) + const regenerateButton = page.getByRole('button', { + name: /^Regenerate Data$/i, + }) - try { - const table = page.locator('table').first() - const bodyRows = table.locator('tbody tr') - const regenerateButton = page.getByRole('button', { - name: /^Regenerate Data$/i, - }) + await expect(page.locator('tbody tr').first()).toBeVisible() - await expect(table).toBeVisible() - await expect(bodyRows.first()).toBeVisible() - await expect(regenerateButton).toBeVisible() + const firstRowBefore = await getFirstBodyRowText(page) - const firstRowBefore = await getFirstBodyRowText(table) + await regenerateButton.click() - await regenerateButton.click() + await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore) + await expect(page.locator('tbody tr')).toHaveCount(10) - await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore) - await expect(bodyRows.first()).toBeVisible() - expect(errors).toEqual([]) - } finally { - await server.close() - } + expect(errors).toEqual([]) +}) + +test('infers the first sort direction from the column value type', async ({ + page, +}) => { + const errors = await openExample(page) + + // String columns start ascending, every other value type starts descending. + await expect(sortToggle(page, 'firstName')).toHaveAttribute( + 'title', + 'Sort ascending', + ) + await expect(sortToggle(page, 'status')).toHaveAttribute( + 'title', + 'Sort ascending', + ) + await expect(sortToggle(page, 'age')).toHaveAttribute( + 'title', + 'Sort descending', + ) + await expect(sortToggle(page, 'createdAt')).toHaveAttribute( + 'title', + 'Sort descending', + ) + + // `sortDescFirst: false` overrides the inference on a nullable string column, + // where the first row's value may be undefined and defeat auto detection. + await expect(sortToggle(page, 'lastName')).toHaveAttribute( + 'title', + 'Sort ascending', + ) + + // The row number column has no accessor, so it cannot sort. + await expect(sortToggle(page, 'rowNumber')).toHaveCount(0) + + expect(errors).toEqual([]) +}) + +test('cycles a string column ascending, descending, then clears', async ({ + page, +}) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'firstName') + + await toggle.click() + await expectSorting(page, [{ id: 'firstName', desc: false }]) + await expect(toggle).toContainText('🔼') + await expect(toggle).toHaveAttribute('title', 'Sort descending') + + await toggle.click() + await expectSorting(page, [{ id: 'firstName', desc: true }]) + await expect(toggle).toContainText('🔽') + await expect(toggle).toHaveAttribute('title', 'Clear sort') + + await toggle.click() + await expectSorting(page, []) + await expect(toggle).not.toContainText('🔼') + await expect(toggle).not.toContainText('🔽') + await expect(toggle).toHaveAttribute('title', 'Sort ascending') + + expect(errors).toEqual([]) +}) + +test('starts a numeric column descending', async ({ page }) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'age') + + await toggle.click() + await expectSorting(page, [{ id: 'age', desc: true }]) + await expect(toggle).toContainText('🔽') + + await toggle.click() + await expectSorting(page, [{ id: 'age', desc: false }]) + await expect(toggle).toContainText('🔼') + + await toggle.click() + await expectSorting(page, []) + + expect(errors).toEqual([]) +}) + +test('reorders rows to match the sorted column', async ({ page }) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'age') + const unsorted = await readNumericColumn(page, 'age') + + await toggle.click() + await expect(toggle).toContainText('🔽') + + const descending = await readNumericColumn(page, 'age') + expect(descending).toEqual([...descending].sort((a, b) => b - a)) + // The top row now holds the maximum across all 1,000 rows, which is at least + // the maximum of the ten that happened to be visible before sorting. + expect(descending[0]).toBeGreaterThanOrEqual(Math.max(...unsorted)) + + await toggle.click() + await expect(toggle).toContainText('🔼') + + const ascending = await readNumericColumn(page, 'age') + expect(ascending).toEqual([...ascending].sort((a, b) => a - b)) + expect(ascending[0]).toBeLessThanOrEqual(Math.min(...unsorted)) + + expect(errors).toEqual([]) +}) + +test('inverts the rendered order on the Rank column', async ({ page }) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'rank') + const unsorted = await readNumericColumn(page, 'rank') + + await toggle.click() + await expectSorting(page, [{ id: 'rank', desc: true }]) + await expect(toggle).toContainText('🔽') + + // `invertSorting: true` flips the comparison after the descending flip, so a + // column marked descending renders ascending values. Golf scores, not typos. + const ranks = await readNumericColumn(page, 'rank') + expect(ranks).toEqual([...ranks].sort((a, b) => a - b)) + expect(ranks[0]).toBeLessThanOrEqual(Math.min(...unsorted)) + + expect(errors).toEqual([]) +}) + +test('keeps undefined values last in both sort directions', async ({ + page, +}) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'lastName') + + // Roughly a tenth of the 1,000 rows have an undefined last name. The default + // `sortUndefined` would surface them at one end; `'last'` pins them to the + // bottom regardless of direction, so neither direction shows a blank cell. + await toggle.click() + await expect(toggle).toContainText('🔼') + + const ascending = await readColumn(page, 'lastName') + expect(ascending).toHaveLength(10) + expect(ascending.filter((name) => name === '')).toEqual([]) + + await toggle.click() + await expect(toggle).toContainText('🔽') + + const descending = await readColumn(page, 'lastName') + expect(descending).toHaveLength(10) + expect(descending.filter((name) => name === '')).toEqual([]) + + expect(errors).toEqual([]) +}) + +test('sorts by a custom sort function', async ({ page }) => { + const errors = await openExample(page) + const toggle = sortToggle(page, 'status') + + // Ascending puts `single` first. An alphanumeric fallback would put + // `complicated` there instead, so this fails if `sortStatusFn` is dropped. + await toggle.click() + await expectSorting(page, [{ id: 'status', desc: false }]) + await expect(toggle).toContainText('🔼') + + const ascending = await readColumn(page, 'status') + expect([...new Set(ascending)]).toEqual([STATUS_ORDER[0]]) + + await toggle.click() + await expect(toggle).toContainText('🔽') + + const descending = await readColumn(page, 'status') + expect([...new Set(descending)]).toEqual([STATUS_ORDER[2]]) + + expect(errors).toEqual([]) +}) + +test('multi-sorts with Shift-click', async ({ page }) => { + const errors = await openExample(page) + + await sortToggle(page, 'status').click() + await expectSorting(page, [{ id: 'status', desc: false }]) + + // Shift appends a column rather than replacing the sort. + await sortToggle(page, 'age').click({ modifiers: ['Shift'] }) + await expectSorting(page, [ + { id: 'status', desc: false }, + { id: 'age', desc: true }, + ]) + await expect(sortToggle(page, 'status')).toContainText('🔼') + await expect(sortToggle(page, 'age')).toContainText('🔽') + + // A shifted column runs its own asc/desc/remove cycle. + await sortToggle(page, 'age').click({ modifiers: ['Shift'] }) + await expectSorting(page, [ + { id: 'status', desc: false }, + { id: 'age', desc: false }, + ]) + + await sortToggle(page, 'age').click({ modifiers: ['Shift'] }) + await expectSorting(page, [{ id: 'status', desc: false }]) + + // An unshifted click on another column replaces the whole sort. + await sortToggle(page, 'firstName').click() + await expectSorting(page, [{ id: 'firstName', desc: false }]) + + expect(errors).toEqual([]) }) diff --git a/examples/react/sub-components/tests/e2e/smoke.spec.ts b/examples/react/sub-components/tests/e2e/smoke.spec.ts index 2eb199bf28..fb32154dbd 100644 --- a/examples/react/sub-components/tests/e2e/smoke.spec.ts +++ b/examples/react/sub-components/tests/e2e/smoke.spec.ts @@ -1,10 +1,24 @@ -import { expect, test } from '@playwright/test' -import type { Locator, Page } from '@playwright/test' import path from 'node:path' +import { expect, test } from '@playwright/test' import { startExampleServer } from '../../../../../tests/e2e/helpers/startExampleServer' +import type { Page } from '@playwright/test' const exampleDir = path.resolve() +const TOTAL_ROWS = 10 + +let server: Awaited> | undefined + +test.beforeAll(async () => { + // One server for the whole file. A cold Vite start costs more than a test. + test.setTimeout(180_000) + server = await startExampleServer(exampleDir) +}) + +test.afterAll(async () => { + await server?.close() +}) + function collectPageErrors(page: Page) { const errors: Array = [] @@ -22,7 +36,8 @@ function collectPageErrors(page: Page) { } async function openExample(page: Page) { - const server = await startExampleServer(exampleDir) + if (!server) throw new Error('Example server failed to start') + const errors = collectPageErrors(page) await page.route( @@ -36,51 +51,127 @@ async function openExample(page: Page) { await page.goto(server.url) - return { errors, server } + return errors } -async function getFirstBodyRowText(table: Locator) { - const text = await table.locator('tbody tr').first().textContent() +function bodyRows(page: Page) { + return page.locator('tbody tr') +} + +/** Every data row carries an expander in its first cell. */ +function rowExpander(page: Page, index: number) { + return bodyRows(page).nth(index).getByRole('button') +} + +/** The sub component renders the row's own data as JSON inside a `
`. */
+function subComponents(page: Page) {
+  return page.locator('tbody pre')
+}
+
+async function readSubComponentJson(page: Page, index: number) {
+  const text = await subComponents(page).nth(index).textContent()
+  return JSON.parse(text ?? '{}') as Record
+}
+
+async function getFirstBodyRowText(page: Page) {
+  const text = await bodyRows(page).first().textContent()
   return text?.replace(/\s+/g, ' ').trim() ?? ''
 }
 
 test('renders the table without crashing', async ({ page }) => {
-  const { errors, server } = await openExample(page)
-
-  try {
-    const table = page.locator('table').first()
-
-    await expect(table).toBeVisible()
-    await expect(table.locator('thead th').first()).toBeVisible()
-    await expect(table.locator('tbody tr').first()).toBeVisible()
-    expect(errors).toEqual([])
-  } finally {
-    await server.close()
-  }
+  const errors = await openExample(page)
+  const table = page.locator('table').first()
+
+  await expect(table).toBeVisible()
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS)
+  // Nothing is expanded, so no sub component rows exist yet.
+  await expect(subComponents(page)).toHaveCount(0)
+  await expect(rowExpander(page, 0)).toHaveText('👉')
+
+  expect(errors).toEqual([])
 })
 
 test('regenerates table data', async ({ page }) => {
-  const { errors, server } = await openExample(page)
+  const errors = await openExample(page)
+  const regenerateButton = page.getByRole('button', {
+    name: /^Regenerate Data$/i,
+  })
+
+  await expect(bodyRows(page).first()).toBeVisible()
+
+  const firstRowBefore = await getFirstBodyRowText(page)
+
+  await regenerateButton.click()
+
+  await expect.poll(() => getFirstBodyRowText(page)).not.toBe(firstRowBefore)
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS)
+
+  expect(errors).toEqual([])
+})
+
+test('expands a row to reveal its sub component', async ({ page }) => {
+  const errors = await openExample(page)
+
+  await rowExpander(page, 0).click()
+
+  await expect(rowExpander(page, 0)).toHaveText('👇')
+  await expect(subComponents(page)).toHaveCount(1)
+  // The sub component adds a full width row directly beneath its parent.
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS + 1)
+  await expect(bodyRows(page).nth(1).locator('td')).toHaveCount(1)
+
+  await rowExpander(page, 0).click()
+
+  await expect(rowExpander(page, 0)).toHaveText('👉')
+  await expect(subComponents(page)).toHaveCount(0)
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS)
+
+  expect(errors).toEqual([])
+})
+
+test('renders the expanded row own data in the sub component', async ({
+  page,
+}) => {
+  const errors = await openExample(page)
+
+  // Read a value straight out of the row before opening it, so the sub
+  // component can be checked against its own parent rather than any row.
+  const ageCell = await bodyRows(page)
+    .first()
+    .locator('td')
+    .nth(3)
+    .textContent()
+
+  await rowExpander(page, 0).click()
+  await expect(subComponents(page)).toHaveCount(1)
+
+  const original = await readSubComponentJson(page, 0)
+  expect(original['age']).toBe(Number(ageCell?.trim()))
+  expect(original).toHaveProperty('firstName')
+  expect(original).toHaveProperty('status')
+
+  expect(errors).toEqual([])
+})
+
+test('keeps several sub components open at once', async ({ page }) => {
+  const errors = await openExample(page)
+
+  await rowExpander(page, 0).click()
+  await expect(subComponents(page)).toHaveCount(1)
 
-  try {
-    const table = page.locator('table').first()
-    const bodyRows = table.locator('tbody tr')
-    const regenerateButton = page.getByRole('button', {
-      name: /^Regenerate Data$/i,
-    })
+  // Row 1 is now the first sub component row, so the next data row is row 2.
+  await rowExpander(page, 2).click()
 
-    await expect(table).toBeVisible()
-    await expect(bodyRows.first()).toBeVisible()
-    await expect(regenerateButton).toBeVisible()
+  await expect(subComponents(page)).toHaveCount(2)
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS + 2)
+  await expect(rowExpander(page, 0)).toHaveText('👇')
+  await expect(rowExpander(page, 2)).toHaveText('👇')
 
-    const firstRowBefore = await getFirstBodyRowText(table)
+  // Collapsing one leaves the other open.
+  await rowExpander(page, 0).click()
 
-    await regenerateButton.click()
+  await expect(subComponents(page)).toHaveCount(1)
+  await expect(bodyRows(page)).toHaveCount(TOTAL_ROWS + 1)
 
-    await expect.poll(() => getFirstBodyRowText(table)).not.toBe(firstRowBefore)
-    await expect(bodyRows.first()).toBeVisible()
-    expect(errors).toEqual([])
-  } finally {
-    await server.close()
-  }
+  expect(errors).toEqual([])
 })
diff --git a/examples/react/with-tanstack-query/src/main.tsx b/examples/react/with-tanstack-query/src/main.tsx
index 54cbd78c4d..ec8dfe0ca2 100644
--- a/examples/react/with-tanstack-query/src/main.tsx
+++ b/examples/react/with-tanstack-query/src/main.tsx
@@ -181,7 +181,9 @@ function App() {
         {dataQuery.data?.rowCount.toLocaleString()} Rows
       
       
-
{JSON.stringify(table.state, null, 2)}
+
+        {JSON.stringify(table.state, null, 2)}
+      
) } diff --git a/playwright.config.ts b/playwright.config.ts index e6013b3f87..e8b89f9447 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,27 @@ +import path from 'node:path' import { defineConfig, devices } from '@playwright/test' +const testDir = process.env.PLAYWRIGHT_TEST_DIR ?? './tests/e2e' + +// Every example runs its own Playwright process with PLAYWRIGHT_TEST_DIR set to +// `/tests/e2e`, so name the project after the example. Without this, +// each reporter line reads `[chromium] > smoke.spec.ts` and a failure in the +// release audit job does not say which of the ~343 examples broke. +function getProjectName() { + if (!process.env.PLAYWRIGHT_TEST_DIR) return 'chromium' + + const exampleDir = path.resolve(testDir, '..', '..') + + return `${path.basename(path.dirname(exampleDir))}/${path.basename(exampleDir)}` +} + export default defineConfig({ - testDir: process.env.PLAYWRIGHT_TEST_DIR ?? './tests/e2e', - fullyParallel: true, + testDir, + // The unit of parallelism is the spec file, not the test. Each example's spec + // starts one dev server in `beforeAll` and shares it across its tests; with + // `fullyParallel` every test becomes its own job, so CI's two workers would + // split a single file and start that server twice. + fullyParallel: false, timeout: 60_000, expect: { timeout: 10_000, @@ -16,7 +35,7 @@ export default defineConfig({ }, projects: [ { - name: 'chromium', + name: getProjectName(), use: { ...devices['Desktop Chrome'] }, }, ],