Skip to content

Commit 836b577

Browse files
Initial draft
1 parent 7e732a0 commit 836b577

3 files changed

Lines changed: 59 additions & 20 deletions

File tree

src/pagination/BasePaginator.ts

Lines changed: 53 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { StateStore } from '../store';
22
import { debounce, type DebouncedFunc } from '../utils';
33

4-
type PaginationDirection = 'next' | 'prev';
5-
type Cursor = { next: string | null; prev: string | null };
6-
export type PaginationQueryParams = { direction: PaginationDirection };
4+
type PaginationDirection = PaginationDirectionNext | PaginationDirectionPrev;
5+
type PaginationDirectionPrev = 'prev';
6+
type PaginationDirectionNext = 'next';
7+
type Cursor = { next?: string; prev?: string };
8+
export type PaginationQueryParams =
9+
| { direction: PaginationDirectionNext; next?: Cursor['next']; offset?: number }
10+
| { direction: PaginationDirectionPrev; prev?: Cursor['prev']; offset?: number };
711
export type PaginationQueryReturnValue<T> = { items: T[] } & {
812
next?: string;
913
prev?: string;
@@ -24,6 +28,7 @@ export type PaginatorState<T = any> = {
2428
lastQueryError?: Error;
2529
cursor?: Cursor;
2630
offset?: number;
31+
isStateValid: boolean;
2732
};
2833

2934
export type PaginatorOptions = {
@@ -40,12 +45,16 @@ export abstract class BasePaginator<T> {
4045
state: StateStore<PaginatorState<T>>;
4146
pageSize: number;
4247
protected _executeQueryDebounced!: DebouncedExecQueryFunction;
43-
protected _isCursorPagination = false;
48+
// in cases where particular combination of filters would return only one item, the cursors
49+
// (`next`/`prev`) won't be included in the response - in such cases it's better to build
50+
// BasePaginator inheritors with this value already pre-defined
51+
protected abstract _isCursorPagination: boolean;
4452

4553
protected constructor(options?: PaginatorOptions) {
4654
const { debounceMs, pageSize } = { ...DEFAULT_PAGINATION_OPTIONS, ...options };
4755
this.pageSize = pageSize;
4856
this.state = new StateStore<PaginatorState<T>>(this.initialState);
57+
4958
this.setDebounceOptions({ debounceMs });
5059
}
5160

@@ -78,13 +87,18 @@ export abstract class BasePaginator<T> {
7887
lastQueryError: undefined,
7988
cursor: undefined,
8089
offset: 0,
90+
isStateValid: true,
8191
};
8292
}
8393

8494
get items() {
8595
return this.state.getLatestValue().items;
8696
}
8797

98+
get isStateValid() {
99+
return this.state.getLatestValue().isStateValid;
100+
}
101+
88102
get cursor() {
89103
return this.state.getLatestValue().cursor;
90104
}
@@ -102,8 +116,10 @@ export abstract class BasePaginator<T> {
102116
};
103117

104118
canExecuteQuery = (direction: PaginationDirection) =>
105-
(!this.isLoading && direction === 'next' && this.hasNext) ||
106-
(direction === 'prev' && this.hasPrev);
119+
!this.isLoading &&
120+
((direction === 'next' && this.hasNext) ||
121+
(direction === 'prev' && this.hasPrev) ||
122+
!this.isStateValid);
107123

108124
protected getStateBeforeFirstQuery(): PaginatorState<T> {
109125
return {
@@ -124,30 +140,45 @@ export abstract class BasePaginator<T> {
124140
isLoading: false,
125141
items: isFirstPage
126142
? stateUpdate.items
127-
: [...(this.items ?? []), ...(stateUpdate.items || [])],
143+
: [...(current.items ?? []), ...(stateUpdate.items ?? [])],
128144
};
129145
}
130146

131147
async executeQuery({ direction }: { direction: PaginationDirection }) {
132148
if (!this.canExecuteQuery(direction)) return;
133-
const isFirstPage = typeof this.items === 'undefined';
134-
if (isFirstPage) {
135-
this.state.next(this.getStateBeforeFirstQuery());
136-
} else {
137-
this.state.partialNext({ isLoading: true });
138-
}
139149

140-
const stateUpdate: Partial<PaginatorState<T>> = {};
150+
const isFirstPage = typeof this.items === 'undefined' || !this.isStateValid;
151+
152+
this.state.partialNext({ isLoading: true });
153+
154+
const stateUpdate: Partial<PaginatorState<T>> = isFirstPage
155+
? this.getStateBeforeFirstQuery()
156+
: {};
157+
141158
try {
142-
const results = await this.query({ direction });
159+
const queryParams: PaginationQueryParams = { direction };
160+
161+
if (!isFirstPage) {
162+
if (this._isCursorPagination) {
163+
// @ts-expect-error this is perfectly valid
164+
queryParams[queryParams.direction] = this.cursor?.[queryParams.direction];
165+
} else {
166+
queryParams['offset'] = this.offset;
167+
}
168+
}
169+
170+
const results = await this.query(queryParams);
171+
143172
if (!results) return;
173+
144174
const { items, next, prev } = results;
175+
145176
if (isFirstPage && (next || prev)) {
146177
this._isCursorPagination = true;
147178
}
148179

149180
if (this._isCursorPagination) {
150-
stateUpdate.cursor = { next: next || null, prev: prev || null };
181+
stateUpdate.cursor = { next, prev };
151182
stateUpdate.hasNext = !!next;
152183
stateUpdate.hasPrev = !!prev;
153184
} else {
@@ -156,8 +187,8 @@ export abstract class BasePaginator<T> {
156187
}
157188

158189
stateUpdate.items = await this.filterQueryResults(items);
159-
} catch (e) {
160-
stateUpdate.lastQueryError = e as Error;
190+
} catch (error) {
191+
stateUpdate.lastQueryError = error as Error;
161192
} finally {
162193
this.state.next(this.getStateAfterQuery(stateUpdate, isFirstPage));
163194
}
@@ -181,4 +212,8 @@ export abstract class BasePaginator<T> {
181212
prevDebounced = () => {
182213
this._executeQueryDebounced({ direction: 'prev' });
183214
};
215+
216+
invalidate = () => {
217+
this.state.partialNext({ isStateValid: false });
218+
};
184219
}

src/pagination/ReminderPaginator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export class ReminderPaginator extends BasePaginator<ReminderResponse> {
1111
private client: StreamChat;
1212
protected _filters: ReminderFilters | undefined;
1313
protected _sort: ReminderSort | undefined;
14+
protected _isCursorPagination = true;
1415

1516
get filters(): ReminderFilters | undefined {
1617
return this._filters;

test/unit/pagination/BasePaginator.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class Paginator extends BasePaginator<TestItem> {
2222
queryReject: Function = vi.fn();
2323
queryPromise: Promise<PaginationQueryReturnValue<TestItem>> | null = null;
2424
mockClientQuery = vi.fn();
25+
protected _isCursorPagination = false;
2526

2627
constructor(options: PaginatorOptions = {}) {
2728
super(options);
@@ -52,6 +53,7 @@ describe('BasePaginator', () => {
5253
hasNext: true,
5354
hasPrev: true,
5455
isLoading: false,
56+
isStateValid: true,
5557
items: undefined,
5658
lastQueryError: undefined,
5759
cursor: undefined,
@@ -66,6 +68,7 @@ describe('BasePaginator', () => {
6668
hasNext: true,
6769
hasPrev: true,
6870
isLoading: false,
71+
isStateValid: true,
6972
items: undefined,
7073
lastQueryError: undefined,
7174
cursor: undefined,
@@ -105,7 +108,7 @@ describe('BasePaginator', () => {
105108
expect(paginator.hasNext).toBe(false);
106109
expect(paginator.hasPrev).toBe(false);
107110
expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]);
108-
expect(paginator.cursor).toEqual({ next: null, prev: null });
111+
expect(paginator.cursor).toEqual({ next: undefined, prev: undefined });
109112

110113
paginator.next();
111114
expect(paginator.isLoading).toBe(false);
@@ -168,7 +171,7 @@ describe('BasePaginator', () => {
168171
expect(paginator.hasNext).toBe(false);
169172
expect(paginator.hasPrev).toBe(false);
170173
expect(paginator.items).toEqual([{ id: 'id1' }, { id: 'id2' }]);
171-
expect(paginator.cursor).toEqual({ next: null, prev: null });
174+
expect(paginator.cursor).toEqual({ next: undefined, prev: undefined });
172175

173176
paginator.prev();
174177
expect(paginator.isLoading).toBe(false);

0 commit comments

Comments
 (0)