refactor(advancedSearch): reorder exports and improve type definitions

refactor(types): reorganize SearchCondition and AdvancedSearchState interfaces
refactor(filterPresets): streamline useFilterPresets hook and localStorage handling
refactor(filtering): clean up ColumnFilterButton and ColumnFilterPopover components
refactor(loading): separate GriddyLoadingOverlay from GriddyLoadingSkeleton
refactor(searchHistory): enhance useSearchHistory hook with persistence
refactor(index): update exports for adapters and core components
refactor(rendering): improve EditableCell and TableCell components for clarity
refactor(rendering): enhance TableHeader and VirtualBody components for better readability
This commit is contained in:
2026-02-15 19:54:33 +02:00
parent 9ec2e73640
commit 7244bd33fc
31 changed files with 3571 additions and 1305 deletions

263
llm/docs/resolvespec-js.md Normal file
View File

@@ -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<T>(schema, entity, id?: number|string|string[], options?): Promise<APIResponse<T>>`
- `create<T>(schema, entity, data: any|any[], options?): Promise<APIResponse<T>>`
- `update<T>(schema, entity, data: any|any[], id?: number|string|string[], options?): Promise<APIResponse<T>>`
- `delete(schema, entity, id: number|string): Promise<APIResponse<void>>`
- `getMetadata(schema, entity): Promise<APIResponse<TableMetadata>>`
## 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<T>(schema, entity, id?: string, options?): Promise<APIResponse<T>>`
- `create<T>(schema, entity, data, options?): Promise<APIResponse<T>>`
- `update<T>(schema, entity, id: string, data, options?): Promise<APIResponse<T>>`
- `delete(schema, entity, id: string): Promise<APIResponse<void>>`
### 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<string, string>;
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<T = any> {
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<string, any>; 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

View File

@@ -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<TState, TProps>(createState?, useValue?)
**Parameters:**
- `createState` (optional): Zustand `StateCreator<TState>` function
- `useValue` (optional): Custom hook receiving `{ useStore, useStoreApi } & TProps`, returns additional state to merge
**Returns:** `SyncStoreReturn<TState, TProps>` 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<Partial<TProps & TState>>` | 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<MyState, MyProps>(
(set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
})
);
function Counter() {
const { count, increment } = useStore();
return <button onClick={increment}>Count: {count}</button>;
}
function App() {
return (
<Provider initialCount={10}>
<Counter />
</Provider>
);
}
```
### With Custom Hook Logic
```tsx
const { Provider, useStore } = createSyncStore<MyState, MyProps>(
(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
<Provider initialCount={10} persist={{ name: 'my-store', storage: localStorage }}>
<Counter />
</Provider>
```
### Selective Prop Syncing
```tsx
<Provider initialCount={10} otherProp="value" firstSyncProps={['initialCount']}>
<Counter />
</Provider>
```
## Internal Types
```typescript
type LocalUseStore<TState, TProps> = TState & TProps;
// Store state includes a $sync method for internal prop syncing
type InternalStoreState<TState, TProps> = TState & TProps & {
$sync: (props: TProps) => void;
};
type SyncStoreReturn<TState, TProps> = {
Provider: (props: { children: ReactNode } & {
firstSyncProps?: string[];
persist?: PersistOptions<Partial<TProps & TState>>;
waitForSync?: boolean;
fallback?: ReactNode;
} & TProps) => React.ReactNode;
useStore: {
(): LocalUseStore<TState, TProps>;
<U>(selector: (state: LocalUseStore<TState, TProps>) => U, equalityFn?: (a: U, b: U) => boolean): U;
};
};
```

View File

@@ -110,6 +110,7 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@warkypublic/artemis-kit": "^1.0.10", "@warkypublic/artemis-kit": "^1.0.10",
"@warkypublic/zustandsyncstore": "^1.0.0", "@warkypublic/zustandsyncstore": "^1.0.0",
"@warkypublic/resolvespec-js": "^1.0.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
"immer": "^10.1.3", "immer": "^10.1.3",
"react": ">= 19.0.0", "react": ">= 19.0.0",

17
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
'@warkypublic/artemis-kit': '@warkypublic/artemis-kit':
specifier: ^1.0.10 specifier: ^1.0.10
version: 1.0.10 version: 1.0.10
'@warkypublic/resolvespec-js':
specifier: ^1.0.1
version: 1.0.1
'@warkypublic/zustandsyncstore': '@warkypublic/zustandsyncstore':
specifier: ^1.0.0 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))) 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==} resolution: {integrity: sha512-qIgjcWqLyYfoKDUYt3Gm7PVe2S4AdjA46J1jPIff1p6wUP5WsHA8UfZq7pEdP6YNxqavv+h84oe1+HsJOoU6jQ==}
engines: {node: '>=14.16'} engines: {node: '>=14.16'}
'@warkypublic/resolvespec-js@1.0.1':
resolution: {integrity: sha512-uXP1HouxpOKXfwE6qpy0gCcrMPIgjDT53aVGkfork4QejRSunbKWSKKawW2nIm7RnyFhSjPILMXcnT5xUiXOew==}
engines: {node: '>=18'}
'@warkypublic/zustandsyncstore@1.0.0': '@warkypublic/zustandsyncstore@1.0.0':
resolution: {integrity: sha512-hvd4Xrn5btEPjJwNgX52ONoZHnAJdF3NcoTK3GJMVrullcZ+tS2W/SCWIa8vTiYBEhdNyavxsNDZT2x/C9GmVg==} resolution: {integrity: sha512-hvd4Xrn5btEPjJwNgX52ONoZHnAJdF3NcoTK3GJMVrullcZ+tS2W/SCWIa8vTiYBEhdNyavxsNDZT2x/C9GmVg==}
peerDependencies: peerDependencies:
@@ -3819,6 +3826,10 @@ packages:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true hasBin: true
uuid@13.0.0:
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
hasBin: true
vary@1.1.2: vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -5516,6 +5527,10 @@ snapshots:
semver: 7.7.3 semver: 7.7.3
uuid: 11.1.0 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)))': '@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: dependencies:
'@warkypublic/artemis-kit': 1.0.10 '@warkypublic/artemis-kit': 1.0.10
@@ -8059,6 +8074,8 @@ snapshots:
uuid@11.1.0: {} uuid@11.1.0: {}
uuid@13.0.0: {}
vary@1.1.2: {} 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))): 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))):

File diff suppressed because it is too large Load Diff

View File

@@ -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<User>[] = [
{ 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<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#d3f9d8',
border: '1px solid #51cf66',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>HeaderSpecAdapter:</strong> Connects Griddy to a HeaderSpec API. Same auto-wiring as
ResolveSpecAdapter but uses HeaderSpecClient.
<div style={{ marginTop: 8 }}>
<button onClick={() => adapterRef.current?.refetch()} type="button">
Manual Refetch
</button>
</div>
</Box>
<Griddy<User>
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',
}}
>
<HeaderSpecAdapter ref={adapterRef} {...props} mode="offset" />
</Griddy>
</Box>
);
}
// ─── Wrapper for HeaderSpecAdapter story ────────────────────────────────────
function ResolveSpecAdapterStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#e7f5ff',
border: '1px solid #339af0',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>ResolveSpecAdapter:</strong> Connects Griddy to a ResolveSpec API. Sorting,
filtering, and pagination are translated to ResolveSpec Options automatically.
<div style={{ marginTop: 8 }}>
<button onClick={() => adapterRef.current?.refetch()} type="button">
Manual Refetch
</button>
</div>
</Box>
<Griddy<User>
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',
}}
>
<ResolveSpecAdapter ref={adapterRef} {...props} mode="offset" />
</Griddy>
</Box>
);
}
// ─── 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<AdapterConfig>;
export default meta;
type Story = StoryObj<typeof meta>;
// ─── 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) => <ResolveSpecAdapterStory {...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) => <HeaderSpecAdapterStory {...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) => <ResolveSpecAdapterStory {...args} />,
};
/** ResolveSpec with custom debounce — slower debounce for expensive queries */
export const WithCustomDebounce: Story = {
args: {
debounceMs: 1000,
},
render: (args) => <ResolveSpecAdapterStory {...args} />,
};
/** ResolveSpec with autoFetch disabled — data only loads on manual refetch */
export const ManualFetchOnly: Story = {
args: {
autoFetch: false,
},
render: (args) => <ResolveSpecAdapterStory {...args} />,
};
/** ResolveSpec with default options merged into every request */
export const WithDefaultOptions: Story = {
args: {
defaultOptions: {
limit: 50,
sort: [{ column: 'name', direction: 'asc' }],
},
},
render: (args) => <ResolveSpecAdapterStory {...args} />,
};
// ─── Cursor / Infinite Scroll Stories ────────────────────────────────────────
function HeaderSpecInfiniteScrollStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#d3f9d8',
border: '1px solid #51cf66',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>HeaderSpec cursor mode:</strong> HeaderSpecAdapter with cursor-based infinite
scroll.
</Box>
<Griddy<User>
columns={columns}
data={[]}
getRowId={(row) => String(row.id)}
height={500}
manualFiltering
manualSorting
>
<HeaderSpecAdapter ref={adapterRef} {...props} mode="cursor" />
</Griddy>
</Box>
);
}
function InfiniteScrollStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#fff3bf',
border: '1px solid #fab005',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>Cursor mode (default):</strong> Uses cursor-based pagination with infinite scroll.
Scroll to the bottom to load more rows automatically.
</Box>
<Griddy<User>
columns={columns}
data={[]}
getRowId={(row) => String(row.id)}
height={500}
manualFiltering
manualSorting
>
<ResolveSpecAdapter ref={adapterRef} {...props} mode="cursor" />
</Griddy>
</Box>
);
}
function OffsetPaginationStory(props: AdapterConfig) {
const adapterRef = useRef<AdapterRef>(null);
return (
<Box h="100%" mih="500px" w="100%">
<Box
mb="sm"
p="xs"
style={{
background: '#ffe3e3',
border: '1px solid #fa5252',
borderRadius: 4,
fontSize: 13,
}}
>
<strong>Offset mode:</strong> Uses traditional offset/limit pagination with page controls.
</Box>
<Griddy<User>
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',
}}
>
<ResolveSpecAdapter ref={adapterRef} {...props} mode="offset" />
</Griddy>
</Box>
);
}
/** ResolveSpec with cursor pagination and infinite scroll (default adapter mode) */
export const WithInfiniteScroll: Story = {
args: {
baseUrl: 'https://utils.btsys.tech/api',
},
render: (args) => <InfiniteScrollStory {...args} />,
};
/** ResolveSpec with explicit cursor pagination config */
export const WithCursorPagination: Story = {
args: {
cursorField: 'id',
pageSize: 50,
},
render: (args) => <InfiniteScrollStory {...args} />,
};
/** ResolveSpec with offset pagination controls */
export const WithOffsetPagination: Story = {
args: {
pageSize: 25,
},
render: (args) => <OffsetPaginationStory {...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) => <HeaderSpecInfiniteScrollStory {...args} />,
};

View File

@@ -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<AdapterRef, AdapterConfig>(
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 | ReturnType<typeof setTimeout>>(null);
const mountedRef = useRef(true);
// Cursor state (only used in cursor mode)
const cursorRef = useRef<null | string>(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 | string>(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;
}
);

View File

@@ -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<AdapterRef, AdapterConfig>(
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 | ReturnType<typeof setTimeout>>(null);
const mountedRef = useRef(true);
// Cursor state (only used in cursor mode)
const cursorRef = useRef<null | string>(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 | string>(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;
}
);

View File

@@ -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'

View File

@@ -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<string, string> = {
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<string, string>,
defaultOptions?: Partial<Options>
): 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<string, string>
): 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<string, string>
): 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, string>): 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;
}

View File

@@ -0,0 +1,30 @@
import type {
ComputedColumn,
CustomOperator,
Options,
PreloadOption,
} from '@warkypublic/resolvespec-js';
export interface AdapterConfig {
autoFetch?: boolean;
baseUrl: string;
columnMap?: Record<string, string>;
computedColumns?: ComputedColumn[];
/** Field to extract cursor value from last row. Default: 'id' */
cursorField?: string;
customOperators?: CustomOperator[];
debounceMs?: number;
defaultOptions?: Partial<Options>;
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<void>;
}

View File

