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:
815
tests/e2e/griddy-core.spec.ts
Normal file
815
tests/e2e/griddy-core.spec.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user