feat(pagination): add server-side pagination support and controls
- Implement pagination control UI with page navigation and size selector - Enable server-side callbacks for page changes and size adjustments - Integrate pagination into Griddy component with data count handling
This commit is contained in:
@@ -48,9 +48,11 @@
|
||||
"url": "git+https://git.warky.dev/wdevs/oranguru.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/dates": "^8.3.14",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.18",
|
||||
"dayjs": "^1.11.19",
|
||||
"moment": "^2.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
44
pnpm-lock.yaml
generated
44
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@mantine/core':
|
||||
specifier: ^8.3.1
|
||||
version: 8.3.1(@mantine/hooks@8.3.1(react@19.2.4))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/dates':
|
||||
specifier: ^8.3.14
|
||||
version: 8.3.14(@mantine/core@8.3.1(@mantine/hooks@8.3.1(react@19.2.4))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.1(react@19.2.4))(dayjs@1.11.19)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/hooks':
|
||||
specifier: ^8.3.1
|
||||
version: 8.3.1(react@19.2.4)
|
||||
@@ -44,6 +47,9 @@ importers:
|
||||
'@warkypublic/zustandsyncstore':
|
||||
specifier: ^0.0.4
|
||||
version: 0.0.4(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))(zustand@5.0.8(@types/react@19.2.13)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)))
|
||||
dayjs:
|
||||
specifier: ^1.11.19
|
||||
version: 1.11.19
|
||||
idb-keyval:
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.2
|
||||
@@ -753,6 +759,15 @@ packages:
|
||||
react: ^18.x || ^19.x
|
||||
react-dom: ^18.x || ^19.x
|
||||
|
||||
'@mantine/dates@8.3.14':
|
||||
resolution: {integrity: sha512-NdStRo2ZQ55MoMF5B9vjhpBpHRDHF1XA9Dkb1kKSdNuLlaFXKlvoaZxj/3LfNPpn7Nqlns78nWt4X8/cgC2YIg==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 8.3.14
|
||||
'@mantine/hooks': 8.3.14
|
||||
dayjs: '>=1.0.0'
|
||||
react: ^18.x || ^19.x
|
||||
react-dom: ^18.x || ^19.x
|
||||
|
||||
'@mantine/hooks@8.3.1':
|
||||
resolution: {integrity: sha512-lQutBS+Q0iz/cNFvdrsYassPWo3RtWcmDGJeOtKfHigLzFOhxUuLOkQgepDbMf3WcVMB/tist6Px1PQOv57JTw==}
|
||||
peerDependencies:
|
||||
@@ -871,56 +886,67 @@ packages:
|
||||
resolution: {integrity: sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.50.2':
|
||||
resolution: {integrity: sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.50.2':
|
||||
resolution: {integrity: sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.50.2':
|
||||
resolution: {integrity: sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.50.2':
|
||||
resolution: {integrity: sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.50.2':
|
||||
resolution: {integrity: sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.50.2':
|
||||
resolution: {integrity: sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==}
|
||||
@@ -1090,24 +1116,28 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-arm64-musl@1.15.11':
|
||||
resolution: {integrity: sha512-PYftgsTaGnfDK4m6/dty9ryK1FbLk+LosDJ/RJR2nkXGc8rd+WenXIlvHjWULiBVnS1RsjHHOXmTS4nDhe0v0w==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-linux-x64-gnu@1.15.11':
|
||||
resolution: {integrity: sha512-DKtnJKIHiZdARyTKiX7zdRjiDS1KihkQWatQiCHMv+zc2sfwb4Glrodx2VLOX4rsa92NLR0Sw8WLcPEMFY1szQ==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/core-linux-x64-musl@1.15.11':
|
||||
resolution: {integrity: sha512-mUjjntHj4+8WBaiDe5UwRNHuEzLjIWBTSGTw0JT9+C9/Yyuh4KQqlcEQ3ro6GkHmBGXBFpGIj/o5VMyRWfVfWw==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/core-win32-arm64-msvc@1.15.11':
|
||||
resolution: {integrity: sha512-ZkNNG5zL49YpaFzfl6fskNOSxtcZ5uOYmWBkY4wVAvgbSAQzLRVBp+xArGWh2oXlY/WgL99zQSGTv7RI5E6nzA==}
|
||||
@@ -1834,6 +1864,9 @@ packages:
|
||||
resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
dayjs@1.11.19:
|
||||
resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==}
|
||||
|
||||
de-indent@1.0.2:
|
||||
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
|
||||
|
||||
@@ -4658,6 +4691,15 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
|
||||
'@mantine/dates@8.3.14(@mantine/core@8.3.1(@mantine/hooks@8.3.1(react@19.2.4))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.1(react@19.2.4))(dayjs@1.11.19)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@mantine/core': 8.3.1(@mantine/hooks@8.3.1(react@19.2.4))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@mantine/hooks': 8.3.1(react@19.2.4)
|
||||
clsx: 2.1.1
|
||||
dayjs: 1.11.19
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@mantine/hooks@8.3.1(react@19.2.4)':
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
@@ -5842,6 +5884,8 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-data-view: 1.0.2
|
||||
|
||||
dayjs@1.11.19: {}
|
||||
|
||||
de-indent@1.0.2: {}
|
||||
|
||||
debug@4.4.3:
|
||||
|
||||
@@ -159,14 +159,37 @@ src/Griddy/features/filtering/
|
||||
- Filter popover UI with operators
|
||||
- 6 Storybook stories with examples
|
||||
- 8 Playwright E2E test cases
|
||||
- [ ] Phase 5.5: Date filtering (requires @mantine/dates)
|
||||
- [ ] Phase 6: In-place editing
|
||||
- [ ] Phase 7: Pagination + remote data adapters
|
||||
- [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 ✅)
|
||||
- [ ] Phase 8: Grouping, pinning, column reorder, export
|
||||
- [ ] Phase 9: Polish, docs, tests
|
||||
|
||||
## 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)
|
||||
|
||||
## Build & Testing Status
|
||||
- [x] `pnpm run typecheck` — ✅ PASS (0 errors)
|
||||
@@ -197,17 +220,58 @@ pnpm exec playwright test --debug
|
||||
pnpm exec playwright show-report
|
||||
```
|
||||
|
||||
## Next Phase (Phase 5.5 - Date Filtering)
|
||||
## Recent Completions
|
||||
|
||||
**Planned Tasks**:
|
||||
1. Install `@mantine/dates` dependency
|
||||
2. Create `FilterDate.tsx` component with date range picker
|
||||
3. Add date operators: after, before, between, exactDate
|
||||
4. Integrate into ColumnFilterPopover
|
||||
5. Add date filtering Storybook story
|
||||
6. Add Playwright E2E tests for date filtering
|
||||
### Phase 5.5 - Date Filtering
|
||||
**Files Created**:
|
||||
- `src/Griddy/features/filtering/FilterDate.tsx` — Date picker with single/range modes
|
||||
|
||||
**Estimated Effort**: 1-2 hours
|
||||
**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
|
||||
|
||||
**Next Phase**: Phase 8 (Grouping, Pinning, Export) or Phase 9 (Polish & Documentation)
|
||||
|
||||
## Resume Instructions (When Returning)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ColumnFiltersState, RowSelectionState } from '@tanstack/react-table'
|
||||
|
||||
import { Box } from '@mantine/core'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { GriddyColumn, GriddyProps } from './core/types'
|
||||
|
||||
@@ -423,7 +423,51 @@ export const WithBooleanFiltering: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
/** Combined filtering - all filter types together */
|
||||
/** Date filtering with operators like is, isBefore, isAfter, isBetween */
|
||||
export const WithDateFiltering: Story = {
|
||||
render: () => {
|
||||
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||
|
||||
const filterColumns: GriddyColumn<Person>[] = [
|
||||
{ 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: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||
{ accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
|
||||
{ accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
|
||||
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||
{
|
||||
accessor: 'startDate',
|
||||
filterable: true,
|
||||
filterConfig: { type: 'date' },
|
||||
header: 'Start Date',
|
||||
id: 'startDate',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
},
|
||||
{ accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="500px" w="100%">
|
||||
<Griddy<Person>
|
||||
columnFilters={filters}
|
||||
columns={filterColumns}
|
||||
data={smallData}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={500}
|
||||
onColumnFiltersChange={setFilters}
|
||||
/>
|
||||
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
<strong>Active Filters:</strong>
|
||||
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/** Combined filtering - all filter types together (text, number, enum, boolean, date) */
|
||||
export const WithAllFilterTypes: Story = {
|
||||
render: () => {
|
||||
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||
@@ -471,7 +515,15 @@ export const WithAllFilterTypes: Story = {
|
||||
width: 130,
|
||||
},
|
||||
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||
{
|
||||
accessor: 'startDate',
|
||||
filterable: true,
|
||||
filterConfig: { type: 'date' },
|
||||
header: 'Start Date',
|
||||
id: 'startDate',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
accessor: 'active',
|
||||
filterable: true,
|
||||
@@ -550,7 +602,15 @@ export const LargeDatasetWithFiltering: Story = {
|
||||
width: 130,
|
||||
},
|
||||
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||
{
|
||||
accessor: 'startDate',
|
||||
filterable: true,
|
||||
filterConfig: { type: 'date' },
|
||||
header: 'Start Date',
|
||||
id: 'startDate',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
accessor: 'active',
|
||||
filterable: true,
|
||||
@@ -580,3 +640,333 @@ export const LargeDatasetWithFiltering: Story = {
|
||||
)
|
||||
},
|
||||
}
|
||||
/** Server-side filtering and sorting - data fetching handled externally */
|
||||
export const ServerSideFilteringSorting: Story = {
|
||||
render: () => {
|
||||
const [filters, setFilters] = useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [serverData, setServerData] = useState<Person[]>([])
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Simulate server-side data fetching
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true)
|
||||
// Simulate network delay
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
let filteredData = [...largeData]
|
||||
|
||||
// Apply filters (simulating server-side filtering)
|
||||
filters.forEach((filter) => {
|
||||
const filterValue = filter.value as any
|
||||
if (filterValue?.operator && filterValue?.value !== undefined) {
|
||||
filteredData = filteredData.filter(row => {
|
||||
const cellValue = row[filter.id as keyof Person]
|
||||
switch (filterValue.operator) {
|
||||
case 'contains':
|
||||
return String(cellValue).toLowerCase().includes(String(filterValue.value).toLowerCase())
|
||||
case 'equals':
|
||||
return cellValue === filterValue.value
|
||||
case 'greaterThan':
|
||||
return Number(cellValue) > Number(filterValue.value)
|
||||
case 'lessThan':
|
||||
return Number(cellValue) < Number(filterValue.value)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Apply sorting (simulating server-side sorting)
|
||||
if (sorting.length > 0) {
|
||||
const sort = sorting[0]
|
||||
filteredData.sort((a, b) => {
|
||||
const aVal = a[sort.id as keyof Person]
|
||||
const bVal = b[sort.id as keyof Person]
|
||||
if (aVal < bVal) return sort.desc ? 1 : -1
|
||||
if (aVal > bVal) return sort.desc ? -1 : 1
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
setServerData(filteredData)
|
||||
setTotalCount(filteredData.length)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
fetchData()
|
||||
}, [filters, sorting])
|
||||
|
||||
const filterColumns: GriddyColumn<Person>[] = [
|
||||
{ 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',
|
||||
filterable: true,
|
||||
filterConfig: { type: 'text' },
|
||||
header: 'Last Name',
|
||||
id: 'lastName',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
},
|
||||
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||
{
|
||||
accessor: 'age',
|
||||
filterable: true,
|
||||
filterConfig: { type: 'number' },
|
||||
header: 'Age',
|
||||
id: 'age',
|
||||
sortable: true,
|
||||
width: 70,
|
||||
},
|
||||
{ accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
|
||||
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||
{ accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="600px" w="100%">
|
||||
<Box mb="sm" p="xs" style={{ background: '#fff3cd', border: '1px solid #ffc107', borderRadius: 4, fontSize: 13 }}>
|
||||
<strong>Server-Side Mode:</strong> Filtering and sorting are handled by simulated server. Data fetches on filter/sort change.
|
||||
</Box>
|
||||
<Griddy<Person>
|
||||
columnFilters={filters}
|
||||
columns={filterColumns}
|
||||
data={serverData}
|
||||
dataCount={totalCount}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={500}
|
||||
manualFiltering
|
||||
manualSorting
|
||||
onColumnFiltersChange={setFilters}
|
||||
onSortingChange={setSorting}
|
||||
sorting={sorting}
|
||||
/>
|
||||
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
<strong>Server State:</strong>
|
||||
<div>Loading: {isLoading ? 'true' : 'false'}</div>
|
||||
<div>Total Count: {totalCount}</div>
|
||||
<div>Displayed Rows: {serverData.length}</div>
|
||||
<strong style={{ marginTop: 8, display: 'block' }}>Active Filters:</strong>
|
||||
<pre style={{ margin: '4px 0' }}>{JSON.stringify(filters, null, 2)}</pre>
|
||||
<strong style={{ marginTop: 8, display: 'block' }}>Active Sorting:</strong>
|
||||
<pre style={{ margin: '4px 0' }}>{JSON.stringify(sorting, null, 2)}</pre>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/** Inline editing - double-click cell or Ctrl+E/Enter to edit */
|
||||
export const WithInlineEditing: Story = {
|
||||
render: () => {
|
||||
const [data, setData] = useState<Person[]>(smallData.map(p => ({ ...p })))
|
||||
|
||||
const handleEditCommit = async (rowId: string, columnId: string, value: unknown) => {
|
||||
setData(prev => prev.map(row =>
|
||||
String(row.id) === rowId
|
||||
? { ...row, [columnId]: value }
|
||||
: row
|
||||
))
|
||||
}
|
||||
|
||||
const editColumns: GriddyColumn<Person>[] = [
|
||||
{ accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 },
|
||||
{
|
||||
accessor: 'firstName',
|
||||
editable: true,
|
||||
editorConfig: { type: 'text' },
|
||||
header: 'First Name',
|
||||
id: 'firstName',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
accessor: 'lastName',
|
||||
editable: true,
|
||||
editorConfig: { type: 'text' },
|
||||
header: 'Last Name',
|
||||
id: 'lastName',
|
||||
sortable: true,
|
||||
width: 120,
|
||||
},
|
||||
{ accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||
{
|
||||
accessor: 'age',
|
||||
editable: true,
|
||||
editorConfig: { max: 100, min: 18, type: 'number' },
|
||||
header: 'Age',
|
||||
id: 'age',
|
||||
sortable: true,
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
accessor: 'department',
|
||||
editable: true,
|
||||
editorConfig: {
|
||||
options: departments.map(d => ({ label: d, value: d })),
|
||||
type: 'select',
|
||||
},
|
||||
header: 'Department',
|
||||
id: 'department',
|
||||
sortable: true,
|
||||
width: 130,
|
||||
},
|
||||
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||
{
|
||||
accessor: 'active',
|
||||
editable: true,
|
||||
editorConfig: { type: 'checkbox' },
|
||||
header: 'Active',
|
||||
id: 'active',
|
||||
sortable: true,
|
||||
width: 80,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="600px" w="100%">
|
||||
<Box mb="sm" p="xs" style={{ background: '#d1ecf1', border: '1px solid #bee5eb', borderRadius: 4, fontSize: 13 }}>
|
||||
<strong>Editing Mode:</strong> Double-click any editable cell (First Name, Last Name, Age, Department, Active) or press Ctrl+E/Enter when a row is focused.
|
||||
<div style={{ marginTop: 4 }}>
|
||||
<strong>Keyboard:</strong> Enter commits, Escape cancels, Tab moves to next editable cell
|
||||
</div>
|
||||
</Box>
|
||||
<Griddy<Person>
|
||||
columns={editColumns}
|
||||
data={data}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={500}
|
||||
onEditCommit={handleEditCommit}
|
||||
/>
|
||||
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
<strong>Modified Data (first 3 rows):</strong>
|
||||
<pre style={{ margin: '4px 0', maxHeight: 200, overflow: 'auto' }}>{JSON.stringify(data.slice(0, 3), null, 2)}</pre>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/** Client-side pagination - paginate large datasets in memory */
|
||||
export const WithClientSidePagination: Story = {
|
||||
render: () => {
|
||||
const paginationColumns: GriddyColumn<Person>[] = [
|
||||
{ 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: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||
{ accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
|
||||
{ accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
|
||||
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||
{ accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="600px" w="100%">
|
||||
<Box mb="sm" p="xs" style={{ background: '#d4edda', border: '1px solid #c3e6cb', borderRadius: 4, fontSize: 13 }}>
|
||||
<strong>Client-Side Pagination:</strong> 10,000 rows paginated in memory. Fast page switching, all data loaded upfront.
|
||||
</Box>
|
||||
<Griddy<Person>
|
||||
columns={paginationColumns}
|
||||
data={largeData}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={500}
|
||||
pagination={{
|
||||
enabled: true,
|
||||
pageSize: 25,
|
||||
pageSizeOptions: [10, 25, 50, 100],
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
/** Server-side pagination - fetch pages from server */
|
||||
export const WithServerSidePagination: Story = {
|
||||
render: () => {
|
||||
const [serverData, setServerData] = useState<Person[]>([])
|
||||
const [pageIndex, setPageIndex] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(25)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
// Simulate server-side pagination
|
||||
useEffect(() => {
|
||||
const fetchPage = async () => {
|
||||
setIsLoading(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
const start = pageIndex * pageSize
|
||||
const end = start + pageSize
|
||||
const page = largeData.slice(start, end)
|
||||
|
||||
setServerData(page)
|
||||
setTotalCount(largeData.length)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
fetchPage()
|
||||
}, [pageIndex, pageSize])
|
||||
|
||||
const paginationColumns: GriddyColumn<Person>[] = [
|
||||
{ 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: 'email', header: 'Email', id: 'email', sortable: true, width: 240 },
|
||||
{ accessor: 'age', header: 'Age', id: 'age', sortable: true, width: 70 },
|
||||
{ accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 },
|
||||
{ accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 },
|
||||
{ accessor: 'startDate', header: 'Start Date', id: 'startDate', sortable: true, width: 120 },
|
||||
{ accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 },
|
||||
]
|
||||
|
||||
return (
|
||||
<Box h="100%" mih="600px" w="100%">
|
||||
<Box mb="sm" p="xs" style={{ background: '#fff3cd', border: '1px solid #ffc107', borderRadius: 4, fontSize: 13 }}>
|
||||
<strong>Server-Side Pagination:</strong> Data fetched per page from simulated server. Only current page loaded.
|
||||
</Box>
|
||||
<Griddy<Person>
|
||||
columns={paginationColumns}
|
||||
data={serverData}
|
||||
dataCount={totalCount}
|
||||
getRowId={(row) => String(row.id)}
|
||||
height={500}
|
||||
pagination={{
|
||||
enabled: true,
|
||||
onPageChange: (page) => setPageIndex(page),
|
||||
onPageSizeChange: (size) => {
|
||||
setPageSize(size)
|
||||
setPageIndex(0)
|
||||
},
|
||||
pageSize,
|
||||
pageSizeOptions: [10, 25, 50, 100],
|
||||
}}
|
||||
/>
|
||||
<Box mt="sm" p="xs" style={{ background: '#f8f9fa', border: '1px solid #dee2e6', borderRadius: 4, fontFamily: 'monospace', fontSize: 12 }}>
|
||||
<strong>Server State:</strong>
|
||||
<div>Loading: {isLoading ? 'true' : 'false'}</div>
|
||||
<div>Current Page: {pageIndex + 1}</div>
|
||||
<div>Page Size: {pageSize}</div>
|
||||
<div>Total Rows: {totalCount}</div>
|
||||
<div>Displayed Rows: {serverData.length}</div>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, u
|
||||
import type { GriddyProps, GriddyRef } from './types'
|
||||
|
||||
import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation'
|
||||
import { PaginationControl } from '../features/pagination'
|
||||
import { SearchOverlay } from '../features/search/SearchOverlay'
|
||||
import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer'
|
||||
import { TableHeader } from '../rendering/TableHeader'
|
||||
@@ -60,6 +61,9 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
const height = useGriddyStore((s) => s.height)
|
||||
const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation)
|
||||
const className = useGriddyStore((s) => s.className)
|
||||
const manualSorting = useGriddyStore((s) => s.manualSorting)
|
||||
const manualFiltering = useGriddyStore((s) => s.manualFiltering)
|
||||
const dataCount = useGriddyStore((s) => s.dataCount)
|
||||
const setTable = useGriddyStore((s) => s.setTable)
|
||||
const setVirtualizer = useGriddyStore((s) => s.setVirtualizer)
|
||||
const setScrollRef = useGriddyStore((s) => s.setScrollRef)
|
||||
@@ -91,6 +95,23 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize,
|
||||
})
|
||||
|
||||
// Wrap pagination setters to call callbacks
|
||||
const handlePaginationChange = (updater: any) => {
|
||||
setInternalPagination(prev => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||
// Call callbacks if pagination config exists
|
||||
if (paginationConfig) {
|
||||
if (next.pageIndex !== prev.pageIndex && paginationConfig.onPageChange) {
|
||||
paginationConfig.onPageChange(next.pageIndex)
|
||||
}
|
||||
if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) {
|
||||
paginationConfig.onPageSizeChange(next.pageSize)
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve controlled vs uncontrolled
|
||||
const sorting = controlledSorting ?? internalSorting
|
||||
const setSorting = onSortingChange ?? setInternalSorting
|
||||
@@ -114,16 +135,19 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
enableRowSelection,
|
||||
enableSorting: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(),
|
||||
getRowId: getRowId as any ?? ((_, index) => String(index)),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getSortedRowModel: manualSorting ? undefined : getSortedRowModel(),
|
||||
manualFiltering: manualFiltering ?? false,
|
||||
manualSorting: manualSorting ?? false,
|
||||
onColumnFiltersChange: setColumnFilters as any,
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onPaginationChange: paginationConfig?.enabled ? setInternalPagination : undefined,
|
||||
onPaginationChange: paginationConfig?.enabled ? handlePaginationChange : undefined,
|
||||
onRowSelectionChange: setRowSelection as any,
|
||||
onSortingChange: setSorting as any,
|
||||
rowCount: dataCount,
|
||||
state: {
|
||||
columnFilters,
|
||||
columnOrder,
|
||||
@@ -238,6 +262,12 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
<TableHeader />
|
||||
<VirtualBody />
|
||||
</div>
|
||||
{paginationConfig?.enabled && (
|
||||
<PaginationControl
|
||||
pageSizeOptions={paginationConfig.pageSizeOptions}
|
||||
table={table}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,10 +23,13 @@ export interface GriddyStoreState extends GriddyUIState {
|
||||
columns?: GriddyColumn<any>[]
|
||||
data?: any[]
|
||||
dataAdapter?: DataAdapter<any>
|
||||
dataCount?: number
|
||||
getRowId?: (row: any, index: number) => string
|
||||
grouping?: GroupingConfig
|
||||
height?: number | string
|
||||
keyboardNavigation?: boolean
|
||||
manualFiltering?: boolean
|
||||
manualSorting?: boolean
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
|
||||
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningStat
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import type { EditorConfig } from '../editors'
|
||||
import type { FilterConfig } from '../features/filtering'
|
||||
|
||||
// ─── Column Definition ───────────────────────────────────────────────────────
|
||||
@@ -46,6 +47,7 @@ export interface GriddyColumn<T> {
|
||||
accessor: ((row: T) => unknown) | keyof T
|
||||
editable?: ((row: T) => boolean) | boolean
|
||||
editor?: EditorComponent<T>
|
||||
editorConfig?: EditorConfig
|
||||
filterable?: boolean
|
||||
filterConfig?: FilterConfig
|
||||
filterFn?: FilterFn<T>
|
||||
@@ -91,6 +93,8 @@ export interface GriddyProps<T> {
|
||||
|
||||
// ─── Data Adapter ───
|
||||
dataAdapter?: DataAdapter<T>
|
||||
/** Total row count (for server-side pagination/filtering). If provided, enables manual mode. */
|
||||
dataCount?: number
|
||||
/** Stable row identity function */
|
||||
getRowId?: (row: T, index: number) => string
|
||||
// ─── Grouping ───
|
||||
@@ -101,6 +105,10 @@ export interface GriddyProps<T> {
|
||||
// ─── Keyboard ───
|
||||
/** Enable keyboard navigation. Default: true */
|
||||
keyboardNavigation?: boolean
|
||||
/** Manual filtering mode - filtering handled externally (server-side). Default: false */
|
||||
manualFiltering?: boolean
|
||||
/** Manual sorting mode - sorting handled externally (server-side). Default: false */
|
||||
manualSorting?: boolean
|
||||
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
|
||||
// ─── Editing ───
|
||||
|
||||
43
src/Griddy/editors/CheckboxEditor.tsx
Normal file
43
src/Griddy/editors/CheckboxEditor.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Checkbox } from '@mantine/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { BaseEditorProps } from './types'
|
||||
|
||||
export function CheckboxEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, value }: BaseEditorProps<boolean>) {
|
||||
const [checked, setChecked] = useState(Boolean(value))
|
||||
|
||||
useEffect(() => {
|
||||
setChecked(Boolean(value))
|
||||
}, [value])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onCommit(checked)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
onCommit(checked)
|
||||
if (e.shiftKey) {
|
||||
onMovePrev?.()
|
||||
} else {
|
||||
onMoveNext?.()
|
||||
}
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setChecked(!checked)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
autoFocus={autoFocus}
|
||||
checked={checked}
|
||||
onChange={(e) => setChecked(e.currentTarget.checked)}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
}
|
||||
46
src/Griddy/editors/DateEditor.tsx
Normal file
46
src/Griddy/editors/DateEditor.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { DatePickerInput } from '@mantine/dates'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { BaseEditorProps } from './types'
|
||||
|
||||
export function DateEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, value }: BaseEditorProps<Date | string>) {
|
||||
const [dateValue, setDateValue] = useState<Date | null>(() =>
|
||||
value ? new Date(value) : null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setDateValue(value ? new Date(value) : null)
|
||||
}, [value])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onCommit(dateValue ?? '')
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
onCommit(dateValue ?? '')
|
||||
if (e.shiftKey) {
|
||||
onMovePrev?.()
|
||||
} else {
|
||||
onMoveNext?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DatePickerInput
|
||||
autoFocus={autoFocus}
|
||||
clearable
|
||||
onChange={(date) => {
|
||||
const dateVal = date ? (typeof date === 'string' ? new Date(date) : date) : null
|
||||
setDateValue(dateVal)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="xs"
|
||||
value={dateValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
49
src/Griddy/editors/NumericEditor.tsx
Normal file
49
src/Griddy/editors/NumericEditor.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NumberInput } from '@mantine/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { BaseEditorProps } from './types'
|
||||
|
||||
interface NumericEditorProps extends BaseEditorProps<number> {
|
||||
max?: number
|
||||
min?: number
|
||||
step?: number
|
||||
}
|
||||
|
||||
export function NumericEditor({ autoFocus = true, max, min, onCancel, onCommit, onMoveNext, onMovePrev, step = 1, value }: NumericEditorProps) {
|
||||
const [inputValue, setInputValue] = useState<number | string>(value ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value ?? '')
|
||||
}, [value])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onCommit(typeof inputValue === 'number' ? inputValue : Number(inputValue))
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
onCommit(typeof inputValue === 'number' ? inputValue : Number(inputValue))
|
||||
if (e.shiftKey) {
|
||||
onMovePrev?.()
|
||||
} else {
|
||||
onMoveNext?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<NumberInput
|
||||
autoFocus={autoFocus}
|
||||
max={max}
|
||||
min={min}
|
||||
onChange={(val) => setInputValue(val ?? '')}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="xs"
|
||||
step={step}
|
||||
value={inputValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
49
src/Griddy/editors/SelectEditor.tsx
Normal file
49
src/Griddy/editors/SelectEditor.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Select } from '@mantine/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { BaseEditorProps, SelectOption } from './types'
|
||||
|
||||
interface SelectEditorProps extends BaseEditorProps<any> {
|
||||
options: SelectOption[]
|
||||
}
|
||||
|
||||
export function SelectEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, options, value }: SelectEditorProps) {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(value != null ? String(value) : null)
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedValue(value != null ? String(value) : null)
|
||||
}, [value])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
// Find the actual value from options
|
||||
const option = options.find(opt => String(opt.value) === selectedValue)
|
||||
onCommit(option?.value ?? selectedValue)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const option = options.find(opt => String(opt.value) === selectedValue)
|
||||
onCommit(option?.value ?? selectedValue)
|
||||
if (e.shiftKey) {
|
||||
onMovePrev?.()
|
||||
} else {
|
||||
onMoveNext?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
autoFocus={autoFocus}
|
||||
data={options.map(opt => ({ label: opt.label, value: String(opt.value) }))}
|
||||
onChange={(val) => setSelectedValue(val)}
|
||||
onKeyDown={handleKeyDown}
|
||||
searchable
|
||||
size="xs"
|
||||
value={selectedValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
40
src/Griddy/editors/TextEditor.tsx
Normal file
40
src/Griddy/editors/TextEditor.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { TextInput } from '@mantine/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import type { BaseEditorProps } from './types'
|
||||
|
||||
export function TextEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, value }: BaseEditorProps<string>) {
|
||||
const [inputValue, setInputValue] = useState(value ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value ?? '')
|
||||
}, [value])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
onCommit(inputValue)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
onCommit(inputValue)
|
||||
if (e.shiftKey) {
|
||||
onMovePrev?.()
|
||||
} else {
|
||||
onMoveNext?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
autoFocus={autoFocus}
|
||||
onChange={(e) => setInputValue(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
size="xs"
|
||||
value={inputValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
6
src/Griddy/editors/index.ts
Normal file
6
src/Griddy/editors/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { CheckboxEditor } from './CheckboxEditor'
|
||||
export { DateEditor } from './DateEditor'
|
||||
export { NumericEditor } from './NumericEditor'
|
||||
export { SelectEditor } from './SelectEditor'
|
||||
export { TextEditor } from './TextEditor'
|
||||
export type { BaseEditorProps, EditorComponent, EditorConfig, EditorType, SelectOption, ValidationResult, ValidationRule } from './types'
|
||||
45
src/Griddy/editors/types.ts
Normal file
45
src/Griddy/editors/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
// ─── Editor Props ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BaseEditorProps<T = any> {
|
||||
autoFocus?: boolean
|
||||
onCancel: () => void
|
||||
onCommit: (value: T) => void
|
||||
onMoveNext?: () => void
|
||||
onMovePrev?: () => void
|
||||
value: T
|
||||
}
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ValidationRule<T = any> {
|
||||
message: string
|
||||
validate: (value: T) => boolean
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
errors: string[]
|
||||
isValid: boolean
|
||||
}
|
||||
|
||||
// ─── Editor Registry ─────────────────────────────────────────────────────────
|
||||
|
||||
export type EditorType = 'checkbox' | 'date' | 'number' | 'select' | 'text'
|
||||
|
||||
export interface SelectOption {
|
||||
label: string
|
||||
value: any
|
||||
}
|
||||
|
||||
export interface EditorConfig {
|
||||
max?: number
|
||||
min?: number
|
||||
options?: SelectOption[]
|
||||
placeholder?: string
|
||||
step?: number
|
||||
type?: EditorType
|
||||
validation?: ValidationRule[]
|
||||
}
|
||||
|
||||
export type EditorComponent<T = any> = (props: BaseEditorProps<T>) => ReactNode
|
||||
@@ -8,6 +8,7 @@ import type { FilterConfig, FilterValue } from './types'
|
||||
import { getGriddyColumn } from '../../core/columnMapper'
|
||||
import { ColumnFilterButton } from './ColumnFilterButton'
|
||||
import { FilterBoolean } from './FilterBoolean'
|
||||
import { FilterDate } from './FilterDate'
|
||||
import { FilterInput } from './FilterInput'
|
||||
import { FilterSelect } from './FilterSelect'
|
||||
import { OPERATORS_BY_TYPE } from './operators'
|
||||
@@ -103,6 +104,14 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
|
||||
<FilterBoolean onChange={setLocalValue} value={localValue} />
|
||||
)}
|
||||
|
||||
{filterConfig.type === 'date' && (
|
||||
<FilterDate
|
||||
onChange={setLocalValue}
|
||||
operators={operators}
|
||||
value={localValue}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleClear} size="xs" variant="subtle">
|
||||
Clear
|
||||
|
||||
109
src/Griddy/features/filtering/FilterDate.tsx
Normal file
109
src/Griddy/features/filtering/FilterDate.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Group, Select, Stack } from '@mantine/core'
|
||||
import { DatePickerInput } from '@mantine/dates'
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { FilterOperator, FilterValue } from './types'
|
||||
|
||||
interface FilterDateProps {
|
||||
onChange: (value: FilterValue) => void
|
||||
operators: FilterOperator[]
|
||||
value?: FilterValue
|
||||
}
|
||||
|
||||
export function FilterDate({ onChange, operators, value }: FilterDateProps) {
|
||||
const [operator, setOperator] = useState<string>(value?.operator || operators[0]?.id || '')
|
||||
const [startDate, setStartDate] = useState<Date | null>(() =>
|
||||
value?.startDate ? new Date(value.startDate) : null
|
||||
)
|
||||
const [endDate, setEndDate] = useState<Date | null>(() =>
|
||||
value?.endDate ? new Date(value.endDate) : null
|
||||
)
|
||||
|
||||
const selectedOperator = operators.find((op) => op.id === operator)
|
||||
const requiresValue = selectedOperator?.requiresValue !== false
|
||||
|
||||
const handleOperatorChange = (newOp: null | string) => {
|
||||
if (newOp) {
|
||||
setOperator(newOp)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "isBetween" operator specially
|
||||
if (operator === 'isBetween') {
|
||||
return (
|
||||
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
data={operators.map((op) => ({ label: op.label, value: op.id }))}
|
||||
label="Operator"
|
||||
onChange={handleOperatorChange}
|
||||
searchable
|
||||
size="xs"
|
||||
value={operator}
|
||||
/>
|
||||
<Group grow>
|
||||
<DatePickerInput
|
||||
clearable
|
||||
label="Start Date"
|
||||
onChange={(date) => {
|
||||
const dateValue = date ? (typeof date === 'string' ? new Date(date) : date) : null
|
||||
setStartDate(dateValue)
|
||||
onChange({
|
||||
endDate: endDate ?? undefined,
|
||||
operator: 'isBetween',
|
||||
startDate: dateValue ?? undefined,
|
||||
})
|
||||
}}
|
||||
placeholder="Start date"
|
||||
size="xs"
|
||||
value={startDate}
|
||||
/>
|
||||
<DatePickerInput
|
||||
clearable
|
||||
label="End Date"
|
||||
onChange={(date) => {
|
||||
const dateValue = date ? (typeof date === 'string' ? new Date(date) : date) : null
|
||||
setEndDate(dateValue)
|
||||
onChange({
|
||||
endDate: dateValue ?? undefined,
|
||||
operator: 'isBetween',
|
||||
startDate: startDate ?? undefined,
|
||||
})
|
||||
}}
|
||||
placeholder="End date"
|
||||
size="xs"
|
||||
value={endDate}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs" onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
data={operators.map((op) => ({ label: op.label, value: op.id }))}
|
||||
label="Operator"
|
||||
onChange={handleOperatorChange}
|
||||
searchable
|
||||
size="xs"
|
||||
value={operator}
|
||||
/>
|
||||
{requiresValue && (
|
||||
<DatePickerInput
|
||||
autoFocus
|
||||
clearable
|
||||
onChange={(date) => {
|
||||
const dateValue = date ? (typeof date === 'string' ? new Date(date) : date) : null
|
||||
onChange({
|
||||
operator,
|
||||
value: dateValue ?? undefined,
|
||||
})
|
||||
}}
|
||||
placeholder="Select date..."
|
||||
size="xs"
|
||||
value={value?.value ? new Date(value.value) : null}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -119,6 +119,50 @@ const booleanIsFalse: FilterFn<any> = (row: any, columnId: string) => {
|
||||
return value === false || value === 0 || String(value).toLowerCase() === 'false'
|
||||
}
|
||||
|
||||
// ─── Date Filter Functions ──────────────────────────────────────────────────
|
||||
|
||||
const dateIs: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||
const value = row.getValue(columnId)
|
||||
if (value == null || filterValue.value == null) return false
|
||||
const rowDate = new Date(value)
|
||||
const filterDate = new Date(filterValue.value)
|
||||
return rowDate.toDateString() === filterDate.toDateString()
|
||||
}
|
||||
|
||||
const dateIsBefore: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||
const value = row.getValue(columnId)
|
||||
if (value == null || filterValue.value == null) return false
|
||||
const rowDate = new Date(value)
|
||||
const filterDate = new Date(filterValue.value)
|
||||
return rowDate < filterDate
|
||||
}
|
||||
|
||||
const dateIsAfter: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||
const value = row.getValue(columnId)
|
||||
if (value == null || filterValue.value == null) return false
|
||||
const rowDate = new Date(value)
|
||||
const filterDate = new Date(filterValue.value)
|
||||
return rowDate > filterDate
|
||||
}
|
||||
|
||||
const dateIsBetween: FilterFn<any> = (row: any, columnId: string, filterValue: FilterValue) => {
|
||||
const value = row.getValue(columnId)
|
||||
if (value == null) return false
|
||||
const rowDate = new Date(value)
|
||||
const startDate = filterValue.startDate ? new Date(filterValue.startDate) : null
|
||||
const endDate = filterValue.endDate ? new Date(filterValue.endDate) : null
|
||||
if (startDate && endDate) {
|
||||
return rowDate >= startDate && rowDate <= endDate
|
||||
}
|
||||
if (startDate) {
|
||||
return rowDate >= startDate
|
||||
}
|
||||
if (endDate) {
|
||||
return rowDate <= endDate
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Filter Function Map ────────────────────────────────────────────────────
|
||||
|
||||
const FILTER_FN_MAP: Record<string, FilterFn<any>> = {
|
||||
@@ -139,6 +183,10 @@ const FILTER_FN_MAP: Record<string, FilterFn<any>> = {
|
||||
greaterThan: numberGreaterThan,
|
||||
greaterThanOrEqual: numberGreaterThanOrEqual,
|
||||
includes: enumIncludes,
|
||||
is: dateIs,
|
||||
isAfter: dateIsAfter,
|
||||
isBefore: dateIsBefore,
|
||||
isBetween: dateIsBetween,
|
||||
isEmpty: (
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(row: any, columnId: string, _filterValue: any, _addMeta: any) => {
|
||||
|
||||
@@ -2,8 +2,9 @@ export { ColumnFilterButton } from './ColumnFilterButton'
|
||||
export { HeaderContextMenu } from './ColumnFilterContextMenu'
|
||||
export { ColumnFilterPopover } from './ColumnFilterPopover'
|
||||
export { FilterBoolean } from './FilterBoolean'
|
||||
export { FilterDate } from './FilterDate'
|
||||
export { createOperatorFilter } from './filterFunctions'
|
||||
export { FilterInput } from './FilterInput'
|
||||
export { FilterSelect } from './FilterSelect'
|
||||
export { BOOLEAN_OPERATORS, ENUM_OPERATORS, NUMBER_OPERATORS, OPERATORS_BY_TYPE, TEXT_OPERATORS } from './operators'
|
||||
export { BOOLEAN_OPERATORS, DATE_OPERATORS, ENUM_OPERATORS, NUMBER_OPERATORS, OPERATORS_BY_TYPE, TEXT_OPERATORS } from './operators'
|
||||
export type { FilterConfig, FilterEnumOption, FilterOperator, FilterState, FilterValue } from './types'
|
||||
|
||||
@@ -41,10 +41,22 @@ export const BOOLEAN_OPERATORS: FilterOperator[] = [
|
||||
{ id: 'isEmpty', label: 'All', requiresValue: false },
|
||||
]
|
||||
|
||||
// ─── Date Operators ─────────────────────────────────────────────────────────
|
||||
|
||||
export const DATE_OPERATORS: FilterOperator[] = [
|
||||
{ id: 'is', label: 'Is' },
|
||||
{ id: 'isBefore', label: 'Is before' },
|
||||
{ id: 'isAfter', label: 'Is after' },
|
||||
{ id: 'isBetween', label: 'Is between' },
|
||||
{ id: 'isEmpty', label: 'Is empty', requiresValue: false },
|
||||
{ id: 'isNotEmpty', label: 'Is not empty', requiresValue: false },
|
||||
]
|
||||
|
||||
// ─── Operator Maps ──────────────────────────────────────────────────────────
|
||||
|
||||
export const OPERATORS_BY_TYPE = {
|
||||
boolean: BOOLEAN_OPERATORS,
|
||||
date: DATE_OPERATORS,
|
||||
enum: ENUM_OPERATORS,
|
||||
number: NUMBER_OPERATORS,
|
||||
text: TEXT_OPERATORS,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
export interface FilterConfig {
|
||||
enumOptions?: FilterEnumOption[]
|
||||
operators?: FilterOperator[]
|
||||
type: 'boolean' | 'enum' | 'number' | 'text'
|
||||
type: 'boolean' | 'date' | 'enum' | 'number' | 'text'
|
||||
}
|
||||
|
||||
export interface FilterEnumOption {
|
||||
@@ -25,9 +25,11 @@ export interface FilterState {
|
||||
}
|
||||
|
||||
export interface FilterValue {
|
||||
endDate?: Date
|
||||
max?: number
|
||||
min?: number
|
||||
operator: string
|
||||
startDate?: Date
|
||||
value?: any
|
||||
values?: any[]
|
||||
}
|
||||
|
||||
@@ -124,6 +124,15 @@ export function useKeyboardNavigation<TData = unknown>({
|
||||
case 'e': {
|
||||
if (ctrl && editingEnabled && focusedRowIndex !== null) {
|
||||
e.preventDefault()
|
||||
// Find first editable column
|
||||
const columns = table.getAllColumns().filter(col => col.id !== '_selection')
|
||||
const firstEditableCol = columns.find(col => {
|
||||
const meta = col.columnDef.meta as any
|
||||
return meta?.griddy?.editable === true
|
||||
})
|
||||
if (firstEditableCol) {
|
||||
state.setFocusedColumn(firstEditableCol.id)
|
||||
}
|
||||
state.setEditing(true)
|
||||
}
|
||||
return
|
||||
@@ -139,6 +148,15 @@ export function useKeyboardNavigation<TData = unknown>({
|
||||
case 'Enter': {
|
||||
if (editingEnabled && focusedRowIndex !== null && !ctrl) {
|
||||
e.preventDefault()
|
||||
// Find first editable column
|
||||
const columns = table.getAllColumns().filter(col => col.id !== '_selection')
|
||||
const firstEditableCol = columns.find(col => {
|
||||
const meta = col.columnDef.meta as any
|
||||
return meta?.griddy?.editable === true
|
||||
})
|
||||
if (firstEditableCol) {
|
||||
state.setFocusedColumn(firstEditableCol.id)
|
||||
}
|
||||
state.setEditing(true)
|
||||
}
|
||||
return
|
||||
|
||||
81
src/Griddy/features/pagination/PaginationControl.tsx
Normal file
81
src/Griddy/features/pagination/PaginationControl.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
|
||||
import { ActionIcon, Group, Select, Text } from '@mantine/core'
|
||||
import { IconChevronLeft, IconChevronRight, IconChevronsLeft, IconChevronsRight } from '@tabler/icons-react'
|
||||
|
||||
import styles from '../../styles/griddy.module.css'
|
||||
|
||||
interface PaginationControlProps<T> {
|
||||
pageSizeOptions?: number[]
|
||||
table: Table<T>
|
||||
}
|
||||
|
||||
export function PaginationControl<T>({ pageSizeOptions = [10, 25, 50, 100], table }: PaginationControlProps<T>) {
|
||||
const pageIndex = table.getState().pagination.pageIndex
|
||||
const pageSize = table.getState().pagination.pageSize
|
||||
const pageCount = table.getPageCount()
|
||||
const canPreviousPage = table.getCanPreviousPage()
|
||||
const canNextPage = table.getCanNextPage()
|
||||
|
||||
return (
|
||||
<Group className={styles['griddy-pagination']} gap="md" justify="space-between" p="xs">
|
||||
<Group gap="xs">
|
||||
<Text c="dimmed" size="sm">
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
disabled={!canPreviousPage}
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
<IconChevronsLeft size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
disabled={!canPreviousPage}
|
||||
onClick={() => table.previousPage()}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
<IconChevronLeft size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
disabled={!canNextPage}
|
||||
onClick={() => table.nextPage()}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
<IconChevronRight size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
disabled={!canNextPage}
|
||||
onClick={() => table.setPageIndex(pageCount - 1)}
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
>
|
||||
<IconChevronsRight size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs">
|
||||
<Text c="dimmed" size="sm">
|
||||
Rows per page:
|
||||
</Text>
|
||||
<Select
|
||||
data={pageSizeOptions.map(size => ({ label: String(size), value: String(size) }))}
|
||||
onChange={(value) => {
|
||||
if (value) {
|
||||
table.setPageSize(Number(value))
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
value={String(pageSize)}
|
||||
w={70}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
1
src/Griddy/features/pagination/index.ts
Normal file
1
src/Griddy/features/pagination/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PaginationControl } from './PaginationControl'
|
||||
@@ -992,8 +992,8 @@ persist={{
|
||||
- [x] Filter status indicators (blue/gray icons in headers)
|
||||
- [x] Debounced text input (300ms)
|
||||
- [x] Apply/Clear buttons for filter controls
|
||||
- [ ] Date filtering (Phase 5.5 - requires @mantine/dates)
|
||||
- [ ] Server-side sort/filter support (`manualSorting`, `manualFiltering`)
|
||||
- [x] Date filtering (Phase 5.5 - COMPLETE with @mantine/dates)
|
||||
- [x] Server-side sort/filter support (`manualSorting`, `manualFiltering`) - COMPLETE
|
||||
- [ ] Sort/filter state persistence
|
||||
|
||||
**Deliverable**: Complete data manipulation features powered by TanStack Table
|
||||
@@ -1022,27 +1022,34 @@ persist={{
|
||||
- `tests/e2e/filtering-context-menu.spec.ts` — 8 comprehensive E2E test cases
|
||||
|
||||
### Phase 6: In-Place Editing
|
||||
- [ ] Implement `EditableCell.tsx` with editor mounting
|
||||
- [ ] Implement built-in editors: Text, Numeric, Date, Select, Checkbox
|
||||
- [ ] Keyboard editing:
|
||||
- [x] Implement `EditableCell.tsx` with editor mounting
|
||||
- [x] Implement built-in editors: Text, Numeric, Date, Select, Checkbox
|
||||
- [x] Keyboard editing:
|
||||
- Ctrl+E or Enter to start editing
|
||||
- Tab/Shift+Tab between editable cells
|
||||
- Enter to commit + move to next row
|
||||
- Tab/Shift+Tab between editable cells (partial - editors handle Tab)
|
||||
- Enter to commit
|
||||
- Escape to cancel
|
||||
- [ ] Validation system
|
||||
- [ ] `onEditCommit` callback
|
||||
- [ ] Undo/redo (optional)
|
||||
- [x] `onEditCommit` callback
|
||||
- [x] Double-click to edit
|
||||
- [x] Editor types: text, number, date, select, checkbox
|
||||
- [ ] Validation system (deferred)
|
||||
- [ ] Tab to next editable cell navigation (deferred)
|
||||
- [ ] Undo/redo (optional, deferred)
|
||||
|
||||
**Deliverable**: Full in-place editing with keyboard support
|
||||
**Deliverable**: Full in-place editing with keyboard support - COMPLETE ✅
|
||||
|
||||
### Phase 7: Pagination & Data Adapters
|
||||
- [ ] Client-side pagination via TanStack Table `getPaginationRowModel()`
|
||||
- [ ] Pagination controls UI (page nav, page size selector)
|
||||
- [ ] Implement `RemoteServerAdapter` with cursor + offset support
|
||||
- [ ] Loading states and error handling
|
||||
- [ ] Infinite scroll pattern (optional)
|
||||
- [x] Client-side pagination via TanStack Table `getPaginationRowModel()`
|
||||
- [x] Pagination controls UI (page nav, page size selector)
|
||||
- [x] Server-side pagination callbacks (`onPageChange`, `onPageSizeChange`)
|
||||
- [x] Page navigation controls (first, previous, next, last)
|
||||
- [x] Page size selector dropdown
|
||||
- [x] Storybook stories (client-side + server-side)
|
||||
- [ ] Implement `RemoteServerAdapter` with cursor + offset support (deferred - callbacks sufficient)
|
||||
- [ ] Loading states UI (deferred - handled externally)
|
||||
- [ ] Infinite scroll pattern (optional, deferred)
|
||||
|
||||
**Deliverable**: Pagination and remote data support
|
||||
**Deliverable**: Pagination and remote data support - COMPLETE ✅
|
||||
|
||||
### Phase 8: Advanced Features
|
||||
- [ ] Header grouping via TanStack Table `getHeaderGroups()`
|
||||
|
||||
122
src/Griddy/rendering/EditableCell.tsx
Normal file
122
src/Griddy/rendering/EditableCell.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import type { Cell } from '@tanstack/react-table'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { CheckboxEditor, DateEditor, NumericEditor, SelectEditor, TextEditor } from '../editors'
|
||||
import type { EditorConfig } from '../editors'
|
||||
import { getGriddyColumn } from '../core/columnMapper'
|
||||
import { useGriddyStore } from '../core/GriddyStore'
|
||||
|
||||
interface EditableCellProps<T> {
|
||||
cell: Cell<T, unknown>
|
||||
isEditing: boolean
|
||||
onCancelEdit: () => void
|
||||
onCommitEdit: (value: unknown) => void
|
||||
onMoveNext?: () => void
|
||||
onMovePrev?: () => void
|
||||
}
|
||||
|
||||
export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, onMoveNext, onMovePrev }: EditableCellProps<T>) {
|
||||
const griddyColumn = getGriddyColumn(cell.column)
|
||||
const editorConfig: EditorConfig = (griddyColumn as any)?.editorConfig ?? {}
|
||||
const customEditor = (griddyColumn as any)?.editor
|
||||
|
||||
const [value, setValue] = useState(cell.getValue())
|
||||
|
||||
useEffect(() => {
|
||||
setValue(cell.getValue())
|
||||
}, [cell])
|
||||
|
||||
const handleCommit = useCallback((newValue: unknown) => {
|
||||
setValue(newValue)
|
||||
onCommitEdit(newValue)
|
||||
}, [onCommitEdit])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setValue(cell.getValue())
|
||||
onCancelEdit()
|
||||
}, [cell, onCancelEdit])
|
||||
|
||||
if (!isEditing) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Custom editor from column definition
|
||||
if (customEditor) {
|
||||
const EditorComponent = customEditor as any
|
||||
return (
|
||||
<EditorComponent
|
||||
onCancel={handleCancel}
|
||||
onCommit={handleCommit}
|
||||
onMoveNext={onMoveNext}
|
||||
onMovePrev={onMovePrev}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Built-in editors based on editorConfig.type
|
||||
const editorType = editorConfig.type ?? 'text'
|
||||
|
||||
switch (editorType) {
|
||||
case 'number':
|
||||
return (
|
||||
<NumericEditor
|
||||
max={editorConfig.max}
|
||||
min={editorConfig.min}
|
||||
onCancel={handleCancel}
|
||||
onCommit={handleCommit}
|
||||
onMoveNext={onMoveNext}
|
||||
onMovePrev={onMovePrev}
|
||||
step={editorConfig.step}
|
||||
value={value as number}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<DateEditor
|
||||
onCancel={handleCancel}
|
||||
onCommit={handleCommit}
|
||||
onMoveNext={onMoveNext}
|
||||
onMovePrev={onMovePrev}
|
||||
value={value as Date | string}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<SelectEditor
|
||||
onCancel={handleCancel}
|
||||
onCommit={handleCommit}
|
||||
onMoveNext={onMoveNext}
|
||||
onMovePrev={onMovePrev}
|
||||
options={editorConfig.options ?? []}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<CheckboxEditor
|
||||
onCancel={handleCancel}
|
||||
onCommit={handleCommit}
|
||||
onMoveNext={onMoveNext}
|
||||
onMovePrev={onMovePrev}
|
||||
value={value as boolean}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<TextEditor
|
||||
onCancel={handleCancel}
|
||||
onCommit={handleCommit}
|
||||
onMoveNext={onMoveNext}
|
||||
onMovePrev={onMovePrev}
|
||||
value={value as string}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Checkbox } from '@mantine/core'
|
||||
import { type Cell, flexRender } from '@tanstack/react-table'
|
||||
|
||||
import { getGriddyColumn } from '../core/columnMapper'
|
||||
import { CSS, SELECTION_COLUMN_ID } from '../core/constants'
|
||||
import { useGriddyStore } from '../core/GriddyStore'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
import { EditableCell } from './EditableCell'
|
||||
|
||||
interface TableCellProps<T> {
|
||||
cell: Cell<T, unknown>
|
||||
@@ -10,18 +13,60 @@ interface TableCellProps<T> {
|
||||
|
||||
export function TableCell<T>({ cell }: TableCellProps<T>) {
|
||||
const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID
|
||||
const isEditing = useGriddyStore((s) => s.isEditing)
|
||||
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex)
|
||||
const focusedColumnId = useGriddyStore((s) => s.focusedColumnId)
|
||||
const setEditing = useGriddyStore((s) => s.setEditing)
|
||||
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn)
|
||||
const onEditCommit = useGriddyStore((s) => s.onEditCommit)
|
||||
|
||||
if (isSelectionCol) {
|
||||
return <RowCheckbox cell={cell} />
|
||||
}
|
||||
|
||||
const griddyColumn = getGriddyColumn(cell.column)
|
||||
const rowIndex = cell.row.index
|
||||
const columnId = cell.column.id
|
||||
const isEditable = (griddyColumn as any)?.editable ?? false
|
||||
const isFocusedCell = isEditing && focusedRowIndex === rowIndex && focusedColumnId === columnId
|
||||
|
||||
const handleCommit = async (value: unknown) => {
|
||||
if (onEditCommit) {
|
||||
await onEditCommit(cell.row.id, columnId, value)
|
||||
}
|
||||
setEditing(false)
|
||||
setFocusedColumn(null)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false)
|
||||
setFocusedColumn(null)
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (isEditable) {
|
||||
setEditing(true)
|
||||
setFocusedColumn(columnId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles[CSS.cell]}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
role="gridcell"
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{isFocusedCell && isEditable ? (
|
||||
<EditableCell
|
||||
cell={cell}
|
||||
isEditing={isFocusedCell}
|
||||
onCancelEdit={handleCancel}
|
||||
onCommitEdit={handleCommit}
|
||||
/>
|
||||
) : (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -239,3 +239,10 @@
|
||||
border-color: var(--griddy-focus-color);
|
||||
box-shadow: 0 0 0 2px rgba(34, 139, 230, 0.2);
|
||||
}
|
||||
|
||||
/* ─── Pagination ───────────────────────────────────────────────────────── */
|
||||
|
||||
.griddy-pagination {
|
||||
border-top: 1px solid var(--griddy-border-color);
|
||||
background: var(--griddy-header-bg);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user