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/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 +++++++++++++++++++++ 5 files changed, 533 insertions(+) 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 (limited to 'src/components/organisms/posts-list') 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); -- cgit v1.2.3