@@ -15,25 +15,33 @@ import {
type SortingState, type SortingState,
useReactTable, useReactTable,
type VisibilityState, type VisibilityState,
} from '@tanstack/react-table' } from '@tanstack/react-table';
import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' 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 { advancedSearchGlobalFilterFn, AdvancedSearchPanel } from '../features/advancedSearch';
import { GriddyErrorBoundary } from '../features/errorBoundary' import { GriddyErrorBoundary } from '../features/errorBoundary';
import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation' import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation';
import { GriddyLoadingOverlay, GriddyLoadingSkeleton } from '../features/loading' import { GriddyLoadingOverlay, GriddyLoadingSkeleton } from '../features/loading';
import { PaginationControl } from '../features/pagination' import { PaginationControl } from '../features/pagination';
import { SearchOverlay } from '../features/search/SearchOverlay' import { SearchOverlay } from '../features/search/SearchOverlay';
import { GridToolbar } from '../features/toolbar' import { GridToolbar } from '../features/toolbar';
import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer' import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer';
import { TableHeader } from '../rendering/TableHeader' import { TableHeader } from '../rendering/TableHeader';
import { VirtualBody } from '../rendering/VirtualBody' import { VirtualBody } from '../rendering/VirtualBody';
import styles from '../styles/griddy.module.css' import styles from '../styles/griddy.module.css';
import { mapColumns } from './columnMapper' import { mapColumns } from './columnMapper';
import { CSS, DEFAULTS } from './constants' import { CSS, DEFAULTS } from './constants';
import { GriddyProvider, useGriddyStore } from './GriddyStore' import { GriddyProvider, useGriddyStore } from './GriddyStore';
// ─── Inner Component (lives inside Provider, has store access) ─────────────── // ─── Inner Component (lives inside Provider, has store access) ───────────────
@@ -45,119 +53,119 @@ function _Griddy<T>(props: GriddyProps<T>, ref: Ref<GriddyRef<T>>) {
</GriddyErrorBoundary> </GriddyErrorBoundary>
{props.children} {props.children}
</GriddyProvider> </GriddyProvider>
) );
} }
// ─── Main Component with forwardRef ────────────────────────────────────────── // ─── Main Component with forwardRef ──────────────────────────────────────────
function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) { function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
// Read props from synced store // Read props from synced store
const data = useGriddyStore((s) => s.data) const data = useGriddyStore((s) => s.data);
const userColumns = useGriddyStore((s) => s.columns) const userColumns = useGriddyStore((s) => s.columns);
const getRowId = useGriddyStore((s) => s.getRowId) const getRowId = useGriddyStore((s) => s.getRowId);
const selection = useGriddyStore((s) => s.selection) const selection = useGriddyStore((s) => s.selection);
const search = useGriddyStore((s) => s.search) const search = useGriddyStore((s) => s.search);
const groupingConfig = useGriddyStore((s) => s.grouping) const groupingConfig = useGriddyStore((s) => s.grouping);
const paginationConfig = useGriddyStore((s) => s.pagination) const paginationConfig = useGriddyStore((s) => s.pagination);
const controlledSorting = useGriddyStore((s) => s.sorting) const controlledSorting = useGriddyStore((s) => s.sorting);
const onSortingChange = useGriddyStore((s) => s.onSortingChange) const onSortingChange = useGriddyStore((s) => s.onSortingChange);
const controlledFilters = useGriddyStore((s) => s.columnFilters) const controlledFilters = useGriddyStore((s) => s.columnFilters);
const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange) const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange);
const controlledPinning = useGriddyStore((s) => s.columnPinning) const controlledPinning = useGriddyStore((s) => s.columnPinning);
const onColumnPinningChange = useGriddyStore((s) => s.onColumnPinningChange) const onColumnPinningChange = useGriddyStore((s) => s.onColumnPinningChange);
const controlledRowSelection = useGriddyStore((s) => s.rowSelection) const controlledRowSelection = useGriddyStore((s) => s.rowSelection);
const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange) const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange);
const onEditCommit = useGriddyStore((s) => s.onEditCommit) const onEditCommit = useGriddyStore((s) => s.onEditCommit);
const rowHeight = useGriddyStore((s) => s.rowHeight) const rowHeight = useGriddyStore((s) => s.rowHeight);
const overscanProp = useGriddyStore((s) => s.overscan) const overscanProp = useGriddyStore((s) => s.overscan);
const height = useGriddyStore((s) => s.height) const height = useGriddyStore((s) => s.height);
const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation) const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation);
const className = useGriddyStore((s) => s.className) const className = useGriddyStore((s) => s.className);
const showToolbar = useGriddyStore((s) => s.showToolbar) const showToolbar = useGriddyStore((s) => s.showToolbar);
const exportFilename = useGriddyStore((s) => s.exportFilename) const exportFilename = useGriddyStore((s) => s.exportFilename);
const isLoading = useGriddyStore((s) => s.isLoading) const isLoading = useGriddyStore((s) => s.isLoading);
const filterPresets = useGriddyStore((s) => s.filterPresets) const filterPresets = useGriddyStore((s) => s.filterPresets);
const advancedSearch = useGriddyStore((s) => s.advancedSearch) const advancedSearch = useGriddyStore((s) => s.advancedSearch);
const persistenceKey = useGriddyStore((s) => s.persistenceKey) const persistenceKey = useGriddyStore((s) => s.persistenceKey);
const manualSorting = useGriddyStore((s) => s.manualSorting) const manualSorting = useGriddyStore((s) => s.manualSorting);
const manualFiltering = useGriddyStore((s) => s.manualFiltering) const manualFiltering = useGriddyStore((s) => s.manualFiltering);
const dataCount = useGriddyStore((s) => s.dataCount) const dataCount = useGriddyStore((s) => s.dataCount);
const setTable = useGriddyStore((s) => s.setTable) const setTable = useGriddyStore((s) => s.setTable);
const setVirtualizer = useGriddyStore((s) => s.setVirtualizer) const setVirtualizer = useGriddyStore((s) => s.setVirtualizer);
const setScrollRef = useGriddyStore((s) => s.setScrollRef) const setScrollRef = useGriddyStore((s) => s.setScrollRef);
const setFocusedRow = useGriddyStore((s) => s.setFocusedRow) const setFocusedRow = useGriddyStore((s) => s.setFocusedRow);
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn) const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn);
const setEditing = useGriddyStore((s) => s.setEditing) const setEditing = useGriddyStore((s) => s.setEditing);
const setTotalRows = useGriddyStore((s) => s.setTotalRows) const setTotalRows = useGriddyStore((s) => s.setTotalRows);
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex) const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex);
const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight;
const effectiveOverscan = overscanProp ?? DEFAULTS.overscan const effectiveOverscan = overscanProp ?? DEFAULTS.overscan;
const enableKeyboard = keyboardNavigation !== false const enableKeyboard = keyboardNavigation !== false;
// ─── Column Mapping ─── // ─── Column Mapping ───
const columns = useMemo( const columns = useMemo(
() => mapColumns(userColumns ?? [], selection) as ColumnDef<T, any>[], () => mapColumns(userColumns ?? [], selection) as ColumnDef<T, any>[],
[userColumns, selection], [userColumns, selection]
) );
// ─── Table State (internal/uncontrolled) ─── // ─── Table State (internal/uncontrolled) ───
const [internalSorting, setInternalSorting] = useState<SortingState>([]) const [internalSorting, setInternalSorting] = useState<SortingState>([]);
const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>([]) const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>([]);
const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({}) const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({});
const [globalFilter, setGlobalFilter] = useState<string | undefined>(undefined) const [globalFilter, setGlobalFilter] = useState<string | undefined>(undefined);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([]) const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([]);
// Build initial column pinning from column definitions // Build initial column pinning from column definitions
const initialPinning = useMemo(() => { const initialPinning = useMemo(() => {
const left: string[] = [] const left: string[] = [];
const right: string[] = [] const right: string[] = [];
userColumns?.forEach(col => { userColumns?.forEach((col) => {
if (col.pinned === 'left') left.push(col.id) if (col.pinned === 'left') left.push(col.id);
else if (col.pinned === 'right') right.push(col.id) else if (col.pinned === 'right') right.push(col.id);
}) });
return { left, right } return { left, right };
}, [userColumns]) }, [userColumns]);
const [internalPinning, setInternalPinning] = useState<ColumnPinningState>(initialPinning) const [internalPinning, setInternalPinning] = useState<ColumnPinningState>(initialPinning);
const [grouping, setGrouping] = useState<GroupingState>(groupingConfig?.columns ?? []) const [grouping, setGrouping] = useState<GroupingState>(groupingConfig?.columns ?? []);
const [expanded, setExpanded] = useState({}) const [expanded, setExpanded] = useState({});
const [internalPagination, setInternalPagination] = useState<PaginationState>({ const [internalPagination, setInternalPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize, pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize,
}) });
// Wrap pagination setters to call callbacks // Wrap pagination setters to call callbacks
const handlePaginationChange = (updater: any) => { const handlePaginationChange = (updater: any) => {
setInternalPagination(prev => { setInternalPagination((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater const next = typeof updater === 'function' ? updater(prev) : updater;
// Call callbacks if pagination config exists // Call callbacks if pagination config exists
if (paginationConfig) { if (paginationConfig) {
if (next.pageIndex !== prev.pageIndex && paginationConfig.onPageChange) { if (next.pageIndex !== prev.pageIndex && paginationConfig.onPageChange) {
paginationConfig.onPageChange(next.pageIndex) paginationConfig.onPageChange(next.pageIndex);
} }
if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) { if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) {
paginationConfig.onPageSizeChange(next.pageSize) paginationConfig.onPageSizeChange(next.pageSize);
} }
} }
return next return next;
}) });
} };
// Resolve controlled vs uncontrolled // Resolve controlled vs uncontrolled
const sorting = controlledSorting ?? internalSorting const sorting = controlledSorting ?? internalSorting;
const setSorting = onSortingChange ?? setInternalSorting const setSorting = onSortingChange ?? setInternalSorting;
const columnFilters = controlledFilters ?? internalFilters const columnFilters = controlledFilters ?? internalFilters;
const setColumnFilters = onColumnFiltersChange ?? setInternalFilters const setColumnFilters = onColumnFiltersChange ?? setInternalFilters;
const columnPinning = controlledPinning ?? internalPinning const columnPinning = controlledPinning ?? internalPinning;
const setColumnPinning = onColumnPinningChange ?? setInternalPinning const setColumnPinning = onColumnPinningChange ?? setInternalPinning;
const rowSelectionState = controlledRowSelection ?? internalRowSelection const rowSelectionState = controlledRowSelection ?? internalRowSelection;
const setRowSelection = onRowSelectionChange ?? setInternalRowSelection const setRowSelection = onRowSelectionChange ?? setInternalRowSelection;
// ─── Selection config ─── // ─── Selection config ───
const enableRowSelection = selection ? selection.mode !== 'none' : false const enableRowSelection = selection ? selection.mode !== 'none' : false;
const enableMultiRowSelection = selection?.mode === 'multi' const enableMultiRowSelection = selection?.mode === 'multi';
// ─── TanStack Table Instance ─── // ─── TanStack Table Instance ───
const table = useReactTable<T>({ const table = useReactTable<T>({
@@ -177,7 +185,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
getExpandedRowModel: getExpandedRowModel(), getExpandedRowModel: getExpandedRowModel(),
getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(), getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(),
getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined, getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined,
getRowId: getRowId as any ?? ((_, index) => String(index)), getRowId: (getRowId as any) ?? ((_, index) => String(index)),
getSortedRowModel: manualSorting ? undefined : getSortedRowModel(), getSortedRowModel: manualSorting ? undefined : getSortedRowModel(),
manualFiltering: manualFiltering ?? false, manualFiltering: manualFiltering ?? false,
manualSorting: manualSorting ?? false, manualSorting: manualSorting ?? false,
@@ -206,10 +214,10 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
}, },
...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}), ...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}),
columnResizeMode: 'onChange', columnResizeMode: 'onChange',
}) });
// ─── Scroll Container Ref ─── // ─── Scroll Container Ref ───
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null);
// ─── TanStack Virtual ─── // ─── TanStack Virtual ───
const virtualizer = useGridVirtualizer({ const virtualizer = useGridVirtualizer({
@@ -217,16 +225,22 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
rowHeight: effectiveRowHeight, rowHeight: effectiveRowHeight,
scrollRef, scrollRef,
table, table,
}) });
// ─── Sync table + virtualizer + scrollRef into store ─── // ─── Sync table + virtualizer + scrollRef into store ───
useEffect(() => { setTable(table) }, [table, setTable]) useEffect(() => {
useEffect(() => { setVirtualizer(virtualizer) }, [virtualizer, setVirtualizer]) setTable(table);
useEffect(() => { setScrollRef(scrollRef.current) }, [setScrollRef]) }, [table, setTable]);
useEffect(() => {
setVirtualizer(virtualizer);
}, [virtualizer, setVirtualizer]);
useEffect(() => {
setScrollRef(scrollRef.current);
}, [setScrollRef]);
// ─── Keyboard Navigation ─── // ─── Keyboard Navigation ───
// Get the full store state for imperative access in keyboard handler // Get the full store state for imperative access in keyboard handler
const storeState = useGriddyStore() const storeState = useGriddyStore();
useKeyboardNavigation({ useKeyboardNavigation({
editingEnabled: !!onEditCommit, editingEnabled: !!onEditCommit,
@@ -236,59 +250,64 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
storeState, storeState,
table, table,
virtualizer, virtualizer,
}) });
// ─── Set initial focus when data loads ─── // ─── Set initial focus when data loads ───
const rowCount = table.getRowModel().rows.length const rowCount = table.getRowModel().rows.length;
useEffect(() => { useEffect(() => {
setTotalRows(rowCount) setTotalRows(rowCount);
if (rowCount > 0 && focusedRowIndex === null) { if (rowCount > 0 && focusedRowIndex === null) {
setFocusedRow(0) setFocusedRow(0);
} }
}, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow]) }, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow]);
// ─── Imperative Ref ─── // ─── Imperative Ref ───
useImperativeHandle(tableRef, () => ({ useImperativeHandle(
tableRef,
() => ({
deselectAll: () => table.resetRowSelection(), deselectAll: () => table.resetRowSelection(),
focusRow: (index: number) => { focusRow: (index: number) => {
setFocusedRow(index) setFocusedRow(index);
virtualizer.scrollToIndex(index, { align: 'auto' }) virtualizer.scrollToIndex(index, { align: 'auto' });
}, },
getTable: () => table, getTable: () => table,
getUIState: () => ({ getUIState: () =>
({
focusedColumnId: null, focusedColumnId: null,
focusedRowIndex, focusedRowIndex,
isEditing: false, isEditing: false,
isSearchOpen: false, isSearchOpen: false,
isSelecting: false, isSelecting: false,
totalRows: rowCount, totalRows: rowCount,
} as any), }) as any,
getVirtualizer: () => virtualizer, getVirtualizer: () => virtualizer,
scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }), scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }),
selectRow: (id: string) => { selectRow: (id: string) => {
const row = table.getRowModel().rows.find((r) => r.id === id) const row = table.getRowModel().rows.find((r) => r.id === id);
row?.toggleSelected(true) row?.toggleSelected(true);
}, },
startEditing: (rowId: string, columnId?: string) => { startEditing: (rowId: string, columnId?: string) => {
const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId) const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId);
if (rowIndex >= 0) { if (rowIndex >= 0) {
setFocusedRow(rowIndex) setFocusedRow(rowIndex);
if (columnId) setFocusedColumn(columnId) if (columnId) setFocusedColumn(columnId);
setEditing(true) setEditing(true);
} }
}, },
}), [table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount]) }),
[table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount]
);
// ─── Render ─── // ─── Render ───
const containerStyle: React.CSSProperties = { const containerStyle: React.CSSProperties = {
height: height ?? '100%', height: height ?? '100%',
overflow: 'auto', overflow: 'auto',
position: 'relative', position: 'relative',
} };
const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null;
const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined;
return ( return (
<div <div
@@ -325,15 +344,12 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
)} )}
</div> </div>
{paginationConfig?.enabled && ( {paginationConfig?.enabled && (
<PaginationControl <PaginationControl pageSizeOptions={paginationConfig.pageSizeOptions} table={table} />
pageSizeOptions={paginationConfig.pageSizeOptions}
table={table}
/>
)} )}
</div> </div>
) );
} }
export const Griddy = forwardRef(_Griddy) as <T>( export const Griddy = forwardRef(_Griddy) as <T>(
props: GriddyProps<T> & React.RefAttributes<GriddyRef<T>> props: GriddyProps<T> & React.RefAttributes<GriddyRef<T>>
) => React.ReactElement ) => React.ReactElement;

