diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-05-13 15:39:55 +0200 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-05-13 15:46:05 +0200 |
| commit | dab72bb270ee2ee47a0b472d5e9e240cba7cbf0f (patch) | |
| tree | a64a49a1048eeab1204a9b04923135edd1f259e1 | |
| parent | c5b516e2c933e77b2550fe6becebacb3fbdd30eb (diff) | |
chore: handle blog pagination
| -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; |
