feat(search): add search history functionality with dropdown and persistence
- Implement SearchHistoryDropdown component for displaying recent searches - Add useSearchHistory hook for managing search history in localStorage - Integrate search history into SearchOverlay for user convenience - Update GridToolbar to support filter presets - Enhance SearchOverlay with close button and history display
This commit is contained in:
333
tests/e2e/griddy-features.spec.ts
Normal file
333
tests/e2e/griddy-features.spec.ts
Normal file
@@ -0,0 +1,333 @@
|
||||
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`)
|
||||
// Wait for the grid root to render
|
||||
await page.waitForSelector('[role="grid"]', { timeout: 10000 })
|
||||
}
|
||||
|
||||
// ─── 1. Error Boundary ──────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Error Boundary', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoStory(page, 'with-error-boundary')
|
||||
})
|
||||
|
||||
test('should render grid normally before error', async ({ page }) => {
|
||||
await expect(page.locator('[role="grid"]')).toBeVisible()
|
||||
await expect(page.locator('[role="row"]').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show error fallback when error is triggered', async ({ page }) => {
|
||||
await page.locator('button:has-text("Trigger Error")').click()
|
||||
await expect(page.locator('text=Something went wrong rendering the grid.')).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.locator('text=Intentional render error for testing')).toBeVisible()
|
||||
await expect(page.locator('button:has-text("Retry")')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should recover when retry is clicked', async ({ page }) => {
|
||||
await page.locator('button:has-text("Trigger Error")').click()
|
||||
await expect(page.locator('text=Something went wrong rendering the grid.')).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await page.locator('button:has-text("Retry")').click()
|
||||
|
||||
// The error boundary defers state reset via setTimeout to let parent onRetry flush first
|
||||
// Wait for the error message to disappear, then verify grid re-renders
|
||||
await expect(page.locator('text=Something went wrong rendering the grid.')).not.toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('[role="grid"]')).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 2. Loading States ──────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Loading States', () => {
|
||||
test('should show skeleton when loading with no data', async ({ page }) => {
|
||||
// Navigate directly - don't wait for grid role since it may show skeleton first
|
||||
await page.goto('/iframe.html?id=components-griddy--with-loading-states&viewMode=story')
|
||||
// Skeleton uses shimmer animation - look for the skeleton bar elements
|
||||
const skeleton = page.locator('[class*="skeleton"]')
|
||||
await expect(skeleton.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should show grid after data loads', async ({ page }) => {
|
||||
await page.goto('/iframe.html?id=components-griddy--with-loading-states&viewMode=story')
|
||||
// Wait for data to load (3s delay in story)
|
||||
await expect(page.locator('[role="row"]').first()).toBeVisible({ timeout: 6000 })
|
||||
})
|
||||
|
||||
test('should show skeleton again after reload', async ({ page }) => {
|
||||
await page.goto('/iframe.html?id=components-griddy--with-loading-states&viewMode=story')
|
||||
// Wait for initial load
|
||||
await expect(page.locator('[role="row"]').first()).toBeVisible({ timeout: 6000 })
|
||||
|
||||
// Click reload
|
||||
await page.locator('button:has-text("Reload Data")').click()
|
||||
|
||||
// Skeleton should appear
|
||||
const skeleton = page.locator('[class*="skeleton"]')
|
||||
await expect(skeleton.first()).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Then data should load again
|
||||
await expect(page.locator('[role="row"]').first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 3. Custom Cell Renderers ───────────────────────────────────────────────
|
||||
|
||||
test.describe('Custom Cell Renderers', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoStory(page, 'with-custom-renderers')
|
||||
})
|
||||
|
||||
test('should render badge elements for department column', async ({ page }) => {
|
||||
const badges = page.locator('[class*="renderer-badge"]')
|
||||
await expect(badges.first()).toBeVisible({ timeout: 5000 })
|
||||
expect(await badges.count()).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('should render progress bar elements', async ({ page }) => {
|
||||
const progressBars = page.locator('[class*="renderer-progress"]')
|
||||
await expect(progressBars.first()).toBeVisible({ timeout: 5000 })
|
||||
expect(await progressBars.count()).toBeGreaterThan(0)
|
||||
|
||||
const innerBar = page.locator('[class*="renderer-progress-bar"]').first()
|
||||
await expect(innerBar).toBeVisible()
|
||||
})
|
||||
|
||||
test('should render sparkline SVGs', async ({ page }) => {
|
||||
const sparklines = page.locator('[class*="renderer-sparkline"]')
|
||||
await expect(sparklines.first()).toBeVisible({ timeout: 5000 })
|
||||
expect(await sparklines.count()).toBeGreaterThan(0)
|
||||
|
||||
const polyline = page.locator('[class*="renderer-sparkline"] polyline').first()
|
||||
await expect(polyline).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 4. Quick Filters ──────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Quick Filters', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoStory(page, 'with-quick-filters')
|
||||
})
|
||||
|
||||
test('should show quick filter dropdown in filter popover', async ({ page }) => {
|
||||
// The Department column has a filter button - click it
|
||||
const departmentHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' })
|
||||
await expect(departmentHeader).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Click the filter icon inside the Department header
|
||||
const filterIcon = departmentHeader.locator('[aria-label="Open column filter"]')
|
||||
await expect(filterIcon).toBeVisible({ timeout: 3000 })
|
||||
await filterIcon.click()
|
||||
|
||||
// The popover should show the "Filter: department" text from ColumnFilterPopover
|
||||
await expect(page.locator('text=Filter: department')).toBeVisible({ timeout: 5000 })
|
||||
// Quick filter section text should also appear
|
||||
await expect(page.getByText('Quick Filter', { exact: true })).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('should show unique values as checkboxes', async ({ page }) => {
|
||||
const departmentHeader = page.locator('[role="columnheader"]').filter({ hasText: 'Department' })
|
||||
await expect(departmentHeader).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// Click the filter icon
|
||||
const filterIcon = departmentHeader.locator('[aria-label="Open column filter"]')
|
||||
await filterIcon.click()
|
||||
|
||||
// Wait for the filter popover to open
|
||||
await expect(page.locator('text=Filter: department')).toBeVisible({ timeout: 5000 })
|
||||
|
||||
// The Quick Filter section should have the search values input
|
||||
await expect(page.locator('input[placeholder="Search values..."]')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Should have checkbox labels for department values (e.g., Design, Engineering, Finance...)
|
||||
const checkboxCount = await page.locator('input[type="checkbox"]').count()
|
||||
expect(checkboxCount).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 5. Advanced Search ────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Advanced Search', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoStory(page, 'with-advanced-search')
|
||||
})
|
||||
|
||||
test('should render the advanced search panel', async ({ page }) => {
|
||||
// Look for the panel by its CSS class since "Advanced Search" text also appears in the info box
|
||||
const panel = page.locator('[class*="advanced-search"]')
|
||||
await expect(panel.first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should have boolean operator selector', async ({ page }) => {
|
||||
// Use exact text matching for the segmented control labels
|
||||
const segmented = page.locator('.mantine-SegmentedControl-root')
|
||||
await expect(segmented).toBeVisible({ timeout: 5000 })
|
||||
|
||||
await expect(page.getByText('AND', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('OR', { exact: true })).toBeVisible()
|
||||
await expect(page.getByText('NOT', { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test('should have a condition row with column, operator, value', async ({ page }) => {
|
||||
await expect(page.locator('input[placeholder="Column"]')).toBeVisible({ timeout: 5000 })
|
||||
await expect(page.locator('input[placeholder="Value"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should add a new condition row when clicking Add', async ({ page }) => {
|
||||
const addBtn = page.locator('button:has-text("Add condition")')
|
||||
await expect(addBtn).toBeVisible({ timeout: 5000 })
|
||||
|
||||
const initialCount = await page.locator('input[placeholder="Value"]').count()
|
||||
await addBtn.click()
|
||||
const newCount = await page.locator('input[placeholder="Value"]').count()
|
||||
expect(newCount).toBe(initialCount + 1)
|
||||
})
|
||||
|
||||
test('should filter data when search is applied', async ({ page }) => {
|
||||
const initialRowCount = await page.locator('[role="row"]').count()
|
||||
|
||||
await page.locator('input[placeholder="Column"]').click()
|
||||
await page.locator('[role="option"]:has-text("First Name")').click()
|
||||
await page.locator('input[placeholder="Value"]').fill('Alice')
|
||||
await page.locator('button:has-text("Search")').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const filteredRowCount = await page.locator('[role="row"]').count()
|
||||
expect(filteredRowCount).toBeLessThan(initialRowCount)
|
||||
})
|
||||
|
||||
test('should clear search when Clear is clicked', async ({ page }) => {
|
||||
await page.locator('input[placeholder="Column"]').click()
|
||||
await page.locator('[role="option"]:has-text("First Name")').click()
|
||||
await page.locator('input[placeholder="Value"]').fill('Alice')
|
||||
await page.locator('button:has-text("Search")').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const filteredCount = await page.locator('[role="row"]').count()
|
||||
|
||||
await page.locator('[class*="advanced-search"] button:has-text("Clear")').click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const clearedCount = await page.locator('[role="row"]').count()
|
||||
expect(clearedCount).toBeGreaterThanOrEqual(filteredCount)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 6. Filter Presets ─────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Filter Presets', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoStory(page, 'with-filter-presets')
|
||||
// Clear any existing presets from localStorage
|
||||
await page.evaluate(() => localStorage.removeItem('griddy-filter-presets-storybook-presets'))
|
||||
})
|
||||
|
||||
test('should show filter presets button in toolbar', async ({ page }) => {
|
||||
const presetsBtn = page.locator('[aria-label="Filter presets"]')
|
||||
await expect(presetsBtn).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('should open presets menu on click', async ({ page }) => {
|
||||
await page.locator('[aria-label="Filter presets"]').click()
|
||||
await expect(page.locator('text=Saved Presets')).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('text=Save Current Filters')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should show empty state when no presets saved', async ({ page }) => {
|
||||
await page.locator('[aria-label="Filter presets"]').click()
|
||||
await expect(page.locator('text=No presets saved')).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('should save a preset', async ({ page }) => {
|
||||
await page.locator('[aria-label="Filter presets"]').click()
|
||||
await expect(page.locator('text=Save Current Filters')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await page.locator('input[placeholder="Preset name"]').fill('My Test Preset')
|
||||
// Use a more specific selector to only match the actual Save button, not menu items
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
// Reopen menu and check preset is listed
|
||||
await page.locator('[aria-label="Filter presets"]').click()
|
||||
await expect(page.locator('text=My Test Preset')).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
})
|
||||
|
||||
// ─── 7. Search History ─────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Search History', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await gotoStory(page, 'with-search-history')
|
||||
// Clear any existing search history
|
||||
await page.evaluate(() => localStorage.removeItem('griddy-search-history-storybook-search-history'))
|
||||
})
|
||||
|
||||
test('should open search overlay with Ctrl+F', async ({ page }) => {
|
||||
const container = page.locator('[class*="griddy-container"]')
|
||||
await container.click()
|
||||
await page.keyboard.press('Control+f')
|
||||
await expect(page.locator('[class*="search-overlay"]')).toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('should filter grid when typing in search', async ({ page }) => {
|
||||
const container = page.locator('[class*="griddy-container"]')
|
||||
await container.click()
|
||||
await page.keyboard.press('Control+f')
|
||||
|
||||
const initialRows = await page.locator('[role="row"]').count()
|
||||
|
||||
const searchInput = page.locator('[aria-label="Search grid"]')
|
||||
await searchInput.fill('Alice')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const filteredRows = await page.locator('[role="row"]').count()
|
||||
expect(filteredRows).toBeLessThan(initialRows)
|
||||
})
|
||||
|
||||
test('should close search with Escape', async ({ page }) => {
|
||||
const container = page.locator('[class*="griddy-container"]')
|
||||
await container.click()
|
||||
await page.keyboard.press('Control+f')
|
||||
await expect(page.locator('[class*="search-overlay"]')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('[class*="search-overlay"]')).not.toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('should close search with X button', async ({ page }) => {
|
||||
const container = page.locator('[class*="griddy-container"]')
|
||||
await container.click()
|
||||
await page.keyboard.press('Control+f')
|
||||
await expect(page.locator('[class*="search-overlay"]')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
await page.locator('[aria-label="Close search"]').click()
|
||||
await expect(page.locator('[class*="search-overlay"]')).not.toBeVisible({ timeout: 3000 })
|
||||
})
|
||||
|
||||
test('should show search history on focus after previous search', async ({ page }) => {
|
||||
const container = page.locator('[class*="griddy-container"]')
|
||||
await container.click()
|
||||
await page.keyboard.press('Control+f')
|
||||
|
||||
const searchInput = page.locator('[aria-label="Search grid"]')
|
||||
await searchInput.fill('Alice')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Close and reopen search
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('[class*="search-overlay"]')).not.toBeVisible({ timeout: 3000 })
|
||||
|
||||
await container.click()
|
||||
await page.keyboard.press('Control+f')
|
||||
await expect(page.locator('[class*="search-overlay"]')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Focus the input
|
||||
await searchInput.click()
|
||||
|
||||
// History dropdown should appear
|
||||
await expect(page.locator('text=Recent searches')).toBeVisible({ timeout: 3000 })
|
||||
await expect(page.locator('[class*="search-history-item"]').first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user