View File

@@ -1,10 +1,26 @@
import type { Table } from '@tanstack/react-table' import type { Table } from '@tanstack/react-table';
import type { ColumnFiltersState, ColumnPinningState, RowSelectionState, SortingState } from '@tanstack/react-table' import type {
import type { Virtualizer } from '@tanstack/react-virtual' 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 ───────────────────────────────────────────────────────────── // ─── 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. * Fields from GriddyProps must be declared here so TypeScript can see them.
*/ */
export interface GriddyStoreState extends GriddyUIState { export interface GriddyStoreState extends GriddyUIState {
_scrollRef: HTMLDivElement | null _scrollRef: HTMLDivElement | null;
// ─── Internal refs (set imperatively) ─── // ─── Internal refs (set imperatively) ───
_table: null | Table<any> _table: null | Table<any>;
_virtualizer: null | Virtualizer<HTMLDivElement, Element> _virtualizer: null | Virtualizer<HTMLDivElement, Element>;
advancedSearch?: AdvancedSearchConfig advancedSearch?: AdvancedSearchConfig;
className?: string // ─── Adapter Actions ───
columnFilters?: ColumnFiltersState appendData: (data: any[]) => void;
columns?: GriddyColumn<any>[] className?: string;
columnPinning?: ColumnPinningState columnFilters?: ColumnFiltersState;
onColumnPinningChange?: (pinning: ColumnPinningState) => void columnPinning?: ColumnPinningState;
data?: any[] columns?: GriddyColumn<any>[];
data?: any[];
dataAdapter?: DataAdapter<any>;
dataCount?: number;
// ─── Error State ─── // ─── Error State ───
error: Error | null error: Error | null;
exportFilename?: string exportFilename?: string;
dataAdapter?: DataAdapter<any> filterPresets?: boolean;
dataCount?: number getRowId?: (row: any, index: number) => string;
filterPresets?: boolean grouping?: GroupingConfig;
getRowId?: (row: any, index: number) => string height?: number | string;
grouping?: GroupingConfig infiniteScroll?: InfiniteScrollConfig;
height?: number | string isLoading?: boolean;
infiniteScroll?: InfiniteScrollConfig keyboardNavigation?: boolean;
isLoading?: boolean manualFiltering?: boolean;
keyboardNavigation?: boolean manualSorting?: boolean;
manualFiltering?: boolean onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
manualSorting?: boolean onColumnPinningChange?: (pinning: ColumnPinningState) => void;
onColumnFiltersChange?: (filters: ColumnFiltersState) => void onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void;
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void onError?: (error: Error) => void;
onError?: (error: Error) => void onRowSelectionChange?: (selection: RowSelectionState) => void;
onRowSelectionChange?: (selection: RowSelectionState) => void onSortingChange?: (sorting: SortingState) => void;
onSortingChange?: (sorting: SortingState) => void overscan?: number;
overscan?: number pagination?: PaginationConfig;
pagination?: PaginationConfig persistenceKey?: string;
persistenceKey?: string rowHeight?: number;
rowHeight?: number rowSelection?: RowSelectionState;
rowSelection?: RowSelectionState
search?: SearchConfig
selection?: SelectionConfig search?: SearchConfig;
setError: (error: Error | null) => void selection?: SelectionConfig;
showToolbar?: boolean setData: (data: any[]) => void;
setScrollRef: (el: HTMLDivElement | null) => 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 ─── // ─── Internal ref setters ───
setTable: (table: Table<any>) => void setTable: (table: Table<any>) => void;
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void showToolbar?: boolean;
sorting?: SortingState sorting?: SortingState;
// ─── Synced from GriddyProps (written by $sync) ─── // ─── Synced from GriddyProps (written by $sync) ───
uniqueId?: string uniqueId?: string;
} }
// ─── Create Store ──────────────────────────────────────────────────────────── // ─── Create Store ────────────────────────────────────────────────────────────
@@ -69,13 +92,13 @@ export interface GriddyStoreState extends GriddyUIState {
export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore< export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
GriddyStoreState, GriddyStoreState,
GriddyProps<any> GriddyProps<any>
>( >((set, get) => ({
(set, get) => ({
_scrollRef: null, _scrollRef: null,
// ─── Internal Refs ─── // ─── Internal Refs ───
_table: null, _table: null,
_virtualizer: null, _virtualizer: null,
appendData: (data) => set((state) => ({ data: [...(state.data ?? []), ...data] })),
error: null, error: null,
focusedColumnId: null, focusedColumnId: null,
// ─── Focus State ─── // ─── Focus State ───
@@ -87,23 +110,27 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync
isSearchOpen: false, isSearchOpen: false,
isSelecting: false, isSelecting: false,
moveFocus: (direction, amount) => { moveFocus: (direction, amount) => {
const { focusedRowIndex, totalRows } = get() const { focusedRowIndex, totalRows } = get();
const current = focusedRowIndex ?? 0 const current = focusedRowIndex ?? 0;
const delta = direction === 'down' ? amount : -amount const delta = direction === 'down' ? amount : -amount;
const next = Math.max(0, Math.min(current + delta, totalRows - 1)) const next = Math.max(0, Math.min(current + delta, totalRows - 1));
set({ focusedRowIndex: next }) set({ focusedRowIndex: next });
}, },
moveFocusToEnd: () => { moveFocusToEnd: () => {
const { totalRows } = get() const { totalRows } = get();
set({ focusedRowIndex: Math.max(0, totalRows - 1) }) set({ focusedRowIndex: Math.max(0, totalRows - 1) });
}, },
moveFocusToStart: () => set({ focusedRowIndex: 0 }), moveFocusToStart: () => set({ focusedRowIndex: 0 }),
setData: (data) => set({ data }),
setDataCount: (count) => set({ dataCount: count }),
setEditing: (editing) => set({ isEditing: editing }), setEditing: (editing) => set({ isEditing: editing }),
setError: (error) => set({ error }), setError: (error) => set({ error }),
setFocusedColumn: (id) => set({ focusedColumnId: id }), setFocusedColumn: (id) => set({ focusedColumnId: id }),
// ─── Actions ─── // ─── Actions ───
setFocusedRow: (index) => set({ focusedRowIndex: index }), setFocusedRow: (index) => set({ focusedRowIndex: index }),
setInfiniteScroll: (config) => set({ infiniteScroll: config }),
setIsLoading: (loading) => set({ isLoading: loading }),
setScrollRef: (el) => set({ _scrollRef: el }), setScrollRef: (el) => set({ _scrollRef: el }),
setSearchOpen: (open) => set({ isSearchOpen: open }), setSearchOpen: (open) => set({ isSearchOpen: open }),
@@ -116,5 +143,4 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }), setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
// ─── Row Count ─── // ─── Row Count ───
totalRows: 0, totalRows: 0,
}), }));
)

View File

