From b5f6c72b30ceb06ff7b994f138baa2c0659eb69f Mon Sep 17 00:00:00 2001 From: AlexDanilovich Date: Wed, 20 Dec 2023 11:50:04 +0100 Subject: [PATCH] feat: added fetching and pagination services --- package-lock.json | 11 ++ package.json | 1 + src/services/fetching.ts | 27 +++ src/services/pagination.ts | 338 +++++++++++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+) create mode 100644 src/services/fetching.ts create mode 100644 src/services/pagination.ts diff --git a/package-lock.json b/package-lock.json index e3d211c..345fc29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ }, "peerDependencies": { "lodash": "^4.17.21", + "mobx": "^6.12.0", "react": ">=16.8.0", "tslib": "^2.5.0", "yup": ">=0.32.11" @@ -6222,6 +6223,16 @@ "node": ">= 6" } }, + "node_modules/mobx": { + "version": "6.12.0", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.12.0.tgz", + "integrity": "sha512-Mn6CN6meXEnMa0a5u6a5+RKrqRedHBhZGd15AWLk9O6uFY4KYHzImdt8JI8WODo1bjTSRnwXhJox+FCUZhCKCQ==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mobx" + } + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", diff --git a/package.json b/package.json index b84f700..3ff1452 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ }, "peerDependencies": { "lodash": "^4.17.21", + "mobx": "^6.12.0", "react": ">=16.8.0", "tslib": "^2.5.0", "yup": ">=0.32.11" diff --git a/src/services/fetching.ts b/src/services/fetching.ts new file mode 100644 index 0000000..e734b7b --- /dev/null +++ b/src/services/fetching.ts @@ -0,0 +1,27 @@ +import { action, makeObservable, observable } from 'mobx'; + +class Fetching { + /** + * isFetching on submit + */ + public isFetching = false; + + /** + * @constructor + */ + constructor() { + makeObservable(this, { + isFetching: observable, + setFetching: action.bound, + }); + } + + /** + * Set is loading + */ + public setFetching(isLoading: boolean): void { + this.isFetching = isLoading; + } +} + +export default Fetching; diff --git a/src/services/pagination.ts b/src/services/pagination.ts new file mode 100644 index 0000000..c544079 --- /dev/null +++ b/src/services/pagination.ts @@ -0,0 +1,338 @@ +import _ from 'lodash'; +import type { IReactionDisposer } from 'mobx'; +import { action, makeObservable, observable, reaction } from 'mobx'; + +export type IRequestReturn = + | { count?: number; data: TEntity[]; page: number } + | string + | undefined; + +type TEntitiesFunc = (page?: number) => Promise>; + +interface IPaginationParams { + getEntities: TEntitiesFunc; + entities?: TEntity[]; + initState?: Partial; + shouldAdd?: boolean; + isLocal?: boolean; +} + +export interface IState { + error: string | null; + isFetching: boolean; + isFirstRender: boolean; + isFirstPage: boolean; + isLastPage: boolean; + hasPreviousPage: boolean; + hasNextPage: boolean; + pageSize: number; + page: number; + totalPages: number; + count: number; + firstPageNumber: number; + lastPageNumber: number; + pages: number[]; + filter?: string; + orderBy?: string; +} + +export const getInitialState = (): IState => ({ + error: null, + isFetching: false, + isFirstRender: true, + isFirstPage: true, + isLastPage: false, + hasPreviousPage: false, + hasNextPage: true, + pageSize: 20, + page: 1, + totalPages: 0, + count: 0, + firstPageNumber: 1, + lastPageNumber: 0, + pages: [1], +}); + +/** + * Pagination + */ +class Pagination { + /** + * List of entities + */ + public entities: TEntity[] = []; + + /** + * Pagination state + */ + public state: IState; + + /** + * Default error message + */ + public defaultErrorMsg = 'Unknown error'; + + /** + * Get table entities + */ + public getEntities: IPaginationParams['getEntities']; + + /** + * List will be paginated via infinite scroll or with pages buttons + * @private + */ + private shouldAdd: boolean; + + /** + * Add protection on push duplicates for strict mode if true + * @private + */ + private isLocal: boolean; + + /** + * @constructor + */ + constructor({ + getEntities, + initState = {}, + shouldAdd = false, + isLocal = false, + }: IPaginationParams) { + this.shouldAdd = shouldAdd; + this.isLocal = isLocal; + this.state = { ...getInitialState(), ...initState }; + this.getEntities = this.wrapRequest(getEntities); + + makeObservable(this, { + entities: observable, + state: observable, + setEntities: action.bound, + setFetching: action.bound, + setPageSize: action.bound, + setPage: action.bound, + setError: action.bound, + setTotalCount: action.bound, + resetState: action.bound, + resetIsFirstRender: action.bound, + setHasNextPage: action.bound, + setHasPreviousPage: action.bound, + }); + } + /** + * Serialize observable data for SSR + */ + public toJSON = (): { entities: TEntity[]; state: IState } => ({ + entities: this.entities, + state: this.state, + }); + + /** + * Serialize pagination service for SSR + */ + public static create = < + TContext extends { pagination: Pagination; getEntities: TEntitiesFunc }, + >( + context: TContext, + ) => { + const paginationState = context.pagination; + + context.pagination = new Pagination({ + getEntities: context.getEntities, + shouldAdd: true, + }); + + context.pagination.entities = paginationState?.entities; + context.pagination.state = paginationState?.state; + + context.getEntities = context.pagination.getEntities; + }; + + /** + * Add state subscribers + * Get entities when state changed + */ + public addSubscribe = (): IReactionDisposer => + reaction( + () => ({ + page: this.state.page, + pageSize: this.state.pageSize, + }), + () => void this.getEntities(), + ); + + /** + * Reset state before retry requests + */ + public resetIsFirstRender(): void { + this.state.isFirstRender = true; + } + + /** + * Reset table store + */ + public resetState(): void { + this.state = getInitialState(); + this.setEntities([]); + } + + /** + * Set error message + */ + public setError(message: string | null): void { + this.state.error = message; + } + + /** + * Set page size + */ + public setPageSize(count: number): void { + this.state.pageSize = count; + this.setPage(1); + } + + /** + * Set current page + */ + public setPage(page: number): void { + this.state.page = page; + } + + /** + * Set shouldAdd only on init + */ + public setShouldAdd(shouldAdd: boolean): void { + this.shouldAdd = shouldAdd; + } + + /** + * Set list entities + */ + public setEntities(entities: TEntity[], shouldAdd = false): void { + if (shouldAdd) { + // Add protection on push duplicates for strict mode + if (this.isLocal) { + const isDuplicatedResult = this.entities.some((el) => _.isEqual(el, entities?.[0])); + + if (isDuplicatedResult) { + return; + } + } + + this.entities.push(...entities); + } else { + this.entities = entities; + } + } + + /** + * Wrapper for get entities + */ + public wrapRequest(callback: TEntitiesFunc): TEntitiesFunc { + this.getEntities = async (pageVal) => { + this.setError(null); + this.setFetching(true); + + const result = await callback(pageVal ?? this.state.page); + + this.setFetching(false); + + if (result === undefined) { + return; + } + + if (typeof result === 'string') { + this.setError(result ?? this.defaultErrorMsg); + + return; + } + + if (!result.page) { + result.page = 1; + } + + const { data, count, page } = result; + + this.setPage(page); + this.setTotalCount(count ?? data.length); + this.setEntities(data, page > 1 && this.shouldAdd); + this.setHasNextPage(); + this.setHasPreviousPage(); + + return result; + }; + + return this.getEntities; + } + + /** + * Toggle fetching + */ + public setFetching(isFetching: boolean): void { + this.state.isFetching = isFetching; + + if (!this.state.isFetching) { + this.state.isFirstRender = false; + } + } + + /** + * Lazy load pagination + */ + public getNextPage = (): Promise> | undefined => { + const { page, pageSize, count } = this.state; + + if (page * pageSize >= count) { + return; + } + + if (this.entities.length >= count) { + return; + } + + if (page === 1 && count < pageSize) { + return; + } + + return this.getEntities(page + 1); + }; + + /** + * Get prev page + */ + public getPrevPage = (): Promise> | undefined => { + const { page } = this.state; + + if (page <= 1) { + return; + } + + return this.getEntities(page - 1); + }; + + /** + * Set count entities + */ + public setTotalCount(count: number): void { + this.state.count = count; + } + + /** + * Set has next page state + */ + public setHasNextPage(): void { + const { count, page, pageSize } = this.state; + + this.state.hasNextPage = count > page * pageSize; + } + + /** + * Set has previous page state + */ + public setHasPreviousPage(): void { + const { page } = this.state; + + this.state.hasPreviousPage = page > 1; + } +} + +export default Pagination;