This commit is contained in:
seb
2026-10-05 22:29:22 +02:00
commit 705bd6d199
26 changed files with 6095 additions and 0 deletions

393
src/lib/AutomatGrid.tsx Normal file
View 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>
);
}
}

View 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
View 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';