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:
263
llm/docs/resolvespec-js.md
Normal file
263
llm/docs/resolvespec-js.md
Normal 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
|
||||
131
llm/docs/zustandsyncstore.md
Normal file
131
llm/docs/zustandsyncstore.md
Normal 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;
|
||||
};
|
||||
};
|
||||
```
|
||||
@@ -110,6 +110,7 @@
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@warkypublic/artemis-kit": "^1.0.10",
|
||||
"@warkypublic/zustandsyncstore": "^1.0.0",
|
||||
"@warkypublic/resolvespec-js": "^1.0.1",
|
||||
"idb-keyval": "^6.2.2",
|
||||
"immer": "^10.1.3",
|
||||
"react": ">= 19.0.0",
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -44,6 +44,9 @@ importers:
|
||||
'@warkypublic/artemis-kit':
|
||||
specifier: ^1.0.10
|
||||
version: 1.0.10
|
||||
'@warkypublic/resolvespec-js':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
'@warkypublic/zustandsyncstore':
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))(zustand@5.0.8(@types/react@19.2.14)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)))
|
||||
@@ -1509,6 +1512,10 @@ packages:
|
||||
resolution: {integrity: sha512-qIgjcWqLyYfoKDUYt3Gm7PVe2S4AdjA46J1jPIff1p6wUP5WsHA8UfZq7pEdP6YNxqavv+h84oe1+HsJOoU6jQ==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
'@warkypublic/resolvespec-js@1.0.1':
|
||||
resolution: {integrity: sha512-uXP1HouxpOKXfwE6qpy0gCcrMPIgjDT53aVGkfork4QejRSunbKWSKKawW2nIm7RnyFhSjPILMXcnT5xUiXOew==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@warkypublic/zustandsyncstore@1.0.0':
|
||||
resolution: {integrity: sha512-hvd4Xrn5btEPjJwNgX52ONoZHnAJdF3NcoTK3GJMVrullcZ+tS2W/SCWIa8vTiYBEhdNyavxsNDZT2x/C9GmVg==}
|
||||
peerDependencies:
|
||||
@@ -3819,6 +3826,10 @@ packages:
|
||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||
hasBin: true
|
||||
|
||||
uuid@13.0.0:
|
||||
resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==}
|
||||
hasBin: true
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -5516,6 +5527,10 @@ snapshots:
|
||||
semver: 7.7.3
|
||||
uuid: 11.1.0
|
||||
|
||||
'@warkypublic/resolvespec-js@1.0.1':
|
||||
dependencies:
|
||||
uuid: 13.0.0
|
||||
|
||||
'@warkypublic/zustandsyncstore@1.0.0(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4))(zustand@5.0.8(@types/react@19.2.14)(immer@10.1.3)(react@19.2.4)(use-sync-external-store@1.5.0(react@19.2.4)))':
|
||||
dependencies:
|
||||
'@warkypublic/artemis-kit': 1.0.10
|
||||
@@ -8059,6 +8074,8 @@ snapshots:
|
||||
|
||||
uuid@11.1.0: {}
|
||||
|
||||
uuid@13.0.0: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite-plugin-dts@4.5.4(@types/node@25.2.3)(rollup@4.50.2)(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.3)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.6))):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
393
src/Griddy/adapters/Adapters.stories.tsx
Normal file
393
src/Griddy/adapters/Adapters.stories.tsx
Normal 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} />,
|
||||
};
|
||||
299
src/Griddy/adapters/HeaderSpecAdapter.tsx
Normal file
299
src/Griddy/adapters/HeaderSpecAdapter.tsx
Normal 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;
|
||||
}
|
||||
);
|
||||
285
src/Griddy/adapters/ResolveSpecAdapter.tsx
Normal file
285
src/Griddy/adapters/ResolveSpecAdapter.tsx
Normal 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;
|
||||
}
|
||||
);
|
||||
4
src/Griddy/adapters/index.ts
Normal file
4
src/Griddy/adapters/index.ts
Normal 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'
|
||||
126
src/Griddy/adapters/mapOptions.ts
Normal file
126
src/Griddy/adapters/mapOptions.ts
Normal 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;
|
||||
}
|
||||
30
src/Griddy/adapters/types.ts
Normal file
30
src/Griddy/adapters/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -15,25 +15,33 @@ import {
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from '@tanstack/react-table'
|
||||
import React, { forwardRef, type Ref, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
} from '@tanstack/react-table';
|
||||
import React, {
|
||||
forwardRef,
|
||||
type Ref,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { GriddyProps, GriddyRef } from './types'
|
||||
import type { GriddyProps, GriddyRef } from './types';
|
||||
|
||||
import { AdvancedSearchPanel, advancedSearchGlobalFilterFn } from '../features/advancedSearch'
|
||||
import { GriddyErrorBoundary } from '../features/errorBoundary'
|
||||
import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation'
|
||||
import { GriddyLoadingOverlay, GriddyLoadingSkeleton } from '../features/loading'
|
||||
import { PaginationControl } from '../features/pagination'
|
||||
import { SearchOverlay } from '../features/search/SearchOverlay'
|
||||
import { GridToolbar } from '../features/toolbar'
|
||||
import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer'
|
||||
import { TableHeader } from '../rendering/TableHeader'
|
||||
import { VirtualBody } from '../rendering/VirtualBody'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
import { mapColumns } from './columnMapper'
|
||||
import { CSS, DEFAULTS } from './constants'
|
||||
import { GriddyProvider, useGriddyStore } from './GriddyStore'
|
||||
import { advancedSearchGlobalFilterFn, AdvancedSearchPanel } from '../features/advancedSearch';
|
||||
import { GriddyErrorBoundary } from '../features/errorBoundary';
|
||||
import { useKeyboardNavigation } from '../features/keyboard/useKeyboardNavigation';
|
||||
import { GriddyLoadingOverlay, GriddyLoadingSkeleton } from '../features/loading';
|
||||
import { PaginationControl } from '../features/pagination';
|
||||
import { SearchOverlay } from '../features/search/SearchOverlay';
|
||||
import { GridToolbar } from '../features/toolbar';
|
||||
import { useGridVirtualizer } from '../rendering/hooks/useGridVirtualizer';
|
||||
import { TableHeader } from '../rendering/TableHeader';
|
||||
import { VirtualBody } from '../rendering/VirtualBody';
|
||||
import styles from '../styles/griddy.module.css';
|
||||
import { mapColumns } from './columnMapper';
|
||||
import { CSS, DEFAULTS } from './constants';
|
||||
import { GriddyProvider, useGriddyStore } from './GriddyStore';
|
||||
|
||||
// ─── Inner Component (lives inside Provider, has store access) ───────────────
|
||||
|
||||
@@ -45,119 +53,119 @@ function _Griddy<T>(props: GriddyProps<T>, ref: Ref<GriddyRef<T>>) {
|
||||
</GriddyErrorBoundary>
|
||||
{props.children}
|
||||
</GriddyProvider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component with forwardRef ──────────────────────────────────────────
|
||||
|
||||
function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
// Read props from synced store
|
||||
const data = useGriddyStore((s) => s.data)
|
||||
const userColumns = useGriddyStore((s) => s.columns)
|
||||
const getRowId = useGriddyStore((s) => s.getRowId)
|
||||
const selection = useGriddyStore((s) => s.selection)
|
||||
const search = useGriddyStore((s) => s.search)
|
||||
const groupingConfig = useGriddyStore((s) => s.grouping)
|
||||
const paginationConfig = useGriddyStore((s) => s.pagination)
|
||||
const controlledSorting = useGriddyStore((s) => s.sorting)
|
||||
const onSortingChange = useGriddyStore((s) => s.onSortingChange)
|
||||
const controlledFilters = useGriddyStore((s) => s.columnFilters)
|
||||
const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange)
|
||||
const controlledPinning = useGriddyStore((s) => s.columnPinning)
|
||||
const onColumnPinningChange = useGriddyStore((s) => s.onColumnPinningChange)
|
||||
const controlledRowSelection = useGriddyStore((s) => s.rowSelection)
|
||||
const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange)
|
||||
const onEditCommit = useGriddyStore((s) => s.onEditCommit)
|
||||
const rowHeight = useGriddyStore((s) => s.rowHeight)
|
||||
const overscanProp = useGriddyStore((s) => s.overscan)
|
||||
const height = useGriddyStore((s) => s.height)
|
||||
const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation)
|
||||
const className = useGriddyStore((s) => s.className)
|
||||
const showToolbar = useGriddyStore((s) => s.showToolbar)
|
||||
const exportFilename = useGriddyStore((s) => s.exportFilename)
|
||||
const isLoading = useGriddyStore((s) => s.isLoading)
|
||||
const filterPresets = useGriddyStore((s) => s.filterPresets)
|
||||
const advancedSearch = useGriddyStore((s) => s.advancedSearch)
|
||||
const persistenceKey = useGriddyStore((s) => s.persistenceKey)
|
||||
const manualSorting = useGriddyStore((s) => s.manualSorting)
|
||||
const manualFiltering = useGriddyStore((s) => s.manualFiltering)
|
||||
const dataCount = useGriddyStore((s) => s.dataCount)
|
||||
const setTable = useGriddyStore((s) => s.setTable)
|
||||
const setVirtualizer = useGriddyStore((s) => s.setVirtualizer)
|
||||
const setScrollRef = useGriddyStore((s) => s.setScrollRef)
|
||||
const setFocusedRow = useGriddyStore((s) => s.setFocusedRow)
|
||||
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn)
|
||||
const setEditing = useGriddyStore((s) => s.setEditing)
|
||||
const setTotalRows = useGriddyStore((s) => s.setTotalRows)
|
||||
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex)
|
||||
const data = useGriddyStore((s) => s.data);
|
||||
const userColumns = useGriddyStore((s) => s.columns);
|
||||
const getRowId = useGriddyStore((s) => s.getRowId);
|
||||
const selection = useGriddyStore((s) => s.selection);
|
||||
const search = useGriddyStore((s) => s.search);
|
||||
const groupingConfig = useGriddyStore((s) => s.grouping);
|
||||
const paginationConfig = useGriddyStore((s) => s.pagination);
|
||||
const controlledSorting = useGriddyStore((s) => s.sorting);
|
||||
const onSortingChange = useGriddyStore((s) => s.onSortingChange);
|
||||
const controlledFilters = useGriddyStore((s) => s.columnFilters);
|
||||
const onColumnFiltersChange = useGriddyStore((s) => s.onColumnFiltersChange);
|
||||
const controlledPinning = useGriddyStore((s) => s.columnPinning);
|
||||
const onColumnPinningChange = useGriddyStore((s) => s.onColumnPinningChange);
|
||||
const controlledRowSelection = useGriddyStore((s) => s.rowSelection);
|
||||
const onRowSelectionChange = useGriddyStore((s) => s.onRowSelectionChange);
|
||||
const onEditCommit = useGriddyStore((s) => s.onEditCommit);
|
||||
const rowHeight = useGriddyStore((s) => s.rowHeight);
|
||||
const overscanProp = useGriddyStore((s) => s.overscan);
|
||||
const height = useGriddyStore((s) => s.height);
|
||||
const keyboardNavigation = useGriddyStore((s) => s.keyboardNavigation);
|
||||
const className = useGriddyStore((s) => s.className);
|
||||
const showToolbar = useGriddyStore((s) => s.showToolbar);
|
||||
const exportFilename = useGriddyStore((s) => s.exportFilename);
|
||||
const isLoading = useGriddyStore((s) => s.isLoading);
|
||||
const filterPresets = useGriddyStore((s) => s.filterPresets);
|
||||
const advancedSearch = useGriddyStore((s) => s.advancedSearch);
|
||||
const persistenceKey = useGriddyStore((s) => s.persistenceKey);
|
||||
const manualSorting = useGriddyStore((s) => s.manualSorting);
|
||||
const manualFiltering = useGriddyStore((s) => s.manualFiltering);
|
||||
const dataCount = useGriddyStore((s) => s.dataCount);
|
||||
const setTable = useGriddyStore((s) => s.setTable);
|
||||
const setVirtualizer = useGriddyStore((s) => s.setVirtualizer);
|
||||
const setScrollRef = useGriddyStore((s) => s.setScrollRef);
|
||||
const setFocusedRow = useGriddyStore((s) => s.setFocusedRow);
|
||||
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn);
|
||||
const setEditing = useGriddyStore((s) => s.setEditing);
|
||||
const setTotalRows = useGriddyStore((s) => s.setTotalRows);
|
||||
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex);
|
||||
|
||||
const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight
|
||||
const effectiveOverscan = overscanProp ?? DEFAULTS.overscan
|
||||
const enableKeyboard = keyboardNavigation !== false
|
||||
const effectiveRowHeight = rowHeight ?? DEFAULTS.rowHeight;
|
||||
const effectiveOverscan = overscanProp ?? DEFAULTS.overscan;
|
||||
const enableKeyboard = keyboardNavigation !== false;
|
||||
|
||||
// ─── Column Mapping ───
|
||||
const columns = useMemo(
|
||||
() => mapColumns(userColumns ?? [], selection) as ColumnDef<T, any>[],
|
||||
[userColumns, selection],
|
||||
)
|
||||
[userColumns, selection]
|
||||
);
|
||||
|
||||
// ─── Table State (internal/uncontrolled) ───
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([])
|
||||
const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>([])
|
||||
const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({})
|
||||
const [globalFilter, setGlobalFilter] = useState<string | undefined>(undefined)
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([])
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([]);
|
||||
const [internalFilters, setInternalFilters] = useState<ColumnFiltersState>([]);
|
||||
const [internalRowSelection, setInternalRowSelection] = useState<RowSelectionState>({});
|
||||
const [globalFilter, setGlobalFilter] = useState<string | undefined>(undefined);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnOrder, setColumnOrder] = useState<ColumnOrderState>([]);
|
||||
|
||||
// Build initial column pinning from column definitions
|
||||
const initialPinning = useMemo(() => {
|
||||
const left: string[] = []
|
||||
const right: string[] = []
|
||||
userColumns?.forEach(col => {
|
||||
if (col.pinned === 'left') left.push(col.id)
|
||||
else if (col.pinned === 'right') right.push(col.id)
|
||||
})
|
||||
return { left, right }
|
||||
}, [userColumns])
|
||||
const left: string[] = [];
|
||||
const right: string[] = [];
|
||||
userColumns?.forEach((col) => {
|
||||
if (col.pinned === 'left') left.push(col.id);
|
||||
else if (col.pinned === 'right') right.push(col.id);
|
||||
});
|
||||
return { left, right };
|
||||
}, [userColumns]);
|
||||
|
||||
const [internalPinning, setInternalPinning] = useState<ColumnPinningState>(initialPinning)
|
||||
const [grouping, setGrouping] = useState<GroupingState>(groupingConfig?.columns ?? [])
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [internalPinning, setInternalPinning] = useState<ColumnPinningState>(initialPinning);
|
||||
const [grouping, setGrouping] = useState<GroupingState>(groupingConfig?.columns ?? []);
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const [internalPagination, setInternalPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: paginationConfig?.pageSize ?? DEFAULTS.pageSize,
|
||||
})
|
||||
});
|
||||
|
||||
// Wrap pagination setters to call callbacks
|
||||
const handlePaginationChange = (updater: any) => {
|
||||
setInternalPagination(prev => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater
|
||||
setInternalPagination((prev) => {
|
||||
const next = typeof updater === 'function' ? updater(prev) : updater;
|
||||
// Call callbacks if pagination config exists
|
||||
if (paginationConfig) {
|
||||
if (next.pageIndex !== prev.pageIndex && paginationConfig.onPageChange) {
|
||||
paginationConfig.onPageChange(next.pageIndex)
|
||||
paginationConfig.onPageChange(next.pageIndex);
|
||||
}
|
||||
if (next.pageSize !== prev.pageSize && paginationConfig.onPageSizeChange) {
|
||||
paginationConfig.onPageSizeChange(next.pageSize)
|
||||
paginationConfig.onPageSizeChange(next.pageSize);
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Resolve controlled vs uncontrolled
|
||||
const sorting = controlledSorting ?? internalSorting
|
||||
const setSorting = onSortingChange ?? setInternalSorting
|
||||
const columnFilters = controlledFilters ?? internalFilters
|
||||
const setColumnFilters = onColumnFiltersChange ?? setInternalFilters
|
||||
const columnPinning = controlledPinning ?? internalPinning
|
||||
const setColumnPinning = onColumnPinningChange ?? setInternalPinning
|
||||
const rowSelectionState = controlledRowSelection ?? internalRowSelection
|
||||
const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
|
||||
const sorting = controlledSorting ?? internalSorting;
|
||||
const setSorting = onSortingChange ?? setInternalSorting;
|
||||
const columnFilters = controlledFilters ?? internalFilters;
|
||||
const setColumnFilters = onColumnFiltersChange ?? setInternalFilters;
|
||||
const columnPinning = controlledPinning ?? internalPinning;
|
||||
const setColumnPinning = onColumnPinningChange ?? setInternalPinning;
|
||||
const rowSelectionState = controlledRowSelection ?? internalRowSelection;
|
||||
const setRowSelection = onRowSelectionChange ?? setInternalRowSelection;
|
||||
|
||||
// ─── Selection config ───
|
||||
const enableRowSelection = selection ? selection.mode !== 'none' : false
|
||||
const enableMultiRowSelection = selection?.mode === 'multi'
|
||||
const enableRowSelection = selection ? selection.mode !== 'none' : false;
|
||||
const enableMultiRowSelection = selection?.mode === 'multi';
|
||||
|
||||
// ─── TanStack Table Instance ───
|
||||
const table = useReactTable<T>({
|
||||
@@ -177,7 +185,7 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getFilteredRowModel: manualFiltering ? undefined : getFilteredRowModel(),
|
||||
getGroupedRowModel: groupingConfig?.enabled ? getGroupedRowModel() : undefined,
|
||||
getRowId: getRowId as any ?? ((_, index) => String(index)),
|
||||
getRowId: (getRowId as any) ?? ((_, index) => String(index)),
|
||||
getSortedRowModel: manualSorting ? undefined : getSortedRowModel(),
|
||||
manualFiltering: manualFiltering ?? false,
|
||||
manualSorting: manualSorting ?? false,
|
||||
@@ -206,10 +214,10 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
},
|
||||
...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}),
|
||||
columnResizeMode: 'onChange',
|
||||
})
|
||||
});
|
||||
|
||||
// ─── Scroll Container Ref ───
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ─── TanStack Virtual ───
|
||||
const virtualizer = useGridVirtualizer({
|
||||
@@ -217,16 +225,22 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
rowHeight: effectiveRowHeight,
|
||||
scrollRef,
|
||||
table,
|
||||
})
|
||||
});
|
||||
|
||||
// ─── Sync table + virtualizer + scrollRef into store ───
|
||||
useEffect(() => { setTable(table) }, [table, setTable])
|
||||
useEffect(() => { setVirtualizer(virtualizer) }, [virtualizer, setVirtualizer])
|
||||
useEffect(() => { setScrollRef(scrollRef.current) }, [setScrollRef])
|
||||
useEffect(() => {
|
||||
setTable(table);
|
||||
}, [table, setTable]);
|
||||
useEffect(() => {
|
||||
setVirtualizer(virtualizer);
|
||||
}, [virtualizer, setVirtualizer]);
|
||||
useEffect(() => {
|
||||
setScrollRef(scrollRef.current);
|
||||
}, [setScrollRef]);
|
||||
|
||||
// ─── Keyboard Navigation ───
|
||||
// Get the full store state for imperative access in keyboard handler
|
||||
const storeState = useGriddyStore()
|
||||
const storeState = useGriddyStore();
|
||||
|
||||
useKeyboardNavigation({
|
||||
editingEnabled: !!onEditCommit,
|
||||
@@ -236,59 +250,64 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
storeState,
|
||||
table,
|
||||
virtualizer,
|
||||
})
|
||||
});
|
||||
|
||||
// ─── Set initial focus when data loads ───
|
||||
const rowCount = table.getRowModel().rows.length
|
||||
const rowCount = table.getRowModel().rows.length;
|
||||
|
||||
useEffect(() => {
|
||||
setTotalRows(rowCount)
|
||||
setTotalRows(rowCount);
|
||||
if (rowCount > 0 && focusedRowIndex === null) {
|
||||
setFocusedRow(0)
|
||||
setFocusedRow(0);
|
||||
}
|
||||
}, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow])
|
||||
}, [rowCount, focusedRowIndex, setTotalRows, setFocusedRow]);
|
||||
|
||||
// ─── Imperative Ref ───
|
||||
useImperativeHandle(tableRef, () => ({
|
||||
deselectAll: () => table.resetRowSelection(),
|
||||
focusRow: (index: number) => {
|
||||
setFocusedRow(index)
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' })
|
||||
},
|
||||
getTable: () => table,
|
||||
getUIState: () => ({
|
||||
focusedColumnId: null,
|
||||
focusedRowIndex,
|
||||
isEditing: false,
|
||||
isSearchOpen: false,
|
||||
isSelecting: false,
|
||||
totalRows: rowCount,
|
||||
} as any),
|
||||
getVirtualizer: () => virtualizer,
|
||||
scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }),
|
||||
selectRow: (id: string) => {
|
||||
const row = table.getRowModel().rows.find((r) => r.id === id)
|
||||
row?.toggleSelected(true)
|
||||
},
|
||||
startEditing: (rowId: string, columnId?: string) => {
|
||||
const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId)
|
||||
if (rowIndex >= 0) {
|
||||
setFocusedRow(rowIndex)
|
||||
if (columnId) setFocusedColumn(columnId)
|
||||
setEditing(true)
|
||||
}
|
||||
},
|
||||
}), [table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount])
|
||||
useImperativeHandle(
|
||||
tableRef,
|
||||
() => ({
|
||||
deselectAll: () => table.resetRowSelection(),
|
||||
focusRow: (index: number) => {
|
||||
setFocusedRow(index);
|
||||
virtualizer.scrollToIndex(index, { align: 'auto' });
|
||||
},
|
||||
getTable: () => table,
|
||||
getUIState: () =>
|
||||
({
|
||||
focusedColumnId: null,
|
||||
focusedRowIndex,
|
||||
isEditing: false,
|
||||
isSearchOpen: false,
|
||||
isSelecting: false,
|
||||
totalRows: rowCount,
|
||||
}) as any,
|
||||
getVirtualizer: () => virtualizer,
|
||||
scrollToRow: (index: number) => virtualizer.scrollToIndex(index, { align: 'auto' }),
|
||||
selectRow: (id: string) => {
|
||||
const row = table.getRowModel().rows.find((r) => r.id === id);
|
||||
row?.toggleSelected(true);
|
||||
},
|
||||
startEditing: (rowId: string, columnId?: string) => {
|
||||
const rowIndex = table.getRowModel().rows.findIndex((r) => r.id === rowId);
|
||||
if (rowIndex >= 0) {
|
||||
setFocusedRow(rowIndex);
|
||||
if (columnId) setFocusedColumn(columnId);
|
||||
setEditing(true);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[table, virtualizer, setFocusedRow, setFocusedColumn, setEditing, focusedRowIndex, rowCount]
|
||||
);
|
||||
|
||||
// ─── Render ───
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height: height ?? '100%',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}
|
||||
};
|
||||
|
||||
const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null
|
||||
const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined
|
||||
const focusedRow = focusedRowIndex !== null ? table.getRowModel().rows[focusedRowIndex] : null;
|
||||
const focusedRowId = focusedRow ? `griddy-row-${focusedRow.id}` : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -325,15 +344,12 @@ function GriddyInner<T>({ tableRef }: { tableRef: Ref<GriddyRef<T>> }) {
|
||||
)}
|
||||
</div>
|
||||
{paginationConfig?.enabled && (
|
||||
<PaginationControl
|
||||
pageSizeOptions={paginationConfig.pageSizeOptions}
|
||||
table={table}
|
||||
/>
|
||||
<PaginationControl pageSizeOptions={paginationConfig.pageSizeOptions} table={table} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export const Griddy = forwardRef(_Griddy) as <T>(
|
||||
props: GriddyProps<T> & React.RefAttributes<GriddyRef<T>>
|
||||
) => React.ReactElement
|
||||
) => React.ReactElement;
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import type { Table } from '@tanstack/react-table'
|
||||
import type { ColumnFiltersState, ColumnPinningState, RowSelectionState, SortingState } from '@tanstack/react-table'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { Table } from '@tanstack/react-table';
|
||||
import type {
|
||||
ColumnFiltersState,
|
||||
ColumnPinningState,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
} from '@tanstack/react-table';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
|
||||
import { createSyncStore } from '@warkypublic/zustandsyncstore'
|
||||
import { createSyncStore } from '@warkypublic/zustandsyncstore';
|
||||
|
||||
import type { AdvancedSearchConfig, DataAdapter, GriddyColumn, GriddyProps, GriddyUIState, GroupingConfig, InfiniteScrollConfig, PaginationConfig, SearchConfig, SelectionConfig } from './types'
|
||||
import type {
|
||||
AdvancedSearchConfig,
|
||||
DataAdapter,
|
||||
GriddyColumn,
|
||||
GriddyProps,
|
||||
GriddyUIState,
|
||||
GroupingConfig,
|
||||
InfiniteScrollConfig,
|
||||
PaginationConfig,
|
||||
SearchConfig,
|
||||
SelectionConfig,
|
||||
} from './types';
|
||||
|
||||
// ─── Store State ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -14,54 +30,61 @@ import type { AdvancedSearchConfig, DataAdapter, GriddyColumn, GriddyProps, Grid
|
||||
* Fields from GriddyProps must be declared here so TypeScript can see them.
|
||||
*/
|
||||
export interface GriddyStoreState extends GriddyUIState {
|
||||
_scrollRef: HTMLDivElement | null
|
||||
_scrollRef: HTMLDivElement | null;
|
||||
// ─── Internal refs (set imperatively) ───
|
||||
_table: null | Table<any>
|
||||
_virtualizer: null | Virtualizer<HTMLDivElement, Element>
|
||||
advancedSearch?: AdvancedSearchConfig
|
||||
className?: string
|
||||
columnFilters?: ColumnFiltersState
|
||||
columns?: GriddyColumn<any>[]
|
||||
columnPinning?: ColumnPinningState
|
||||
onColumnPinningChange?: (pinning: ColumnPinningState) => void
|
||||
data?: any[]
|
||||
_table: null | Table<any>;
|
||||
_virtualizer: null | Virtualizer<HTMLDivElement, Element>;
|
||||
advancedSearch?: AdvancedSearchConfig;
|
||||
// ─── Adapter Actions ───
|
||||
appendData: (data: any[]) => void;
|
||||
className?: string;
|
||||
columnFilters?: ColumnFiltersState;
|
||||
columnPinning?: ColumnPinningState;
|
||||
columns?: GriddyColumn<any>[];
|
||||
data?: any[];
|
||||
dataAdapter?: DataAdapter<any>;
|
||||
dataCount?: number;
|
||||
// ─── Error State ───
|
||||
error: Error | null
|
||||
exportFilename?: string
|
||||
dataAdapter?: DataAdapter<any>
|
||||
dataCount?: number
|
||||
filterPresets?: boolean
|
||||
getRowId?: (row: any, index: number) => string
|
||||
grouping?: GroupingConfig
|
||||
height?: number | string
|
||||
infiniteScroll?: InfiniteScrollConfig
|
||||
isLoading?: boolean
|
||||
keyboardNavigation?: boolean
|
||||
manualFiltering?: boolean
|
||||
manualSorting?: boolean
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
|
||||
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
|
||||
onError?: (error: Error) => void
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void
|
||||
onSortingChange?: (sorting: SortingState) => void
|
||||
overscan?: number
|
||||
pagination?: PaginationConfig
|
||||
persistenceKey?: string
|
||||
rowHeight?: number
|
||||
rowSelection?: RowSelectionState
|
||||
search?: SearchConfig
|
||||
error: Error | null;
|
||||
exportFilename?: string;
|
||||
filterPresets?: boolean;
|
||||
getRowId?: (row: any, index: number) => string;
|
||||
grouping?: GroupingConfig;
|
||||
height?: number | string;
|
||||
infiniteScroll?: InfiniteScrollConfig;
|
||||
isLoading?: boolean;
|
||||
keyboardNavigation?: boolean;
|
||||
manualFiltering?: boolean;
|
||||
manualSorting?: boolean;
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
|
||||
onColumnPinningChange?: (pinning: ColumnPinningState) => void;
|
||||
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void;
|
||||
onError?: (error: Error) => void;
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
overscan?: number;
|
||||
pagination?: PaginationConfig;
|
||||
persistenceKey?: string;
|
||||
rowHeight?: number;
|
||||
rowSelection?: RowSelectionState;
|
||||
|
||||
selection?: SelectionConfig
|
||||
setError: (error: Error | null) => void
|
||||
showToolbar?: boolean
|
||||
setScrollRef: (el: HTMLDivElement | null) => void
|
||||
search?: SearchConfig;
|
||||
selection?: SelectionConfig;
|
||||
setData: (data: any[]) => void;
|
||||
setDataCount: (count: number) => void;
|
||||
setError: (error: Error | null) => void;
|
||||
setInfiniteScroll: (config: InfiniteScrollConfig | undefined) => void;
|
||||
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
setScrollRef: (el: HTMLDivElement | null) => void;
|
||||
// ─── Internal ref setters ───
|
||||
setTable: (table: Table<any>) => void
|
||||
setTable: (table: Table<any>) => void;
|
||||
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void;
|
||||
|
||||
setVirtualizer: (virtualizer: Virtualizer<HTMLDivElement, Element>) => void
|
||||
sorting?: SortingState
|
||||
showToolbar?: boolean;
|
||||
sorting?: SortingState;
|
||||
// ─── Synced from GriddyProps (written by $sync) ───
|
||||
uniqueId?: string
|
||||
uniqueId?: string;
|
||||
}
|
||||
|
||||
// ─── Create Store ────────────────────────────────────────────────────────────
|
||||
@@ -69,52 +92,55 @@ export interface GriddyStoreState extends GriddyUIState {
|
||||
export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSyncStore<
|
||||
GriddyStoreState,
|
||||
GriddyProps<any>
|
||||
>(
|
||||
(set, get) => ({
|
||||
_scrollRef: null,
|
||||
// ─── Internal Refs ───
|
||||
_table: null,
|
||||
>((set, get) => ({
|
||||
_scrollRef: null,
|
||||
// ─── Internal Refs ───
|
||||
_table: null,
|
||||
|
||||
_virtualizer: null,
|
||||
error: null,
|
||||
focusedColumnId: null,
|
||||
// ─── Focus State ───
|
||||
focusedRowIndex: null,
|
||||
_virtualizer: null,
|
||||
appendData: (data) => set((state) => ({ data: [...(state.data ?? []), ...data] })),
|
||||
error: null,
|
||||
focusedColumnId: null,
|
||||
// ─── Focus State ───
|
||||
focusedRowIndex: null,
|
||||
|
||||
// ─── Mode State ───
|
||||
isEditing: false,
|
||||
// ─── Mode State ───
|
||||
isEditing: false,
|
||||
|
||||
isSearchOpen: false,
|
||||
isSelecting: false,
|
||||
moveFocus: (direction, amount) => {
|
||||
const { focusedRowIndex, totalRows } = get()
|
||||
const current = focusedRowIndex ?? 0
|
||||
const delta = direction === 'down' ? amount : -amount
|
||||
const next = Math.max(0, Math.min(current + delta, totalRows - 1))
|
||||
set({ focusedRowIndex: next })
|
||||
},
|
||||
isSearchOpen: false,
|
||||
isSelecting: false,
|
||||
moveFocus: (direction, amount) => {
|
||||
const { focusedRowIndex, totalRows } = get();
|
||||
const current = focusedRowIndex ?? 0;
|
||||
const delta = direction === 'down' ? amount : -amount;
|
||||
const next = Math.max(0, Math.min(current + delta, totalRows - 1));
|
||||
set({ focusedRowIndex: next });
|
||||
},
|
||||
|
||||
moveFocusToEnd: () => {
|
||||
const { totalRows } = get()
|
||||
set({ focusedRowIndex: Math.max(0, totalRows - 1) })
|
||||
},
|
||||
moveFocusToStart: () => set({ focusedRowIndex: 0 }),
|
||||
setEditing: (editing) => set({ isEditing: editing }),
|
||||
setError: (error) => set({ error }),
|
||||
setFocusedColumn: (id) => set({ focusedColumnId: id }),
|
||||
// ─── Actions ───
|
||||
setFocusedRow: (index) => set({ focusedRowIndex: index }),
|
||||
setScrollRef: (el) => set({ _scrollRef: el }),
|
||||
moveFocusToEnd: () => {
|
||||
const { totalRows } = get();
|
||||
set({ focusedRowIndex: Math.max(0, totalRows - 1) });
|
||||
},
|
||||
moveFocusToStart: () => set({ focusedRowIndex: 0 }),
|
||||
setData: (data) => set({ data }),
|
||||
setDataCount: (count) => set({ dataCount: count }),
|
||||
setEditing: (editing) => set({ isEditing: editing }),
|
||||
setError: (error) => set({ error }),
|
||||
setFocusedColumn: (id) => set({ focusedColumnId: id }),
|
||||
// ─── Actions ───
|
||||
setFocusedRow: (index) => set({ focusedRowIndex: index }),
|
||||
setInfiniteScroll: (config) => set({ infiniteScroll: config }),
|
||||
setIsLoading: (loading) => set({ isLoading: loading }),
|
||||
setScrollRef: (el) => set({ _scrollRef: el }),
|
||||
|
||||
setSearchOpen: (open) => set({ isSearchOpen: open }),
|
||||
setSearchOpen: (open) => set({ isSearchOpen: open }),
|
||||
|
||||
setSelecting: (selecting) => set({ isSelecting: selecting }),
|
||||
// ─── Internal Ref Setters ───
|
||||
setTable: (table) => set({ _table: table }),
|
||||
setSelecting: (selecting) => set({ isSelecting: selecting }),
|
||||
// ─── Internal Ref Setters ───
|
||||
setTable: (table) => set({ _table: table }),
|
||||
|
||||
setTotalRows: (count) => set({ totalRows: count }),
|
||||
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
|
||||
// ─── Row Count ───
|
||||
totalRows: 0,
|
||||
}),
|
||||
)
|
||||
setTotalRows: (count) => set({ totalRows: count }),
|
||||
setVirtualizer: (virtualizer) => set({ _virtualizer: virtualizer }),
|
||||
// ─── Row Count ───
|
||||
totalRows: 0,
|
||||
}));
|
||||
|
||||
@@ -1,22 +1,85 @@
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
|
||||
import type { GriddyColumn, SelectionConfig } from './types'
|
||||
import type { GriddyColumn, SelectionConfig } from './types';
|
||||
|
||||
import { createOperatorFilter } from '../features/filtering'
|
||||
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants'
|
||||
import { createOperatorFilter } from '../features/filtering';
|
||||
import { DEFAULTS, SELECTION_COLUMN_ID, SELECTION_COLUMN_SIZE } from './constants';
|
||||
|
||||
/**
|
||||
* Retrieves the original GriddyColumn from a TanStack column's meta.
|
||||
*/
|
||||
export function getGriddyColumn<T>(column: { columnDef: ColumnDef<T> }): GriddyColumn<T> | undefined {
|
||||
return (column.columnDef.meta as { griddy?: GriddyColumn<T> })?.griddy
|
||||
export function getGriddyColumn<T>(column: {
|
||||
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
|
||||
*/
|
||||
function mapSingleColumn<T>(col: GriddyColumn<T>): ColumnDef<T> {
|
||||
const isStringAccessor = typeof col.accessor !== 'function'
|
||||
const isStringAccessor = typeof col.accessor !== 'function';
|
||||
|
||||
const def: ColumnDef<T> = {
|
||||
id: col.id,
|
||||
@@ -37,92 +100,33 @@ function mapSingleColumn<T>(col: GriddyColumn<T>): ColumnDef<T> {
|
||||
meta: { griddy: col },
|
||||
minSize: col.minWidth ?? DEFAULTS.minColumnWidth,
|
||||
size: col.width,
|
||||
}
|
||||
};
|
||||
|
||||
// For function accessors, TanStack can't auto-detect the sort type, so provide a default
|
||||
if (col.sortFn) {
|
||||
def.sortingFn = col.sortFn
|
||||
def.sortingFn = col.sortFn;
|
||||
} else if (!isStringAccessor && col.sortable !== false) {
|
||||
// Use alphanumeric sorting for function accessors
|
||||
def.sortingFn = 'alphanumeric'
|
||||
def.sortingFn = 'alphanumeric';
|
||||
}
|
||||
|
||||
if (col.filterFn) {
|
||||
def.filterFn = col.filterFn
|
||||
def.filterFn = col.filterFn;
|
||||
} else if (col.filterable) {
|
||||
def.filterFn = createOperatorFilter()
|
||||
def.filterFn = createOperatorFilter();
|
||||
}
|
||||
|
||||
if (col.renderer) {
|
||||
const renderer = col.renderer
|
||||
def.cell = (info) => renderer({
|
||||
column: col,
|
||||
columnIndex: info.cell.column.getIndex(),
|
||||
row: info.row.original,
|
||||
rowIndex: info.row.index,
|
||||
value: info.getValue(),
|
||||
})
|
||||
const renderer = col.renderer;
|
||||
def.cell = (info) =>
|
||||
renderer({
|
||||
column: col,
|
||||
columnIndex: info.cell.column.getIndex(),
|
||||
row: info.row.original,
|
||||
rowIndex: info.row.index,
|
||||
value: info.getValue(),
|
||||
});
|
||||
}
|
||||
|
||||
return def
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps Griddy's user-facing GriddyColumn<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
|
||||
return def;
|
||||
}
|
||||
|
||||
@@ -1,289 +1,315 @@
|
||||
import type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ExpandedState, FilterFn, GroupingState, PaginationState, RowSelectionState, SortingFn, SortingState, Table, VisibilityState } from '@tanstack/react-table'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { ReactNode } from 'react'
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
ColumnOrderState,
|
||||
ColumnPinningState,
|
||||
ExpandedState,
|
||||
FilterFn,
|
||||
GroupingState,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
SortingFn,
|
||||
SortingState,
|
||||
Table,
|
||||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import type { Virtualizer } from '@tanstack/react-virtual';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { EditorConfig } from '../editors'
|
||||
import type { FilterConfig } from '../features/filtering'
|
||||
import type { EditorConfig } from '../editors';
|
||||
import type { FilterConfig } from '../features/filtering';
|
||||
|
||||
// ─── Column Definition ───────────────────────────────────────────────────────
|
||||
|
||||
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode
|
||||
export interface AdvancedSearchConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ─── Cell Rendering ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface DataAdapter<T> {
|
||||
delete?: (row: T) => Promise<void>
|
||||
fetch: (config: FetchConfig) => Promise<GriddyDataSource<T>>
|
||||
save?: (row: T) => Promise<void>
|
||||
}
|
||||
export type CellRenderer<T> = (props: RendererProps<T>) => ReactNode;
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EditorProps<T> {
|
||||
column: GriddyColumn<T>
|
||||
onCancel: () => void
|
||||
onCommit: (newValue: unknown) => void
|
||||
onMoveNext: () => void
|
||||
onMovePrev: () => void
|
||||
row: T
|
||||
rowIndex: number
|
||||
value: unknown
|
||||
}
|
||||
export type EditorComponent<T> = (props: EditorProps<T>) => ReactNode;
|
||||
|
||||
export interface FetchConfig {
|
||||
cursor?: string
|
||||
filters?: ColumnFiltersState
|
||||
globalFilter?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
sorting?: SortingState
|
||||
export interface EditorProps<T> {
|
||||
column: GriddyColumn<T>;
|
||||
onCancel: () => void;
|
||||
onCommit: (newValue: unknown) => void;
|
||||
onMoveNext: () => void;
|
||||
onMovePrev: () => void;
|
||||
row: T;
|
||||
rowIndex: number;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
// ─── Selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GriddyColumn<T> {
|
||||
accessor: ((row: T) => unknown) | keyof T
|
||||
aggregationFn?: 'sum' | 'min' | 'max' | 'mean' | 'median' | 'unique' | 'uniqueCount' | 'count'
|
||||
editable?: ((row: T) => boolean) | boolean
|
||||
editor?: EditorComponent<T>
|
||||
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
|
||||
export interface FetchConfig {
|
||||
cursor?: string;
|
||||
filters?: ColumnFiltersState;
|
||||
globalFilter?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sorting?: SortingState;
|
||||
}
|
||||
|
||||
// ─── Search ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GriddyDataSource<T> {
|
||||
data: T[]
|
||||
error?: Error
|
||||
isLoading?: boolean
|
||||
pageInfo?: { cursor?: string; hasNextPage: boolean; }
|
||||
total?: number
|
||||
export interface GriddyColumn<T> {
|
||||
accessor: ((row: T) => unknown) | keyof T;
|
||||
aggregationFn?: 'count' | 'max' | 'mean' | 'median' | 'min' | 'sum' | 'unique' | 'uniqueCount';
|
||||
editable?: ((row: T) => boolean) | boolean;
|
||||
editor?: EditorComponent<T>;
|
||||
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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface AdvancedSearchConfig {
|
||||
enabled: boolean
|
||||
export interface GriddyDataSource<T> {
|
||||
data: T[];
|
||||
error?: Error;
|
||||
isLoading?: boolean;
|
||||
pageInfo?: { cursor?: string; hasNextPage: boolean };
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export interface GriddyProps<T> {
|
||||
// ─── Advanced Search ───
|
||||
advancedSearch?: AdvancedSearchConfig
|
||||
advancedSearch?: AdvancedSearchConfig;
|
||||
// ─── Children (adapters, etc.) ───
|
||||
children?: ReactNode
|
||||
children?: ReactNode;
|
||||
// ─── Styling ───
|
||||
className?: string
|
||||
// ─── Toolbar ───
|
||||
/** Show toolbar with export and column visibility controls. Default: false */
|
||||
showToolbar?: boolean
|
||||
/** Export filename. Default: 'export.csv' */
|
||||
exportFilename?: string
|
||||
// ─── Filter Presets ───
|
||||
/** Enable filter presets save/load in toolbar. Default: false */
|
||||
filterPresets?: boolean
|
||||
className?: string;
|
||||
// ─── Filtering ───
|
||||
/** Controlled column filters state */
|
||||
columnFilters?: ColumnFiltersState
|
||||
/** Column definitions */
|
||||
columns: GriddyColumn<T>[]
|
||||
columnFilters?: ColumnFiltersState;
|
||||
/** Controlled column pinning state */
|
||||
columnPinning?: ColumnPinningState
|
||||
onColumnPinningChange?: (pinning: ColumnPinningState) => void
|
||||
|
||||
columnPinning?: ColumnPinningState;
|
||||
/** Column definitions */
|
||||
columns: GriddyColumn<T>[];
|
||||
/** Data array */
|
||||
data: T[]
|
||||
|
||||
data: T[];
|
||||
// ─── Data Adapter ───
|
||||
dataAdapter?: DataAdapter<T>
|
||||
dataAdapter?: DataAdapter<T>;
|
||||
/** Total row count (for server-side pagination/filtering). If provided, enables manual mode. */
|
||||
dataCount?: number
|
||||
/** Stable row identity function */
|
||||
getRowId?: (row: T, index: number) => string
|
||||
// ─── Grouping ───
|
||||
grouping?: GroupingConfig
|
||||
dataCount?: number;
|
||||
/** Export filename. Default: 'export.csv' */
|
||||
exportFilename?: string;
|
||||
|
||||
// ─── Filter Presets ───
|
||||
/** Enable filter presets save/load in toolbar. Default: false */
|
||||
filterPresets?: boolean;
|
||||
|
||||
/** Stable row identity function */
|
||||
getRowId?: (row: T, index: number) => string;
|
||||
// ─── Grouping ───
|
||||
grouping?: GroupingConfig;
|
||||
/** Container height */
|
||||
height?: number | string
|
||||
// ─── Loading ───
|
||||
/** Show loading skeleton/overlay. Default: false */
|
||||
isLoading?: boolean
|
||||
height?: number | string;
|
||||
// ─── Infinite Scroll ───
|
||||
/** Infinite scroll configuration */
|
||||
infiniteScroll?: InfiniteScrollConfig
|
||||
infiniteScroll?: InfiniteScrollConfig;
|
||||
|
||||
// ─── Loading ───
|
||||
/** Show loading skeleton/overlay. Default: false */
|
||||
isLoading?: boolean;
|
||||
// ─── Keyboard ───
|
||||
/** Enable keyboard navigation. Default: true */
|
||||
keyboardNavigation?: boolean
|
||||
keyboardNavigation?: boolean;
|
||||
/** Manual filtering mode - filtering handled externally (server-side). Default: false */
|
||||
manualFiltering?: boolean
|
||||
manualFiltering?: boolean;
|
||||
/** Manual sorting mode - sorting handled externally (server-side). Default: false */
|
||||
manualSorting?: boolean
|
||||
manualSorting?: boolean;
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
|
||||
onColumnPinningChange?: (pinning: ColumnPinningState) => void;
|
||||
|
||||
onColumnFiltersChange?: (filters: ColumnFiltersState) => void
|
||||
// ─── Editing ───
|
||||
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void
|
||||
onEditCommit?: (rowId: string, columnId: string, value: unknown) => Promise<void> | void;
|
||||
// ─── Error Handling ───
|
||||
/** Callback when a render error is caught by the error boundary */
|
||||
onError?: (error: Error) => void
|
||||
onError?: (error: Error) => void;
|
||||
/** Callback before the error boundary retries rendering */
|
||||
onRetry?: () => void
|
||||
|
||||
onRetry?: () => void;
|
||||
/** Selection change callback */
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void
|
||||
onRowSelectionChange?: (selection: RowSelectionState) => void;
|
||||
|
||||
onSortingChange?: (sorting: SortingState) => void
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
|
||||
/** Overscan row count. Default: 10 */
|
||||
overscan?: number
|
||||
overscan?: number;
|
||||
|
||||
// ─── Pagination ───
|
||||
pagination?: PaginationConfig
|
||||
pagination?: PaginationConfig;
|
||||
|
||||
// ─── Persistence ───
|
||||
/** localStorage key prefix for persisting column layout */
|
||||
persistenceKey?: string
|
||||
persistenceKey?: string;
|
||||
|
||||
// ─── Virtualization ───
|
||||
/** Row height in pixels. Default: 36 */
|
||||
rowHeight?: number
|
||||
rowHeight?: number;
|
||||
/** Controlled row selection state */
|
||||
rowSelection?: RowSelectionState
|
||||
|
||||
rowSelection?: RowSelectionState;
|
||||
// ─── Search ───
|
||||
search?: SearchConfig
|
||||
search?: SearchConfig;
|
||||
|
||||
// ─── Selection ───
|
||||
/** Selection configuration */
|
||||
selection?: SelectionConfig
|
||||
selection?: SelectionConfig;
|
||||
|
||||
// ─── Toolbar ───
|
||||
/** Show toolbar with export and column visibility controls. Default: false */
|
||||
showToolbar?: boolean;
|
||||
|
||||
// ─── Sorting ───
|
||||
/** Controlled sorting state */
|
||||
sorting?: SortingState
|
||||
sorting?: SortingState;
|
||||
|
||||
/** Unique identifier for persistence */
|
||||
uniqueId?: string
|
||||
uniqueId?: string;
|
||||
}
|
||||
|
||||
// ─── Data Adapter ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GriddyRef<T = unknown> {
|
||||
deselectAll: () => void
|
||||
focusRow: (index: number) => void
|
||||
getTable: () => Table<T>
|
||||
getUIState: () => GriddyUIState
|
||||
getVirtualizer: () => Virtualizer<HTMLDivElement, Element>
|
||||
scrollToRow: (index: number) => void
|
||||
selectRow: (id: string) => void
|
||||
startEditing: (rowId: string, columnId?: string) => void
|
||||
deselectAll: () => void;
|
||||
focusRow: (index: number) => void;
|
||||
getTable: () => Table<T>;
|
||||
getUIState: () => GriddyUIState;
|
||||
getVirtualizer: () => Virtualizer<HTMLDivElement, Element>;
|
||||
scrollToRow: (index: number) => void;
|
||||
selectRow: (id: string) => void;
|
||||
startEditing: (rowId: string, columnId?: string) => void;
|
||||
}
|
||||
|
||||
export interface GriddyUIState {
|
||||
focusedColumnId: null | string
|
||||
focusedColumnId: null | string;
|
||||
// Focus
|
||||
focusedRowIndex: null | number
|
||||
focusedRowIndex: null | number;
|
||||
|
||||
// Modes
|
||||
isEditing: boolean
|
||||
isSearchOpen: boolean
|
||||
isSelecting: boolean
|
||||
isEditing: boolean;
|
||||
isSearchOpen: boolean;
|
||||
isSelecting: boolean;
|
||||
|
||||
moveFocus: (direction: 'down' | 'up', amount: number) => void
|
||||
moveFocus: (direction: 'down' | 'up', amount: number) => void;
|
||||
|
||||
moveFocusToEnd: () => void
|
||||
moveFocusToStart: () => void
|
||||
setEditing: (editing: boolean) => void
|
||||
setFocusedColumn: (id: null | string) => void
|
||||
moveFocusToEnd: () => void;
|
||||
moveFocusToStart: () => void;
|
||||
setEditing: (editing: boolean) => void;
|
||||
setFocusedColumn: (id: null | string) => void;
|
||||
// Actions
|
||||
setFocusedRow: (index: null | number) => void
|
||||
setSearchOpen: (open: boolean) => void
|
||||
setSelecting: (selecting: boolean) => void
|
||||
setTotalRows: (count: number) => void
|
||||
setFocusedRow: (index: null | number) => void;
|
||||
setSearchOpen: (open: boolean) => void;
|
||||
setSelecting: (selecting: boolean) => void;
|
||||
setTotalRows: (count: number) => void;
|
||||
// Row count (synced from table)
|
||||
totalRows: number
|
||||
totalRows: number;
|
||||
}
|
||||
|
||||
export interface GroupingConfig {
|
||||
columns?: string[]
|
||||
enabled: boolean
|
||||
columns?: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// ─── Grouping ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface InfiniteScrollConfig {
|
||||
/** Enable infinite scroll */
|
||||
enabled: boolean
|
||||
/** Threshold in rows from the end to trigger loading. Default: 10 */
|
||||
threshold?: number
|
||||
/** Callback to load more data. Should update the data array. */
|
||||
onLoadMore?: () => Promise<void> | void
|
||||
/** Whether data is currently loading */
|
||||
isLoading?: boolean
|
||||
enabled: boolean;
|
||||
/** Whether there is more data to load */
|
||||
hasMore?: boolean
|
||||
hasMore?: boolean;
|
||||
/** Whether data is currently loading */
|
||||
isLoading?: boolean;
|
||||
/** Callback to load more data. Should update the data array. */
|
||||
onLoadMore?: () => Promise<void> | void;
|
||||
/** Threshold in rows from the end to trigger loading. Default: 10 */
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
enabled: boolean
|
||||
onPageChange?: (page: number) => void
|
||||
onPageSizeChange?: (pageSize: number) => void
|
||||
pageSize: number
|
||||
pageSizeOptions?: number[]
|
||||
type: 'cursor' | 'offset'
|
||||
enabled: boolean;
|
||||
onPageChange?: (page: number) => void;
|
||||
onPageSizeChange?: (pageSize: number) => void;
|
||||
pageSize: number;
|
||||
pageSizeOptions?: number[];
|
||||
type: 'cursor' | 'offset';
|
||||
}
|
||||
|
||||
// ─── Main Props ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface RendererProps<T> {
|
||||
column: GriddyColumn<T>
|
||||
columnIndex: number
|
||||
isEditing?: boolean
|
||||
row: T
|
||||
rowIndex: number
|
||||
searchQuery?: string
|
||||
value: unknown
|
||||
column: GriddyColumn<T>;
|
||||
columnIndex: number;
|
||||
isEditing?: boolean;
|
||||
row: T;
|
||||
rowIndex: number;
|
||||
searchQuery?: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
// ─── UI State (Zustand Store) ────────────────────────────────────────────────
|
||||
|
||||
export interface SearchConfig {
|
||||
caseSensitive?: boolean
|
||||
debounceMs?: number
|
||||
enabled: boolean
|
||||
fuzzy?: boolean
|
||||
highlightMatches?: boolean
|
||||
placeholder?: string
|
||||
caseSensitive?: boolean;
|
||||
debounceMs?: number;
|
||||
enabled: boolean;
|
||||
fuzzy?: boolean;
|
||||
highlightMatches?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// ─── Ref API ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SelectionConfig {
|
||||
/** 'none' = no selection, 'single' = one row at a time, 'multi' = multiple rows */
|
||||
mode: 'multi' | 'none' | 'single'
|
||||
mode: 'multi' | 'none' | 'single';
|
||||
/** Maintain selection across pagination/sorting. Default: true */
|
||||
preserveSelection?: boolean
|
||||
preserveSelection?: boolean;
|
||||
/** Allow clicking row body to toggle selection. Default: true */
|
||||
selectOnClick?: boolean
|
||||
selectOnClick?: boolean;
|
||||
/** Show checkbox column (auto-added as first column). Default: true when mode !== 'none' */
|
||||
showCheckbox?: boolean
|
||||
showCheckbox?: boolean;
|
||||
}
|
||||
|
||||
// ─── Re-exports for convenience ──────────────────────────────────────────────
|
||||
|
||||
export type { ColumnDef, ColumnFiltersState, ColumnOrderState, ColumnPinningState, ExpandedState, GroupingState, PaginationState, RowSelectionState, SortingState, Table, VisibilityState }
|
||||
export type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
ColumnOrderState,
|
||||
ColumnPinningState,
|
||||
ExpandedState,
|
||||
GroupingState,
|
||||
PaginationState,
|
||||
RowSelectionState,
|
||||
SortingState,
|
||||
Table,
|
||||
VisibilityState,
|
||||
};
|
||||
|
||||
@@ -1,49 +1,59 @@
|
||||
import { Select } from '@mantine/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Select } from '@mantine/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { BaseEditorProps, SelectOption } from './types'
|
||||
import type { BaseEditorProps, SelectOption } from './types';
|
||||
|
||||
interface SelectEditorProps extends BaseEditorProps<any> {
|
||||
options: SelectOption[]
|
||||
options: SelectOption[];
|
||||
}
|
||||
|
||||
export function SelectEditor({ autoFocus = true, onCancel, onCommit, onMoveNext, onMovePrev, options, value }: SelectEditorProps) {
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(value != null ? String(value) : null)
|
||||
export function SelectEditor({
|
||||
autoFocus = true,
|
||||
onCancel,
|
||||
onCommit,
|
||||
onMoveNext,
|
||||
onMovePrev,
|
||||
options,
|
||||
value,
|
||||
}: SelectEditorProps) {
|
||||
const [selectedValue, setSelectedValue] = useState<null | string>(
|
||||
value != null ? String(value) : null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedValue(value != null ? String(value) : null)
|
||||
}, [value])
|
||||
setSelectedValue(value != null ? String(value) : null);
|
||||
}, [value]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
// Find the actual value from options
|
||||
const option = options.find(opt => String(opt.value) === selectedValue)
|
||||
onCommit(option?.value ?? selectedValue)
|
||||
const option = options.find((opt) => String(opt.value) === selectedValue);
|
||||
onCommit(option?.value ?? selectedValue);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancel()
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
const option = options.find(opt => String(opt.value) === selectedValue)
|
||||
onCommit(option?.value ?? selectedValue)
|
||||
e.preventDefault();
|
||||
const option = options.find((opt) => String(opt.value) === selectedValue);
|
||||
onCommit(option?.value ?? selectedValue);
|
||||
if (e.shiftKey) {
|
||||
onMovePrev?.()
|
||||
onMovePrev?.();
|
||||
} else {
|
||||
onMoveNext?.()
|
||||
onMoveNext?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
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)}
|
||||
onKeyDown={handleKeyDown}
|
||||
searchable
|
||||
size="xs"
|
||||
value={selectedValue}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// ─── Editor Props ────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BaseEditorProps<T = any> {
|
||||
autoFocus?: boolean
|
||||
onCancel: () => void
|
||||
onCommit: (value: T) => void
|
||||
onMoveNext?: () => void
|
||||
onMovePrev?: () => void
|
||||
value: T
|
||||
autoFocus?: boolean;
|
||||
onCancel: () => void;
|
||||
onCommit: (value: T) => void;
|
||||
onMoveNext?: () => void;
|
||||
onMovePrev?: () => void;
|
||||
value: T;
|
||||
}
|
||||
|
||||
// ─── Validation ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ValidationRule<T = any> {
|
||||
message: string
|
||||
validate: (value: T) => boolean
|
||||
}
|
||||
export type EditorComponent<T = any> = (props: BaseEditorProps<T>) => ReactNode;
|
||||
|
||||
export interface ValidationResult {
|
||||
errors: string[]
|
||||
isValid: boolean
|
||||
export interface EditorConfig {
|
||||
max?: number;
|
||||
min?: number;
|
||||
options?: SelectOption[];
|
||||
placeholder?: string;
|
||||
step?: number;
|
||||
type?: EditorType;
|
||||
validation?: ValidationRule[];
|
||||
}
|
||||
|
||||
// ─── Editor Registry ─────────────────────────────────────────────────────────
|
||||
|
||||
export type EditorType = 'checkbox' | 'date' | 'number' | 'select' | 'text'
|
||||
export type EditorType = 'checkbox' | 'date' | 'number' | 'select' | 'text';
|
||||
|
||||
export interface SelectOption {
|
||||
label: string
|
||||
value: any
|
||||
label: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface EditorConfig {
|
||||
max?: number
|
||||
min?: number
|
||||
options?: SelectOption[]
|
||||
placeholder?: string
|
||||
step?: number
|
||||
type?: EditorType
|
||||
validation?: ValidationRule[]
|
||||
export interface ValidationResult {
|
||||
errors: string[];
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export type EditorComponent<T = any> = (props: BaseEditorProps<T>) => ReactNode
|
||||
export interface ValidationRule<T = any> {
|
||||
message: string;
|
||||
validate: (value: T) => boolean;
|
||||
}
|
||||
|
||||
@@ -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 { IconPlus } from '@tabler/icons-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { Button, Group, SegmentedControl, Stack, Text } from '@mantine/core';
|
||||
import { IconPlus } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { useGriddyStore } from '../../core/GriddyStore'
|
||||
import styles from '../../styles/griddy.module.css'
|
||||
import { advancedFilter } from './advancedFilterFn'
|
||||
import { SearchConditionRow } from './SearchConditionRow'
|
||||
import type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types'
|
||||
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 {
|
||||
return { columnId: '', id: String(nextId++), operator: 'contains', value: '' }
|
||||
}
|
||||
let nextId = 1;
|
||||
|
||||
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) {
|
||||
const userColumns = useGriddyStore((s) => s.columns) ?? []
|
||||
const userColumns = useGriddyStore((s) => s.columns) ?? [];
|
||||
const [searchState, setSearchState] = useState<AdvancedSearchState>({
|
||||
booleanOperator: 'AND',
|
||||
conditions: [createCondition()],
|
||||
})
|
||||
});
|
||||
|
||||
const columnOptions = useMemo(
|
||||
() => userColumns
|
||||
.filter((c) => c.searchable !== false)
|
||||
.map((c) => ({ label: String(c.header), value: c.id })),
|
||||
[userColumns],
|
||||
)
|
||||
() =>
|
||||
userColumns
|
||||
.filter((c) => c.searchable !== false)
|
||||
.map((c) => ({ label: String(c.header), value: c.id })),
|
||||
[userColumns]
|
||||
);
|
||||
|
||||
const handleConditionChange = useCallback((index: number, condition: SearchCondition) => {
|
||||
setSearchState((prev) => {
|
||||
const conditions = [...prev.conditions]
|
||||
conditions[index] = condition
|
||||
return { ...prev, conditions }
|
||||
})
|
||||
}, [])
|
||||
const conditions = [...prev.conditions];
|
||||
conditions[index] = condition;
|
||||
return { ...prev, conditions };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRemove = useCallback((index: number) => {
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
conditions: prev.conditions.filter((_, i) => i !== index),
|
||||
}))
|
||||
}, [])
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setSearchState((prev) => ({
|
||||
...prev,
|
||||
conditions: [...prev.conditions, createCondition()],
|
||||
}))
|
||||
}, [])
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const activeConditions = searchState.conditions.filter((c) => c.columnId && c.value)
|
||||
const activeConditions = searchState.conditions.filter((c) => c.columnId && c.value);
|
||||
if (activeConditions.length === 0) {
|
||||
table.setGlobalFilter(undefined)
|
||||
return
|
||||
table.setGlobalFilter(undefined);
|
||||
return;
|
||||
}
|
||||
// Use globalFilter with a custom function key
|
||||
table.setGlobalFilter({ _advancedSearch: searchState })
|
||||
}, [searchState, table])
|
||||
table.setGlobalFilter({ _advancedSearch: searchState });
|
||||
}, [searchState, table]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setSearchState({ booleanOperator: 'AND', conditions: [createCondition()] })
|
||||
table.setGlobalFilter(undefined)
|
||||
}, [table])
|
||||
setSearchState({ booleanOperator: 'AND', conditions: [createCondition()] });
|
||||
table.setGlobalFilter(undefined);
|
||||
}, [table]);
|
||||
|
||||
return (
|
||||
<div className={styles['griddy-advanced-search']}>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600} size="sm">Advanced Search</Text>
|
||||
<Text fw={600} size="sm">
|
||||
Advanced Search
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
data={['AND', 'OR', 'NOT']}
|
||||
onChange={(val) => setSearchState((prev) => ({ ...prev, booleanOperator: val as BooleanOperator }))}
|
||||
onChange={(val) =>
|
||||
setSearchState((prev) => ({ ...prev, booleanOperator: val as BooleanOperator }))
|
||||
}
|
||||
size="xs"
|
||||
value={searchState.booleanOperator}
|
||||
/>
|
||||
@@ -114,21 +138,9 @@ export function AdvancedSearchPanel({ table }: AdvancedSearchPanelProps) {
|
||||
</Group>
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
function createCondition(): SearchCondition {
|
||||
return { columnId: '', id: String(nextId++), operator: 'contains', value: '' };
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
import type { Row } from '@tanstack/react-table'
|
||||
import type { Row } from '@tanstack/react-table';
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
import type { AdvancedSearchState, SearchCondition } from './types';
|
||||
|
||||
export function advancedFilter<T>(row: Row<T>, searchState: AdvancedSearchState): boolean {
|
||||
const { booleanOperator, conditions } = searchState
|
||||
const active = conditions.filter((c) => c.columnId && c.value)
|
||||
const { booleanOperator, conditions } = searchState;
|
||||
const active = conditions.filter((c) => c.columnId && c.value);
|
||||
|
||||
if (active.length === 0) return true
|
||||
if (active.length === 0) return true;
|
||||
|
||||
switch (booleanOperator) {
|
||||
case 'AND':
|
||||
return active.every((c) => matchCondition(row, c))
|
||||
case 'OR':
|
||||
return active.some((c) => matchCondition(row, c))
|
||||
return active.every((c) => matchCondition(row, c));
|
||||
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:
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { AdvancedSearchPanel, advancedSearchGlobalFilterFn } from './AdvancedSearchPanel'
|
||||
export type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types'
|
||||
export { advancedSearchGlobalFilterFn, AdvancedSearchPanel } from './AdvancedSearchPanel';
|
||||
export type { AdvancedSearchState, BooleanOperator, SearchCondition } from './types';
|
||||
|
||||
@@ -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 {
|
||||
booleanOperator: BooleanOperator
|
||||
conditions: SearchCondition[]
|
||||
booleanOperator: BooleanOperator;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return `griddy-filter-presets-${persistenceKey}`
|
||||
return `griddy-filter-presets-${persistenceKey}`;
|
||||
}
|
||||
|
||||
function loadPresets(persistenceKey: string): FilterPreset[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey(persistenceKey))
|
||||
return raw ? JSON.parse(raw) : []
|
||||
const raw = localStorage.getItem(getStorageKey(persistenceKey));
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function savePresets(persistenceKey: string, presets: FilterPreset[]) {
|
||||
localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(presets))
|
||||
}
|
||||
|
||||
export function useFilterPresets(persistenceKey?: string) {
|
||||
const key = persistenceKey ?? 'default'
|
||||
const [presets, setPresets] = useState<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 }
|
||||
localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(presets));
|
||||
}
|
||||
|
||||
@@ -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 { IconFilter } from '@tabler/icons-react'
|
||||
import type React from 'react'
|
||||
import { forwardRef } from 'react'
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { IconFilter } from '@tabler/icons-react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { CSS } from '../../core/constants'
|
||||
import styles from '../../styles/griddy.module.css'
|
||||
import { CSS } from '../../core/constants';
|
||||
import styles from '../../styles/griddy.module.css';
|
||||
|
||||
interface ColumnFilterButtonProps {
|
||||
column: Column<any, any>
|
||||
onClick?: (e: React.MouseEvent) => void
|
||||
column: Column<any, any>;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const ColumnFilterButton = forwardRef<HTMLButtonElement, ColumnFilterButtonProps>(
|
||||
function ColumnFilterButton({ column, onClick, ...rest }, ref) {
|
||||
const isActive = !!column.getFilterValue()
|
||||
const isActive = !!column.getFilterValue();
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
{...rest}
|
||||
aria-label="Open column filter"
|
||||
className={[
|
||||
styles[CSS.filterButton],
|
||||
isActive ? styles[CSS.filterButtonActive] : '',
|
||||
]
|
||||
className={[styles[CSS.filterButton], isActive ? styles[CSS.filterButtonActive] : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
color={isActive ? 'blue' : 'gray'}
|
||||
@@ -35,6 +32,6 @@ export const ColumnFilterButton = forwardRef<HTMLButtonElement, ColumnFilterButt
|
||||
>
|
||||
<IconFilter size={14} />
|
||||
</ActionIcon>
|
||||
)
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 type React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { Button, Group, Popover, Stack, Text } from '@mantine/core';
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { FilterConfig, FilterValue } from './types'
|
||||
import type { FilterConfig, FilterValue } from './types';
|
||||
|
||||
import { getGriddyColumn } from '../../core/columnMapper'
|
||||
import { QuickFilterDropdown } from '../quickFilter'
|
||||
import { ColumnFilterButton } from './ColumnFilterButton'
|
||||
import { FilterBoolean } from './FilterBoolean'
|
||||
import { FilterDate } from './FilterDate'
|
||||
import { FilterInput } from './FilterInput'
|
||||
import { FilterSelect } from './FilterSelect'
|
||||
import { OPERATORS_BY_TYPE } from './operators'
|
||||
import { getGriddyColumn } from '../../core/columnMapper';
|
||||
import { QuickFilterDropdown } from '../quickFilter';
|
||||
import { ColumnFilterButton } from './ColumnFilterButton';
|
||||
import { FilterBoolean } from './FilterBoolean';
|
||||
import { FilterDate } from './FilterDate';
|
||||
import { FilterInput } from './FilterInput';
|
||||
import { FilterSelect } from './FilterSelect';
|
||||
import { OPERATORS_BY_TYPE } from './operators';
|
||||
|
||||
interface ColumnFilterPopoverProps {
|
||||
column: Column<any, any>
|
||||
onOpenedChange?: (opened: boolean) => void
|
||||
opened?: boolean
|
||||
column: Column<any, any>;
|
||||
onOpenedChange?: (opened: boolean) => void;
|
||||
opened?: boolean;
|
||||
}
|
||||
|
||||
export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOpened }: ColumnFilterPopoverProps) {
|
||||
const [internalOpened, setInternalOpened] = useState(false)
|
||||
export function ColumnFilterPopover({
|
||||
column,
|
||||
onOpenedChange,
|
||||
opened: externalOpened,
|
||||
}: ColumnFilterPopoverProps) {
|
||||
const [internalOpened, setInternalOpened] = useState(false);
|
||||
|
||||
// Support both internal and external control
|
||||
const opened = externalOpened !== undefined ? externalOpened : internalOpened
|
||||
const opened = externalOpened !== undefined ? externalOpened : internalOpened;
|
||||
const setOpened = (value: boolean) => {
|
||||
if (externalOpened !== undefined) {
|
||||
onOpenedChange?.(value)
|
||||
onOpenedChange?.(value);
|
||||
} else {
|
||||
setInternalOpened(value)
|
||||
setInternalOpened(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
const [localValue, setLocalValue] = useState<FilterValue | undefined>(
|
||||
(column.getFilterValue() as FilterValue) || undefined,
|
||||
)
|
||||
(column.getFilterValue() as FilterValue) || undefined
|
||||
);
|
||||
|
||||
const griddyColumn = getGriddyColumn(column)
|
||||
const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig
|
||||
const griddyColumn = getGriddyColumn(column);
|
||||
const filterConfig: FilterConfig | undefined = (griddyColumn as any)?.filterConfig;
|
||||
|
||||
if (!filterConfig) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleApply = () => {
|
||||
column.setFilterValue(localValue)
|
||||
setOpened(false)
|
||||
}
|
||||
column.setFilterValue(localValue);
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setLocalValue(undefined)
|
||||
column.setFilterValue(undefined)
|
||||
setOpened(false)
|
||||
}
|
||||
setLocalValue(undefined);
|
||||
column.setFilterValue(undefined);
|
||||
setOpened(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpened(false)
|
||||
setOpened(false);
|
||||
// Reset to previous value if popover is closed without applying
|
||||
setLocalValue((column.getFilterValue() as FilterValue) || undefined)
|
||||
}
|
||||
setLocalValue((column.getFilterValue() as FilterValue) || undefined);
|
||||
};
|
||||
|
||||
const operators =
|
||||
filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type]
|
||||
const operators = filterConfig.operators || OPERATORS_BY_TYPE[filterConfig.type];
|
||||
|
||||
const handleToggle = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setOpened(!opened)
|
||||
}
|
||||
e.stopPropagation();
|
||||
setOpened(!opened);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover onClose={handleClose} opened={opened} position="bottom-start" withinPortal>
|
||||
@@ -112,20 +115,16 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
|
||||
)}
|
||||
|
||||
{filterConfig.type === 'date' && (
|
||||
<FilterDate
|
||||
onChange={setLocalValue}
|
||||
operators={operators}
|
||||
value={localValue}
|
||||
/>
|
||||
<FilterDate onChange={setLocalValue} operators={operators} value={localValue} />
|
||||
)}
|
||||
|
||||
{filterConfig.quickFilter && (
|
||||
<QuickFilterDropdown
|
||||
column={column}
|
||||
onApply={(val) => {
|
||||
setLocalValue(val)
|
||||
column.setFilterValue(val)
|
||||
setOpened(false)
|
||||
setLocalValue(val);
|
||||
column.setFilterValue(val);
|
||||
setOpened(false);
|
||||
}}
|
||||
value={localValue}
|
||||
/>
|
||||
@@ -142,5 +141,5 @@ export function ColumnFilterPopover({ column, onOpenedChange, opened: externalOp
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import type { GriddyColumn } from '../../core/types'
|
||||
import type { GriddyColumn } from '../../core/types';
|
||||
|
||||
import { DEFAULTS } from '../../core/constants'
|
||||
import { useGriddyStore } from '../../core/GriddyStore'
|
||||
import styles from '../../styles/griddy.module.css'
|
||||
import { DEFAULTS } from '../../core/constants';
|
||||
import { useGriddyStore } from '../../core/GriddyStore';
|
||||
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() {
|
||||
const columns = useGriddyStore((s) => s.columns)
|
||||
const rowHeight = useGriddyStore((s) => s.rowHeight) ?? DEFAULTS.rowHeight
|
||||
const skeletonRowCount = 8
|
||||
const columns = useGriddyStore((s) => s.columns);
|
||||
const rowHeight = useGriddyStore((s) => s.rowHeight) ?? DEFAULTS.rowHeight;
|
||||
const skeletonRowCount = 8;
|
||||
|
||||
return (
|
||||
<div className={styles['griddy-skeleton']}>
|
||||
{Array.from({ length: skeletonRowCount }, (_, rowIndex) => (
|
||||
<div
|
||||
className={styles['griddy-skeleton-row']}
|
||||
key={rowIndex}
|
||||
style={{ height: rowHeight }}
|
||||
>
|
||||
<div className={styles['griddy-skeleton-row']} key={rowIndex} style={{ height: rowHeight }}>
|
||||
{(columns ?? []).map((col: GriddyColumn<any>) => (
|
||||
<div
|
||||
className={styles['griddy-skeleton-cell']}
|
||||
@@ -29,13 +33,5 @@ export function GriddyLoadingSkeleton() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GriddyLoadingOverlay() {
|
||||
return (
|
||||
<div className={styles['griddy-loading-overlay']}>
|
||||
<div className={styles['griddy-loading-spinner']}>Loading...</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return `griddy-search-history-${persistenceKey}`
|
||||
return `griddy-search-history-${persistenceKey}`;
|
||||
}
|
||||
|
||||
function loadHistory(persistenceKey: string): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey(persistenceKey))
|
||||
return raw ? JSON.parse(raw) : []
|
||||
const raw = localStorage.getItem(getStorageKey(persistenceKey));
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(persistenceKey: string, history: string[]) {
|
||||
localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(history))
|
||||
}
|
||||
|
||||
export function useSearchHistory(persistenceKey?: string) {
|
||||
const key = persistenceKey ?? 'default'
|
||||
const [history, setHistory] = useState<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 }
|
||||
localStorage.setItem(getStorageKey(persistenceKey), JSON.stringify(history));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
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'
|
||||
// Adapter exports
|
||||
export {
|
||||
applyCursor,
|
||||
buildOptions,
|
||||
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 {
|
||||
AdvancedSearchConfig,
|
||||
CellRenderer,
|
||||
@@ -20,12 +32,17 @@ export type {
|
||||
RendererProps,
|
||||
SearchConfig,
|
||||
SelectionConfig,
|
||||
} from './core/types'
|
||||
|
||||
} from './core/types';
|
||||
// Feature exports
|
||||
export { GriddyErrorBoundary } from './features/errorBoundary'
|
||||
export { GriddyLoadingOverlay, GriddyLoadingSkeleton } from './features/loading'
|
||||
export { BadgeRenderer, ImageRenderer, ProgressBarRenderer, SparklineRenderer } from './features/renderers'
|
||||
export { FilterPresetsMenu, useFilterPresets } from './features/filterPresets'
|
||||
export type { FilterPreset } from './features/filterPresets'
|
||||
export { useSearchHistory } from './features/searchHistory'
|
||||
export { GriddyErrorBoundary } from './features/errorBoundary';
|
||||
export { FilterPresetsMenu, useFilterPresets } from './features/filterPresets';
|
||||
export type { FilterPreset } from './features/filterPresets';
|
||||
export { GriddyLoadingOverlay, GriddyLoadingSkeleton } from './features/loading';
|
||||
|
||||
export {
|
||||
BadgeRenderer,
|
||||
ImageRenderer,
|
||||
ProgressBarRenderer,
|
||||
SparklineRenderer,
|
||||
} from './features/renderers';
|
||||
export { useSearchHistory } from './features/searchHistory';
|
||||
|
||||
@@ -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 { getGriddyColumn } from '../core/columnMapper'
|
||||
import type { EditorConfig } from '../editors';
|
||||
|
||||
import { getGriddyColumn } from '../core/columnMapper';
|
||||
import { CheckboxEditor, DateEditor, NumericEditor, SelectEditor, TextEditor } from '../editors';
|
||||
|
||||
interface EditableCellProps<T> {
|
||||
cell: Cell<T, unknown>
|
||||
isEditing: boolean
|
||||
onCancelEdit: () => void
|
||||
onCommitEdit: (value: unknown) => void
|
||||
onMoveNext?: () => void
|
||||
onMovePrev?: () => void
|
||||
cell: Cell<T, unknown>;
|
||||
isEditing: boolean;
|
||||
onCancelEdit: () => void;
|
||||
onCommitEdit: (value: unknown) => void;
|
||||
onMoveNext?: () => void;
|
||||
onMovePrev?: () => void;
|
||||
}
|
||||
|
||||
export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, onMoveNext, onMovePrev }: EditableCellProps<T>) {
|
||||
const griddyColumn = getGriddyColumn(cell.column)
|
||||
const editorConfig: EditorConfig = (griddyColumn as any)?.editorConfig ?? {}
|
||||
const customEditor = (griddyColumn as any)?.editor
|
||||
export function EditableCell<T>({
|
||||
cell,
|
||||
isEditing,
|
||||
onCancelEdit,
|
||||
onCommitEdit,
|
||||
onMoveNext,
|
||||
onMovePrev,
|
||||
}: EditableCellProps<T>) {
|
||||
const griddyColumn = getGriddyColumn(cell.column);
|
||||
const editorConfig: EditorConfig = (griddyColumn as any)?.editorConfig ?? {};
|
||||
const customEditor = (griddyColumn as any)?.editor;
|
||||
|
||||
const [value, setValue] = useState(cell.getValue())
|
||||
const [value, setValue] = useState(cell.getValue());
|
||||
|
||||
useEffect(() => {
|
||||
setValue(cell.getValue())
|
||||
}, [cell])
|
||||
setValue(cell.getValue());
|
||||
}, [cell]);
|
||||
|
||||
const handleCommit = useCallback((newValue: unknown) => {
|
||||
setValue(newValue)
|
||||
onCommitEdit(newValue)
|
||||
}, [onCommitEdit])
|
||||
const handleCommit = useCallback(
|
||||
(newValue: unknown) => {
|
||||
setValue(newValue);
|
||||
onCommitEdit(newValue);
|
||||
},
|
||||
[onCommitEdit]
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setValue(cell.getValue())
|
||||
onCancelEdit()
|
||||
}, [cell, onCancelEdit])
|
||||
setValue(cell.getValue());
|
||||
onCancelEdit();
|
||||
}, [cell, onCancelEdit]);
|
||||
|
||||
if (!isEditing) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Custom editor from column definition
|
||||
if (customEditor) {
|
||||
const EditorComponent = customEditor as any
|
||||
const EditorComponent = customEditor as any;
|
||||
return (
|
||||
<EditorComponent
|
||||
onCancel={handleCancel}
|
||||
@@ -51,13 +62,35 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
|
||||
onMovePrev={onMovePrev}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Built-in editors based on editorConfig.type
|
||||
const editorType = editorConfig.type ?? 'text'
|
||||
const editorType = editorConfig.type ?? 'text';
|
||||
|
||||
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':
|
||||
return (
|
||||
<NumericEditor
|
||||
@@ -70,18 +103,7 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
|
||||
step={editorConfig.step}
|
||||
value={value as number}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<DateEditor
|
||||
onCancel={handleCancel}
|
||||
onCommit={handleCommit}
|
||||
onMoveNext={onMoveNext}
|
||||
onMovePrev={onMovePrev}
|
||||
value={value as Date | string}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
@@ -93,18 +115,7 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
|
||||
options={editorConfig.options ?? []}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<CheckboxEditor
|
||||
onCancel={handleCancel}
|
||||
onCommit={handleCommit}
|
||||
onMoveNext={onMoveNext}
|
||||
onMovePrev={onMovePrev}
|
||||
value={value as boolean}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
@@ -116,6 +127,6 @@ export function EditableCell<T>({ cell, isEditing, onCancelEdit, onCommitEdit, o
|
||||
onMovePrev={onMovePrev}
|
||||
value={value as string}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,63 @@
|
||||
import { Checkbox } from '@mantine/core'
|
||||
import { type Cell, flexRender } from '@tanstack/react-table'
|
||||
import { Checkbox } from '@mantine/core';
|
||||
import { type Cell, flexRender } from '@tanstack/react-table';
|
||||
|
||||
import { getGriddyColumn } from '../core/columnMapper'
|
||||
import { CSS, SELECTION_COLUMN_ID } from '../core/constants'
|
||||
import { useGriddyStore } from '../core/GriddyStore'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
import { EditableCell } from './EditableCell'
|
||||
import { getGriddyColumn } from '../core/columnMapper';
|
||||
import { CSS, SELECTION_COLUMN_ID } from '../core/constants';
|
||||
import { useGriddyStore } from '../core/GriddyStore';
|
||||
import styles from '../styles/griddy.module.css';
|
||||
import { EditableCell } from './EditableCell';
|
||||
|
||||
interface TableCellProps<T> {
|
||||
cell: Cell<T, unknown>
|
||||
showGrouping?: boolean
|
||||
cell: Cell<T, unknown>;
|
||||
showGrouping?: boolean;
|
||||
}
|
||||
|
||||
export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
|
||||
const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID
|
||||
const isEditing = useGriddyStore((s) => s.isEditing)
|
||||
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex)
|
||||
const focusedColumnId = useGriddyStore((s) => s.focusedColumnId)
|
||||
const setEditing = useGriddyStore((s) => s.setEditing)
|
||||
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn)
|
||||
const onEditCommit = useGriddyStore((s) => s.onEditCommit)
|
||||
const isSelectionCol = cell.column.id === SELECTION_COLUMN_ID;
|
||||
const isEditing = useGriddyStore((s) => s.isEditing);
|
||||
const focusedRowIndex = useGriddyStore((s) => s.focusedRowIndex);
|
||||
const focusedColumnId = useGriddyStore((s) => s.focusedColumnId);
|
||||
const setEditing = useGriddyStore((s) => s.setEditing);
|
||||
const setFocusedColumn = useGriddyStore((s) => s.setFocusedColumn);
|
||||
const onEditCommit = useGriddyStore((s) => s.onEditCommit);
|
||||
|
||||
if (isSelectionCol) {
|
||||
return <RowCheckbox cell={cell} />
|
||||
return <RowCheckbox cell={cell} />;
|
||||
}
|
||||
|
||||
const griddyColumn = getGriddyColumn(cell.column)
|
||||
const rowIndex = cell.row.index
|
||||
const columnId = cell.column.id
|
||||
const isEditable = (griddyColumn as any)?.editable ?? false
|
||||
const isFocusedCell = isEditing && focusedRowIndex === rowIndex && focusedColumnId === columnId
|
||||
const griddyColumn = getGriddyColumn(cell.column);
|
||||
const rowIndex = cell.row.index;
|
||||
const columnId = cell.column.id;
|
||||
const isEditable = (griddyColumn as any)?.editable ?? false;
|
||||
const isFocusedCell = isEditing && focusedRowIndex === rowIndex && focusedColumnId === columnId;
|
||||
|
||||
const handleCommit = async (value: unknown) => {
|
||||
if (onEditCommit) {
|
||||
await onEditCommit(cell.row.id, columnId, value)
|
||||
await onEditCommit(cell.row.id, columnId, value);
|
||||
}
|
||||
setEditing(false)
|
||||
setFocusedColumn(null)
|
||||
}
|
||||
setEditing(false);
|
||||
setFocusedColumn(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditing(false)
|
||||
setFocusedColumn(null)
|
||||
}
|
||||
setEditing(false);
|
||||
setFocusedColumn(null);
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
if (isEditable) {
|
||||
setEditing(true)
|
||||
setFocusedColumn(columnId)
|
||||
setEditing(true);
|
||||
setFocusedColumn(columnId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isPinned = cell.column.getIsPinned()
|
||||
const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined
|
||||
const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined;
|
||||
const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined;
|
||||
|
||||
const isGrouped = cell.getIsGrouped()
|
||||
const isAggregated = cell.getIsAggregated()
|
||||
const isPlaceholder = cell.getIsPlaceholder()
|
||||
const isGrouped = cell.getIsGrouped();
|
||||
const isAggregated = cell.getIsAggregated();
|
||||
const isPlaceholder = cell.getIsPlaceholder();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -65,7 +65,9 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
|
||||
styles[CSS.cell],
|
||||
isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '',
|
||||
isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
role="gridcell"
|
||||
style={{
|
||||
@@ -80,8 +82,8 @@ export function TableCell<T>({ cell, showGrouping }: TableCellProps<T>) {
|
||||
<button
|
||||
onClick={() => cell.row.toggleExpanded()}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
marginRight: 4,
|
||||
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})
|
||||
</>
|
||||
) : 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 : (
|
||||
flexRender(cell.column.columnDef.cell, cell.getContext())
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function RowCheckbox<T>({ cell }: TableCellProps<T>) {
|
||||
const row = cell.row
|
||||
const isPinned = cell.column.getIsPinned()
|
||||
const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined
|
||||
const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined
|
||||
const row = cell.row;
|
||||
const isPinned = cell.column.getIsPinned();
|
||||
const leftOffset = isPinned === 'left' ? cell.column.getStart('left') : undefined;
|
||||
const rightOffset = isPinned === 'right' ? cell.column.getAfter('right') : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -122,7 +127,9 @@ function RowCheckbox<T>({ cell }: TableCellProps<T>) {
|
||||
styles[CSS.cell],
|
||||
isPinned === 'left' ? styles['griddy-cell--pinned-left'] : '',
|
||||
isPinned === 'right' ? styles['griddy-cell--pinned-right'] : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="gridcell"
|
||||
style={{
|
||||
left: leftOffset !== undefined ? `${leftOffset}px` : undefined,
|
||||
@@ -141,5 +148,5 @@ function RowCheckbox<T>({ cell }: TableCellProps<T>) {
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +1,85 @@
|
||||
import { Checkbox } from '@mantine/core'
|
||||
import { flexRender } from '@tanstack/react-table'
|
||||
import { useState } from 'react'
|
||||
import { Checkbox } from '@mantine/core';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { CSS, SELECTION_COLUMN_ID } from '../core/constants'
|
||||
import { useGriddyStore } from '../core/GriddyStore'
|
||||
import { ColumnFilterPopover, HeaderContextMenu } from '../features/filtering'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
import { CSS, SELECTION_COLUMN_ID } from '../core/constants';
|
||||
import { useGriddyStore } from '../core/GriddyStore';
|
||||
import { ColumnFilterPopover, HeaderContextMenu } from '../features/filtering';
|
||||
import styles from '../styles/griddy.module.css';
|
||||
|
||||
export function TableHeader() {
|
||||
const table = useGriddyStore((s) => s._table)
|
||||
const [filterPopoverOpen, setFilterPopoverOpen] = useState<null | string>(null)
|
||||
const [draggedColumn, setDraggedColumn] = useState<string | null>(null)
|
||||
const table = useGriddyStore((s) => s._table);
|
||||
const [filterPopoverOpen, setFilterPopoverOpen] = useState<null | string>(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) => {
|
||||
setDraggedColumn(columnId)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', columnId)
|
||||
}
|
||||
setDraggedColumn(columnId);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', columnId);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetColumnId: string) => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
if (!draggedColumn || draggedColumn === targetColumnId) {
|
||||
setDraggedColumn(null)
|
||||
return
|
||||
setDraggedColumn(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const columnOrder = table.getState().columnOrder
|
||||
const currentOrder = columnOrder.length ? columnOrder : table.getAllLeafColumns().map(c => c.id)
|
||||
const columnOrder = table.getState().columnOrder;
|
||||
const currentOrder = columnOrder.length
|
||||
? columnOrder
|
||||
: table.getAllLeafColumns().map((c) => c.id);
|
||||
|
||||
const draggedIdx = currentOrder.indexOf(draggedColumn)
|
||||
const targetIdx = currentOrder.indexOf(targetColumnId)
|
||||
const draggedIdx = currentOrder.indexOf(draggedColumn);
|
||||
const targetIdx = currentOrder.indexOf(targetColumnId);
|
||||
|
||||
if (draggedIdx === -1 || targetIdx === -1) {
|
||||
setDraggedColumn(null)
|
||||
return
|
||||
setDraggedColumn(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const newOrder = [...currentOrder]
|
||||
newOrder.splice(draggedIdx, 1)
|
||||
newOrder.splice(targetIdx, 0, draggedColumn)
|
||||
const newOrder = [...currentOrder];
|
||||
newOrder.splice(draggedIdx, 1);
|
||||
newOrder.splice(targetIdx, 0, draggedColumn);
|
||||
|
||||
table.setColumnOrder(newOrder)
|
||||
setDraggedColumn(null)
|
||||
}
|
||||
table.setColumnOrder(newOrder);
|
||||
setDraggedColumn(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedColumn(null)
|
||||
}
|
||||
setDraggedColumn(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles[CSS.thead]} role="rowgroup">
|
||||
{headerGroups.map((headerGroup) => (
|
||||
<div className={styles[CSS.headerRow]} key={headerGroup.id} role="row">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const isSortable = header.column.getCanSort()
|
||||
const sortDir = header.column.getIsSorted()
|
||||
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID
|
||||
const isFilterPopoverOpen = filterPopoverOpen === header.column.id
|
||||
const isPinned = header.column.getIsPinned()
|
||||
const leftOffset = isPinned === 'left' ? header.getStart('left') : undefined
|
||||
const rightOffset = isPinned === 'right' ? header.column.getAfter('right') : undefined
|
||||
const isSortable = header.column.getCanSort();
|
||||
const sortDir = header.column.getIsSorted();
|
||||
const isSelectionCol = header.column.id === SELECTION_COLUMN_ID;
|
||||
const isFilterPopoverOpen = filterPopoverOpen === header.column.id;
|
||||
const isPinned = header.column.getIsPinned();
|
||||
const leftOffset = isPinned === 'left' ? header.getStart('left') : undefined;
|
||||
const rightOffset = isPinned === 'right' ? header.column.getAfter('right') : undefined;
|
||||
|
||||
const isDragging = draggedColumn === header.column.id
|
||||
const canReorder = !isSelectionCol && !isPinned
|
||||
const isDragging = draggedColumn === header.column.id;
|
||||
const canReorder = !isSelectionCol && !isPinned;
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'}
|
||||
aria-sort={
|
||||
sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'
|
||||
}
|
||||
className={[
|
||||
styles[CSS.headerCell],
|
||||
isSortable ? styles[CSS.headerCellSortable] : '',
|
||||
@@ -83,7 +87,9 @@ export function TableHeader() {
|
||||
isPinned === 'left' ? styles['griddy-header-cell--pinned-left'] : '',
|
||||
isPinned === 'right' ? styles['griddy-header-cell--pinned-right'] : '',
|
||||
isDragging ? styles['griddy-header-cell--dragging'] : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
draggable={canReorder}
|
||||
key={header.id}
|
||||
onClick={isSortable ? header.column.getToggleSortingHandler() : undefined}
|
||||
@@ -137,19 +143,19 @@ export function TableHeader() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectAllCheckbox() {
|
||||
const table = useGriddyStore((s) => s._table)
|
||||
const selection = useGriddyStore((s) => s.selection)
|
||||
const table = useGriddyStore((s) => s._table);
|
||||
const selection = useGriddyStore((s) => s.selection);
|
||||
|
||||
if (!table || !selection || selection.mode !== 'multi') return null
|
||||
if (!table || !selection || selection.mode !== 'multi') return null;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
@@ -159,5 +165,5 @@ function SelectAllCheckbox() {
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
size="xs"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,68 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { CSS } from '../core/constants'
|
||||
import { useGriddyStore } from '../core/GriddyStore'
|
||||
import styles from '../styles/griddy.module.css'
|
||||
import { TableRow } from './TableRow'
|
||||
import { CSS } from '../core/constants';
|
||||
import { useGriddyStore } from '../core/GriddyStore';
|
||||
import styles from '../styles/griddy.module.css';
|
||||
import { TableRow } from './TableRow';
|
||||
|
||||
export function VirtualBody() {
|
||||
const table = useGriddyStore((s) => s._table)
|
||||
const virtualizer = useGriddyStore((s) => s._virtualizer)
|
||||
const setTotalRows = useGriddyStore((s) => s.setTotalRows)
|
||||
const infiniteScroll = useGriddyStore((s) => s.infiniteScroll)
|
||||
const table = useGriddyStore((s) => s._table);
|
||||
const virtualizer = useGriddyStore((s) => s._virtualizer);
|
||||
const setTotalRows = useGriddyStore((s) => s.setTotalRows);
|
||||
const infiniteScroll = useGriddyStore((s) => s.infiniteScroll);
|
||||
|
||||
const rows = table?.getRowModel().rows
|
||||
const virtualRows = virtualizer?.getVirtualItems()
|
||||
const totalSize = virtualizer?.getTotalSize() ?? 0
|
||||
const rows = table?.getRowModel().rows;
|
||||
const virtualRows = virtualizer?.getVirtualItems();
|
||||
const totalSize = virtualizer?.getTotalSize() ?? 0;
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
if (rows) {
|
||||
setTotalRows(rows.length)
|
||||
setTotalRows(rows.length);
|
||||
}
|
||||
}, [rows?.length, setTotalRows])
|
||||
}, [rows?.length, setTotalRows]);
|
||||
|
||||
// Infinite scroll: detect when approaching the end
|
||||
useEffect(() => {
|
||||
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
|
||||
if (isLoading || !hasMore || isLoadingRef.current) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the last rendered virtual row is within threshold of the end
|
||||
const lastVirtualRow = virtualRows[virtualRows.length - 1]
|
||||
if (!lastVirtualRow) return
|
||||
const lastVirtualRow = virtualRows[virtualRows.length - 1];
|
||||
if (!lastVirtualRow) return;
|
||||
|
||||
const lastVirtualIndex = lastVirtualRow.index
|
||||
const totalRows = rows.length
|
||||
const distanceFromEnd = totalRows - lastVirtualIndex - 1
|
||||
const lastVirtualIndex = lastVirtualRow.index;
|
||||
const totalRows = rows.length;
|
||||
const distanceFromEnd = totalRows - lastVirtualIndex - 1;
|
||||
|
||||
if (distanceFromEnd <= threshold) {
|
||||
isLoadingRef.current = true
|
||||
const loadPromise = infiniteScroll.onLoadMore()
|
||||
isLoadingRef.current = true;
|
||||
const loadPromise = infiniteScroll.onLoadMore();
|
||||
|
||||
if (loadPromise instanceof Promise) {
|
||||
loadPromise.finally(() => {
|
||||
isLoadingRef.current = false
|
||||
})
|
||||
isLoadingRef.current = false;
|
||||
});
|
||||
} 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 (
|
||||
<div
|
||||
@@ -75,17 +75,10 @@ export function VirtualBody() {
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
if (!row) return null
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
size={virtualRow.size}
|
||||
start={virtualRow.start}
|
||||
/>
|
||||
)
|
||||
return <TableRow key={row.id} row={row} size={virtualRow.size} start={virtualRow.start} />;
|
||||
})}
|
||||
{showLoadingIndicator && (
|
||||
<div
|
||||
@@ -101,5 +94,5 @@ export function VirtualBody() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user