From 303cb62d3e7eb5e261a38e1611202f6edcd15d70 Mon Sep 17 00:00:00 2001 From: Daniel Marcano Date: Thu, 26 Oct 2023 12:24:49 +0200 Subject: [PATCH] Feat: create Common/BlogPostCard component (#6037) * feat: create shortHumanReadableDate to parse dates into a short friendlier to read version * feat: create Common/Card component * fix: update Avatar component styles to avoid layout shift issue * fix: remove h1 tag from Common/Preview component, to avoid having more than one h1 tag in any given page * feat: create Author type, rename property to authors, and modify the code accordingly * refactor: rename Card to BlogPostCard, remove subtitle prop, and merge firstName and lastName into a fullName prop * refactor: remove shortHumanReadableDate abstraction, and add unit tests to Common/BlogPostCard --- .../AvatarGroup/Avatar/index.module.css | 3 + .../BlogPostCard/__tests__/index.test.mjs | 123 ++++++++++++++++++ .../Common/BlogPostCard/index.module.css | 54 ++++++++ .../Common/BlogPostCard/index.stories.tsx | 45 +++++++ components/Common/BlogPostCard/index.tsx | 82 ++++++++++++ components/Common/Preview/index.tsx | 2 +- i18n/locales/en.json | 5 +- 7 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 components/Common/BlogPostCard/__tests__/index.test.mjs create mode 100644 components/Common/BlogPostCard/index.module.css create mode 100644 components/Common/BlogPostCard/index.stories.tsx create mode 100644 components/Common/BlogPostCard/index.tsx diff --git a/components/Common/AvatarGroup/Avatar/index.module.css b/components/Common/AvatarGroup/Avatar/index.module.css index 3fa1ddf0f8c5e..01286938d0582 100644 --- a/components/Common/AvatarGroup/Avatar/index.module.css +++ b/components/Common/AvatarGroup/Avatar/index.module.css @@ -18,5 +18,8 @@ .avatarRoot { @apply -ml-2 + h-8 + w-8 + flex-shrink-0 first:ml-0; } diff --git a/components/Common/BlogPostCard/__tests__/index.test.mjs b/components/Common/BlogPostCard/__tests__/index.test.mjs new file mode 100644 index 0000000000000..6d8ec4a490a7a --- /dev/null +++ b/components/Common/BlogPostCard/__tests__/index.test.mjs @@ -0,0 +1,123 @@ +import { render, screen } from '@testing-library/react'; + +import BlogPostCard from '@/components/Common/BlogPostCard'; +import { LocaleProvider } from '@/providers/localeProvider'; + +function renderBlogPostCard({ + title = 'Blog post title', + type = 'vulnerability', + description = 'Blog post description', + authors = [], + date = new Date(), +}) { + render( + + + + ); + + return { title, type, description, authors, date }; +} + +describe('BlogPostCard', () => { + describe('Rendering', () => { + it('Wraps the entire card within an article', () => { + renderBlogPostCard({}); + + expect(screen.getByRole('article')).toBeVisible(); + }); + + it('Renders the title prop correctly', () => { + const { title } = renderBlogPostCard({}); + + // Title from Preview component + expect(screen.getByRole('heading', { level: 2 })).toHaveTextContent( + title + ); + + // The second title should be hidden for screen-readers + // to prevent them from reading it twice + expect(screen.getAllByText(title)[1]).toHaveAttribute( + 'aria-hidden', + 'true' + ); + }); + + it('Renders the description prop correctly', () => { + const { description } = renderBlogPostCard({}); + + expect(screen.getByText(description)).toBeVisible(); + }); + + it.each([ + { label: 'Vulnerabilities', type: 'vulnerability' }, + { label: 'Announcements', type: 'announcement' }, + { label: 'Releases', type: 'release' }, + ])( + 'Renders "%label" text when passing it the type "%type"', + ({ label, type }) => { + renderBlogPostCard({ type }); + + expect(screen.getByText(label)).toBeVisible(); + } + ); + + it('Renders all passed authors fullName(s), comma-separated', () => { + const authors = [ + { fullName: 'Jane Doe', src: '' }, + { fullName: 'John Doe', src: '' }, + ]; + + renderBlogPostCard({ authors }); + + const fullNames = authors.reduce((prev, curr, index) => { + if (index === 0) { + return curr.fullName; + } + + return `${prev}, ${curr.fullName}`; + }, ''); + + expect(screen.getByText(fullNames)).toBeVisible(); + }); + + it('Renders all passed authors fullName(s), comma-separated', () => { + const authors = [ + { fullName: 'Jane Doe', src: '' }, + { fullName: 'John Doe', src: '' }, + ]; + + renderBlogPostCard({ authors }); + + const fullNames = authors.reduce((prev, curr, index) => { + if (index === 0) { + return curr.fullName; + } + + return `${prev}, ${curr.fullName}`; + }, ''); + + expect(screen.getByText(fullNames)).toBeVisible(); + }); + + it('Renders date prop in short format', () => { + const date = new Date(); + + renderBlogPostCard({ date }); + + const dateTimeFormat = new Intl.DateTimeFormat(navigator.language, { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + + expect(screen.getByText(dateTimeFormat.format(date))).toBeVisible(); + }); + }); +}); diff --git a/components/Common/BlogPostCard/index.module.css b/components/Common/BlogPostCard/index.module.css new file mode 100644 index 0000000000000..d5690e04c4659 --- /dev/null +++ b/components/Common/BlogPostCard/index.module.css @@ -0,0 +1,54 @@ +.container { + @apply max-w-full + bg-white + dark:bg-neutral-950; +} + +.preview { + @apply mb-6 + max-w-full + rounded + p-4; +} + +.subtitle { + @apply mb-2 + text-xs + font-semibold + text-green-600 + dark:text-green-400; +} + +.title { + @apply mb-2 + text-xl + font-semibold + text-neutral-900 + dark:text-white; +} + +.description { + @apply mb-6 + line-clamp-3 + text-sm + text-neutral-800 + dark:text-neutral-200; +} + +.footer { + @apply flex + gap-x-3; +} + +.author { + @apply text-sm + font-semibold + text-neutral-900 + dark:text-white; +} + +.date { + @apply text-sm + text-neutral-800 + dark:text-neutral-200; +} diff --git a/components/Common/BlogPostCard/index.stories.tsx b/components/Common/BlogPostCard/index.stories.tsx new file mode 100644 index 0000000000000..dbf3a370edf89 --- /dev/null +++ b/components/Common/BlogPostCard/index.stories.tsx @@ -0,0 +1,45 @@ +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +import BlogPostCard from '@/components/Common/BlogPostCard'; + +type Story = StoryObj; +type Meta = MetaObj; + +export const Default: Story = { + args: { + title: 'Node.js March 17th Infrastructure Incident Post-mortem', + type: 'vulnerability', + description: + 'Starting on March 15th and going through to March 17th (with much of the issue being mitigated on the 16th), users were receiving intermittent 404 responses when trying to download Node.js from nodejs.org, or even accessing parts of the website.', + authors: [ + { + fullName: 'Hayden Bleasel', + src: 'https://avatars.githubusercontent.com/u/', + }, + ], + date: new Date(), + }, + decorators: [ + Story => ( +
+ +
+ ), + ], +}; + +export const MoreThanOneAuthor: Story = { + ...Default, + args: { + ...Default.args, + authors: [ + ...(Default.args?.authors ?? []), + { + fullName: 'Jane Doe', + src: 'https://avatars.githubusercontent.com/u/', + }, + ], + }, +}; + +export default { component: BlogPostCard } as Meta; diff --git a/components/Common/BlogPostCard/index.tsx b/components/Common/BlogPostCard/index.tsx new file mode 100644 index 0000000000000..5b36beabd56b0 --- /dev/null +++ b/components/Common/BlogPostCard/index.tsx @@ -0,0 +1,82 @@ +import { useMemo } from 'react'; +import type { ComponentProps, FC } from 'react'; +import { FormattedMessage } from 'react-intl'; + +import AvatarGroup from '@/components/Common/AvatarGroup'; +import Preview from '@/components/Common/Preview'; + +import styles from './index.module.css'; + +const dateTimeFormat = new Intl.DateTimeFormat(navigator.language, { + day: 'numeric', + month: 'short', + year: 'numeric', +}); + +type Author = { + fullName: string; + src: string; +}; + +type BlogPostCardProps = { + title: ComponentProps['title']; + type: Required>['type']; + description: string; + authors: Author[]; + date: Date; +}; + +const BlogPostCard: FC = ({ + title, + type, + description, + authors, + date, +}) => { + const avatars = useMemo( + () => + authors.map(({ fullName, src }) => ({ + alt: fullName, + src, + toString: () => fullName, + })), + [authors] + ); + + const formattedDate = useMemo( + () => ({ + ISOString: date.toISOString(), + short: dateTimeFormat.format(date), + }), + [date] + ); + + return ( +
+ +

+ +

+ +

{description}

+
+ +
+

{avatars.join(', ')}

+ +
+
+
+ ); +}; + +export default BlogPostCard; diff --git a/components/Common/Preview/index.tsx b/components/Common/Preview/index.tsx index 8d98fa4fc6365..1f59ca7e2e28b 100644 --- a/components/Common/Preview/index.tsx +++ b/components/Common/Preview/index.tsx @@ -37,7 +37,7 @@ const Preview: FC = ({ src={`${basePath}/static/images/logos/js-white.svg`} alt="Node.js" /> -

{title}

+

{title}

); diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 61cac039e0aaf..d61aec8f8a8c4 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -64,5 +64,8 @@ "components.common.pagination.nextAriaLabel": "Next page", "components.common.pagination.defaultLabel": "Pagination", "components.common.pagination.pageLabel": "Go to page {pageNumber}", - "components.common.languageDropdown.label": "Choose Language" + "components.common.languageDropdown.label": "Choose Language", + "components.common.card.announcement": "Announcements", + "components.common.card.release": "Releases", + "components.common.card.vulnerability": "Vulnerabilities" }