From 391450f615d9fdcc049c24b372406e00101a58af Mon Sep 17 00:00:00 2001 From: Hein Date: Sun, 15 Feb 2026 22:24:38 +0200 Subject: [PATCH] feat(pagination): add pagination state management and cursor handling --- src/Griddy/adapters/Adapters.stories.tsx | 33 +++++++---- src/Griddy/adapters/HeaderSpecAdapter.tsx | 68 ++++++++++------------ src/Griddy/adapters/ResolveSpecAdapter.tsx | 46 ++++++++------- src/Griddy/adapters/mapOptions.ts | 2 +- src/Griddy/core/Griddy.tsx | 13 ++++- src/Griddy/core/GriddyStore.ts | 7 ++- 6 files changed, 97 insertions(+), 72 deletions(-) diff --git a/src/Griddy/adapters/Adapters.stories.tsx b/src/Griddy/adapters/Adapters.stories.tsx index 8c7b315..f17eb81 100644 --- a/src/Griddy/adapters/Adapters.stories.tsx +++ b/src/Griddy/adapters/Adapters.stories.tsx @@ -158,6 +158,10 @@ const meta = { control: 'object', description: 'Griddy column ID to API column name mapping', }, + cursorField: { + control: 'text', + description: 'Field to extract cursor from (default: "id")', + }, debounceMs: { control: { max: 2000, min: 0, step: 50, type: 'range' }, description: 'Filter change debounce in ms', @@ -166,6 +170,11 @@ const meta = { control: 'text', description: 'Database entity/table name', }, + mode: { + control: 'inline-radio', + description: 'Pagination mode: cursor (infinite scroll) or offset (page controls)', + options: ['cursor', 'offset'], + }, schema: { control: 'text', description: 'Database schema name', @@ -193,7 +202,7 @@ export const ResolveSpec: Story = { baseUrl: 'https://utils.btsys.tech/api', }, - render: (args) => , + render: (args) => , }; /** HeaderSpec adapter — same as ResolveSpec but uses HeaderSpecClient */ @@ -209,7 +218,7 @@ export const HeaderSpec: Story = { token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639', }, - render: (args) => , + render: (args) => , }; /** ResolveSpec with column mapping — remaps Griddy column IDs to different API column names */ @@ -222,7 +231,7 @@ export const WithColumnMap: Story = { name: 'full_name', }, }, - render: (args) => , + render: (args) => , }; /** ResolveSpec with custom debounce — slower debounce for expensive queries */ @@ -230,7 +239,7 @@ export const WithCustomDebounce: Story = { args: { debounceMs: 1000, }, - render: (args) => , + render: (args) => , }; /** ResolveSpec with autoFetch disabled — data only loads on manual refetch */ @@ -238,7 +247,7 @@ export const ManualFetchOnly: Story = { args: { autoFetch: false, }, - render: (args) => , + render: (args) => , }; /** ResolveSpec with default options merged into every request */ @@ -249,7 +258,7 @@ export const WithDefaultOptions: Story = { sort: [{ column: 'name', direction: 'asc' }], }, }, - render: (args) => , + render: (args) => , }; // ─── Cursor / Infinite Scroll Stories ──────────────────────────────────────── @@ -280,7 +289,7 @@ function HeaderSpecInfiniteScrollStory(props: AdapterConfig) { manualFiltering manualSorting > - + ); @@ -361,7 +370,7 @@ export const WithInfiniteScroll: Story = { baseUrl: 'https://utils.btsys.tech/api', }, - render: (args) => , + render: (args) => , }; /** ResolveSpec with explicit cursor pagination config */ @@ -370,7 +379,7 @@ export const WithCursorPagination: Story = { cursorField: 'id', pageSize: 50, }, - render: (args) => , + render: (args) => , }; /** ResolveSpec with offset pagination controls */ @@ -378,7 +387,7 @@ export const WithOffsetPagination: Story = { args: { pageSize: 25, }, - render: (args) => , + render: (args) => , }; /** HeaderSpec adapter with cursor-based infinite scroll */ @@ -389,5 +398,7 @@ export const HeaderSpecInfiniteScroll: Story = { token: ' 773EB99C-F625-4E99-9DB9-CDDA7CA17639', }, - render: (args) => , + render: (args) => ( + + ), }; diff --git a/src/Griddy/adapters/HeaderSpecAdapter.tsx b/src/Griddy/adapters/HeaderSpecAdapter.tsx index 4599bfc..b611d32 100644 --- a/src/Griddy/adapters/HeaderSpecAdapter.tsx +++ b/src/Griddy/adapters/HeaderSpecAdapter.tsx @@ -28,22 +28,19 @@ export const HeaderSpecAdapter = forwardRef( const sorting = useGriddyStore((s) => s.sorting ?? []); const columnFilters = useGriddyStore((s) => s.columnFilters ?? []); const pagination = useGriddyStore((s) => s.pagination); - const _setData = useGriddyStore((s) => s.setData); + const paginationState = useGriddyStore((s) => s.paginationState); + const setData = useGriddyStore((s) => s.setData); const appendData = useGriddyStore((s) => s.appendData); const setDataCount = useGriddyStore((s) => s.setDataCount); const setIsLoading = useGriddyStore((s) => s.setIsLoading); const setError = useGriddyStore((s) => s.setError); const setInfiniteScroll = useGriddyStore((s) => s.setInfiniteScroll); - const setData = (data: any[]) => { - console.log('Set Data', data); - _setData(data); - }; const clientRef = useRef(getHeaderSpecClient({ baseUrl, token })); const debounceRef = useRef>(null); const mountedRef = useRef(true); - // Cursor state (only used in cursor mode) + // Infinite scroll state (cursor mode) const cursorRef = useRef(null); const hasMoreRef = useRef(true); const [cursorLoading, setCursorLoading] = useState(false); @@ -67,14 +64,15 @@ export const HeaderSpecAdapter = forwardRef( setError(null); try { - const paginationState = pagination?.enabled - ? { pageIndex: 0, pageSize: pagination.pageSize } - : undefined; + // Fall back to config when store hasn't synced yet (initial render) + const effectivePagination = + paginationState ?? + (pagination?.enabled ? { pageIndex: 0, pageSize: pagination.pageSize } : undefined); const options = buildOptions( sorting, columnFilters, - paginationState, + effectivePagination, columnMap, defaultOptions ); @@ -84,13 +82,11 @@ export const HeaderSpecAdapter = forwardRef( if (customOperators) options.customOperators = customOperators; const response = await clientRef.current.read(schema, entity, undefined, options); - if (!mountedRef.current) return; - console.log('Fetch data (offset mode) Res', { - response, - }); + if (response.success) { - setData(Array.isArray(response.data) ? response.data : [response.data]); + const rows = Array.isArray(response.data) ? response.data : [response.data]; + setData(rows); if (response.metadata?.total != null) { setDataCount(response.metadata.total); } @@ -110,6 +106,7 @@ export const HeaderSpecAdapter = forwardRef( sorting, columnFilters, pagination, + paginationState, columnMap, defaultOptions, preload, @@ -123,7 +120,7 @@ export const HeaderSpecAdapter = forwardRef( setError, ]); - // ─── Cursor mode fetch ─── + // ─── Cursor mode fetch (uses cursor_forward only) ─── const fetchCursorPage = useCallback( async (cursor: null | string, isAppend: boolean) => { if (!mountedRef.current) return; @@ -148,7 +145,7 @@ export const HeaderSpecAdapter = forwardRef( if (computedColumns) options.computedColumns = computedColumns; if (customOperators) options.customOperators = customOperators; - const cursorOptions = applyCursor(options, cursor, pageSize); + const cursorOptions = applyCursor(options, pageSize, cursor); const response = await clientRef.current.read(schema, entity, undefined, cursorOptions); if (!mountedRef.current) return; @@ -162,18 +159,28 @@ export const HeaderSpecAdapter = forwardRef( setData(rows); } - if (response.metadata?.total != null) { + if (response.metadata?.total) { setDataCount(response.metadata.total); + } else if (response.metadata?.count) { + setDataCount(response.metadata.count); } // Extract cursor from last row if (rows.length > 0) { const lastRow = rows[rows.length - 1]; - cursorRef.current = - lastRow?.[cursorField] != null ? String(lastRow[cursorField]) : null; + if (lastRow?.[cursorField] == null) { + hasMoreRef.current = false; + setError( + new Error( + `Cursor field "${cursorField}" not found in response data. ` + + `Set cursorField to match your data's primary key, or use mode="offset".` + ) + ); + return; + } + cursorRef.current = String(lastRow[cursorField]); } - // Determine hasMore hasMoreRef.current = rows.length >= pageSize; } else if (response.error) { setError(new Error(response.error.message ?? 'Request failed')); @@ -213,13 +220,11 @@ export const HeaderSpecAdapter = forwardRef( ); const fetchNextPage = useCallback(() => { - console.log('Fetch next page', { hasMore: hasMoreRef.current, cursorLoading }); if (!hasMoreRef.current || cursorLoading) return; - fetchCursorPage(cursorRef.current, true); + return fetchCursorPage(cursorRef.current, true); }, [cursorLoading, fetchCursorPage]); const resetAndFetch = useCallback(async () => { - console.log('Reset and fetch', { hasMore: hasMoreRef.current, cursorLoading }); cursorRef.current = null; hasMoreRef.current = true; await fetchCursorPage(null, false); @@ -230,7 +235,6 @@ export const HeaderSpecAdapter = forwardRef( // ─── Infinite scroll config sync (cursor mode only) ─── useEffect(() => { - // Skip infinite scroll if not in cursor mode OR if pagination is explicitly enabled if (mode !== 'cursor' || pagination?.enabled) { setInfiniteScroll(undefined); return; @@ -245,7 +249,6 @@ export const HeaderSpecAdapter = forwardRef( }); }, [mode, pagination?.enabled, cursorLoading, fetchNextPage, setInfiniteScroll]); - // Cleanup infinite scroll on unmount useEffect(() => { return () => { setInfiniteScroll(undefined); @@ -256,7 +259,6 @@ export const HeaderSpecAdapter = forwardRef( const initialFetchDone = useRef(false); useEffect(() => { - console.log('Auto-fetch effect', { autoFetch, initialFetchDone: initialFetchDone.current }); if (autoFetch && !initialFetchDone.current) { initialFetchDone.current = true; fetchData(); @@ -268,7 +270,7 @@ export const HeaderSpecAdapter = forwardRef( const depsKey = mode === 'cursor' ? JSON.stringify({ columnFilters, sorting }) - : JSON.stringify({ columnFilters, pagination, sorting }); + : JSON.stringify({ columnFilters, paginationState, sorting }); if (prevDepsRef.current === null) { prevDepsRef.current = depsKey; @@ -284,16 +286,8 @@ export const HeaderSpecAdapter = forwardRef( return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [sorting, columnFilters, pagination, debounceMs, fetchData, mode]); + }, [sorting, columnFilters, paginationState, debounceMs, fetchData, mode]); - console.log('Render HeaderSpecAdapter', { - sorting, - columnFilters, - pagination, - cursor: cursorRef.current, - hasMore: hasMoreRef.current, - cursorLoading, - }); return null; } ); diff --git a/src/Griddy/adapters/ResolveSpecAdapter.tsx b/src/Griddy/adapters/ResolveSpecAdapter.tsx index bea415f..92d71f7 100644 --- a/src/Griddy/adapters/ResolveSpecAdapter.tsx +++ b/src/Griddy/adapters/ResolveSpecAdapter.tsx @@ -28,6 +28,7 @@ export const ResolveSpecAdapter = forwardRef( const sorting = useGriddyStore((s) => s.sorting ?? []); const columnFilters = useGriddyStore((s) => s.columnFilters ?? []); const pagination = useGriddyStore((s) => s.pagination); + const paginationState = useGriddyStore((s) => s.paginationState); const setData = useGriddyStore((s) => s.setData); const appendData = useGriddyStore((s) => s.appendData); const setDataCount = useGriddyStore((s) => s.setDataCount); @@ -39,12 +40,11 @@ export const ResolveSpecAdapter = forwardRef( const debounceRef = useRef>(null); const mountedRef = useRef(true); - // Cursor state (only used in cursor mode) + // Infinite scroll state (cursor mode) const cursorRef = useRef(null); const hasMoreRef = useRef(true); const [cursorLoading, setCursorLoading] = useState(false); - // Update client if baseUrl/token changes useEffect(() => { clientRef.current = getResolveSpecClient({ baseUrl, token }); }, [baseUrl, token]); @@ -64,14 +64,14 @@ export const ResolveSpecAdapter = forwardRef( setError(null); try { - const paginationState = pagination?.enabled - ? { pageIndex: 0, pageSize: pagination.pageSize } - : undefined; + // Fall back to config when store hasn't synced yet (initial render) + const effectivePagination = paginationState ?? + (pagination?.enabled ? { pageIndex: 0, pageSize: pagination.pageSize } : undefined); const options = buildOptions( sorting, columnFilters, - paginationState, + effectivePagination, columnMap, defaultOptions ); @@ -85,7 +85,8 @@ export const ResolveSpecAdapter = forwardRef( if (!mountedRef.current) return; if (response.success) { - setData(Array.isArray(response.data) ? response.data : [response.data]); + const rows = Array.isArray(response.data) ? response.data : [response.data]; + setData(rows); if (response.metadata?.total != null) { setDataCount(response.metadata.total); } @@ -105,6 +106,7 @@ export const ResolveSpecAdapter = forwardRef( sorting, columnFilters, pagination, + paginationState, columnMap, defaultOptions, preload, @@ -118,7 +120,7 @@ export const ResolveSpecAdapter = forwardRef( setError, ]); - // ─── Cursor mode fetch ─── + // ─── Cursor mode fetch (uses cursor_forward only) ─── const fetchCursorPage = useCallback( async (cursor: null | string, isAppend: boolean) => { if (!mountedRef.current) return; @@ -143,7 +145,7 @@ export const ResolveSpecAdapter = forwardRef( if (computedColumns) options.computedColumns = computedColumns; if (customOperators) options.customOperators = customOperators; - const cursorOptions = applyCursor(options, cursor, pageSize); + const cursorOptions = applyCursor(options, pageSize, cursor); const response = await clientRef.current.read(schema, entity, undefined, cursorOptions); if (!mountedRef.current) return; @@ -157,18 +159,26 @@ export const ResolveSpecAdapter = forwardRef( setData(rows); } - if (response.metadata?.total != null) { + if (response.metadata?.total) { setDataCount(response.metadata.total); + } else if (response.metadata?.count) { + setDataCount(response.metadata.count); } // Extract cursor from last row if (rows.length > 0) { const lastRow = rows[rows.length - 1]; - cursorRef.current = - lastRow?.[cursorField] != null ? String(lastRow[cursorField]) : null; + if (lastRow?.[cursorField] == null) { + hasMoreRef.current = false; + setError(new Error( + `Cursor field "${cursorField}" not found in response data. ` + + `Set cursorField to match your data's primary key, or use mode="offset".` + )); + return; + } + cursorRef.current = String(lastRow[cursorField]); } - // Determine hasMore hasMoreRef.current = rows.length >= pageSize; } else if (response.error) { setError(new Error(response.error.message ?? 'Request failed')); @@ -209,7 +219,7 @@ export const ResolveSpecAdapter = forwardRef( const fetchNextPage = useCallback(() => { if (!hasMoreRef.current || cursorLoading) return; - fetchCursorPage(cursorRef.current, true); + return fetchCursorPage(cursorRef.current, true); }, [cursorLoading, fetchCursorPage]); const resetAndFetch = useCallback(async () => { @@ -223,7 +233,6 @@ export const ResolveSpecAdapter = forwardRef( // ─── Infinite scroll config sync (cursor mode only) ─── useEffect(() => { - // Skip infinite scroll if not in cursor mode OR if pagination is explicitly enabled if (mode !== 'cursor' || pagination?.enabled) { setInfiniteScroll(undefined); return; @@ -238,7 +247,6 @@ export const ResolveSpecAdapter = forwardRef( }); }, [mode, pagination?.enabled, cursorLoading, fetchNextPage, setInfiniteScroll]); - // Cleanup infinite scroll on unmount useEffect(() => { return () => { setInfiniteScroll(undefined); @@ -247,7 +255,6 @@ export const ResolveSpecAdapter = forwardRef( useImperativeHandle(ref, () => ({ refetch: fetchData }), [fetchData]); - // Auto-fetch on mount const initialFetchDone = useRef(false); useEffect(() => { if (autoFetch && !initialFetchDone.current) { @@ -256,13 +263,12 @@ export const ResolveSpecAdapter = forwardRef( } }, [autoFetch, fetchData]); - // Debounced re-fetch on state changes (skip initial) const prevDepsRef = useRef(null); useEffect(() => { const depsKey = mode === 'cursor' ? JSON.stringify({ columnFilters, sorting }) - : JSON.stringify({ columnFilters, pagination, sorting }); + : JSON.stringify({ columnFilters, paginationState, sorting }); if (prevDepsRef.current === null) { prevDepsRef.current = depsKey; @@ -278,7 +284,7 @@ export const ResolveSpecAdapter = forwardRef( return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [sorting, columnFilters, pagination, debounceMs, fetchData, mode]); + }, [sorting, columnFilters, paginationState, debounceMs, fetchData, mode]); return null; } diff --git a/src/Griddy/adapters/mapOptions.ts b/src/Griddy/adapters/mapOptions.ts index 1052e1b..b50f30c 100644 --- a/src/Griddy/adapters/mapOptions.ts +++ b/src/Griddy/adapters/mapOptions.ts @@ -25,7 +25,7 @@ const OPERATOR_MAP: Record = { startsWith: 'startswith', }; -export function applyCursor(opts: Options, cursor: null | string, limit: number): Options { +export function applyCursor(opts: Options, limit: number, cursor?: null | string): Options { const result = { ...opts, limit }; if (cursor) { result.cursor_forward = cursor; diff --git a/src/Griddy/core/Griddy.tsx b/src/Griddy/core/Griddy.tsx index 122da00..8245271 100644 --- a/src/Griddy/core/Griddy.tsx +++ b/src/Griddy/core/Griddy.tsx @@ -91,6 +91,7 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { const manualFiltering = useGriddyStore((s) => s.manualFiltering); const dataCount = useGriddyStore((s) => s.dataCount); const setTable = useGriddyStore((s) => s.setTable); + const setPaginationState = useGriddyStore((s) => s.setPaginationState); const setVirtualizer = useGriddyStore((s) => s.setVirtualizer); const setScrollRef = useGriddyStore((s) => s.setScrollRef); const setFocusedRow = useGriddyStore((s) => s.setFocusedRow); @@ -153,6 +154,13 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { }); }; + // Sync pagination state to store so adapters can read pageIndex/pageSize + useEffect(() => { + if (paginationConfig?.enabled) { + setPaginationState(internalPagination); + } + }, [paginationConfig?.enabled, internalPagination, setPaginationState]); + // Resolve controlled vs uncontrolled const sorting = controlledSorting ?? internalSorting; const setSorting = onSortingChange ?? setInternalSorting; @@ -188,6 +196,7 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { getRowId: (getRowId as any) ?? ((_, index) => String(index)), getSortedRowModel: manualSorting ? undefined : getSortedRowModel(), manualFiltering: manualFiltering ?? false, + manualPagination: paginationConfig?.type === 'offset', manualSorting: manualSorting ?? false, onColumnFiltersChange: setColumnFilters as any, onColumnOrderChange: setColumnOrder, @@ -212,7 +221,9 @@ function GriddyInner({ tableRef }: { tableRef: Ref> }) { sorting, ...(paginationConfig?.enabled ? { pagination: internalPagination } : {}), }, - ...(paginationConfig?.enabled ? { getPaginationRowModel: getPaginationRowModel() } : {}), + ...(paginationConfig?.enabled && paginationConfig.type !== 'offset' + ? { getPaginationRowModel: getPaginationRowModel() } + : {}), columnResizeMode: 'onChange', }); diff --git a/src/Griddy/core/GriddyStore.ts b/src/Griddy/core/GriddyStore.ts index 0a9e27e..6c06a39 100644 --- a/src/Griddy/core/GriddyStore.ts +++ b/src/Griddy/core/GriddyStore.ts @@ -64,18 +64,20 @@ export interface GriddyStoreState extends GriddyUIState { onSortingChange?: (sorting: SortingState) => void; overscan?: number; pagination?: PaginationConfig; + paginationState?: { pageIndex: number; pageSize: number }; persistenceKey?: string; rowHeight?: number; - rowSelection?: RowSelectionState; + rowSelection?: RowSelectionState; search?: SearchConfig; selection?: SelectionConfig; setData: (data: any[]) => void; setDataCount: (count: number) => void; setError: (error: Error | null) => void; - setInfiniteScroll: (config: InfiniteScrollConfig | undefined) => void; + setInfiniteScroll: (config: InfiniteScrollConfig | undefined) => void; setIsLoading: (loading: boolean) => void; + setPaginationState: (state: { pageIndex: number; pageSize: number }) => void; setScrollRef: (el: HTMLDivElement | null) => void; // ─── Internal ref setters ─── setTable: (table: Table) => void; @@ -131,6 +133,7 @@ export const { Provider: GriddyProvider, useStore: useGriddyStore } = createSync setFocusedRow: (index) => set({ focusedRowIndex: index }), setInfiniteScroll: (config) => set({ infiniteScroll: config }), setIsLoading: (loading) => set({ isLoading: loading }), + setPaginationState: (state) => set({ paginationState: state }), setScrollRef: (el) => set({ _scrollRef: el }), setSearchOpen: (open) => set({ isSearchOpen: open }),