@@ -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 { createOperatorFilter } from '../features/filtering';
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants' import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants';
/** /**
* Retrieves the original GriddyColumn from a TanStack column's meta. * Retrieves the original GriddyColumn from a TanStack column's meta.
*/ */
export function getGriddyColumn<T>(column: { columnDef: ColumnDef<T> }): GriddyColumn<T> | undefined { export function getGriddyColumn<T>(column: {
return (column.columnDef.meta as { griddy?: GriddyColumn<T> })?.griddy columnDef: ColumnDef<T>;
}): GriddyColumn<T> | undefined {
return (column.columnDef.meta as { griddy?: GriddyColumn<T> })?.griddy;
}
/**
* Maps Griddy's user-facing GriddyColumn<T> definitions to TanStack Table ColumnDef<T>[].
* Supports header grouping and optionally prepends a selection checkbox column.
*/
export function mapColumns<T>(
columns: GriddyColumn<T>[],
selection?: SelectionConfig
): ColumnDef<T>[] {
// Group columns by headerGroup
const grouped = new Map<string, GriddyColumn<T>[]>();
const ungrouped: GriddyColumn<T>[] = [];
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<T>[] = [];
// Add ungrouped columns first
ungrouped.forEach((col) => {
mapped.push(mapSingleColumn(col));
});
// Add grouped columns
grouped.forEach((groupColumns, groupName) => {
const groupDef: ColumnDef<T> = {
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<T> = {
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 * Converts a single GriddyColumn to a TanStack ColumnDef
*/ */
function mapSingleColumn<T>(col: GriddyColumn<T>): ColumnDef<T> { function mapSingleColumn<T>(col: GriddyColumn<T>): ColumnDef<T> {
const isStringAccessor = typeof col.accessor !== 'function' const isStringAccessor = typeof col.accessor !== 'function';
const def: ColumnDef<T> = { const def: ColumnDef<T> = {
id: col.id, id: col.id,
@@ -37,92 +100,33 @@ function mapSingleColumn<T>(col: GriddyColumn<T>): ColumnDef<T> {
meta: { griddy: col }, meta: { griddy: col },
minSize: col.minWidth ?? DEFAULTS.minColumnWidth, minSize: col.minWidth ?? DEFAULTS.minColumnWidth,
size: col.width, size: col.width,
} };
// For function accessors, TanStack can't auto-detect the sort type, so provide a default // For function accessors, TanStack can't auto-detect the sort type, so provide a default
if (col.sortFn) { if (col.sortFn) {
def.sortingFn = col.sortFn def.sortingFn = col.sortFn;
} else if (!isStringAccessor && col.sortable !== false) { } else if (!isStringAccessor && col.sortable !== false) {
// Use alphanumeric sorting for function accessors // Use alphanumeric sorting for function accessors
def.sortingFn = 'alphanumeric' def.sortingFn = 'alphanumeric';
} }
if (col.filterFn) { if (col.filterFn) {
def.filterFn = col.filterFn def.filterFn = col.filterFn;
} else if (col.filterable) { } else if (col.filterable) {
def.filterFn = createOperatorFilter() def.filterFn = createOperatorFilter();
} }
if (col.renderer) { if (col.renderer) {
const renderer = col.renderer const renderer = col.renderer;
def.cell = (info) => renderer({ def.cell = (info) =>
renderer({
column: col, column: col,
columnIndex: info.cell.column.getIndex(), columnIndex: info.cell.column.getIndex(),
row: info.row.original, row: info.row.original,
rowIndex: info.row.index, rowIndex: info.row.index,
value: info.getValue(), value: info.getValue(),
}) });
} }
return def return def;
}
/**
* Maps Griddy's user-facing GriddyColumn<T> definitions to TanStack Table ColumnDef<T>[].
* Supports header grouping and optionally prepends a selection checkbox column.
*/
export function mapColumns<T>(
columns: GriddyColumn<T>[],
selection?: SelectionConfig,
): ColumnDef<T>[] {
// Group columns by headerGroup
const grouped = new Map<string, GriddyColumn<T>[]>()
const ungrouped: GriddyColumn<T>[] = []
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<T>[] = []
// Add ungrouped columns first
ungrouped.forEach(col => {
mapped.push(mapSingleColumn(col))
})
// Add grouped columns
grouped.forEach((groupColumns, groupName) => {
const groupDef: ColumnDef<T> = {
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<T> = {
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
} }

View File

@@ -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 {
import type { Virtualizer } from '@tanstack/react-virtual' ColumnDef,
import type { ReactNode } from 'react' 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 { EditorConfig } from '../editors';
import type { FilterConfig } from '../features/filtering' import type { FilterConfig } from '../features/filtering';
// ─── Column Definition ─────────────────────────────────────────────────────── // ─── Column Definition ───────────────────────────────────────────────────────
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode export interface AdvancedSearchConfig {
enabled: boolean;
}
// ─── Cell Rendering ────────────────────────────────────────────────────────── // ─── Cell Rendering ──────────────────────────────────────────────────────────
export interface DataAdapter<T> { export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode;
delete?: (row: T) => Promise<void>
fetch: (config: FetchConfig) => Promise<GriddyDataSource<T>>
save?: (row: T) => Promise<void>
}
export type EditorComponent<T> = (props: EditorProps<T>) => ReactNode export interface DataAdapter<T> {
delete?: (row: T) => Promise<void>;
fetch: (config: FetchConfig) => Promise<GriddyDataSource<T>>;
save?: (row: T) => Promise<void>;
}
// ─── Editors ───────────────────────────────────────────────────────────────── // ─── Editors ─────────────────────────────────────────────────────────────────
export interface EditorProps<T> { export type EditorComponent<T> = (props: EditorProps<T>) => ReactNode;
column: GriddyColumn<T>
onCancel: () => void
onCommit: (newValue: unknown) => void
onMoveNext: () => void
onMovePrev: () => void
row: T
rowIndex: number
value: unknown
}
export interface FetchConfig { export interface EditorProps<T> {
cursor?: string column: GriddyColumn<T>;
filters?: ColumnFiltersState onCancel: () => void;
globalFilter?: string onCommit: (newValue: unknown) => void;
page?: number onMoveNext: () => void;
pageSize?: number onMovePrev: () => void;
sorting?: SortingState row: T;
rowIndex: number;
value: unknown;
} }
// ─── Selection ─────────────────────────────────────────────────────────────── // ─── Selection ───────────────────────────────────────────────────────────────
export interface GriddyColumn<T> { export interface FetchConfig {
accessor: ((row: T) => unknown) | keyof T cursor?: string;
aggregationFn?: 'sum' | 'min' | 'max' | 'mean' | 'median' | 'unique' | 'uniqueCount' | 'count' filters?: ColumnFiltersState;
editable?: ((row: T) => boolean) | boolean globalFilter?: string;
editor?: EditorComponent<T> page?: number;
editorConfig?: EditorConfig pageSize?: number;
filterable?: boolean sorting?: SortingState;
filterConfig?: FilterConfig
filterFn?: FilterFn<T>
groupable?: boolean
header: ReactNode | string
headerGroup?: string
hidden?: boolean
id: string
maxWidth?: number
minWidth?: number
pinned?: 'left' | 'right'
renderer?: CellRenderer<T>
/** Metadata passed to custom renderers (ProgressBar, Badge, Image, Sparkline) */
rendererMeta?: Record<string, unknown>
searchable?: boolean
sortable?: boolean
sortFn?: SortingFn<T>
width?: number
} }
// ─── Search ────────────────────────────────────────────────────────────────── // ─── Search ──────────────────────────────────────────────────────────────────
export interface GriddyDataSource<T> { export interface GriddyColumn<T> {
data: T[] accessor: ((row: T) => unknown) | keyof T;
error?: Error aggregationFn?: 'count' | 'max' | 'mean' | 'median' | 'min' | 'sum' | 'unique' | 'uniqueCount';
isLoading?: boolean editable?: ((row: T) => boolean) | boolean;
pageInfo?: { cursor?: string; hasNextPage: boolean; } editor?: EditorComponent<T>;
total?: number editorConfig?: EditorConfig;
filterable?: boolean;
filterConfig?: FilterConfig;
filterFn?: FilterFn<T>;
groupable?: boolean;
header: ReactNode | string;
headerGroup?: string;
hidden?: boolean;
id: string;
maxWidth?: number;
minWidth?: number;
pinned?: 'left' | 'right';
renderer?: CellRenderer<T>;
/** Metadata passed to custom renderers (ProgressBar, Badge, Image, Sparkline) */
rendererMeta?: Record<string, unknown>;
searchable?: boolean;
sortable?: boolean;
sortFn?: SortingFn<T>;
width?: number;
} }
// ─── Pagination ────────────────────────────────────────────────────────────── // ─── Pagination ──────────────────────────────────────────────────────────────
export interface AdvancedSearchConfig { export interface GriddyDataSource<T> {
enabled: boolean data: T[];
error?: Error;
isLoading?: boolean;
pageInfo?: { cursor?: string; hasNextPage: boolean };
total?: number;
} }
export interface GriddyProps<T> { export interface GriddyProps<T> {
// ─── Advanced Search ─── // ─── Advanced Search ───
advancedSearch?: AdvancedSearchConfig advancedSearch?: AdvancedSearchConfig;
// ─── Children (adapters, etc.) ─── // ─── Children (adapters, etc.) ───
children?: ReactNode children?: ReactNode;
// ─── Styling ─── // ─── Styling ───
className?: string 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
// ─── Filtering ─── // ─── Filtering ───
/** Controlled column filters state */ /** Controlled column filters state */
columnFilters?: ColumnFiltersState columnFilters?: ColumnFiltersState;
/** Column definitions */
columns: GriddyColumn<T>[]
/** Controlled column pinning state */ /** Controlled column pinning state */
columnPinning?: ColumnPinningState columnPinning?: ColumnPinningState;
onColumnPinningChange?: (pinning: ColumnPinningState) => void /** Column definitions */
columns: GriddyColumn<T>[];
/** Data array */ /** Data array */
data: T[] data: T[];
// ─── Data Adapter ─── // ─── Data Adapter ───
dataAdapter?: DataAdapter<T> dataAdapter?: DataAdapter<T>;
/** Total row count (for server-side pagination/filtering). If provided, enables manual mode. */ /** Total row count (for server-side pagination/filtering). If provided, enables manual mode. */
dataCount?: number dataCount?: number;
/** Stable row identity function */ /** Export filename. Default: 'export.csv' */
getRowId?: (row: T, index: number) => string exportFilename?: string;
// ─── Grouping ───
grouping?: GroupingConfig
// ─── 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 */ /** Container height */
height?: number | string height?: number | string;
// ─── Loading ───
/** Show loading skeleton/overlay. Default: false */
isLoading?: boolean
// ─── Infinite Scroll ─── // ─── Infinite Scroll ───
/** Infinite scroll configuration */ /** Infinite scroll configuration */
infiniteScroll?: InfiniteScrollConfig infiniteScroll?: InfiniteScrollConfig;
// ─── Loading ───
/** Show loading skeleton/overlay. Default: false */
isLoading?: boolean;
// ─── Keyboard ─── // ─── Keyboard ───
/** Enable keyboard navigation. Default: true */ /** Enable keyboard navigation. Default: true */
keyboardNavigation?: boolean keyboardNavigation?: boolean;
/** Manual filtering mode - filtering handled externally (server-side). Default: false */ /** Manual filtering mode - filtering handled externally (server-side). Default: false */
manualFiltering?: boolean manualFiltering?: boolean;
/** Manual sorting mode - sorting handled externally (server-side). Default: false */ /** 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 ─── // ─── Editing ───
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void;
// ─── Error Handling ─── // ─── Error Handling ───
/** Callback when a render error is caught by the error boundary */ /** 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 */ /** Callback before the error boundary retries rendering */
onRetry?: () => void onRetry?: () => void;
/** Selection change callback */ /** 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 row count. Default: 10 */
overscan?: number overscan?: number;
// ─── Pagination ─── // ─── Pagination ───
pagination?: PaginationConfig pagination?: PaginationConfig;
// ─── Persistence ─── // ─── Persistence ───
/** localStorage key prefix for persisting column layout */ /** localStorage key prefix for persisting column layout */
persistenceKey?: string persistenceKey?: string;
// ─── Virtualization ─── // ─── Virtualization ───
/** Row height in pixels. Default: 36 */ /** Row height in pixels. Default: 36 */
rowHeight?: number rowHeight?: number;
/** Controlled row selection state */ /** Controlled row selection state */
rowSelection?: RowSelectionState rowSelection?: RowSelectionState;
// ─── Search ─── // ─── Search ───
search?: SearchConfig search?: SearchConfig;
// ─── Selection ─── // ─── Selection ───
/** Selection configuration */ /** Selection configuration */
selection?: SelectionConfig selection?: SelectionConfig;
// ─── Toolbar ───
/** Show toolbar with export and column visibility controls. Default: false */
showToolbar?: boolean;
// ─── Sorting ─── // ─── Sorting ───
/** Controlled sorting state */ /** Controlled sorting state */
sorting?: SortingState sorting?: SortingState;
/** Unique identifier for persistence */ /** Unique identifier for persistence */
uniqueId?: string uniqueId?: string;
} }
// ─── Data Adapter ──────────────────────────────────────────────────────────── // ─── Data Adapter ────────────────────────────────────────────────────────────
export interface GriddyRef<T = unknown> { export interface GriddyRef<T = unknown> {
deselectAll: () => void deselectAll: () => void;
focusRow: (index: number) => void focusRow: (index: number) => void;
getTable: () => Table<T> getTable: () => Table<T>;
getUIState: () => GriddyUIState getUIState: () => GriddyUIState;
getVirtualizer: () => Virtualizer<HTMLDivElement, Element> getVirtualizer: () => Virtualizer<HTMLDivElement, Element>;
scrollToRow: (index: number) => void scrollToRow: (index: number) => void;
selectRow: (id: string) => void selectRow: (id: string) => void;
startEditing: (rowId: string, columnId?: string) => void startEditing: (rowId: string, columnId?: string) => void;
} }
export interface GriddyUIState { export interface GriddyUIState {
focusedColumnId: null | string focusedColumnId: null | string;
// Focus // Focus
focusedRowIndex: null | number focusedRowIndex: null | number;
// Modes // Modes
isEditing: boolean isEditing: boolean;
isSearchOpen: boolean isSearchOpen: boolean;
isSelecting: boolean isSelecting: boolean;
moveFocus: (direction: 'down' | 'up', amount: number) => void moveFocus: (direction: 'down' | 'up', amount: number) => void;
moveFocusToEnd: () => void moveFocusToEnd: () => void;
moveFocusToStart: () => void moveFocusToStart: () => void;
setEditing: (editing: boolean) => void setEditing: (editing: boolean) => void;
setFocusedColumn: (id: null | string) => void setFocusedColumn: (id: null | string) => void;
// Actions // Actions
setFocusedRow: (index: null | number) => void setFocusedRow: (index: null | number) => void;
setSearchOpen: (open: boolean) => void setSearchOpen: (open: boolean) => void;
setSelecting: (selecting: boolean) => void setSelecting: (selecting: boolean) => void;
setTotalRows: (count: number) => void setTotalRows: (count: number) => void;
// Row count (synced from table) // Row count (synced from table)
totalRows: number totalRows: number;
} }
export interface GroupingConfig { export interface GroupingConfig {
columns?: string[] columns?: string[];
enabled: boolean enabled: boolean;
} }
// ─── Grouping ──────────────────────────────────────────────────────────────── // ─── Grouping ────────────────────────────────────────────────────────────────
export interface InfiniteScrollConfig { export interface InfiniteScrollConfig {
/** Enable infinite scroll */ /** Enable infinite scroll */
enabled: boolean 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> | void
/** Whether data is currently loading */
isLoading?: boolean
/** Whether there is more data to load */ /** 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> | void;
/** Threshold in rows from the end to trigger loading. Default: 10 */
threshold?: number;
} }
export interface PaginationConfig { export interface PaginationConfig {
enabled: boolean enabled: boolean;
onPageChange?: (page: number) => void onPageChange?: (page: number) => void;
onPageSizeChange?: (pageSize: number) => void onPageSizeChange?: (pageSize: number) => void;
pageSize: number pageSize: number;
pageSizeOptions?: number[] pageSizeOptions?: number[];
type: 'cursor' | 'offset' type: 'cursor' | 'offset';
} }
// ─── Main Props ────────────────────────────────────────────────────────────── // ─── Main Props ──────────────────────────────────────────────────────────────
export interface RendererProps<T> { export interface RendererProps<T> {
column: GriddyColumn<T> column: GriddyColumn<T>;
columnIndex: number columnIndex: number;
isEditing?: boolean isEditing?: boolean;
row: T row: T;
rowIndex: number rowIndex: number;
searchQuery?: string searchQuery?: string;
value: unknown value: unknown;
} }
// ─── UI State (Zustand Store) ──────────────────────────────────────────────── // ─── UI State (Zustand Store) ────────────────────────────────────────────────
export interface SearchConfig { export interface SearchConfig {
caseSensitive?: boolean caseSensitive?: boolean;
debounceMs?: number debounceMs?: number;
enabled: boolean enabled: boolean;
fuzzy?: boolean fuzzy?: boolean;
highlightMatches?: boolean highlightMatches?: boolean;
placeholder?: string placeholder?: string;
} }
// ─── Ref API ───────────────────────────────────────────────────────────────── // ─── Ref API ─────────────────────────────────────────────────────────────────
export interface SelectionConfig { export interface SelectionConfig {
/** 'none' = no selection, 'single' = one row at a time, 'multi' = multiple rows */ /** '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 */ /** Maintain selection across pagination/sorting. Default: true */
preserveSelection?: boolean preserveSelection?: boolean;
/** Allow clicking row body to toggle selection. Default: true */ /** 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' */ /** Show checkbox column (auto-added as first column). Default: true when mode !== 'none' */
showCheckbox?: boolean showCheckbox?: boolean;
} }
// ─── Re-exports for convenience ────────────────────────────────────────────── // ─── 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,
};

View File

@@ -1,49 +1,59 @@
import { Select } from '@mantine/core' import { Select } from '@mantine/core';
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react';
import type { BaseEditorProps, SelectOption } from './types' import type { BaseEditorProps, SelectOption } from './types';
interface SelectEditorProps extends BaseEditorProps<any> { interface SelectEditorProps extends BaseEditorProps<any> {
options: SelectOption[] options: SelectOption[];
} }
export function SelectEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, options, value }: SelectEditorProps) { export function SelectEditor({
const [selectedValue, setSelectedValue] = useState<string | null>(value != null ? String(value) : null) autoFocus = true,
onCancel,
onCommit,
onMoveNext,
onMovePrev,
options,
value,
}: SelectEditorProps) {
const [selectedValue, setSelectedValue] = useState<null | string>(
value != null ? String(value) : null
);
useEffect(() => { useEffect(() => {
setSelectedValue(value != null ? String(value) : null) setSelectedValue(value != null ? String(value) : null);
}, [value]) }, [value]);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault();
// Find the actual value from options // Find the actual value from options
const option = options.find(opt => String(opt.value) === selectedValue) const option = options.find((opt) => String(opt.value) === selectedValue);
onCommit(option?.value ?? selectedValue) onCommit(option?.value ?? selectedValue);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault() e.preventDefault();
onCancel() onCancel();
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
e.preventDefault() e.preventDefault();
const option = options.find(opt => String(opt.value) === selectedValue) const option = options.find((opt) => String(opt.value) === selectedValue);
onCommit(option?.value ?? selectedValue) onCommit(option?.value ?? selectedValue);
if (e.shiftKey) { if (e.shiftKey) {
onMovePrev?.() onMovePrev?.();
} else { } else {
onMoveNext?.() onMoveNext?.();
}
} }
} }
};
return ( return (
<Select <Select
autoFocus={autoFocus} autoFocus={autoFocus}
data={options.map(opt => ({ label: opt.label, value: String(opt.value) }))} data={options.map((opt) => ({ label: opt.label, value: String(opt.value) }))}
onChange={(val) => setSelectedValue(val)} onChange={(val) => setSelectedValue(val)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
searchable searchable
size="xs" size="xs"
value={selectedValue} value={selectedValue}
/> />
) );
} }

View File

@@ -1,45 +1,45 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react';
// ─── Editor Props ──────────────────────────────────────────────────────────── // ─── Editor Props ────────────────────────────────────────────────────────────
export interface BaseEditorProps<T = any> { export interface BaseEditorProps<T = any> {
autoFocus?: boolean autoFocus?: boolean;
onCancel: () => void onCancel: () => void;
onCommit: (value: T) => void onCommit: (value: T) => void;
onMoveNext?: () => void onMoveNext?: () => void;
onMovePrev?: () => void onMovePrev?: () => void;
value: T value: T;
} }
// ─── Validation ────────────────────────────────────────────────────────────── // ─── Validation ──────────────────────────────────────────────────────────────
export interface ValidationRule<T = any> { export type EditorComponent<T = any> = (props: BaseEditorProps<T>) => ReactNode;
message: string
validate: (value: T) => boolean
}
export interface ValidationResult { export interface EditorConfig {
errors: string[] max?: number;
isValid: boolean min?: number;
options?: SelectOption[];
placeholder?: string;
step?: number;
type?: EditorType;
validation?: ValidationRule[];
} }
// ─── Editor Registry ───────────────────────────────────────────────────────── // ─── Editor Registry ─────────────────────────────────────────────────────────
export type EditorType = 'checkbox' | 'date' | 'number' | 'select' | 'text' export type EditorType = 'checkbox' | 'date' | 'number' | 'select' | 'text';
export interface SelectOption { export interface SelectOption {
label: string label: string;
value: any value: any;
} }
export interface EditorConfig { export interface ValidationResult {
max?: number errors: string[];
min?: number isValid: boolean;
options?: SelectOption[]
placeholder?: string
step?: number
type?: EditorType
validation?: ValidationRule[]
} }
export type EditorComponent<T = any> = (props: BaseEditorProps<T>) => ReactNode export interface ValidationRule<T = any> {
message: string;
validate: (value: T) => boolean;
}

View File

@@ -1,84 +1,108 @@
import type { Table } from '@tanstack/react-table' import type { Table } from '@tanstack/react-table';
import { Button, Group, SegmentedControl, Stack, Text } from '@mantine/core' import { Button, Group, SegmentedControl, Stack, Text } from '@mantine/core';
import { IconPlus } from '@tabler/icons-react' import { IconPlus } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react';
import { useGriddyStore } from '../../core/GriddyStore' import type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types';
import styles from '../../styles/griddy.module.css'
import { advancedFilter } from './advancedFilterFn'
import { SearchConditionRow } from './SearchConditionRow'
import type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types'
let nextId = 1 import { useGriddyStore } from '../../core/GriddyStore';
import styles from '../../styles/griddy.module.css';
import { advancedFilter } from './advancedFilterFn';
import { SearchConditionRow } from './SearchConditionRow';
function createCondition(): SearchCondition { let nextId = 1;
return { columnId: '', id: String(nextId++), operator: 'contains', value: '' }
}
interface AdvancedSearchPanelProps { interface AdvancedSearchPanelProps {
table: Table<any> table: Table<any>;
}
// Custom global filter function that handles advanced search
export function advancedSearchGlobalFilterFn(
row: any,
_columnId: string,
filterValue: any
): boolean {
if (filterValue?._advancedSearch) {
return advancedFilter(row, filterValue._advancedSearch);
}
// Fallback to default string search
if (typeof filterValue === 'string') {
const search = filterValue.toLowerCase();
return row.getAllCells().some((cell: any) => {
const val = cell.getValue();
return String(val ?? '')
.toLowerCase()
.includes(search);
});
}
return true;
} }
export function AdvancedSearchPanel({ table }: AdvancedSearchPanelProps) { export function AdvancedSearchPanel({ table }: AdvancedSearchPanelProps) {
const userColumns = useGriddyStore((s) => s.columns) ?? [] const userColumns = useGriddyStore((s) => s.columns) ?? [];
const [searchState, setSearchState] = useState<AdvancedSearchState>({ const [searchState, setSearchState] = useState<AdvancedSearchState>({
booleanOperator: 'AND', booleanOperator: 'AND',
conditions: [createCondition()], conditions: [createCondition()],
}) });
const columnOptions = useMemo( const columnOptions = useMemo(
() => userColumns () =>
userColumns
.filter((c) => c.searchable !== false) .filter((c) => c.searchable !== false)
.map((c) => ({ label: String(c.header), value: c.id })), .map((c) => ({ label: String(c.header), value: c.id })),
[userColumns], [userColumns]
) );
const handleConditionChange = useCallback((index: number, condition: SearchCondition) => { const handleConditionChange = useCallback((index: number, condition: SearchCondition) => {
setSearchState((prev) => { setSearchState((prev) => {
const conditions = [...prev.conditions] const conditions = [...prev.conditions];
conditions[index] = condition conditions[index] = condition;
return { ...prev, conditions } return { ...prev, conditions };
}) });
}, []) }, []);
const handleRemove = useCallback((index: number) => { const handleRemove = useCallback((index: number) => {
setSearchState((prev) => ({ setSearchState((prev) => ({
...prev, ...prev,
conditions: prev.conditions.filter((_, i) => i !== index), conditions: prev.conditions.filter((_, i) => i !== index),
})) }));
}, []) }, []);
const handleAdd = useCallback(() => { const handleAdd = useCallback(() => {
setSearchState((prev) => ({ setSearchState((prev) => ({
...prev, ...prev,
conditions: [...prev.conditions, createCondition()], conditions: [...prev.conditions, createCondition()],
})) }));
}, []) }, []);
const handleApply = useCallback(() => { const handleApply = useCallback(() => {
const activeConditions = searchState.conditions.filter((c) => c.columnId && c.value) const activeConditions = searchState.conditions.filter((c) => c.columnId && c.value);
if (activeConditions.length === 0) { if (activeConditions.length === 0) {
table.setGlobalFilter(undefined) table.setGlobalFilter(undefined);
return return;
} }
// Use globalFilter with a custom function key // Use globalFilter with a custom function key
table.setGlobalFilter({ _advancedSearch: searchState }) table.setGlobalFilter({ _advancedSearch: searchState });
}, [searchState, table]) }, [searchState, table]);
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
setSearchState({ booleanOperator: 'AND', conditions: [createCondition()] }) setSearchState({ booleanOperator: 'AND', conditions: [createCondition()] });
table.setGlobalFilter(undefined) table.setGlobalFilter(undefined);
}, [table]) }, [table]);
return ( return (
<div className={styles['griddy-advanced-search']}> <div className={styles['griddy-advanced-search']}>
<Stack gap="xs"> <Stack gap="xs">
<Group justify="space-between"> <Group justify="space-between">
<Text fw={600} size="sm">Advanced Search</Text> <Text fw={600} size="sm">
Advanced Search
</Text>
<SegmentedControl <SegmentedControl
data={['AND', 'OR', 'NOT']} data={['AND', 'OR', 'NOT']}
onChange={(val) => setSearchState((prev) => ({ ...prev, booleanOperator: val as BooleanOperator }))} onChange={(val) =>
setSearchState((prev) => ({ ...prev, booleanOperator: val as BooleanOperator }))
}
size="xs" size="xs"
value={searchState.booleanOperator} value={searchState.booleanOperator}
/> />
@@ -114,21 +138,9 @@ export function AdvancedSearchPanel({ table }: AdvancedSearchPanelProps) {
</Group> </Group>
</Stack> </Stack>
</div> </div>
) );
} }
// Custom global filter function that handles advanced search function createCondition(): SearchCondition {
export function advancedSearchGlobalFilterFn(row: any, _columnId: string, filterValue: any): boolean { return { columnId: '', id: String(nextId++), operator: 'contains', value: '' };
if (filterValue?._advancedSearch) {
return advancedFilter(row, filterValue._advancedSearch)
}
// Fallback to default string search
if (typeof filterValue === 'string') {
const search = filterValue.toLowerCase()
return row.getAllCells().some((cell: any) => {
const val = cell.getValue()
return String(val ?? '').toLowerCase().includes(search)
})
}
return true
} }

View File

@@ -1,45 +1,45 @@
import type { Row } from '@tanstack/react-table' import type { Row } from '@tanstack/react-table';
import type { AdvancedSearchState, SearchCondition } from './types' import type { AdvancedSearchState, SearchCondition } from './types';
function matchCondition<T>(row: Row<T>, condition: SearchCondition): boolean {
const cellValue = String(row.getValue(condition.columnId) ?? '').toLowerCase()
const searchValue = condition.value.toLowerCase()
switch (condition.operator) {
case 'contains':
return cellValue.includes(searchValue)
case 'equals':
return cellValue === searchValue
case 'startsWith':
return cellValue.startsWith(searchValue)
case 'endsWith':
return cellValue.endsWith(searchValue)
case 'notContains':
return !cellValue.includes(searchValue)
case 'greaterThan':
return Number(row.getValue(condition.columnId)) > Number(condition.value)
case 'lessThan':
return Number(row.getValue(condition.columnId)) < Number(condition.value)
default:
return true
}
}
export function advancedFilter<T>(row: Row<T>, searchState: AdvancedSearchState): boolean { export function advancedFilter<T>(row: Row<T>, searchState: AdvancedSearchState): boolean {
const { booleanOperator, conditions } = searchState const { booleanOperator, conditions } = searchState;
const active = conditions.filter((c) => c.columnId && c.value) const active = conditions.filter((c) => c.columnId && c.value);
if (active.length === 0) return true if (active.length === 0) return true;
switch (booleanOperator) { switch (booleanOperator) {
case 'AND': case 'AND':
return active.every((c) => matchCondition(row, c)) return active.every((c) => matchCondition(row, c));
case 'OR':
return active.some((c) => matchCondition(row, c))
case 'NOT': case 'NOT':
return !active.some((c) => matchCondition(row, c)) return !active.some((c) => matchCondition(row, c));
case 'OR':
return active.some((c) => matchCondition(row, c));
default: default:
return true return true;
}
}
function matchCondition<T>(row: Row<T>, condition: SearchCondition): boolean {
const cellValue = String(row.getValue(condition.columnId) ?? '').toLowerCase();
const searchValue = condition.value.toLowerCase();
switch (condition.operator) {
case 'contains':
return cellValue.includes(searchValue);
case 'endsWith':
return cellValue.endsWith(searchValue);
case 'equals':
return cellValue === searchValue;
case 'greaterThan':
return Number(row.getValue(condition.columnId)) > Number(condition.value);
case 'lessThan':
return Number(row.getValue(condition.columnId)) < Number(condition.value);
case 'notContains':
return !cellValue.includes(searchValue);
case 'startsWith':
return cellValue.startsWith(searchValue);
default:
return true;
} }
} }

View File

@@ -1,2 +1,2 @@
export { AdvancedSearchPanel, advancedSearchGlobalFilterFn } from './AdvancedSearchPanel' export { advancedSearchGlobalFilterFn, AdvancedSearchPanel } from './AdvancedSearchPanel';
export type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types' export type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types';

View File

@@ -1,13 +1,20 @@
export interface SearchCondition {
columnId: string
id: string
operator: 'contains' | 'endsWith' | 'equals' | 'greaterThan' | 'lessThan' | 'notContains' | 'startsWith'
value: string
}
export type BooleanOperator = 'AND' | 'NOT' | 'OR'
export interface AdvancedSearchState { export interface AdvancedSearchState {
booleanOperator: BooleanOperator booleanOperator: BooleanOperator;
conditions: SearchCondition[] conditions: SearchCondition[];
}
export type BooleanOperator = 'AND' | 'NOT' | 'OR';
export interface SearchCondition {
columnId: string;
id: string;
operator:
| 'contains'
| 'endsWith'
| 'equals'
| 'greaterThan'
| 'lessThan'
| 'notContains'
| 'startsWith';
value: string;
} }

View File

@@ -1,43 +1,49 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react';
import type { FilterPreset } from './types' import type { FilterPreset } from './types';
export function useFilterPresets(persistenceKey?: string) {
const key = persistenceKey ?? 'default';
const [presets, setPresets] = useState<FilterPreset[]>(() => loadPresets(key));
const addPreset = useCallback(
(preset: Omit<FilterPreset, 'id'>) => {
setPresets((prev) => {
const next = [...prev, { ...preset, id: String(Date.now()) }];
savePresets(key, next);
return next;
});
},
[key]
);
const deletePreset = useCallback(
(id: string) => {
setPresets((prev) => {
const next = prev.filter((p) => p.id !== id);
savePresets(key, next);
return next;
});
},
[key]
);
return { addPreset, deletePreset, presets };
}
function getStorageKey(persistenceKey: string) { function getStorageKey(persistenceKey: string) {
return `griddy-filter-presets-${persistenceKey}` return `griddy-filter-presets-${persistenceKey}`;
} }
function loadPresets(persistenceKey: string): FilterPreset[] { function loadPresets(persistenceKey: string): FilterPreset[] {
try { try {
const raw = localStorage.getItem(getStorageKey(persistenceKey)) const raw = localStorage.getItem(getStorageKey(persistenceKey));
return raw ? JSON.parse(raw) : [] return raw ? JSON.parse(raw) : [];
} catch { } catch {
return [] return [];
} }
} }
function savePresets(persistenceKey: string, presets: FilterPreset[]) { function savePresets(persistenceKey: string, presets: FilterPreset[]) {
localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(presets)) localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(presets));
}
export function useFilterPresets(persistenceKey?: string) {
const key = persistenceKey ?? 'default'
const [presets, setPresets] = useState<FilterPreset[]>(() => loadPresets(key))
const addPreset = useCallback((preset: Omit<FilterPreset, 'id'>) => {
setPresets((prev) => {
const next = [...prev, { ...preset, id: String(Date.now()) }]
savePresets(key, next)
return next
})
}, [key])
const deletePreset = useCallback((id: string) => {
setPresets((prev) => {
const next = prev.filter((p) => p.id !== id)
savePresets(key, next)
return next
})
}, [key])
return { addPreset, deletePreset, presets }
} }

