diff --git a/src/pagination/BasePaginator.ts b/src/pagination/BasePaginator.ts index 7f73f0f53b..fe8e5d499d 100644 --- a/src/pagination/BasePaginator.ts +++ b/src/pagination/BasePaginator.ts @@ -1,9 +1,13 @@ import { StateStore } from '../store'; import { debounce, type DebouncedFunc } from '../utils'; -type PaginationDirection = 'next' | 'prev'; -type Cursor = { next: string | null; prev: string | null }; -export type PaginationQueryParams = { direction: PaginationDirection }; +type PaginationDirection = PaginationDirectionNext | PaginationDirectionPrev; +type PaginationDirectionPrev = 'prev'; +type PaginationDirectionNext = 'next'; +type Cursor = { next?: string; prev?: string }; +export type PaginationQueryParams = + | { direction: PaginationDirectionNext; next?: Cursor['next']; offset?: number } + | { direction: PaginationDirectionPrev; prev?: Cursor['prev']; offset?: number }; export type PaginationQueryReturnValue = { items: T[] } & { next?: string; prev?: string; @@ -24,6 +28,7 @@ export type PaginatorState = { lastQueryError?: Error; cursor?: Cursor; offset?: number; + isStateValid: boolean; }; export type PaginatorOptions = { @@ -40,12 +45,16 @@ export abstract class BasePaginator { state: StateStore>; pageSize: number; protected _executeQueryDebounced!: DebouncedExecQueryFunction; - protected _isCursorPagination = false; + // in cases where particular combination of filters would return only one item, the cursors + // (`next`/`prev`) won't be included in the response - in such cases it's better to build + // BasePaginator inheritors with this value already pre-defined + protected abstract _isCursorPagination: boolean; protected constructor(options?: PaginatorOptions) { const { debounceMs, pageSize } = { ...DEFAULT_PAGINATION_OPTIONS, ...options }; this.pageSize = pageSize; this.state = new StateStore>(this.initialState); + this.setDebounceOptions({ debounceMs }); } @@ -78,6 +87,7 @@ export abstract class BasePaginator { lastQueryError: undefined, cursor: undefined, offset: 0, + isStateValid: true, }; } @@ -85,6 +95,10 @@ export abstract class BasePaginator { return this.state.getLatestValue().items; } + get isStateValid() { + return this.state.getLatestValue().isStateValid; + } + get cursor() { return this.state.getLatestValue().cursor; } @@ -102,8 +116,10 @@ export abstract class BasePaginator { }; canExecuteQuery = (direction: PaginationDirection) => - (!this.isLoading && direction === 'next' && this.hasNext) || - (direction === 'prev' && this.hasPrev); + !this.isLoading && + ((direction === 'next' && this.hasNext) || + (direction === 'prev' && this.hasPrev) || + !this.isStateValid); protected getStateBeforeFirstQuery(): PaginatorState { return { @@ -124,30 +140,45 @@ export abstract class BasePaginator { isLoading: false, items: isFirstPage ? stateUpdate.items - : [...(this.items ?? []), ...(stateUpdate.items || [])], + : [...(current.items ?? []), ...(stateUpdate.items ?? [])], }; } async executeQuery({ direction }: { direction: PaginationDirection }) { if (!this.canExecuteQuery(direction)) return; - const isFirstPage = typeof this.items === 'undefined'; - if (isFirstPage) { - this.state.next(this.getStateBeforeFirstQuery()); - } else { - this.state.partialNext({ isLoading: true }); - } - const stateUpdate: Partial> = {}; + const isFirstPage = typeof this.items === 'undefined' || !this.isStateValid; + + this.state.partialNext({ isLoading: true }); + + const stateUpdate: Partial> = isFirstPage + ? this.getStateBeforeFirstQuery() + : {}; + try { - const results = await this.query({ direction }); + const queryParams: PaginationQueryParams = { direction }; + + if (!isFirstPage) { + if (this._isCursorPagination) { + // @ts-expect-error this is perfectly valid + queryParams[queryParams.direction] = this.cursor?.[queryParams.direction]; + } else { + queryParams['offset'] = this.offset; + } + } + + const results = await this.query(queryParams); + if (!results) return; + const { items, next, prev } = results; + if (isFirstPage && (next || prev)) { this._isCursorPagination = true; } if (this._isCursorPagination) { - stateUpdate.cursor = { next: next || null, prev: prev || null }; + stateUpdate.cursor = { next, prev }; stateUpdate.hasNext = !!next; stateUpdate.hasPrev = !!prev; } else { @@ -156,8 +187,8 @@ export abstract class BasePaginator { } stateUpdate.items = await this.filterQueryResults(items); - } catch (e) { - stateUpdate.lastQueryError = e as Error; + } catch (error) { + stateUpdate.lastQueryError = error as Error; } finally { this.state.next(this.getStateAfterQuery(stateUpdate, isFirstPage)); } @@ -181,4 +212,8 @@ export abstract class BasePaginator { prevDebounced = () => { this._executeQueryDebounced({ direction: 'prev' }); }; + + invalidate = () => { + this.state.partialNext({ isStateValid: false }); + }; } diff --git a/src/pagination/ReminderPaginator.ts b/src/pagination/ReminderPaginator.ts index ff81b5dc91..5f53fc9213 100644 --- a/src/pagination/ReminderPaginator.ts +++ b/src/pagination/ReminderPaginator.ts @@ -11,6 +11,7 @@ export class ReminderPaginator extends BasePaginator { private client: StreamChat; protected _filters: ReminderFilters | undefined; protected _sort: ReminderSort | undefined; + protected _isCursorPagination = true; get filters(): ReminderFilters | undefined { return this._filters; diff --git a/test/unit/pagination/BasePaginator.test.ts b/test/unit/pagination/BasePaginator.test.ts index 1f988e22e2..de4d20e8d0 100644 --- a/test/unit/pagination/BasePaginator.test.ts +++ b/test/unit/pagination/BasePaginator.test.ts @@ -22,6 +22,7 @@ class Paginator extends BasePaginator { queryReject: Function = vi.fn(); queryPromise: Promise> | null = null; mockClientQuery = vi.fn(); + protected _isCursorPagination = false; constructor(options: PaginatorOptions = {}) { super(options); @@ -52,6 +53,7 @@ describe('BasePaginator', () => { hasNext: true, hasPrev: true, isLoading: false, + isStateValid: true, items: undefined, lastQueryError: undefined, cursor: undefined, @@ -66,6 +68,7 @@ describe('BasePaginator', () => { hasNext: true, hasPrev: true, isLoading: false, + isStateValid: true, items: undefined, lastQueryError: undefined, cursor: undefined, @@ -105,7 +108,7 @@ describe('BasePaginator', () => { expect(paginator.hasNext).toBe(false); expect(paginator.hasPrev).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.cursor).toEqual({ next: null, prev: null }); + expect(paginator.cursor).toEqual({ next: undefined, prev: undefined }); paginator.next(); expect(paginator.isLoading).toBe(false); @@ -168,7 +171,7 @@ describe('BasePaginator', () => { expect(paginator.hasNext).toBe(false); expect(paginator.hasPrev).toBe(false); expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]); - expect(paginator.cursor).toEqual({ next: null, prev: null }); + expect(paginator.cursor).toEqual({ next: undefined, prev: undefined }); paginator.prev(); expect(paginator.isLoading).toBe(false);