diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/atoms/loaders/progress-bar.module.scss | 2 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.stories.tsx | 44 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.test.tsx | 15 | ||||
| -rw-r--r-- | src/components/organisms/layout/posts-list.tsx | 73 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.module.scss | 34 | ||||
| -rw-r--r-- | src/components/organisms/layout/summary.tsx | 11 | ||||
| -rw-r--r-- | src/pages/blog/index.tsx | 110 | ||||
| -rw-r--r-- | src/services/graphql/articles.ts | 66 | ||||
| -rw-r--r-- | src/utils/helpers/rss.ts | 13 | ||||
| -rw-r--r-- | src/utils/hooks/use-pagination.tsx | 116 | 
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; | 