View File

@@ -1,30 +1,27 @@
import type { Column } from '@tanstack/react-table' import type { Column } from '@tanstack/react-table';
import type React from 'react';
import { ActionIcon } from '@mantine/core' import { ActionIcon } from '@mantine/core';
import { IconFilter } from '@tabler/icons-react' import { IconFilter } from '@tabler/icons-react';
import type React from 'react' import { forwardRef } from 'react';
import { forwardRef } from 'react'
import { CSS } from '../../core/constants' import { CSS } from '../../core/constants';
import styles from '../../styles/griddy.module.css' import styles from '../../styles/griddy.module.css';
interface ColumnFilterButtonProps { interface ColumnFilterButtonProps {
column: Column<any, any> column: Column<any, any>;
onClick?: (e: React.MouseEvent) => void onClick?: (e: React.MouseEvent) => void;
} }
export const ColumnFilterButton = forwardRef<HTMLButtonElement, ColumnFilterButtonProps>( export const ColumnFilterButton = forwardRef<HTMLButtonElement, ColumnFilterButtonProps>(
function ColumnFilterButton({ column, onClick, ...rest }, ref) { function ColumnFilterButton({ column, onClick, ...rest }, ref) {
const isActive = !!column.getFilterValue() const isActive = !!column.getFilterValue();
return ( return (
<ActionIcon <ActionIcon
{...rest} {...rest}
aria-label="Open column filter" aria-label="Open column filter"
className={[ className={[styles[CSS.filterButton], isActive ? styles[CSS.filterButtonActive] : '']
styles[CSS.filterButton],
isActive ? styles[CSS.filterButtonActive] : '',
]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
color={isActive ? 'blue' : 'gray'} color={isActive ? 'blue' : 'gray'}
@@ -35,6 +32,6 @@ export const ColumnFilterButton = forwardRef<HTMLButtonElement, ColumnFilterButt
> >
<IconFilter size={14} /> <IconFilter size={14} />
</ActionIcon> </ActionIcon>
) );
}, }
) );

View File

@@ -1,73 +1,76 @@
import type { Column } from '@tanstack/react-table' import type { Column } from '@tanstack/react-table';
import type React from 'react';
import { Button, Group, Popover, Stack, Text } from '@mantine/core' import { Button, Group, Popover, Stack, Text } from '@mantine/core';
import type React from 'react' import { useState } from 'react';
import { useState } from 'react'
import type { FilterConfig, FilterValue } from './types' import type { FilterConfig, FilterValue } from './types';
import { getGriddyColumn } from '../../core/columnMapper' import { getGriddyColumn } from '../../core/columnMapper';
import { QuickFilterDropdown } from '../quickFilter' import { QuickFilterDropdown } from '../quickFilter';
import { ColumnFilterButton } from './ColumnFilterButton' import { ColumnFilterButton } from './ColumnFilterButton';
import { FilterBoolean } from './FilterBoolean' import { FilterBoolean } from './FilterBoolean';
import { FilterDate } from './FilterDate' import { FilterDate } from './FilterDate';
import { FilterInput } from './FilterInput' import { FilterInput } from './FilterInput';
import { FilterSelect } from './FilterSelect' import { FilterSelect } from './FilterSelect';
import { OPERATORS_BY_TYPE } from './operators' import { OPERATORS_BY_TYPE } from './operators';
interface ColumnFilterPopoverProps { interface ColumnFilterPopoverProps {
column: Column<any, any> column: Column<any, any>;
onOpenedChange?: (opened: boolean) => void onOpenedChange?: (opened: boolean) => void;
opened?: boolean opened?: boolean;
} }
export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOpened }: ColumnFilterPopoverProps) { export function ColumnFilterPopover({
const [internalOpened, setInternalOpened] = useState(false) column,
onOpenedChange,
opened: externalOpened,
}: ColumnFilterPopoverProps) {
const [internalOpened, setInternalOpened] = useState(false);
// Support both internal and external control // Support both internal and external control
const opened = externalOpened !== undefined ? externalOpened : internalOpened const opened = externalOpened !== undefined ? externalOpened : internalOpened;
const setOpened = (value: boolean) => { const setOpened = (value: boolean) => {
if (externalOpened !== undefined) { if (externalOpened !== undefined) {
onOpenedChange?.(value) onOpenedChange?.(value);
} else { } else {
setInternalOpened(value) setInternalOpened(value);
}
} }
};
const [localValue, setLocalValue] = useState<FilterValue | undefined>( const [localValue, setLocalValue] = useState<FilterValue | undefined>(
(column.getFilterValue() as FilterValue) || undefined, (column.getFilterValue() as FilterValue) || undefined
) );
const griddyColumn = getGriddyColumn(column) const griddyColumn = getGriddyColumn(column);
const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig;
if (!filterConfig) { if (!filterConfig) {
return null return null;
} }
const handleApply = () => { const handleApply = () => {
column.setFilterValue(localValue) column.setFilterValue(localValue);
setOpened(false) setOpened(false);
} };
const handleClear = () => { const handleClear = () => {
setLocalValue(undefined) setLocalValue(undefined);
column.setFilterValue(undefined) column.setFilterValue(undefined);
setOpened(false) setOpened(false);
} };
const handleClose = () => { const handleClose = () => {
setOpened(false) setOpened(false);
// Reset to previous value if popover is closed without applying // Reset to previous value if popover is closed without applying
setLocalValue((column.getFilterValue() as FilterValue) || undefined) setLocalValue((column.getFilterValue() as FilterValue) || undefined);
} };
const operators = const operators = filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type];
filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type]
const handleToggle = (e: React.MouseEvent) => { const handleToggle = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation();
setOpened(!opened) setOpened(!opened);
} };
return ( return (
<Popover onClose={handleClose} opened={opened} position="bottom-start" withinPortal> <Popover onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
@@ -112,20 +115,16 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
)} )}
{filterConfig.type === 'date' && ( {filterConfig.type === 'date' && (
<FilterDate <FilterDate onChange={setLocalValue} operators={operators} value={localValue} />
onChange={setLocalValue}
operators={operators}
value={localValue}
/>
)} )}
{filterConfig.quickFilter && ( {filterConfig.quickFilter && (
<QuickFilterDropdown <QuickFilterDropdown
column={column} column={column}
onApply={(val) => { onApply={(val) => {
setLocalValue(val) setLocalValue(val);
column.setFilterValue(val) column.setFilterValue(val);
setOpened(false) setOpened(false);
}} }}
value={localValue} value={localValue}
/> />
@@ -142,5 +141,5 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
</Stack> </Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
) );
} }

