Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 53 additions & 18 deletions src/pagination/BasePaginator.ts
Original file line number Diff line number Diff line change
@@ -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<T> = { items: T[] } & {
next?: string;
prev?: string;
Expand All @@ -24,6 +28,7 @@ export type PaginatorState<T = any> = {
lastQueryError?: Error;
cursor?: Cursor;
offset?: number;
isStateValid: boolean;
};

export type PaginatorOptions = {
Expand All @@ -40,12 +45,16 @@ export abstract class BasePaginator<T> {
state: StateStore<PaginatorState<T>>;
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<PaginatorState<T>>(this.initialState);

this.setDebounceOptions({ debounceMs });
}

Expand Down Expand Up @@ -78,13 +87,18 @@ export abstract class BasePaginator<T> {
lastQueryError: undefined,
cursor: undefined,
offset: 0,
isStateValid: true,
};
}

get items() {
return this.state.getLatestValue().items;
}

get isStateValid() {
return this.state.getLatestValue().isStateValid;
}

get cursor() {
return this.state.getLatestValue().cursor;
}
Expand All @@ -102,8 +116,10 @@ export abstract class BasePaginator<T> {
};

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<T> {
return {
Expand All @@ -124,30 +140,45 @@ export abstract class BasePaginator<T> {
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());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing this is potentially breaking

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is setting state before the query is executed. This is a hook that can be used by integrators to adjust the state before the first query.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State before the first query should always be initialState unless invalidated. When invalidated and re-fetched, the state is kept until there's something to replace it with to prevent the data -> no data -> new data UI blinking.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, then we should also remove getStateBeforeFirstQuery method, because in this PR it does not make anymore sense - it is called before the first query but not applied to the state before the first query.

} else {
this.state.partialNext({ isLoading: true });
}

const stateUpdate: Partial<PaginatorState<T>> = {};
const isFirstPage = typeof this.items === 'undefined' || !this.isStateValid;

this.state.partialNext({ isLoading: true });

const stateUpdate: Partial<PaginatorState<T>> = 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 };
Comment thread
MartinCupela marked this conversation as resolved.
stateUpdate.hasNext = !!next;
stateUpdate.hasPrev = !!prev;
} else {
Expand All @@ -156,8 +187,8 @@ export abstract class BasePaginator<T> {
}

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));
}
Expand All @@ -181,4 +212,8 @@ export abstract class BasePaginator<T> {
prevDebounced = () => {
this._executeQueryDebounced({ direction: 'prev' });
};

invalidate = () => {
this.state.partialNext({ isStateValid: false });
};
}
1 change: 1 addition & 0 deletions src/pagination/ReminderPaginator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export class ReminderPaginator extends BasePaginator<ReminderResponse> {
private client: StreamChat;
protected _filters: ReminderFilters | undefined;
protected _sort: ReminderSort | undefined;
protected _isCursorPagination = true;

get filters(): ReminderFilters | undefined {
return this._filters;
Expand Down
7 changes: 5 additions & 2 deletions test/unit/pagination/BasePaginator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class Paginator extends BasePaginator<TestItem> {
queryReject: Function = vi.fn();
queryPromise: Promise<PaginationQueryReturnValue<TestItem>> | null = null;
mockClientQuery = vi.fn();
protected _isCursorPagination = false;

constructor(options: PaginatorOptions = {}) {
super(options);
Expand Down Expand Up @@ -52,6 +53,7 @@ describe('BasePaginator', () => {
hasNext: true,
hasPrev: true,
isLoading: false,
isStateValid: true,
items: undefined,
lastQueryError: undefined,
cursor: undefined,
Expand All @@ -66,6 +68,7 @@ describe('BasePaginator', () => {
hasNext: true,
hasPrev: true,
isLoading: false,
isStateValid: true,
items: undefined,
lastQueryError: undefined,
cursor: undefined,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down