Genesis
This commit is contained in:
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';
|
||||
Reference in New Issue
Block a user