View File

@@ -1,22 +1,26 @@
import type { GriddyColumn } from '../../core/types' import type { GriddyColumn } from '../../core/types';
import { DEFAULTS } from '../../core/constants' import { DEFAULTS } from '../../core/constants';
import { useGriddyStore } from '../../core/GriddyStore' import { useGriddyStore } from '../../core/GriddyStore';
import styles from '../../styles/griddy.module.css' import styles from '../../styles/griddy.module.css';
export function GriddyLoadingOverlay() {
return (
<div className={styles['griddy-loading-overlay']}>
<div className={styles['griddy-loading-spinner']}>Loading...</div>
</div>
);
}
export function GriddyLoadingSkeleton() { export function GriddyLoadingSkeleton() {
const columns = useGriddyStore((s) => s.columns) const columns = useGriddyStore((s) => s.columns);
const rowHeight = useGriddyStore((s) => s.rowHeight) ?? DEFAULTS.rowHeight const rowHeight = useGriddyStore((s) => s.rowHeight) ?? DEFAULTS.rowHeight;
const skeletonRowCount = 8 const skeletonRowCount = 8;
return ( return (
<div className={styles['griddy-skeleton']}> <div className={styles['griddy-skeleton']}>
{Array.from({ length: skeletonRowCount }, (_, rowIndex) => ( {Array.from({ length: skeletonRowCount }, (_, rowIndex) => (
<div <div className={styles['griddy-skeleton-row']} key={rowIndex} style={{ height: rowHeight }}>
className={styles['griddy-skeleton-row']}
key={rowIndex}
style={{ height: rowHeight }}
>
{(columns ?? []).map((col: GriddyColumn<any>) => ( {(columns ?? []).map((col: GriddyColumn<any>) => (
<div <div
className={styles['griddy-skeleton-cell']} className={styles['griddy-skeleton-cell']}
@@ -29,13 +33,5 @@ export function GriddyLoadingSkeleton() {
</div> </div>
))} ))}
</div> </div>
) );
}
export function GriddyLoadingOverlay() {
return (
<div className={styles['griddy-loading-overlay']}>
<div className={styles['griddy-loading-spinner']}>Loading...</div>
</div>
)
} }

View File

@@ -1,42 +1,45 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react';
const MAX_HISTORY = 10 const MAX_HISTORY = 10;
export function useSearchHistory(persistenceKey?: string) {
const key = persistenceKey ?? 'default';
const [history, setHistory] = useState<string[]>(() => loadHistory(key));
const addEntry = useCallback(
(query: string) => {
if (!query.trim()) return;
setHistory((prev) => {
const filtered = prev.filter((h) => h !== query);
const next = [query, ...filtered].slice(0, MAX_HISTORY);
saveHistory(key, next);
return next;
});
},
[key]
);
const clearHistory = useCallback(() => {
setHistory([]);
localStorage.removeItem(getStorageKey(key));
}, [key]);
return { addEntry, clearHistory, history };
}
function getStorageKey(persistenceKey: string) { function getStorageKey(persistenceKey: string) {
return `griddy-search-history-${persistenceKey}` return `griddy-search-history-${persistenceKey}`;
} }
function loadHistory(persistenceKey: string): string[] { function loadHistory(persistenceKey: string): string[] {
try { try {
const raw = localStorage.getItem(getStorageKey(persistenceKey)) const raw = localStorage.getItem(getStorageKey(persistenceKey));
return raw ? JSON.parse(raw) : [] return raw ? JSON.parse(raw) : [];
} catch { } catch {
return [] return [];
} }
} }
function saveHistory(persistenceKey: string, history: string[]) { function saveHistory(persistenceKey: string, history: string[]) {
localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(history)) localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(history));
}
export function useSearchHistory(persistenceKey?: string) {
const key = persistenceKey ?? 'default'
const [history, setHistory] = useState<string[]>(() => loadHistory(key))
const addEntry = useCallback((query: string) => {
if (!query.trim()) return
setHistory((prev) => {
const filtered = prev.filter((h) => h !== query)
const next = [query, ...filtered].slice(0, MAX_HISTORY)
saveHistory(key, next)
return next
})
}, [key])
const clearHistory = useCallback(() => {
setHistory([])
localStorage.removeItem(getStorageKey(key))
}, [key])
return { addEntry, clearHistory, history }
} }

View File

@@ -1,8 +1,20 @@
export { getGriddyColumn, mapColumns } from './core/columnMapper' // Adapter exports
export { CSS, DEFAULTS, SELECTION_COLUMN_ID } from './core/constants' export {
export { Griddy } from './core/Griddy' applyCursor,
export { GriddyProvider, useGriddyStore } from './core/GriddyStore' buildOptions,
export type { GriddyStoreState } from './core/GriddyStore' HeaderSpecAdapter,
mapFilters,
mapPagination,
mapSorting,
ResolveSpecAdapter,
} from './adapters';
export type { AdapterConfig, AdapterRef } from './adapters';
export { getGriddyColumn, mapColumns } from './core/columnMapper';
export { CSS, DEFAULTS, SELECTION_COLUMN_ID } from './core/constants';
export { Griddy } from './core/Griddy';
export { GriddyProvider, useGriddyStore } from './core/GriddyStore';
export type { GriddyStoreState } from './core/GriddyStore';
export type { export type {
AdvancedSearchConfig, AdvancedSearchConfig,
CellRenderer, CellRenderer,
@@ -20,12 +32,17 @@ export type {
RendererProps, RendererProps,
SearchConfig, SearchConfig,
SelectionConfig, SelectionConfig,
} from './core/types' } from './core/types';
// Feature exports // Feature exports
export { GriddyErrorBoundary } from './features/errorBoundary' export { GriddyErrorBoundary } from './features/errorBoundary';
export { GriddyLoadingOverlay, GriddyLoadingSkeleton } from './features/loading' export { FilterPresetsMenu, useFilterPresets } from './features/filterPresets';
export { BadgeRenderer, ImageRenderer, ProgressBarRenderer, SparklineRenderer } from './features/renderers' export type { FilterPreset } from './features/filterPresets';
export { FilterPresetsMenu, useFilterPresets } from './features/filterPresets' export { GriddyLoadingOverlay, GriddyLoadingSkeleton } from './features/loading';
export type { FilterPreset } from './features/filterPresets'
export { useSearchHistory } from './features/searchHistory' export {
BadgeRenderer,
ImageRenderer,
ProgressBarRenderer,
SparklineRenderer,
} from './features/renderers';
export { useSearchHistory } from './features/searchHistory';

View File

@@ -1,48 +1,59 @@
import type { Cell } from '@tanstack/react-table' import type { Cell } from '@tanstack/react-table';
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react';
import { CheckboxEditor, DateEditor, NumericEditor, SelectEditor, TextEditor } from '../editors' import type { EditorConfig } from '../editors';
import type { EditorConfig } from '../editors'
import { getGriddyColumn } from '../core/columnMapper' import { getGriddyColumn } from '../core/columnMapper';
import { CheckboxEditor, DateEditor, NumericEditor, SelectEditor, TextEditor } from '../editors';
interface EditableCellProps<T> { interface EditableCellProps<T> {
cell: Cell<T, unknown> cell: Cell<T, unknown>;
isEditing: boolean isEditing: boolean;
onCancelEdit: () => void onCancelEdit: () => void;
onCommitEdit: (value: unknown) => void onCommitEdit: (value: unknown) => void;
onMoveNext?: () => void onMoveNext?: () => void;
onMovePrev?: () => void onMovePrev?: () => void;
} }
export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, onMoveNext, onMovePrev }: EditableCellProps<T>) { export function EditableCell<T>({
const griddyColumn = getGriddyColumn(cell.column) cell,
const editorConfig: EditorConfig = (griddyColumn as any)?.editorConfig ?? {} isEditing,
const customEditor = (griddyColumn as any)?.editor onCancelEdit,
onCommitEdit,
onMoveNext,
onMovePrev,
}: EditableCellProps<T>) {
const griddyColumn = getGriddyColumn(cell.column);
const editorConfig: EditorConfig = (griddyColumn as any)?.editorConfig ?? {};
const customEditor = (griddyColumn as any)?.editor;
const [value, setValue] = useState(cell.getValue()) const [value, setValue] = useState(cell.getValue());
useEffect(() => { useEffect(() => {
setValue(cell.getValue()) setValue(cell.getValue());
}, [cell]) }, [cell]);
const handleCommit = useCallback((newValue: unknown) => { const handleCommit = useCallback(
setValue(newValue) (newValue: unknown) => {
onCommitEdit(newValue) setValue(newValue);
}, [onCommitEdit]) onCommitEdit(newValue);
},
[onCommitEdit]
);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
setValue(cell.getValue()) setValue(cell.getValue());
onCancelEdit() onCancelEdit();
}, [cell, onCancelEdit]) }, [cell, onCancelEdit]);
if (!isEditing) { if (!isEditing) {
return null return null;
} }
// Custom editor from column definition // Custom editor from column definition
if (customEditor) { if (customEditor) {
const EditorComponent = customEditor as any const EditorComponent = customEditor as any;
return ( return (
<EditorComponent <EditorComponent
onCancel={handleCancel} onCancel={handleCancel}
@@ -51,13 +62,35 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
onMovePrev={onMovePrev} onMovePrev={onMovePrev}
value={value} value={value}
/> />
) );
} }
// Built-in editors based on editorConfig.type // Built-in editors based on editorConfig.type
const editorType = editorConfig.type ?? 'text' const editorType = editorConfig.type ?? 'text';
switch (editorType) { switch (editorType) {
case 'checkbox':
return (
<CheckboxEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as boolean}
/>
);
case 'date':
return (
<DateEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as Date | string}
/>
);
case 'number': case 'number':
return ( return (
<NumericEditor <NumericEditor
@@ -70,18 +103,7 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
step={editorConfig.step} step={editorConfig.step}
value={value as number} value={value as number}
/> />
) );
case 'date':
return (
<DateEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as Date | string}
/>
)
case 'select': case 'select':
return ( return (
@@ -93,18 +115,7 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
options={editorConfig.options ?? []} options={editorConfig.options ?? []}
value={value} value={value}
/> />
) );
case 'checkbox':
return (
<CheckboxEditor
onCancel={handleCancel}
onCommit={handleCommit}
onMoveNext={onMoveNext}
onMovePrev={onMovePrev}
value={value as boolean}
/>
)
case 'text': case 'text':
default: default:
@@ -116,6 +127,6 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
onMovePrev={onMovePrev} onMovePrev={onMovePrev}
value={value as string} value={value as string}
/> />
) );
} }
} }

