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:
2026-02-15 13:52:36 +02:00
parent 6226193ab5
commit 9ec2e73640
42 changed files with 2026 additions and 780 deletions

View 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()
})
})