diff --git a/documentation/docs/api-reference/core/hooks/data/useInfiniteList.md b/documentation/docs/api-reference/core/hooks/data/useInfiniteList.md new file mode 100644 index 000000000000..453682db3a9f --- /dev/null +++ b/documentation/docs/api-reference/core/hooks/data/useInfiniteList.md @@ -0,0 +1,377 @@ +--- +id: useInfiniteList +title: useInfiniteList +siderbar_label: useInfiniteList +description: useInfiniteList data hook from refine is a modified version of react-query's useInfiniteQuery for retrieving items from a resource with pagination, search, sort, and filter configurations. +--- + +`useInfiniteList` is a modified version of `react-query`'s [`useInfiniteQuery`](https://react-query.tanstack.com/guides/useInfiniteQuery) used for retrieving items from a `resource` with pagination, sort, and filter configurations. It is ideal for lists where the total number of records is unknown and the user loads the next pages with a button. + +It uses the `getList` method as the query function from the [`dataProvider`](/docs/api-reference/core/providers/data-provider.md) which is passed to ``. + +```tsx live url=http://localhost:3000/categories previewHeight=420px hideCode +import React from "react"; +import { Refine } from "@pankod/refine-core"; + +setInitialRoutes(["/posts"]); +// visible-block-start +import React from "react"; +import { useInfiniteList } from "@pankod/refine-core"; + +const PostList = () => { + const { + data, + error, + hasNextPage, + isLoading, + fetchNextPage, + isFetchingNextPage, + } = useInfiniteList({ + resource: "categories", + config: { + pagination: { + pageSize: 4 + } + } + }); + + if (isLoading) { + return

Loading

; + } + if (error) { + return

Something went wrong

; + } + + return ( +
+ + + { + hasNextPage && ( + + ) + } +
+ ); +} +// visible-block-end + +setRefineProps({ + // Layout: (props: LayoutProps) => , + resources: [ + { + name: "posts", + list: PostList, + }, + ], +}); + +render(); +``` + +## Usage + +Let's assume that we have a `posts` resource with the following data: + +```ts title="https://api.fake-rest.refine.dev/posts" +{ + [ + { + id: 1, + title: "E-business", + status: "draft", + }, + { + id: 2, + title: "Virtual Invoice Avon", + status: "published", + }, + { + id: 3, + title: "Powerful Crypto", + status: "rejected", + }, + ]; +} +``` + +First of all, we will use `useInfiniteList` without passing any query configurations. + +```tsx +import { useInfiniteList } from "@pankod/refine-core"; + +type IPost = { + id: number; + title: string; + status: "rejected" | "published" | "draft"; +}; + +const postInfiniteListResult = useInfiniteList({ resource: "posts" }); +``` + +```json title="postInfiniteListResult" +{ + "status": "success", + "data": { + "pages": [ + { + "data": [ + { + "id": 1, + "title": "E-business", + "status": "draft" + }, + { + "id": 2, + "title": "Virtual Invoice Avon", + "status": "published" + } + ], + "total": 1370 + } + ] + }, + "hasNextPage": true, + "hasPreviousPage": false, + "isFetchingNextPage": false, + "isFetchingPreviousPage": false + ... +} +``` + +If your API returns the result like above, you can use useInfiniteList without any configuration. + +:::caution +`getList` also has default values for pagination: + +```ts +{ + pagination: { current: 1, pageSize: 10 } +} +``` + +::: +:::caution +If you want to create your own `getList` method, it will automatically implement default query configurations since `useInfiniteList` can work with no configuration parameters. +::: + +### Query Configuration + +#### `pagination` + +Allows us to set page and items per page values. + +For example imagine that we have 1000 post records: + +```ts +import { useInfiniteList } from "@pankod/refine-core"; + +const postListQueryResult = useInfiniteList({ + resource: "posts", + config: { + pagination: { current: 3, pageSize: 8 }, + }, +}); +``` + +#### `sort` + +Allows us to sort records by the speficified order and field. + +```ts +import { useInfiniteList } from "@pankod/refine-core"; + +const postListQueryResult = useInfiniteList({ + resource: "posts", + config: { + sort: [{ order: "asc", field: "title" }], + }, +}); +``` + +#### `filters` + +Allows us to filter queries using refine's filter operators. It is configured via `field`, `operator` and `value` properites. + +[Refer to supported operators. →](/docs/api-reference/core/interfaceReferences/#crudfilters) + + +```ts +import { useInfiniteList } from "@pankod/refine-core"; + +const postListQueryResult = useInfiniteList({ + resource: "posts", + config: { + filters: [ + { + field: "status", + operator: "eq", + value: "rejected", + }, + ], + }, +}); +``` + +:::tip +`useInfiniteList` returns the result of `react-query`'s `useInfiniteQuery` which includes many properties such as `fetchNextPage`, `hasNextPage` and `isFetchingNextPage`. + +- For example, to disable query from running automatically you can set `enabled` to `false`. + +```ts +import { useInfiniteList } from "@pankod/refine-core"; + +const postListQueryResult = useInfiniteList({ + resource: "posts", + // highlight-start + queryOptions: { + enabled: false, + getNextPageParam: ({ data }) => { + const lastRow = data[data.length - 1]; + return lastRow.id; + }, + }, + // highlight-end +}); +``` + +[Refer to react-query docs for further information. →](https://react-query.tanstack.com/reference/useInfiniteQuery) +::: + + +## API + +### Properties + + + +### Config parameters + +```ts +interface UseListConfig { + hasPagination?: boolean; + pagination?: { + current?: number; + pageSize?: number; + }; + sort?: Array<{ + field: string; + order: "asc" | "desc"; + }>; + filters?: Array<{ + field: string; + operator: CrudOperators; + value: any; + }>; +} +``` + +### Type Parameters + +| Property | Desription | Type | Default | +| -------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | +| TData | Result data of the query. Extends [`BaseRecord`](/api-reference/core/interfaces.md#baserecord) | [`BaseRecord`](/api-reference/core/interfaces.md#baserecord) | [`BaseRecord`](/api-reference/core/interfaces.md#baserecord) | +| TError | Custom error object that extends [`HttpError`](/api-reference/core/interfaces.md#httperror) | [`HttpError`](/api-reference/core/interfaces.md#httperror) | [`HttpError`](/api-reference/core/interfaces.md#httperror) | + +### Return values + +| Description | Type | +| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Result of the `react-query`'s `useInfiniteQuery` | [`InfiniteQueryObserverResult<{`
` data: TData[];`
` total: number; },`
` TError>`](https://react-query.tanstack.com/reference/useInfiniteQuery) | + +## FAQ +### How to use cursor based pagination? + +Some APIs use the `cursor-pagination` method for its benefits. This method uses a `cursor` object to determine the next set of data. The cursor can be a number or a string and is passed to the API as a query parameter. + +**Preparing the data provider:** + +Consumes data from data provider `useInfiniteList` with `getList` method. First of all, we need to make the this method in the data provider convenient for this API. The `cursor` data is kept in `pagination` and should be set to `0` by default. + +```ts +getList: async ({ resource, pagination }) => { + const { current } = pagination; + const { data } = await axios.get( + `https://api.fake-rest.refine.dev/${resource}?cursor=${current || 0}`, + ); + + return { + data: data[resource], + total: 0, + }; +}, +``` + +:::tip +As the `total` data is only needed in the `offset-pagination` method, define it as `0` here. +::: + +After this process, we have successfully retrieved the first page data. Let's fill the `cursor` object for the next page. + +```ts +getList: async ({ resource, pagination }) => { + const { current } = pagination; + const { data } = await axios.get( + `https://api.fake-rest.refine.dev/${resource}?cursor=${current || 0}`, + ); + + return { + data: data[resource], + total: 0, + // highlight-start + cursor: { + next: data.cursor.next, + prev: data.cursor.prev, + }, + // highlight-end + }; +}, +``` + +### How to override the `getNextPageParam` method? + +By default, `refine` expects you to return the `cursor` object, but is not required. This is because some APIs don't work that way. To fix this problem you need to override the `getNextPageParam` method and return the next `cursor`. + +```tsx +import { useInfiniteList } from "@pankod/refine-core"; + +const { + data, + error, + hasNextPage, + isLoading, + fetchNextPage, + isFetchingNextPage, +} = useInfiniteList({ + resource: "posts", + // highlight-start + queryOptions: { + getNextPageParam: (lastPage, allPages) => { + // return the last post's id + const { data } = lastPage; + const lastPost = data[data.length - 1]; + return lastPost.id; + }, + }, + // highlight-end +}); +``` +:::tip +When you override this method, you can access the `lastPage` and `allPages`. +::: \ No newline at end of file diff --git a/documentation/sidebars.js b/documentation/sidebars.js index a18a78b2449d..840d394d8917 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -99,6 +99,7 @@ module.exports = { "api-reference/core/hooks/data/useDelete", "api-reference/core/hooks/data/useDeleteMany", "api-reference/core/hooks/data/useList", + "api-reference/core/hooks/data/useInfiniteList", "api-reference/core/hooks/data/useMany", "api-reference/core/hooks/data/useOne", "api-reference/core/hooks/data/useUpdate", diff --git a/examples/use-infinite-list/.gitignore b/examples/use-infinite-list/.gitignore new file mode 100644 index 000000000000..4d29575de804 --- /dev/null +++ b/examples/use-infinite-list/.gitignore @@ -0,0 +1,23 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# production +/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/examples/use-infinite-list/README.md b/examples/use-infinite-list/README.md new file mode 100644 index 000000000000..fead4f67f5b5 --- /dev/null +++ b/examples/use-infinite-list/README.md @@ -0,0 +1,45 @@ + +
+ + + +
+
+ + +
+ +
+ +
Build your React-based CRUD applications, without constraints.
An open source, headless web application framework developed with flexibility in mind. + +
+
+ + +[![Discord](https://img.shields.io/discord/837692625737613362.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/refine) +[![Twitter Follow](https://img.shields.io/twitter/follow/refine_dev?style=social)](https://twitter.com/refine_dev) + +refine - 100% open source React framework to build web apps 3x faster | Product Hunt + +
+ +## Try it out on your local + +```bash +npm create refine-app@latest -- --example use-infinite-list +``` + +## Try it out on StackBlitz + +
+ +[![Open useInfiniteList hook example from refine](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/refinedev/refine/tree/master/examples/use-infinite-list?terminal=start&preset=node) + diff --git a/examples/use-infinite-list/package.json b/examples/use-infinite-list/package.json new file mode 100644 index 000000000000..e4d525d859d4 --- /dev/null +++ b/examples/use-infinite-list/package.json @@ -0,0 +1,39 @@ +{ + "name": "use-infinite-list", + "version": "3.25.0", + "private": true, + "dependencies": { + "@pankod/refine-core": "^3.94.2", + "@pankod/refine-cli": "^1.17.0", + "@pankod/refine-react-hook-form": "^3.37.2", + "@pankod/refine-react-router-v6": "^3.38.0", + "@pankod/refine-react-table": "^4.9.0", + "@pankod/refine-simple-rest": "^3.37.4", + "@types/node": "^12.20.11", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0", + "react-scripts": "^5.0.0", + "cross-env": "^7.0.3", + "typescript": "^4.7.4" + }, + "scripts": { + "start": "cross-env DISABLE_ESLINT_PLUGIN=true react-scripts start", + "build": "cross-env DISABLE_ESLINT_PLUGIN=true react-scripts build", + "eject": "react-scripts eject", + "refine": "refine" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/examples/use-infinite-list/public/favicon.ico b/examples/use-infinite-list/public/favicon.ico new file mode 100644 index 000000000000..2f05c5f484fb Binary files /dev/null and b/examples/use-infinite-list/public/favicon.ico differ diff --git a/examples/use-infinite-list/public/index.html b/examples/use-infinite-list/public/index.html new file mode 100644 index 000000000000..6b56c98b2eb8 --- /dev/null +++ b/examples/use-infinite-list/public/index.html @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + refine useInfiniteList hook example + + + + + +
+ + + + \ No newline at end of file diff --git a/examples/use-infinite-list/public/manifest.json b/examples/use-infinite-list/public/manifest.json new file mode 100644 index 000000000000..29a0b885989d --- /dev/null +++ b/examples/use-infinite-list/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "refine useInfiniteList hook example", + "name": "refine useInfiniteList hook example", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/examples/use-infinite-list/src/App.css b/examples/use-infinite-list/src/App.css new file mode 100644 index 000000000000..20684948f1a9 --- /dev/null +++ b/examples/use-infinite-list/src/App.css @@ -0,0 +1,29 @@ +table { + border-spacing: 0; + border: 1px solid black; +} + +table th, +td { + margin: 0; + padding: 0.5rem; + border-bottom: 1px solid black; + border-right: 1px solid black; +} + +table tr:last-child td { + border-bottom: 0; +} + +table th, +td { + margin: 0; + padding: 0.5rem; + border-bottom: 1px solid black; + border-right: 1px solid black; +} + +table th:last-child, +td:last-child { + border-right: 0; +} diff --git a/examples/use-infinite-list/src/App.tsx b/examples/use-infinite-list/src/App.tsx new file mode 100644 index 000000000000..54aad2f6cf0c --- /dev/null +++ b/examples/use-infinite-list/src/App.tsx @@ -0,0 +1,55 @@ +import { LayoutProps, Refine } from "@pankod/refine-core"; +import routerProvider, { Link } from "@pankod/refine-react-router-v6"; +import dataProvider from "@pankod/refine-simple-rest"; + +import { githubDataProvider } from "github-data-provider"; +import { PostList } from "pages/posts/list"; +import { CommitList } from "pages/commits/list"; +import "./App.css"; + +const Layout: React.FunctionComponent = ({ children }) => { + return ( +
+
    +
  • + + Post example with Simple Rest (offset pagination) + +
  • +
  • + + Commits example with Github API Data Provider (cursor + pagination) + +
  • +
+ + {children} +
+ ); +}; + +const App: React.FC = () => { + return ( + + ); +}; + +export default App; diff --git a/examples/use-infinite-list/src/github-data-provider/index.ts b/examples/use-infinite-list/src/github-data-provider/index.ts new file mode 100644 index 000000000000..8375591a15b6 --- /dev/null +++ b/examples/use-infinite-list/src/github-data-provider/index.ts @@ -0,0 +1,55 @@ +import { AxiosInstance } from "axios"; +import { DataProvider } from "@pankod/refine-core"; +import { axiosInstance } from "./utils"; + +/* + * Cursor pagination is left simple for example. + */ + +export const githubDataProvider = ( + httpClient: AxiosInstance = axiosInstance, +): Omit< + Required, + "createMany" | "updateMany" | "deleteMany" +> => ({ + getList: async ({ resource, pagination }) => { + const { data } = await httpClient.get( + `https://api.github.com/${resource}?until=${ + pagination?.current || new Date().toISOString() + }`, + ); + + return { + data, + total: 0, + }; + }, + + getMany: async () => { + throw new Error("Method not implemented."); + }, + + create: async () => { + throw new Error("Method not implemented."); + }, + + update: async () => { + throw new Error("Method not implemented."); + }, + + getOne: async () => { + throw new Error("Method not implemented."); + }, + + deleteOne: async () => { + throw new Error("Method not implemented."); + }, + + getApiUrl: () => { + return "https://api.github.com"; + }, + + custom: async () => { + throw new Error("Method not implemented."); + }, +}); diff --git a/examples/use-infinite-list/src/github-data-provider/utils/axios.ts b/examples/use-infinite-list/src/github-data-provider/utils/axios.ts new file mode 100644 index 000000000000..4098c943eb11 --- /dev/null +++ b/examples/use-infinite-list/src/github-data-provider/utils/axios.ts @@ -0,0 +1,23 @@ +import { HttpError } from "@pankod/refine-core"; + +// "axios" package should be installed to customize the http client +import axios from "axios"; + +const axiosInstance = axios.create(); + +axiosInstance.interceptors.response.use( + (response) => { + return response; + }, + (error) => { + const customError: HttpError = { + ...error, + message: error.response?.data?.message, + statusCode: error.response?.status, + }; + + return Promise.reject(customError); + }, +); + +export { axiosInstance }; diff --git a/examples/use-infinite-list/src/github-data-provider/utils/index.ts b/examples/use-infinite-list/src/github-data-provider/utils/index.ts new file mode 100644 index 000000000000..fb9a82afe129 --- /dev/null +++ b/examples/use-infinite-list/src/github-data-provider/utils/index.ts @@ -0,0 +1 @@ +export { axiosInstance } from "./axios"; diff --git a/examples/use-infinite-list/src/index.tsx b/examples/use-infinite-list/src/index.tsx new file mode 100644 index 000000000000..ed9fcf1e6b0b --- /dev/null +++ b/examples/use-infinite-list/src/index.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; + +import App from "./App"; + +const container = document.getElementById("root"); +// eslint-disable-next-line +const root = createRoot(container!); +root.render( + + + , +); diff --git a/examples/use-infinite-list/src/interfaces/index.d.ts b/examples/use-infinite-list/src/interfaces/index.d.ts new file mode 100644 index 000000000000..161ccdc517c1 --- /dev/null +++ b/examples/use-infinite-list/src/interfaces/index.d.ts @@ -0,0 +1,5 @@ +export interface IPost { + id: number; + title: string; + createdAt: string; +} diff --git a/examples/use-infinite-list/src/pages/commits/list.tsx b/examples/use-infinite-list/src/pages/commits/list.tsx new file mode 100644 index 000000000000..d6d76dc09a58 --- /dev/null +++ b/examples/use-infinite-list/src/pages/commits/list.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { useInfiniteList } from "@pankod/refine-core"; + +export const CommitList: React.FC = () => { + const { + data, + error, + hasNextPage, + isLoading, + fetchNextPage, + isFetchingNextPage, + } = useInfiniteList({ + resource: "repos/refinedev/refine/commits", + dataProviderName: "github", + queryOptions: { + getNextPageParam: ({ data }) => { + // return the last commit date of the last page + const lastCommit = data[data.length - 1]; + return lastCommit.commit.committer.date; + }, + }, + }); + + if (isLoading) { + return

Loading

; + } + if (error) { + return

Something went wrong

; + } + + return ( +
+
    + {data?.pages.map((page) => + page.data.map(({ sha, commit }) => ( +
  • + {commit.committer.date} +
    + {commit.message} - {commit.committer.name} +
  • + )), + )} +
+ + +
+ ); +}; diff --git a/examples/use-infinite-list/src/pages/posts/list.tsx b/examples/use-infinite-list/src/pages/posts/list.tsx new file mode 100644 index 000000000000..66976467a101 --- /dev/null +++ b/examples/use-infinite-list/src/pages/posts/list.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { useInfiniteList } from "@pankod/refine-core"; + +import { IPost } from "interfaces"; + +export const PostList: React.FC = () => { + const { + data, + error, + hasNextPage, + isLoading, + fetchNextPage, + isFetchingNextPage, + } = useInfiniteList({ + resource: "posts", + }); + + if (isLoading) { + return

Loading

; + } + if (error) { + return

Something went wrong

; + } + + return ( +
+
    + {data?.pages.map((page) => + page.data.map(({ id, title, createdAt }) => ( +
  • + {createdAt} +
    + {title} +
  • + )), + )} +
+ + +
+ ); +}; diff --git a/examples/use-infinite-list/src/react-app-env.d.ts b/examples/use-infinite-list/src/react-app-env.d.ts new file mode 100644 index 000000000000..6431bc5fc6b2 --- /dev/null +++ b/examples/use-infinite-list/src/react-app-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/use-infinite-list/tsconfig.json b/examples/use-infinite-list/tsconfig.json new file mode 100644 index 000000000000..5fecd495dbcf --- /dev/null +++ b/examples/use-infinite-list/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "baseUrl": "src", + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src", + ] +} \ No newline at end of file diff --git a/packages/core/src/contexts/data/IDataContext.ts b/packages/core/src/contexts/data/IDataContext.ts index 48d93e70ff10..1553fdd3df30 100644 --- a/packages/core/src/contexts/data/IDataContext.ts +++ b/packages/core/src/contexts/data/IDataContext.ts @@ -87,6 +87,7 @@ export interface CustomResponse { export interface GetListResponse { data: TData[]; total: number; + [key: string]: any; } export interface CreateResponse { diff --git a/packages/core/src/definitions/helpers/index.ts b/packages/core/src/definitions/helpers/index.ts index 34c8318640e3..bb53814bf8d8 100644 --- a/packages/core/src/definitions/helpers/index.ts +++ b/packages/core/src/definitions/helpers/index.ts @@ -12,3 +12,7 @@ export { redirectPage } from "./redirectPage"; export { sequentialPromises } from "./sequentialPromises"; export { pickDataProvider } from "./pickDataProvider"; export { handleMultiple } from "./handleMultiple"; +export { + getNextPageParam, + getPreviousPageParam, +} from "./useInfinitePagination"; diff --git a/packages/core/src/definitions/helpers/useInfinitePagination/index.spec.ts b/packages/core/src/definitions/helpers/useInfinitePagination/index.spec.ts new file mode 100644 index 000000000000..5fb243aeb33e --- /dev/null +++ b/packages/core/src/definitions/helpers/useInfinitePagination/index.spec.ts @@ -0,0 +1,65 @@ +import { getNextPageParam, getPreviousPageParam } from "./index"; + +describe("useInfiniteList pagination helper", () => { + describe("getNextPageParam", () => { + it("default page size", () => { + const hasNextPage = getNextPageParam({ + data: [], + total: 10, + }); + expect(hasNextPage).toBe(undefined); + }); + it("custom pageSize and current page", () => { + const hasNextPage = getNextPageParam({ + data: [], + total: 10, + pagination: { + current: 2, + pageSize: 3, + }, + }); + expect(hasNextPage).toBe(3); + }); + it("cursor", () => { + const hasNextPage = getNextPageParam({ + data: [], + total: 10, + cursor: { + next: 2, + }, + }); + expect(hasNextPage).toBe(2); + }); + }); + describe("getPreviousPageParam", () => { + it("custom pageSize and current page", () => { + const hasPreviousPage = getPreviousPageParam({ + data: [], + total: 10, + pagination: { + current: 2, + pageSize: 3, + }, + }); + expect(hasPreviousPage).toBe(1); + }); + it("hasPreviousPage false", () => { + const hasPreviousPage = getPreviousPageParam({ + data: [], + total: 10, + }); + expect(hasPreviousPage).toBe(undefined); + }); + it("cursor", () => { + const hasPreviousPage = getPreviousPageParam({ + data: [], + total: 10, + cursor: { + next: 2, + prev: 1, + }, + }); + expect(hasPreviousPage).toBe(1); + }); + }); +}); diff --git a/packages/core/src/definitions/helpers/useInfinitePagination/index.ts b/packages/core/src/definitions/helpers/useInfinitePagination/index.ts new file mode 100644 index 000000000000..9733cfa4d124 --- /dev/null +++ b/packages/core/src/definitions/helpers/useInfinitePagination/index.ts @@ -0,0 +1,30 @@ +import { GetListResponse } from "../../../interfaces"; + +export const getNextPageParam = (lastPage: GetListResponse) => { + const { pagination, cursor } = lastPage; + + // cursor pagination + if (cursor?.next) { + return cursor.next; + } + + const current = pagination?.current || 1; + + const pageSize = pagination?.pageSize || 10; + const totalPages = Math.ceil((lastPage.total || 0) / pageSize); + + return current < totalPages ? Number(current) + 1 : undefined; +}; + +export const getPreviousPageParam = (lastPage: GetListResponse) => { + const { pagination, cursor } = lastPage; + + // cursor pagination + if (cursor?.prev) { + return cursor.prev; + } + + const current = pagination?.current || 1; + + return current === 1 ? undefined : current - 1; +}; diff --git a/packages/core/src/hooks/data/index.ts b/packages/core/src/hooks/data/index.ts index f8a29f1dcf00..94be2377487f 100644 --- a/packages/core/src/hooks/data/index.ts +++ b/packages/core/src/hooks/data/index.ts @@ -15,3 +15,4 @@ export { useCustom } from "./useCustom"; export { useCustomMutation } from "./useCustomMutation"; export { useDataProvider } from "./useDataProvider"; +export { useInfiniteList } from "./useInfiniteList"; diff --git a/packages/core/src/hooks/data/useInfiniteList.spec.tsx b/packages/core/src/hooks/data/useInfiniteList.spec.tsx new file mode 100644 index 000000000000..f602cfab9158 --- /dev/null +++ b/packages/core/src/hooks/data/useInfiniteList.spec.tsx @@ -0,0 +1,255 @@ +import { renderHook, waitFor } from "@testing-library/react"; + +import { MockJSONServer, TestWrapper } from "@test"; + +import { useInfiniteList } from "./useInfiniteList"; +import { defaultRefineOptions } from "@contexts/refine"; +import { + IDataMultipleContextProvider, + IRefineContextProvider, +} from "../../interfaces"; + +const mockRefineProvider: IRefineContextProvider = { + hasDashboard: false, + ...defaultRefineOptions, + options: defaultRefineOptions, +}; + +describe("useInfiniteList Hook", () => { + it("with rest json server", async () => { + const { result } = renderHook( + () => useInfiniteList({ resource: "posts" }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + }), + }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + const { data } = result.current; + + expect(data?.pages).toHaveLength(1); + expect(data?.pages[0].data).toHaveLength(2); + expect(data?.pages[0].total).toEqual(2); + }); + + it("hasNextPage is truthy", async () => { + const { result } = renderHook( + () => + useInfiniteList({ + resource: "posts", + config: { + pagination: { + pageSize: 1, + }, + }, + }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + }), + }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + const { hasNextPage } = result.current; + expect(hasNextPage).toBeTruthy(); + }); + + it("cursor has next", async () => { + const mockDataProvider = { + default: { + ...MockJSONServer.default, + getList: async () => { + return { + data: [ + { + title: "title1", + }, + ], + total: 0, + cursor: { + next: undefined, + }, + }; + }, + }, + } as IDataMultipleContextProvider; + + const { result } = renderHook( + () => + useInfiniteList({ + resource: "posts", + config: { + pagination: { + pageSize: 1, + }, + }, + }), + { + wrapper: TestWrapper({ + dataProvider: mockDataProvider, + resources: [{ name: "posts" }], + }), + }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBeFalsy(); + }); + + const { hasNextPage } = result.current; + expect(hasNextPage).toBeUndefined(); + }); + + describe("useResourceSubscription", () => { + it("useSubscription", async () => { + const onSubscribeMock = jest.fn(); + + const { result } = renderHook( + () => + useInfiniteList({ + resource: "posts", + }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(onSubscribeMock).toBeCalled(); + expect(onSubscribeMock).toHaveBeenCalledWith({ + channel: "resources/posts", + callback: expect.any(Function), + params: { + filters: undefined, + hasPagination: undefined, + metaData: undefined, + pagination: undefined, + resource: "posts", + sort: undefined, + subscriptionType: "useList", + }, + types: ["*"], + }); + }); + + it("liveMode = Off useSubscription", async () => { + const onSubscribeMock = jest.fn(); + + const { result } = renderHook( + () => + useInfiniteList({ + resource: "posts", + }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(onSubscribeMock).not.toBeCalled(); + }); + + it("liveMode = Off and liveMode hook param auto", async () => { + const onSubscribeMock = jest.fn(); + + const { result } = renderHook( + () => useInfiniteList({ resource: "posts", liveMode: "auto" }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: jest.fn(), + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "off", + }, + }), + }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(onSubscribeMock).toBeCalled(); + }); + + it("unsubscribe call on unmount", async () => { + const onSubscribeMock = jest.fn(() => true); + const onUnsubscribeMock = jest.fn(); + + const { result, unmount } = renderHook( + () => + useInfiniteList({ + resource: "posts", + }), + { + wrapper: TestWrapper({ + dataProvider: MockJSONServer, + resources: [{ name: "posts" }], + liveProvider: { + unsubscribe: onUnsubscribeMock, + subscribe: onSubscribeMock, + }, + refineProvider: { + ...mockRefineProvider, + liveMode: "auto", + }, + }), + }, + ); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(onSubscribeMock).toBeCalled(); + + unmount(); + expect(onUnsubscribeMock).toBeCalledWith(true); + expect(onUnsubscribeMock).toBeCalledTimes(1); + }); + }); +}); diff --git a/packages/core/src/hooks/data/useInfiniteList.ts b/packages/core/src/hooks/data/useInfiniteList.ts new file mode 100644 index 000000000000..26a6857f2851 --- /dev/null +++ b/packages/core/src/hooks/data/useInfiniteList.ts @@ -0,0 +1,202 @@ +import { + useInfiniteQuery, + UseInfiniteQueryOptions, + InfiniteQueryObserverResult, +} from "@tanstack/react-query"; +import { + CrudFilters, + Pagination, + BaseRecord, + HttpError, + CrudSorting, + MetaDataQuery, + SuccessErrorNotification, + LiveModeProps, + GetListResponse, +} from "../../interfaces"; +import { + useResource, + useCheckError, + useHandleNotification, + useResourceSubscription, + useTranslate, + useDataProvider, +} from "@hooks"; +import { + queryKeys, + pickDataProvider, + getNextPageParam, + getPreviousPageParam, +} from "@definitions/helpers"; + +export interface UseInfiniteListConfig { + pagination?: Pagination; + hasPagination?: boolean; + sort?: CrudSorting; + filters?: CrudFilters; +} + +export type UseInfiniteListProps = { + /** + * Resource name for API data interactions + */ + resource: string; + /** + * Configuration for pagination, sorting and filtering + * @type [`useInfiniteListConfig`](/docs/api-reference/core/hooks/data/useInfiniteList/#config-parameters) + */ + config?: UseInfiniteListConfig; + /** + * react-query's [useInfiniteQuery](https://tanstack.com/query/v4/docs/react/reference/useInfiniteQuery) options, + */ + queryOptions?: UseInfiniteQueryOptions, TError>; + /** + * Metadata query for `dataProvider` + */ + metaData?: MetaDataQuery; + /** + * If there is more than one `dataProvider`, you should use the `dataProviderName` that you will use. + */ + dataProviderName?: string; +} & SuccessErrorNotification & + LiveModeProps; + +/** + * `useInfiniteList` is a modified version of `react-query`'s {@link https://tanstack.com/query/latest/docs/react/guides/infinite-queries `useInfiniteQuery`} used for retrieving items from a `resource` with pagination, sort, and filter configurations. + * + * It uses the `getList` method as the query function from the `dataProvider` which is passed to ``. + * + * @see {@link https://refine.dev/docs/core/hooks/data/useInfiniteList} for more details. + * + * @typeParam TData - Result data of the query extends {@link https://refine.dev/docs/core/interfaceReferences#baserecord `BaseRecord`} + * @typeParam TError - Custom error object that extends {@link https://refine.dev/docs/core/interfaceReferences#httperror `HttpError`} + * + */ +export const useInfiniteList = < + TData extends BaseRecord = BaseRecord, + TError extends HttpError = HttpError, +>({ + resource, + config, + queryOptions, + successNotification, + errorNotification, + metaData, + liveMode, + onLiveEvent, + liveParams, + dataProviderName, +}: UseInfiniteListProps): InfiniteQueryObserverResult< + GetListResponse, + TError +> => { + const { resources } = useResource(); + const dataProvider = useDataProvider(); + const queryKey = queryKeys( + resource, + pickDataProvider(resource, dataProviderName, resources), + metaData, + ); + const { getList } = dataProvider( + pickDataProvider(resource, dataProviderName, resources), + ); + + const translate = useTranslate(); + const { mutate: checkError } = useCheckError(); + const handleNotification = useHandleNotification(); + + const isEnabled = + queryOptions?.enabled === undefined || queryOptions?.enabled === true; + + useResourceSubscription({ + resource, + types: ["*"], + params: { + metaData, + pagination: config?.pagination, + hasPagination: config?.hasPagination, + sort: config?.sort, + filters: config?.filters, + subscriptionType: "useList", + ...liveParams, + }, + channel: `resources/${resource}`, + enabled: isEnabled, + liveMode, + onLiveEvent, + }); + + const queryResponse = useInfiniteQuery, TError>( + queryKey.list(config), + ({ queryKey, pageParam, signal }) => { + const { hasPagination, ...restConfig } = config || {}; + const pagination = { + ...config?.pagination, + current: pageParam, + }; + + return getList({ + resource, + ...restConfig, + pagination, + hasPagination, + metaData: { + ...metaData, + queryContext: { + queryKey, + pageParam, + signal, + }, + }, + }).then(({ data, total, ...rest }) => { + return { + data, + total, + pagination, + ...rest, + }; + }); + }, + { + getNextPageParam: (lastPage) => getNextPageParam(lastPage), + getPreviousPageParam: (lastPage) => getPreviousPageParam(lastPage), + ...queryOptions, + onSuccess: (data) => { + queryOptions?.onSuccess?.(data); + + const notificationConfig = + typeof successNotification === "function" + ? successNotification( + data, + { metaData, config }, + resource, + ) + : successNotification; + + handleNotification(notificationConfig); + }, + onError: (err: TError) => { + checkError(err); + queryOptions?.onError?.(err); + + const notificationConfig = + typeof errorNotification === "function" + ? errorNotification(err, { metaData, config }, resource) + : errorNotification; + + handleNotification(notificationConfig, { + key: `${resource}-useInfiniteList-notification`, + message: translate( + "common:notifications.error", + { statusCode: err.statusCode }, + `Error (status code: ${err.statusCode})`, + ), + description: err.message, + type: "error", + }); + }, + }, + ); + + return queryResponse; +}; diff --git a/packages/core/src/index.tsx b/packages/core/src/index.tsx index d7caa48d6de6..23c9c8b2b910 100644 --- a/packages/core/src/index.tsx +++ b/packages/core/src/index.tsx @@ -93,5 +93,7 @@ export { importCSVMapper, routeGenerator, userFriendlyResourceName, + getNextPageParam, + getPreviousPageParam, } from "./definitions/helpers"; export { file2Base64 } from "./definitions/upload";