From 56878f647ea0f1066fa3e222d7aa0d83057f496d Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Mon, 13 Nov 2023 17:45:59 +0100 Subject: refactor(components): rewrite PostsList component * remove NoResults component and move logic to Search page * add a usePostsList hook * remove Pagination from PostsList (it is only used if javascript is disabled and not on every posts list) * replace `byYear` prop with `sortByYear` * replace `loadMore` prop with `onLoadMore` * remove `showLoadMoreBtn` (we can use `loadMore` prop instead to determine if we need to display the button) * replace `titleLevel` prop with `headingLvl` * add `firstNewResult` prop to handle focus on the new results when loading more article (we should not focus a useless span but the item directly) --- src/components/organisms/index.ts | 2 +- src/components/organisms/layout/index.ts | 2 - .../organisms/layout/no-results.stories.tsx | 15 - .../organisms/layout/no-results.test.tsx | 12 - src/components/organisms/layout/no-results.tsx | 55 ---- .../organisms/layout/posts-list.fixture.tsx | 62 ---- .../organisms/layout/posts-list.module.scss | 66 ----- .../organisms/layout/posts-list.stories.tsx | 191 ------------ .../organisms/layout/posts-list.test.tsx | 31 -- src/components/organisms/layout/posts-list.tsx | 327 --------------------- .../nav/pagination/pagination.module.scss | 4 + .../organisms/nav/pagination/pagination.tsx | 13 +- src/components/organisms/posts-list/index.ts | 1 + .../organisms/posts-list/posts-list.module.scss | 40 +++ .../organisms/posts-list/posts-list.stories.tsx | 181 ++++++++++++ .../organisms/posts-list/posts-list.test.tsx | 75 +++++ src/components/organisms/posts-list/posts-list.tsx | 236 +++++++++++++++ .../templates/page/page-layout.stories.tsx | 30 +- src/i18n/en.json | 66 ++--- src/i18n/fr.json | 68 ++--- src/pages/blog/index.tsx | 101 ++++++- src/pages/blog/page/[number].tsx | 71 ++++- src/pages/recherche/index.tsx | 69 ++++- src/pages/sujet/[slug].tsx | 10 +- src/pages/thematique/[slug].tsx | 9 +- src/styles/pages/blog.module.scss | 18 ++ src/styles/pages/topic.module.scss | 6 - src/utils/helpers/index.ts | 1 + src/utils/helpers/refs.test.tsx | 28 ++ src/utils/helpers/refs.ts | 16 + src/utils/hooks/index.ts | 1 + src/utils/hooks/use-posts-list/index.ts | 1 + .../hooks/use-posts-list/use-posts-list.test.ts | 24 ++ src/utils/hooks/use-posts-list/use-posts-list.ts | 66 +++++ 34 files changed, 1013 insertions(+), 885 deletions(-) delete mode 100644 src/components/organisms/layout/index.ts delete mode 100644 src/components/organisms/layout/no-results.stories.tsx delete mode 100644 src/components/organisms/layout/no-results.test.tsx delete mode 100644 src/components/organisms/layout/no-results.tsx delete mode 100644 src/components/organisms/layout/posts-list.fixture.tsx delete mode 100644 src/components/organisms/layout/posts-list.module.scss delete mode 100644 src/components/organisms/layout/posts-list.stories.tsx delete mode 100644 src/components/organisms/layout/posts-list.test.tsx delete mode 100644 src/components/organisms/layout/posts-list.tsx create mode 100644 src/components/organisms/posts-list/index.ts create mode 100644 src/components/organisms/posts-list/posts-list.module.scss create mode 100644 src/components/organisms/posts-list/posts-list.stories.tsx create mode 100644 src/components/organisms/posts-list/posts-list.test.tsx create mode 100644 src/components/organisms/posts-list/posts-list.tsx create mode 100644 src/styles/pages/blog.module.scss delete mode 100644 src/styles/pages/topic.module.scss create mode 100644 src/utils/helpers/refs.test.tsx create mode 100644 src/utils/helpers/refs.ts create mode 100644 src/utils/hooks/use-posts-list/index.ts create mode 100644 src/utils/hooks/use-posts-list/use-posts-list.test.ts create mode 100644 src/utils/hooks/use-posts-list/use-posts-list.ts (limited to 'src') diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 04a985d..d0d1f99 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -1,9 +1,9 @@ export * from './comment'; export * from './comments-list'; export * from './forms'; -export * from './layout'; export * from './nav'; export * from './navbar'; +export * from './posts-list'; export * from './post-preview'; export * from './project-overview'; export * from './widgets'; diff --git a/src/components/organisms/layout/index.ts b/src/components/organisms/layout/index.ts deleted file mode 100644 index 03fba32..0000000 --- a/src/components/organisms/layout/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './no-results'; -export * from './posts-list'; diff --git a/src/components/organisms/layout/no-results.stories.tsx b/src/components/organisms/layout/no-results.stories.tsx deleted file mode 100644 index cfcee83..0000000 --- a/src/components/organisms/layout/no-results.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { NoResults as NoResultsComponent } from './no-results'; - -export default { - title: 'Organisms/Layout', - component: NoResultsComponent, - argTypes: {}, -} as ComponentMeta; - -const Template: ComponentStory = (args) => ( - -); - -export const NoResults = Template.bind({}); -NoResults.args = {}; diff --git a/src/components/organisms/layout/no-results.test.tsx b/src/components/organisms/layout/no-results.test.tsx deleted file mode 100644 index fdd86f7..0000000 --- a/src/components/organisms/layout/no-results.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { NoResults } from './no-results'; - -describe('NoResults', () => { - it('renders a text with a form', () => { - render(); - - expect(rtlScreen.getByText(/No results/i)).toBeInTheDocument(); - expect(rtlScreen.getByRole('searchbox')).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/layout/no-results.tsx b/src/components/organisms/layout/no-results.tsx deleted file mode 100644 index f760616..0000000 --- a/src/components/organisms/layout/no-results.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useRouter } from 'next/router'; -import { type FC, useCallback } from 'react'; -import { useIntl } from 'react-intl'; -import { ROUTES } from '../../../utils/constants'; -import { SearchForm, type SearchFormSubmit } from '../forms'; - -/** - * NoResults component - * - * Renders a no results text with a search form. - */ -export const NoResults: FC = () => { - const intl = useIntl(); - const router = useRouter(); - const searchSubmitHandler: SearchFormSubmit = useCallback( - ({ query }) => { - if (!query) - return { - messages: { - error: intl.formatMessage({ - defaultMessage: 'Query must be longer than one character.', - description: 'NoResults: invalid query message', - id: 'VkfO7t', - }), - }, - validator: (value) => value.query.length > 1, - }; - - router.push({ pathname: ROUTES.SEARCH, query: { s: query } }); - - return undefined; - }, - [intl, router] - ); - - return ( - <> -

- {intl.formatMessage({ - defaultMessage: 'No results found.', - description: 'NoResults: no results', - id: '5O2vpy', - })} -

-

- {intl.formatMessage({ - defaultMessage: 'Would you like to try a new search?', - description: 'NoResults: try a new search message', - id: 'DVBwfu', - })} -

- - - ); -}; diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx deleted file mode 100644 index e1f7a95..0000000 --- a/src/components/organisms/layout/posts-list.fixture.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import NextImage from 'next/image'; -import type { PostData } from './posts-list'; - -export const introPost1 = - 'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.'; - -export const introPost2 = - 'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.'; - -export const introPost3 = - 'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.'; - -export const cover = { - alt: 'cover', - height: 480, - src: 'http://picsum.photos/640/480', - width: 640, -}; - -export const posts: PostData[] = [ - { - cover: , - excerpt: introPost1, - id: 'post-1', - meta: { - publicationDate: '2022-02-26', - wordsCount: introPost1.split(' ').length, - thematics: [ - { id: 1, name: 'Cat 1', url: '#' }, - { id: 2, name: 'Cat 2', url: '#' }, - ], - comments: { count: 1, postHeading: 'Ratione velit fuga' }, - }, - heading: 'Ratione velit fuga', - url: '#', - }, - { - excerpt: introPost2, - id: 'post-2', - meta: { - publicationDate: '2022-02-20', - wordsCount: introPost2.split(' ').length, - thematics: [{ id: 2, name: 'Cat 2', url: '#' }], - comments: { count: 0, postHeading: 'Debitis laudantium laudantium' }, - }, - heading: 'Debitis laudantium laudantium', - url: '#', - }, - { - cover: , - excerpt: introPost3, - id: 'post-3', - meta: { - publicationDate: '2021-12-20', - wordsCount: introPost3.split(' ').length, - thematics: [{ id: 1, name: 'Cat 1', url: '#' }], - comments: { count: 3, postHeading: 'Quaerat ut corporis' }, - }, - heading: 'Quaerat ut corporis', - url: '#', - }, -]; diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss deleted file mode 100644 index cc5acda..0000000 --- a/src/components/organisms/layout/posts-list.module.scss +++ /dev/null @@ -1,66 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; -@use "../../../styles/abstracts/placeholders"; - -.section { - &:not(:last-of-type) { - margin-bottom: var(--spacing-md); - } - - @include mix.media("screen") { - @include mix.dimensions("md") { - display: grid; - grid-template-columns: fun.convert-px(150) minmax(0, 1fr); - align-items: first baseline; - margin-left: fun.convert-px(-150); - } - } -} - -.list { - .item { - border-bottom: fun.convert-px(1) solid var(--color-border); - } -} - -.year { - margin-bottom: var(--spacing-md); - padding-bottom: fun.convert-px(3); - background: linear-gradient( - to top, - var(--color-primary-dark) 0.3rem, - transparent 0.3rem - ) - 0 0 / 3rem 100% no-repeat; - font-size: var(--font-size-2xl); - font-weight: 500; - text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light); - - @include mix.media("screen") { - @include mix.dimensions("md") { - grid-column: 1; - justify-self: end; - padding-right: var(--spacing-lg); - position: sticky; - top: var(--spacing-xs); - } - - @include mix.dimensions("lg") { - padding-right: var(--spacing-xl); - } - } -} - -.btn { - display: flex; - margin: auto; -} - -.progress { - margin-block: var(--spacing-md); -} - -.pagination { - margin-inline: auto; - margin-block-end: var(--spacing-lg); -} diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/layout/posts-list.stories.tsx deleted file mode 100644 index b5af1d3..0000000 --- a/src/components/organisms/layout/posts-list.stories.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { PostsList } from './posts-list'; -import { posts } from './posts-list.fixture'; - -/** - * PostsList - Storybook Meta - */ -export default { - title: 'Organisms/Layout/PostsList', - component: PostsList, - args: { - byYear: false, - isLoading: false, - pageNumber: 1, - showLoadMoreBtn: false, - siblings: 1, - titleLevel: 2, - }, - argTypes: { - baseUrl: { - control: { - type: 'text', - }, - description: 'The pagination base url.', - table: { - category: 'Options', - defaultValue: { summary: '/page/' }, - }, - type: { - name: 'string', - required: false, - }, - }, - byYear: { - control: { - type: 'boolean', - }, - description: 'True to display the posts by year.', - table: { - category: 'Options', - defaultValue: { summary: false }, - }, - type: { - name: 'boolean', - 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, - }, - }, - pageNumber: { - control: { - type: 'number', - }, - description: 'The current page number.', - table: { - category: 'Options', - defaultValue: { summary: 1 }, - }, - type: { - name: 'number', - required: false, - }, - }, - posts: { - description: 'The posts data.', - type: { - name: 'object', - required: true, - 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, - }, - }, - siblings: { - control: { - type: 'number', - }, - description: 'The number of page siblings inside pagination.', - table: { - category: 'Options', - defaultValue: { summary: 1 }, - }, - type: { - name: 'number', - required: false, - }, - }, - titleLevel: { - control: { - type: 'number', - min: 1, - max: 6, - }, - description: 'The title level (hn).', - table: { - category: 'Options', - defaultValue: { summary: 2 }, - }, - type: { - name: 'number', - required: false, - }, - }, - total: { - control: { - type: 'number', - }, - description: 'The number of posts.', - type: { - name: 'number', - required: true, - }, - }, - }, -} as ComponentMeta; - -const Template: ComponentStory = (args) => ( - -); - -/** - * PostsList Stories - Default - */ -export const Default = Template.bind({}); -Default.args = { - posts, - total: posts.length, -}; - -/** - * PostsList Stories - By years - */ -export const ByYears = Template.bind({}); -ByYears.args = { - posts, - byYear: true, - total: posts.length, -}; -ByYears.decorators = [ - (Story) => ( -
- -
- ), -]; - -/** - * PostsList Stories - No results - */ -export const NoResults = Template.bind({}); -NoResults.args = { - posts: [], - total: posts.length, -}; diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx deleted file mode 100644 index fabf31f..0000000 --- a/src/components/organisms/layout/posts-list.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { PostsList } from './posts-list'; -import { posts } from './posts-list.fixture'; - -describe('PostsList', () => { - it('renders the correct number of posts', () => { - render(); - expect(rtlScreen.getAllByRole('article')).toHaveLength(posts.length); - }); - - it('renders the number of loaded posts', () => { - render(); - const info = `${posts.length} loaded articles out of a total of ${posts.length}`; - expect(rtlScreen.getByText(info)).toBeInTheDocument(); - }); - - it('renders a load more button', () => { - render( - - ); - expect( - rtlScreen.getByRole('button', { name: /Load more/i }) - ).toBeInTheDocument(); - }); - - it('renders a search form if no results', () => { - render(); - expect(rtlScreen.getByRole('searchbox')).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx deleted file mode 100644 index 40306a6..0000000 --- a/src/components/organisms/layout/posts-list.tsx +++ /dev/null @@ -1,327 +0,0 @@ -/* eslint-disable max-statements */ -import { type FC, Fragment, useRef, useCallback, useId } from 'react'; -import { useIntl } from 'react-intl'; -import { useIsMounted, useSettings } from '../../../utils/hooks'; -import { - Button, - Heading, - type HeadingLevel, - ProgressBar, - Spinner, - List, - ListItem, -} from '../../atoms'; -import { - Pagination, - type PaginationProps, - type RenderPaginationItemAriaLabel, - type RenderPaginationLink, -} from '../nav'; -import { - PostPreview, - type PostPreviewMetaData, - type PostPreviewProps, -} from '../post-preview'; -import { NoResults } from './no-results'; -import styles from './posts-list.module.scss'; - -export type PostData = Pick< - PostPreviewProps, - 'cover' | 'excerpt' | 'heading' | 'url' -> & { - /** - * The post id. - */ - id: string | number; - /** - * The post meta. - */ - meta: PostPreviewMetaData & - Required>; -}; - -export type YearCollection = Record; - -export type PostsListProps = Pick & { - /** - * The pagination base url. - */ - baseUrl?: string; - /** - * True to display the posts by year. Default: false. - */ - byYear?: boolean; - /** - * Determine if the data is loading. - */ - isLoading?: boolean; - /** - * Load more button handler. - */ - loadMore?: () => void; - /** - * The current page number. Default: 1. - */ - pageNumber?: number; - /** - * The posts data. - */ - posts: PostData[]; - /** - * Determine if the load more button should be visible. - */ - showLoadMoreBtn?: boolean; - /** - * The posts heading level (hn). - */ - titleLevel?: HeadingLevel; - /** - * The total posts number. - */ - total: number; -}; - -/** - * Create a collection of posts sorted by year. - * - * @param {PostData[]} data - A collection of posts. - * @returns {YearCollection} The posts sorted by year. - */ -const sortPostsByYear = (data: PostData[]): YearCollection => { - const yearCollection: Partial = {}; - - data.forEach((post) => { - const postYear = new Date(post.meta.publicationDate) - .getFullYear() - .toString(); - yearCollection[postYear] = [...(yearCollection[postYear] ?? []), post]; - }); - - return yearCollection as YearCollection; -}; - -/** - * PostsList component - * - * Render a list of post summaries. - */ -export const PostsList: FC = ({ - baseUrl = '', - byYear = false, - isLoading = false, - loadMore, - pageNumber = 1, - posts, - showLoadMoreBtn = false, - siblings, - titleLevel, - total, -}) => { - const intl = useIntl(); - const listRef = useRef(null); - const lastPostRef = useRef(null); - const isMounted = useIsMounted(listRef); - const { blog } = useSettings(); - const lastPostId = posts.length ? posts[posts.length - 1].id : 0; - const progressBarId = useId(); - - /** - * Retrieve the list of posts. - * - * @param {PostData[]} allPosts - A collection fo posts. - * @param {HeadingLevel} [headingLevel] - The posts heading level (hn). - * @returns {JSX.Element} The list of posts. - */ - const getList = ( - allPosts: PostData[], - headingLevel: HeadingLevel = 2 - ): JSX.Element => ( - - {allPosts.map(({ id, ...post }) => ( - - - - - {id === lastPostId && ( - - - - )} - - ))} - - ); - - /** - * Retrieve the list of posts. - * - * @returns {JSX.Element | JSX.Element[]} The posts list. - */ - const getPosts = (): JSX.Element | JSX.Element[] => { - const firstLevel = titleLevel ?? 2; - if (!byYear) return getList(posts, firstLevel); - - const postsPerYear = sortPostsByYear(posts); - const years = Object.keys(postsPerYear).reverse(); - const nextLevel = (firstLevel + 1) as HeadingLevel; - - return years.map((year) => ( -
- - {year} - - {getList(postsPerYear[year], nextLevel)} -
- )); - }; - - const loadedPostsCount = - pageNumber === 1 - ? posts.length - : pageNumber * blog.postsPerPage + posts.length; - const progressInfo = intl.formatMessage( - { - defaultMessage: - '{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}', - description: 'PostsList: loaded articles progress', - id: '9MeLN3', - }, - { - articlesCount: loadedPostsCount, - total, - } - ); - - const loadMoreBody = intl.formatMessage({ - defaultMessage: 'Load more articles?', - description: 'PostsList: load more button', - id: 'uaqd5F', - }); - - const loadingMoreArticles = intl.formatMessage({ - defaultMessage: 'Loading more articles...', - description: 'PostsList: loading more articles message', - id: 'xYemkP', - }); - - /** - * Load more posts handler. - */ - const loadMorePosts = useCallback(() => { - if (lastPostRef.current) { - lastPostRef.current.focus(); - } - - if (loadMore) loadMore(); - }, [loadMore]); - - const getProgressBar = () => ( - <> - - {showLoadMoreBtn ? ( - - ) : null} - - ); - - const paginationAriaLabel = intl.formatMessage({ - defaultMessage: 'Pagination', - description: 'PostsList: pagination accessible name', - id: 'k1aA+G', - }); - - const renderItemAriaLabel: RenderPaginationItemAriaLabel = useCallback( - ({ kind, pageNumber: page, isCurrentPage }) => { - switch (kind) { - case 'backward': - return intl.formatMessage({ - defaultMessage: 'Go to previous page', - description: 'PostsList: pagination backward link label', - id: 'PHO94k', - }); - case 'forward': - return intl.formatMessage({ - defaultMessage: 'Go to next page', - description: 'PostsList: pagination forward link label', - id: 'HaKhih', - }); - case 'number': - default: - return isCurrentPage - ? intl.formatMessage( - { - defaultMessage: 'Current page, page {number}', - description: 'PostsList: pagination current page label', - id: 'nwDGkZ', - }, - { number: page } - ) - : intl.formatMessage( - { - defaultMessage: 'Go to page {number}', - description: 'PostsList: pagination page link label', - id: 'AmHSC4', - }, - { number: page } - ); - } - }, - [intl] - ); - - const renderLink: RenderPaginationLink = useCallback( - (page) => `${baseUrl}${page}`, - [baseUrl] - ); - - const getPagination = () => { - if (total < blog.postsPerPage) return null; - - return ( - - ); - }; - - if (posts.length === 0) return ; - - return ( - <> - {getPosts()} - {isLoading ? {loadingMoreArticles} : null} - {isMounted ? getProgressBar() : getPagination()} - - ); -}; diff --git a/src/components/organisms/nav/pagination/pagination.module.scss b/src/components/organisms/nav/pagination/pagination.module.scss index 13970d3..eeb9ca6 100644 --- a/src/components/organisms/nav/pagination/pagination.module.scss +++ b/src/components/organisms/nav/pagination/pagination.module.scss @@ -4,6 +4,10 @@ gap: var(--spacing-sm); align-items: center; width: fit-content; + + &--centered { + margin-inline: auto; + } } .list { diff --git a/src/components/organisms/nav/pagination/pagination.tsx b/src/components/organisms/nav/pagination/pagination.tsx index 8e95122..006663b 100644 --- a/src/components/organisms/nav/pagination/pagination.tsx +++ b/src/components/organisms/nav/pagination/pagination.tsx @@ -31,6 +31,12 @@ export type PaginationProps = Omit & { * The currently active page number. */ current: number; + /** + * Should the pagination be centered? + * + * @default false + */ + isCentered?: boolean; /** * Function used to provide an accessible label to pagination items. */ @@ -83,6 +89,7 @@ const PaginationWithRef: ForwardRefRenderFunction< { className = '', current, + isCentered = false, renderItemAriaLabel, renderLink, siblings = 1, @@ -91,7 +98,11 @@ const PaginationWithRef: ForwardRefRenderFunction< }, ref ) => { - const paginationClass = `${styles.wrapper} ${className}`; + const paginationClass = [ + styles.wrapper, + styles[isCentered ? 'wrapper--centered' : ''], + className, + ].join(' '); const displayRange = current === 1 || current === total ? siblings + 1 : siblings; const hasPreviousPage = current > 1; diff --git a/src/components/organisms/posts-list/index.ts b/src/components/organisms/posts-list/index.ts new file mode 100644 index 0000000..a5faa8e --- /dev/null +++ b/src/components/organisms/posts-list/index.ts @@ -0,0 +1 @@ +export * from './posts-list'; diff --git a/src/components/organisms/posts-list/posts-list.module.scss b/src/components/organisms/posts-list/posts-list.module.scss new file mode 100644 index 0000000..fc0ef44 --- /dev/null +++ b/src/components/organisms/posts-list/posts-list.module.scss @@ -0,0 +1,40 @@ +@use "../../../styles/abstracts/functions" as fun; +@use "../../../styles/abstracts/mixins" as mix; +@use "../../../styles/abstracts/placeholders"; + +.section { + max-width: 100%; + margin-block-end: var(--spacing-md); + + @include mix.media("screen") { + @include mix.dimensions("md") { + display: grid; + grid-template-columns: var(--col1, auto) minmax(0, 1fr); + align-items: first baseline; + gap: var(--gap, var(--spacing-lg)); + } + } +} + +:where(.section) .year { + @extend %h2; + + margin-bottom: var(--spacing-md); + + @include mix.media("screen") { + @include mix.dimensions("md") { + grid-column: 1; + position: sticky; + top: var(--spacing-xs); + justify-self: end; + } + } +} + +.progress { + margin-block: var(--spacing-md); +} + +.btn { + margin-inline: auto; +} diff --git a/src/components/organisms/posts-list/posts-list.stories.tsx b/src/components/organisms/posts-list/posts-list.stories.tsx new file mode 100644 index 0000000..0a00afe --- /dev/null +++ b/src/components/organisms/posts-list/posts-list.stories.tsx @@ -0,0 +1,181 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import { PostsList } from './posts-list'; + +/** + * PostsList - Storybook Meta + */ +export default { + title: 'Organisms/PostsList', + component: PostsList, + args: {}, + argTypes: { + baseUrl: { + control: { + type: 'text', + }, + description: 'The pagination base url.', + table: { + category: 'Options', + defaultValue: { summary: '/page/' }, + }, + type: { + name: 'string', + required: false, + }, + }, + byYear: { + control: { + type: 'boolean', + }, + description: 'True to display the posts by year.', + table: { + category: 'Options', + defaultValue: { summary: false }, + }, + type: { + name: 'boolean', + 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, + }, + }, + pageNumber: { + control: { + type: 'number', + }, + description: 'The current page number.', + table: { + category: 'Options', + defaultValue: { summary: 1 }, + }, + type: { + name: 'number', + required: false, + }, + }, + posts: { + description: 'The posts data.', + type: { + name: 'object', + required: true, + 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, + }, + }, + siblings: { + control: { + type: 'number', + }, + description: 'The number of page siblings inside pagination.', + table: { + category: 'Options', + defaultValue: { summary: 1 }, + }, + type: { + name: 'number', + required: false, + }, + }, + titleLevel: { + control: { + type: 'number', + min: 1, + max: 6, + }, + description: 'The title level (hn).', + table: { + category: 'Options', + defaultValue: { summary: 2 }, + }, + type: { + name: 'number', + required: false, + }, + }, + total: { + control: { + type: 'number', + }, + description: 'The number of posts.', + type: { + name: 'number', + required: true, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ( + +); + +/** + * PostsList Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + posts: [ + { + excerpt: + 'Omnis voluptatem et sit sit porro possimus quo rerum. Natus et sint cupiditate magnam omnis a consequuntur reprehenderit. Ex omnis voluptatem itaque id laboriosam qui dolorum facilis architecto. Impedit aliquid et qui quae dolorum accusamus rerum.', + heading: 'Post 1', + id: 'post1', + meta: { publicationDate: '2023-11-06' }, + url: '#post1', + }, + { + excerpt: + 'Nobis omnis excepturi deserunt laudantium unde totam quam. Voluptates maiores minima voluptatem nihil ea voluptatem similique. Praesentium ratione necessitatibus et et dolore voluptas illum dignissimos ipsum. Eius tempore ex.', + heading: 'Post 2', + id: 'post2', + meta: { publicationDate: '2023-11-05' }, + url: '#post2', + }, + { + excerpt: + 'Doloremque est dolorum explicabo. Laudantium quos delectus odit esse fugit officiis. Fugit provident vero harum atque. Eos nam qui sit ut minus voluptas. Reprehenderit rerum ut nostrum. Eos dolores mollitia quia ea voluptatem rerum vel.', + heading: 'Post 3', + id: 'post3', + meta: { publicationDate: '2023-11-04' }, + url: '#post3', + }, + ], +}; diff --git a/src/components/organisms/posts-list/posts-list.test.tsx b/src/components/organisms/posts-list/posts-list.test.tsx new file mode 100644 index 0000000..8d91162 --- /dev/null +++ b/src/components/organisms/posts-list/posts-list.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { userEvent } from '@testing-library/user-event'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { type PostData, PostsList } from './posts-list'; + +const posts = [ + { + excerpt: + 'Omnis voluptatem et sit sit porro possimus quo rerum. Natus et sint cupiditate magnam omnis a consequuntur reprehenderit. Ex omnis voluptatem itaque id laboriosam qui dolorum facilis architecto. Impedit aliquid et qui quae dolorum accusamus rerum.', + heading: 'Post 1', + id: 'post1', + meta: { publicationDate: '2023-11-06' }, + url: '#post1', + }, + { + excerpt: + 'Nobis omnis excepturi deserunt laudantium unde totam quam. Voluptates maiores minima voluptatem nihil ea voluptatem similique. Praesentium ratione necessitatibus et et dolore voluptas illum dignissimos ipsum. Eius tempore ex.', + heading: 'Post 2', + id: 'post2', + meta: { publicationDate: '2023-02-05' }, + url: '#post2', + }, + { + excerpt: + 'Doloremque est dolorum explicabo. Laudantium quos delectus odit esse fugit officiis. Fugit provident vero harum atque. Eos nam qui sit ut minus voluptas. Reprehenderit rerum ut nostrum. Eos dolores mollitia quia ea voluptatem rerum vel.', + heading: 'Post 3', + id: 'post3', + meta: { publicationDate: '2022-10-04' }, + url: '#post3', + }, +] satisfies PostData[]; + +describe('PostsList', () => { + it('renders a list of posts', () => { + render(); + + expect(rtlScreen.getAllByRole('article')).toHaveLength(posts.length); + }); + + it('can render a list of posts divided by year in sections', () => { + const yearHeadingLvl = 2; + const yearCount = new Set( + posts.map((post) => post.meta.publicationDate.split('-')[0]) + ).size; + + render(); + + expect( + rtlScreen.getAllByRole('heading', { level: yearHeadingLvl }) + ).toHaveLength(yearCount); + expect( + rtlScreen.getAllByRole('heading', { level: yearHeadingLvl + 1 }) + ).toHaveLength(posts.length); + }); + + it('can render a load more button', async () => { + const loadMore = jest.fn(); + const user = userEvent.setup(); + + render(); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(3); + + expect(loadMore).not.toHaveBeenCalled(); + + const loadMoreBtn = rtlScreen.getByRole('button', { name: /Load more/ }); + + expect(loadMoreBtn).toBeInTheDocument(); + + await user.click(loadMoreBtn); + + expect(loadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/organisms/posts-list/posts-list.tsx b/src/components/organisms/posts-list/posts-list.tsx new file mode 100644 index 0000000..783bc4e --- /dev/null +++ b/src/components/organisms/posts-list/posts-list.tsx @@ -0,0 +1,236 @@ +import { + type ForwardRefRenderFunction, + type HTMLAttributes, + forwardRef, + useCallback, + useRef, + type RefCallback, +} from 'react'; +import { useIntl } from 'react-intl'; +import { mergeRefs } from '../../../utils/helpers'; +import { + Heading, + type HeadingLevel, + List, + ListItem, + Button, + ProgressBar, +} from '../../atoms'; +import { + PostPreview, + type PostPreviewMetaData, + type PostPreviewProps, +} from '../post-preview'; +import styles from './posts-list.module.scss'; + +const MAX_HEADING_LVL = 6; + +export type PostData = Pick< + PostPreviewProps, + 'cover' | 'excerpt' | 'heading' | 'url' +> & { + /** + * The post id. + */ + id: string | number; + /** + * The post meta. + */ + meta: PostPreviewMetaData & + Required>; +}; + +const getPostsByYear = (posts: PostData[]) => { + const yearCollection = new Map(); + + for (const post of posts) { + const currentPostYear = new Date(post.meta.publicationDate) + .getFullYear() + .toString(); + + const yearPosts = yearCollection.get(currentPostYear) ?? []; + + yearCollection.set(currentPostYear, [...yearPosts, post]); + } + + return yearCollection; +}; + +type GetPostsListOptions = { + headingLvl: HeadingLevel; + isOrdered?: boolean; +}; + +export type PostsListProps = Omit< + HTMLAttributes, + 'children' +> & { + /** + * The first new result index. It will be use to make the load more button + * accessible for keyboard users. + */ + firstNewResult?: number; + /** + * The heading level to use on posts titles. + * + * @default 2 + */ + headingLvl?: HeadingLevel; + /** + * Should we indicate that new posts are loading? + * + * @default false + */ + isLoading?: boolean; + /** + * A callback function to handle loading more posts. + */ + onLoadMore?: () => void; + /** + * The posts. + */ + posts: PostData[]; + /** + * Should we use a different section by year? + */ + sortByYear?: boolean; + /** + * The total posts number. + */ + total?: number; +}; + +const PostsListWithRef: ForwardRefRenderFunction< + HTMLDivElement, + PostsListProps +> = ( + { + firstNewResult, + headingLvl = 2, + isLoading = false, + onLoadMore, + posts, + sortByYear = false, + total, + ...props + }, + ref +) => { + const wrapperRef = useRef(null); + const firstNewResultRef: RefCallback = useCallback((el) => { + el?.focus(); + }, []); + const intl = useIntl(); + const progressInfo = intl.formatMessage( + { + defaultMessage: + '{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}', + description: 'PostsList: loaded articles progress', + id: '9MeLN3', + }, + { + articlesCount: posts.length, + total, + } + ); + const loadMoreBtn = intl.formatMessage({ + defaultMessage: 'Load more posts?', + description: 'PostsList: load more button', + id: 'hGvQpI', + }); + + const getPostsList = useCallback( + ( + data: PostData[], + { headingLvl: lvl, isOrdered }: GetPostsListOptions, + indexAcc = 0 + ) => ( + + {data.map(({ id, ...post }, index) => { + const isFirstNewResult = firstNewResult === indexAcc + index; + + return ( + + + + ); + })} + + ), + [firstNewResult, firstNewResultRef] + ); + + const getSortedPostsList = useCallback( + (data: PostData[]) => { + const postsByYear = Array.from(getPostsByYear(data)); + const postsLvl = + headingLvl < MAX_HEADING_LVL + ? ((headingLvl + 1) as HeadingLevel) + : headingLvl; + let indexAcc = 0; + + return postsByYear.map(([year, sortedPosts], index) => { + indexAcc += + index > 0 ? postsByYear[index - 1][1].length : sortedPosts.length; + + return ( +
+ + {year} + + {getPostsList( + sortedPosts, + { + headingLvl: postsLvl, + isOrdered: true, + }, + indexAcc + )} +
+ ); + }); + }, + [getPostsList, headingLvl] + ); + + return ( +
+ {sortByYear + ? getSortedPostsList(posts) + : getPostsList(posts, { headingLvl })} + {total ? ( + + ) : null} + {onLoadMore ? ( + + ) : null} +
+ ); +}; + +export const PostsList = forwardRef(PostsListWithRef); diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx index 4086fcd..20740db 100644 --- a/src/components/templates/page/page-layout.stories.tsx +++ b/src/components/templates/page/page-layout.stories.tsx @@ -1,7 +1,6 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { ButtonLink, Heading, Link } from '../../atoms'; import { LinksListWidget, PostsList, Sharing } from '../../organisms'; -import { posts } from '../../organisms/layout/posts-list.fixture'; import { LayoutBase } from '../layout/layout.stories'; import { PageLayout as PageLayoutComponent } from './page-layout'; @@ -465,6 +464,33 @@ const blogCategories = [ { name: 'Cat 4', url: '#' }, ]; +const posts = [ + { + excerpt: + 'Omnis voluptatem et sit sit porro possimus quo rerum. Natus et sint cupiditate magnam omnis a consequuntur reprehenderit. Ex omnis voluptatem itaque id laboriosam qui dolorum facilis architecto. Impedit aliquid et qui quae dolorum accusamus rerum.', + heading: 'Post 1', + id: 'post1', + meta: { publicationDate: '2023-11-06' }, + url: '#post1', + }, + { + excerpt: + 'Nobis omnis excepturi deserunt laudantium unde totam quam. Voluptates maiores minima voluptatem nihil ea voluptatem similique. Praesentium ratione necessitatibus et et dolore voluptas illum dignissimos ipsum. Eius tempore ex.', + heading: 'Post 2', + id: 'post2', + meta: { publicationDate: '2023-11-05' }, + url: '#post2', + }, + { + excerpt: + 'Doloremque est dolorum explicabo. Laudantium quos delectus odit esse fugit officiis. Fugit provident vero harum atque. Eos nam qui sit ut minus voluptas. Reprehenderit rerum ut nostrum. Eos dolores mollitia quia ea voluptatem rerum vel.', + heading: 'Post 3', + id: 'post3', + meta: { publicationDate: '2023-11-04' }, + url: '#post3', + }, +]; + /** * Page Layout Stories - Posts list */ @@ -473,7 +499,7 @@ Blog.args = { breadcrumb: postsListBreadcrumb, title: 'Blog', headerMeta: [{ id: 'total', label: 'Total:', value: `${posts.length}` }], - children: , + children: , widgets: [ ; @@ -68,7 +77,8 @@ const BlogPage: NextPageWithLayout = ({ title, url: ROUTES.BLOG, }); - + const postsListRef = useRef(null); + const isMounted = useIsMounted(postsListRef); const { blog, website } = useSettings(); const { asPath } = useRouter(); const page = { @@ -105,14 +115,15 @@ const BlogPage: NextPageWithLayout = ({ const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); const { - data, error, + firstNewResultIndex, isLoading, isLoadingMore, isRefreshing, hasNextPage, loadMore, - } = usePagination({ + posts, + } = usePostsList({ fallback: [articles], fetcher: getArticles, perPage: blog.postsPerPage, @@ -129,7 +140,54 @@ const BlogPage: NextPageWithLayout = ({ description: 'BlogPage: topics list widget title', id: '2D9tB5', }); - const postsListBaseUrl = `${ROUTES.BLOG}/page/`; + const renderPaginationLink: RenderPaginationLink = useCallback( + (pageNum) => `${ROUTES.BLOG}/page/${pageNum}`, + [] + ); + const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback( + ({ kind, pageNumber: number, isCurrentPage }) => { + switch (kind) { + case 'backward': + return intl.formatMessage( + { + defaultMessage: 'Go to previous page, page {number}', + description: 'BlogPage: previous page label', + id: 'faO6BQ', + }, + { number } + ); + case 'forward': + return intl.formatMessage( + { + defaultMessage: 'Go to next page, page {number}', + description: 'BlogPage: next page label', + id: 'oq3BzP', + }, + { number } + ); + case 'number': + default: + return isCurrentPage + ? intl.formatMessage( + { + defaultMessage: 'Current page, page {number}', + description: 'BlogPage: current page label', + id: 'JL6G22', + }, + { number } + ) + : intl.formatMessage( + { + defaultMessage: 'Go to page {number}', + description: 'BlogPage: page number label', + id: 'IVczxR', + }, + { number } + ); + } + }, + [intl] + ); const headerMeta: MetaItemData[] = totalArticles ? [ @@ -153,6 +211,12 @@ const BlogPage: NextPageWithLayout = ({ ] : []; + const paginationAriaLabel = intl.formatMessage({ + defaultMessage: 'Pagination', + description: 'BlogPage: pagination accessible name', + id: 'AXe1Iz', + }); + return ( <> @@ -206,17 +270,28 @@ const BlogPage: NextPageWithLayout = ({ />, ]} > - {data ? ( + {posts ? ( ) : null} + {isMounted ? null : ( + + )} {error ? ( = ({ description: 'BlogPage: topics list widget title', id: '2D9tB5', }); - const postsListBaseUrl = `${ROUTES.BLOG}/page/`; + const renderPaginationLink: RenderPaginationLink = useCallback( + (pageNum) => `${ROUTES.BLOG}/page/${pageNum}`, + [] + ); + const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback( + ({ kind, pageNumber: number, isCurrentPage }) => { + switch (kind) { + case 'backward': + return intl.formatMessage( + { + defaultMessage: 'Go to previous page, page {number}', + description: 'BlogPage: previous page label', + id: 'faO6BQ', + }, + { number } + ); + case 'forward': + return intl.formatMessage( + { + defaultMessage: 'Go to next page, page {number}', + description: 'BlogPage: next page label', + id: 'oq3BzP', + }, + { number } + ); + case 'number': + default: + return isCurrentPage + ? intl.formatMessage( + { + defaultMessage: 'Current page, page {number}', + description: 'BlogPage: current page label', + id: 'JL6G22', + }, + { number } + ) + : intl.formatMessage( + { + defaultMessage: 'Go to page {number}', + description: 'BlogPage: page number label', + id: 'IVczxR', + }, + { number } + ); + } + }, + [intl] + ); const headerMeta: MetaItemData[] = totalArticles ? [ @@ -155,6 +206,12 @@ const BlogPage: NextPageWithLayout = ({ ] : []; + const paginationAriaLabel = intl.formatMessage({ + defaultMessage: 'Pagination', + description: 'BlogPage: pagination accessible name', + id: 'AXe1Iz', + }); + return ( <> @@ -208,11 +265,13 @@ const BlogPage: NextPageWithLayout = ({ />, ]} > - + diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index a0e5057..effd087 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -3,6 +3,7 @@ import type { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; +import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { getLayout, @@ -13,6 +14,8 @@ import { PageLayout, PostsList, Spinner, + SearchForm, + type SearchFormSubmit, } from '../../components'; import { getArticles, @@ -22,9 +25,9 @@ import { getTotalThematics, getTotalTopics, } from '../../services/graphql'; +import styles from '../../styles/pages/blog.module.scss'; import type { NextPageWithLayout, - RawArticle, RawThematicPreview, RawTopicPreview, } from '../../types'; @@ -33,7 +36,6 @@ import { getBlogSchema, getLinksListItems, getPageLinkFromRawData, - getPostsList, getSchemaJson, getWebPageSchema, } from '../../utils/helpers'; @@ -41,7 +43,7 @@ import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { useBreadcrumb, useDataFromAPI, - usePagination, + usePostsList, useSettings, } from '../../utils/hooks'; @@ -59,7 +61,7 @@ const SearchPage: NextPageWithLayout = ({ topicsList, }) => { const intl = useIntl(); - const { asPath, query } = useRouter(); + const { asPath, query, push: routerPush } = useRouter(); const title = query.s ? intl.formatMessage( { @@ -116,14 +118,15 @@ const SearchPage: NextPageWithLayout = ({ const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); const { - data, error, + firstNewResultIndex, isLoading, isLoadingMore, isRefreshing, hasNextPage, loadMore, - } = usePagination({ + posts, + } = usePostsList({ fallback: [], fetcher: getArticles, perPage: blog.postsPerPage, @@ -167,13 +170,33 @@ const SearchPage: NextPageWithLayout = ({ description: 'SearchPage: topics list widget title', id: 'N804XO', }); - const postsListBaseUrl = `${ROUTES.SEARCH}/page/`; const loadingResults = intl.formatMessage({ defaultMessage: 'Loading the search results...', description: 'SearchPage: loading search results message', id: 'EeCqAE', }); + const searchSubmitHandler: SearchFormSubmit = useCallback( + ({ query: searchQuery }) => { + if (!searchQuery) + return { + messages: { + error: intl.formatMessage({ + defaultMessage: 'Query must be longer than one character.', + description: 'NoResults: invalid query message', + id: 'VkfO7t', + }), + }, + validator: (value) => value.query.length > 1, + }; + + routerPush({ pathname: ROUTES.SEARCH, query: { s: searchQuery } }); + + return undefined; + }, + [intl, routerPush] + ); + return ( <> @@ -227,18 +250,34 @@ const SearchPage: NextPageWithLayout = ({ />, ]} > - {data && data.length > 0 ? ( + {posts ? null : {loadingResults}} + {posts?.length ? ( ) : ( - {loadingResults} + <> +

+ {intl.formatMessage({ + defaultMessage: 'No results found.', + description: 'SearchPage: no results', + id: 'YV//MH', + })} +

+

+ {intl.formatMessage({ + defaultMessage: 'Would you like to try a new search?', + description: 'SearchPage: try a new search message', + id: 'vtDLzG', + })} +

+ + )} {error ? ( = ({ ); const pageUrl = `${website.url}${asPath}`; - const postsListBaseUrl = `${ROUTES.TOPICS}/page/`; return ( <> @@ -225,11 +224,10 @@ const TopicPage: NextPageWithLayout = ({ )} ) : null} diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index bb97f47..61d105e 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -20,6 +20,7 @@ import { getThematicsPreview, getTotalThematics, } from '../../services/graphql'; +import styles from '../../styles/pages/blog.module.scss'; import type { NextPageWithLayout, PageLink, Thematic } from '../../types'; import { ROUTES } from '../../utils/constants'; import { @@ -128,7 +129,6 @@ const ThematicPage: NextPageWithLayout = ({ id: '/42Z0z', }); const pageUrl = `${website.url}${asPath}`; - const postsListBaseUrl = `${ROUTES.THEMATICS.INDEX}/page/`; return ( <> @@ -197,11 +197,10 @@ const ThematicPage: NextPageWithLayout = ({ )} ) : null} diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss new file mode 100644 index 0000000..0fbde7c --- /dev/null +++ b/src/styles/pages/blog.module.scss @@ -0,0 +1,18 @@ +@use "../abstracts/functions" as fun; +@use "../abstracts/mixins" as mix; + +.list { + @include mix.media("screen") { + @include mix.dimensions("md") { + --col1: #{fun.convert-px(100)}; + --gap: var(--spacing-lg); + + margin-left: calc((var(--col1) + var(--gap)) * -1); + } + } +} + +.logo { + max-width: fun.convert-px(50); + margin: 0 var(--spacing-xs) 0 0; +} diff --git a/src/styles/pages/topic.module.scss b/src/styles/pages/topic.module.scss deleted file mode 100644 index bec4ba2..0000000 --- a/src/styles/pages/topic.module.scss +++ /dev/null @@ -1,6 +0,0 @@ -@use "../abstracts/functions" as fun; - -.logo { - max-width: fun.convert-px(50); - margin: 0 var(--spacing-xs) 0 0; -} diff --git a/src/utils/helpers/index.ts b/src/utils/helpers/index.ts index f340a49..79077de 100644 --- a/src/utils/helpers/index.ts +++ b/src/utils/helpers/index.ts @@ -2,6 +2,7 @@ export * from './author'; export * from './images'; export * from './pages'; export * from './reading-time'; +export * from './refs'; export * from './rss'; export * from './schema-org'; export * from './strings'; diff --git a/src/utils/helpers/refs.test.tsx b/src/utils/helpers/refs.test.tsx new file mode 100644 index 0000000..93e5f89 --- /dev/null +++ b/src/utils/helpers/refs.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, jest } from '@jest/globals'; +import { render } from '@testing-library/react'; +import { forwardRef, useImperativeHandle } from 'react'; +import { mergeRefs } from './refs'; + +const refValue = 'minus architecto qui'; +const TestComponentWithForwardedRef = forwardRef((_, ref) => { + useImperativeHandle(ref, () => refValue); + return null; +}); +TestComponentWithForwardedRef.displayName = 'TestComponentWithForwardedRef'; + +describe('merge-refs', () => { + it('can merge a ref function with a ref object', () => { + const refFn = jest.fn(); + const refObj = { current: null }; + + const TestComponent = () => ( + + ); + + render(); + + expect(refFn).toHaveBeenCalledTimes(1); + expect(refFn).toHaveBeenLastCalledWith(refValue); + expect(refObj.current).toBe(refValue); + }); +}); diff --git a/src/utils/helpers/refs.ts b/src/utils/helpers/refs.ts new file mode 100644 index 0000000..74a695a --- /dev/null +++ b/src/utils/helpers/refs.ts @@ -0,0 +1,16 @@ +import type { LegacyRef, MutableRefObject, RefCallback } from 'react'; +import type { Nullable } from '../../types'; + +export const mergeRefs = + ( + refs: (MutableRefObject | LegacyRef | undefined | null)[] + ): RefCallback => + (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value); + } else if (ref !== null) { + (ref as MutableRefObject>).current = value; + } + }); + }; diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 9cc2b0f..68fb7ce 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -14,6 +14,7 @@ export * from './use-match-media'; export * from './use-mutation-observer'; export * from './use-on-click-outside'; export * from './use-pagination'; +export * from './use-posts-list'; export * from './use-prism'; export * from './use-prism-theme'; export * from './use-reading-time'; diff --git a/src/utils/hooks/use-posts-list/index.ts b/src/utils/hooks/use-posts-list/index.ts new file mode 100644 index 0000000..664c142 --- /dev/null +++ b/src/utils/hooks/use-posts-list/index.ts @@ -0,0 +1 @@ +export * from './use-posts-list'; diff --git a/src/utils/hooks/use-posts-list/use-posts-list.test.ts b/src/utils/hooks/use-posts-list/use-posts-list.test.ts new file mode 100644 index 0000000..1d11111 --- /dev/null +++ b/src/utils/hooks/use-posts-list/use-posts-list.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import { getArticles } from '../../../services/graphql'; +import { usePostsList } from './use-posts-list'; + +describe('usePostsList', () => { + it('can return the first new result index when loading more posts', async () => { + const perPage = 5; + const { result } = renderHook(() => + usePostsList({ fetcher: getArticles, perPage }) + ); + + expect.assertions(2); + + expect(result.current.firstNewResultIndex).toBeUndefined(); + + await act(async () => { + await result.current.loadMore(); + }); + + // Assuming there is more than one page. + expect(result.current.firstNewResultIndex).toBe(perPage + 1); + }); +}); diff --git a/src/utils/hooks/use-posts-list/use-posts-list.ts b/src/utils/hooks/use-posts-list/use-posts-list.ts new file mode 100644 index 0000000..661727f --- /dev/null +++ b/src/utils/hooks/use-posts-list/use-posts-list.ts @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react'; +import type { PostData } from '../../../components'; +import type { Maybe, RawArticle } from '../../../types'; +import { getPostsList } from '../../helpers'; +import { + type UsePaginationConfig, + usePagination, + type UsePaginationReturn, +} from '../use-pagination'; + +export type usePostsListReturn = Omit< + UsePaginationReturn, + 'data' +> & { + /** + * The index of the first new result when loading more posts. + */ + firstNewResultIndex: Maybe; + /** + * The posts list. + */ + posts: Maybe; +}; + +export const usePostsList = ( + config: UsePaginationConfig +): usePostsListReturn => { + const { + data, + error, + hasNextPage, + isEmpty, + isError, + isLoading, + isLoadingMore, + isRefreshing, + isValidating, + loadMore, + size, + } = usePagination(config); + const [firstNewResultIndex, setFirstNewResultIndex] = + useState>(undefined); + + const posts = data ? getPostsList(data) : undefined; + + const handleLoadMore = useCallback(async () => { + setFirstNewResultIndex(size * config.perPage + 1); + + await loadMore(); + }, [config.perPage, loadMore, size]); + + return { + error, + firstNewResultIndex, + hasNextPage, + isEmpty, + isError, + isLoading, + isLoadingMore, + isRefreshing, + isValidating, + loadMore: handleLoadMore, + posts, + size, + }; +}; -- cgit v1.2.3