diff --git a/llm/docs/resolvespec-js.md b/llm/docs/resolvespec-js.md new file mode 100644 index 0000000..9bd3657 --- /dev/null +++ b/llm/docs/resolvespec-js.md @@ -0,0 +1,263 @@ +# @warkypublic/resolvespec-js v1.0.0 + +TypeScript client library for ResolveSpec APIs. Supports body-based REST, header-based REST, and WebSocket protocols. Aligns with Go backend types. + +## Clients + +| Client | Protocol | Singleton Factory | +|---|---|---| +| `ResolveSpecClient` | REST (body JSON) | `getResolveSpecClient(config)` | +| `HeaderSpecClient` | REST (HTTP headers) | `getHeaderSpecClient(config)` | +| `WebSocketClient` | WebSocket | `getWebSocketClient(config)` | + +Singleton factories cache instances keyed by URL. + +## Config + +```typescript +interface ClientConfig { + baseUrl: string; + token?: string; // Bearer token +} + +interface WebSocketClientConfig { + url: string; + reconnect?: boolean; + reconnectInterval?: number; + maxReconnectAttempts?: number; + heartbeatInterval?: number; + debug?: boolean; +} +``` + +## ResolveSpecClient (Body-Based REST) + +```typescript +import { ResolveSpecClient, getResolveSpecClient } from '@warkypublic/resolvespec-js'; + +const client = new ResolveSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' }); + +// CRUD - signature: (schema, entity, id?, options?) +await client.read('public', 'users', undefined, { columns: ['id', 'name'], limit: 10 }); +await client.read('public', 'users', 42); // by ID +await client.create('public', 'users', { name: 'New' }); // create +await client.update('public', 'users', { name: 'Updated' }, 42); // update +await client.delete('public', 'users', 42); // delete +await client.getMetadata('public', 'users'); // table metadata +``` + +**Method signatures:** +- `read(schema, entity, id?: number|string|string[], options?): Promise>` +- `create(schema, entity, data: any|any[], options?): Promise>` +- `update(schema, entity, data: any|any[], id?: number|string|string[], options?): Promise>` +- `delete(schema, entity, id: number|string): Promise>` +- `getMetadata(schema, entity): Promise>` + +## HeaderSpecClient (Header-Based REST) + +```typescript +import { HeaderSpecClient, getHeaderSpecClient } from '@warkypublic/resolvespec-js'; + +const client = new HeaderSpecClient({ baseUrl: 'http://localhost:3000', token: 'your-token' }); + +// CRUD - HTTP methods: GET=read, POST=create, PUT=update, DELETE=delete +await client.read('public', 'users', undefined, { columns: ['id', 'name'], limit: 50 }); +await client.create('public', 'users', { name: 'New' }); +await client.update('public', 'users', '42', { name: 'Updated' }); +await client.delete('public', 'users', '42'); +``` + +**Method signatures:** +- `read(schema, entity, id?: string, options?): Promise>` +- `create(schema, entity, data, options?): Promise>` +- `update(schema, entity, id: string, data, options?): Promise>` +- `delete(schema, entity, id: string): Promise>` + +### Header Mapping + +| Header | Options Field | Format | +|---|---|---| +| `X-Select-Fields` | `columns` | comma-separated | +| `X-Not-Select-Fields` | `omit_columns` | comma-separated | +| `X-FieldFilter-{col}` | `filters` (eq, AND) | value | +| `X-SearchOp-{op}-{col}` | `filters` (AND) | value | +| `X-SearchOr-{op}-{col}` | `filters` (OR) | value | +| `X-Sort` | `sort` | `+col` asc, `-col` desc | +| `X-Limit` / `X-Offset` | `limit` / `offset` | number | +| `X-Cursor-Forward` | `cursor_forward` | string | +| `X-Cursor-Backward` | `cursor_backward` | string | +| `X-Preload` | `preload` | `Rel:col1,col2` pipe-separated | +| `X-Fetch-RowNumber` | `fetch_row_number` | string | +| `X-CQL-SEL-{col}` | `computedColumns` | expression | +| `X-Custom-SQL-W` | `customOperators` | SQL AND-joined | + +### Utility Functions + +```typescript +import { buildHeaders, encodeHeaderValue, decodeHeaderValue } from '@warkypublic/resolvespec-js'; + +buildHeaders({ columns: ['id', 'name'], limit: 10 }); +// => { 'X-Select-Fields': 'id,name', 'X-Limit': '10' } + +encodeHeaderValue('complex value'); // 'ZIP_...' (base64 encoded) +decodeHeaderValue(encoded); // original string +``` + +## WebSocketClient + +```typescript +import { WebSocketClient, getWebSocketClient } from '@warkypublic/resolvespec-js'; + +const ws = new WebSocketClient({ url: 'ws://localhost:8080/ws', reconnect: true, heartbeatInterval: 30000 }); +await ws.connect(); + +// CRUD +await ws.read('users', { schema: 'public', limit: 10, filters: [...], columns: [...] }); +await ws.create('users', { name: 'New' }, { schema: 'public' }); +await ws.update('users', '1', { name: 'Updated' }, { schema: 'public' }); +await ws.delete('users', '1', { schema: 'public' }); +await ws.meta('users', { schema: 'public' }); + +// Subscriptions +const subId = await ws.subscribe('users', (notification) => { ... }, { schema: 'public', filters: [...] }); +await ws.unsubscribe(subId); +ws.getSubscriptions(); + +// Connection +ws.getState(); // 'connecting' | 'connected' | 'disconnecting' | 'disconnected' | 'reconnecting' +ws.isConnected(); +ws.disconnect(); + +// Events +ws.on('connect', () => {}); +ws.on('disconnect', (event: CloseEvent) => {}); +ws.on('error', (error: Error) => {}); +ws.on('message', (message: WSMessage) => {}); +ws.on('stateChange', (state: ConnectionState) => {}); +ws.off('connect'); +``` + +## Options (Query Parameters) + +```typescript +interface Options { + columns?: string[]; + omit_columns?: string[]; + filters?: FilterOption[]; + sort?: SortOption[]; + limit?: number; + offset?: number; + preload?: PreloadOption[]; + customOperators?: CustomOperator[]; + computedColumns?: ComputedColumn[]; + parameters?: Parameter[]; + cursor_forward?: string; + cursor_backward?: string; + fetch_row_number?: string; +} +``` + +### FilterOption + +```typescript +interface FilterOption { + column: string; + operator: Operator | string; + value: any; + logic_operator?: 'AND' | 'OR'; +} + +// Operators: eq, neq, gt, gte, lt, lte, like, ilike, in, +// contains, startswith, endswith, between, +// between_inclusive, is_null, is_not_null +``` + +### SortOption + +```typescript +interface SortOption { + column: string; + direction: 'asc' | 'desc' | 'ASC' | 'DESC'; +} +``` + +### PreloadOption + +```typescript +interface PreloadOption { + relation: string; + table_name?: string; + columns?: string[]; + omit_columns?: string[]; + sort?: SortOption[]; + filters?: FilterOption[]; + where?: string; + limit?: number; + offset?: number; + updatable?: boolean; + computed_ql?: Record; + recursive?: boolean; + primary_key?: string; + related_key?: string; + foreign_key?: string; + recursive_child_key?: string; + sql_joins?: string[]; + join_aliases?: string[]; +} +``` + +### Other Types + +```typescript +interface ComputedColumn { name: string; expression: string; } +interface CustomOperator { name: string; sql: string; } +interface Parameter { name: string; value: string; sequence?: number; } +``` + +## Response Types + +```typescript +interface APIResponse { + success: boolean; + data: T; + metadata?: Metadata; + error?: APIError; +} + +interface APIError { code: string; message: string; details?: any; detail?: string; } +interface Metadata { total: number; count: number; filtered: number; limit: number; offset: number; row_number?: number; } + +interface TableMetadata { + schema: string; + table: string; + columns: Column[]; + relations: string[]; +} + +interface Column { name: string; type: string; is_nullable: boolean; is_primary: boolean; is_unique: boolean; has_index: boolean; } +``` + +## WebSocket Message Types + +```typescript +type MessageType = 'request' | 'response' | 'notification' | 'subscription' | 'error' | 'ping' | 'pong'; +type WSOperation = 'read' | 'create' | 'update' | 'delete' | 'subscribe' | 'unsubscribe' | 'meta'; + +interface WSMessage { + id?: string; type: MessageType; operation?: WSOperation; + schema?: string; entity?: string; record_id?: string; + data?: any; options?: WSOptions; subscription_id?: string; + success?: boolean; error?: WSErrorInfo; metadata?: Record; timestamp?: string; +} + +interface WSNotificationMessage { + type: 'notification'; operation: WSOperation; subscription_id: string; + schema?: string; entity: string; data: any; timestamp: string; +} +``` + +## Dependencies + +- Runtime: `uuid` +- Peer: none +- Node >= 18 diff --git a/llm/docs/zustandsyncstore.md b/llm/docs/zustandsyncstore.md new file mode 100644 index 0000000..fa339e3 --- /dev/null +++ b/llm/docs/zustandsyncstore.md @@ -0,0 +1,131 @@ +# @warkypublic/zustandsyncstore v1.0.0 + +React library providing synchronized Zustand stores with prop-based state management and persistence support. + +## Peer Dependencies + +- `react` >= 19.0.0 +- `zustand` >= 5.0.0 +- `use-sync-external-store` >= 1.4.0 + +## Runtime Dependencies + +- `@warkypublic/artemis-kit` + +## API + +Single export: `createSyncStore` + +```typescript +import { createSyncStore } from '@warkypublic/zustandsyncstore'; +``` + +### createSyncStore(createState?, useValue?) + +**Parameters:** +- `createState` (optional): Zustand `StateCreator` function +- `useValue` (optional): Custom hook receiving `{ useStore, useStoreApi } & TProps`, returns additional state to merge + +**Returns:** `SyncStoreReturn` containing: +- `Provider` — React context provider component +- `useStore` — Hook to access the store + +### Provider Props + +| Prop | Type | Description | +|---|---|---| +| `children` | `ReactNode` | Required | +| `firstSyncProps` | `string[]` | Props to sync only on first render | +| `persist` | `PersistOptions>` | Zustand persist config | +| `waitForSync` | `boolean` | Wait for sync before rendering children | +| `fallback` | `ReactNode` | Shown while waiting for sync | +| `...TProps` | `TProps` | Custom props synced to store state | + +### useStore Hook + +```typescript +const state = useStore(); // entire state (TState & TProps) +const count = useStore(state => state.count); // with selector +const count = useStore(state => state.count, (a, b) => a === b); // with equality fn +``` + +## Usage + +### Basic + +```tsx +interface MyState { count: number; increment: () => void; } +interface MyProps { initialCount: number; } + +const { Provider, useStore } = createSyncStore( + (set) => ({ + count: 0, + increment: () => set((state) => ({ count: state.count + 1 })), + }) +); + +function Counter() { + const { count, increment } = useStore(); + return ; +} + +function App() { + return ( + + + + ); +} +``` + +### With Custom Hook Logic + +```tsx +const { Provider, useStore } = createSyncStore( + (set) => ({ count: 0, increment: () => set((s) => ({ count: s.count + 1 })) }), + ({ useStore, useStoreApi, initialCount }) => { + const currentCount = useStore(state => state.count); + return { computedValue: initialCount * 2 }; + } +); +``` + +### With Persistence + +```tsx + + + +``` + +### Selective Prop Syncing + +```tsx + + + +``` + +## Internal Types + +```typescript +type LocalUseStore = TState & TProps; + +// Store state includes a $sync method for internal prop syncing +type InternalStoreState = TState & TProps & { + $sync: (props: TProps) => void; +}; + +type SyncStoreReturn = { + Provider: (props: { children: ReactNode } & { + firstSyncProps?: string[]; + persist?: PersistOptions>; + waitForSync?: boolean; + fallback?: ReactNode; + } & TProps) => React.ReactNode; + useStore: { + (): LocalUseStore; + (selector: (state: LocalUseStore) => U, equalityFn?: (a: U, b: U) => boolean): U; + }; +}; +``` diff --git a/package.json b/package.json index 0345621..6db6ceb 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "@tanstack/react-table": "^8.21.3", "@warkypublic/artemis-kit": "^1.0.10", "@warkypublic/zustandsyncstore": "^1.0.0", + "@warkypublic/resolvespec-js": "^1.0.1", "idb-keyval": "^6.2.2", "immer": "^10.1.3", "react": ">= 19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9338e49..549403f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@warkypublic/artemis-kit': specifier: ^1.0.10 version: 1.0.10 + '@warkypublic/resolvespec-js': + specifier: ^1.0.1 + version: 1.0.1 '@warkypublic/zustandsyncstore': specifier: ^1.0.0 version: 1.0.0(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))(zustand@5.0.8(@types/react@19.2.14)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))) @@ -1509,6 +1512,10 @@ packages: resolution: {integrity: sha512-qIgjcWqLyYfoKDUYt3Gm7PVe2S4AdjA46J1jPIff1p6wUP5WsHA8UfZq7pEdP6YNxqavv+h84oe1+HsJOoU6jQ==} engines: {node: '>=14.16'} + '@warkypublic/resolvespec-js@1.0.1': + resolution: {integrity: sha512-uXP1HouxpOKXfwE6qpy0gCcrMPIgjDT53aVGkfork4QejRSunbKWSKKawW2nIm7RnyFhSjPILMXcnT5xUiXOew==} + engines: {node: '>=18'} + '@warkypublic/zustandsyncstore@1.0.0': resolution: {integrity: sha512-hvd4Xrn5btEPjJwNgX52ONoZHnAJdF3NcoTK3GJMVrullcZ+tS2W/SCWIa8vTiYBEhdNyavxsNDZT2x/C9GmVg==} peerDependencies: @@ -3819,6 +3826,10 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + uuid@13.0.0: + resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} + hasBin: true + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -5516,6 +5527,10 @@ snapshots: semver: 7.7.3 uuid: 11.1.0 + '@warkypublic/resolvespec-js@1.0.1': + dependencies: + uuid: 13.0.0 + '@warkypublic/zustandsyncstore@1.0.0(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))(zustand@5.0.8(@types/react@19.2.14)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)))': dependencies: '@warkypublic/artemis-kit': 1.0.10 @@ -8059,6 +8074,8 @@ snapshots: uuid@11.1.0: {} + uuid@13.0.0: {} + vary@1.1.2: {} vite-plugin-dts@4.5.4(@types/node@25.2.3)(rollup@4.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.6))): diff --git a/src/Griddy/Griddy.stories.tsx b/src/Griddy/Griddy.stories.tsx index a0de207..acf19ec 100644 --- a/src/Griddy/Griddy.stories.tsx +++ b/src/Griddy/Griddy.stories.tsx @@ -1,33 +1,84 @@ -import type { Meta, StoryObj } from '@storybook/react-vite' -import type { ColumnFiltersState, RowSelectionState, SortingState } from '@tanstack/react-table' +import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ColumnFiltersState, RowSelectionState, SortingState } from '@tanstack/react-table'; -import { Box } from '@mantine/core' -import { useEffect, useRef, useState } from 'react' +import { Box } from '@mantine/core'; +import { useEffect, useRef, useState } from 'react'; -import type { GriddyColumn, GriddyProps } from './core/types' +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' +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 ───────────────────────────────────────────────────────────── interface Person { - active: boolean - age: number - department: string - email: string - firstName: string - id: number - lastName: string - salary: number - startDate: string + active: boolean; + age: number; + department: string; + email: string; + firstName: string; + id: number; + lastName: string; + salary: number; + startDate: string; } -const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance', 'Design', 'Legal', 'Support'] -const firstNames = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack', 'Karen', 'Leo', 'Mia', 'Nick', 'Olivia', 'Paul', 'Quinn', 'Rose', 'Sam', 'Tina'] -const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Garcia', 'Miller', 'Davis', 'Martinez', 'Anderson', 'Taylor', 'Thomas', 'Hernandez', 'Moore', 'Martin', 'Jackson', 'Thompson', 'White', 'Lopez', 'Lee'] +const departments = [ + 'Engineering', + 'Marketing', + 'Sales', + 'HR', + 'Finance', + 'Design', + 'Legal', + 'Support', +]; +const firstNames = [ + 'Alice', + 'Bob', + 'Charlie', + 'Diana', + 'Eve', + 'Frank', + 'Grace', + 'Henry', + 'Ivy', + 'Jack', + 'Karen', + 'Leo', + 'Mia', + 'Nick', + 'Olivia', + 'Paul', + 'Quinn', + 'Rose', + 'Sam', + 'Tina', +]; +const lastNames = [ + 'Smith', + 'Johnson', + 'Williams', + 'Brown', + 'Jones', + 'Garcia', + 'Miller', + 'Davis', + 'Martinez', + 'Anderson', + 'Taylor', + 'Thomas', + 'Hernandez', + 'Moore', + 'Martin', + 'Jackson', + 'Thompson', + 'White', + 'Lopez', + 'Lee', +]; function generateData(count: number): Person[] { return Array.from({ length: count }, (_, i) => ({ @@ -38,13 +89,13 @@ function generateData(count: number): Person[] { firstName: firstNames[i % firstNames.length], id: i + 1, lastName: lastNames[i % lastNames.length], - salary: 40000 + (i * 1234) % 80000, + salary: 40000 + ((i * 1234) % 80000), startDate: `202${i % 5}-${String((i % 12) + 1).padStart(2, '0')}-${String((i % 28) + 1).padStart(2, '0')}`, - })) + })); } -const smallData = generateData(20) -const largeData = generateData(10_000) +const smallData = generateData(20); +const largeData = generateData(10_000); // ─── Column Definitions ────────────────────────────────────────────────────── @@ -55,10 +106,22 @@ const columns: GriddyColumn[] = [ { 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: (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 }, -] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, +]; // ─── Wrapper ───────────────────────────────────────────────────────────────── @@ -67,7 +130,7 @@ function GriddyWrapper(props: GriddyProps) { - ) + ); } // ─── Meta ──────────────────────────────────────────────────────────────────── @@ -86,15 +149,15 @@ const meta = { }, tags: ['autodocs'], title: 'Components/Griddy', -} satisfies Meta +} satisfies Meta; -export default meta -type Story = StoryObj +export default meta; +type Story = StoryObj; // ─── Stories ───────────────────────────────────────────────────────────────── /** Basic table with 20 rows, sorting enabled by default */ -export const Basic: Story = {} +export const Basic: Story = {}; /** 10,000 rows with virtualization */ export const LargeDataset: Story = { @@ -102,12 +165,12 @@ export const LargeDataset: Story = { data: largeData, height: 600, }, -} +}; /** Single row selection mode - click or Space to select */ export const SingleSelection: Story = { render: () => { - const [selection, setSelection] = useState({}) + const [selection, setSelection] = useState({}); return ( @@ -124,14 +187,14 @@ export const SingleSelection: Story = { Selected: {JSON.stringify(selection)} - ) + ); }, -} +}; /** Multi row selection - Shift+Arrow to extend, Ctrl+A to select all, Space to toggle */ export const MultiSelection: Story = { render: () => { - const [selection, setSelection] = useState({}) + const [selection, setSelection] = useState({}); return ( @@ -145,17 +208,18 @@ export const MultiSelection: Story = { selection={{ mode: 'multi', selectOnClick: true, showCheckbox: true }} /> - Selected ({Object.keys(selection).filter(k => selection[k]).length} rows): {JSON.stringify(selection)} + Selected ({Object.keys(selection).filter((k) => selection[k]).length} rows):{' '} + {JSON.stringify(selection)} - ) + ); }, -} +}; /** Multi-select with 10k rows - test keyboard navigation + selection at scale */ export const LargeMultiSelection: Story = { render: () => { - const [selection, setSelection] = useState({}) + const [selection, setSelection] = useState({}); return ( @@ -169,28 +233,38 @@ export const LargeMultiSelection: Story = { selection={{ mode: 'multi', showCheckbox: true }} /> - Selected: {Object.keys(selection).filter(k => selection[k]).length} / {largeData.length} rows + Selected: {Object.keys(selection).filter((k) => selection[k]).length} / {largeData.length}{' '} + rows - ) + ); }, -} +}; /** Search enabled - Ctrl+F to open search overlay */ export const WithSearch: Story = { args: { search: { enabled: true, highlightMatches: true, placeholder: 'Search people...' }, }, -} +}; /** Keyboard navigation guide story */ export const KeyboardNavigation: Story = { render: () => { - const [selection, setSelection] = useState({}) + const [selection, setSelection] = useState({}); return ( - + Keyboard Shortcuts (click on the grid first to focus it) @@ -206,7 +280,16 @@ export const KeyboardNavigation: Story = { ['Escape', 'Clear selection / close search'], ].map(([key, desc]) => ( - + ))} @@ -224,17 +307,17 @@ export const KeyboardNavigation: Story = { selection={{ mode: 'multi', showCheckbox: true }} /> - Selected: {Object.keys(selection).filter(k => selection[k]).length} rows + Selected: {Object.keys(selection).filter((k) => selection[k]).length} rows - ) + ); }, -} +}; /** Text filtering with operators like contains, equals, starts with, etc. */ export const WithTextFiltering: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -258,11 +341,29 @@ export const WithTextFiltering: Story = { }, { 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: '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 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ]; return ( @@ -274,19 +375,29 @@ export const WithTextFiltering: Story = { height={500} onColumnFiltersChange={setFilters} /> - + Active Filters:
{JSON.stringify(filters, null, 2)}
- ) + ); }, -} +}; /** Number filtering with operators like equals, between, greater than, etc. */ export const WithNumberFiltering: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -302,7 +413,13 @@ export const WithNumberFiltering: Story = { sortable: true, width: 70, }, - { accessor: 'department', header: 'Department', id: 'department', sortable: true, width: 130 }, + { + accessor: 'department', + header: 'Department', + id: 'department', + sortable: true, + width: 130, + }, { accessor: (row) => row.salary, filterable: true, @@ -313,8 +430,14 @@ export const WithNumberFiltering: Story = { 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 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ]; return ( @@ -326,19 +449,29 @@ export const WithNumberFiltering: Story = { height={500} onColumnFiltersChange={setFilters} /> - + Active Filters:
{JSON.stringify(filters, null, 2)}
- ) + ); }, -} +}; /** Enum (multi-select) filtering with includes/excludes operators */ export const WithEnumFiltering: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -358,10 +491,22 @@ export const WithEnumFiltering: Story = { sortable: true, width: 130, }, - { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { + 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 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ]; return ( @@ -373,19 +518,29 @@ export const WithEnumFiltering: Story = { height={500} onColumnFiltersChange={setFilters} /> - + Active Filters:
{JSON.stringify(filters, null, 2)}
- ) + ); }, -} +}; /** Boolean filtering with true/false/all operators */ export const WithBooleanFiltering: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -393,8 +548,20 @@ export const WithBooleanFiltering: Story = { { 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: '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: 'active', @@ -405,7 +572,7 @@ export const WithBooleanFiltering: Story = { sortable: true, width: 80, }, - ] + ]; return ( @@ -417,19 +584,29 @@ export const WithBooleanFiltering: Story = { height={500} onColumnFiltersChange={setFilters} /> - + Active Filters:
{JSON.stringify(filters, null, 2)}
- ) + ); }, -} +}; /** Date filtering with operators like is, isBefore, isAfter, isBetween */ export const WithDateFiltering: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -437,8 +614,20 @@ export const WithDateFiltering: Story = { { 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: '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, @@ -448,8 +637,14 @@ export const WithDateFiltering: Story = { sortable: true, width: 120, }, - { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', sortable: true, width: 80 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ]; return ( @@ -461,19 +656,29 @@ export const WithDateFiltering: Story = { height={500} onColumnFiltersChange={setFilters} /> - + Active Filters:
{JSON.stringify(filters, null, 2)}
- ) + ); }, -} +}; /** Combined filtering - all filter types together (text, number, enum, boolean, date) */ export const WithAllFilterTypes: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -517,7 +722,13 @@ export const WithAllFilterTypes: Story = { sortable: true, width: 130, }, - { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { + accessor: (row) => `$${row.salary.toLocaleString()}`, + header: 'Salary', + id: 'salary', + sortable: true, + width: 110, + }, { accessor: 'startDate', filterable: true, @@ -536,7 +747,7 @@ export const WithAllFilterTypes: Story = { sortable: true, width: 80, }, - ] + ]; return ( @@ -548,19 +759,29 @@ export const WithAllFilterTypes: Story = { height={500} onColumnFiltersChange={setFilters} /> - + Active Filters (AND logic - all must match):
{JSON.stringify(filters, null, 2)}
- ) + ); }, -} +}; /** Large dataset with filtering and sorting */ export const LargeDatasetWithFiltering: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -604,7 +825,13 @@ export const LargeDatasetWithFiltering: Story = { sortable: true, width: 130, }, - { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { + accessor: (row) => `$${row.salary.toLocaleString()}`, + header: 'Salary', + id: 'salary', + sortable: true, + width: 110, + }, { accessor: 'startDate', filterable: true, @@ -623,7 +850,7 @@ export const LargeDatasetWithFiltering: Story = { sortable: true, width: 80, }, - ] + ]; return ( @@ -635,73 +862,85 @@ export const LargeDatasetWithFiltering: Story = { height={600} onColumnFiltersChange={setFilters} /> - + Active Filters:
{JSON.stringify(filters, null, 2)}
- ) + ); }, -} +}; /** Server-side filtering and sorting - data fetching handled externally */ export const ServerSideFilteringSorting: Story = { render: () => { - const [filters, setFilters] = useState([]) - const [sorting, setSorting] = useState([]) - const [serverData, setServerData] = useState([]) - const [totalCount, setTotalCount] = useState(0) - const [isLoading, setIsLoading] = useState(false) + const [filters, setFilters] = useState([]); + const [sorting, setSorting] = useState([]); + const [serverData, setServerData] = useState([]); + const [totalCount, setTotalCount] = useState(0); + const [isLoading, setIsLoading] = useState(false); // Simulate server-side data fetching useEffect(() => { const fetchData = async () => { - setIsLoading(true) + setIsLoading(true); // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 300)) + await new Promise((resolve) => setTimeout(resolve, 300)); - let filteredData = [...largeData] + let filteredData = [...largeData]; // Apply filters (simulating server-side filtering) filters.forEach((filter) => { - const filterValue = filter.value as any + const filterValue = filter.value as any; if (filterValue?.operator && filterValue?.value !== undefined) { - filteredData = filteredData.filter(row => { - const cellValue = row[filter.id as keyof Person] + 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()) + return String(cellValue) + .toLowerCase() + .includes(String(filterValue.value).toLowerCase()); case 'equals': - return cellValue === filterValue.value + return cellValue === filterValue.value; case 'greaterThan': - return Number(cellValue) > Number(filterValue.value) + return Number(cellValue) > Number(filterValue.value); case 'lessThan': - return Number(cellValue) < Number(filterValue.value) + return Number(cellValue) < Number(filterValue.value); default: - return true + return true; } - }) + }); } - }) + }); // Apply sorting (simulating server-side sorting) if (sorting.length > 0) { - const sort = sorting[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 - }) + 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) - } + setServerData(filteredData); + setTotalCount(filteredData.length); + setIsLoading(false); + }; - fetchData() - }, [filters, sorting]) + fetchData(); + }, [filters, sorting]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -733,16 +972,44 @@ export const ServerSideFilteringSorting: Story = { 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: '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 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ]; return ( - - Server-Side Mode: Filtering and sorting are handled by simulated server. Data fetches on filter/sort change. + + Server-Side Mode: Filtering and sorting are handled by simulated server. + Data fetches on filter/sort change. columnFilters={filters} @@ -757,33 +1024,41 @@ export const ServerSideFilteringSorting: Story = { onSortingChange={setSorting} sorting={sorting} /> - + Server State:
Loading: {isLoading ? 'true' : 'false'}
Total Count: {totalCount}
Displayed Rows: {serverData.length}
- Active Filters: + Active Filters:
{JSON.stringify(filters, null, 2)}
- Active Sorting: + Active Sorting:
{JSON.stringify(sorting, null, 2)}
- ) + ); }, -} +}; /** Inline editing - double-click cell or Ctrl+E/Enter to edit */ export const WithInlineEditing: Story = { render: () => { - const [data, setData] = useState(smallData.map(p => ({ ...p }))) + const [data, setData] = useState(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 - )) - } + setData((prev) => + prev.map((row) => (String(row.id) === rowId ? { ...row, [columnId]: value } : row)) + ); + }; const editColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -819,7 +1094,7 @@ export const WithInlineEditing: Story = { accessor: 'department', editable: true, editorConfig: { - options: departments.map(d => ({ label: d, value: d })), + options: departments.map((d) => ({ label: d, value: d })), type: 'select', }, header: 'Department', @@ -827,7 +1102,13 @@ export const WithInlineEditing: Story = { sortable: true, width: 130, }, - { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', sortable: true, width: 110 }, + { + 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', @@ -838,14 +1119,25 @@ export const WithInlineEditing: Story = { sortable: true, width: 80, }, - ] + ]; return ( - - Editing Mode: Double-click any editable cell (First Name, Last Name, Age, Department, Active) or press Ctrl+E/Enter when a row is focused. + + Editing Mode: Double-click any editable cell (First Name, Last Name, Age, + Department, Active) or press Ctrl+E/Enter when a row is focused.
- Keyboard: Enter commits, Escape cancels, Tab moves to next editable cell + Keyboard: Enter commits, Escape cancels, Tab moves to next editable + cell
@@ -855,14 +1147,26 @@ export const WithInlineEditing: Story = { height={500} onEditCommit={handleEditCommit} /> - + Modified Data (first 3 rows): -
{JSON.stringify(data.slice(0, 3), null, 2)}
+
+            {JSON.stringify(data.slice(0, 3), null, 2)}
+          
- ) + ); }, -} +}; /** Client-side pagination - paginate large datasets in memory */ export const WithClientSidePagination: Story = { @@ -873,16 +1177,44 @@ export const WithClientSidePagination: Story = { { 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: '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 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ]; return ( - - Client-Side Pagination: 10,000 rows paginated in memory. Fast page switching, all data loaded upfront. + + Client-Side Pagination: 10,000 rows paginated in memory. Fast page + switching, all data loaded upfront. columns={paginationColumns} @@ -897,36 +1229,36 @@ export const WithClientSidePagination: Story = { }} /> - ) + ); }, -} +}; /** Server-side pagination - fetch pages from server */ export const WithServerSidePagination: Story = { render: () => { - const [serverData, setServerData] = useState([]) - const [pageIndex, setPageIndex] = useState(0) - const [pageSize, setPageSize] = useState(25) - const [totalCount, setTotalCount] = useState(0) - const [isLoading, setIsLoading] = useState(false) + const [serverData, setServerData] = useState([]); + 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)) + setIsLoading(true); + await new Promise((resolve) => setTimeout(resolve, 300)); - const start = pageIndex * pageSize - const end = start + pageSize - const page = largeData.slice(start, end) + const start = pageIndex * pageSize; + const end = start + pageSize; + const page = largeData.slice(start, end); - setServerData(page) - setTotalCount(largeData.length) - setIsLoading(false) - } + setServerData(page); + setTotalCount(largeData.length); + setIsLoading(false); + }; - fetchPage() - }, [pageIndex, pageSize]) + fetchPage(); + }, [pageIndex, pageSize]); const paginationColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -934,16 +1266,44 @@ export const WithServerSidePagination: Story = { { 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: '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 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ]; return ( - - Server-Side Pagination: Data fetched per page from simulated server. Only current page loaded. + + Server-Side Pagination: Data fetched per page from simulated server. Only + current page loaded. columns={paginationColumns} @@ -955,15 +1315,25 @@ export const WithServerSidePagination: Story = { enabled: true, onPageChange: (page) => setPageIndex(page), onPageSizeChange: (size) => { - setPageSize(size) - setPageIndex(0) + setPageSize(size); + setPageIndex(0); }, pageSize, pageSizeOptions: [10, 25, 50, 100], type: 'offset', }} /> - + Server State:
Loading: {isLoading ? 'true' : 'false'}
Current Page: {pageIndex + 1}
@@ -972,9 +1342,9 @@ export const WithServerSidePagination: Story = {
Displayed Rows: {serverData.length}
- ) + ); }, -} +}; /** Column visibility and CSV export */ export const WithToolbar: Story = { @@ -985,15 +1355,42 @@ export const WithToolbar: Story = { { 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: '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 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, + ]; return ( - + Toolbar Features:
• Click the columns icon to show/hide columns @@ -1011,16 +1408,16 @@ export const WithToolbar: Story = { showToolbar /> - ) + ); }, -} +}; /** Infinite scroll - load data progressively as user scrolls */ export const WithInfiniteScroll: Story = { render: () => { - const [data, setData] = useState(() => generateData(50)) - const [isLoading, setIsLoading] = useState(false) - const [hasMore, setHasMore] = useState(true) + const [data, setData] = useState(() => generateData(50)); + const [isLoading, setIsLoading] = useState(false); + const [hasMore, setHasMore] = useState(true); const infiniteColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', width: 60 }, @@ -1029,33 +1426,43 @@ export const WithInfiniteScroll: Story = { { accessor: 'email', header: 'Email', id: 'email', width: 240 }, { accessor: 'age', header: 'Age', id: 'age', width: 70 }, { accessor: 'department', header: 'Department', id: 'department', width: 130 }, - ] + ]; const loadMore = async () => { if (data.length >= 200) { - setHasMore(false) - return + setHasMore(false); + return; } - setIsLoading(true) + setIsLoading(true); // Simulate API delay - await new Promise(resolve => setTimeout(resolve, 1000)) + await new Promise((resolve) => setTimeout(resolve, 1000)); // Generate next batch of data - const nextBatch = generateData(50).map(person => ({ + const nextBatch = generateData(50).map((person) => ({ ...person, id: person.id + data.length, - })) + })); - setData(prev => [...prev, ...nextBatch]) - setIsLoading(false) - } + setData((prev) => [...prev, ...nextBatch]); + setIsLoading(false); + }; return ( - - Infinite Scroll: Data loads automatically as you scroll down. Current: {data.length} rows + + Infinite Scroll: Data loads automatically as you scroll down. Current:{' '} + {data.length} rows {!hasMore && ' (all data loaded)'} @@ -1072,9 +1479,9 @@ export const WithInfiniteScroll: Story = { }} /> - ) + ); }, -} +}; /** Column pinning - pin columns to left or right */ export const WithColumnPinning: Story = { @@ -1086,15 +1493,36 @@ export const WithColumnPinning: Story = { { accessor: 'email', header: 'Email', id: 'email', width: 300 }, { accessor: 'age', header: 'Age', id: 'age', width: 70 }, { accessor: 'department', header: 'Department', id: 'department', width: 150 }, - { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', width: 110 }, + { + accessor: (row) => `$${row.salary.toLocaleString()}`, + header: 'Salary', + id: 'salary', + width: 110, + }, { accessor: 'startDate', header: 'Start Date', id: 'startDate', width: 120 }, - { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', pinned: 'right', width: 80 }, - ] + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + pinned: 'right', + width: 80, + }, + ]; return ( - - Column Pinning: ID and First Name are pinned left, Active is pinned right. Scroll horizontally to see pinned columns stay in place. + + Column Pinning: ID and First Name are pinned left, Active is pinned + right. Scroll horizontally to see pinned columns stay in place. columns={pinnedColumns} @@ -1103,29 +1531,69 @@ export const WithColumnPinning: Story = { height={500} /> - ) + ); }, -} +}; /** Header grouping - multi-level column headers */ export const WithHeaderGrouping: Story = { render: () => { const groupedColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', width: 60 }, - { accessor: 'firstName', header: 'First Name', headerGroup: 'Personal Info', id: 'firstName', width: 120 }, - { accessor: 'lastName', header: 'Last Name', headerGroup: 'Personal Info', id: 'lastName', width: 120 }, + { + accessor: 'firstName', + header: 'First Name', + headerGroup: 'Personal Info', + id: 'firstName', + width: 120, + }, + { + accessor: 'lastName', + header: 'Last Name', + headerGroup: 'Personal Info', + id: 'lastName', + width: 120, + }, { accessor: 'age', header: 'Age', headerGroup: 'Personal Info', id: 'age', width: 70 }, { accessor: 'email', header: 'Email', headerGroup: 'Contact', id: 'email', width: 240 }, - { accessor: 'department', header: 'Department', headerGroup: 'Employment', id: 'department', width: 130 }, - { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', headerGroup: 'Employment', id: 'salary', width: 110 }, - { accessor: 'startDate', header: 'Start Date', headerGroup: 'Employment', id: 'startDate', width: 120 }, - { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', width: 80 }, - ] + { + accessor: 'department', + header: 'Department', + headerGroup: 'Employment', + id: 'department', + width: 130, + }, + { + accessor: (row) => `$${row.salary.toLocaleString()}`, + header: 'Salary', + headerGroup: 'Employment', + id: 'salary', + width: 110, + }, + { + accessor: 'startDate', + header: 'Start Date', + headerGroup: 'Employment', + id: 'startDate', + width: 120, + }, + { accessor: (row) => (row.active ? 'Yes' : 'No'), header: 'Active', id: 'active', width: 80 }, + ]; return ( - - Header Grouping: Columns are grouped under "Personal Info", "Contact", and "Employment" headers. + + Header Grouping: Columns are grouped under "Personal Info", + "Contact", and "Employment" headers. columns={groupedColumns} @@ -1134,25 +1602,48 @@ export const WithHeaderGrouping: Story = { height={500} /> - ) + ); }, -} +}; /** Data grouping - group rows by column values */ export const WithDataGrouping: Story = { render: () => { const dataGroupColumns: GriddyColumn[] = [ - { accessor: 'department', aggregationFn: 'count', groupable: true, header: 'Department', id: 'department', width: 150 }, + { + accessor: 'department', + aggregationFn: 'count', + groupable: true, + header: 'Department', + id: 'department', + width: 150, + }, { accessor: 'firstName', header: 'First Name', id: 'firstName', width: 120 }, { accessor: 'lastName', header: 'Last Name', id: 'lastName', width: 120 }, { accessor: 'age', aggregationFn: 'mean', header: 'Age', id: 'age', width: 70 }, - { accessor: (row) => `$${row.salary.toLocaleString()}`, aggregationFn: 'sum', header: 'Salary', id: 'salary', width: 120 }, - ] + { + accessor: (row) => `$${row.salary.toLocaleString()}`, + aggregationFn: 'sum', + header: 'Salary', + id: 'salary', + width: 120, + }, + ]; return ( - - Data Grouping: Data is grouped by Department. Click the expand/collapse button to show/hide group members. Aggregated values shown in parentheses. + + Data Grouping: Data is grouped by Department. Click the expand/collapse + button to show/hide group members. Aggregated values shown in parentheses. columns={dataGroupColumns} @@ -1162,9 +1653,9 @@ export const WithDataGrouping: Story = { height={500} /> - ) + ); }, -} +}; /** Column reordering - drag and drop columns */ export const WithColumnReordering: Story = { @@ -1176,15 +1667,30 @@ export const WithColumnReordering: Story = { { accessor: 'email', header: 'Email', id: 'email', width: 240 }, { accessor: 'age', header: 'Age', id: 'age', width: 70 }, { accessor: 'department', header: 'Department', id: 'department', width: 130 }, - { accessor: (row) => `$${row.salary.toLocaleString()}`, header: 'Salary', id: 'salary', width: 110 }, + { + accessor: (row) => `$${row.salary.toLocaleString()}`, + header: 'Salary', + id: 'salary', + width: 110, + }, { accessor: 'startDate', header: 'Start Date', id: 'startDate', width: 120 }, - { accessor: (row) => row.active ? 'Yes' : 'No', header: 'Active', id: 'active', width: 80 }, - ] + { accessor: (row) => (row.active ? 'Yes' : 'No'), header: 'Active', id: 'active', width: 80 }, + ]; return ( - - Column Reordering: Drag column headers to reorder them. Pinned columns and the selection column cannot be reordered. + + Column Reordering: Drag column headers to reorder them. Pinned columns + and the selection column cannot be reordered. columns={reorderColumns} @@ -1194,22 +1700,22 @@ export const WithColumnReordering: Story = { selection={{ mode: 'multi' }} /> - ) + ); }, -} +}; /** Error boundary - catches render errors and shows fallback UI */ export const WithErrorBoundary: Story = { render: () => { - const [shouldError, setShouldError] = useState(false) + 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 shouldErrorRef = useRef(false); + shouldErrorRef.current = shouldError; const ErrorColumn = () => { - if (shouldErrorRef.current) throw new Error('Intentional render error for testing') - return OK - } + if (shouldErrorRef.current) throw new Error('Intentional render error for testing'); + return OK; + }; const errorColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', width: 60 }, @@ -1221,12 +1727,22 @@ export const WithErrorBoundary: Story = { renderer: () => , width: 100, }, - ] + ]; return ( - - Error Boundary: Click the button below to trigger an error. The error boundary catches it and shows a retry button. + + Error Boundary: Click the button below to trigger an error. The error + boundary catches it and shows a retry button.
+
@@ -1285,19 +1816,19 @@ export const WithLoadingStates: Story = { 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[] + department: string; + id: number; + name: string; + performance: number; + trend: number[]; } const rendererData: RendererData[] = Array.from({ length: 15 }, (_, i) => ({ @@ -1306,7 +1837,7 @@ export const WithCustomRenderers: Story = { 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 }, @@ -1345,12 +1876,22 @@ export const WithCustomRenderers: Story = { rendererMeta: { color: '#228be6', height: 24, width: 80 }, width: 120, }, - ] + ]; return ( - - Custom Renderers: Badge (department), Progress Bar (performance), Sparkline (trend). + + Custom Renderers: Badge (department), Progress Bar (performance), + Sparkline (trend). columns={rendererColumns} @@ -1359,14 +1900,14 @@ export const WithCustomRenderers: Story = { height={500} /> - ) + ); }, -} +}; /** Quick filters - checkbox list of unique values in filter popover */ export const WithQuickFilters: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const quickFilterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -1382,12 +1923,22 @@ export const WithQuickFilters: Story = { 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. + + Quick Filters: Click the filter icon on "Department" to see a + checkbox list of unique values. columnFilters={filters} @@ -1398,17 +1949,27 @@ export const WithQuickFilters: Story = { 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. + + Advanced Search: Use the search panel to add multiple conditions with + AND/OR/NOT operators. advancedSearch={{ enabled: true }} @@ -1418,14 +1979,14 @@ export const WithAdvancedSearch: Story = { height={500} /> - ) + ); }, -} +}; /** Filter presets - save and load filter configurations */ export const WithFilterPresets: Story = { render: () => { - const [filters, setFilters] = useState([]) + const [filters, setFilters] = useState([]); const filterColumns: GriddyColumn[] = [ { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, @@ -1460,12 +2021,22 @@ export const WithFilterPresets: Story = { sortable: true, width: 70, }, - ] + ]; return ( - - Filter Presets: Apply filters, then click the bookmark icon in the toolbar to save/load presets. Persists to localStorage. + + Filter Presets: Apply filters, then click the bookmark icon in the + toolbar to save/load presets. Persists to localStorage. columnFilters={filters} @@ -1479,17 +2050,27 @@ export const WithFilterPresets: Story = { 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. + + Search History: Press Ctrl+F to search. Previous searches are saved and + shown when you focus the search input. columns={columns} @@ -1500,6 +2081,6 @@ export const WithSearchHistory: Story = { search={{ enabled: true, placeholder: 'Search (history enabled)...' }} /> - ) + ); }, -} +}; diff --git a/src/Griddy/adapters/Adapters.stories.tsx b/src/Griddy/adapters/Adapters.stories.tsx new file mode 100644 index 0000000..8c7b315 --- /dev/null +++ b/src/Griddy/adapters/Adapters.stories.tsx @@ -0,0 +1,393 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { Box } from '@mantine/core'; +import { useRef } from 'react'; + +import type { GriddyColumn } from '../core/types'; +import type { AdapterConfig, AdapterRef } from './types'; + +import { Griddy } from '../core/Griddy'; +import { HeaderSpecAdapter } from './HeaderSpecAdapter'; +import { ResolveSpecAdapter } from './ResolveSpecAdapter'; + +// ─── Sample Column Definitions ────────────────────────────────────────────── + +interface User { + active: boolean; + department: string; + email: string; + id: number; + name: string; +} + +const columns: GriddyColumn[] = [ + { accessor: 'id', header: 'ID', id: 'id', sortable: true, width: 60 }, + { accessor: 'name', header: 'Name', id: 'name', sortable: true, width: 150 }, + { accessor: 'email', header: 'Email', id: 'email', sortable: true, width: 240 }, + { + accessor: 'department', + filterable: true, + filterConfig: { + enumOptions: ['Engineering', 'Marketing', 'Sales', 'HR'].map((d) => ({ label: d, value: d })), + type: 'enum', + }, + header: 'Department', + id: 'department', + sortable: true, + width: 130, + }, + { + accessor: (row) => (row.active ? 'Yes' : 'No'), + header: 'Active', + id: 'active', + sortable: true, + width: 80, + }, +]; + +// ─── Wrapper for ResolveSpecAdapter story ─────────────────────────────────── + +function HeaderSpecAdapterStory(props: AdapterConfig) { + const adapterRef = useRef(null); + + return ( + + + HeaderSpecAdapter: Connects Griddy to a HeaderSpec API. Same auto-wiring as + ResolveSpecAdapter but uses HeaderSpecClient. +
+ +
+
+ + columns={columns} + data={[]} + getRowId={(row) => String(row.id)} + height={500} + manualFiltering + manualSorting + pagination={{ + enabled: true, + pageSize: props.defaultOptions?.limit ?? 25, + pageSizeOptions: [10, 25, 50, 100], + type: 'offset', + }} + > + + +
+ ); +} + +// ─── Wrapper for HeaderSpecAdapter story ──────────────────────────────────── + +function ResolveSpecAdapterStory(props: AdapterConfig) { + const adapterRef = useRef(null); + + return ( + + + ResolveSpecAdapter: Connects Griddy to a ResolveSpec API. Sorting, + filtering, and pagination are translated to ResolveSpec Options automatically. +
+ +
+
+ + columns={columns} + data={[]} + getRowId={(row) => String(row.id)} + height={500} + manualFiltering + manualSorting + pagination={{ + enabled: true, + pageSize: props.defaultOptions?.limit ?? 25, + pageSizeOptions: [10, 25, 50, 100], + type: 'offset', + }} + > + + +
+ ); +} + +// ─── Meta ─────────────────────────────────────────────────────────────────── + +const meta = { + args: { + autoFetch: true, + baseUrl: 'http://localhost:3000', + debounceMs: 300, + entity: 'users', + schema: 'public', + }, + argTypes: { + autoFetch: { + control: 'boolean', + description: 'Fetch data on mount', + }, + baseUrl: { + control: 'text', + description: 'API base URL', + }, + columnMap: { + control: 'object', + description: 'Griddy column ID to API column name mapping', + }, + debounceMs: { + control: { max: 2000, min: 0, step: 50, type: 'range' }, + description: 'Filter change debounce in ms', + }, + entity: { + control: 'text', + description: 'Database entity/table name', + }, + schema: { + control: 'text', + description: 'Database schema name', + }, + token: { + control: 'text', + description: 'Auth token (optional)', + }, + }, + parameters: { + layout: 'fullscreen', + }, + tags: ['autodocs'], + title: 'Components/Griddy/Adapters', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ─── Stories ──────────────────────────────────────────────────────────────── + +/** ResolveSpec adapter — auto-wires sorting, filtering, pagination to a ResolveSpec API */ +export const ResolveSpec: Story = { + args: { + baseUrl: 'https://utils.btsys.tech/api', + }, + + render: (args) => , +}; + +/** HeaderSpec adapter — same as ResolveSpec but uses HeaderSpecClient */ +export const HeaderSpec: Story = { + args: { + baseUrl: 'https://utils.btsys.tech/api', + columnMap: { + active: 'inactive', + department: 'department', + email: 'email', + name: 'name', + }, + token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639', + }, + + render: (args) => , +}; + +/** ResolveSpec with column mapping — remaps Griddy column IDs to different API column names */ +export const WithColumnMap: Story = { + args: { + columnMap: { + active: 'is_active', + department: 'dept', + email: 'email_address', + name: 'full_name', + }, + }, + render: (args) => , +}; + +/** ResolveSpec with custom debounce — slower debounce for expensive queries */ +export const WithCustomDebounce: Story = { + args: { + debounceMs: 1000, + }, + render: (args) => , +}; + +/** ResolveSpec with autoFetch disabled — data only loads on manual refetch */ +export const ManualFetchOnly: Story = { + args: { + autoFetch: false, + }, + render: (args) => , +}; + +/** ResolveSpec with default options merged into every request */ +export const WithDefaultOptions: Story = { + args: { + defaultOptions: { + limit: 50, + sort: [{ column: 'name', direction: 'asc' }], + }, + }, + render: (args) => , +}; + +// ─── Cursor / Infinite Scroll Stories ──────────────────────────────────────── + +function HeaderSpecInfiniteScrollStory(props: AdapterConfig) { + const adapterRef = useRef(null); + + return ( + + + HeaderSpec cursor mode: HeaderSpecAdapter with cursor-based infinite + scroll. + + + columns={columns} + data={[]} + getRowId={(row) => String(row.id)} + height={500} + manualFiltering + manualSorting + > + + + + ); +} + +function InfiniteScrollStory(props: AdapterConfig) { + const adapterRef = useRef(null); + + return ( + + + Cursor mode (default): Uses cursor-based pagination with infinite scroll. + Scroll to the bottom to load more rows automatically. + + + columns={columns} + data={[]} + getRowId={(row) => String(row.id)} + height={500} + manualFiltering + manualSorting + > + + + + ); +} + +function OffsetPaginationStory(props: AdapterConfig) { + const adapterRef = useRef(null); + + return ( + + + Offset mode: Uses traditional offset/limit pagination with page controls. + + + columns={columns} + data={[]} + getRowId={(row) => String(row.id)} + height={500} + manualFiltering + manualSorting + pagination={{ + enabled: true, + pageSize: props.pageSize ?? 25, + pageSizeOptions: [10, 25, 50, 100], + type: 'offset', + }} + > + + + + ); +} + +/** ResolveSpec with cursor pagination and infinite scroll (default adapter mode) */ +export const WithInfiniteScroll: Story = { + args: { + baseUrl: 'https://utils.btsys.tech/api', + }, + + render: (args) => , +}; + +/** ResolveSpec with explicit cursor pagination config */ +export const WithCursorPagination: Story = { + args: { + cursorField: 'id', + pageSize: 50, + }, + render: (args) => , +}; + +/** ResolveSpec with offset pagination controls */ +export const WithOffsetPagination: Story = { + args: { + pageSize: 25, + }, + render: (args) => , +}; + +/** HeaderSpec adapter with cursor-based infinite scroll */ +export const HeaderSpecInfiniteScroll: Story = { + args: { + baseUrl: 'https://utils.btsys.tech/api', + columnMap: {}, + token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639', + }, + + render: (args) => , +}; diff --git a/src/Griddy/adapters/HeaderSpecAdapter.tsx b/src/Griddy/adapters/HeaderSpecAdapter.tsx new file mode 100644 index 0000000..4599bfc --- /dev/null +++ b/src/Griddy/adapters/HeaderSpecAdapter.tsx @@ -0,0 +1,299 @@ +import { getHeaderSpecClient } from '@warkypublic/resolvespec-js'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; + +import type { AdapterConfig, AdapterRef } from './types'; + +import { useGriddyStore } from '../core/GriddyStore'; +import { applyCursor, buildOptions } from './mapOptions'; + +export const HeaderSpecAdapter = forwardRef( + function HeaderSpecAdapter(props, ref) { + const { + autoFetch = true, + baseUrl, + columnMap, + computedColumns, + cursorField = 'id', + customOperators, + debounceMs = 300, + defaultOptions, + entity, + mode = 'cursor', + pageSize = 25, + preload, + schema, + token, + } = props; + + const sorting = useGriddyStore((s) => s.sorting ?? []); + const columnFilters = useGriddyStore((s) => s.columnFilters ?? []); + const pagination = useGriddyStore((s) => s.pagination); + const _setData = useGriddyStore((s) => s.setData); + const appendData = useGriddyStore((s) => s.appendData); + const setDataCount = useGriddyStore((s) => s.setDataCount); + const setIsLoading = useGriddyStore((s) => s.setIsLoading); + const setError = useGriddyStore((s) => s.setError); + const setInfiniteScroll = useGriddyStore((s) => s.setInfiniteScroll); + + const setData = (data: any[]) => { + console.log('Set Data', data); + _setData(data); + }; + const clientRef = useRef(getHeaderSpecClient({ baseUrl, token })); + const debounceRef = useRef>(null); + const mountedRef = useRef(true); + + // Cursor state (only used in cursor mode) + const cursorRef = useRef(null); + const hasMoreRef = useRef(true); + const [cursorLoading, setCursorLoading] = useState(false); + + useEffect(() => { + clientRef.current = getHeaderSpecClient({ baseUrl, token }); + }, [baseUrl, token]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // ─── Offset mode fetch (original behavior) ─── + const fetchDataOffset = useCallback(async () => { + if (!mountedRef.current) return; + + setIsLoading(true); + setError(null); + + try { + const paginationState = pagination?.enabled + ? { pageIndex: 0, pageSize: pagination.pageSize } + : undefined; + + const options = buildOptions( + sorting, + columnFilters, + paginationState, + columnMap, + defaultOptions + ); + + if (preload) options.preload = preload; + if (computedColumns) options.computedColumns = computedColumns; + if (customOperators) options.customOperators = customOperators; + + const response = await clientRef.current.read(schema, entity, undefined, options); + + if (!mountedRef.current) return; + console.log('Fetch data (offset mode) Res', { + response, + }); + if (response.success) { + setData(Array.isArray(response.data) ? response.data : [response.data]); + if (response.metadata?.total != null) { + setDataCount(response.metadata.total); + } + } else if (response.error) { + setError(new Error(response.error.message ?? 'Request failed')); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, [ + sorting, + columnFilters, + pagination, + columnMap, + defaultOptions, + preload, + computedColumns, + customOperators, + schema, + entity, + setData, + setDataCount, + setIsLoading, + setError, + ]); + + // ─── Cursor mode fetch ─── + const fetchCursorPage = useCallback( + async (cursor: null | string, isAppend: boolean) => { + if (!mountedRef.current) return; + + if (isAppend) { + setCursorLoading(true); + } else { + setIsLoading(true); + } + setError(null); + + try { + const options = buildOptions( + sorting, + columnFilters, + undefined, + columnMap, + defaultOptions + ); + + if (preload) options.preload = preload; + if (computedColumns) options.computedColumns = computedColumns; + if (customOperators) options.customOperators = customOperators; + + const cursorOptions = applyCursor(options, cursor, pageSize); + const response = await clientRef.current.read(schema, entity, undefined, cursorOptions); + + if (!mountedRef.current) return; + + if (response.success) { + const rows = Array.isArray(response.data) ? response.data : [response.data]; + + if (isAppend) { + appendData(rows); + } else { + setData(rows); + } + + if (response.metadata?.total != null) { + setDataCount(response.metadata.total); + } + + // Extract cursor from last row + if (rows.length > 0) { + const lastRow = rows[rows.length - 1]; + cursorRef.current = + lastRow?.[cursorField] != null ? String(lastRow[cursorField]) : null; + } + + // Determine hasMore + hasMoreRef.current = rows.length >= pageSize; + } else if (response.error) { + setError(new Error(response.error.message ?? 'Request failed')); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mountedRef.current) { + if (isAppend) { + setCursorLoading(false); + } else { + setIsLoading(false); + } + } + } + }, + [ + sorting, + columnFilters, + columnMap, + defaultOptions, + preload, + computedColumns, + customOperators, + schema, + entity, + pageSize, + cursorField, + setData, + appendData, + setDataCount, + setIsLoading, + setError, + ] + ); + + const fetchNextPage = useCallback(() => { + console.log('Fetch next page', { hasMore: hasMoreRef.current, cursorLoading }); + if (!hasMoreRef.current || cursorLoading) return; + fetchCursorPage(cursorRef.current, true); + }, [cursorLoading, fetchCursorPage]); + + const resetAndFetch = useCallback(async () => { + console.log('Reset and fetch', { hasMore: hasMoreRef.current, cursorLoading }); + cursorRef.current = null; + hasMoreRef.current = true; + await fetchCursorPage(null, false); + }, [fetchCursorPage]); + + // ─── Unified fetch dispatch ─── + const fetchData = mode === 'cursor' ? resetAndFetch : fetchDataOffset; + + // ─── Infinite scroll config sync (cursor mode only) ─── + useEffect(() => { + // Skip infinite scroll if not in cursor mode OR if pagination is explicitly enabled + if (mode !== 'cursor' || pagination?.enabled) { + setInfiniteScroll(undefined); + return; + } + + setInfiniteScroll({ + enabled: true, + hasMore: hasMoreRef.current, + isLoading: cursorLoading, + onLoadMore: fetchNextPage, + threshold: 10, + }); + }, [mode, pagination?.enabled, cursorLoading, fetchNextPage, setInfiniteScroll]); + + // Cleanup infinite scroll on unmount + useEffect(() => { + return () => { + setInfiniteScroll(undefined); + }; + }, [setInfiniteScroll]); + + useImperativeHandle(ref, () => ({ refetch: fetchData }), [fetchData]); + + const initialFetchDone = useRef(false); + useEffect(() => { + console.log('Auto-fetch effect', { autoFetch, initialFetchDone: initialFetchDone.current }); + if (autoFetch && !initialFetchDone.current) { + initialFetchDone.current = true; + fetchData(); + } + }, [autoFetch, fetchData]); + + const prevDepsRef = useRef(null); + useEffect(() => { + const depsKey = + mode === 'cursor' + ? JSON.stringify({ columnFilters, sorting }) + : JSON.stringify({ columnFilters, pagination, sorting }); + + if (prevDepsRef.current === null) { + prevDepsRef.current = depsKey; + return; + } + + if (prevDepsRef.current === depsKey) return; + prevDepsRef.current = depsKey; + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(fetchData, debounceMs); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [sorting, columnFilters, pagination, debounceMs, fetchData, mode]); + + console.log('Render HeaderSpecAdapter', { + sorting, + columnFilters, + pagination, + cursor: cursorRef.current, + hasMore: hasMoreRef.current, + cursorLoading, + }); + return null; + } +); diff --git a/src/Griddy/adapters/ResolveSpecAdapter.tsx b/src/Griddy/adapters/ResolveSpecAdapter.tsx new file mode 100644 index 0000000..bea415f --- /dev/null +++ b/src/Griddy/adapters/ResolveSpecAdapter.tsx @@ -0,0 +1,285 @@ +import { getResolveSpecClient } from '@warkypublic/resolvespec-js'; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; + +import type { AdapterConfig, AdapterRef } from './types'; + +import { useGriddyStore } from '../core/GriddyStore'; +import { applyCursor, buildOptions } from './mapOptions'; + +export const ResolveSpecAdapter = forwardRef( + function ResolveSpecAdapter(props, ref) { + const { + autoFetch = true, + baseUrl, + columnMap, + computedColumns, + cursorField = 'id', + customOperators, + debounceMs = 300, + defaultOptions, + entity, + mode = 'cursor', + pageSize = 25, + preload, + schema, + token, + } = props; + + const sorting = useGriddyStore((s) => s.sorting ?? []); + const columnFilters = useGriddyStore((s) => s.columnFilters ?? []); + const pagination = useGriddyStore((s) => s.pagination); + const setData = useGriddyStore((s) => s.setData); + const appendData = useGriddyStore((s) => s.appendData); + const setDataCount = useGriddyStore((s) => s.setDataCount); + const setIsLoading = useGriddyStore((s) => s.setIsLoading); + const setError = useGriddyStore((s) => s.setError); + const setInfiniteScroll = useGriddyStore((s) => s.setInfiniteScroll); + + const clientRef = useRef(getResolveSpecClient({ baseUrl, token })); + const debounceRef = useRef>(null); + const mountedRef = useRef(true); + + // Cursor state (only used in cursor mode) + const cursorRef = useRef(null); + const hasMoreRef = useRef(true); + const [cursorLoading, setCursorLoading] = useState(false); + + // Update client if baseUrl/token changes + useEffect(() => { + clientRef.current = getResolveSpecClient({ baseUrl, token }); + }, [baseUrl, token]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // ─── Offset mode fetch (original behavior) ─── + const fetchDataOffset = useCallback(async () => { + if (!mountedRef.current) return; + + setIsLoading(true); + setError(null); + + try { + const paginationState = pagination?.enabled + ? { pageIndex: 0, pageSize: pagination.pageSize } + : undefined; + + const options = buildOptions( + sorting, + columnFilters, + paginationState, + columnMap, + defaultOptions + ); + + if (preload) options.preload = preload; + if (computedColumns) options.computedColumns = computedColumns; + if (customOperators) options.customOperators = customOperators; + + const response = await clientRef.current.read(schema, entity, undefined, options); + + if (!mountedRef.current) return; + + if (response.success) { + setData(Array.isArray(response.data) ? response.data : [response.data]); + if (response.metadata?.total != null) { + setDataCount(response.metadata.total); + } + } else if (response.error) { + setError(new Error(response.error.message ?? 'Request failed')); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mountedRef.current) { + setIsLoading(false); + } + } + }, [ + sorting, + columnFilters, + pagination, + columnMap, + defaultOptions, + preload, + computedColumns, + customOperators, + schema, + entity, + setData, + setDataCount, + setIsLoading, + setError, + ]); + + // ─── Cursor mode fetch ─── + const fetchCursorPage = useCallback( + async (cursor: null | string, isAppend: boolean) => { + if (!mountedRef.current) return; + + if (isAppend) { + setCursorLoading(true); + } else { + setIsLoading(true); + } + setError(null); + + try { + const options = buildOptions( + sorting, + columnFilters, + undefined, + columnMap, + defaultOptions + ); + + if (preload) options.preload = preload; + if (computedColumns) options.computedColumns = computedColumns; + if (customOperators) options.customOperators = customOperators; + + const cursorOptions = applyCursor(options, cursor, pageSize); + const response = await clientRef.current.read(schema, entity, undefined, cursorOptions); + + if (!mountedRef.current) return; + + if (response.success) { + const rows = Array.isArray(response.data) ? response.data : [response.data]; + + if (isAppend) { + appendData(rows); + } else { + setData(rows); + } + + if (response.metadata?.total != null) { + setDataCount(response.metadata.total); + } + + // Extract cursor from last row + if (rows.length > 0) { + const lastRow = rows[rows.length - 1]; + cursorRef.current = + lastRow?.[cursorField] != null ? String(lastRow[cursorField]) : null; + } + + // Determine hasMore + hasMoreRef.current = rows.length >= pageSize; + } else if (response.error) { + setError(new Error(response.error.message ?? 'Request failed')); + } + } catch (err) { + if (mountedRef.current) { + setError(err instanceof Error ? err : new Error(String(err))); + } + } finally { + if (mountedRef.current) { + if (isAppend) { + setCursorLoading(false); + } else { + setIsLoading(false); + } + } + } + }, + [ + sorting, + columnFilters, + columnMap, + defaultOptions, + preload, + computedColumns, + customOperators, + schema, + entity, + pageSize, + cursorField, + setData, + appendData, + setDataCount, + setIsLoading, + setError, + ] + ); + + const fetchNextPage = useCallback(() => { + if (!hasMoreRef.current || cursorLoading) return; + fetchCursorPage(cursorRef.current, true); + }, [cursorLoading, fetchCursorPage]); + + const resetAndFetch = useCallback(async () => { + cursorRef.current = null; + hasMoreRef.current = true; + await fetchCursorPage(null, false); + }, [fetchCursorPage]); + + // ─── Unified fetch dispatch ─── + const fetchData = mode === 'cursor' ? resetAndFetch : fetchDataOffset; + + // ─── Infinite scroll config sync (cursor mode only) ─── + useEffect(() => { + // Skip infinite scroll if not in cursor mode OR if pagination is explicitly enabled + if (mode !== 'cursor' || pagination?.enabled) { + setInfiniteScroll(undefined); + return; + } + + setInfiniteScroll({ + enabled: true, + hasMore: hasMoreRef.current, + isLoading: cursorLoading, + onLoadMore: fetchNextPage, + threshold: 10, + }); + }, [mode, pagination?.enabled, cursorLoading, fetchNextPage, setInfiniteScroll]); + + // Cleanup infinite scroll on unmount + useEffect(() => { + return () => { + setInfiniteScroll(undefined); + }; + }, [setInfiniteScroll]); + + useImperativeHandle(ref, () => ({ refetch: fetchData }), [fetchData]); + + // Auto-fetch on mount + const initialFetchDone = useRef(false); + useEffect(() => { + if (autoFetch && !initialFetchDone.current) { + initialFetchDone.current = true; + fetchData(); + } + }, [autoFetch, fetchData]); + + // Debounced re-fetch on state changes (skip initial) + const prevDepsRef = useRef(null); + useEffect(() => { + const depsKey = + mode === 'cursor' + ? JSON.stringify({ columnFilters, sorting }) + : JSON.stringify({ columnFilters, pagination, sorting }); + + if (prevDepsRef.current === null) { + prevDepsRef.current = depsKey; + return; + } + + if (prevDepsRef.current === depsKey) return; + prevDepsRef.current = depsKey; + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(fetchData, debounceMs); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [sorting, columnFilters, pagination, debounceMs, fetchData, mode]); + + return null; + } +); diff --git a/src/Griddy/adapters/index.ts b/src/Griddy/adapters/index.ts new file mode 100644 index 0000000..66d3e70 --- /dev/null +++ b/src/Griddy/adapters/index.ts @@ -0,0 +1,4 @@ +export { HeaderSpecAdapter } from './HeaderSpecAdapter' +export { applyCursor, buildOptions, mapFilters, mapPagination, mapSorting } from './mapOptions' +export { ResolveSpecAdapter } from './ResolveSpecAdapter' +export type { AdapterConfig, AdapterRef } from './types' diff --git a/src/Griddy/adapters/mapOptions.ts b/src/Griddy/adapters/mapOptions.ts new file mode 100644 index 0000000..1052e1b --- /dev/null +++ b/src/Griddy/adapters/mapOptions.ts @@ -0,0 +1,126 @@ +import type { ColumnFiltersState, PaginationState, SortingState } from '@tanstack/react-table'; +import type { FilterOption, Options, SortOption } from '@warkypublic/resolvespec-js'; + +const OPERATOR_MAP: Record = { + between: 'between', + contains: 'ilike', + endsWith: 'endswith', + equals: 'eq', + excludes: 'in', + greaterThan: 'gt', + greaterThanOrEqual: 'gte', + includes: 'in', + is: 'eq', + isAfter: 'gt', + isBefore: 'lt', + isBetween: 'between_inclusive', + isEmpty: 'is_null', + isFalse: 'eq', + isNotEmpty: 'is_not_null', + isTrue: 'eq', + lessThan: 'lt', + lessThanOrEqual: 'lte', + notContains: 'ilike', + notEquals: 'neq', + startsWith: 'startswith', +}; + +export function applyCursor(opts: Options, cursor: null | string, limit: number): Options { + const result = { ...opts, limit }; + if (cursor) { + result.cursor_forward = cursor; + } + return result; +} + +export function buildOptions( + sorting: SortingState, + filters: ColumnFiltersState, + pagination: PaginationState | undefined, + columnMap?: Record, + defaultOptions?: Partial +): Options { + const opts: Options = { ...defaultOptions }; + + if (sorting.length > 0) { + opts.sort = mapSorting(sorting, columnMap); + } + + if (filters.length > 0) { + opts.filters = mapFilters(filters, columnMap); + } + + if (pagination) { + const { limit, offset } = mapPagination(pagination); + opts.limit = limit; + opts.offset = offset; + } + + return opts; +} + +export function mapFilters( + filters: ColumnFiltersState, + columnMap?: Record +): FilterOption[] { + return filters.flatMap((filter) => { + const filterValue = filter.value as any; + + // Enum filter with values array + if (filterValue?.values && Array.isArray(filterValue.values)) { + return [ + { + column: resolveColumn(filter.id, columnMap), + operator: 'in', + value: filterValue.values, + }, + ]; + } + + const operator = filterValue?.operator ?? 'eq'; + const value = filterValue?.value ?? filterValue; + + return [ + { + column: resolveColumn(filter.id, columnMap), + operator: resolveOperator(operator), + value: resolveFilterValue(operator, value), + }, + ]; + }); +} + +export function mapPagination(pagination: PaginationState): { limit: number; offset: number } { + return { + limit: pagination.pageSize, + offset: pagination.pageIndex * pagination.pageSize, + }; +} + +export function mapSorting( + sorting: SortingState, + columnMap?: Record +): SortOption[] { + return sorting.map(({ desc, id }) => ({ + column: resolveColumn(id, columnMap), + direction: desc ? ('desc' as const) : ('asc' as const), + })); +} + +function resolveColumn(id: string, columnMap?: Record): string { + return columnMap?.[id] ?? id; +} + +function resolveFilterValue(operator: string, value: any): any { + if (operator === 'isTrue') return true; + if (operator === 'isFalse') return false; + if (operator === 'contains') return `%${value}%`; + if (operator === 'startsWith') return `${value}%`; + if (operator === 'endsWith') return `%${value}`; + if (operator === 'notContains') return `%${value}%`; + return value; +} + +function resolveOperator(op: string): string { + return OPERATOR_MAP[op] ?? op; +} diff --git a/src/Griddy/adapters/types.ts b/src/Griddy/adapters/types.ts new file mode 100644 index 0000000..1a86d60 --- /dev/null +++ b/src/Griddy/adapters/types.ts @@ -0,0 +1,30 @@ +import type { + ComputedColumn, + CustomOperator, + Options, + PreloadOption, +} from '@warkypublic/resolvespec-js'; + +export interface AdapterConfig { + autoFetch?: boolean; + baseUrl: string; + columnMap?: Record; + computedColumns?: ComputedColumn[]; + /** Field to extract cursor value from last row. Default: 'id' */ + cursorField?: string; + customOperators?: CustomOperator[]; + debounceMs?: number; + defaultOptions?: Partial; + entity: string; + /** Pagination mode. 'cursor' uses cursor-based infinite scroll, 'offset' uses offset/limit pagination. Default: 'cursor' */ + mode?: 'cursor' | 'offset'; + /** Page size for both cursor and offset modes. Default: 25 */ + pageSize?: number; + preload?: PreloadOption[]; + schema: string; + token?: string; +} + +export interface AdapterRef { + refetch: () => Promise; +} diff --git a/src/Griddy/core/Griddy.tsx b/src/Griddy/core/Griddy.tsx index 9ba3fa3..122da00 100644 --- a/src/Griddy/core/Griddy.tsx +++ b/src/Griddy/core/Griddy.tsx @@ -15,25 +15,33 @@ import { type SortingState, useReactTable, type VisibilityState, -} from '@tanstack/react-table' -import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +} from '@tanstack/react-table'; +import React, { + forwardRef, + type Ref, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; -import type { GriddyProps, GriddyRef } from './types' +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' -import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer' -import { TableHeader } from '../rendering/TableHeader' -import { VirtualBody } from '../rendering/VirtualBody' -import styles from '../styles/griddy.module.css' -import { mapColumns } from './columnMapper' -import { CSS, DEFAULTS } from './constants' -import { GriddyProvider, useGriddyStore } from './GriddyStore' +import { advancedSearchGlobalFilterFn, AdvancedSearchPanel } 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'; +import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer'; +import { TableHeader } from '../rendering/TableHeader'; +import { VirtualBody } from '../rendering/VirtualBody'; +import styles from '../styles/griddy.module.css'; +import { mapColumns } from './columnMapper'; +import { CSS, DEFAULTS } from './constants'; +import { GriddyProvider, useGriddyStore } from './GriddyStore'; // ─── Inner Component (lives inside Provider, has store access) ─────────────── @@ -45,119 +53,119 @@ function _Griddy(props: GriddyProps, ref: Ref>) { {props.children} - ) + ); } // ─── Main Component with forwardRef ────────────────────────────────────────── function GriddyInner({ tableRef }: { tableRef: Ref> }) { // Read props from synced store - const data = useGriddyStore((s) => s.data) - const userColumns = useGriddyStore((s) => s.columns) - const getRowId = useGriddyStore((s) => s.getRowId) - const selection = useGriddyStore((s) => s.selection) - const search = useGriddyStore((s) => s.search) - const groupingConfig = useGriddyStore((s) => s.grouping) - const paginationConfig = useGriddyStore((s) => s.pagination) - const controlledSorting = useGriddyStore((s) => s.sorting) - const onSortingChange = useGriddyStore((s) => s.onSortingChange) - const controlledFilters = useGriddyStore((s) => s.columnFilters) - const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange) - const controlledPinning = useGriddyStore((s) => s.columnPinning) - const onColumnPinningChange = useGriddyStore((s) => s.onColumnPinningChange) - const controlledRowSelection = useGriddyStore((s) => s.rowSelection) - const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange) - const onEditCommit = useGriddyStore((s) => s.onEditCommit) - const rowHeight = useGriddyStore((s) => s.rowHeight) - const overscanProp = useGriddyStore((s) => s.overscan) - const height = useGriddyStore((s) => s.height) - const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation) - 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) - const setTable = useGriddyStore((s) => s.setTable) - const setVirtualizer = useGriddyStore((s) => s.setVirtualizer) - const setScrollRef = useGriddyStore((s) => s.setScrollRef) - const setFocusedRow = useGriddyStore((s) => s.setFocusedRow) - const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn) - const setEditing = useGriddyStore((s) => s.setEditing) - const setTotalRows = useGriddyStore((s) => s.setTotalRows) - const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex) + const data = useGriddyStore((s) => s.data); + const userColumns = useGriddyStore((s) => s.columns); + const getRowId = useGriddyStore((s) => s.getRowId); + const selection = useGriddyStore((s) => s.selection); + const search = useGriddyStore((s) => s.search); + const groupingConfig = useGriddyStore((s) => s.grouping); + const paginationConfig = useGriddyStore((s) => s.pagination); + const controlledSorting = useGriddyStore((s) => s.sorting); + const onSortingChange = useGriddyStore((s) => s.onSortingChange); + const controlledFilters = useGriddyStore((s) => s.columnFilters); + const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange); + const controlledPinning = useGriddyStore((s) => s.columnPinning); + const onColumnPinningChange = useGriddyStore((s) => s.onColumnPinningChange); + const controlledRowSelection = useGriddyStore((s) => s.rowSelection); + const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange); + const onEditCommit = useGriddyStore((s) => s.onEditCommit); + const rowHeight = useGriddyStore((s) => s.rowHeight); + const overscanProp = useGriddyStore((s) => s.overscan); + const height = useGriddyStore((s) => s.height); + const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation); + 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); + const setTable = useGriddyStore((s) => s.setTable); + const setVirtualizer = useGriddyStore((s) => s.setVirtualizer); + const setScrollRef = useGriddyStore((s) => s.setScrollRef); + const setFocusedRow = useGriddyStore((s) => s.setFocusedRow); + const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn); + const setEditing = useGriddyStore((s) => s.setEditing); + const setTotalRows = useGriddyStore((s) => s.setTotalRows); + const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex); - const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight - const effectiveOverscan = overscanProp ?? DEFAULTS.overscan - const enableKeyboard = keyboardNavigation !== false + const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight; + const effectiveOverscan = overscanProp ?? DEFAULTS.overscan; + const enableKeyboard = keyboardNavigation !== false; // ─── Column Mapping ─── const columns = useMemo( () => mapColumns(userColumns ?? [], selection) as ColumnDef[], - [userColumns, selection], - ) + [userColumns, selection] + ); // ─── Table State (internal/uncontrolled) ─── - const [internalSorting, setInternalSorting] = useState([]) - const [internalFilters, setInternalFilters] = useState([]) - const [internalRowSelection, setInternalRowSelection] = useState({}) - const [globalFilter, setGlobalFilter] = useState(undefined) - const [columnVisibility, setColumnVisibility] = useState({}) - const [columnOrder, setColumnOrder] = useState([]) + const [internalSorting, setInternalSorting] = useState([]); + const [internalFilters, setInternalFilters] = useState([]); + const [internalRowSelection, setInternalRowSelection] = useState({}); + const [globalFilter, setGlobalFilter] = useState(undefined); + const [columnVisibility, setColumnVisibility] = useState({}); + const [columnOrder, setColumnOrder] = useState([]); // Build initial column pinning from column definitions const initialPinning = useMemo(() => { - const left: string[] = [] - const right: string[] = [] - userColumns?.forEach(col => { - if (col.pinned === 'left') left.push(col.id) - else if (col.pinned === 'right') right.push(col.id) - }) - return { left, right } - }, [userColumns]) + const left: string[] = []; + const right: string[] = []; + userColumns?.forEach((col) => { + if (col.pinned === 'left') left.push(col.id); + else if (col.pinned === 'right') right.push(col.id); + }); + return { left, right }; + }, [userColumns]); - const [internalPinning, setInternalPinning] = useState(initialPinning) - const [grouping, setGrouping] = useState(groupingConfig?.columns ?? []) - const [expanded, setExpanded] = useState({}) + const [internalPinning, setInternalPinning] = useState(initialPinning); + const [grouping, setGrouping] = useState(groupingConfig?.columns ?? []); + const [expanded, setExpanded] = useState({}); const [internalPagination, setInternalPagination] = useState({ pageIndex: 0, 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 + 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) + paginationConfig.onPageChange(next.pageIndex); } if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) { - paginationConfig.onPageSizeChange(next.pageSize) + paginationConfig.onPageSizeChange(next.pageSize); } } - return next - }) - } + return next; + }); + }; // Resolve controlled vs uncontrolled - const sorting = controlledSorting ?? internalSorting - const setSorting = onSortingChange ?? setInternalSorting - const columnFilters = controlledFilters ?? internalFilters - const setColumnFilters = onColumnFiltersChange ?? setInternalFilters - const columnPinning = controlledPinning ?? internalPinning - const setColumnPinning = onColumnPinningChange ?? setInternalPinning - const rowSelectionState = controlledRowSelection ?? internalRowSelection - const setRowSelection = onRowSelectionChange ?? setInternalRowSelection + const sorting = controlledSorting ?? internalSorting; + const setSorting = onSortingChange ?? setInternalSorting; + const columnFilters = controlledFilters ?? internalFilters; + const setColumnFilters = onColumnFiltersChange ?? setInternalFilters; + const columnPinning = controlledPinning ?? internalPinning; + const setColumnPinning = onColumnPinningChange ?? setInternalPinning; + const rowSelectionState = controlledRowSelection ?? internalRowSelection; + const setRowSelection = onRowSelectionChange ?? setInternalRowSelection; // ─── Selection config ─── - const enableRowSelection = selection ? selection.mode !== 'none' : false - const enableMultiRowSelection = selection?.mode === 'multi' + const enableRowSelection = selection ? selection.mode !== 'none' : false; + const enableMultiRowSelection = selection?.mode === 'multi'; // ─── TanStack Table Instance ─── const table = useReactTable({ @@ -177,7 +185,7 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { getExpandedRowModel: getExpandedRowModel(), getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(), getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined, - getRowId: getRowId as any ?? ((_, index) => String(index)), + getRowId: (getRowId as any) ?? ((_, index) => String(index)), getSortedRowModel: manualSorting ? undefined : getSortedRowModel(), manualFiltering: manualFiltering ?? false, manualSorting: manualSorting ?? false, @@ -206,10 +214,10 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { }, ...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}), columnResizeMode: 'onChange', - }) + }); // ─── Scroll Container Ref ─── - const scrollRef = useRef(null) + const scrollRef = useRef(null); // ─── TanStack Virtual ─── const virtualizer = useGridVirtualizer({ @@ -217,16 +225,22 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { rowHeight: effectiveRowHeight, scrollRef, table, - }) + }); // ─── Sync table + virtualizer + scrollRef into store ─── - useEffect(() => { setTable(table) }, [table, setTable]) - useEffect(() => { setVirtualizer(virtualizer) }, [virtualizer, setVirtualizer]) - useEffect(() => { setScrollRef(scrollRef.current) }, [setScrollRef]) + useEffect(() => { + setTable(table); + }, [table, setTable]); + useEffect(() => { + setVirtualizer(virtualizer); + }, [virtualizer, setVirtualizer]); + useEffect(() => { + setScrollRef(scrollRef.current); + }, [setScrollRef]); // ─── Keyboard Navigation ─── // Get the full store state for imperative access in keyboard handler - const storeState = useGriddyStore() + const storeState = useGriddyStore(); useKeyboardNavigation({ editingEnabled: !!onEditCommit, @@ -236,59 +250,64 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { storeState, table, virtualizer, - }) + }); // ─── Set initial focus when data loads ─── - const rowCount = table.getRowModel().rows.length + const rowCount = table.getRowModel().rows.length; useEffect(() => { - setTotalRows(rowCount) + setTotalRows(rowCount); if (rowCount > 0 && focusedRowIndex === null) { - setFocusedRow(0) + setFocusedRow(0); } - }, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow]) + }, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow]); // ─── Imperative Ref ─── - useImperativeHandle(tableRef, () => ({ - deselectAll: () => table.resetRowSelection(), - focusRow: (index: number) => { - setFocusedRow(index) - virtualizer.scrollToIndex(index, { align: 'auto' }) - }, - getTable: () => table, - getUIState: () => ({ - focusedColumnId: null, - focusedRowIndex, - isEditing: false, - isSearchOpen: false, - isSelecting: false, - totalRows: rowCount, - } as any), - getVirtualizer: () => virtualizer, - scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }), - selectRow: (id: string) => { - const row = table.getRowModel().rows.find((r) => r.id === id) - row?.toggleSelected(true) - }, - startEditing: (rowId: string, columnId?: string) => { - const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId) - if (rowIndex >= 0) { - setFocusedRow(rowIndex) - if (columnId) setFocusedColumn(columnId) - setEditing(true) - } - }, - }), [table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount]) + useImperativeHandle( + tableRef, + () => ({ + deselectAll: () => table.resetRowSelection(), + focusRow: (index: number) => { + setFocusedRow(index); + virtualizer.scrollToIndex(index, { align: 'auto' }); + }, + getTable: () => table, + getUIState: () => + ({ + focusedColumnId: null, + focusedRowIndex, + isEditing: false, + isSearchOpen: false, + isSelecting: false, + totalRows: rowCount, + }) as any, + getVirtualizer: () => virtualizer, + scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }), + selectRow: (id: string) => { + const row = table.getRowModel().rows.find((r) => r.id === id); + row?.toggleSelected(true); + }, + startEditing: (rowId: string, columnId?: string) => { + const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId); + if (rowIndex >= 0) { + setFocusedRow(rowIndex); + if (columnId) setFocusedColumn(columnId); + setEditing(true); + } + }, + }), + [table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount] + ); // ─── Render ─── const containerStyle: React.CSSProperties = { height: height ?? '100%', overflow: 'auto', position: 'relative', - } + }; - const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null - const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined + const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null; + const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined; return (
({ tableRef }: { tableRef: Ref> }) { )}
{paginationConfig?.enabled && ( - + )}
- ) + ); } export const Griddy = forwardRef(_Griddy) as ( props: GriddyProps & React.RefAttributes> -) => React.ReactElement +) => React.ReactElement; diff --git a/src/Griddy/core/GriddyStore.ts b/src/Griddy/core/GriddyStore.ts index 81f9726..0a9e27e 100644 --- a/src/Griddy/core/GriddyStore.ts +++ b/src/Griddy/core/GriddyStore.ts @@ -1,10 +1,26 @@ -import type { Table } from '@tanstack/react-table' -import type { ColumnFiltersState, ColumnPinningState, RowSelectionState, SortingState } from '@tanstack/react-table' -import type { Virtualizer } from '@tanstack/react-virtual' +import type { Table } from '@tanstack/react-table'; +import type { + ColumnFiltersState, + ColumnPinningState, + RowSelectionState, + SortingState, +} from '@tanstack/react-table'; +import type { Virtualizer } from '@tanstack/react-virtual'; -import { createSyncStore } from '@warkypublic/zustandsyncstore' +import { createSyncStore } from '@warkypublic/zustandsyncstore'; -import type { AdvancedSearchConfig, DataAdapter, GriddyColumn, GriddyProps, GriddyUIState, GroupingConfig, InfiniteScrollConfig, PaginationConfig, SearchConfig, SelectionConfig } from './types' +import type { + AdvancedSearchConfig, + DataAdapter, + GriddyColumn, + GriddyProps, + GriddyUIState, + GroupingConfig, + InfiniteScrollConfig, + PaginationConfig, + SearchConfig, + SelectionConfig, +} from './types'; // ─── Store State ───────────────────────────────────────────────────────────── @@ -14,54 +30,61 @@ import type { AdvancedSearchConfig, DataAdapter, GriddyColumn, GriddyProps, Grid * Fields from GriddyProps must be declared here so TypeScript can see them. */ export interface GriddyStoreState extends GriddyUIState { - _scrollRef: HTMLDivElement | null + _scrollRef: HTMLDivElement | null; // ─── Internal refs (set imperatively) ─── - _table: null | Table - _virtualizer: null | Virtualizer - advancedSearch?: AdvancedSearchConfig - className?: string - columnFilters?: ColumnFiltersState - columns?: GriddyColumn[] - columnPinning?: ColumnPinningState - onColumnPinningChange?: (pinning: ColumnPinningState) => void - data?: any[] + _table: null | Table; + _virtualizer: null | Virtualizer; + advancedSearch?: AdvancedSearchConfig; + // ─── Adapter Actions ─── + appendData: (data: any[]) => void; + className?: string; + columnFilters?: ColumnFiltersState; + columnPinning?: ColumnPinningState; + columns?: GriddyColumn[]; + data?: any[]; + dataAdapter?: DataAdapter; + dataCount?: number; // ─── 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 - pagination?: PaginationConfig - persistenceKey?: string - rowHeight?: number - rowSelection?: RowSelectionState - search?: SearchConfig + error: Error | null; + exportFilename?: string; + 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; + onColumnPinningChange?: (pinning: ColumnPinningState) => void; + onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise | void; + onError?: (error: Error) => void; + onRowSelectionChange?: (selection: RowSelectionState) => void; + onSortingChange?: (sorting: SortingState) => void; + overscan?: number; + pagination?: PaginationConfig; + persistenceKey?: string; + rowHeight?: number; + rowSelection?: RowSelectionState; - selection?: SelectionConfig - setError: (error: Error | null) => void - showToolbar?: boolean - setScrollRef: (el: HTMLDivElement | null) => void + search?: SearchConfig; + selection?: SelectionConfig; + setData: (data: any[]) => void; + setDataCount: (count: number) => void; + setError: (error: Error | null) => void; + setInfiniteScroll: (config: InfiniteScrollConfig | undefined) => void; + + setIsLoading: (loading: boolean) => void; + setScrollRef: (el: HTMLDivElement | null) => void; // ─── Internal ref setters ─── - setTable: (table: Table) => void + setTable: (table: Table) => void; + setVirtualizer: (virtualizer: Virtualizer) => void; - setVirtualizer: (virtualizer: Virtualizer) => void - sorting?: SortingState + showToolbar?: boolean; + sorting?: SortingState; // ─── Synced from GriddyProps (written by $sync) ─── - uniqueId?: string + uniqueId?: string; } // ─── Create Store ──────────────────────────────────────────────────────────── @@ -69,52 +92,55 @@ export interface GriddyStoreState extends GriddyUIState { export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore< GriddyStoreState, GriddyProps ->( - (set, get) => ({ - _scrollRef: null, - // ─── Internal Refs ─── - _table: null, +>((set, get) => ({ + _scrollRef: null, + // ─── Internal Refs ─── + _table: null, - _virtualizer: null, - error: null, - focusedColumnId: null, - // ─── Focus State ─── - focusedRowIndex: null, + _virtualizer: null, + appendData: (data) => set((state) => ({ data: [...(state.data ?? []), ...data] })), + error: null, + focusedColumnId: null, + // ─── Focus State ─── + focusedRowIndex: null, - // ─── Mode State ─── - isEditing: false, + // ─── Mode State ─── + isEditing: false, - isSearchOpen: false, - isSelecting: false, - moveFocus: (direction, amount) => { - const { focusedRowIndex, totalRows } = get() - const current = focusedRowIndex ?? 0 - const delta = direction === 'down' ? amount : -amount - const next = Math.max(0, Math.min(current + delta, totalRows - 1)) - set({ focusedRowIndex: next }) - }, + isSearchOpen: false, + isSelecting: false, + moveFocus: (direction, amount) => { + const { focusedRowIndex, totalRows } = get(); + const current = focusedRowIndex ?? 0; + const delta = direction === 'down' ? amount : -amount; + const next = Math.max(0, Math.min(current + delta, totalRows - 1)); + set({ focusedRowIndex: next }); + }, - moveFocusToEnd: () => { - const { totalRows } = get() - set({ focusedRowIndex: Math.max(0, totalRows - 1) }) - }, - moveFocusToStart: () => set({ focusedRowIndex: 0 }), - setEditing: (editing) => set({ isEditing: editing }), - setError: (error) => set({ error }), - setFocusedColumn: (id) => set({ focusedColumnId: id }), - // ─── Actions ─── - setFocusedRow: (index) => set({ focusedRowIndex: index }), - setScrollRef: (el) => set({ _scrollRef: el }), + moveFocusToEnd: () => { + const { totalRows } = get(); + set({ focusedRowIndex: Math.max(0, totalRows - 1) }); + }, + moveFocusToStart: () => set({ focusedRowIndex: 0 }), + setData: (data) => set({ data }), + setDataCount: (count) => set({ dataCount: count }), + setEditing: (editing) => set({ isEditing: editing }), + setError: (error) => set({ error }), + setFocusedColumn: (id) => set({ focusedColumnId: id }), + // ─── Actions ─── + setFocusedRow: (index) => set({ focusedRowIndex: index }), + setInfiniteScroll: (config) => set({ infiniteScroll: config }), + setIsLoading: (loading) => set({ isLoading: loading }), + setScrollRef: (el) => set({ _scrollRef: el }), - setSearchOpen: (open) => set({ isSearchOpen: open }), + setSearchOpen: (open) => set({ isSearchOpen: open }), - setSelecting: (selecting) => set({ isSelecting: selecting }), - // ─── Internal Ref Setters ─── - setTable: (table) => set({ _table: table }), + setSelecting: (selecting) => set({ isSelecting: selecting }), + // ─── Internal Ref Setters ─── + setTable: (table) => set({ _table: table }), - setTotalRows: (count) => set({ totalRows: count }), - setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }), - // ─── Row Count ─── - totalRows: 0, - }), -) + setTotalRows: (count) => set({ totalRows: count }), + setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }), + // ─── Row Count ─── + totalRows: 0, +})); diff --git a/src/Griddy/core/columnMapper.ts b/src/Griddy/core/columnMapper.ts index b25d892..a104456 100644 --- a/src/Griddy/core/columnMapper.ts +++ b/src/Griddy/core/columnMapper.ts @@ -1,22 +1,85 @@ -import type { ColumnDef } from '@tanstack/react-table' +import type { ColumnDef } from '@tanstack/react-table'; -import type { GriddyColumn, SelectionConfig } from './types' +import type { GriddyColumn, SelectionConfig } from './types'; -import { createOperatorFilter } from '../features/filtering' -import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants' +import { createOperatorFilter } from '../features/filtering'; +import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants'; /** * Retrieves the original GriddyColumn from a TanStack column's meta. */ -export function getGriddyColumn(column: { columnDef: ColumnDef }): GriddyColumn | undefined { - return (column.columnDef.meta as { griddy?: GriddyColumn })?.griddy +export function getGriddyColumn(column: { + columnDef: ColumnDef; +}): GriddyColumn | undefined { + return (column.columnDef.meta as { griddy?: GriddyColumn })?.griddy; +} + +/** + * Maps Griddy's user-facing GriddyColumn definitions to TanStack Table ColumnDef[]. + * Supports header grouping and optionally prepends a selection checkbox column. + */ +export function mapColumns( + columns: GriddyColumn[], + selection?: SelectionConfig +): ColumnDef[] { + // Group columns by headerGroup + const grouped = new Map[]>(); + const ungrouped: GriddyColumn[] = []; + + columns.forEach((col) => { + if (col.headerGroup) { + const existing = grouped.get(col.headerGroup) || []; + existing.push(col); + grouped.set(col.headerGroup, existing); + } else { + ungrouped.push(col); + } + }); + + // Build column definitions + const mapped: ColumnDef[] = []; + + // Add ungrouped columns first + ungrouped.forEach((col) => { + mapped.push(mapSingleColumn(col)); + }); + + // Add grouped columns + grouped.forEach((groupColumns, groupName) => { + const groupDef: ColumnDef = { + columns: groupColumns.map((col) => mapSingleColumn(col)), + header: groupName, + id: `group-${groupName}`, + }; + mapped.push(groupDef); + }); + + // Prepend checkbox column if selection is enabled + if (selection && selection.mode !== 'none' && selection.showCheckbox !== false) { + const checkboxCol: ColumnDef = { + cell: 'select-row', // Rendered by TableCell with actual checkbox + enableColumnFilter: false, + enableHiding: false, + enableResizing: false, + enableSorting: false, + header: + selection.mode === 'multi' + ? 'select-all' // Rendered by TableHeader with actual checkbox + : '', + id: SELECTION_COLUMN_ID, + size: SELECTION_COLUMN_SIZE, + }; + mapped.unshift(checkboxCol); + } + + return mapped; } /** * Converts a single GriddyColumn to a TanStack ColumnDef */ function mapSingleColumn(col: GriddyColumn): ColumnDef { - const isStringAccessor = typeof col.accessor !== 'function' + const isStringAccessor = typeof col.accessor !== 'function'; const def: ColumnDef = { id: col.id, @@ -37,92 +100,33 @@ function mapSingleColumn(col: GriddyColumn): ColumnDef { meta: { griddy: col }, minSize: col.minWidth ?? DEFAULTS.minColumnWidth, size: col.width, - } + }; // For function accessors, TanStack can't auto-detect the sort type, so provide a default if (col.sortFn) { - def.sortingFn = col.sortFn + def.sortingFn = col.sortFn; } else if (!isStringAccessor && col.sortable !== false) { // Use alphanumeric sorting for function accessors - def.sortingFn = 'alphanumeric' + def.sortingFn = 'alphanumeric'; } if (col.filterFn) { - def.filterFn = col.filterFn + def.filterFn = col.filterFn; } else if (col.filterable) { - def.filterFn = createOperatorFilter() + 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(), - }) + 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 -} - -/** - * Maps Griddy's user-facing GriddyColumn definitions to TanStack Table ColumnDef[]. - * Supports header grouping and optionally prepends a selection checkbox column. - */ -export function mapColumns( - columns: GriddyColumn[], - selection?: SelectionConfig, -): ColumnDef[] { - // Group columns by headerGroup - const grouped = new Map[]>() - const ungrouped: GriddyColumn[] = [] - - columns.forEach(col => { - if (col.headerGroup) { - const existing = grouped.get(col.headerGroup) || [] - existing.push(col) - grouped.set(col.headerGroup, existing) - } else { - ungrouped.push(col) - } - }) - - // Build column definitions - const mapped: ColumnDef[] = [] - - // Add ungrouped columns first - ungrouped.forEach(col => { - mapped.push(mapSingleColumn(col)) - }) - - // Add grouped columns - grouped.forEach((groupColumns, groupName) => { - const groupDef: ColumnDef = { - header: groupName, - id: `group-${groupName}`, - columns: groupColumns.map(col => mapSingleColumn(col)), - } - mapped.push(groupDef) - }) - - // Prepend checkbox column if selection is enabled - if (selection && selection.mode !== 'none' && selection.showCheckbox !== false) { - const checkboxCol: ColumnDef = { - cell: 'select-row', // Rendered by TableCell with actual checkbox - enableColumnFilter: false, - enableHiding: false, - enableResizing: false, - enableSorting: false, - header: selection.mode === 'multi' - ? 'select-all' // Rendered by TableHeader with actual checkbox - : '', - id: SELECTION_COLUMN_ID, - size: SELECTION_COLUMN_SIZE, - } - mapped.unshift(checkboxCol) - } - - return mapped + return def; } diff --git a/src/Griddy/core/types.ts b/src/Griddy/core/types.ts index 34312f9..562b4a1 100644 --- a/src/Griddy/core/types.ts +++ b/src/Griddy/core/types.ts @@ -1,289 +1,315 @@ -import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ExpandedState, FilterFn, GroupingState, PaginationState, RowSelectionState, SortingFn, SortingState, Table, VisibilityState } from '@tanstack/react-table' -import type { Virtualizer } from '@tanstack/react-virtual' -import type { ReactNode } from 'react' +import type { + ColumnDef, + ColumnFiltersState, + ColumnOrderState, + ColumnPinningState, + ExpandedState, + FilterFn, + GroupingState, + PaginationState, + RowSelectionState, + SortingFn, + SortingState, + Table, + VisibilityState, +} from '@tanstack/react-table'; +import type { Virtualizer } from '@tanstack/react-virtual'; +import type { ReactNode } from 'react'; -import type { EditorConfig } from '../editors' -import type { FilterConfig } from '../features/filtering' +import type { EditorConfig } from '../editors'; +import type { FilterConfig } from '../features/filtering'; // ─── Column Definition ─────────────────────────────────────────────────────── -export type CellRenderer = (props: RendererProps) => ReactNode +export interface AdvancedSearchConfig { + enabled: boolean; +} // ─── Cell Rendering ────────────────────────────────────────────────────────── -export interface DataAdapter { - delete?: (row: T) => Promise - fetch: (config: FetchConfig) => Promise> - save?: (row: T) => Promise -} +export type CellRenderer = (props: RendererProps) => ReactNode; -export type EditorComponent = (props: EditorProps) => ReactNode +export interface DataAdapter { + delete?: (row: T) => Promise; + fetch: (config: FetchConfig) => Promise>; + save?: (row: T) => Promise; +} // ─── Editors ───────────────────────────────────────────────────────────────── -export interface EditorProps { - column: GriddyColumn - onCancel: () => void - onCommit: (newValue: unknown) => void - onMoveNext: () => void - onMovePrev: () => void - row: T - rowIndex: number - value: unknown -} +export type EditorComponent = (props: EditorProps) => ReactNode; -export interface FetchConfig { - cursor?: string - filters?: ColumnFiltersState - globalFilter?: string - page?: number - pageSize?: number - sorting?: SortingState +export interface EditorProps { + column: GriddyColumn; + onCancel: () => void; + onCommit: (newValue: unknown) => void; + onMoveNext: () => void; + onMovePrev: () => void; + row: T; + rowIndex: number; + value: unknown; } // ─── Selection ─────────────────────────────────────────────────────────────── -export interface GriddyColumn { - accessor: ((row: T) => unknown) | keyof T - aggregationFn?: 'sum' | 'min' | 'max' | 'mean' | 'median' | 'unique' | 'uniqueCount' | 'count' - editable?: ((row: T) => boolean) | boolean - editor?: EditorComponent - editorConfig?: EditorConfig - filterable?: boolean - filterConfig?: FilterConfig - filterFn?: FilterFn - groupable?: boolean - header: ReactNode | string - headerGroup?: string - hidden?: boolean - id: string - maxWidth?: number - minWidth?: number - pinned?: 'left' | 'right' - renderer?: CellRenderer - /** Metadata passed to custom renderers (ProgressBar, Badge, Image, Sparkline) */ - rendererMeta?: Record - searchable?: boolean - sortable?: boolean - sortFn?: SortingFn - width?: number +export interface FetchConfig { + cursor?: string; + filters?: ColumnFiltersState; + globalFilter?: string; + page?: number; + pageSize?: number; + sorting?: SortingState; } // ─── Search ────────────────────────────────────────────────────────────────── -export interface GriddyDataSource { - data: T[] - error?: Error - isLoading?: boolean - pageInfo?: { cursor?: string; hasNextPage: boolean; } - total?: number +export interface GriddyColumn { + accessor: ((row: T) => unknown) | keyof T; + aggregationFn?: 'count' | 'max' | 'mean' | 'median' | 'min' | 'sum' | 'unique' | 'uniqueCount'; + editable?: ((row: T) => boolean) | boolean; + editor?: EditorComponent; + editorConfig?: EditorConfig; + filterable?: boolean; + filterConfig?: FilterConfig; + filterFn?: FilterFn; + groupable?: boolean; + header: ReactNode | string; + headerGroup?: string; + hidden?: boolean; + id: string; + maxWidth?: number; + minWidth?: number; + pinned?: 'left' | 'right'; + renderer?: CellRenderer; + /** Metadata passed to custom renderers (ProgressBar, Badge, Image, Sparkline) */ + rendererMeta?: Record; + searchable?: boolean; + sortable?: boolean; + sortFn?: SortingFn; + width?: number; } // ─── Pagination ────────────────────────────────────────────────────────────── -export interface AdvancedSearchConfig { - enabled: boolean +export interface GriddyDataSource { + data: T[]; + error?: Error; + isLoading?: boolean; + pageInfo?: { cursor?: string; hasNextPage: boolean }; + total?: number; } export interface GriddyProps { // ─── Advanced Search ─── - advancedSearch?: AdvancedSearchConfig + advancedSearch?: AdvancedSearchConfig; // ─── Children (adapters, etc.) ─── - children?: ReactNode + children?: ReactNode; // ─── Styling ─── - className?: string - // ─── Toolbar ─── - /** Show toolbar with export and column visibility controls. Default: false */ - showToolbar?: boolean - /** Export filename. Default: 'export.csv' */ - exportFilename?: string - // ─── Filter Presets ─── - /** Enable filter presets save/load in toolbar. Default: false */ - filterPresets?: boolean + className?: string; // ─── Filtering ─── /** Controlled column filters state */ - columnFilters?: ColumnFiltersState - /** Column definitions */ - columns: GriddyColumn[] + columnFilters?: ColumnFiltersState; /** Controlled column pinning state */ - columnPinning?: ColumnPinningState - onColumnPinningChange?: (pinning: ColumnPinningState) => void - + columnPinning?: ColumnPinningState; + /** Column definitions */ + columns: GriddyColumn[]; /** Data array */ - data: T[] - + data: T[]; // ─── Data Adapter ─── - dataAdapter?: DataAdapter + dataAdapter?: DataAdapter; /** 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 ─── - grouping?: GroupingConfig + dataCount?: number; + /** Export filename. Default: 'export.csv' */ + exportFilename?: string; + // ─── Filter Presets ─── + /** Enable filter presets save/load in toolbar. Default: false */ + filterPresets?: boolean; + + /** Stable row identity function */ + getRowId?: (row: T, index: number) => string; + // ─── Grouping ─── + grouping?: GroupingConfig; /** Container height */ - height?: number | string - // ─── Loading ─── - /** Show loading skeleton/overlay. Default: false */ - isLoading?: boolean + height?: number | string; // ─── Infinite Scroll ─── /** Infinite scroll configuration */ - infiniteScroll?: InfiniteScrollConfig + infiniteScroll?: InfiniteScrollConfig; + + // ─── Loading ─── + /** Show loading skeleton/overlay. Default: false */ + isLoading?: boolean; // ─── Keyboard ─── /** Enable keyboard navigation. Default: true */ - keyboardNavigation?: boolean + keyboardNavigation?: boolean; /** Manual filtering mode - filtering handled externally (server-side). Default: false */ - manualFiltering?: boolean + manualFiltering?: boolean; /** Manual sorting mode - sorting handled externally (server-side). Default: false */ - manualSorting?: boolean + manualSorting?: boolean; + onColumnFiltersChange?: (filters: ColumnFiltersState) => void; + onColumnPinningChange?: (pinning: ColumnPinningState) => void; - onColumnFiltersChange?: (filters: ColumnFiltersState) => void // ─── Editing ─── - onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise | void + 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 + onError?: (error: Error) => void; /** Callback before the error boundary retries rendering */ - onRetry?: () => void - + onRetry?: () => void; /** Selection change callback */ - onRowSelectionChange?: (selection: RowSelectionState) => void + onRowSelectionChange?: (selection: RowSelectionState) => void; - onSortingChange?: (sorting: SortingState) => void + onSortingChange?: (sorting: SortingState) => void; /** Overscan row count. Default: 10 */ - overscan?: number + overscan?: number; // ─── Pagination ─── - pagination?: PaginationConfig + pagination?: PaginationConfig; // ─── Persistence ─── /** localStorage key prefix for persisting column layout */ - persistenceKey?: string + persistenceKey?: string; + // ─── Virtualization ─── /** Row height in pixels. Default: 36 */ - rowHeight?: number + rowHeight?: number; /** Controlled row selection state */ - rowSelection?: RowSelectionState - + rowSelection?: RowSelectionState; // ─── Search ─── - search?: SearchConfig + search?: SearchConfig; // ─── Selection ─── /** Selection configuration */ - selection?: SelectionConfig + selection?: SelectionConfig; + + // ─── Toolbar ─── + /** Show toolbar with export and column visibility controls. Default: false */ + showToolbar?: boolean; // ─── Sorting ─── /** Controlled sorting state */ - sorting?: SortingState + sorting?: SortingState; /** Unique identifier for persistence */ - uniqueId?: string + uniqueId?: string; } // ─── Data Adapter ──────────────────────────────────────────────────────────── export interface GriddyRef { - deselectAll: () => void - focusRow: (index: number) => void - getTable: () => Table - getUIState: () => GriddyUIState - getVirtualizer: () => Virtualizer - scrollToRow: (index: number) => void - selectRow: (id: string) => void - startEditing: (rowId: string, columnId?: string) => void + deselectAll: () => void; + focusRow: (index: number) => void; + getTable: () => Table; + getUIState: () => GriddyUIState; + getVirtualizer: () => Virtualizer; + scrollToRow: (index: number) => void; + selectRow: (id: string) => void; + startEditing: (rowId: string, columnId?: string) => void; } export interface GriddyUIState { - focusedColumnId: null | string + focusedColumnId: null | string; // Focus - focusedRowIndex: null | number + focusedRowIndex: null | number; // Modes - isEditing: boolean - isSearchOpen: boolean - isSelecting: boolean + isEditing: boolean; + isSearchOpen: boolean; + isSelecting: boolean; - moveFocus: (direction: 'down' | 'up', amount: number) => void + moveFocus: (direction: 'down' | 'up', amount: number) => void; - moveFocusToEnd: () => void - moveFocusToStart: () => void - setEditing: (editing: boolean) => void - setFocusedColumn: (id: null | string) => void + moveFocusToEnd: () => void; + moveFocusToStart: () => void; + setEditing: (editing: boolean) => void; + setFocusedColumn: (id: null | string) => void; // Actions - setFocusedRow: (index: null | number) => void - setSearchOpen: (open: boolean) => void - setSelecting: (selecting: boolean) => void - setTotalRows: (count: number) => void + setFocusedRow: (index: null | number) => void; + setSearchOpen: (open: boolean) => void; + setSelecting: (selecting: boolean) => void; + setTotalRows: (count: number) => void; // Row count (synced from table) - totalRows: number + totalRows: number; } export interface GroupingConfig { - columns?: string[] - enabled: boolean + columns?: string[]; + enabled: boolean; } // ─── Grouping ──────────────────────────────────────────────────────────────── export interface InfiniteScrollConfig { /** Enable infinite scroll */ - enabled: boolean - /** Threshold in rows from the end to trigger loading. Default: 10 */ - threshold?: number - /** Callback to load more data. Should update the data array. */ - onLoadMore?: () => Promise | void - /** Whether data is currently loading */ - isLoading?: boolean + enabled: boolean; /** Whether there is more data to load */ - hasMore?: boolean + hasMore?: boolean; + /** Whether data is currently loading */ + isLoading?: boolean; + /** Callback to load more data. Should update the data array. */ + onLoadMore?: () => Promise | void; + /** Threshold in rows from the end to trigger loading. Default: 10 */ + threshold?: number; } export interface PaginationConfig { - enabled: boolean - onPageChange?: (page: number) => void - onPageSizeChange?: (pageSize: number) => void - pageSize: number - pageSizeOptions?: number[] - type: 'cursor' | 'offset' + enabled: boolean; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + pageSize: number; + pageSizeOptions?: number[]; + type: 'cursor' | 'offset'; } // ─── Main Props ────────────────────────────────────────────────────────────── export interface RendererProps { - column: GriddyColumn - columnIndex: number - isEditing?: boolean - row: T - rowIndex: number - searchQuery?: string - value: unknown + column: GriddyColumn; + columnIndex: number; + isEditing?: boolean; + row: T; + rowIndex: number; + searchQuery?: string; + value: unknown; } // ─── UI State (Zustand Store) ──────────────────────────────────────────────── export interface SearchConfig { - caseSensitive?: boolean - debounceMs?: number - enabled: boolean - fuzzy?: boolean - highlightMatches?: boolean - placeholder?: string + caseSensitive?: boolean; + debounceMs?: number; + enabled: boolean; + fuzzy?: boolean; + highlightMatches?: boolean; + placeholder?: string; } // ─── Ref API ───────────────────────────────────────────────────────────────── export interface SelectionConfig { /** 'none' = no selection, 'single' = one row at a time, 'multi' = multiple rows */ - mode: 'multi' | 'none' | 'single' + mode: 'multi' | 'none' | 'single'; /** Maintain selection across pagination/sorting. Default: true */ - preserveSelection?: boolean + preserveSelection?: boolean; /** Allow clicking row body to toggle selection. Default: true */ - selectOnClick?: boolean + selectOnClick?: boolean; /** Show checkbox column (auto-added as first column). Default: true when mode !== 'none' */ - showCheckbox?: boolean + showCheckbox?: boolean; } // ─── Re-exports for convenience ────────────────────────────────────────────── -export type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ExpandedState, GroupingState, PaginationState, RowSelectionState, SortingState, Table, VisibilityState } +export type { + ColumnDef, + ColumnFiltersState, + ColumnOrderState, + ColumnPinningState, + ExpandedState, + GroupingState, + PaginationState, + RowSelectionState, + SortingState, + Table, + VisibilityState, +}; diff --git a/src/Griddy/editors/SelectEditor.tsx b/src/Griddy/editors/SelectEditor.tsx index ccc60fa..fe200c2 100644 --- a/src/Griddy/editors/SelectEditor.tsx +++ b/src/Griddy/editors/SelectEditor.tsx @@ -1,49 +1,59 @@ -import { Select } from '@mantine/core' -import { useEffect, useState } from 'react' +import { Select } from '@mantine/core'; +import { useEffect, useState } from 'react'; -import type { BaseEditorProps, SelectOption } from './types' +import type { BaseEditorProps, SelectOption } from './types'; interface SelectEditorProps extends BaseEditorProps { - options: SelectOption[] + options: SelectOption[]; } -export function SelectEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, options, value }: SelectEditorProps) { - const [selectedValue, setSelectedValue] = useState(value != null ? String(value) : null) +export function SelectEditor({ + autoFocus = true, + onCancel, + onCommit, + onMoveNext, + onMovePrev, + options, + value, +}: SelectEditorProps) { + const [selectedValue, setSelectedValue] = useState( + value != null ? String(value) : null + ); useEffect(() => { - setSelectedValue(value != null ? String(value) : null) - }, [value]) + setSelectedValue(value != null ? String(value) : null); + }, [value]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { - e.preventDefault() + e.preventDefault(); // Find the actual value from options - const option = options.find(opt => String(opt.value) === selectedValue) - onCommit(option?.value ?? selectedValue) + const option = options.find((opt) => String(opt.value) === selectedValue); + onCommit(option?.value ?? selectedValue); } else if (e.key === 'Escape') { - e.preventDefault() - onCancel() + e.preventDefault(); + onCancel(); } else if (e.key === 'Tab') { - e.preventDefault() - const option = options.find(opt => String(opt.value) === selectedValue) - onCommit(option?.value ?? selectedValue) + e.preventDefault(); + const option = options.find((opt) => String(opt.value) === selectedValue); + onCommit(option?.value ?? selectedValue); if (e.shiftKey) { - onMovePrev?.() + onMovePrev?.(); } else { - onMoveNext?.() + onMoveNext?.(); } } - } + }; return (
{key} + {key} + {desc}