View File

@@ -1,63 +1,63 @@
import { Checkbox } from '@mantine/core' import { Checkbox } from '@mantine/core';
import { type Cell, flexRender } from '@tanstack/react-table' import { type Cell, flexRender } from '@tanstack/react-table';
import { getGriddyColumn } from '../core/columnMapper' import { getGriddyColumn } from '../core/columnMapper';
import { CSS, SELECTION_COLUMN_ID } from '../core/constants' import { CSS, SELECTION_COLUMN_ID } from '../core/constants';
import { useGriddyStore } from '../core/GriddyStore' import { useGriddyStore } from '../core/GriddyStore';
import styles from '../styles/griddy.module.css' import styles from '../styles/griddy.module.css';
import { EditableCell } from './EditableCell' import { EditableCell } from './EditableCell';
interface TableCellProps<T> { interface TableCellProps<T> {
cell: Cell<T, unknown> cell: Cell<T, unknown>;
showGrouping?: boolean showGrouping?: boolean;
} }
export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) { export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID;
const isEditing = useGriddyStore((s) => s.isEditing) const isEditing = useGriddyStore((s) => s.isEditing);
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex) const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex);
const focusedColumnId = useGriddyStore((s) => s.focusedColumnId) const focusedColumnId = useGriddyStore((s) => s.focusedColumnId);
const setEditing = useGriddyStore((s) => s.setEditing) const setEditing = useGriddyStore((s) => s.setEditing);
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn) const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn);
const onEditCommit = useGriddyStore((s) => s.onEditCommit) const onEditCommit = useGriddyStore((s) => s.onEditCommit);
if (isSelectionCol) { if (isSelectionCol) {
return <RowCheckbox cell={cell} /> return <RowCheckbox cell={cell} />;
} }
const griddyColumn = getGriddyColumn(cell.column) const griddyColumn = getGriddyColumn(cell.column);
const rowIndex = cell.row.index const rowIndex = cell.row.index;
const columnId = cell.column.id const columnId = cell.column.id;
const isEditable = (griddyColumn as any)?.editable ?? false const isEditable = (griddyColumn as any)?.editable ?? false;
const isFocusedCell = isEditing && focusedRowIndex === rowIndex && focusedColumnId === columnId const isFocusedCell = isEditing && focusedRowIndex === rowIndex && focusedColumnId === columnId;
const handleCommit = async (value: unknown) => { const handleCommit = async (value: unknown) => {
if (onEditCommit) { if (onEditCommit) {
await onEditCommit(cell.row.id, columnId, value) await onEditCommit(cell.row.id, columnId, value);
}
setEditing(false)
setFocusedColumn(null)
} }
setEditing(false);
setFocusedColumn(null);
};
const handleCancel = () => { const handleCancel = () => {
setEditing(false) setEditing(false);
setFocusedColumn(null) setFocusedColumn(null);
} };
const handleDoubleClick = () => { const handleDoubleClick = () => {
if (isEditable) { if (isEditable) {
setEditing(true) setEditing(true);
setFocusedColumn(columnId) setFocusedColumn(columnId);
}
} }
};
const isPinned = cell.column.getIsPinned() const isPinned = cell.column.getIsPinned();
const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined;
const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined;
const isGrouped = cell.getIsGrouped() const isGrouped = cell.getIsGrouped();
const isAggregated = cell.getIsAggregated() const isAggregated = cell.getIsAggregated();
const isPlaceholder = cell.getIsPlaceholder() const isPlaceholder = cell.getIsPlaceholder();
return ( return (
<div <div
@@ -65,7 +65,9 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
styles[CSS.cell], styles[CSS.cell],
isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '', isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '',
isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '', isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '',
].filter(Boolean).join(' ')} ]
.filter(Boolean)
.join(' ')}
onDoubleClick={handleDoubleClick} onDoubleClick={handleDoubleClick}
role="gridcell" role="gridcell"
style={{ style={{
@@ -80,8 +82,8 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
<button <button
onClick={() => cell.row.toggleExpanded()} onClick={() => cell.row.toggleExpanded()}
style={{ style={{
border: 'none',
background: 'none', background: 'none',
border: 'none',
cursor: 'pointer', cursor: 'pointer',
marginRight: 4, marginRight: 4,
padding: 0, padding: 0,
@@ -102,19 +104,22 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
{flexRender(cell.column.columnDef.cell, cell.getContext())} ({cell.row.subRows.length}) {flexRender(cell.column.columnDef.cell, cell.getContext())} ({cell.row.subRows.length})
</> </>
) : isAggregated ? ( ) : isAggregated ? (
flexRender(cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell, cell.getContext()) flexRender(
cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell,
cell.getContext()
)
) : isPlaceholder ? null : ( ) : isPlaceholder ? null : (
flexRender(cell.column.columnDef.cell, cell.getContext()) flexRender(cell.column.columnDef.cell, cell.getContext())
)} )}
</div> </div>
) );
} }
function RowCheckbox<T>({ cell }: TableCellProps<T>) { function RowCheckbox<T>({ cell }: TableCellProps<T>) {
const row = cell.row const row = cell.row;
const isPinned = cell.column.getIsPinned() const isPinned = cell.column.getIsPinned();
const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined;
const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined;
return ( return (
<div <div
@@ -122,7 +127,9 @@ function RowCheckbox<T>({ cell }: TableCellProps<T>) {
styles[CSS.cell], styles[CSS.cell],
isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '', isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '',
isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '', isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '',
].filter(Boolean).join(' ')} ]
.filter(Boolean)
.join(' ')}
role="gridcell" role="gridcell"
style={{ style={{
left: leftOffset !== undefined ? `${leftOffset}px` : undefined, left: leftOffset !== undefined ? `${leftOffset}px` : undefined,
@@ -141,5 +148,5 @@ function RowCheckbox<T>({ cell }: TableCellProps<T>) {
size="xs" size="xs"
/> />
</div> </div>
) );
} }

View File

@@ -1,81 +1,85 @@
import { Checkbox } from '@mantine/core' import { Checkbox } from '@mantine/core';
import { flexRender } from '@tanstack/react-table' import { flexRender } from '@tanstack/react-table';
import { useState } from 'react' import { useState } from 'react';
import { CSS, SELECTION_COLUMN_ID } from '../core/constants' import { CSS, SELECTION_COLUMN_ID } from '../core/constants';
import { useGriddyStore } from '../core/GriddyStore' import { useGriddyStore } from '../core/GriddyStore';
import { ColumnFilterPopover, HeaderContextMenu } from '../features/filtering' import { ColumnFilterPopover, HeaderContextMenu } from '../features/filtering';
import styles from '../styles/griddy.module.css' import styles from '../styles/griddy.module.css';
export function TableHeader() { export function TableHeader() {
const table = useGriddyStore((s) => s._table) const table = useGriddyStore((s) => s._table);
const [filterPopoverOpen, setFilterPopoverOpen] = useState<null | string>(null) const [filterPopoverOpen, setFilterPopoverOpen] = useState<null | string>(null);
const [draggedColumn, setDraggedColumn] = useState<string | null>(null) const [draggedColumn, setDraggedColumn] = useState<null | string>(null);
if (!table) return null if (!table) return null;
const headerGroups = table.getHeaderGroups() const headerGroups = table.getHeaderGroups();
const handleDragStart = (e: React.DragEvent, columnId: string) => { const handleDragStart = (e: React.DragEvent, columnId: string) => {
setDraggedColumn(columnId) setDraggedColumn(columnId);
e.dataTransfer.effectAllowed = 'move' e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', columnId) e.dataTransfer.setData('text/plain', columnId);
} };
const handleDragOver = (e: React.DragEvent) => { const handleDragOver = (e: React.DragEvent) => {
e.preventDefault() e.preventDefault();
e.dataTransfer.dropEffect = 'move' e.dataTransfer.dropEffect = 'move';
} };
const handleDrop = (e: React.DragEvent, targetColumnId: string) => { const handleDrop = (e: React.DragEvent, targetColumnId: string) => {
e.preventDefault() e.preventDefault();
if (!draggedColumn || draggedColumn === targetColumnId) { if (!draggedColumn || draggedColumn === targetColumnId) {
setDraggedColumn(null) setDraggedColumn(null);
return return;
} }
const columnOrder = table.getState().columnOrder const columnOrder = table.getState().columnOrder;
const currentOrder = columnOrder.length ? columnOrder : table.getAllLeafColumns().map(c => c.id) const currentOrder = columnOrder.length
? columnOrder
: table.getAllLeafColumns().map((c) => c.id);
const draggedIdx = currentOrder.indexOf(draggedColumn) const draggedIdx = currentOrder.indexOf(draggedColumn);
const targetIdx = currentOrder.indexOf(targetColumnId) const targetIdx = currentOrder.indexOf(targetColumnId);
if (draggedIdx === -1 || targetIdx === -1) { if (draggedIdx === -1 || targetIdx === -1) {
setDraggedColumn(null) setDraggedColumn(null);
return return;
} }
const newOrder = [...currentOrder] const newOrder = [...currentOrder];
newOrder.splice(draggedIdx, 1) newOrder.splice(draggedIdx, 1);
newOrder.splice(targetIdx, 0, draggedColumn) newOrder.splice(targetIdx, 0, draggedColumn);
table.setColumnOrder(newOrder) table.setColumnOrder(newOrder);
setDraggedColumn(null) setDraggedColumn(null);
} };
const handleDragEnd = () => { const handleDragEnd = () => {
setDraggedColumn(null) setDraggedColumn(null);
} };
return ( return (
<div className={styles[CSS.thead]} role="rowgroup"> <div className={styles[CSS.thead]} role="rowgroup">
{headerGroups.map((headerGroup) => ( {headerGroups.map((headerGroup) => (
<div className={styles[CSS.headerRow]} key={headerGroup.id} role="row"> <div className={styles[CSS.headerRow]} key={headerGroup.id} role="row">
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
const isSortable = header.column.getCanSort() const isSortable = header.column.getCanSort();
const sortDir = header.column.getIsSorted() const sortDir = header.column.getIsSorted();
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID const isSelectionCol = header.column.id === SELECTION_COLUMN_ID;
const isFilterPopoverOpen = filterPopoverOpen === header.column.id const isFilterPopoverOpen = filterPopoverOpen === header.column.id;
const isPinned = header.column.getIsPinned() const isPinned = header.column.getIsPinned();
const leftOffset = isPinned === 'left' ? header.getStart('left') : undefined const leftOffset = isPinned === 'left' ? header.getStart('left') : undefined;
const rightOffset = isPinned === 'right' ? header.column.getAfter('right') : undefined const rightOffset = isPinned === 'right' ? header.column.getAfter('right') : undefined;
const isDragging = draggedColumn === header.column.id const isDragging = draggedColumn === header.column.id;
const canReorder = !isSelectionCol && !isPinned const canReorder = !isSelectionCol && !isPinned;
return ( return (
<div <div
aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'} aria-sort={
sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'
}
className={[ className={[
styles[CSS.headerCell], styles[CSS.headerCell],
isSortable ? styles[CSS.headerCellSortable] : '', isSortable ? styles[CSS.headerCellSortable] : '',
@@ -83,7 +87,9 @@ export function TableHeader() {
isPinned === 'left' ? styles['griddy-header-cell--pinned-left'] : '', isPinned === 'left' ? styles['griddy-header-cell--pinned-left'] : '',
isPinned === 'right' ? styles['griddy-header-cell--pinned-right'] : '', isPinned === 'right' ? styles['griddy-header-cell--pinned-right'] : '',
isDragging ? styles['griddy-header-cell--dragging'] : '', isDragging ? styles['griddy-header-cell--dragging'] : '',
].filter(Boolean).join(' ')} ]
.filter(Boolean)
.join(' ')}
draggable={canReorder} draggable={canReorder}
key={header.id} key={header.id}
onClick={isSortable ? header.column.getToggleSortingHandler() : undefined} onClick={isSortable ? header.column.getToggleSortingHandler() : undefined}
@@ -137,19 +143,19 @@ export function TableHeader() {
/> />
)} )}
</div> </div>
) );
})} })}
</div> </div>
))} ))}
</div> </div>
) );
} }
function SelectAllCheckbox() { function SelectAllCheckbox() {
const table = useGriddyStore((s) => s._table) const table = useGriddyStore((s) => s._table);
const selection = useGriddyStore((s) => s.selection) const selection = useGriddyStore((s) => s.selection);
if (!table || !selection || selection.mode !== 'multi') return null if (!table || !selection || selection.mode !== 'multi') return null;
return ( return (
<Checkbox <Checkbox
@@ -159,5 +165,5 @@ function SelectAllCheckbox() {
onChange={table.getToggleAllRowsSelectedHandler()} onChange={table.getToggleAllRowsSelectedHandler()}
size="xs" size="xs"
/> />
) );
} }

View File

@@ -1,68 +1,68 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react';
import { CSS } from '../core/constants' import { CSS } from '../core/constants';
import { useGriddyStore } from '../core/GriddyStore' import { useGriddyStore } from '../core/GriddyStore';
import styles from '../styles/griddy.module.css' import styles from '../styles/griddy.module.css';
import { TableRow } from './TableRow' import { TableRow } from './TableRow';
export function VirtualBody() { export function VirtualBody() {
const table = useGriddyStore((s) => s._table) const table = useGriddyStore((s) => s._table);
const virtualizer = useGriddyStore((s) => s._virtualizer) const virtualizer = useGriddyStore((s) => s._virtualizer);
const setTotalRows = useGriddyStore((s) => s.setTotalRows) const setTotalRows = useGriddyStore((s) => s.setTotalRows);
const infiniteScroll = useGriddyStore((s) => s.infiniteScroll) const infiniteScroll = useGriddyStore((s) => s.infiniteScroll);
const rows = table?.getRowModel().rows const rows = table?.getRowModel().rows;
const virtualRows = virtualizer?.getVirtualItems() const virtualRows = virtualizer?.getVirtualItems();
const totalSize = virtualizer?.getTotalSize() ?? 0 const totalSize = virtualizer?.getTotalSize() ?? 0;
// Track if we're currently loading to prevent multiple simultaneous calls // Track if we're currently loading to prevent multiple simultaneous calls
const isLoadingRef = useRef(false) const isLoadingRef = useRef(false);
// Sync row count to store for keyboard navigation bounds // Sync row count to store for keyboard navigation bounds
useEffect(() => { useEffect(() => {
if (rows) { if (rows) {
setTotalRows(rows.length) setTotalRows(rows.length);
} }
}, [rows?.length, setTotalRows]) }, [rows?.length, setTotalRows]);
// Infinite scroll: detect when approaching the end // Infinite scroll: detect when approaching the end
useEffect(() => { useEffect(() => {
if (!infiniteScroll?.enabled || !infiniteScroll.onLoadMore || !virtualRows || !rows) { if (!infiniteScroll?.enabled || !infiniteScroll.onLoadMore || !virtualRows || !rows) {
return return;
} }
const { threshold = 10, hasMore = true, isLoading = false } = infiniteScroll const { hasMore = true, isLoading = false, threshold = 10 } = infiniteScroll;
// Don't trigger if already loading or no more data // Don't trigger if already loading or no more data
if (isLoading || !hasMore || isLoadingRef.current) { if (isLoading || !hasMore || isLoadingRef.current) {
return return;
} }
// Check if the last rendered virtual row is within threshold of the end // Check if the last rendered virtual row is within threshold of the end
const lastVirtualRow = virtualRows[virtualRows.length - 1] const lastVirtualRow = virtualRows[virtualRows.length - 1];
if (!lastVirtualRow) return if (!lastVirtualRow) return;
const lastVirtualIndex = lastVirtualRow.index const lastVirtualIndex = lastVirtualRow.index;
const totalRows = rows.length const totalRows = rows.length;
const distanceFromEnd = totalRows - lastVirtualIndex - 1 const distanceFromEnd = totalRows - lastVirtualIndex - 1;
if (distanceFromEnd <= threshold) { if (distanceFromEnd <= threshold) {
isLoadingRef.current = true isLoadingRef.current = true;
const loadPromise = infiniteScroll.onLoadMore() const loadPromise = infiniteScroll.onLoadMore();
if (loadPromise instanceof Promise) { if (loadPromise instanceof Promise) {
loadPromise.finally(() => { loadPromise.finally(() => {
isLoadingRef.current = false isLoadingRef.current = false;
}) });
} else { } else {
isLoadingRef.current = false isLoadingRef.current = false;
} }
} }
}, [virtualRows, rows, infiniteScroll]) }, [virtualRows, rows, infiniteScroll]);
if (!table || !virtualizer || !rows || !virtualRows) return null if (!table || !virtualizer || !rows || !virtualRows) return null;
const showLoadingIndicator = infiniteScroll?.enabled && infiniteScroll.isLoading const showLoadingIndicator = infiniteScroll?.enabled && infiniteScroll.isLoading;
return ( return (
<div <div
@@ -75,17 +75,10 @@ export function VirtualBody() {
}} }}
> >
{virtualRows.map((virtualRow) => { {virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] const row = rows[virtualRow.index];
if (!row) return null if (!row) return null;
return ( return <TableRow key={row.id} row={row} size={virtualRow.size} start={virtualRow.start} />;
<TableRow
key={row.id}
row={row}
size={virtualRow.size}
start={virtualRow.start}
/>
)
})} })}
{showLoadingIndicator && ( {showLoadingIndicator && (
<div <div
@@ -101,5 +94,5 @@ export function VirtualBody() {
</div> </div>
)} )}
</div> </div>
) );
} }