feat(tree): add flat and lazy modes for tree data handling

* Implement flat mode to transform flat data with parentId to nested structure.
* Introduce lazy mode for on-demand loading of children.
* Update tree rendering logic to accommodate new modes.
* Enhance tree cell expansion logic to support lazy loading.
* Add dark mode styles for improved UI experience.
* Create comprehensive end-to-end tests for tree functionality.
This commit is contained in:
Hein
2026-02-17 13:03:20 +02:00
parent 9ddc960578
commit 93568891cd
23 changed files with 1131 additions and 707 deletions

View File

@@ -0,0 +1,815 @@
import { expect, test } from '@playwright/test';
// Helper to navigate to a story inside the Storybook iframe
async function gotoStory(page: any, storyId: string) {
await page.goto(`/iframe.html?id=components-griddy--${storyId}&viewMode=story`);
await page.waitForSelector('[role="grid"]', { timeout: 10000 });
}
// ─── 1. Basic Rendering ────────────────────────────────────────────────────────
test.describe('Basic Rendering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'basic');
});
test('renders the grid with rows and columns', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible();
// Header row should exist
const headers = page.locator('[role="columnheader"]');
await expect(headers.first()).toBeVisible();
// Should have all 9 column headers
await expect(headers).toHaveCount(9);
});
test('renders correct column headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Age' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Department' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Salary' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Start Date' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Active' })).toBeVisible();
});
test('renders data rows', async ({ page }) => {
// Should render multiple data rows (20 in small dataset)
const rows = page.locator('[role="row"]');
// At least header + some data rows
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('clicking a column header sorts data', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]', { hasText: 'ID' });
await idHeader.click();
// After clicking, should have a sort indicator
const sortAttr = await idHeader.getAttribute('aria-sort');
expect(sortAttr).toBeTruthy();
expect(['ascending', 'descending']).toContain(sortAttr);
});
test('clicking column header twice reverses sort', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]', { hasText: 'ID' });
await idHeader.click();
const firstSort = await idHeader.getAttribute('aria-sort');
await idHeader.click();
const secondSort = await idHeader.getAttribute('aria-sort');
expect(firstSort).not.toEqual(secondSort);
});
});
// ─── 2. Large Dataset (Virtualization) ─────────────────────────────────────────
test.describe('Large Dataset', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'large-dataset');
});
test('renders without crashing on 10k rows', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible();
const rows = page.locator('[role="row"]');
const count = await rows.count();
// Virtualized: should render far fewer rows than 10,000
expect(count).toBeGreaterThan(1);
expect(count).toBeLessThan(200);
});
test('grid aria-rowcount reflects total data size', async ({ page }) => {
const grid = page.locator('[role="grid"]');
const rowCount = await grid.getAttribute('aria-rowcount');
expect(Number(rowCount)).toBe(10000);
});
});
// ─── 3. Single Selection ────────────────────────────────────────────────────────
test.describe('Single Selection', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'single-selection');
});
test('renders checkboxes in each row', async ({ page }) => {
const checkboxes = page.locator('[role="row"] input[type="checkbox"]');
await expect(checkboxes.first()).toBeVisible({ timeout: 5000 });
const count = await checkboxes.count();
expect(count).toBeGreaterThan(0);
});
test('clicking a row selects it', async ({ page }) => {
const firstDataRow = page.locator('[role="row"]').nth(1);
await firstDataRow.click();
await page.waitForTimeout(300);
// Selection state should show in the debug output
const selectionText = page.locator('text=Selected:');
await expect(selectionText).toBeVisible();
});
test('clicking another row deselects previous in single mode', async ({ page }) => {
// Click first data row
const firstDataRow = page.locator('[role="row"]').nth(1);
await firstDataRow.click();
await page.waitForTimeout(300);
// Click second data row
const secondDataRow = page.locator('[role="row"]').nth(2);
await secondDataRow.click();
await page.waitForTimeout(300);
// In single selection, only one should be selected
const checkboxes = page.locator('[role="row"] input[type="checkbox"]:checked');
const checkedCount = await checkboxes.count();
expect(checkedCount).toBeLessThanOrEqual(1);
});
});
// ─── 4. Multi Selection ─────────────────────────────────────────────────────────
test.describe('Multi Selection', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'multi-selection');
});
test('renders select-all checkbox in header', async ({ page }) => {
const selectAllCheckbox = page.locator('[aria-label="Select all rows"]');
await expect(selectAllCheckbox).toBeVisible({ timeout: 5000 });
});
test('selecting multiple rows shows count', async ({ page }) => {
// Click first data row
await page.locator('[role="row"]').nth(1).click();
await page.waitForTimeout(200);
// Shift-click third row to extend selection
await page.locator('[role="row"]').nth(3).click({ modifiers: ['Shift'] });
await page.waitForTimeout(300);
// The selected count should show in the debug output
const countText = page.locator('text=/Selected \\(\\d+ rows\\)/');
await expect(countText).toBeVisible({ timeout: 3000 });
});
test('select-all checkbox selects all rows', async ({ page }) => {
const selectAll = page.locator('[aria-label="Select all rows"]');
await selectAll.click();
await page.waitForTimeout(300);
// Should show all 20 rows selected
await expect(page.locator('text=/Selected \\(20 rows\\)/')).toBeVisible({ timeout: 3000 });
});
});
// ─── 5. Search ──────────────────────────────────────────────────────────────────
test.describe('Search', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-search');
});
test('Ctrl+F opens search overlay', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
test('search filters rows', async ({ page }) => {
const initialRowCount = await page.locator('[role="row"]').count();
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
await page.waitForTimeout(500);
const filteredRowCount = await page.locator('[role="row"]').count();
expect(filteredRowCount).toBeLessThan(initialRowCount);
});
test('Escape closes search overlay', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
await page.keyboard.press('Escape');
await expect(page.locator('[aria-label="Search grid"]')).not.toBeVisible({ timeout: 3000 });
});
test('clearing search restores all rows', async ({ page }) => {
const initialRowCount = await page.locator('[role="row"]').count();
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
await page.waitForTimeout(500);
// Clear the search
await searchInput.fill('');
await page.waitForTimeout(500);
const restoredRowCount = await page.locator('[role="row"]').count();
expect(restoredRowCount).toBeGreaterThanOrEqual(initialRowCount);
});
});
// ─── 6. Keyboard Navigation ────────────────────────────────────────────────────
test.describe('Keyboard Navigation', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'keyboard-navigation');
});
test('ArrowDown moves focus to next row', async ({ page }) => {
// Focus the grid container
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
// Grid should still have focus (not lost)
await expect(page.locator('[role="grid"]')).toBeVisible();
});
test('Space toggles row selection', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
// Move to first data row
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Space');
await page.waitForTimeout(300);
// Should show "Selected: 1 rows" in the debug text
await expect(page.locator('text=/Selected: \\d+ rows/')).toBeVisible({ timeout: 3000 });
});
test('Ctrl+A selects all rows', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+a');
await page.waitForTimeout(300);
await expect(page.locator('text=/Selected: 20 rows/')).toBeVisible({ timeout: 3000 });
});
test('Ctrl+F opens search from keyboard nav story', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
});
// ─── 7. Inline Editing ──────────────────────────────────────────────────────────
test.describe('Inline Editing', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-inline-editing');
});
test('double-click on editable cell enters edit mode', async ({ page }) => {
// Find a First Name cell (column index 1, since ID is 0)
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
await firstNameCell.dblclick();
// An input should appear
const input = page.locator('[role="row"]').nth(1).locator('input');
await expect(input).toBeVisible({ timeout: 3000 });
});
test('Enter commits the edit', async ({ page }) => {
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
const originalText = await firstNameCell.innerText();
await firstNameCell.dblclick();
const input = page.locator('[role="row"]').nth(1).locator('input').first();
await expect(input).toBeVisible({ timeout: 3000 });
await input.fill('TestName');
await page.keyboard.press('Enter');
await page.waitForTimeout(300);
// The cell text should now show the new value
const updatedText = await firstNameCell.innerText();
expect(updatedText).toContain('TestName');
});
test('Escape cancels the edit', async ({ page }) => {
const firstNameCell = page.locator('[role="row"]').nth(1).locator('[role="gridcell"]').nth(1);
const originalText = await firstNameCell.innerText();
await firstNameCell.dblclick();
const input = page.locator('[role="row"]').nth(1).locator('input').first();
await expect(input).toBeVisible({ timeout: 3000 });
await input.fill('CancelledValue');
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
// The cell should revert to original value
const restoredText = await firstNameCell.innerText();
expect(restoredText).toBe(originalText);
});
});
// ─── 8. Client-Side Pagination ──────────────────────────────────────────────────
test.describe('Client-Side Pagination', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-client-side-pagination');
});
test('renders pagination controls', async ({ page }) => {
await expect(page.locator('text=Page 1 of')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=Rows per page:')).toBeVisible();
});
test('shows correct initial page info', async ({ page }) => {
// 10,000 rows / 25 per page = 400 pages
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
});
test('next page button navigates forward', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the "next page" button (single chevron right)
const nextPageBtn = page.locator('[class*="griddy-pagination"] button').nth(2);
await nextPageBtn.click();
await page.waitForTimeout(300);
await expect(page.locator('text=Page 2 of 400')).toBeVisible();
});
test('last page button navigates to end', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the "last page" button (double chevron right)
const lastPageBtn = page.locator('[class*="griddy-pagination"] button').nth(3);
await lastPageBtn.click();
await page.waitForTimeout(300);
await expect(page.locator('text=Page 400 of 400')).toBeVisible();
});
test('first page button is disabled on first page', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// First page button (double chevron left) should be disabled
const firstPageBtn = page.locator('[class*="griddy-pagination"] button').first();
await expect(firstPageBtn).toBeDisabled();
});
test('page size selector changes rows per page', async ({ page }) => {
await expect(page.locator('text=Page 1 of 400')).toBeVisible({ timeout: 5000 });
// Click the page size select (Mantine Select renders as textbox)
const pageSizeSelect = page.getByRole('textbox');
await pageSizeSelect.click();
// Select 50 rows per page
await page.locator('[role="option"]', { hasText: '50' }).click();
await page.waitForTimeout(300);
// 10,000 / 50 = 200 pages
await expect(page.locator('text=Page 1 of 200')).toBeVisible({ timeout: 3000 });
});
});
// ─── 9. Server-Side Pagination ──────────────────────────────────────────────────
test.describe('Server-Side Pagination', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-server-side-pagination');
});
test('renders data after initial load', async ({ page }) => {
// Wait for server-side data to load (300ms delay)
await expect(page.locator('text=Displayed Rows: 25')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('shows server state info', async ({ page }) => {
await expect(page.locator('text=Total Rows: 10000')).toBeVisible({ timeout: 5000 });
await expect(page.locator('text=Displayed Rows: 25')).toBeVisible();
});
test('navigating pages updates server state', async ({ page }) => {
await expect(page.locator('text=Current Page: 1')).toBeVisible({ timeout: 5000 });
// Navigate to next page
const nextPageBtn = page.locator('[class*="griddy-pagination"] button').nth(2);
await nextPageBtn.click();
await expect(page.locator('text=Current Page: 2')).toBeVisible({ timeout: 5000 });
});
});
// ─── 10. Toolbar ────────────────────────────────────────────────────────────────
test.describe('Toolbar', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-toolbar');
});
test('renders export button', async ({ page }) => {
const exportBtn = page.locator('[aria-label="Export to CSV"]');
await expect(exportBtn).toBeVisible({ timeout: 5000 });
});
test('renders column toggle button', async ({ page }) => {
const columnToggleBtn = page.locator('[aria-label="Toggle columns"]');
await expect(columnToggleBtn).toBeVisible({ timeout: 5000 });
});
test('column toggle menu shows all columns', async ({ page }) => {
await page.locator('[aria-label="Toggle columns"]').click();
await expect(page.locator('text=Toggle Columns')).toBeVisible({ timeout: 3000 });
// Should show checkboxes for each column
const checkboxes = page.locator('.mantine-Menu-dropdown input[type="checkbox"]');
const count = await checkboxes.count();
expect(count).toBeGreaterThanOrEqual(8);
});
test('toggling column visibility hides a column', async ({ page }) => {
// Verify "Email" header is initially visible
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible({ timeout: 5000 });
// Open column toggle menu
await page.locator('[aria-label="Toggle columns"]').click();
await expect(page.locator('text=Toggle Columns')).toBeVisible({ timeout: 3000 });
// Uncheck the "Email" column
const emailCheckbox = page.locator('.mantine-Checkbox-root', { hasText: 'Email' }).locator('input[type="checkbox"]');
await emailCheckbox.click();
await page.waitForTimeout(300);
// Close the menu by clicking elsewhere
await page.locator('[role="grid"]').click();
await page.waitForTimeout(300);
// Email header should now be hidden
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).not.toBeVisible();
});
});
// ─── 11. Column Pinning ─────────────────────────────────────────────────────────
test.describe('Column Pinning', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-column-pinning');
});
test('renders grid with pinned columns', async ({ page }) => {
// ID and First Name should be visible (pinned left)
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
// Active should be visible (pinned right)
await expect(page.locator('[role="columnheader"]', { hasText: 'Active' })).toBeVisible();
});
test('all column headers render', async ({ page }) => {
const headers = page.locator('[role="columnheader"]');
const count = await headers.count();
expect(count).toBeGreaterThanOrEqual(8);
});
});
// ─── 12. Header Grouping ────────────────────────────────────────────────────────
test.describe('Header Grouping', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-header-grouping');
});
test('renders group headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'Personal Info' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'Contact' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Employment' })).toBeVisible();
});
test('renders child column headers under groups', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Age' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Department' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Salary' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Start Date' })).toBeVisible();
});
test('has multiple header rows', async ({ page }) => {
// Should have at least 2 header rows (group + column)
const headerRows = page.locator('[role="row"]').filter({ has: page.locator('[role="columnheader"]') });
const count = await headerRows.count();
expect(count).toBeGreaterThanOrEqual(2);
});
});
// ─── 13. Data Grouping ──────────────────────────────────────────────────────────
test.describe('Data Grouping', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-data-grouping');
});
test('renders grouped rows by department', async ({ page }) => {
// Should show department names as group headers
await expect(page.locator('[role="row"]', { hasText: 'Engineering' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="row"]', { hasText: 'Marketing' })).toBeVisible();
});
test('expanding a group shows its members', async ({ page }) => {
// Click on Engineering group to expand it
const engineeringRow = page.locator('[role="row"]', { hasText: 'Engineering' });
await engineeringRow.locator('button').first().click();
await page.waitForTimeout(300);
// Should show individual people from Engineering department
// From generateData: index 0 is Engineering (Alice Smith)
const rows = page.locator('[role="row"]');
const count = await rows.count();
// After expanding one group, should have more rows visible
expect(count).toBeGreaterThan(8); // 8 department groups
});
});
// ─── 14. Column Reordering ──────────────────────────────────────────────────────
test.describe('Column Reordering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-column-reordering');
});
test('renders all column headers', async ({ page }) => {
await expect(page.locator('[role="columnheader"]', { hasText: 'ID' })).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="columnheader"]', { hasText: 'First Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Last Name' })).toBeVisible();
await expect(page.locator('[role="columnheader"]', { hasText: 'Email' })).toBeVisible();
});
test('column headers have draggable attribute', async ({ page }) => {
// Column headers should be draggable for reordering
const headers = page.locator('[role="columnheader"][draggable="true"]');
const count = await headers.count();
// At least some headers should be draggable (selection column is excluded)
expect(count).toBeGreaterThan(0);
});
});
// ─── 15. Infinite Scroll ────────────────────────────────────────────────────────
test.describe('Infinite Scroll', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-infinite-scroll');
});
test('renders initial batch of rows', async ({ page }) => {
// Initial batch is 50 rows
await expect(page.locator('text=Current: 50 rows')).toBeVisible({ timeout: 5000 });
});
test('scrolling to bottom loads more data', async ({ page }) => {
await expect(page.locator('text=Current: 50 rows')).toBeVisible({ timeout: 5000 });
// Scroll to bottom of the grid container
const container = page.locator('[role="grid"] [tabindex="0"]');
await container.click();
// Use End key to jump to last row, triggering infinite scroll
await page.keyboard.press('End');
await page.waitForTimeout(2000); // Wait for the 1000ms load delay + buffer
// Should now have more than 50 rows
await expect(page.locator('text=Current: 100 rows')).toBeVisible({ timeout: 5000 });
});
});
// ─── 16. Text Filtering ─────────────────────────────────────────────────────────
test.describe('Text Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-text-filtering');
});
test('filterable columns show filter icon', async ({ page }) => {
const firstNameHeader = page.locator('[role="columnheader"]').filter({ hasText: 'First Name' });
await expect(firstNameHeader).toBeVisible({ timeout: 5000 });
const filterIcon = firstNameHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening filter popover shows text filter UI', async ({ page }) => {
const firstNameHeader = page.locator('[role="columnheader"]').filter({ hasText: 'First Name' });
const filterIcon = firstNameHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: firstName')).toBeVisible({ timeout: 5000 });
});
test('non-filterable columns have no filter icon', async ({ page }) => {
const idHeader = page.locator('[role="columnheader"]').filter({ hasText: 'ID' });
await expect(idHeader).toBeVisible({ timeout: 5000 });
const filterIcon = idHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).not.toBeVisible();
});
});
// ─── 17. Number Filtering ───────────────────────────────────────────────────────
test.describe('Number Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-number-filtering');
});
test('age column has filter icon', async ({ page }) => {
const ageHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Age' });
await expect(ageHeader).toBeVisible({ timeout: 5000 });
const filterIcon = ageHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('salary column has filter icon', async ({ page }) => {
const salaryHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Salary' });
await expect(salaryHeader).toBeVisible({ timeout: 5000 });
const filterIcon = salaryHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening number filter shows numeric filter UI', async ({ page }) => {
const ageHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Age' });
const filterIcon = ageHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: age')).toBeVisible({ timeout: 5000 });
});
});
// ─── 18. Enum Filtering ─────────────────────────────────────────────────────────
test.describe('Enum Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-enum-filtering');
});
test('department column has filter icon', async ({ page }) => {
const deptHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' });
await expect(deptHeader).toBeVisible({ timeout: 5000 });
const filterIcon = deptHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening enum filter shows the filter popover', async ({ page }) => {
const deptHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' });
const filterIcon = deptHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: department')).toBeVisible({ timeout: 5000 });
});
});
// ─── 19. Boolean Filtering ──────────────────────────────────────────────────────
test.describe('Boolean Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-boolean-filtering');
});
test('active column has filter icon', async ({ page }) => {
const activeHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Active' });
await expect(activeHeader).toBeVisible({ timeout: 5000 });
const filterIcon = activeHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening boolean filter shows the filter popover', async ({ page }) => {
const activeHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Active' });
const filterIcon = activeHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: active')).toBeVisible({ timeout: 5000 });
});
});
// ─── 20. Date Filtering ─────────────────────────────────────────────────────────
test.describe('Date Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-date-filtering');
});
test('start date column has filter icon', async ({ page }) => {
const dateHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Start Date' });
await expect(dateHeader).toBeVisible({ timeout: 5000 });
const filterIcon = dateHeader.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
});
test('opening date filter shows the filter popover', async ({ page }) => {
const dateHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Start Date' });
const filterIcon = dateHeader.locator('[aria-label="Open column filter"]');
await filterIcon.click();
await expect(page.locator('text=Filter: startDate')).toBeVisible({ timeout: 5000 });
});
});
// ─── 21. All Filter Types ───────────────────────────────────────────────────────
test.describe('All Filter Types', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'with-all-filter-types');
});
test('all filterable columns show filter icons', async ({ page }) => {
const filterableColumns = ['First Name', 'Last Name', 'Age', 'Department', 'Start Date', 'Active'];
for (const colName of filterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
}
});
test('non-filterable columns have no filter icon', async ({ page }) => {
const nonFilterableColumns = ['ID', 'Email', 'Salary'];
for (const colName of nonFilterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).not.toBeVisible();
}
});
});
// ─── 22. Server-Side Filtering & Sorting ────────────────────────────────────────
test.describe('Server-Side Filtering & Sorting', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'server-side-filtering-sorting');
});
test('renders data from simulated server', async ({ page }) => {
// Wait for server data to load (300ms simulated delay)
await expect(page.locator('text=Loading: false')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('shows server-side mode indicator', async ({ page }) => {
await expect(page.locator('text=Server-Side Mode:')).toBeVisible({ timeout: 5000 });
});
test('sorting updates server state', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 });
// Wait for initial load
await page.waitForTimeout(500);
// Click First Name header to sort
const firstNameHeader = page.locator('[role="columnheader"]', { hasText: 'First Name' });
await firstNameHeader.click();
await page.waitForTimeout(500);
// Active Sorting section should now show sorting state
await expect(page.locator('text=Active Sorting:')).toBeVisible();
});
});
// ─── 23. Large Dataset with Filtering ───────────────────────────────────────────
test.describe('Large Dataset with Filtering', () => {
test.beforeEach(async ({ page }) => {
await gotoStory(page, 'large-dataset-with-filtering');
});
test('renders large dataset with filter columns', async ({ page }) => {
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 });
const rows = page.locator('[role="row"]');
const count = await rows.count();
expect(count).toBeGreaterThan(1);
});
test('all expected filter columns have filter icons', async ({ page }) => {
const filterableColumns = ['First Name', 'Last Name', 'Age', 'Department', 'Start Date', 'Active'];
for (const colName of filterableColumns) {
const header = page.locator('[role="columnheader"]').filter({ hasText: colName });
await expect(header).toBeVisible({ timeout: 5000 });
const filterIcon = header.locator('[aria-label="Open column filter"]');
await expect(filterIcon).toBeVisible({ timeout: 3000 });
}
});
});

View File

@@ -211,11 +211,14 @@ test.describe('Tree with Search Auto-Expand', () => {
});
test('Ctrl+F opens search overlay', async ({ page }) => {
// Focus the grid scroll container before pressing keyboard shortcut
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
await expect(page.locator('[aria-label="Search grid"]')).toBeVisible({ timeout: 3000 });
});
test('searching for a leaf node auto-expands ancestors', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');
@@ -230,6 +233,7 @@ test.describe('Tree with Search Auto-Expand', () => {
});
test('clearing search preserves expanded state', async ({ page }) => {
await page.locator('[role="grid"] [tabindex="0"]').click();
await page.keyboard.press('Control+f');
const searchInput = page.locator('[aria-label="Search grid"]');
await searchInput.fill('Alice');