Genesis
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# AGENTS — react-virtual-aggridautomat
|
||||
|
||||
## What
|
||||
PureComponent wrapper: AG Grid infinite-scroll + react-automat state. Library in `src/lib/`, demo in `src/demo/`.
|
||||
|
||||
## Exports (`src/lib/index.ts`)
|
||||
- `AutomatGrid` — PureComponent. Props: `automat` (required), `columnDefs` (required), `rowIdField` (default `'id'`), `cacheBlockSize`, `rowBuffer`, `height`, `width`, `onGridApiReady`, plus standard AG Grid overrides.
|
||||
- `createGridAutomat(options)` — Factory returning `Automat<GridAutomatState, GridAutomatActions>`.
|
||||
|
||||
## createGridAutomat
|
||||
```ts
|
||||
createGridAutomat({ fetchRows, name?, blockSize? })
|
||||
```
|
||||
- `fetchRows: ({startRow, endRow, sortModel, filterModel}) => Promise<{rows, lastRow?}>` — the only required option.
|
||||
- State: `ready`, `loading`, `totalRows`, `sortModel`, `filterModel`, `revision`, `error`.
|
||||
- Actions: `reload()`, `setSort(model)`, `setFilter(model)`.
|
||||
|
||||
## AutomatGrid internals
|
||||
- Uses `InfiniteRowModelModule` (community, no enterprise needed).
|
||||
- Constructor: sync read `automat.state`. Mount: `automat.subscribe(this, selector)`. Unmount: `unsub()`.
|
||||
- On `revision` change (via `componentDidUpdate`): rebuilds `IDatasource` and calls `api.setGridOption('datasource', ds)`.
|
||||
- `onSortChanged`/`onFilterChanged` → `automat.actions.setSort/setFilter` → bumps revision → datasource reset.
|
||||
- `getRowId` uses `props.rowIdField` (default `'id'`) from row data.
|
||||
|
||||
## Patterns
|
||||
```tsx
|
||||
// 1. Create automat (module scope)
|
||||
const grid = createGridAutomat({ fetchRows: myFetcher });
|
||||
// 2. Render
|
||||
<AutomatGrid automat={grid} columnDefs={cols} rowIdField="id" />
|
||||
// 3. Reload externally
|
||||
grid.actions.reload();
|
||||
// 4. Subscribe from another component
|
||||
this.unsub = grid.subscribe(this, s => ({ loading: s.loading }));
|
||||
```
|
||||
|
||||
## Rules
|
||||
- Never use hooks — PureComponent only.
|
||||
- Subscribe in `componentDidMount`, unsubscribe in `componentWillUnmount`.
|
||||
- Read `automat.state` in constructor for zero-flash initial render.
|
||||
- `fetchRows` must return `{ rows, lastRow? }`. Set `lastRow` to total count to stop infinite scrolling.
|
||||
- AG Grid modules: only `InfiniteRowModelModule` is registered by `AutomatGrid`.
|
||||
|
||||
## Commands
|
||||
```
|
||||
npm run dev # Vite dev server
|
||||
npm run build # tsc + vite build
|
||||
```
|
||||
12
LICENSE
Normal file
12
LICENSE
Normal file
@@ -0,0 +1,12 @@
|
||||
Copyright (C) 2026 by Sebastian Green-Huber
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
232
README.md
Normal file
232
README.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# ⚡ react-virtual-aggridautomat
|
||||
|
||||
> Opinionated, fully-virtualized AG Grid component powered by [react-automat](https://github.com/sebseb7/react-automat) — infinite-scroll data grids as React `PureComponent` with zero hooks, HOCs, or Context Providers.
|
||||
|
||||
[](https://www.ag-grid.com/)
|
||||
[](https://github.com/sebseb7/react-automat)
|
||||
[](https://react.dev/)
|
||||
|
||||
---
|
||||
|
||||
## What is this?
|
||||
|
||||
A reusable `<AutomatGrid />` PureComponent that wires AG Grid's [Infinite Row Model](https://www.ag-grid.com/react-data-grid/infinite-scrolling/) to a `react-automat` store. Your async data fetcher is the only thing you provide — the library handles:
|
||||
|
||||
- **Virtual row loading** — only visible rows exist in the DOM
|
||||
- **Block caching** — configurable cache size, debounced scroll loading
|
||||
- **Server-side sort & filter** — sort/filter changes flow through the automat and trigger datasource resets
|
||||
- **Reactive state** — loading indicators, error state, row counts, and revision tracking all live in the automat
|
||||
- **Zero hooks** — designed for `PureComponent` with synchronous constructor reads and fine-grained automat subscriptions
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install ag-grid-community ag-grid-react react-automat
|
||||
```
|
||||
|
||||
> This library is not yet published to npm. Clone or copy `src/lib/` into your project.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a grid automat
|
||||
|
||||
```ts
|
||||
// myGridAutomat.ts
|
||||
import { createGridAutomat } from './lib';
|
||||
|
||||
export const myGridAutomat = createGridAutomat({
|
||||
fetchRows: async ({ startRow, endRow, sortModel, filterModel }) => {
|
||||
const res = await fetch(`/api/items?start=${startRow}&end=${endRow}`);
|
||||
const { items, total } = await res.json();
|
||||
return { rows: items, lastRow: total };
|
||||
},
|
||||
name: 'myGrid', // Visible in window.__AUTOMATS__
|
||||
blockSize: 100, // Rows per block (passed to AG Grid's cacheBlockSize)
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Render the grid
|
||||
|
||||
```tsx
|
||||
// MyView.tsx
|
||||
import { PureComponent } from 'react';
|
||||
import { AutomatGrid } from './lib';
|
||||
import { myGridAutomat } from './myGridAutomat';
|
||||
|
||||
class MyView extends PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<AutomatGrid
|
||||
automat={myGridAutomat}
|
||||
columnDefs={[
|
||||
{ field: 'id', maxWidth: 80 },
|
||||
{ field: 'name', flex: 2, filter: 'agTextColumnFilter' },
|
||||
{ field: 'price', valueFormatter: p => `€${p.value?.toFixed(2)}` },
|
||||
]}
|
||||
rowIdField="id"
|
||||
height="600px"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. React to grid state
|
||||
|
||||
The automat is a standard `react-automat` store — subscribe to it from any PureComponent:
|
||||
|
||||
```tsx
|
||||
class StatusBar extends PureComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
loading: myGridAutomat.state.loading,
|
||||
totalRows: myGridAutomat.state.totalRows,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.unsub = myGridAutomat.subscribe(this, (s) => ({
|
||||
loading: s.loading,
|
||||
totalRows: s.totalRows,
|
||||
}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.unsub();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.state.loading ? 'Loading…' : `${this.state.totalRows} rows`}
|
||||
<button onClick={() => myGridAutomat.actions.reload()}>↻ Reload</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createGridAutomat(options)`
|
||||
|
||||
Creates a `react-automat` store to back the grid.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `fetchRows` | `(params: GridFetchParams) => Promise<GridFetchResult>` | **required** | Async function called by AG Grid's datasource for each block |
|
||||
| `name` | `string` | `null` | Automat registry name (visible in `window.__AUTOMATS__`) |
|
||||
| `blockSize` | `number` | `100` | Rows per cache block |
|
||||
|
||||
#### `GridFetchParams`
|
||||
|
||||
```ts
|
||||
{
|
||||
startRow: number;
|
||||
endRow: number;
|
||||
sortModel: SortModelItem[];
|
||||
filterModel: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
#### `GridFetchResult<TRow>`
|
||||
|
||||
```ts
|
||||
{
|
||||
rows: TRow[];
|
||||
lastRow?: number; // Total count. undefined/-1 = unknown (infinite scroll continues)
|
||||
}
|
||||
```
|
||||
|
||||
#### Automat State
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `ready` | `boolean` | `true` after first block loads |
|
||||
| `loading` | `boolean` | `true` while any block is in-flight |
|
||||
| `totalRows` | `number` | Known row count (`-1` if unknown) |
|
||||
| `sortModel` | `SortModelItem[]` | Current sort state |
|
||||
| `filterModel` | `Record<string, unknown>` | Current filter state |
|
||||
| `revision` | `number` | Increments on reload/sort/filter change |
|
||||
| `error` | `string \| null` | Last fetch error message |
|
||||
|
||||
#### Automat Actions
|
||||
|
||||
| Action | Description |
|
||||
|---|---|
|
||||
| `reload()` | Bumps revision, clears cache, forces full refetch |
|
||||
| `setSort(sortModel)` | Updates sort model + reloads |
|
||||
| `setFilter(filterModel)` | Updates filter model + reloads |
|
||||
|
||||
### `<AutomatGrid />` Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `automat` | `Automat<GridAutomatState, GridAutomatActions>` | **required** | The grid automat store |
|
||||
| `columnDefs` | `ColDef[]` | **required** | AG Grid column definitions |
|
||||
| `defaultColDef` | `ColDef` | `{ flex: 1, minWidth: 100, sortable: true, resizable: true }` | Default column config |
|
||||
| `rowIdField` | `string` | `'id'` | Row data field used for stable row IDs |
|
||||
| `cacheBlockSize` | `number` | automat's `blockSize` | Override cache block size |
|
||||
| `rowBuffer` | `number` | `0` | Extra rows rendered outside viewport |
|
||||
| `infiniteInitialRowCount` | `number` | `200` | Scrollbar size hint |
|
||||
| `maxConcurrentDatasourceRequests` | `number` | `2` | Parallel block fetches |
|
||||
| `maxBlocksInCache` | `number` | `10` | Max cached blocks before eviction |
|
||||
| `blockLoadDebounceMillis` | `number` | `100` | Scroll debounce |
|
||||
| `height` | `string \| number` | `'100%'` | Container height |
|
||||
| `width` | `string \| number` | `'100%'` | Container width |
|
||||
| `rowHeight` | `number` | AG Grid default (42) | Row height in pixels |
|
||||
| `theme` | `string` | `'ag-theme-alpine ag-theme-alpine-dark'` | Container theme class |
|
||||
| `modules` | `Module[]` | InfiniteRowModel + Text/Number/Date/Custom filters | Additional AG Grid modules |
|
||||
| `onGridApiReady` | `(api: GridApi) => void` | — | Callback when AG Grid API is available |
|
||||
| `className` | `string` | — | Container CSS class |
|
||||
| `style` | `CSSProperties` | — | Container inline styles |
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ AutomatGrid (PureComponent) │
|
||||
│ │
|
||||
│ constructor: reads automat.state synchronously │
|
||||
│ componentDidMount: subscribes with selector │
|
||||
│ componentDidUpdate: if revision changed → reset ds │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌────────────────────────┐ │
|
||||
│ │ AG Grid │────▶│ IDatasource.getRows() │ │
|
||||
│ │ (infinite │ │ calls automat._fetcher │ │
|
||||
│ │ row model) │◀────│ → successCallback() │ │
|
||||
│ └──────────────┘ └────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ onSortChanged ──────▶ automat.actions.setSort() │
|
||||
│ onFilterChanged ────▶ automat.actions.setFilter() │
|
||||
│ │ │ │
|
||||
│ └──── revision bump ─────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Running the Demo
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The demo generates 10,000 fake product rows and serves them through a simulated async fetcher with artificial latency. Open http://localhost:5173 to see infinite scroll in action.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/ # Reusable library
|
||||
│ ├── createGridAutomat.ts # Automat factory
|
||||
│ ├── AutomatGrid.tsx # PureComponent grid wrapper
|
||||
│ └── index.ts # Barrel exports
|
||||
└── demo/ # Demo app
|
||||
├── demoAutomat.ts # 10k fake rows + fetcher
|
||||
└── DemoGrid.tsx # Demo consumer component
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
0BSD
|
||||
22
eslint.config.js
Normal file
22
eslint.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Opinionated virtualized AG Grid powered by react-automat PureComponent state management" />
|
||||
<title>AutomatGrid — Virtualized AG Grid + react-automat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3282
package-lock.json
generated
Normal file
3282
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "react-virtual-aggridautomat",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"license": "0BSD",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"main": "./src/lib/index.ts",
|
||||
"types": "./src/lib/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/lib/index.ts",
|
||||
"import": "./src/lib/index.ts",
|
||||
"default": "./src/lib/index.ts"
|
||||
},
|
||||
"./createGridAutomat": {
|
||||
"types": "./src/lib/createGridAutomat.ts",
|
||||
"import": "./src/lib/createGridAutomat.ts",
|
||||
"default": "./src/lib/createGridAutomat.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"ag-grid-community": "^36.2.0",
|
||||
"ag-grid-react": "^36.2.0",
|
||||
"marked": "^18.0.14",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^19.3.0",
|
||||
"react-automat": "file:../automat",
|
||||
"react-dom": "^19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^26.6.4",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"eslint": "^10.12.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"typescript": "~7.0.2",
|
||||
"typescript-eslint": "^8.71.0",
|
||||
"vite": "^8.3.2"
|
||||
}
|
||||
}
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
156
src/App.tsx
Normal file
156
src/App.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { DemoGrid } from './demo/DemoGrid';
|
||||
import { DocViewer } from './demo/DocViewer';
|
||||
import { CodeViewer } from './demo/CodeViewer';
|
||||
|
||||
export type AppView = 'demo' | 'docs' | 'code' | 'split';
|
||||
|
||||
interface AppState {
|
||||
view: AppView;
|
||||
splitRightTab: 'docs' | 'code';
|
||||
}
|
||||
|
||||
class App extends PureComponent<Record<string, never>, AppState> {
|
||||
constructor(props: Record<string, never>) {
|
||||
super(props);
|
||||
this.state = {
|
||||
view: 'demo',
|
||||
splitRightTab: 'docs',
|
||||
};
|
||||
}
|
||||
|
||||
private handleSetView = (view: AppView): void => {
|
||||
this.setState({ view });
|
||||
};
|
||||
|
||||
private handleSetSplitRightTab = (tab: 'docs' | 'code'): void => {
|
||||
this.setState({ splitRightTab: tab });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { view, splitRightTab } = this.state;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
{/* ── Main Top Navigation Bar ── */}
|
||||
<header className="app-topbar">
|
||||
<div className="app-topbar-brand">
|
||||
<span className="app-brand-icon">⚡</span>
|
||||
<div className="app-brand-text">
|
||||
<span className="app-brand-title">react-virtual-aggridautomat</span>
|
||||
<span className="app-brand-subtitle">Infinite AG Grid + react-automat</span>
|
||||
</div>
|
||||
<div className="app-brand-badges">
|
||||
<span className="badge-chip badge-ag">AG Grid v36</span>
|
||||
<span className="badge-chip badge-automat">react-automat</span>
|
||||
<span className="badge-chip badge-pure">PureComponent</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="app-nav-tabs" aria-label="Main Navigation">
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-tab-btn ${view === 'demo' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetView('demo')}
|
||||
>
|
||||
<span className="nav-tab-icon">⚡</span>
|
||||
<span>Live Demo</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-tab-btn ${view === 'docs' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetView('docs')}
|
||||
>
|
||||
<span className="nav-tab-icon">📖</span>
|
||||
<span>Documentation</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-tab-btn ${view === 'code' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetView('code')}
|
||||
>
|
||||
<span className="nav-tab-icon">💻</span>
|
||||
<span>Demo Code</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`nav-tab-btn ${view === 'split' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetView('split')}
|
||||
title="View Demo and Documentation / Code side by side"
|
||||
>
|
||||
<span className="nav-tab-icon">🪟</span>
|
||||
<span>Split View</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className="app-topbar-actions">
|
||||
<a
|
||||
href="https://github.com/sebseb7/react-automat"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="app-github-link"
|
||||
title="GitHub repository"
|
||||
>
|
||||
GitHub ↗
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Content View Area ── */}
|
||||
<main className="app-viewport">
|
||||
{view === 'demo' && (
|
||||
<DemoGrid onNavigateView={this.handleSetView} />
|
||||
)}
|
||||
|
||||
{view === 'docs' && (
|
||||
<DocViewer />
|
||||
)}
|
||||
|
||||
{view === 'code' && (
|
||||
<CodeViewer />
|
||||
)}
|
||||
|
||||
{view === 'split' && (
|
||||
<div className="split-view-container">
|
||||
<div className="split-pane split-pane-left">
|
||||
<DemoGrid onNavigateView={this.handleSetView} />
|
||||
</div>
|
||||
|
||||
<div className="split-divider" />
|
||||
|
||||
<div className="split-pane split-pane-right">
|
||||
<div className="split-right-header">
|
||||
<div className="split-tab-switcher">
|
||||
<button
|
||||
type="button"
|
||||
className={`split-tab-btn ${splitRightTab === 'docs' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetSplitRightTab('docs')}
|
||||
>
|
||||
📖 Documentation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`split-tab-btn ${splitRightTab === 'code' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetSplitRightTab('code')}
|
||||
>
|
||||
💻 Demo & Core Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="split-right-content">
|
||||
{splitRightTab === 'docs' ? <DocViewer /> : <CodeViewer />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
208
src/demo/CodeViewer.tsx
Normal file
208
src/demo/CodeViewer.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { PureComponent } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-tsx';
|
||||
|
||||
import demoGridCode from './DemoGrid.tsx?raw';
|
||||
import demoAutomatCode from './demoAutomat.ts?raw';
|
||||
import automatGridCode from '../lib/AutomatGrid.tsx?raw';
|
||||
import createGridAutomatCode from '../lib/createGridAutomat.ts?raw';
|
||||
|
||||
interface SourceFile {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'demo' | 'lib';
|
||||
description: string;
|
||||
content: string;
|
||||
language: 'tsx' | 'typescript';
|
||||
}
|
||||
|
||||
const FILES: SourceFile[] = [
|
||||
{
|
||||
id: 'demo-grid',
|
||||
name: 'DemoGrid.tsx',
|
||||
category: 'demo',
|
||||
description: 'Showcase PureComponent wiring AutomatGrid, columns, and live stats',
|
||||
content: demoGridCode,
|
||||
language: 'tsx',
|
||||
},
|
||||
{
|
||||
id: 'demo-automat',
|
||||
name: 'demoAutomat.ts',
|
||||
category: 'demo',
|
||||
description: '10,000 fake items generator, simulated async server fetcher & store factory',
|
||||
content: demoAutomatCode,
|
||||
language: 'typescript',
|
||||
},
|
||||
{
|
||||
id: 'lib-grid',
|
||||
name: 'AutomatGrid.tsx',
|
||||
category: 'lib',
|
||||
description: 'Reusable PureComponent wrapper for AG Grid InfiniteRowModel & react-automat',
|
||||
content: automatGridCode,
|
||||
language: 'tsx',
|
||||
},
|
||||
{
|
||||
id: 'lib-factory',
|
||||
name: 'createGridAutomat.ts',
|
||||
category: 'lib',
|
||||
description: 'Automat store factory managing scroll revisions, sort, filter, and state',
|
||||
content: createGridAutomatCode,
|
||||
language: 'typescript',
|
||||
},
|
||||
];
|
||||
|
||||
interface CodeViewerProps {
|
||||
initialFileId?: string;
|
||||
}
|
||||
|
||||
interface CodeViewerState {
|
||||
activeFileId: string;
|
||||
copied: boolean;
|
||||
}
|
||||
|
||||
export class CodeViewer extends PureComponent<CodeViewerProps, CodeViewerState> {
|
||||
private copyTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(props: CodeViewerProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
activeFileId: props.initialFileId || 'demo-grid',
|
||||
copied: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.copyTimeout) {
|
||||
clearTimeout(this.copyTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSelectFile = (fileId: string): void => {
|
||||
this.setState({ activeFileId: fileId, copied: false });
|
||||
};
|
||||
|
||||
private handleCopy = (): void => {
|
||||
const activeFile = FILES.find((f) => f.id === this.state.activeFileId);
|
||||
if (!activeFile) return;
|
||||
|
||||
navigator.clipboard.writeText(activeFile.content).then(() => {
|
||||
this.setState({ copied: true });
|
||||
if (this.copyTimeout) clearTimeout(this.copyTimeout);
|
||||
this.copyTimeout = setTimeout(() => {
|
||||
this.setState({ copied: false });
|
||||
}, 2000);
|
||||
});
|
||||
};
|
||||
|
||||
private lineNumbersRef: HTMLDivElement | null = null;
|
||||
|
||||
private handleCodeScroll = (e: React.UIEvent<HTMLPreElement>): void => {
|
||||
if (this.lineNumbersRef) {
|
||||
this.lineNumbersRef.scrollTop = e.currentTarget.scrollTop;
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { activeFileId, copied } = this.state;
|
||||
const activeFile = FILES.find((f) => f.id === activeFileId) || FILES[0];
|
||||
|
||||
// Compute line count
|
||||
const lines = activeFile.content.split('\n');
|
||||
const lineCount = lines.length;
|
||||
|
||||
// Highlight code using Prism
|
||||
const grammar = Prism.languages.tsx || Prism.languages.typescript;
|
||||
const highlightedHtml = Prism.highlight(
|
||||
activeFile.content,
|
||||
grammar,
|
||||
activeFile.language,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="code-viewer-container">
|
||||
{/* Top File Selector Bar */}
|
||||
<div className="code-viewer-tabs">
|
||||
<div className="code-tabs-group">
|
||||
<span className="code-tabs-label">Demo Files:</span>
|
||||
{FILES.filter((f) => f.category === 'demo').map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={`code-tab-btn ${activeFileId === f.id ? 'active' : ''}`}
|
||||
onClick={() => this.handleSelectFile(f.id)}
|
||||
>
|
||||
<span className="code-tab-icon">📄</span>
|
||||
<span className="code-tab-name">{f.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="code-tabs-group">
|
||||
<span className="code-tabs-label">Library Core:</span>
|
||||
{FILES.filter((f) => f.category === 'lib').map((f) => (
|
||||
<button
|
||||
key={f.id}
|
||||
type="button"
|
||||
className={`code-tab-btn ${activeFileId === f.id ? 'active' : ''}`}
|
||||
onClick={() => this.handleSelectFile(f.id)}
|
||||
>
|
||||
<span className="code-tab-icon">⚙</span>
|
||||
<span className="code-tab-name">{f.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active File Header info */}
|
||||
<div className="code-file-meta">
|
||||
<div className="code-file-details">
|
||||
<span className="code-file-tag">
|
||||
{activeFile.category === 'demo' ? 'DEMO SOURCE' : 'CORE LIBRARY'}
|
||||
</span>
|
||||
<span className="code-file-name">{activeFile.name}</span>
|
||||
<span className="code-file-desc">{activeFile.description}</span>
|
||||
</div>
|
||||
|
||||
<div className="code-file-actions">
|
||||
<span className="code-file-lines">{lineCount} lines</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`code-copy-full-btn ${copied ? 'copied' : ''}`}
|
||||
onClick={this.handleCopy}
|
||||
>
|
||||
{copied ? '✓ Copied to Clipboard' : '📋 Copy File'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Content with Line Numbers */}
|
||||
<div className="code-editor-layout">
|
||||
{/* Gutter / Line numbers */}
|
||||
<div
|
||||
className="code-line-numbers"
|
||||
ref={(el) => {
|
||||
this.lineNumbersRef = el;
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{lines.map((_, idx) => (
|
||||
<div key={idx + 1} className="code-line-number">
|
||||
{idx + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Syntax Highlighted Code */}
|
||||
<pre className="code-syntax-area" onScroll={this.handleCodeScroll}>
|
||||
<code
|
||||
className={`language-${activeFile.language}`}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
517
src/demo/DemoGrid.tsx
Normal file
517
src/demo/DemoGrid.tsx
Normal file
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* DemoGrid.tsx
|
||||
*
|
||||
* Demo PureComponent showing AutomatGrid in action.
|
||||
* Subscribes to demoGridAutomat for status display and
|
||||
* demonstrates interactive server-side sorting, column filtering,
|
||||
* floating filter rows, and quick presets.
|
||||
*/
|
||||
import { PureComponent } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { AutomatGrid } from '../lib/AutomatGrid';
|
||||
import { demoGridAutomat } from './demoAutomat';
|
||||
import type { ProductRow } from './demoAutomat';
|
||||
import type { ColDef } from 'ag-grid-community';
|
||||
import type { SortModelItem } from '../lib/createGridAutomat';
|
||||
|
||||
// ─── Status badge renderer ──────────────────────────────────────
|
||||
|
||||
function StatusBadge({ value }: { value: string }) {
|
||||
const colors: Record<string, { bg: string; fg: string }> = {
|
||||
active: { bg: '#059669', fg: '#ecfdf5' },
|
||||
pending: { bg: '#d97706', fg: '#fffbeb' },
|
||||
discontinued: { bg: '#dc2626', fg: '#fef2f2' },
|
||||
};
|
||||
const c = colors[value] ?? { bg: '#4b5563', fg: '#f3f4f6' };
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
background: c.bg,
|
||||
color: c.fg,
|
||||
padding: '3px 10px',
|
||||
borderRadius: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
letterSpacing: 0.3,
|
||||
display: 'inline-block',
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Column definitions with full filter & sort support ──────────
|
||||
|
||||
const COLUMN_DEFS: ColDef<ProductRow>[] = [
|
||||
{
|
||||
headerName: '#',
|
||||
field: 'id',
|
||||
maxWidth: 90,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
floatingFilter: true,
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'SKU',
|
||||
field: 'sku',
|
||||
maxWidth: 140,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
cellStyle: { fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Product Name',
|
||||
field: 'name',
|
||||
minWidth: 220,
|
||||
flex: 2,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
},
|
||||
{
|
||||
headerName: 'Category',
|
||||
field: 'category',
|
||||
minWidth: 150,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
},
|
||||
{
|
||||
headerName: 'Price',
|
||||
field: 'price',
|
||||
maxWidth: 130,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
floatingFilter: true,
|
||||
valueFormatter: (p) => (p.value != null ? `€${Number(p.value).toFixed(2)}` : ''),
|
||||
cellStyle: { textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Stock',
|
||||
field: 'stock',
|
||||
maxWidth: 110,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
floatingFilter: true,
|
||||
cellStyle: { textAlign: 'right', fontVariantNumeric: 'tabular-nums' },
|
||||
},
|
||||
{
|
||||
headerName: 'Supplier',
|
||||
field: 'supplier',
|
||||
minWidth: 160,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
},
|
||||
{
|
||||
headerName: 'Rating',
|
||||
field: 'rating',
|
||||
maxWidth: 100,
|
||||
sortable: true,
|
||||
filter: 'agNumberColumnFilter',
|
||||
floatingFilter: true,
|
||||
valueFormatter: (p) => (p.value != null ? `${Number(p.value).toFixed(1)} ★` : ''),
|
||||
cellStyle: { textAlign: 'center' },
|
||||
},
|
||||
{
|
||||
headerName: 'Status',
|
||||
field: 'status',
|
||||
maxWidth: 140,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
cellRenderer: StatusBadge,
|
||||
},
|
||||
{
|
||||
headerName: 'Updated',
|
||||
field: 'lastUpdated',
|
||||
maxWidth: 130,
|
||||
sortable: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Component Props & State ─────────────────────────────────────
|
||||
|
||||
export interface DemoGridProps {
|
||||
onNavigateView?: (view: 'demo' | 'docs' | 'code' | 'split') => void;
|
||||
}
|
||||
|
||||
interface DemoGridState {
|
||||
loading: boolean;
|
||||
ready: boolean;
|
||||
totalRows: number;
|
||||
error: string | null;
|
||||
sortModel: SortModelItem[];
|
||||
filterModel: Record<string, unknown>;
|
||||
revision: number;
|
||||
searchValue: string;
|
||||
}
|
||||
|
||||
// ─── Demo Component ──────────────────────────────────────────────
|
||||
|
||||
export class DemoGrid extends PureComponent<DemoGridProps, DemoGridState> {
|
||||
private unsub: (() => void) | null = null;
|
||||
private searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(props: DemoGridProps) {
|
||||
super(props);
|
||||
const s = demoGridAutomat.state;
|
||||
this.state = {
|
||||
loading: s.loading,
|
||||
ready: s.ready,
|
||||
totalRows: s.totalRows,
|
||||
error: s.error,
|
||||
sortModel: s.sortModel,
|
||||
filterModel: s.filterModel,
|
||||
revision: s.revision,
|
||||
searchValue: '',
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.unsub = demoGridAutomat.subscribe(
|
||||
this as unknown as import('react').PureComponent,
|
||||
(s) => ({
|
||||
loading: s.loading,
|
||||
ready: s.ready,
|
||||
totalRows: s.totalRows,
|
||||
error: s.error,
|
||||
sortModel: s.sortModel,
|
||||
filterModel: s.filterModel,
|
||||
revision: s.revision,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.unsub?.();
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
|
||||
private handleReload = (): void => {
|
||||
demoGridAutomat.actions.reload();
|
||||
};
|
||||
|
||||
private handleSetSort = (colId: string, dir: 'asc' | 'desc'): void => {
|
||||
demoGridAutomat.actions.setSort([{ colId, sort: dir }]);
|
||||
};
|
||||
|
||||
private handleClearSort = (): void => {
|
||||
demoGridAutomat.actions.setSort([]);
|
||||
};
|
||||
|
||||
private handleSetFilterPreset = (filterModel: Record<string, unknown>): void => {
|
||||
this.setState({ searchValue: '' });
|
||||
demoGridAutomat.actions.setFilter(filterModel);
|
||||
};
|
||||
|
||||
private handleClearFilter = (): void => {
|
||||
this.setState({ searchValue: '' });
|
||||
demoGridAutomat.actions.setFilter({});
|
||||
};
|
||||
|
||||
private handleSearchInput = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const val = e.target.value;
|
||||
this.setState({ searchValue: val });
|
||||
|
||||
if (this.searchTimeout) {
|
||||
clearTimeout(this.searchTimeout);
|
||||
}
|
||||
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
const current = { ...demoGridAutomat.state.filterModel };
|
||||
if (!val.trim()) {
|
||||
delete current.name;
|
||||
} else {
|
||||
current.name = {
|
||||
filterType: 'text',
|
||||
type: 'contains',
|
||||
filter: val.trim(),
|
||||
};
|
||||
}
|
||||
demoGridAutomat.actions.setFilter(current);
|
||||
}, 200);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { loading, totalRows, error, sortModel, filterModel, revision, searchValue } = this.state;
|
||||
|
||||
const hasFilters = Object.keys(filterModel).length > 0;
|
||||
const hasSort = sortModel.length > 0;
|
||||
|
||||
// Formatting active sort description
|
||||
const currentSort = sortModel[0];
|
||||
const sortSummary = currentSort
|
||||
? `${currentSort.colId.toUpperCase()} (${currentSort.sort === 'asc' ? '↑ Asc' : '↓ Desc'})`
|
||||
: 'None (Natural)';
|
||||
|
||||
// Formatting active filter keys
|
||||
const filterKeys = Object.keys(filterModel);
|
||||
const filterSummary = filterKeys.length > 0
|
||||
? filterKeys.map((k) => `${k}`).join(', ')
|
||||
: 'None (All Rows)';
|
||||
|
||||
return (
|
||||
<div className="demo-container">
|
||||
{/* ── Top Header bar ── */}
|
||||
<header className="demo-header">
|
||||
<div className="demo-header-left">
|
||||
<h1 className="demo-title">
|
||||
<span className="demo-title-icon">⚡</span>
|
||||
AutomatGrid Demo
|
||||
</h1>
|
||||
<span className="demo-subtitle">
|
||||
10,000 rows · Infinite scroll · Server sort & filter · Automat-driven
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="demo-header-right">
|
||||
<div className="demo-stats">
|
||||
{totalRows >= 0 && (
|
||||
<span className="demo-stat">
|
||||
{totalRows.toLocaleString()} rows
|
||||
</span>
|
||||
)}
|
||||
{error && (
|
||||
<span className="demo-stat demo-stat--error">
|
||||
⚠ {error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{this.props.onNavigateView && (
|
||||
<div className="demo-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="demo-pill-btn"
|
||||
onClick={() => this.props.onNavigateView?.('docs')}
|
||||
title="View full README documentation"
|
||||
>
|
||||
📖 Docs
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="demo-pill-btn"
|
||||
onClick={() => this.props.onNavigateView?.('code')}
|
||||
title="View demo & library source code"
|
||||
>
|
||||
💻 Code
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="demo-reload-btn"
|
||||
onClick={this.handleReload}
|
||||
disabled={loading}
|
||||
title="Reload data (bumps automat revision)"
|
||||
>
|
||||
↻ Reload
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Sort & Filter Interactive Control Showcase ── */}
|
||||
<section className="demo-toolbar" aria-label="Sort and Filter Controls">
|
||||
{/* Row 1: Filters */}
|
||||
<div className="demo-toolbar-row">
|
||||
<div className="demo-search-box">
|
||||
<span className="demo-search-icon">🔍</span>
|
||||
<input
|
||||
type="text"
|
||||
className="demo-search-input"
|
||||
placeholder="Filter by Product Name..."
|
||||
value={searchValue}
|
||||
onChange={this.handleSearchInput}
|
||||
/>
|
||||
{searchValue && (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-search-clear"
|
||||
onClick={() => {
|
||||
this.setState({ searchValue: '' });
|
||||
const current = { ...demoGridAutomat.state.filterModel };
|
||||
delete current.name;
|
||||
demoGridAutomat.actions.setFilter(current);
|
||||
}}
|
||||
title="Clear name filter"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="demo-preset-group">
|
||||
<span className="demo-group-label">Filter Presets:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-filter-btn ${!hasFilters ? 'active' : ''}`}
|
||||
onClick={this.handleClearFilter}
|
||||
>
|
||||
All (10k)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-filter-btn ${filterModel.status ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetFilterPreset({ status: { filterType: 'text', type: 'equals', filter: 'active' } })}
|
||||
>
|
||||
🟢 Active
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-filter-btn`}
|
||||
onClick={() => this.handleSetFilterPreset({ status: { filterType: 'text', type: 'equals', filter: 'pending' } })}
|
||||
>
|
||||
🟡 Pending
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-filter-btn`}
|
||||
onClick={() => this.handleSetFilterPreset({ status: { filterType: 'text', type: 'equals', filter: 'discontinued' } })}
|
||||
>
|
||||
🔴 Discontinued
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-filter-btn ${filterModel.category ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetFilterPreset({ category: { filterType: 'text', type: 'contains', filter: 'Electronics' } })}
|
||||
>
|
||||
⚡ Electronics
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-filter-btn ${filterModel.rating ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetFilterPreset({ rating: { filterType: 'number', type: 'greaterThanOrEqual', filter: 4 } })}
|
||||
>
|
||||
★ Rating ≥ 4.0
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-filter-btn ${filterModel.stock ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetFilterPreset({ stock: { filterType: 'number', type: 'lessThan', filter: 500 } })}
|
||||
>
|
||||
📉 Low Stock (<500)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-filter-btn ${filterModel.price ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetFilterPreset({ price: { filterType: 'number', type: 'lessThan', filter: 50 } })}
|
||||
>
|
||||
🏷 Price < €50
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Sorts & Live State Indicator */}
|
||||
<div className="demo-toolbar-row demo-toolbar-row--sub">
|
||||
<div className="demo-preset-group">
|
||||
<span className="demo-group-label">Quick Sort:</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-sort-btn ${currentSort?.colId === 'price' && currentSort?.sort === 'asc' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetSort('price', 'asc')}
|
||||
>
|
||||
Price: Low → High
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-sort-btn ${currentSort?.colId === 'price' && currentSort?.sort === 'desc' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetSort('price', 'desc')}
|
||||
>
|
||||
Price: High → Low
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-sort-btn ${currentSort?.colId === 'rating' && currentSort?.sort === 'desc' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetSort('rating', 'desc')}
|
||||
>
|
||||
Rating: High → Low
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`demo-sort-btn ${currentSort?.colId === 'name' && currentSort?.sort === 'asc' ? 'active' : ''}`}
|
||||
onClick={() => this.handleSetSort('name', 'asc')}
|
||||
>
|
||||
Name: A → Z
|
||||
</button>
|
||||
{hasSort && (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-clear-btn"
|
||||
onClick={this.handleClearSort}
|
||||
>
|
||||
✕ Clear Sort
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Automat State Pills */}
|
||||
<div className="demo-automat-state-bar">
|
||||
<span className="state-badge state-badge--sort" title="Active Sort Model in demoGridAutomat">
|
||||
Sort: <strong>{sortSummary}</strong>
|
||||
</span>
|
||||
<span className="state-badge state-badge--filter" title="Active Filter Model in demoGridAutomat">
|
||||
Filter: <strong>{filterSummary}</strong>
|
||||
</span>
|
||||
<span className="state-badge state-badge--rev" title="Current store revision count">
|
||||
Rev: <strong>#{revision}</strong>
|
||||
</span>
|
||||
{(hasFilters || hasSort) && (
|
||||
<button
|
||||
type="button"
|
||||
className="demo-reset-all-btn"
|
||||
onClick={() => {
|
||||
this.handleClearFilter();
|
||||
this.handleClearSort();
|
||||
}}
|
||||
title="Reset all filters and sorting"
|
||||
>
|
||||
Reset All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Virtual Grid ── */}
|
||||
<div className="demo-grid-wrapper">
|
||||
<AutomatGrid<ProductRow>
|
||||
automat={demoGridAutomat}
|
||||
columnDefs={COLUMN_DEFS}
|
||||
defaultColDef={{
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
filter: true,
|
||||
floatingFilter: true,
|
||||
}}
|
||||
rowIdField="id"
|
||||
cacheBlockSize={100}
|
||||
maxBlocksInCache={100}
|
||||
infiniteInitialRowCount={10000}
|
||||
blockLoadDebounceMillis={80}
|
||||
height="100%"
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<footer className="demo-footer">
|
||||
<span>
|
||||
<code>react-automat</code> + <code>ag-grid-react</code> · Virtualized PureComponent with Server-Side Sort & Filter
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
195
src/demo/DocViewer.tsx
Normal file
195
src/demo/DocViewer.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { PureComponent } from 'react';
|
||||
import { marked } from 'marked';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-jsx';
|
||||
import 'prismjs/components/prism-tsx';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import readmeRaw from '../../README.md?raw';
|
||||
|
||||
interface TocItem {
|
||||
id: string;
|
||||
text: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface DocViewerState {
|
||||
html: string;
|
||||
toc: TocItem[];
|
||||
activeHeadingId: string | null;
|
||||
copiedIndex: number | null;
|
||||
}
|
||||
|
||||
export class DocViewer extends PureComponent<Record<string, never>, DocViewerState> {
|
||||
private contentRef: HTMLElement | null = null;
|
||||
|
||||
constructor(props: Record<string, never>) {
|
||||
super(props);
|
||||
|
||||
const { html, toc } = this.parseMarkdown(readmeRaw);
|
||||
this.state = {
|
||||
html,
|
||||
toc,
|
||||
activeHeadingId: toc[0]?.id ?? null,
|
||||
copiedIndex: null,
|
||||
};
|
||||
}
|
||||
|
||||
private parseMarkdown(markdown: string): { html: string; toc: TocItem[] } {
|
||||
const toc: TocItem[] = [];
|
||||
|
||||
marked.use({
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
renderer: {
|
||||
heading({ text, depth }) {
|
||||
// Generate URL-friendly slug
|
||||
const plainText = text.replace(/<[^>]+>/g, '').trim();
|
||||
const id = plainText
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-');
|
||||
|
||||
if (depth === 2 || depth === 3) {
|
||||
toc.push({ id, text: plainText, depth });
|
||||
}
|
||||
|
||||
return `<h${depth} id="${id}" class="doc-heading doc-heading-${depth}"><a href="#${id}" class="heading-anchor">#</a>${text}</h${depth}>`;
|
||||
},
|
||||
code({ text, lang }) {
|
||||
const validLang = lang && Prism.languages[lang] ? lang : 'typescript';
|
||||
const grammar = Prism.languages[validLang] || Prism.languages.typescript;
|
||||
let highlighted: string;
|
||||
try {
|
||||
highlighted = Prism.highlight(text, grammar, validLang);
|
||||
} catch {
|
||||
highlighted = text;
|
||||
}
|
||||
|
||||
const encodedRaw = encodeURIComponent(text);
|
||||
return `
|
||||
<div class="doc-code-block" data-raw="${encodedRaw}">
|
||||
<div class="doc-code-header">
|
||||
<span class="doc-code-lang">${lang || 'code'}</span>
|
||||
<button class="doc-code-copy" type="button" aria-label="Copy snippet">Copy</button>
|
||||
</div>
|
||||
<pre class="language-${lang}"><code class="language-${lang}">${highlighted}</code></pre>
|
||||
</div>
|
||||
`;
|
||||
},
|
||||
table({ header, rows }) {
|
||||
return `<div class="doc-table-wrapper"><table class="doc-table"><thead>${header}</thead><tbody>${rows}</tbody></table></div>`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const html = marked.parse(markdown) as string;
|
||||
return { html, toc };
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
if (this.contentRef) {
|
||||
this.contentRef.addEventListener('click', this.handleContentClick);
|
||||
}
|
||||
window.addEventListener('scroll', this.handleScroll, true);
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.contentRef) {
|
||||
this.contentRef.removeEventListener('click', this.handleContentClick);
|
||||
}
|
||||
window.removeEventListener('scroll', this.handleScroll, true);
|
||||
}
|
||||
|
||||
private handleContentClick = (e: MouseEvent): void => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.classList.contains('doc-code-copy')) {
|
||||
const wrapper = target.closest('.doc-code-block');
|
||||
if (wrapper) {
|
||||
const raw = decodeURIComponent(wrapper.getAttribute('data-raw') || '');
|
||||
navigator.clipboard.writeText(raw).then(() => {
|
||||
const original = target.textContent;
|
||||
target.textContent = 'Copied!';
|
||||
target.classList.add('copied');
|
||||
setTimeout(() => {
|
||||
target.textContent = original;
|
||||
target.classList.remove('copied');
|
||||
}, 1800);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private handleScroll = (): void => {
|
||||
if (!this.contentRef) return;
|
||||
const headings = this.contentRef.querySelectorAll('.doc-heading');
|
||||
const scrollPos = this.contentRef.scrollTop + 100;
|
||||
|
||||
let currentId: string | null = null;
|
||||
headings.forEach((h) => {
|
||||
const el = h as HTMLElement;
|
||||
if (el.offsetTop <= scrollPos) {
|
||||
currentId = el.id;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentId && currentId !== this.state.activeHeadingId) {
|
||||
this.setState({ activeHeadingId: currentId });
|
||||
}
|
||||
};
|
||||
|
||||
private scrollToHeading = (id: string): void => {
|
||||
if (!this.contentRef) return;
|
||||
const target = this.contentRef.querySelector(`#${id}`) as HTMLElement;
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
this.setState({ activeHeadingId: id });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { html, toc, activeHeadingId } = this.state;
|
||||
|
||||
return (
|
||||
<div className="doc-layout">
|
||||
{/* Table of contents sidebar */}
|
||||
<aside className="doc-sidebar">
|
||||
<div className="doc-sidebar-header">
|
||||
<span className="doc-sidebar-icon">📑</span>
|
||||
<span>Documentation</span>
|
||||
</div>
|
||||
<nav className="doc-toc">
|
||||
{toc.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`doc-toc-item doc-toc-depth-${item.depth} ${
|
||||
activeHeadingId === item.id ? 'active' : ''
|
||||
}`}
|
||||
onClick={() => this.scrollToHeading(item.id)}
|
||||
>
|
||||
{item.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Main rendered documentation */}
|
||||
<main
|
||||
className="doc-content-container"
|
||||
ref={(el) => {
|
||||
this.contentRef = el;
|
||||
}}
|
||||
onScroll={this.handleScroll}
|
||||
>
|
||||
<div
|
||||
className="doc-content"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
222
src/demo/demoAutomat.ts
Normal file
222
src/demo/demoAutomat.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* demoAutomat.ts
|
||||
*
|
||||
* Demo data generator & grid automat for the showcase app.
|
||||
* Generates 10,000 fake product rows and serves them through a simulated
|
||||
* async fetcher with configurable latency — demonstrating how AutomatGrid
|
||||
* virtualizes a large dataset.
|
||||
*/
|
||||
import { createGridAutomat } from '../lib/createGridAutomat';
|
||||
import type { GridFetchParams, GridFetchResult } from '../lib/createGridAutomat';
|
||||
|
||||
// ─── Demo row type ───────────────────────────────────────────────
|
||||
|
||||
export interface ProductRow {
|
||||
id: number;
|
||||
name: string;
|
||||
sku: string;
|
||||
category: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
supplier: string;
|
||||
rating: number;
|
||||
status: 'active' | 'discontinued' | 'pending';
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
// ─── Fake data generation ────────────────────────────────────────
|
||||
|
||||
const CATEGORIES = [
|
||||
'Electronics', 'Clothing', 'Home & Garden', 'Sports', 'Books',
|
||||
'Toys', 'Automotive', 'Health', 'Food & Beverage', 'Office',
|
||||
];
|
||||
|
||||
const SUPPLIERS = [
|
||||
'Globex Corp', 'Initech', 'Umbrella Co', 'Stark Industries',
|
||||
'Wayne Enterprises', 'Cyberdyne', 'Acme Corp', 'Soylent',
|
||||
'Weyland-Yutani', 'Tyrell Corp',
|
||||
];
|
||||
|
||||
const ADJECTIVES = [
|
||||
'Ultra', 'Premium', 'Elite', 'Pro', 'Max', 'Nano', 'Hyper',
|
||||
'Mega', 'Smart', 'Eco', 'Turbo', 'Quantum', 'Apex', 'Prime',
|
||||
];
|
||||
|
||||
const PRODUCTS = [
|
||||
'Widget', 'Gadget', 'Module', 'Sensor', 'Drive', 'Board',
|
||||
'Panel', 'Switch', 'Cable', 'Adapter', 'Battery', 'Display',
|
||||
'Controller', 'Router', 'Hub', 'Filter', 'Valve', 'Motor',
|
||||
];
|
||||
|
||||
const STATUSES: ProductRow['status'][] = ['active', 'discontinued', 'pending'];
|
||||
|
||||
function seededRandom(seed: number): () => number {
|
||||
let s = seed;
|
||||
return () => {
|
||||
s = (s * 16807 + 0) % 2147483647;
|
||||
return s / 2147483647;
|
||||
};
|
||||
}
|
||||
|
||||
function generateProducts(count: number): ProductRow[] {
|
||||
const rng = seededRandom(42);
|
||||
const rows: ProductRow[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const adj = ADJECTIVES[Math.floor(rng() * ADJECTIVES.length)];
|
||||
const prod = PRODUCTS[Math.floor(rng() * PRODUCTS.length)];
|
||||
const cat = CATEGORIES[Math.floor(rng() * CATEGORIES.length)];
|
||||
const sup = SUPPLIERS[Math.floor(rng() * SUPPLIERS.length)];
|
||||
const status = STATUSES[Math.floor(rng() * STATUSES.length)];
|
||||
|
||||
const year = 2024 + Math.floor(rng() * 3);
|
||||
const month = 1 + Math.floor(rng() * 12);
|
||||
const day = 1 + Math.floor(rng() * 28);
|
||||
|
||||
rows.push({
|
||||
id: i + 1,
|
||||
name: `${adj} ${prod} ${i + 1}`,
|
||||
sku: `SKU-${String(i + 1).padStart(6, '0')}`,
|
||||
category: cat,
|
||||
price: Math.round((rng() * 999 + 1) * 100) / 100,
|
||||
stock: Math.floor(rng() * 5000),
|
||||
supplier: sup,
|
||||
rating: Math.round(rng() * 50) / 10,
|
||||
status,
|
||||
lastUpdated: `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Pre-generate the dataset once at module load
|
||||
const ALL_PRODUCTS = generateProducts(10_000);
|
||||
|
||||
// ─── Simulated fetcher ───────────────────────────────────────────
|
||||
|
||||
/** Helper to evaluate a single filter condition on a row value. */
|
||||
function matchesCondition(rawVal: unknown, filter: {
|
||||
filter?: string | number;
|
||||
filterTo?: number;
|
||||
type?: string;
|
||||
filterType?: string;
|
||||
}): boolean {
|
||||
if (filter.type === 'blank') {
|
||||
return rawVal == null || rawVal === '';
|
||||
}
|
||||
if (filter.type === 'notBlank') {
|
||||
return rawVal != null && rawVal !== '';
|
||||
}
|
||||
if (filter.filter == null && filter.filterTo == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Number comparison if filter is numeric or value is numeric
|
||||
if (typeof filter.filter === 'number' || filter.filterType === 'number' || typeof rawVal === 'number') {
|
||||
const val = Number(rawVal);
|
||||
const target = Number(filter.filter);
|
||||
if (isNaN(val)) return false;
|
||||
|
||||
switch (filter.type) {
|
||||
case 'equals': return val === target;
|
||||
case 'notEqual': return val !== target;
|
||||
case 'greaterThan': return val > target;
|
||||
case 'greaterThanOrEqual': return val >= target;
|
||||
case 'lessThan': return val < target;
|
||||
case 'lessThanOrEqual': return val <= target;
|
||||
case 'inRange': {
|
||||
const to = Number(filter.filterTo ?? target);
|
||||
return val >= target && val <= to;
|
||||
}
|
||||
default: return val === target;
|
||||
}
|
||||
}
|
||||
|
||||
// String comparison
|
||||
const strVal = String(rawVal ?? '').toLowerCase();
|
||||
const filterText = String(filter.filter).toLowerCase().trim();
|
||||
|
||||
switch (filter.type) {
|
||||
case 'equals': return strVal === filterText;
|
||||
case 'notEqual': return strVal !== filterText;
|
||||
case 'startsWith': return strVal.startsWith(filterText);
|
||||
case 'endsWith': return strVal.endsWith(filterText);
|
||||
case 'contains':
|
||||
default:
|
||||
return strVal.includes(filterText);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simulated server-side sort & filter + paginated fetch with artificial latency. */
|
||||
async function fetchProducts(params: GridFetchParams): Promise<GridFetchResult<ProductRow>> {
|
||||
// Simulate network latency (40-100ms)
|
||||
await new Promise((r) => setTimeout(r, 40 + Math.random() * 60));
|
||||
|
||||
let data = [...ALL_PRODUCTS];
|
||||
|
||||
// 1. Server-side sorting
|
||||
if (params.sortModel.length > 0) {
|
||||
const sorts = params.sortModel;
|
||||
data.sort((a, b) => {
|
||||
for (const sort of sorts) {
|
||||
const dir = sort.sort === 'asc' ? 1 : -1;
|
||||
const aVal = a[sort.colId as keyof ProductRow];
|
||||
const bVal = b[sort.colId as keyof ProductRow];
|
||||
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
if (aVal !== bVal) return (aVal - bVal) * dir;
|
||||
} else {
|
||||
const aStr = String(aVal ?? '');
|
||||
const bStr = String(bVal ?? '');
|
||||
const cmp = aStr.localeCompare(bStr, undefined, { numeric: true });
|
||||
if (cmp !== 0) return cmp * dir;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Server-side filtering
|
||||
const filterKeys = Object.keys(params.filterModel);
|
||||
if (filterKeys.length > 0) {
|
||||
for (const key of filterKeys) {
|
||||
const model = params.filterModel[key] as {
|
||||
operator?: 'AND' | 'OR';
|
||||
conditions?: Array<{ filter?: string | number; filterTo?: number; type?: string; filterType?: string }>;
|
||||
filter?: string | number;
|
||||
filterTo?: number;
|
||||
type?: string;
|
||||
filterType?: string;
|
||||
};
|
||||
|
||||
if (model.conditions && model.conditions.length > 0) {
|
||||
data = data.filter((row) => {
|
||||
const rawVal = row[key as keyof ProductRow];
|
||||
if (model.operator === 'OR') {
|
||||
return model.conditions!.some((cond) => matchesCondition(rawVal, cond));
|
||||
}
|
||||
return model.conditions!.every((cond) => matchesCondition(rawVal, cond));
|
||||
});
|
||||
} else if (model.filter != null || model.type === 'blank' || model.type === 'notBlank') {
|
||||
data = data.filter((row) => {
|
||||
const rawVal = row[key as keyof ProductRow];
|
||||
return matchesCondition(rawVal, model);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = data.slice(params.startRow, params.endRow);
|
||||
const lastRow = data.length;
|
||||
|
||||
return { rows, lastRow };
|
||||
}
|
||||
|
||||
// ─── Grid automat instance ───────────────────────────────────────
|
||||
|
||||
export const demoGridAutomat = createGridAutomat<ProductRow>({
|
||||
fetchRows: fetchProducts,
|
||||
name: 'demoGrid',
|
||||
blockSize: 100,
|
||||
});
|
||||
429
src/index.css
Normal file
429
src/index.css
Normal file
@@ -0,0 +1,429 @@
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-nav-tabs, .app-split-tabs, .code-viewer-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav-tab-btn, .split-tab-btn, .code-tab-btn, .demo-filter-btn, .demo-sort-btn, .demo-reload-btn, .demo-reset-all-btn {
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-tab-btn.active, .split-tab-btn.active, .code-tab-btn.active, .demo-filter-btn.active, .demo-sort-btn.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.app-viewport, .split-right-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.split-view-container {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.split-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.split-pane-right {
|
||||
border-left: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.split-right-header {
|
||||
padding: 6px 12px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.demo-container, .demo-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.demo-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.demo-header-left, .demo-header-right, .demo-stats, .demo-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.demo-toolbar-row, .demo-automat-state-bar, .code-file-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.demo-search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.demo-preset-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.demo-grid-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.doc-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.doc-sidebar {
|
||||
width: 220px;
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid #ccc;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.doc-toc {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.doc-toc-item {
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.doc-toc-item.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.doc-content-container, .doc-content-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 32px;
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
max-width: 900px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-content h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px 0;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.doc-content h2 {
|
||||
font-size: 18px;
|
||||
margin: 24px 0 12px 0;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.doc-content h3 {
|
||||
font-size: 15px;
|
||||
margin: 16px 0 8px 0;
|
||||
}
|
||||
|
||||
.doc-content p {
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.doc-content ul, .doc-content ol {
|
||||
margin: 0 0 12px 20px;
|
||||
}
|
||||
|
||||
.doc-content li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-content code {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
background: #f0f0f0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.doc-code-block {
|
||||
margin: 12px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.doc-code-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f4f4f4;
|
||||
padding: 4px 10px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.doc-code-copy {
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.doc-code-block pre {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow-x: auto;
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.doc-table-wrapper {
|
||||
overflow-x: auto;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.doc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.doc-table th, .doc-table td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 6px 10px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.doc-table th {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.code-viewer-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-viewer-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
background: #f8f9fa;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.code-tabs-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.code-tabs-label {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.code-tab-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.code-tab-btn.active {
|
||||
background: #0066cc;
|
||||
color: #fff;
|
||||
border-color: #0066cc;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.code-file-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 6px 12px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.code-file-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.code-file-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: #eee;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.code-file-name {
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.code-file-desc {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.code-file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.code-file-lines {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code-copy-full-btn {
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.code-editor-layout {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: #fdfdfd;
|
||||
}
|
||||
|
||||
.code-line-numbers {
|
||||
padding: 10px 8px;
|
||||
border-right: 1px solid #e0e0e0;
|
||||
background: #f7f7f7;
|
||||
user-select: none;
|
||||
text-align: right;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #999;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
min-width: 45px;
|
||||
}
|
||||
|
||||
.code-line-number {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.code-syntax-area, .code-pre-container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
background: transparent;
|
||||
white-space: pre;
|
||||
}
|
||||
393
src/lib/AutomatGrid.tsx
Normal file
393
src/lib/AutomatGrid.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* AutomatGrid.tsx
|
||||
*
|
||||
* Opinionated, fully-virtualized AG Grid component powered by react-automat.
|
||||
*
|
||||
* This is a React PureComponent that:
|
||||
* - Subscribes to a GridAutomat store for reactive state (loading, revision, sort, filter)
|
||||
* - Wires the automat's fetcher into AG Grid's infinite-scroll IDatasource
|
||||
* - Automatically resets the datasource when the automat's revision bumps
|
||||
* - Exposes the AG Grid API ref via `onGridApiReady` callback prop
|
||||
*
|
||||
* Usage:
|
||||
* import { AutomatGrid } from 'react-virtual-aggridautomat';
|
||||
* import { createGridAutomat } from 'react-virtual-aggridautomat';
|
||||
*
|
||||
* const gridAutomat = createGridAutomat({ fetchRows: myFetcher });
|
||||
*
|
||||
* class MyView extends PureComponent {
|
||||
* render() {
|
||||
* return <AutomatGrid automat={gridAutomat} columnDefs={cols} />;
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import type { RefObject } from 'react';
|
||||
import {
|
||||
type ColDef,
|
||||
type GridApi,
|
||||
type GridReadyEvent,
|
||||
type IDatasource,
|
||||
type IGetRowsParams,
|
||||
type SortChangedEvent,
|
||||
type FilterChangedEvent,
|
||||
type GetRowIdParams,
|
||||
type Module,
|
||||
InfiniteRowModelModule,
|
||||
TextFilterModule,
|
||||
NumberFilterModule,
|
||||
CustomFilterModule,
|
||||
DateFilterModule,
|
||||
CellStyleModule,
|
||||
ColumnApiModule,
|
||||
ColumnAutoSizeModule,
|
||||
} from 'ag-grid-community';
|
||||
import { AgGridProvider, AgGridReact } from 'ag-grid-react';
|
||||
import type { Automat } from 'react-automat';
|
||||
import type { GridAutomatState, GridAutomatActions, GridFetchParams, SortModelItem } from './createGridAutomat';
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────
|
||||
|
||||
export interface AutomatGridProps<TRow = Record<string, unknown>> {
|
||||
/** The GridAutomat store instance created by createGridAutomat(). */
|
||||
automat: Automat<GridAutomatState<TRow>, GridAutomatActions>;
|
||||
|
||||
/** AG Grid column definitions. */
|
||||
columnDefs: ColDef<TRow>[];
|
||||
|
||||
/** Default column definition applied to all columns. */
|
||||
defaultColDef?: ColDef<TRow>;
|
||||
|
||||
/** Row ID field name for stable infinite-scroll selection. Defaults to 'id'. */
|
||||
rowIdField?: string;
|
||||
|
||||
/** Cache block size override (defaults to automat's _blockSize). */
|
||||
cacheBlockSize?: number;
|
||||
|
||||
/** Number of rows rendered outside the visible area. Default 0 (optimal for virtualization). */
|
||||
rowBuffer?: number;
|
||||
|
||||
/** Initial row count hint for the scrollbar. Default 200. */
|
||||
infiniteInitialRowCount?: number;
|
||||
|
||||
/** Max concurrent datasource requests. Default 2. */
|
||||
maxConcurrentDatasourceRequests?: number;
|
||||
|
||||
/** Max blocks to keep in the cache. Default 10. */
|
||||
maxBlocksInCache?: number;
|
||||
|
||||
/** Debounce (ms) before loading blocks on scroll. Default 100. */
|
||||
blockLoadDebounceMillis?: number;
|
||||
|
||||
/** CSS height for the grid container. Default '100%'. */
|
||||
height?: string | number;
|
||||
|
||||
/** CSS width for the grid container. Default '100%'. */
|
||||
width?: string | number;
|
||||
|
||||
/** Additional AG Grid theme CSS class. Defaults to 'ag-theme-alpine ag-theme-alpine-dark'. */
|
||||
theme?: string;
|
||||
|
||||
/** Optional AG Grid modules to register. Defaults to InfiniteRowModelModule + Text/Number/Custom/Date filters. */
|
||||
modules?: Module[];
|
||||
|
||||
/** Called once when the AG Grid API is available. */
|
||||
onGridApiReady?: (api: GridApi<TRow>) => void;
|
||||
|
||||
/** Row height in pixels. Default undefined (uses AG Grid default of 42). */
|
||||
rowHeight?: number;
|
||||
|
||||
/** Header height in pixels. */
|
||||
headerHeight?: number;
|
||||
|
||||
/** Additional AG Grid props passed through. */
|
||||
gridOptions?: Record<string, unknown>;
|
||||
|
||||
/** Whether to suppress horizontal scrollbar. */
|
||||
suppressHorizontalScroll?: boolean;
|
||||
|
||||
/** Whether to animate rows on sort change. */
|
||||
animateRows?: boolean;
|
||||
|
||||
/** Row selection mode. */
|
||||
rowSelection?: 'single' | 'multiple';
|
||||
|
||||
/** Additional class for the grid container div. */
|
||||
className?: string;
|
||||
|
||||
/** Inline style for the grid container div. */
|
||||
style?: React.CSSProperties;
|
||||
|
||||
/** Loading overlay component or text. */
|
||||
loadingOverlayText?: string;
|
||||
|
||||
/** No rows overlay text. */
|
||||
noRowsOverlayText?: string;
|
||||
}
|
||||
|
||||
// ─── Internal state (from automat subscription) ──────────────────
|
||||
|
||||
interface AutomatGridState {
|
||||
loading: boolean;
|
||||
ready: boolean;
|
||||
revision: number;
|
||||
error: string | null;
|
||||
totalRows: number;
|
||||
sortModel: SortModelItem[];
|
||||
filterModel: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ─── Modules ─────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_GRID_MODULES: Module[] = [
|
||||
InfiniteRowModelModule,
|
||||
TextFilterModule,
|
||||
NumberFilterModule,
|
||||
CustomFilterModule,
|
||||
DateFilterModule,
|
||||
CellStyleModule,
|
||||
ColumnApiModule,
|
||||
ColumnAutoSizeModule,
|
||||
];
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────
|
||||
|
||||
export class AutomatGrid<TRow = Record<string, unknown>> extends PureComponent<
|
||||
AutomatGridProps<TRow>,
|
||||
AutomatGridState
|
||||
> {
|
||||
private unsub: (() => void) | null = null;
|
||||
private gridApiRef: RefObject<GridApi<TRow> | null> = createRef<GridApi<TRow>>();
|
||||
private isUpdatingFromGrid = false;
|
||||
|
||||
constructor(props: AutomatGridProps<TRow>) {
|
||||
super(props);
|
||||
|
||||
// Sync read from automat in constructor — zero blank flash
|
||||
const s = props.automat.state;
|
||||
this.state = {
|
||||
loading: s.loading,
|
||||
ready: s.ready,
|
||||
revision: s.revision,
|
||||
error: s.error,
|
||||
totalRows: s.totalRows,
|
||||
sortModel: s.sortModel,
|
||||
filterModel: s.filterModel,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
// Subscribe with a selector that extracts fields we react to
|
||||
this.unsub = this.props.automat.subscribe(
|
||||
this as unknown as import('react').PureComponent,
|
||||
(state: GridAutomatState<TRow>) => ({
|
||||
loading: state.loading,
|
||||
ready: state.ready,
|
||||
revision: state.revision,
|
||||
error: state.error,
|
||||
totalRows: state.totalRows,
|
||||
sortModel: state.sortModel,
|
||||
filterModel: state.filterModel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(_prevProps: AutomatGridProps<TRow>, prevState: AutomatGridState): void {
|
||||
const api = this.gridApiRef.current;
|
||||
|
||||
// When revision changes, reset the AG Grid datasource to force a refetch
|
||||
if (this.state.revision !== prevState.revision) {
|
||||
if (api && !this.isUpdatingFromGrid) {
|
||||
// Sync external sort model if changed outside grid
|
||||
if (this.state.sortModel !== prevState.sortModel) {
|
||||
const currentCols = api.getColumnState();
|
||||
const targetCols = currentCols.map((c) => {
|
||||
const match = this.state.sortModel.find((s) => s.colId === c.colId);
|
||||
return {
|
||||
colId: c.colId,
|
||||
sort: match ? match.sort : null,
|
||||
};
|
||||
});
|
||||
api.applyColumnState({ state: targetCols });
|
||||
}
|
||||
|
||||
// Sync external filter model if changed outside grid
|
||||
if (this.state.filterModel !== prevState.filterModel) {
|
||||
api.setFilterModel(this.state.filterModel);
|
||||
}
|
||||
}
|
||||
|
||||
this.applyDatasource();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.unsub?.();
|
||||
this.unsub = null;
|
||||
}
|
||||
|
||||
// ─── AG Grid datasource wiring ──────────────────────────────
|
||||
|
||||
private buildDatasource(): IDatasource {
|
||||
const { automat } = this.props;
|
||||
const fetcher = automat.state._fetcher;
|
||||
const actions = automat.actions;
|
||||
|
||||
return {
|
||||
getRows: (params: IGetRowsParams) => {
|
||||
const fetchParams: GridFetchParams = {
|
||||
startRow: params.startRow,
|
||||
endRow: params.endRow,
|
||||
sortModel: params.sortModel?.length ? params.sortModel : automat.state.sortModel,
|
||||
filterModel: Object.keys(params.filterModel ?? {}).length ? params.filterModel : automat.state.filterModel,
|
||||
};
|
||||
|
||||
actions._fetchStarted();
|
||||
|
||||
fetcher(fetchParams)
|
||||
.then((result) => {
|
||||
const lastRow =
|
||||
result.lastRow != null && result.lastRow >= 0
|
||||
? result.lastRow
|
||||
: undefined;
|
||||
params.successCallback(result.rows, lastRow);
|
||||
actions._fetchCompleted(lastRow ?? -1);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
params.failCallback();
|
||||
actions._fetchFailed(err?.message ?? 'Fetch failed');
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private applyDatasource(): void {
|
||||
const api = this.gridApiRef.current;
|
||||
if (api) {
|
||||
api.setGridOption('datasource', this.buildDatasource());
|
||||
}
|
||||
}
|
||||
|
||||
private handleGridReady = (event: GridReadyEvent<TRow>): void => {
|
||||
(this.gridApiRef as { current: GridApi<TRow> | null }).current = event.api;
|
||||
this.applyDatasource();
|
||||
this.props.onGridApiReady?.(event.api);
|
||||
};
|
||||
|
||||
private handleSortChanged = (event: SortChangedEvent<TRow>): void => {
|
||||
if (this.isUpdatingFromGrid) return;
|
||||
const sortModel = event.api.getColumnState()
|
||||
.filter((c) => c.sort != null)
|
||||
.map((c) => ({ colId: c.colId, sort: c.sort! }));
|
||||
this.isUpdatingFromGrid = true;
|
||||
try {
|
||||
this.props.automat.actions.setSort(sortModel);
|
||||
} finally {
|
||||
this.isUpdatingFromGrid = false;
|
||||
}
|
||||
};
|
||||
|
||||
private handleFilterChanged = (event: FilterChangedEvent<TRow>): void => {
|
||||
if (this.isUpdatingFromGrid) return;
|
||||
const filterModel = event.api.getFilterModel();
|
||||
this.isUpdatingFromGrid = true;
|
||||
try {
|
||||
this.props.automat.actions.setFilter(filterModel as Record<string, unknown>);
|
||||
} finally {
|
||||
this.isUpdatingFromGrid = false;
|
||||
}
|
||||
};
|
||||
|
||||
private getRowId = (params: GetRowIdParams<TRow>): string => {
|
||||
const field = this.props.rowIdField ?? 'id';
|
||||
const data = params.data as Record<string, unknown>;
|
||||
return data[field] != null ? String(data[field]) : String(params.level);
|
||||
};
|
||||
|
||||
// ─── Render ──────────────────────────────────────────────────
|
||||
|
||||
render() {
|
||||
const {
|
||||
columnDefs,
|
||||
defaultColDef,
|
||||
cacheBlockSize,
|
||||
rowBuffer = 0,
|
||||
infiniteInitialRowCount = 200,
|
||||
maxConcurrentDatasourceRequests = 2,
|
||||
maxBlocksInCache = 10,
|
||||
blockLoadDebounceMillis = 100,
|
||||
height = '100%',
|
||||
width = '100%',
|
||||
rowHeight,
|
||||
headerHeight,
|
||||
suppressHorizontalScroll,
|
||||
animateRows,
|
||||
className,
|
||||
style,
|
||||
loadingOverlayText,
|
||||
noRowsOverlayText,
|
||||
automat,
|
||||
} = this.props;
|
||||
|
||||
const effectiveBlockSize = cacheBlockSize ?? automat.state._blockSize ?? 100;
|
||||
|
||||
const mergedDefaultColDef: ColDef<TRow> = {
|
||||
flex: 1,
|
||||
minWidth: 100,
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
...defaultColDef,
|
||||
};
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
height,
|
||||
width,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
...style,
|
||||
};
|
||||
|
||||
const themeClass = this.props.theme ?? 'ag-theme-alpine';
|
||||
const effectiveModules = this.props.modules ?? DEFAULT_GRID_MODULES;
|
||||
|
||||
return (
|
||||
<AgGridProvider modules={effectiveModules}>
|
||||
<div className={`${themeClass} ${className ?? ''}`} style={containerStyle}>
|
||||
<AgGridReact<TRow>
|
||||
theme="legacy"
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={mergedDefaultColDef}
|
||||
rowModelType="infinite"
|
||||
cacheBlockSize={effectiveBlockSize}
|
||||
cacheOverflowSize={2}
|
||||
rowBuffer={rowBuffer}
|
||||
infiniteInitialRowCount={infiniteInitialRowCount}
|
||||
maxConcurrentDatasourceRequests={maxConcurrentDatasourceRequests}
|
||||
maxBlocksInCache={maxBlocksInCache}
|
||||
blockLoadDebounceMillis={blockLoadDebounceMillis}
|
||||
rowHeight={rowHeight}
|
||||
headerHeight={headerHeight}
|
||||
suppressHorizontalScroll={suppressHorizontalScroll}
|
||||
animateRows={animateRows}
|
||||
getRowId={this.getRowId}
|
||||
onGridReady={this.handleGridReady}
|
||||
onSortChanged={this.handleSortChanged}
|
||||
onFilterChanged={this.handleFilterChanged}
|
||||
overlayLoadingTemplate={
|
||||
loadingOverlayText
|
||||
? `<span class="ag-overlay-loading-center">${loadingOverlayText}</span>`
|
||||
: undefined
|
||||
}
|
||||
overlayNoRowsTemplate={
|
||||
noRowsOverlayText
|
||||
? `<span class="ag-overlay-no-rows-center">${noRowsOverlayText}</span>`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</AgGridProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
159
src/lib/createGridAutomat.ts
Normal file
159
src/lib/createGridAutomat.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* createGridAutomat.ts
|
||||
*
|
||||
* Factory that creates a react-automat store to back an AG Grid with
|
||||
* infinite-scroll (virtual) row loading. The automat owns the grid's
|
||||
* data lifecycle — fetching pages, caching blocks, tracking sort/filter,
|
||||
* and exposing reload/invalidate actions.
|
||||
*
|
||||
* Usage:
|
||||
* const gridAutomat = createGridAutomat({ fetchRows, name: 'myGrid' });
|
||||
*
|
||||
* The returned automat is consumed by <AutomatGrid />.
|
||||
*/
|
||||
import { Automat } from 'react-automat';
|
||||
import type { SortModelItem } from 'ag-grid-community';
|
||||
export type { SortModelItem } from 'ag-grid-community';
|
||||
|
||||
// ─── Public types ────────────────────────────────────────────────
|
||||
|
||||
/** The shape of a single page request sent to the consumer's fetch function. */
|
||||
export interface GridFetchParams {
|
||||
startRow: number;
|
||||
endRow: number;
|
||||
sortModel: SortModelItem[];
|
||||
filterModel: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Consumer-provided async function that returns a page of data. */
|
||||
export interface GridFetchResult<TRow = Record<string, unknown>> {
|
||||
rows: TRow[];
|
||||
/** Total row count. Pass `undefined` / `-1` if unknown (infinite). */
|
||||
lastRow?: number;
|
||||
}
|
||||
|
||||
export type GridFetcher<TRow = Record<string, unknown>> = (
|
||||
params: GridFetchParams,
|
||||
) => Promise<GridFetchResult<TRow>>;
|
||||
|
||||
/** Configuration for createGridAutomat(). */
|
||||
export interface GridAutomatOptions<TRow = Record<string, unknown>> {
|
||||
/** Async data fetcher invoked by the AG Grid datasource. */
|
||||
fetchRows: GridFetcher<TRow>;
|
||||
/** Optional automat registry name (visible in window.__AUTOMATS__). */
|
||||
name?: string;
|
||||
/** Cache block size passed to AG Grid's `cacheBlockSize`. Default 100. */
|
||||
blockSize?: number;
|
||||
}
|
||||
|
||||
// ─── State shape ─────────────────────────────────────────────────
|
||||
|
||||
export interface GridAutomatState<TRow = Record<string, unknown>> {
|
||||
/** Whether the very first block has finished loading. */
|
||||
ready: boolean;
|
||||
/** True while any block request is in-flight. */
|
||||
loading: boolean;
|
||||
/** Total known row count (may be -1 / undefined while streaming). */
|
||||
totalRows: number;
|
||||
/** Last-applied sort model (mirrors AG Grid). */
|
||||
sortModel: SortModelItem[];
|
||||
/** Last-applied filter model (mirrors AG Grid). */
|
||||
filterModel: Record<string, unknown>;
|
||||
/** An incrementing counter — bumped on reload() to force AG Grid datasource reset. */
|
||||
revision: number;
|
||||
/** Optional error from the last failed fetch. */
|
||||
error: string | null;
|
||||
/** The fetcher — stored in state so AutomatGrid can read it. */
|
||||
_fetcher: GridFetcher<TRow>;
|
||||
/** Block size config. */
|
||||
_blockSize: number;
|
||||
}
|
||||
|
||||
export interface GridAutomatActions {
|
||||
/** Force a full reload (clears cache, bumps revision). */
|
||||
reload: () => void;
|
||||
/** Update the sort model externally (triggers reload). */
|
||||
setSort: (sortModel: SortModelItem[]) => void;
|
||||
/** Update the filter model externally (triggers reload). */
|
||||
setFilter: (filterModel: Record<string, unknown>) => void;
|
||||
/** Mark a fetch as started (called internally by the datasource adapter). */
|
||||
_fetchStarted: () => void;
|
||||
/** Mark a fetch as completed (called internally by the datasource adapter). */
|
||||
_fetchCompleted: (totalRows: number) => void;
|
||||
/** Mark a fetch as failed. */
|
||||
_fetchFailed: (error: string) => void;
|
||||
}
|
||||
|
||||
// ─── Factory ─────────────────────────────────────────────────────
|
||||
|
||||
export function createGridAutomat<TRow = Record<string, unknown>>(
|
||||
options: GridAutomatOptions<TRow>,
|
||||
): Automat<GridAutomatState<TRow>, GridAutomatActions> {
|
||||
const { fetchRows, name, blockSize = 100 } = options;
|
||||
|
||||
const initialState: GridAutomatState<TRow> = {
|
||||
ready: false,
|
||||
loading: false,
|
||||
totalRows: -1,
|
||||
sortModel: [],
|
||||
filterModel: {},
|
||||
revision: 0,
|
||||
error: null,
|
||||
_fetcher: fetchRows,
|
||||
_blockSize: blockSize,
|
||||
};
|
||||
|
||||
const automat: Automat<GridAutomatState<TRow>, GridAutomatActions> = new Automat(
|
||||
initialState,
|
||||
{
|
||||
reload() {
|
||||
automat.setState((prev) => ({
|
||||
revision: prev.revision + 1,
|
||||
ready: false,
|
||||
loading: false,
|
||||
totalRows: -1,
|
||||
error: null,
|
||||
}));
|
||||
},
|
||||
|
||||
setSort(sortModel: SortModelItem[]) {
|
||||
automat.setState((prev) => ({
|
||||
sortModel,
|
||||
revision: prev.revision + 1,
|
||||
ready: false,
|
||||
loading: false,
|
||||
totalRows: -1,
|
||||
error: null,
|
||||
}));
|
||||
},
|
||||
|
||||
setFilter(filterModel: Record<string, unknown>) {
|
||||
automat.setState((prev) => ({
|
||||
filterModel,
|
||||
revision: prev.revision + 1,
|
||||
ready: false,
|
||||
loading: false,
|
||||
totalRows: -1,
|
||||
error: null,
|
||||
}));
|
||||
},
|
||||
|
||||
_fetchStarted() {
|
||||
automat.setState({ loading: true, error: null });
|
||||
},
|
||||
|
||||
_fetchCompleted(totalRows: number) {
|
||||
automat.setState({ loading: false, ready: true, totalRows });
|
||||
},
|
||||
|
||||
_fetchFailed(error: string) {
|
||||
automat.setState({ loading: false, error });
|
||||
},
|
||||
} as GridAutomatActions,
|
||||
{
|
||||
name: name ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
return automat;
|
||||
}
|
||||
20
src/lib/index.ts
Normal file
20
src/lib/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Library barrel export.
|
||||
*
|
||||
* Consumers import from 'react-virtual-aggridautomat':
|
||||
* import { AutomatGrid, createGridAutomat } from 'react-virtual-aggridautomat';
|
||||
*/
|
||||
|
||||
export { AutomatGrid } from './AutomatGrid';
|
||||
export type { AutomatGridProps } from './AutomatGrid';
|
||||
|
||||
export { createGridAutomat } from './createGridAutomat';
|
||||
export type {
|
||||
GridAutomatOptions,
|
||||
GridAutomatState,
|
||||
GridAutomatActions,
|
||||
GridFetchParams,
|
||||
GridFetchResult,
|
||||
GridFetcher,
|
||||
SortModelItem,
|
||||
} from './createGridAutomat';
|
||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import 'ag-grid-community/styles/ag-grid.css';
|
||||
import 'ag-grid-community/styles/ag-theme-alpine.css';
|
||||
import 'prismjs/themes/prism.css';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
tsconfig.node.json
Normal file
23
tsconfig.node.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// The git HEAD of react-automat doesn't ship pre-built dist/.
|
||||
// Resolve directly to the ESM source so Vite bundles it.
|
||||
'react-automat': path.resolve(import.meta.dirname, 'node_modules/react-automat/src/lib/index.js'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user