aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-13 15:39:55 +0200
committerArmand Philippot <git@armandphilippot.com>2022-05-13 15:46:05 +0200
commitdab72bb270ee2ee47a0b472d5e9e240cba7cbf0f (patch)
treea64a49a1048eeab1204a9b04923135edd1f259e1
parentc5b516e2c933e77b2550fe6becebacb3fbdd30eb (diff)
chore: handle blog pagination
-rw-r--r--src/components/atoms/loaders/progress-bar.module.scss2
-rw-r--r--src/components/organisms/layout/posts-list.stories.tsx44
-rw-r--r--src/components/organisms/layout/posts-list.test.tsx15
-rw-r--r--src/components/organisms/layout/posts-list.tsx73
-rw-r--r--src/components/organisms/layout/summary.module.scss34
-rw-r--r--src/components/organisms/layout/summary.tsx11
-rw-r--r--src/pages/blog/index.tsx110
-rw-r--r--src/services/graphql/articles.ts66
-rw-r--r--src/utils/helpers/rss.ts13
-rw-r--r--src/utils/hooks/use-pagination.tsx116
10 files changed, 415 insertions, 69 deletions
diff --git a/src/components/atoms/loaders/progress-bar.module.scss b/src/components/atoms/loaders/progress-bar.module.scss
index 166b7c4..878010a 100644
--- a/src/components/atoms/loaders/progress-bar.module.scss
+++ b/src/components/atoms/loaders/progress-bar.module.scss
@@ -1,7 +1,6 @@
@use "@styles/abstracts/functions" as fun;
.progress {
- width: max-content;
margin: var(--spacing-sm) auto var(--spacing-md);
text-align: center;
@@ -15,6 +14,7 @@
width: clamp(25ch, 20vw, 30ch);
max-width: 100%;
height: fun.convert-px(13);
+ margin: auto;
appearance: none;
background: var(--color-bg-tertiary);
border: fun.convert-px(1) solid var(--color-primary-darker);
diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx
index de0478f..77318f4 100644
--- a/src/components/organisms/layout/posts-list.stories.tsx
+++ b/src/components/organisms/layout/posts-list.stories.tsx
@@ -9,6 +9,9 @@ export default {
component: PostsList,
args: {
byYear: false,
+ isLoading: false,
+ showLoadMoreBtn: false,
+ titleLevel: 2,
},
argTypes: {
byYear: {
@@ -25,6 +28,33 @@ export default {
required: false,
},
},
+ isLoading: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the data is loading.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ loadMore: {
+ control: {
+ type: null,
+ },
+ description: 'A function to load more posts on button click.',
+ table: {
+ category: 'Events',
+ },
+ type: {
+ name: 'function',
+ required: false,
+ },
+ },
posts: {
description: 'The posts data.',
type: {
@@ -33,6 +63,20 @@ export default {
value: {},
},
},
+ showLoadMoreBtn: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if the load more button should be visible.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
titleLevel: {
control: {
type: 'number',
diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx
index 9b226ac..7429cbd 100644
--- a/src/components/organisms/layout/posts-list.test.tsx
+++ b/src/components/organisms/layout/posts-list.test.tsx
@@ -71,4 +71,19 @@ describe('PostsList', () => {
render(<PostsList posts={posts} total={posts.length} />);
expect(screen.getAllByRole('article')).toHaveLength(posts.length);
});
+
+ it('renders the number of loaded posts', () => {
+ render(<PostsList posts={posts} total={posts.length} />);
+ const info = `${posts.length} loaded articles out of a total of ${posts.length}`;
+ expect(screen.getByText(info)).toBeInTheDocument();
+ });
+
+ it('renders a load more button', () => {
+ render(
+ <PostsList posts={posts} total={posts.length} showLoadMoreBtn={true} />
+ );
+ expect(
+ screen.getByRole('button', { name: /Load more/i })
+ ).toBeInTheDocument();
+ });
});
diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx
index daf4491..4d77d20 100644
--- a/src/components/organisms/layout/posts-list.tsx
+++ b/src/components/organisms/layout/posts-list.tsx
@@ -1,10 +1,11 @@
+import Button from '@components/atoms/buttons/button';
import Heading, { type HeadingLevel } from '@components/atoms/headings/heading';
-import { FC } from 'react';
+import ProgressBar from '@components/atoms/loaders/progress-bar';
+import Spinner from '@components/atoms/loaders/spinner';
+import { FC, Fragment, useRef } from 'react';
import { useIntl } from 'react-intl';
-import Summary, { type SummaryProps } from './summary';
import styles from './posts-list.module.scss';
-import ProgressBar from '@components/atoms/loaders/progress-bar';
-import Button from '@components/atoms/buttons/button';
+import Summary, { type SummaryProps } from './summary';
export type Post = SummaryProps & {
/**
@@ -23,10 +24,22 @@ export type PostsListProps = {
*/
byYear?: boolean;
/**
+ * Determine if the data is loading.
+ */
+ isLoading?: boolean;
+ /**
+ * Load more button handler.
+ */
+ loadMore?: () => void;
+ /**
* The posts data.
*/
posts: Post[];
/**
+ * Determine if the load more button should be visible.
+ */
+ showLoadMoreBtn?: boolean;
+ /**
* The posts heading level (hn).
*/
titleLevel?: HeadingLevel;
@@ -62,29 +75,42 @@ const sortPostsByYear = (data: Post[]): YearCollection => {
*/
const PostsList: FC<PostsListProps> = ({
byYear = false,
+ isLoading = false,
+ loadMore,
posts,
+ showLoadMoreBtn = false,
titleLevel,
total,
}) => {
const intl = useIntl();
+ const lastPostRef = useRef<HTMLSpanElement>(null);
/**
* Retrieve the list of posts.
*
- * @param {Posts[]} data - A collection fo posts.
+ * @param {Posts[]} allPosts - A collection fo posts.
* @param {HeadingLevel} [headingLevel] - The posts heading level (hn).
* @returns {JSX.Element} The list of posts.
*/
const getList = (
- data: Post[],
+ allPosts: Post[],
headingLevel: HeadingLevel = 2
): JSX.Element => {
+ const lastPostId = allPosts[allPosts.length - 1].id;
+
return (
<ol className={styles.list}>
- {data.map(({ id, ...post }) => (
- <li key={id} className={styles.item}>
- <Summary {...post} titleLevel={headingLevel} />
- </li>
+ {allPosts.map(({ id, ...post }) => (
+ <Fragment key={id}>
+ <li className={styles.item}>
+ <Summary {...post} titleLevel={headingLevel} />
+ </li>
+ {id === lastPostId && (
+ <li>
+ <span ref={lastPostRef} tabIndex={-1} />
+ </li>
+ )}
+ </Fragment>
))}
</ol>
);
@@ -93,7 +119,7 @@ const PostsList: FC<PostsListProps> = ({
/**
* Retrieve the list of posts.
*
- * @returns {JSX.Element | JSX.Element[]} - The posts list.
+ * @returns {JSX.Element | JSX.Element[]} The posts list.
*/
const getPosts = (): JSX.Element | JSX.Element[] => {
if (!byYear) return getList(posts);
@@ -123,12 +149,23 @@ const PostsList: FC<PostsListProps> = ({
{ articlesCount: posts.length, total: total }
);
- const loadMore = intl.formatMessage({
+ const loadMoreBody = intl.formatMessage({
defaultMessage: 'Load more articles?',
id: 'uaqd5F',
description: 'PostsList: load more button',
});
+ /**
+ * Load more posts handler.
+ */
+ const loadMorePosts = () => {
+ if (lastPostRef.current) {
+ lastPostRef.current.focus();
+ }
+
+ loadMore && loadMore();
+ };
+
return posts.length === 0 ? (
<p>
{intl.formatMessage({
@@ -140,13 +177,23 @@ const PostsList: FC<PostsListProps> = ({
) : (
<>
{getPosts()}
+ {isLoading && <Spinner />}
<ProgressBar
min={1}
max={total}
current={posts.length}
info={progressInfo}
/>
- <Button className={styles.btn}>{loadMore}</Button>
+ {showLoadMoreBtn && (
+ <Button
+ kind="tertiary"
+ onClick={loadMorePosts}
+ disabled={isLoading}
+ className={styles.btn}
+ >
+ {loadMoreBody}
+ </Button>
+ )}
</>
);
};
diff --git a/src/components/organisms/layout/summary.module.scss b/src/components/organisms/layout/summary.module.scss
index 6d19853..5f22fbb 100644
--- a/src/components/organisms/layout/summary.module.scss
+++ b/src/components/organisms/layout/summary.module.scss
@@ -2,6 +2,10 @@
@use "@styles/abstracts/mixins" as mix;
.wrapper {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ column-gap: var(--spacing-md);
+ row-gap: var(--spacing-sm);
padding: var(--spacing-2xs) 0 var(--spacing-lg);
@include mix.media("screen") {
@@ -18,19 +22,26 @@
}
@include mix.dimensions("sm") {
- display: grid;
grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
grid-template-rows: repeat(3, max-content);
- column-gap: var(--spacing-md);
+ }
+ }
+
+ &:hover {
+ .icon {
+ transform: scaleX(1.4);
+ transform-origin: left;
}
}
}
.cover {
+ display: inline-flex;
+ flex-flow: column nowrap;
+ justify-content: center;
width: auto;
- max-height: fun.convert-px(100);
+ height: fun.convert-px(100);
max-width: 100%;
- margin-bottom: var(--spacing-sm);
border: fun.convert-px(1) solid var(--color-border);
@include mix.media("screen") {
@@ -70,7 +81,9 @@
}
.title {
+ margin: 0;
background: none;
+ color: inherit;
text-shadow: none;
}
@@ -79,18 +92,17 @@
flex-flow: row nowrap;
column-gap: var(--spacing-xs);
width: max-content;
- margin: var(--spacing-sm) 0;
+ margin: var(--spacing-sm) 0 0;
}
.meta {
- display: grid;
- grid-template-columns: repeat(
- auto-fit,
- min(100vw, calc(50% - var(--spacing-lg)))
- );
- margin-top: var(--spacing-lg);
+ flex-flow: row wrap;
font-size: var(--font-size-sm);
+ &__item {
+ flex: 1 0 min(calc(100vw - 2 * var(--spacing-md)), 14ch);
+ }
+
@include mix.media("screen") {
@include mix.dimensions("sm") {
display: flex;
diff --git a/src/components/organisms/layout/summary.tsx b/src/components/organisms/layout/summary.tsx
index 1c4a38b..078f9ee 100644
--- a/src/components/organisms/layout/summary.tsx
+++ b/src/components/organisms/layout/summary.tsx
@@ -141,12 +141,19 @@ const Summary: FC<SummaryProps> = ({
<ButtonLink target={url} className={styles['read-more']}>
<>
{readMore}
- <Arrow direction="right" />
+ <Arrow direction="right" className={styles.icon} />
</>
</ButtonLink>
</div>
<footer className={styles.footer}>
- <Meta data={getMeta(meta)} layout="column" className={styles.meta} />
+ <Meta
+ data={getMeta(meta)}
+ layout="column"
+ itemsLayout="stacked"
+ withSeparator={false}
+ className={styles.meta}
+ groupClassName={styles.meta__item}
+ />
</footer>
</article>
);
diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx
index dc72388..1e7581c 100644
--- a/src/pages/blog/index.tsx
+++ b/src/pages/blog/index.tsx
@@ -1,11 +1,17 @@
-import ProgressBar from '@components/atoms/loaders/progress-bar';
-import { BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
-import PostsList, { Post } from '@components/organisms/layout/posts-list';
+import { type BreadcrumbItem } from '@components/molecules/nav/breadcrumb';
+import PostsList, { type Post } from '@components/organisms/layout/posts-list';
import PageLayout from '@components/templates/page/page-layout';
-import { getArticles, getTotalArticles } from '@services/graphql/articles';
-import { Article, Meta } from '@ts/types/app';
+import { type EdgesResponse } from '@services/graphql/api';
+import {
+ getArticleFromRawData,
+ getArticles,
+ getTotalArticles,
+} from '@services/graphql/articles';
+import { type Article, type Meta } from '@ts/types/app';
+import { type RawArticle } from '@ts/types/raw-data';
import { settings } from '@utils/config';
-import { loadTranslation, Messages } from '@utils/helpers/i18n';
+import { loadTranslation, type Messages } from '@utils/helpers/i18n';
+import usePagination from '@utils/hooks/use-pagination';
import useSettings from '@utils/hooks/use-settings';
import { GetStaticProps, NextPage } from 'next';
import Head from 'next/head';
@@ -15,15 +21,15 @@ import { useIntl } from 'react-intl';
import { Blog, Graph, WebPage } from 'schema-dts';
type BlogPageProps = {
- posts: Article[];
- totalPosts: number;
+ articles: EdgesResponse<RawArticle>;
+ totalArticles: number;
translation: Messages;
};
/**
* Blog index page.
*/
-const BlogPage: NextPage<BlogPageProps> = ({ posts, totalPosts }) => {
+const BlogPage: NextPage<BlogPageProps> = ({ articles, totalArticles }) => {
const intl = useIntl();
const title = intl.formatMessage({
defaultMessage: 'Blog',
@@ -40,7 +46,7 @@ const BlogPage: NextPage<BlogPageProps> = ({ posts, totalPosts }) => {
{ id: 'blog', name: title, url: '/blog' },
];
- const { website } = useSettings();
+ const { blog, website } = useSettings();
const { asPath } = useRouter();
const pageTitle = intl.formatMessage(
{
@@ -98,11 +104,17 @@ const BlogPage: NextPage<BlogPageProps> = ({ posts, totalPosts }) => {
id: 'OF5cPz',
description: 'BlogPage: posts count meta',
},
- { postsCount: totalPosts }
+ { postsCount: totalArticles }
);
- const getPostMeta = (data: Meta<'article'>): Post['meta'] => {
- const { commentsCount, dates, thematics, wordsCount } = data;
+ /**
+ * Retrieve the formatted meta.
+ *
+ * @param {Meta<'article'>} meta - The article meta.
+ * @returns {Post['meta']} The formatted meta.
+ */
+ const getPostMeta = (meta: Meta<'article'>): Post['meta'] => {
+ const { commentsCount, dates, thematics, wordsCount } = meta;
return {
commentsCount,
@@ -114,7 +126,13 @@ const BlogPage: NextPage<BlogPageProps> = ({ posts, totalPosts }) => {
};
};
- const getPosts = (): Post[] => {
+ /**
+ * Retrieve the formatted posts.
+ *
+ * @param {Article[]} posts - An array of articles.
+ * @returns {Post[]} An array of formatted posts.
+ */
+ const getPosts = (posts: Article[]): Post[] => {
return posts.map((post) => {
return {
...post,
@@ -126,6 +144,45 @@ const BlogPage: NextPage<BlogPageProps> = ({ posts, totalPosts }) => {
});
};
+ /**
+ * Retrieve the posts list from raw data.
+ *
+ * @param {EdgesResponse<RawArticle>[]} rawData - The raw data.
+ * @returns {Post[]} An array of posts.
+ */
+ const getPostsList = (rawData: EdgesResponse<RawArticle>[]): Post[] => {
+ const articlesList: RawArticle[] = [];
+ rawData.forEach((articleData) =>
+ articleData.edges.forEach((edge) => {
+ articlesList.push(edge.node);
+ })
+ );
+
+ return getPosts(
+ articlesList.map((article) => getArticleFromRawData(article))
+ );
+ };
+
+ const {
+ data,
+ error,
+ isLoadingInitialData,
+ isLoadingMore,
+ hasNextPage,
+ setSize,
+ } = usePagination<RawArticle>({
+ fallbackData: [articles],
+ fetcher: getArticles,
+ perPage: blog.postsPerPage,
+ });
+
+ /**
+ * Load more posts handler.
+ */
+ const loadMore = () => {
+ setSize((prevSize) => prevSize + 1);
+ };
+
return (
<>
<Head>
@@ -146,21 +203,36 @@ const BlogPage: NextPage<BlogPageProps> = ({ posts, totalPosts }) => {
breadcrumb={breadcrumb}
headerMeta={{ total: postsCount }}
>
- <PostsList posts={getPosts()} byYear={true} total={totalPosts} />
+ {data && (
+ <PostsList
+ byYear={true}
+ isLoading={isLoadingMore || isLoadingInitialData}
+ loadMore={loadMore}
+ posts={getPostsList(data)}
+ showLoadMoreBtn={hasNextPage}
+ total={totalArticles}
+ />
+ )}
+ {error &&
+ intl.formatMessage({
+ defaultMessage: 'Failed to load.',
+ description: 'BlogPage: failed to load text',
+ id: 'C/XGkH',
+ })}
</PageLayout>
</>
);
};
export const getStaticProps: GetStaticProps = async ({ locale }) => {
- const posts = await getArticles({ first: settings.postsPerPage });
- const totalPosts = await getTotalArticles();
+ const articles = await getArticles({ first: settings.postsPerPage });
+ const totalArticles = await getTotalArticles();
const translation = await loadTranslation(locale);
return {
props: {
- posts: JSON.parse(JSON.stringify(posts.articles)),
- totalPosts,
+ articles: JSON.parse(JSON.stringify(articles)),
+ totalArticles,
translation,
},
};
diff --git a/src/services/graphql/articles.ts b/src/services/graphql/articles.ts
index 7aff3e0..1eb112e 100644
--- a/src/services/graphql/articles.ts
+++ b/src/services/graphql/articles.ts
@@ -1,17 +1,18 @@
-import { type Article, type ArticleCard } from '@ts/types/app';
+import { Slug, type Article, type ArticleCard } from '@ts/types/app';
import {
type RawArticle,
type RawArticlePreview,
type TotalItems,
} from '@ts/types/raw-data';
import { getAuthorFromRawData } from '@utils/helpers/author';
-import { getDates } from '@utils/helpers/dates';
import { getImageFromRawData } from '@utils/helpers/images';
import { getPageLinkFromRawData } from '@utils/helpers/pages';
-import { EdgesVars, fetchAPI, getAPIUrl, PageInfo } from './api';
+import { EdgesResponse, EdgesVars, fetchAPI, getAPIUrl, PageInfo } from './api';
import {
+ articleBySlugQuery,
articlesCardQuery,
articlesQuery,
+ articlesSlugQuery,
totalArticlesQuery,
} from './articles.query';
@@ -66,10 +67,7 @@ export const getArticleFromRawData = (data: RawArticle): Article => {
cover: featuredImage?.node
? getImageFromRawData(featuredImage.node)
: undefined,
- dates: {
- publication: date,
- update: modified,
- },
+ dates: { publication: date, update: modified },
readingTime: info.readingTime,
seo: {
description: seo?.metaDesc || '',
@@ -91,25 +89,19 @@ export const getArticleFromRawData = (data: RawArticle): Article => {
/**
* Retrieve the given number of articles from API.
*
- * @param {EdgesVars} obj - An object.
- * @param {number} obj.first - The number of articles.
- * @returns {Promise<GetArticlesReturn>} - The articles data.
+ * @param {EdgesVars} props - An object of GraphQL variables.
+ * @returns {Promise<EdgesResponse<RawArticle>>} The articles data.
*/
-export const getArticles = async ({
- first,
-}: EdgesVars): Promise<GetArticlesReturn> => {
+export const getArticles = async (
+ props: EdgesVars
+): Promise<EdgesResponse<RawArticle>> => {
const response = await fetchAPI<RawArticle, typeof articlesQuery>({
api: getAPIUrl(),
query: articlesQuery,
- variables: { first },
+ variables: { ...props },
});
- return {
- articles: response.posts.edges.map((edge) =>
- getArticleFromRawData(edge.node)
- ),
- pageInfo: response.posts.pageInfo,
- };
+ return response.posts;
};
/**
@@ -123,7 +115,7 @@ const getArticleCardFromRawData = (data: RawArticlePreview): ArticleCard => {
return {
cover: featuredImage ? getImageFromRawData(featuredImage.node) : undefined,
- dates: getDates(date, ''),
+ dates: { publication: date },
id: databaseId,
slug,
title,
@@ -148,3 +140,35 @@ export const getArticlesCard = async ({
return response.posts.nodes.map((node) => getArticleCardFromRawData(node));
};
+
+/**
+ * Retrieve an Article object by slug.
+ *
+ * @param {string} slug - The article slug.
+ * @returns {Promise<Article>} The requested article.
+ */
+export const getArticleBySlug = async (slug: string): Promise<Article> => {
+ const response = await fetchAPI<RawArticle, typeof articleBySlugQuery>({
+ api: getAPIUrl(),
+ query: articleBySlugQuery,
+ variables: { slug },
+ });
+
+ return getArticleFromRawData(response.post);
+};
+
+/**
+ * Retrieve all the articles slugs.
+ *
+ * @returns {Promise<string[]>} - An array of articles slugs.
+ */
+export const getAllArticlesSlugs = async (): Promise<string[]> => {
+ const totalArticles = await getTotalArticles();
+ const response = await fetchAPI<Slug, typeof articlesSlugQuery>({
+ api: getAPIUrl(),
+ query: articlesSlugQuery,
+ variables: { first: totalArticles },
+ });
+
+ return response.posts.edges.map((edge) => edge.node.slug);
+};
diff --git a/src/utils/helpers/rss.ts b/src/utils/helpers/rss.ts
index 95d3b7b..8ee774c 100644
--- a/src/utils/helpers/rss.ts
+++ b/src/utils/helpers/rss.ts
@@ -1,4 +1,8 @@
-import { getArticles, getTotalArticles } from '@services/graphql/articles';
+import {
+ getArticleFromRawData,
+ getArticles,
+ getTotalArticles,
+} from '@services/graphql/articles';
import { Article } from '@ts/types/app';
import { settings } from '@utils/config';
import { Feed } from 'feed';
@@ -10,7 +14,12 @@ import { Feed } from 'feed';
*/
const getAllArticles = async (): Promise<Article[]> => {
const totalArticles = await getTotalArticles();
- const { articles } = await getArticles({ first: totalArticles });
+ const rawArticles = await getArticles({ first: totalArticles });
+ const articles: Article[] = [];
+
+ rawArticles.edges.forEach((edge) =>
+ articles.push(getArticleFromRawData(edge.node))
+ );
return articles;
};
diff --git a/src/utils/hooks/use-pagination.tsx b/src/utils/hooks/use-pagination.tsx
new file mode 100644
index 0000000..1e24b75
--- /dev/null
+++ b/src/utils/hooks/use-pagination.tsx
@@ -0,0 +1,116 @@
+import { type EdgesResponse, type EdgesVars } from '@services/graphql/api';
+import useSWRInfinite, { SWRInfiniteKeyLoader } from 'swr/infinite';
+
+export type UsePaginationProps<T> = {
+ /**
+ * The initial data.
+ */
+ fallbackData: EdgesResponse<T>[];
+ /**
+ * A function to fetch more data.
+ */
+ fetcher: (props: EdgesVars) => Promise<EdgesResponse<T>>;
+ /**
+ * The number of results per page.
+ */
+ perPage: number;
+ /**
+ * An optional search string.
+ */
+ search?: string;
+};
+
+export type UsePaginationReturn<T> = {
+ /**
+ * The data from the API.
+ */
+ data?: EdgesResponse<T>[];
+ /**
+ * An error thrown by fetcher.
+ */
+ error: any;
+ /**
+ * Determine if there's more data to fetch.
+ */
+ hasNextPage?: boolean;
+ /**
+ * Determine if the initial data is loading.
+ */
+ isLoadingInitialData: boolean;
+ /**
+ * Determine if more data is currently loading.
+ */
+ isLoadingMore?: boolean;
+ /**
+ * Determine if the data is refreshing.
+ */
+ isRefreshing?: boolean;
+ /**
+ * Determine if there's a request or revalidation loading.
+ */
+ isValidating: boolean;
+ /**
+ * Set the number of pages that need to be fetched.
+ */
+ setSize: (
+ size: number | ((_size: number) => number)
+ ) => Promise<EdgesResponse<T>[] | undefined>;
+};
+
+/**
+ * Handle data fetching with pagination.
+ *
+ * This hook is a wrapper of `useSWRInfinite` hook.
+ *
+ * @param {UsePaginationProps} props - The pagination configuration.
+ * @returns {UsePaginationReturn} An object with pagination data and helpers.
+ */
+const usePagination = <T extends object>({
+ fallbackData,
+ fetcher,
+ perPage,
+ search,
+}: UsePaginationProps<T>): UsePaginationReturn<T> => {
+ const getKey: SWRInfiniteKeyLoader = (
+ pageIndex: number,
+ previousData: EdgesResponse<T>
+ ): EdgesVars | null => {
+ // Reached the end.
+ if (previousData && !previousData.edges.length) return null;
+
+ // Fetch data using this parameters.
+ return pageIndex === 0
+ ? { first: perPage, search }
+ : {
+ first: perPage,
+ after: previousData.pageInfo.endCursor,
+ search,
+ };
+ };
+
+ const { data, error, isValidating, size, setSize } = useSWRInfinite(
+ getKey,
+ fetcher,
+ { fallbackData }
+ );
+
+ const isLoadingInitialData = !data && !error;
+ const isLoadingMore =
+ isLoadingInitialData ||
+ (size > 0 && data && typeof data[size - 1] === 'undefined');
+ const isRefreshing = isValidating && data && data.length === size;
+ const hasNextPage = data && data[data.length - 1].pageInfo.hasNextPage;
+
+ return {
+ data,
+ error,
+ hasNextPage,
+ isLoadingInitialData,
+ isLoadingMore,
+ isRefreshing,
+ isValidating,
+ setSize,
+ };
+};
+
+export default usePagination;