From 9ec2e73640756bca5b2c588ff821a3f2b23b5825 Mon Sep 17 00:00:00 2001 From: Hein Date: Sun, 15 Feb 2026 13:52:36 +0200 Subject: [PATCH] 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 --- .gitignore | 1 + .storybook/preview.ts | 2 +- src/Griddy/CONTEXT.md | 567 +++++------------- src/Griddy/Griddy.stories.tsx | 311 +++++++++- src/Griddy/SUMMARY.md | 286 --------- src/Griddy/core/Griddy.tsx | 24 +- src/Griddy/core/GriddyStore.ts | 11 +- src/Griddy/core/columnMapper.ts | 12 + src/Griddy/core/types.ts | 19 + .../advancedSearch/AdvancedSearchPanel.tsx | 134 +++++ .../advancedSearch/SearchConditionRow.tsx | 58 ++ .../advancedSearch/advancedFilterFn.ts | 45 ++ src/Griddy/features/advancedSearch/index.ts | 2 + src/Griddy/features/advancedSearch/types.ts | 13 + .../errorBoundary/GriddyErrorBoundary.tsx | 58 ++ src/Griddy/features/errorBoundary/index.ts | 1 + .../filterPresets/FilterPresetsMenu.tsx | 96 +++ src/Griddy/features/filterPresets/index.ts | 3 + src/Griddy/features/filterPresets/types.ts | 8 + .../filterPresets/useFilterPresets.ts | 43 ++ .../features/filtering/ColumnFilterButton.tsx | 47 +- .../filtering/ColumnFilterPopover.tsx | 23 +- src/Griddy/features/filtering/types.ts | 2 + .../loading/GriddyLoadingSkeleton.tsx | 41 ++ src/Griddy/features/loading/index.ts | 1 + .../quickFilter/QuickFilterDropdown.tsx | 81 +++ src/Griddy/features/quickFilter/index.ts | 1 + .../features/renderers/BadgeRenderer.tsx | 24 + .../features/renderers/ImageRenderer.tsx | 27 + .../renderers/ProgressBarRenderer.tsx | 32 + .../features/renderers/SparklineRenderer.tsx | 45 ++ src/Griddy/features/renderers/index.ts | 4 + src/Griddy/features/search/SearchOverlay.tsx | 89 ++- .../searchHistory/SearchHistoryDropdown.tsx | 40 ++ src/Griddy/features/searchHistory/index.ts | 2 + .../searchHistory/useSearchHistory.ts | 42 ++ src/Griddy/features/toolbar/GridToolbar.tsx | 10 +- src/Griddy/index.ts | 9 + src/Griddy/plan.md | 61 +- src/Griddy/rendering/TableHeader.tsx | 2 +- src/Griddy/styles/griddy.module.css | 196 ++++++ tests/e2e/griddy-features.spec.ts | 333 ++++++++++ 42 files changed, 2026 insertions(+), 780 deletions(-) delete mode 100644 src/Griddy/SUMMARY.md create mode 100644 src/Griddy/features/advancedSearch/AdvancedSearchPanel.tsx create mode 100644 src/Griddy/features/advancedSearch/SearchConditionRow.tsx create mode 100644 src/Griddy/features/advancedSearch/advancedFilterFn.ts create mode 100644 src/Griddy/features/advancedSearch/index.ts create mode 100644 src/Griddy/features/advancedSearch/types.ts create mode 100644 src/Griddy/features/errorBoundary/GriddyErrorBoundary.tsx create mode 100644 src/Griddy/features/errorBoundary/index.ts create mode 100644 src/Griddy/features/filterPresets/FilterPresetsMenu.tsx create mode 100644 src/Griddy/features/filterPresets/index.ts create mode 100644 src/Griddy/features/filterPresets/types.ts create mode 100644 src/Griddy/features/filterPresets/useFilterPresets.ts create mode 100644 src/Griddy/features/loading/GriddyLoadingSkeleton.tsx create mode 100644 src/Griddy/features/loading/index.ts create mode 100644 src/Griddy/features/quickFilter/QuickFilterDropdown.tsx create mode 100644 src/Griddy/features/quickFilter/index.ts create mode 100644 src/Griddy/features/renderers/BadgeRenderer.tsx create mode 100644 src/Griddy/features/renderers/ImageRenderer.tsx create mode 100644 src/Griddy/features/renderers/ProgressBarRenderer.tsx create mode 100644 src/Griddy/features/renderers/SparklineRenderer.tsx create mode 100644 src/Griddy/features/renderers/index.ts create mode 100644 src/Griddy/features/searchHistory/SearchHistoryDropdown.tsx create mode 100644 src/Griddy/features/searchHistory/index.ts create mode 100644 src/Griddy/features/searchHistory/useSearchHistory.ts create mode 100644 tests/e2e/griddy-features.spec.ts diff --git a/.gitignore b/.gitignore index f52343a..dc26d56 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ dist-ssr *storybook.log storybook-static +test-results/ \ No newline at end of file diff --git a/.storybook/preview.ts b/.storybook/preview.ts index e2b3d6e..a79b36a 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -13,7 +13,7 @@ const preview: Preview = { }, }, layout: 'fullscreen', - viewMode: 'responsive', + viewMode: 'desktop', }, }; diff --git a/src/Griddy/CONTEXT.md b/src/Griddy/CONTEXT.md index 1f36992..3e666e4 100644 --- a/src/Griddy/CONTEXT.md +++ b/src/Griddy/CONTEXT.md @@ -1,56 +1,148 @@ # Griddy - Implementation Context ## What Is This -Griddy is a new data grid component in the Oranguru package (`@warkypublic/oranguru`), replacing Glide Data Grid (used by Gridler) with TanStack Table + TanStack Virtual. +Griddy is a data grid component in the Oranguru package (`@warkypublic/oranguru`), built on TanStack Table + TanStack Virtual with Zustand state management. ## Architecture ### Two TanStack Libraries -- **@tanstack/react-table** (headless table model): owns sorting, filtering, pagination, row selection, column visibility, grouping state +- **@tanstack/react-table** (headless table model): sorting, filtering, pagination, row selection, column visibility, grouping - **@tanstack/react-virtual** (virtualization): renders only visible rows from the table's row model ### State Management -- **createSyncStore** from `@warkypublic/zustandsyncstore` — same pattern as Gridler's `GridlerStore.tsx` +- **createSyncStore** from `@warkypublic/zustandsyncstore` - `GriddyProvider` wraps children; props auto-sync into the store via `$sync` - `useGriddyStore((s) => s.fieldName)` to read any prop or UI state -- `GriddyStoreState` must explicitly declare all prop fields from `GriddyProps` for TypeScript visibility (the sync happens at runtime but TS needs the types) +- `GriddyStoreState` must explicitly declare all prop fields from `GriddyProps` for TypeScript visibility - UI state (focus, edit mode, search overlay, selection mode) lives in the store - TanStack Table/Virtual instances stored as `_table`, `_virtualizer` in the store ### Component Tree ``` - // forwardRef wrapper - // createSyncStore Provider, syncs all props - // sets up useReactTable + useVirtualizer - // Ctrl+F search (Mantine TextInput) -
// scroll container, keyboard target - // renders table.getHeaderGroups() - // maps virtualizer items → TableRow - // focus/selection CSS, click handler - // flexRender or Mantine Checkbox -
-
+ // forwardRef wrapper + // createSyncStore Provider, syncs all props + // class-based error boundary with retry + // sets up useReactTable + useVirtualizer + // Ctrl+F search (with search history) + // multi-condition boolean search + // export, column visibility, filter presets +
// scroll container, keyboard target + // headers, sort indicators, filter popovers + // shown when isLoading && no data + // maps virtualizer items -> TableRow + // focus/selection CSS, click handler + // flexRender, editors, custom renderers + // translucent overlay when loading with data +
+ // page nav, page size selector +
+
``` -## Key Files +## File Structure +``` +src/Griddy/ +├── core/ +│ ├── Griddy.tsx # Main component, useReactTable + useVirtualizer +│ ├── GriddyStore.ts # Zustand store (createSyncStore) +│ ├── types.ts # All interfaces: GriddyColumn, GriddyProps, GriddyRef, etc. +│ ├── columnMapper.ts # GriddyColumn -> TanStack ColumnDef, checkbox column +│ └── constants.ts # CSS class names, defaults (row height 36, overscan 10) +├── rendering/ +│ ├── VirtualBody.tsx # Virtual row rendering +│ ├── TableHeader.tsx # Headers, sort, resize, filter popovers, drag reorder +│ ├── TableRow.tsx # Row with focus/selection styling +│ ├── TableCell.tsx # Cell via flexRender, checkbox, editing +│ ├── EditableCell.tsx # Editor mounting wrapper +│ └── hooks/ +│ └── useGridVirtualizer.ts +├── editors/ +│ ├── TextEditor.tsx, NumericEditor.tsx, DateEditor.tsx +│ ├── SelectEditor.tsx, CheckboxEditor.tsx +│ ├── types.ts, index.ts +├── features/ +│ ├── errorBoundary/ +│ │ └── GriddyErrorBoundary.tsx # Class-based, onError/onRetry callbacks +│ ├── loading/ +│ │ └── GriddyLoadingSkeleton.tsx # Skeleton rows + overlay spinner +│ ├── renderers/ +│ │ ├── ProgressBarRenderer.tsx # Percentage bar via rendererMeta +│ │ ├── BadgeRenderer.tsx # Colored pill badges +│ │ ├── ImageRenderer.tsx # Thumbnail images +│ │ └── SparklineRenderer.tsx # SVG polyline sparklines +│ ├── filtering/ +│ │ ├── ColumnFilterPopover.tsx # Filter UI with quick filter integration +│ │ ├── ColumnFilterButton.tsx # Filter icon (forwardRef, onClick toggle) +│ │ ├── ColumnFilterContextMenu.tsx # Right-click: Sort, Open Filters +│ │ ├── FilterInput.tsx, FilterSelect.tsx, FilterBoolean.tsx, FilterDate.tsx +│ │ ├── filterFunctions.ts, operators.ts, types.ts +│ ├── quickFilter/ +│ │ └── QuickFilterDropdown.tsx # Checkbox list of unique column values +│ ├── advancedSearch/ +│ │ ├── AdvancedSearchPanel.tsx # Multi-condition search panel +│ │ ├── SearchConditionRow.tsx # Single condition: column + operator + value +│ │ ├── advancedFilterFn.ts # AND/OR/NOT filter logic +│ │ └── types.ts +│ ├── filterPresets/ +│ │ ├── FilterPresetsMenu.tsx # Save/load/delete presets dropdown +│ │ ├── useFilterPresets.ts # localStorage CRUD hook +│ │ └── types.ts +│ ├── search/ +│ │ └── SearchOverlay.tsx # Ctrl+F search with history integration +│ ├── searchHistory/ +│ │ ├── SearchHistoryDropdown.tsx # Recent searches dropdown +│ │ └── useSearchHistory.ts # localStorage hook (last 10 searches) +│ ├── keyboard/ +│ │ └── useKeyboardNavigation.ts +│ ├── pagination/ +│ │ └── PaginationControl.tsx +│ ├── toolbar/ +│ │ └── GridToolbar.tsx # Export, column visibility, filter presets +│ ├── export/ +│ │ └── exportCsv.ts +│ └── columnVisibility/ +│ └── ColumnVisibilityMenu.tsx +├── styles/ +│ └── griddy.module.css +├── index.ts +└── Griddy.stories.tsx # 31 stories covering all features -| File | Purpose | -|------|---------| -| `core/types.ts` | All interfaces: GriddyColumn, GriddyProps, GriddyRef, GriddyUIState, SelectionConfig, SearchConfig, etc. | -| `core/constants.ts` | CSS class names, defaults (row height 36, overscan 10, page size 50) | -| `core/columnMapper.ts` | Maps GriddyColumn → TanStack ColumnDef. Uses `accessorKey` for strings, `accessorFn` for functions. Auto-prepends checkbox column for selection. | -| `core/GriddyStore.ts` | createSyncStore with GriddyStoreState. Exports `GriddyProvider` and `useGriddyStore`. | -| `core/Griddy.tsx` | Main component. GriddyInner reads props from store, creates useReactTable + useVirtualizer, wires keyboard nav. | -| `rendering/VirtualBody.tsx` | Virtual row rendering. **Important**: all hooks must be before early return (hooks violation fix). | -| `rendering/TableHeader.tsx` | Header with sort indicators, resize handles, select-all checkbox. | -| `rendering/TableRow.tsx` | Row with focus/selection styling, click-to-select. | -| `rendering/TableCell.tsx` | Cell rendering via flexRender, checkbox for selection column. | -| `features/keyboard/useKeyboardNavigation.ts` | Full keyboard handler with ref to latest state. | -| `features/search/SearchOverlay.tsx` | Ctrl+F search overlay with debounced global filter. | -| `styles/griddy.module.css` | CSS Modules with custom properties for theming. | -| `Griddy.stories.tsx` | Storybook stories: Basic, LargeDataset, SingleSelection, MultiSelection, WithSearch, KeyboardNavigation. | +tests/e2e/ +├── filtering-context-menu.spec.ts # 8 tests for Phase 5 filtering +└── griddy-features.spec.ts # 26 tests for Phase 10 features +``` + +## Key Props (GriddyProps) +| Prop | Type | Purpose | +|------|------|---------| +| `data` | `T[]` | Data array | +| `columns` | `GriddyColumn[]` | Column definitions | +| `selection` | `SelectionConfig` | none/single/multi row selection | +| `search` | `SearchConfig` | Ctrl+F search overlay | +| `advancedSearch` | `{ enabled }` | Multi-condition search panel | +| `pagination` | `PaginationConfig` | Client/server-side pagination | +| `grouping` | `GroupingConfig` | Data grouping | +| `isLoading` | `boolean` | Show skeleton/overlay | +| `showToolbar` | `boolean` | Export + column visibility toolbar | +| `filterPresets` | `boolean` | Save/load filter presets | +| `onError` | `(error) => void` | Error boundary callback | +| `onRetry` | `() => void` | Error boundary retry callback | +| `onEditCommit` | `(rowId, colId, value) => void` | Edit callback | +| `manualSorting/manualFiltering` | `boolean` | Server-side mode | +| `persistenceKey` | `string` | localStorage key for presets/history | + +## GriddyColumn Key Fields +| Field | Purpose | +|-------|---------| +| `renderer` | Custom cell renderer (wired via columnMapper `def.cell`) | +| `rendererMeta` | Metadata for built-in renderers (colorMap, max, etc.) | +| `filterConfig` | `{ type, quickFilter?, enumOptions? }` | +| `editable` | `boolean \| (row) => boolean` | +| `editorConfig` | Editor-specific config (options, min, max, etc.) | +| `pinned` | `'left' \| 'right'` | +| `headerGroup` | Groups columns under parent header | ## Keyboard Bindings - Arrow Up/Down: move focus @@ -61,400 +153,43 @@ Griddy is a new data grid component in the Oranguru package (`@warkypublic/orang - Ctrl+A: select all (multi mode) - Ctrl+F: open search overlay - Ctrl+E / Enter: enter edit mode -- Ctrl+S: toggle selection mode - Escape: close search / cancel edit / clear selection -## Selection Modes -- `'none'`: no selection -- `'single'`: one row at a time (TanStack `enableMultiRowSelection: false`) -- `'multi'`: multiple rows, checkbox column, shift+click range, ctrl+a - ## Gotchas / Bugs Fixed 1. **Hooks violation in VirtualBody**: `useEffect` was after early `return null`. All hooks must run before any conditional return. -2. **sortingFn crash**: Setting `sortingFn: undefined` explicitly overrides TanStack's auto-detection. Fix: use `accessorKey` for string accessors (enables auto-detect), `sortingFn: 'auto'` for function accessors. -3. **createSyncStore typing**: Props synced at runtime via `$sync` but TypeScript only sees `GriddyStoreState`. All prop fields must be declared in the store state interface. -4. **useGriddyStore has no .getState()**: It's a context-based hook, not a vanilla zustand store. Use `useRef` to track latest state for imperative access in event handlers. -5. **Keyboard focus must scroll**; When keyboard focus changes off screen the screen must scroll with +2. **sortingFn crash**: Setting `sortingFn: undefined` explicitly overrides TanStack's auto-detection. Fix: use `accessorKey` for string accessors. +3. **createSyncStore typing**: Props synced at runtime via `$sync` but TypeScript only sees `GriddyStoreState`. All prop fields must be declared in store state interface. +4. **useGriddyStore has no .getState()**: Context-based hook, not vanilla zustand. Use `useRef` for imperative access. +5. **globalFilterFn: undefined breaks search**: Explicitly setting `globalFilterFn: undefined` disables global filtering. Use conditional spread: `...(advancedSearch?.enabled ? { globalFilterFn } : {})`. +6. **Custom renderers not rendering**: `columnMapper.ts` must wire `GriddyColumn.renderer` into TanStack's `ColumnDef.cell`. +7. **Error boundary retry timing**: `onRetry` parent setState must flush before error boundary clears. Use `setTimeout(0)` to defer `setState({ error: null })`. +8. **ColumnFilterButton must forwardRef**: Mantine's `Popover.Target` requires child to forward refs. +9. **Filter popover click propagation**: Clicking filter icon bubbles to header cell (triggers sort). Fix: explicit `onClick` with `stopPropagation` on ColumnFilterButton, not relying on Mantine Popover.Target auto-toggle. +10. **header.getAfter('right')**: Method exists on `Column`, not `Header`. Use `header.column.getAfter('right')`. ## UI Components -Uses **Mantine** components (not raw HTML): -- `Checkbox` from `@mantine/core` for row/header checkboxes -- `TextInput` from `@mantine/core` for search input -- `Select`, `MultiSelect`, `NumberInput`, `Radio`, `Popover`, `Menu`, `ActionIcon` for filtering (Phase 5) - -## Phase 5: Column Filtering UI (COMPLETE) - -### User Interaction Pattern -1. **Filter Status Indicator**: Gray filter icon in each column header (disabled, non-clickable) -2. **Right-Click Context Menu**: Shows on header right-click with options: - - `Sort` — Toggle column sorting - - `Reset Sorting` — Clear sort (shown only if column is sorted) - - `Reset Filter` — Clear filters (shown only if column has active filter) - - `Open Filters` — Opens filter popover -3. **Filter Popover**: Opened from "Open Filters" menu item - - Positioned below header - - Contains filter operator dropdown and value input(s) - - Apply and Clear buttons - - Filter type determined by `column.filterConfig.type` - -### Filter Types & Operators -| Type | Operators | Input Component | -|------|-----------|-----------------| -| `text` | contains, equals, startsWith, endsWith, notContains, isEmpty, isNotEmpty | TextInput with debounce | -| `number` | equals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between | NumberInput (single or dual for between) | -| `enum` | includes, excludes, isEmpty | MultiSelect with custom options | -| `boolean` | isTrue, isFalse, isEmpty | Radio.Group (True/False/All) | - -### API -```typescript -interface FilterConfig { - type: 'text' | 'number' | 'boolean' | 'enum' - operators?: FilterOperator[] // custom operators (optional) - enumOptions?: Array<{ label: string; value: any }> // for enum type -} - -// Usage in column definition: -{ - id: 'firstName', - accessor: 'firstName', - header: 'First Name', - filterable: true, - filterConfig: { type: 'text' } -} -``` - -### Key Implementation Details -- **Default filterFn**: Automatically assigned when `filterable: true` and no custom `filterFn` provided -- **Operator-based filtering**: Uses `createOperatorFilter()` that delegates to type-specific implementations -- **Debouncing**: Text inputs debounced 300ms to reduce re-renders -- **TanStack Integration**: Uses `column.setFilterValue()` and `column.getFilterValue()` -- **AND Logic**: Multiple column filters applied together (AND by default) -- **Controlled State**: Filter state managed by parent via `columnFilters` prop and `onColumnFiltersChange` callback - -### Files Structure (Phase 5) -``` -src/Griddy/features/filtering/ -├── types.ts # FilterOperator, FilterConfig, FilterValue -├── operators.ts # TEXT_OPERATORS, NUMBER_OPERATORS, etc. -├── filterFunctions.ts # TanStack FilterFn implementations -├── FilterInput.tsx # Text/number input with debounce -├── FilterSelect.tsx # Multi-select for enums -├── FilterBoolean.tsx # Radio group for booleans -├── ColumnFilterButton.tsx # Status indicator icon -├── ColumnFilterPopover.tsx # Popover UI container -├── ColumnFilterContextMenu.tsx # Right-click context menu -└── index.ts # Public exports -``` +Uses **Mantine** components: +- `Checkbox`, `TextInput`, `ActionIcon`, `Popover`, `Menu`, `Button`, `Group`, `Stack`, `Text` +- `Select`, `MultiSelect`, `NumberInput`, `Radio`, `SegmentedControl`, `ScrollArea` +- `@mantine/dates` for DatePickerInput +- `@tabler/icons-react` for icons ## Implementation Status -- [x] Phase 1: Core foundation + TanStack Table -- [x] Phase 2: Virtualization + keyboard navigation -- [x] Phase 3: Row selection (single + multi) -- [x] Phase 4: Search (Ctrl+F overlay) -- [x] Sorting (click header) -- [x] Phase 5: Column filtering UI (COMPLETE ✅) - - Right-click context menu on headers - - Sort, Reset Sort, Reset Filter, Open Filters menu items - - Text, number, enum, boolean filtering - - Filter popover UI with operators - - 6 Storybook stories with examples - - 8 Playwright E2E test cases -- [x] Phase 5.5: Date filtering (COMPLETE ✅) - - Date filter operators: is, isBefore, isAfter, isBetween - - DatePickerInput component integration - - Updated Storybook stories (WithDateFiltering, WithAllFilterTypes, LargeDatasetWithFiltering) - - Filter functions for date comparison -- [x] Server-side filtering/sorting (COMPLETE ✅) - - `manualSorting` and `manualFiltering` props - - `dataCount` prop for total row count - - TanStack Table integration with manual modes - - ServerSideFilteringSorting story demonstrating external data fetching -- [x] Phase 6: In-place editing (COMPLETE ✅) - - 5 built-in editors: TextEditor, NumericEditor, DateEditor, SelectEditor, CheckboxEditor - - EditableCell component with editor mounting - - Keyboard shortcuts: Ctrl+E, Enter (edit), Escape (cancel), Tab (commit and move) - - Double-click to edit - - onEditCommit callback for data mutations - - WithInlineEditing Storybook story -- [x] Phase 7: Pagination (COMPLETE ✅) - - PaginationControl component with Mantine UI - - Client-side pagination (TanStack Table getPaginationRowModel) - - Server-side pagination (onPageChange, onPageSizeChange callbacks) - - Page navigation controls and page size selector - - WithClientSidePagination and WithServerSidePagination stories -- [x] Phase 7: Pagination + remote data adapters (COMPLETE ✅) -- [x] Phase 8: Advanced Features (COMPLETE ✅) - - Column visibility menu with checkboxes - - CSV export function (exportToCsv) - - GridToolbar component - - Column pinning (left/right sticky) ✅ - - Header grouping (multi-level headers) ✅ - - Data grouping (hierarchical data) ✅ - - Column reordering (drag-and-drop) ✅ -- [x] Phase 9: Polish & Documentation (COMPLETE ✅) - - README.md with API reference - - EXAMPLES.md with TypeScript examples - - THEME.md with theming guide - - 24 Storybook stories (added 5 new) - - Full accessibility (ARIA) -- [x] Phase 7.5: Infinite Scroll (COMPLETE ✅) - - Threshold-based loading - - onLoadMore callback - - Loading indicator with spinner - - hasMore flag -- [ ] Phase 10: Future Enhancements (see plan.md) +- [x] Phase 1-9: Core, virtualization, selection, search, filtering, editing, pagination, advanced features, polish +- [x] Phase 7.5: Infinite scroll +- [x] Phase 8 completion: Column pinning, header grouping, data grouping, column reordering +- [x] Phase 10 (partial): Error boundary, loading states, custom renderers, quick filters, advanced search, filter presets, search history +- [ ] Phase 10 remaining: See plan.md -## Dependencies Added -- `@tanstack/react-table` ^8.21.3 (in both dependencies and peerDependencies) -- `@mantine/dates` ^8.3.14 (Phase 5.5) -- `dayjs` ^1.11.19 (peer dependency for @mantine/dates) +## E2E Tests +- **34 total Playwright tests** (8 filtering + 26 feature tests) +- All passing against Storybook at `http://localhost:6006` +- Run: `npx playwright test` (requires Storybook running) -## Build & Testing Status -- [x] `pnpm run typecheck` — ✅ PASS (0 errors) -- [x] `pnpm run lint` — ✅ PASS (0 errors in Phase 5 code) -- [x] `pnpm run build` — ✅ PASS -- [x] `pnpm run storybook` — ✅ 6 Phase 5 stories working -- [x] Playwright test suite created (8 E2E test cases) - -### Commands +## Commands ```bash -# Run all checks -pnpm run typecheck && pnpm run lint && pnpm run build - -# Start Storybook (see filtering stories) -pnpm run storybook - -# Install and run Playwright tests -pnpm exec playwright install -pnpm exec playwright test - -# Run specific test file -pnpm exec playwright test tests/e2e/filtering-context-menu.spec.ts - -# Debug mode -pnpm exec playwright test --debug - -# View HTML report -pnpm exec playwright show-report +pnpm run typecheck && pnpm run build # Build check +pnpm run storybook # Start Storybook +npx playwright test # Run E2E tests +npx playwright test tests/e2e/griddy-features.spec.ts # Phase 10 tests only ``` - -## Recent Completions - -### Phase 5.5 - Date Filtering -**Files Created**: -- `src/Griddy/features/filtering/FilterDate.tsx` — Date picker with single/range modes - -**Files Modified**: -- `types.ts`, `operators.ts`, `filterFunctions.ts`, `ColumnFilterPopover.tsx`, `index.ts` -- `Griddy.stories.tsx` — WithDateFiltering story - -### Server-Side Filtering/Sorting -**Files Modified**: -- `src/Griddy/core/types.ts` — Added `manualSorting`, `manualFiltering`, `dataCount` props -- `src/Griddy/core/GriddyStore.ts` — Added props to store state -- `src/Griddy/core/Griddy.tsx` — Integrated manual modes with TanStack Table -- `src/Griddy/Griddy.stories.tsx` — Added ServerSideFilteringSorting story - -### Phase 6 - In-Place Editing (COMPLETE ✅) -**Files Created** (7 editors + 1 component): -- `src/Griddy/editors/types.ts` — Editor type definitions -- `src/Griddy/editors/TextEditor.tsx` — Text input editor -- `src/Griddy/editors/NumericEditor.tsx` — Number input editor with min/max/step -- `src/Griddy/editors/DateEditor.tsx` — Date picker editor -- `src/Griddy/editors/SelectEditor.tsx` — Dropdown select editor -- `src/Griddy/editors/CheckboxEditor.tsx` — Checkbox editor -- `src/Griddy/editors/index.ts` — Editor exports -- `src/Griddy/rendering/EditableCell.tsx` — Cell editing wrapper - -**Files Modified** (4): -- `core/types.ts` — Added EditorConfig import, editorConfig to GriddyColumn -- `rendering/TableCell.tsx` — Integrated EditableCell, double-click handler, edit mode detection -- `features/keyboard/useKeyboardNavigation.ts` — Enter/Ctrl+E find first editable column -- `Griddy.stories.tsx` — Added WithInlineEditing story - -### Phase 7 - Pagination (COMPLETE ✅) -**Files Created** (2): -- `src/Griddy/features/pagination/PaginationControl.tsx` — Pagination UI with navigation + page size selector -- `src/Griddy/features/pagination/index.ts` — Pagination exports - -**Files Modified** (3): -- `core/Griddy.tsx` — Integrated PaginationControl, wired pagination callbacks -- `styles/griddy.module.css` — Added pagination styles -- `Griddy.stories.tsx` — Added WithClientSidePagination and WithServerSidePagination stories - -**Features**: -- Client-side pagination (10,000 rows in memory) -- Server-side pagination (callbacks trigger data fetch) -- Page navigation (first, prev, next, last) -- Page size selector (10, 25, 50, 100) -- Pagination state integration with TanStack Table - -### Phase 8 - Advanced Features (PARTIAL ✅) -**Files Created** (6): -- `src/Griddy/features/export/exportCsv.ts` — CSV export utility functions -- `src/Griddy/features/export/index.ts` — Export module exports -- `src/Griddy/features/columnVisibility/ColumnVisibilityMenu.tsx` — Column toggle menu -- `src/Griddy/features/columnVisibility/index.ts` — Column visibility exports -- `src/Griddy/features/toolbar/GridToolbar.tsx` — Toolbar with export + column visibility -- `src/Griddy/features/toolbar/index.ts` — Toolbar exports - -**Files Modified** (4): -- `core/types.ts` — Added showToolbar, exportFilename props -- `core/GriddyStore.ts` — Added toolbar props to store state -- `core/Griddy.tsx` — Integrated GridToolbar component -- `Griddy.stories.tsx` — Added WithToolbar story - -**Features Implemented**: -- Column visibility toggle (show/hide columns via menu) -- CSV export (filtered + visible columns) -- Toolbar component (optional, toggleable) -- TanStack Table columnVisibility state integration - -**Deferred**: Column pinning, header grouping, data grouping, column reordering - -### Phase 9 - Polish & Documentation (COMPLETE ✅) - -**Files Created** (3): -- `src/Griddy/README.md` — Comprehensive API documentation and quick start guide -- `src/Griddy/EXAMPLES.md` — TypeScript examples for all major features -- `src/Griddy/THEME.md` — Theming guide with CSS variables - -**Documentation Coverage**: -- ✅ API reference with all props documented -- ✅ Keyboard shortcuts table -- ✅ 10+ code examples (basic, editing, filtering, pagination, server-side) -- ✅ TypeScript integration patterns -- ✅ Theme system with dark mode, high contrast, brand themes -- ✅ Performance notes (10k+ rows, 60fps) -- ✅ Accessibility (ARIA, keyboard navigation) -- ✅ Browser support - -**Storybook Stories** (24 total): -- Basic, LargeDataset -- SingleSelection, MultiSelection, LargeMultiSelection -- WithSearch, KeyboardNavigation -- WithTextFiltering, WithNumberFiltering, WithEnumFiltering, WithBooleanFiltering, WithDateFiltering, WithAllFilterTypes, LargeDatasetWithFiltering -- ServerSideFilteringSorting -- WithInlineEditing -- WithClientSidePagination, WithServerSidePagination -- WithToolbar -- **NEW**: WithInfiniteScroll, WithColumnPinning, WithHeaderGrouping, WithDataGrouping, WithColumnReordering - -**Implementation Complete**: All 9 phases + Phase 7.5 (Infinite Scroll) + Phase 8 remaining features finished! - -## Resume Instructions (When Returning) - -1. **Run full build check**: - ```bash - pnpm run typecheck && pnpm run lint && pnpm run build - ``` - -2. **Start Storybook to verify Phase 5**: - ```bash - pnpm run storybook - # Open http://localhost:6006 - # Check stories: WithTextFiltering, WithNumberFiltering, WithEnumFiltering, WithBooleanFiltering, WithAllFilterTypes, LargeDatasetWithFiltering - ``` - -3. **Run Playwright tests** (requires Storybook running in another terminal): - ```bash - pnpm exec playwright test - ``` - -4. **Current Status**: All phases 1-9 complete, plus infinite scroll and all Phase 8 advanced features - ---- - -## Latest Completions (Phase 7.5 & Phase 8 Completion) - -### Phase 7.5 - Infinite Scroll (COMPLETE ✅) -**Files Modified**: -- `src/Griddy/core/types.ts` — Added InfiniteScrollConfig interface -- `src/Griddy/core/GriddyStore.ts` — Added infiniteScroll to store state -- `src/Griddy/rendering/VirtualBody.tsx` — Scroll detection logic with threshold -- `src/Griddy/styles/griddy.module.css` — Loading indicator styles -- `src/Griddy/Griddy.stories.tsx` — Added WithInfiniteScroll story - -**Features**: -- Threshold-based loading (default 10 rows from end) -- onLoadMore callback with async support -- Loading indicator with spinner animation -- hasMore flag to stop loading -- isLoading state for loading UI - -### Phase 8 - Column Pinning (COMPLETE ✅) -**Files Modified**: -- `src/Griddy/core/types.ts` — Added columnPinning and onColumnPinningChange props -- `src/Griddy/core/GriddyStore.ts` — Added column pinning to store -- `src/Griddy/core/Griddy.tsx` — Integrated TanStack Table columnPinning state -- `src/Griddy/core/columnMapper.ts` — Enabled enablePinning on columns -- `src/Griddy/rendering/TableHeader.tsx` — Sticky positioning for pinned headers -- `src/Griddy/rendering/TableCell.tsx` — Sticky positioning for pinned cells -- `src/Griddy/styles/griddy.module.css` — Pinned column styles with shadows -- `src/Griddy/Griddy.stories.tsx` — Added WithColumnPinning story - -**Features**: -- Left and right column pinning -- Sticky positioning with z-index layering -- Visual shadows on pinned columns -- Auto-builds initial state from column `pinned` property -- Controlled via columnPinning prop - -### Phase 8 - Header Grouping (COMPLETE ✅) -**Files Modified**: -- `src/Griddy/core/columnMapper.ts` — Group columns by headerGroup property -- `src/Griddy/Griddy.stories.tsx` — Added WithHeaderGrouping story - -**Features**: -- Multi-level column headers -- Groups defined by `headerGroup` property -- Automatic parent header creation -- TanStack Table getHeaderGroups() integration - -### Phase 8 - Data Grouping (COMPLETE ✅) -**Files Modified**: -- `src/Griddy/core/types.ts` — Added groupable and aggregationFn to GriddyColumn -- `src/Griddy/core/Griddy.tsx` — Integrated getGroupedRowModel() and grouping state -- `src/Griddy/core/columnMapper.ts` — Enable grouping for groupable columns -- `src/Griddy/rendering/TableRow.tsx` — Detect grouped rows -- `src/Griddy/rendering/TableCell.tsx` — Expand/collapse UI and aggregated rendering -- `src/Griddy/styles/griddy.module.css` — Grouped row styling -- `src/Griddy/Griddy.stories.tsx` — Added WithDataGrouping story - -**Features**: -- Group by column values -- Expand/collapse groups with arrow indicators -- Aggregation functions (sum, mean, count, min, max, etc.) -- Group row count display -- Nested grouping support - -### Phase 8 - Column Reordering (COMPLETE ✅) -**Files Modified**: -- `src/Griddy/rendering/TableHeader.tsx` — HTML5 drag-and-drop handlers -- `src/Griddy/styles/griddy.module.css` — Drag cursor styles -- `src/Griddy/Griddy.stories.tsx` — Added WithColumnReordering story - -**Features**: -- Drag-and-drop column headers -- Visual feedback (opacity, cursor) -- TanStack Table columnOrder integration -- Prevents reordering selection and pinned columns -- Smooth reordering with native drag events - ---- - -## Phase 10: Future Enhancements - -See `plan.md` for comprehensive list of 50+ planned features organized by category: -- Data & State Management (6 items) -- Advanced Data Features (5 items) -- Editing Enhancements (4 items) -- Filtering & Search (4 items) -- Export & Import (4 items) -- UI/UX Improvements (6 items) -- Performance & Optimization (5 items) -- Accessibility & Testing (5 items) -- Developer Experience (6 items) -- Advanced Features (11 items) - -**High Priority Next**: Column layout persistence, validation system, loading states UI, tab-to-next-editable-cell diff --git a/src/Griddy/Griddy.stories.tsx b/src/Griddy/Griddy.stories.tsx index d202333..a0de207 100644 --- a/src/Griddy/Griddy.stories.tsx +++ b/src/Griddy/Griddy.stories.tsx @@ -2,11 +2,14 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import type { ColumnFiltersState, RowSelectionState, SortingState } from '@tanstack/react-table' import { Box } from '@mantine/core' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { GriddyColumn, GriddyProps } from './core/types' import { Griddy } from './core/Griddy' +import { BadgeRenderer } from './features/renderers/BadgeRenderer' +import { ProgressBarRenderer } from './features/renderers/ProgressBarRenderer' +import { SparklineRenderer } from './features/renderers/SparklineRenderer' // ─── Sample Data ───────────────────────────────────────────────────────────── @@ -1194,3 +1197,309 @@ export const WithColumnReordering: Story = { ) }, } + +/** Error boundary - catches render errors and shows fallback UI */ +export const WithErrorBoundary: Story = { + render: () => { + const [shouldError, setShouldError] = useState(false) + // Use a ref so the ErrorColumn always reads the latest value synchronously + const shouldErrorRef = useRef(false) + shouldErrorRef.current = shouldError + + const ErrorColumn = () => { + if (shouldErrorRef.current) throw new Error('Intentional render error for testing') + return OK + } + + const errorColumns: GriddyColumn[] = [ + { accessor: 'id', header: 'ID', id: 'id', width: 60 }, + { accessor: 'firstName', header: 'First Name', id: 'firstName', width: 120 }, + { + accessor: () => null, + header: 'Status', + id: 'status', + renderer: () => , + width: 100, + }, + ] + + return ( + + + Error Boundary: Click the button below to trigger an error. The error boundary catches it and shows a retry button. +
+ +
+
+ + columns={errorColumns} + data={smallData} + getRowId={(row) => String(row.id)} + height={400} + onError={(err) => console.log('Grid error:', err.message)} + onRetry={() => { shouldErrorRef.current = false; setShouldError(false) }} + /> +
+ ) + }, +} + +/** Loading states - skeleton rows and overlay spinner */ +export const WithLoadingStates: Story = { + render: () => { + const [data, setData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const timer = setTimeout(() => { + setData(smallData) + setIsLoading(false) + }, 3000) + return () => clearTimeout(timer) + }, []) + + const handleReload = () => { + setIsLoading(true) + setData([]) + setTimeout(() => { + setData(smallData) + setIsLoading(false) + }, 2000) + } + + return ( + + + Loading States: Shows skeleton when loading with no data, overlay when loading with existing data. +
+ +
+
+ + columns={columns} + data={data} + getRowId={(row) => String(row.id)} + height={500} + isLoading={isLoading} + /> +
+ ) + }, +} + +/** Custom cell renderers - progress bars, badges, sparklines */ +export const WithCustomRenderers: Story = { + render: () => { + interface RendererData { + department: string + id: number + name: string + performance: number + trend: number[] + } + + const rendererData: RendererData[] = Array.from({ length: 15 }, (_, i) => ({ + department: departments[i % departments.length], + id: i + 1, + name: firstNames[i % firstNames.length], + performance: 20 + Math.round(Math.random() * 80), + trend: Array.from({ length: 7 }, () => Math.round(Math.random() * 100)), + })) + + const rendererColumns: GriddyColumn[] = [ + { accessor: 'id', header: 'ID', id: 'id', width: 60 }, + { accessor: 'name', header: 'Name', id: 'name', width: 120 }, + { + accessor: 'department', + header: 'Department', + id: 'department', + renderer: BadgeRenderer, + rendererMeta: { + colorMap: { + Design: '#ae3ec9', + Engineering: '#228be6', + Finance: '#40c057', + HR: '#fab005', + Marketing: '#fd7e14', + Sales: '#fa5252', + }, + defaultColor: '#868e96', + }, + width: 140, + }, + { + accessor: 'performance', + header: 'Performance', + id: 'performance', + renderer: ProgressBarRenderer, + rendererMeta: { max: 100, showLabel: true }, + width: 160, + }, + { + accessor: 'trend', + header: 'Trend', + id: 'trend', + renderer: SparklineRenderer, + rendererMeta: { color: '#228be6', height: 24, width: 80 }, + width: 120, + }, + ] + + return ( + + + Custom Renderers: Badge (department), Progress Bar (performance), Sparkline (trend). + + + columns={rendererColumns} + data={rendererData} + getRowId={(row) => String(row.id)} + height={500} + /> + + ) + }, +} + +/** Quick filters - checkbox list of unique values in filter popover */ +export const WithQuickFilters: Story = { + render: () => { + const [filters, setFilters] = useState([]) + + const quickFilterColumns: GriddyColumn[] = [ + { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, + { accessor: 'firstName', header: 'First Name', id: 'firstName', sortable: true, width: 120 }, + { accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 }, + { + accessor: 'department', + filterable: true, + filterConfig: { quickFilter: true, type: 'text' }, + header: 'Department', + id: 'department', + sortable: true, + width: 150, + }, + { accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 }, + ] + + return ( + + + Quick Filters: Click the filter icon on "Department" to see a checkbox list of unique values. + + + columnFilters={filters} + columns={quickFilterColumns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + onColumnFiltersChange={setFilters} + /> + + ) + }, +} + +/** Advanced search panel - multi-condition search with boolean operators */ +export const WithAdvancedSearch: Story = { + render: () => { + return ( + + + Advanced Search: Use the search panel to add multiple conditions with AND/OR/NOT operators. + + + advancedSearch={{ enabled: true }} + columns={columns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + /> + + ) + }, +} + +/** Filter presets - save and load filter configurations */ +export const WithFilterPresets: Story = { + render: () => { + const [filters, setFilters] = useState([]) + + const filterColumns: GriddyColumn[] = [ + { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, + { + accessor: 'firstName', + filterable: true, + filterConfig: { type: 'text' }, + header: 'First Name', + id: 'firstName', + sortable: true, + width: 120, + }, + { accessor: 'lastName', header: 'Last Name', id: 'lastName', sortable: true, width: 120 }, + { + accessor: 'department', + filterable: true, + filterConfig: { + enumOptions: departments.map((d) => ({ label: d, value: d })), + type: 'enum', + }, + header: 'Department', + id: 'department', + sortable: true, + width: 130, + }, + { + accessor: 'age', + filterable: true, + filterConfig: { type: 'number' }, + header: 'Age', + id: 'age', + sortable: true, + width: 70, + }, + ] + + return ( + + + Filter Presets: Apply filters, then click the bookmark icon in the toolbar to save/load presets. Persists to localStorage. + + + columnFilters={filters} + columns={filterColumns} + data={smallData} + filterPresets + getRowId={(row) => String(row.id)} + height={500} + onColumnFiltersChange={setFilters} + persistenceKey="storybook-presets" + showToolbar + /> + + ) + }, +} + +/** Search history - recent searches persisted and selectable */ +export const WithSearchHistory: Story = { + render: () => { + return ( + + + Search History: Press Ctrl+F to search. Previous searches are saved and shown when you focus the search input. + + + columns={columns} + data={smallData} + getRowId={(row) => String(row.id)} + height={500} + persistenceKey="storybook-search-history" + search={{ enabled: true, placeholder: 'Search (history enabled)...' }} + /> + + ) + }, +} diff --git a/src/Griddy/SUMMARY.md b/src/Griddy/SUMMARY.md deleted file mode 100644 index 57a328e..0000000 --- a/src/Griddy/SUMMARY.md +++ /dev/null @@ -1,286 +0,0 @@ -# Griddy - Implementation Summary - -## Project Completion ✅ - -**Griddy** is a feature-complete, production-ready data grid component built on TanStack Table and TanStack Virtual. - -## Implementation Status: 11/11 Phases Complete (100%) - -### ✅ Phase 1: Core Foundation + TanStack Table -- TanStack Table integration with column mapping -- Basic rendering with flexRender -- GriddyProvider and GriddyStore (createSyncStore pattern) -- Type-safe column definitions - -### ✅ Phase 2: Virtualization + Keyboard Navigation -- TanStack Virtual integration (10,000+ row performance) -- Full keyboard navigation (Arrow keys, Page Up/Down, Home/End) -- Focused row indicator with auto-scroll -- 60 fps scrolling performance - -### ✅ Phase 3: Row Selection -- Single and multi-selection modes -- Checkbox column (auto-prepended) -- Keyboard selection (Space, Shift+Arrow, Ctrl+A) -- Click and Shift+Click range selection - -### ✅ Phase 4: Search -- Ctrl+F search overlay -- Global filter integration -- Debounced input (300ms) -- Search highlighting (prepared for future implementation) - -### ✅ Phase 5: Sorting & Filtering -- Single and multi-column sorting -- Sort indicators in headers -- 5 filter types: text, number, enum, boolean, date -- 20+ filter operators -- Right-click context menu -- Filter popover UI with Apply/Clear buttons -- Server-side sort/filter support (manualSorting, manualFiltering) - -### ✅ Phase 6: In-Place Editing -- 5 built-in editors: Text, Number, Date, Select, Checkbox -- EditableCell component with editor mounting -- Keyboard editing (Ctrl+E, Enter, Escape, Tab) -- Double-click to edit -- onEditCommit callback - -### ✅ Phase 7: Pagination -- Client-side pagination (10,000+ rows in memory) -- Server-side pagination (callbacks) -- PaginationControl UI (first, prev, next, last navigation) -- Page size selector (10, 25, 50, 100) -- TanStack Table pagination integration - -### ✅ Phase 7.5: Infinite Scroll -- Threshold-based loading (trigger near end of data) -- onLoadMore callback with async support -- Loading indicator with spinner animation -- hasMore flag to stop loading when complete -- Client-side progressive data loading - -### ✅ Phase 8: Advanced Features (COMPLETE) -- Column visibility toggle menu -- CSV export (exportToCsv, getTableCsv) -- GridToolbar component -- **Column pinning** (left/right sticky columns) -- **Header grouping** (multi-level column headers) -- **Data grouping** (hierarchical data with expand/collapse) -- **Column reordering** (drag-and-drop) - -### ✅ Phase 9: Polish & Documentation -- README.md with API reference -- EXAMPLES.md with 10+ TypeScript examples -- THEME.md with theming guide -- 24 Storybook stories -- Full ARIA compliance - -### 📋 Phase 10: Future Enhancements (Planned) -- 50+ features organized into 10 categories -- High priority: Column layout persistence, validation system, loading states -- See plan.md for complete roadmap - -## Features Delivered - -### Core Features -- ⌨️ **Keyboard-first** — Full navigation with 15+ shortcuts -- 🚀 **Virtual scrolling** — Handle 10,000+ rows at 60fps -- 📝 **Inline editing** — 5 editor types with keyboard support -- 🔍 **Search** — Ctrl+F overlay with global filter -- 🎯 **Selection** — Single/multi modes with keyboard -- 📊 **Sorting** — Single and multi-column -- 🔎 **Filtering** — 5 types, 20+ operators -- 📄 **Pagination** — Client-side and server-side -- 💾 **CSV Export** — Export filtered data -- 👁️ **Column visibility** — Show/hide columns -- ♾️ **Infinite scroll** — Progressive data loading -- 📌 **Column pinning** — Sticky left/right columns -- 📑 **Header grouping** — Multi-level column headers -- 🗂️ **Data grouping** — Hierarchical data with expand/collapse -- ↔️ **Column reordering** — Drag-and-drop columns - -### Technical Highlights -- **TypeScript** — Fully typed with generics -- **Performance** — 60fps with 10k+ rows -- **Accessibility** — WAI-ARIA compliant -- **Theming** — CSS variables system -- **Bundle size** — ~45KB gzipped -- **Zero runtime** — No data mutations, callback-driven - -## File Statistics - -### Files Created: 58 -- **Core**: 8 files (Griddy.tsx, types.ts, GriddyStore.ts, etc.) -- **Rendering**: 5 files (VirtualBody, TableHeader, TableRow, TableCell, EditableCell) -- **Editors**: 7 files (5 editors + types + index) -- **Features**: 20+ files (filtering, search, keyboard, pagination, toolbar, export, etc.) -- **Documentation**: 5 files (README, EXAMPLES, THEME, plan.md, CONTEXT.md) -- **Tests**: 1 E2E test suite (8 test cases) -- **Stories**: 15+ Storybook stories - -### Lines of Code: ~5,000+ -- TypeScript/TSX: ~4,500 -- CSS: ~300 -- Markdown: ~1,200 - -## Dependencies - -### Required Peer Dependencies -- `react` >= 19.0.0 -- `react-dom` >= 19.0.0 -- `@tanstack/react-table` >= 8.0.0 -- `@tanstack/react-virtual` >= 3.13.0 -- `@mantine/core` >= 8.0.0 -- `@mantine/dates` >= 8.0.0 -- `@mantine/hooks` >= 8.0.0 -- `dayjs` >= 1.11.0 - -### Internal Dependencies -- `@warkypublic/zustandsyncstore` — Store synchronization - -## Browser Support -- ✅ Chrome/Edge (latest 2 versions) -- ✅ Firefox (latest 2 versions) -- ✅ Safari (latest 2 versions) - -## Performance Benchmarks -- **10,000 rows**: 60fps scrolling, <100ms initial render -- **Filtering**: <50ms for 10k rows -- **Sorting**: <100ms for 10k rows -- **Bundle size**: ~45KB gzipped (excluding peers) - -## Storybook Stories (24) - -1. **Basic** — Simple table with sorting -2. **LargeDataset** — 10,000 rows virtualized -3. **SingleSelection** — Single row selection -4. **MultiSelection** — Multi-row selection with keyboard -5. **LargeMultiSelection** — 10k rows with selection -6. **WithSearch** — Ctrl+F search overlay -7. **KeyboardNavigation** — Keyboard shortcuts demo -8. **WithTextFiltering** — Text filters -9. **WithNumberFiltering** — Number filters -10. **WithEnumFiltering** — Enum multi-select filters -11. **WithBooleanFiltering** — Boolean radio filters -12. **WithDateFiltering** — Date picker filters -13. **WithAllFilterTypes** — All filter types combined -14. **LargeDatasetWithFiltering** — 10k rows with filters -15. **ServerSideFilteringSorting** — External data fetching -16. **WithInlineEditing** — Editable cells demo -17. **WithClientSidePagination** — Memory pagination -18. **WithServerSidePagination** — External pagination -19. **WithToolbar** — Column visibility + CSV export -20. **WithInfiniteScroll** — Progressive data loading -21. **WithColumnPinning** — Left/right sticky columns -22. **WithHeaderGrouping** — Multi-level headers -23. **WithDataGrouping** — Hierarchical grouping with expand/collapse -24. **WithColumnReordering** — Drag-and-drop column reordering - -## API Surface - -### Main Component -- `` — Main grid component with 25+ props - -### Hooks -- `useGriddyStore` — Access store from context - -### Utilities -- `exportToCsv()` — Export table to CSV -- `getTableCsv()` — Get CSV string - -### Components -- `GridToolbar` — Optional toolbar -- `PaginationControl` — Pagination UI -- `ColumnVisibilityMenu` — Column toggle -- `SearchOverlay` — Search UI -- `EditableCell` — Cell editor wrapper -- 5 Editor components - -### Types -- `GriddyColumn` — Column definition -- `GriddyProps` — Main props -- `GriddyRef` — Imperative ref -- `SelectionConfig` — Selection config -- `SearchConfig` — Search config -- `PaginationConfig` — Pagination config -- `FilterConfig` — Filter config -- `EditorConfig` — Editor config - -## Accessibility (ARIA) - -### Roles -- ✅ `role="grid"` on container -- ✅ `role="row"` on rows -- ✅ `role="gridcell"` on cells -- ✅ `role="columnheader"` on headers - -### Attributes -- ✅ `aria-selected` on selected rows -- ✅ `aria-activedescendant` for focused row -- ✅ `aria-sort` on sorted columns -- ✅ `aria-label` on interactive elements -- ✅ `aria-rowcount` for total rows - -### Keyboard -- ✅ Full keyboard navigation -- ✅ Focus indicators -- ✅ Screen reader compatible - -## Future Enhancements (Deferred) - -### Phase 8 Remaining -- Column pinning (left/right sticky columns) -- Header grouping (multi-level headers) -- Data grouping (hierarchical data) -- Column reordering (drag-and-drop) - -### Phase 6 Deferred -- Validation system for editors -- Tab-to-next-editable-cell navigation -- Undo/redo functionality - -### General -- Column virtualization (horizontal scrolling) -- Tree/hierarchical data -- Copy/paste support -- Master-detail expandable rows -- Cell-level focus (left/right navigation) - -## Lessons Learned - -### Architecture Wins -1. **TanStack Table** — Excellent headless table library, handles all logic -2. **TanStack Virtual** — Perfect for large datasets -3. **Zustand + createSyncStore** — Clean state management pattern -4. **Column mapper pattern** — Simplifies user-facing API -5. **Callback-driven** — No mutations, pure data flow - -### Development Patterns -1. **Phase-by-phase** — Incremental development kept scope manageable -2. **Storybook-driven** — Visual testing during development -3. **TypeScript generics** — Type safety with flexibility -4. **CSS variables** — Easy theming without JS -5. **Modular features** — Each feature in its own directory - -## Conclusion - -**Griddy is production-ready** with: -- ✅ All core features implemented -- ✅ Comprehensive documentation -- ✅ 15+ working Storybook stories -- ✅ Full TypeScript support -- ✅ Accessibility compliance -- ✅ Performance validated (10k+ rows) - -**Ready for:** -- Production use in Oranguru package -- External users via NPM -- Further feature additions -- Community contributions - -**Next Steps:** -- Publish to NPM as `@warkypublic/oranguru` -- Add to package README -- Monitor for bug reports -- Consider deferred features based on user feedback diff --git a/src/Griddy/core/Griddy.tsx b/src/Griddy/core/Griddy.tsx index be51616..9ba3fa3 100644 --- a/src/Griddy/core/Griddy.tsx +++ b/src/Griddy/core/Griddy.tsx @@ -20,7 +20,10 @@ import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, u import type { GriddyProps, GriddyRef } from './types' +import { AdvancedSearchPanel, advancedSearchGlobalFilterFn } from '../features/advancedSearch' +import { GriddyErrorBoundary } from '../features/errorBoundary' import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation' +import { GriddyLoadingOverlay, GriddyLoadingSkeleton } from '../features/loading' import { PaginationControl } from '../features/pagination' import { SearchOverlay } from '../features/search/SearchOverlay' import { GridToolbar } from '../features/toolbar' @@ -37,7 +40,9 @@ import { GriddyProvider, useGriddyStore } from './GriddyStore' function _Griddy(props: GriddyProps, ref: Ref>) { return ( - + + + {props.children} ) @@ -70,6 +75,10 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { const className = useGriddyStore((s) => s.className) const showToolbar = useGriddyStore((s) => s.showToolbar) const exportFilename = useGriddyStore((s) => s.exportFilename) + const isLoading = useGriddyStore((s) => s.isLoading) + const filterPresets = useGriddyStore((s) => s.filterPresets) + const advancedSearch = useGriddyStore((s) => s.advancedSearch) + const persistenceKey = useGriddyStore((s) => s.persistenceKey) const manualSorting = useGriddyStore((s) => s.manualSorting) const manualFiltering = useGriddyStore((s) => s.manualFiltering) const dataCount = useGriddyStore((s) => s.dataCount) @@ -164,6 +173,7 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { enableRowSelection, enableSorting: true, getCoreRowModel: getCoreRowModel(), + ...(advancedSearch?.enabled ? { globalFilterFn: advancedSearchGlobalFilterFn as any } : {}), getExpandedRowModel: getExpandedRowModel(), getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(), getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined, @@ -289,9 +299,12 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { role="grid" > {search?.enabled && } + {advancedSearch?.enabled && } {showToolbar && ( )} @@ -302,7 +315,14 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { tabIndex={enableKeyboard ? 0 : undefined} > - + {isLoading && (!data || data.length === 0) ? ( + + ) : ( + <> + + {isLoading && } + + )} {paginationConfig?.enabled && ( _virtualizer: null | Virtualizer + advancedSearch?: AdvancedSearchConfig className?: string columnFilters?: ColumnFiltersState columns?: GriddyColumn[] columnPinning?: ColumnPinningState onColumnPinningChange?: (pinning: ColumnPinningState) => void data?: any[] + // ─── Error State ─── + error: Error | null exportFilename?: string dataAdapter?: DataAdapter dataCount?: number + filterPresets?: boolean getRowId?: (row: any, index: number) => string grouping?: GroupingConfig height?: number | string infiniteScroll?: InfiniteScrollConfig + isLoading?: boolean keyboardNavigation?: boolean manualFiltering?: boolean manualSorting?: boolean onColumnFiltersChange?: (filters: ColumnFiltersState) => void onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise | void + onError?: (error: Error) => void onRowSelectionChange?: (selection: RowSelectionState) => void onSortingChange?: (sorting: SortingState) => void overscan?: number @@ -46,6 +52,7 @@ export interface GriddyStoreState extends GriddyUIState { search?: SearchConfig selection?: SelectionConfig + setError: (error: Error | null) => void showToolbar?: boolean setScrollRef: (el: HTMLDivElement | null) => void // ─── Internal ref setters ─── @@ -69,6 +76,7 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync _table: null, _virtualizer: null, + error: null, focusedColumnId: null, // ─── Focus State ─── focusedRowIndex: null, @@ -92,6 +100,7 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync }, moveFocusToStart: () => set({ focusedRowIndex: 0 }), setEditing: (editing) => set({ isEditing: editing }), + setError: (error) => set({ error }), setFocusedColumn: (id) => set({ focusedColumnId: id }), // ─── Actions ─── setFocusedRow: (index) => set({ focusedRowIndex: index }), diff --git a/src/Griddy/core/columnMapper.ts b/src/Griddy/core/columnMapper.ts index 748799a..b25d892 100644 --- a/src/Griddy/core/columnMapper.ts +++ b/src/Griddy/core/columnMapper.ts @@ -52,6 +52,18 @@ function mapSingleColumn(col: GriddyColumn): ColumnDef { } else if (col.filterable) { def.filterFn = createOperatorFilter() } + + if (col.renderer) { + const renderer = col.renderer + def.cell = (info) => renderer({ + column: col, + columnIndex: info.cell.column.getIndex(), + row: info.row.original, + rowIndex: info.row.index, + value: info.getValue(), + }) + } + return def } diff --git a/src/Griddy/core/types.ts b/src/Griddy/core/types.ts index 2e32b55..34312f9 100644 --- a/src/Griddy/core/types.ts +++ b/src/Griddy/core/types.ts @@ -61,6 +61,8 @@ export interface GriddyColumn { minWidth?: number pinned?: 'left' | 'right' renderer?: CellRenderer + /** Metadata passed to custom renderers (ProgressBar, Badge, Image, Sparkline) */ + rendererMeta?: Record searchable?: boolean sortable?: boolean sortFn?: SortingFn @@ -79,7 +81,13 @@ export interface GriddyDataSource { // ─── Pagination ────────────────────────────────────────────────────────────── +export interface AdvancedSearchConfig { + enabled: boolean +} + export interface GriddyProps { + // ─── Advanced Search ─── + advancedSearch?: AdvancedSearchConfig // ─── Children (adapters, etc.) ─── children?: ReactNode // ─── Styling ─── @@ -89,6 +97,9 @@ export interface GriddyProps { showToolbar?: boolean /** Export filename. Default: 'export.csv' */ exportFilename?: string + // ─── Filter Presets ─── + /** Enable filter presets save/load in toolbar. Default: false */ + filterPresets?: boolean // ─── Filtering ─── /** Controlled column filters state */ columnFilters?: ColumnFiltersState @@ -112,6 +123,9 @@ export interface GriddyProps { /** Container height */ height?: number | string + // ─── Loading ─── + /** Show loading skeleton/overlay. Default: false */ + isLoading?: boolean // ─── Infinite Scroll ─── /** Infinite scroll configuration */ infiniteScroll?: InfiniteScrollConfig @@ -126,6 +140,11 @@ export interface GriddyProps { onColumnFiltersChange?: (filters: ColumnFiltersState) => void // ─── Editing ─── onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise | void + // ─── Error Handling ─── + /** Callback when a render error is caught by the error boundary */ + onError?: (error: Error) => void + /** Callback before the error boundary retries rendering */ + onRetry?: () => void /** Selection change callback */ onRowSelectionChange?: (selection: RowSelectionState) => void diff --git a/src/Griddy/features/advancedSearch/AdvancedSearchPanel.tsx b/src/Griddy/features/advancedSearch/AdvancedSearchPanel.tsx new file mode 100644 index 0000000..278db9c --- /dev/null +++ b/src/Griddy/features/advancedSearch/AdvancedSearchPanel.tsx @@ -0,0 +1,134 @@ +import type { Table } from '@tanstack/react-table' + +import { Button, Group, SegmentedControl, Stack, Text } from '@mantine/core' +import { IconPlus } from '@tabler/icons-react' +import { useCallback, useMemo, useState } from 'react' + +import { useGriddyStore } from '../../core/GriddyStore' +import styles from '../../styles/griddy.module.css' +import { advancedFilter } from './advancedFilterFn' +import { SearchConditionRow } from './SearchConditionRow' +import type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types' + +let nextId = 1 + +function createCondition(): SearchCondition { + return { columnId: '', id: String(nextId++), operator: 'contains', value: '' } +} + +interface AdvancedSearchPanelProps { + table: Table +} + +export function AdvancedSearchPanel({ table }: AdvancedSearchPanelProps) { + const userColumns = useGriddyStore((s) => s.columns) ?? [] + const [searchState, setSearchState] = useState({ + booleanOperator: 'AND', + conditions: [createCondition()], + }) + + const columnOptions = useMemo( + () => userColumns + .filter((c) => c.searchable !== false) + .map((c) => ({ label: String(c.header), value: c.id })), + [userColumns], + ) + + const handleConditionChange = useCallback((index: number, condition: SearchCondition) => { + setSearchState((prev) => { + const conditions = [...prev.conditions] + conditions[index] = condition + return { ...prev, conditions } + }) + }, []) + + const handleRemove = useCallback((index: number) => { + setSearchState((prev) => ({ + ...prev, + conditions: prev.conditions.filter((_, i) => i !== index), + })) + }, []) + + const handleAdd = useCallback(() => { + setSearchState((prev) => ({ + ...prev, + conditions: [...prev.conditions, createCondition()], + })) + }, []) + + const handleApply = useCallback(() => { + const activeConditions = searchState.conditions.filter((c) => c.columnId && c.value) + if (activeConditions.length === 0) { + table.setGlobalFilter(undefined) + return + } + // Use globalFilter with a custom function key + table.setGlobalFilter({ _advancedSearch: searchState }) + }, [searchState, table]) + + const handleClear = useCallback(() => { + setSearchState({ booleanOperator: 'AND', conditions: [createCondition()] }) + table.setGlobalFilter(undefined) + }, [table]) + + return ( +
+ + + Advanced Search + setSearchState((prev) => ({ ...prev, booleanOperator: val as BooleanOperator }))} + size="xs" + value={searchState.booleanOperator} + /> + + + {searchState.conditions.map((condition, index) => ( + handleConditionChange(index, c)} + onRemove={() => handleRemove(index)} + /> + ))} + + + + + + + + + +
+ ) +} + +// Custom global filter function that handles advanced search +export function advancedSearchGlobalFilterFn(row: any, _columnId: string, filterValue: any): boolean { + if (filterValue?._advancedSearch) { + return advancedFilter(row, filterValue._advancedSearch) + } + // Fallback to default string search + if (typeof filterValue === 'string') { + const search = filterValue.toLowerCase() + return row.getAllCells().some((cell: any) => { + const val = cell.getValue() + return String(val ?? '').toLowerCase().includes(search) + }) + } + return true +} diff --git a/src/Griddy/features/advancedSearch/SearchConditionRow.tsx b/src/Griddy/features/advancedSearch/SearchConditionRow.tsx new file mode 100644 index 0000000..c12e779 --- /dev/null +++ b/src/Griddy/features/advancedSearch/SearchConditionRow.tsx @@ -0,0 +1,58 @@ +import { ActionIcon, Group, Select, TextInput } from '@mantine/core' +import { IconTrash } from '@tabler/icons-react' + +import type { SearchCondition } from './types' + +interface SearchConditionRowProps { + columns: { label: string; value: string }[] + condition: SearchCondition + onChange: (condition: SearchCondition) => void + onRemove: () => void +} + +const OPERATORS = [ + { label: 'Contains', value: 'contains' }, + { label: 'Equals', value: 'equals' }, + { label: 'Starts with', value: 'startsWith' }, + { label: 'Ends with', value: 'endsWith' }, + { label: 'Not contains', value: 'notContains' }, + { label: 'Greater than', value: 'greaterThan' }, + { label: 'Less than', value: 'lessThan' }, +] + +export function SearchConditionRow({ columns, condition, onChange, onRemove }: SearchConditionRowProps) { + return ( + + onChange({ ...condition, operator: (val as SearchCondition['operator']) ?? 'contains' })} + size="xs" + value={condition.operator} + w={130} + /> + onChange({ ...condition, value: e.currentTarget.value })} + placeholder="Value" + size="xs" + style={{ flex: 1 }} + value={condition.value} + /> + + + + + ) +} diff --git a/src/Griddy/features/advancedSearch/advancedFilterFn.ts b/src/Griddy/features/advancedSearch/advancedFilterFn.ts new file mode 100644 index 0000000..b88a77e --- /dev/null +++ b/src/Griddy/features/advancedSearch/advancedFilterFn.ts @@ -0,0 +1,45 @@ +import type { Row } from '@tanstack/react-table' + +import type { AdvancedSearchState, SearchCondition } from './types' + +function matchCondition(row: Row, condition: SearchCondition): boolean { + const cellValue = String(row.getValue(condition.columnId) ?? '').toLowerCase() + const searchValue = condition.value.toLowerCase() + + switch (condition.operator) { + case 'contains': + return cellValue.includes(searchValue) + case 'equals': + return cellValue === searchValue + case 'startsWith': + return cellValue.startsWith(searchValue) + case 'endsWith': + return cellValue.endsWith(searchValue) + case 'notContains': + return !cellValue.includes(searchValue) + case 'greaterThan': + return Number(row.getValue(condition.columnId)) > Number(condition.value) + case 'lessThan': + return Number(row.getValue(condition.columnId)) < Number(condition.value) + default: + return true + } +} + +export function advancedFilter(row: Row, searchState: AdvancedSearchState): boolean { + const { booleanOperator, conditions } = searchState + const active = conditions.filter((c) => c.columnId && c.value) + + if (active.length === 0) return true + + switch (booleanOperator) { + case 'AND': + return active.every((c) => matchCondition(row, c)) + case 'OR': + return active.some((c) => matchCondition(row, c)) + case 'NOT': + return !active.some((c) => matchCondition(row, c)) + default: + return true + } +} diff --git a/src/Griddy/features/advancedSearch/index.ts b/src/Griddy/features/advancedSearch/index.ts new file mode 100644 index 0000000..e2b6817 --- /dev/null +++ b/src/Griddy/features/advancedSearch/index.ts @@ -0,0 +1,2 @@ +export { AdvancedSearchPanel, advancedSearchGlobalFilterFn } from './AdvancedSearchPanel' +export type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types' diff --git a/src/Griddy/features/advancedSearch/types.ts b/src/Griddy/features/advancedSearch/types.ts new file mode 100644 index 0000000..06d205d --- /dev/null +++ b/src/Griddy/features/advancedSearch/types.ts @@ -0,0 +1,13 @@ +export interface SearchCondition { + columnId: string + id: string + operator: 'contains' | 'endsWith' | 'equals' | 'greaterThan' | 'lessThan' | 'notContains' | 'startsWith' + value: string +} + +export type BooleanOperator = 'AND' | 'NOT' | 'OR' + +export interface AdvancedSearchState { + booleanOperator: BooleanOperator + conditions: SearchCondition[] +} diff --git a/src/Griddy/features/errorBoundary/GriddyErrorBoundary.tsx b/src/Griddy/features/errorBoundary/GriddyErrorBoundary.tsx new file mode 100644 index 0000000..f11da16 --- /dev/null +++ b/src/Griddy/features/errorBoundary/GriddyErrorBoundary.tsx @@ -0,0 +1,58 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react' + +import styles from '../../styles/griddy.module.css' + +interface ErrorBoundaryProps { + children: ReactNode + onError?: (error: Error) => void + onRetry?: () => void +} + +interface ErrorBoundaryState { + error: Error | null +} + +export class GriddyErrorBoundary extends Component { + state: ErrorBoundaryState = { error: null } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error } + } + + componentDidCatch(error: Error, info: ErrorInfo) { + this.props.onError?.(error) + console.error('[Griddy] Render error:', error, info) + } + + handleRetry = () => { + this.props.onRetry?.() + // Defer clearing the error state to allow the parent's onRetry state update + // (e.g., resetting shouldError) to flush before we re-render children + setTimeout(() => this.setState({ error: null }), 0) + } + + render() { + if (this.state.error) { + return ( +
+
!
+
+ Something went wrong rendering the grid. +
+
+ {this.state.error.message} +
+ +
+ ) + } + + return this.props.children + } +} diff --git a/src/Griddy/features/errorBoundary/index.ts b/src/Griddy/features/errorBoundary/index.ts new file mode 100644 index 0000000..e5f7386 --- /dev/null +++ b/src/Griddy/features/errorBoundary/index.ts @@ -0,0 +1 @@ +export { GriddyErrorBoundary } from './GriddyErrorBoundary' diff --git a/src/Griddy/features/filterPresets/FilterPresetsMenu.tsx b/src/Griddy/features/filterPresets/FilterPresetsMenu.tsx new file mode 100644 index 0000000..4313577 --- /dev/null +++ b/src/Griddy/features/filterPresets/FilterPresetsMenu.tsx @@ -0,0 +1,96 @@ +import type { Table } from '@tanstack/react-table' + +import { ActionIcon, Button, Group, Menu, Text, TextInput } from '@mantine/core' +import { IconBookmark, IconTrash } from '@tabler/icons-react' +import { useState } from 'react' + +import { useFilterPresets } from './useFilterPresets' + +interface FilterPresetsMenuProps { + persistenceKey?: string + table: Table +} + +export function FilterPresetsMenu({ persistenceKey, table }: FilterPresetsMenuProps) { + const { addPreset, deletePreset, presets } = useFilterPresets(persistenceKey) + const [newName, setNewName] = useState('') + const [opened, setOpened] = useState(false) + + const handleSave = () => { + if (!newName.trim()) return + addPreset({ + columnFilters: table.getState().columnFilters, + globalFilter: table.getState().globalFilter, + name: newName.trim(), + }) + setNewName('') + } + + const handleLoad = (preset: typeof presets[0]) => { + table.setColumnFilters(preset.columnFilters) + if (preset.globalFilter !== undefined) { + table.setGlobalFilter(preset.globalFilter) + } + setOpened(false) + } + + return ( + + + + + + + + Saved Presets + {presets.length === 0 && ( + + No presets saved + + )} + {presets.map((preset) => ( + handleLoad(preset)} + rightSection={ + { + e.stopPropagation() + deletePreset(preset.id) + }} + size="xs" + variant="subtle" + > + + + } + > + {preset.name} + + ))} + + Save Current Filters +
+ + setNewName(e.currentTarget.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSave()} + placeholder="Preset name" + size="xs" + style={{ flex: 1 }} + value={newName} + /> + + +
+
+
+ ) +} diff --git a/src/Griddy/features/filterPresets/index.ts b/src/Griddy/features/filterPresets/index.ts new file mode 100644 index 0000000..e593cd8 --- /dev/null +++ b/src/Griddy/features/filterPresets/index.ts @@ -0,0 +1,3 @@ +export { FilterPresetsMenu } from './FilterPresetsMenu' +export type { FilterPreset } from './types' +export { useFilterPresets } from './useFilterPresets' diff --git a/src/Griddy/features/filterPresets/types.ts b/src/Griddy/features/filterPresets/types.ts new file mode 100644 index 0000000..5a38a33 --- /dev/null +++ b/src/Griddy/features/filterPresets/types.ts @@ -0,0 +1,8 @@ +import type { ColumnFiltersState } from '@tanstack/react-table' + +export interface FilterPreset { + columnFilters: ColumnFiltersState + globalFilter?: string + id: string + name: string +} diff --git a/src/Griddy/features/filterPresets/useFilterPresets.ts b/src/Griddy/features/filterPresets/useFilterPresets.ts new file mode 100644 index 0000000..61072d6 --- /dev/null +++ b/src/Griddy/features/filterPresets/useFilterPresets.ts @@ -0,0 +1,43 @@ +import { useCallback, useState } from 'react' + +import type { FilterPreset } from './types' + +function getStorageKey(persistenceKey: string) { + return `griddy-filter-presets-${persistenceKey}` +} + +function loadPresets(persistenceKey: string): FilterPreset[] { + try { + const raw = localStorage.getItem(getStorageKey(persistenceKey)) + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } +} + +function savePresets(persistenceKey: string, presets: FilterPreset[]) { + localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(presets)) +} + +export function useFilterPresets(persistenceKey?: string) { + const key = persistenceKey ?? 'default' + const [presets, setPresets] = useState(() => loadPresets(key)) + + const addPreset = useCallback((preset: Omit) => { + setPresets((prev) => { + const next = [...prev, { ...preset, id: String(Date.now()) }] + savePresets(key, next) + return next + }) + }, [key]) + + const deletePreset = useCallback((id: string) => { + setPresets((prev) => { + const next = prev.filter((p) => p.id !== id) + savePresets(key, next) + return next + }) + }, [key]) + + return { addPreset, deletePreset, presets } +} diff --git a/src/Griddy/features/filtering/ColumnFilterButton.tsx b/src/Griddy/features/filtering/ColumnFilterButton.tsx index d009819..4604512 100644 --- a/src/Griddy/features/filtering/ColumnFilterButton.tsx +++ b/src/Griddy/features/filtering/ColumnFilterButton.tsx @@ -2,32 +2,39 @@ import type { Column } from '@tanstack/react-table' import { ActionIcon } from '@mantine/core' import { IconFilter } from '@tabler/icons-react' +import type React from 'react' +import { forwardRef } from 'react' import { CSS } from '../../core/constants' import styles from '../../styles/griddy.module.css' interface ColumnFilterButtonProps { column: Column + onClick?: (e: React.MouseEvent) => void } -export function ColumnFilterButton({ column }: ColumnFilterButtonProps) { - const isActive = !!column.getFilterValue() +export const ColumnFilterButton = forwardRef( + function ColumnFilterButton({ column, onClick, ...rest }, ref) { + const isActive = !!column.getFilterValue() - return ( - - - - ) -} + return ( + + + + ) + }, +) diff --git a/src/Griddy/features/filtering/ColumnFilterPopover.tsx b/src/Griddy/features/filtering/ColumnFilterPopover.tsx index f5480c1..7cc1d04 100644 --- a/src/Griddy/features/filtering/ColumnFilterPopover.tsx +++ b/src/Griddy/features/filtering/ColumnFilterPopover.tsx @@ -1,11 +1,13 @@ import type { Column } from '@tanstack/react-table' import { Button, Group, Popover, Stack, Text } from '@mantine/core' +import type React from 'react' import { useState } from 'react' import type { FilterConfig, FilterValue } from './types' import { getGriddyColumn } from '../../core/columnMapper' +import { QuickFilterDropdown } from '../quickFilter' import { ColumnFilterButton } from './ColumnFilterButton' import { FilterBoolean } from './FilterBoolean' import { FilterDate } from './FilterDate' @@ -62,10 +64,15 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp const operators = filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type] + const handleToggle = (e: React.MouseEvent) => { + e.stopPropagation() + setOpened(!opened) + } + return ( - + - + @@ -112,6 +119,18 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp /> )} + {filterConfig.quickFilter && ( + { + setLocalValue(val) + column.setFilterValue(val) + setOpened(false) + }} + value={localValue} + /> + )} + + ))} + + ) +} diff --git a/src/Griddy/features/searchHistory/index.ts b/src/Griddy/features/searchHistory/index.ts new file mode 100644 index 0000000..0b9c443 --- /dev/null +++ b/src/Griddy/features/searchHistory/index.ts @@ -0,0 +1,2 @@ +export { SearchHistoryDropdown } from './SearchHistoryDropdown' +export { useSearchHistory } from './useSearchHistory' diff --git a/src/Griddy/features/searchHistory/useSearchHistory.ts b/src/Griddy/features/searchHistory/useSearchHistory.ts new file mode 100644 index 0000000..8452a89 --- /dev/null +++ b/src/Griddy/features/searchHistory/useSearchHistory.ts @@ -0,0 +1,42 @@ +import { useCallback, useState } from 'react' + +const MAX_HISTORY = 10 + +function getStorageKey(persistenceKey: string) { + return `griddy-search-history-${persistenceKey}` +} + +function loadHistory(persistenceKey: string): string[] { + try { + const raw = localStorage.getItem(getStorageKey(persistenceKey)) + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } +} + +function saveHistory(persistenceKey: string, history: string[]) { + localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(history)) +} + +export function useSearchHistory(persistenceKey?: string) { + const key = persistenceKey ?? 'default' + const [history, setHistory] = useState(() => loadHistory(key)) + + const addEntry = useCallback((query: string) => { + if (!query.trim()) return + setHistory((prev) => { + const filtered = prev.filter((h) => h !== query) + const next = [query, ...filtered].slice(0, MAX_HISTORY) + saveHistory(key, next) + return next + }) + }, [key]) + + const clearHistory = useCallback(() => { + setHistory([]) + localStorage.removeItem(getStorageKey(key)) + }, [key]) + + return { addEntry, clearHistory, history } +} diff --git a/src/Griddy/features/toolbar/GridToolbar.tsx b/src/Griddy/features/toolbar/GridToolbar.tsx index ba2d02c..2259179 100644 --- a/src/Griddy/features/toolbar/GridToolbar.tsx +++ b/src/Griddy/features/toolbar/GridToolbar.tsx @@ -5,9 +5,12 @@ import { IconDownload } from '@tabler/icons-react' import { ColumnVisibilityMenu } from '../columnVisibility' import { exportToCsv } from '../export' +import { FilterPresetsMenu } from '../filterPresets' interface GridToolbarProps { exportFilename?: string + filterPresets?: boolean + persistenceKey?: string showColumnToggle?: boolean showExport?: boolean table: Table @@ -15,6 +18,8 @@ interface GridToolbarProps { export function GridToolbar({ exportFilename = 'export.csv', + filterPresets = false, + persistenceKey, showColumnToggle = true, showExport = true, table, @@ -23,12 +28,15 @@ export function GridToolbar({ exportToCsv(table, exportFilename) } - if (!showExport && !showColumnToggle) { + if (!showExport && !showColumnToggle && !filterPresets) { return null } return ( + {filterPresets && ( + + )} {showExport && ( { + 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() + }) +})