diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-10 12:16:59 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:27 +0100 |
| commit | d7bcd93efcd4f1ae20678d0efa6777cfadc09a4e (patch) | |
| tree | 714edfa84a8f3c53262c407ac9a2a79c9d2479b8 /src/components/organisms/project-overview | |
| parent | f699802b837d7d9fcf150ff2bf00cd3c5475c87a (diff) | |
refactor(components): replace Overview with ProjectOverview component
* `cover` prop is now expecting a ReactElement (NextImage)
* `meta` prop is now limited to a specific set of meta items
* add a `name` prop to add an accessible name to the figure element
Diffstat (limited to 'src/components/organisms/project-overview')
5 files changed, 439 insertions, 0 deletions
diff --git a/src/components/organisms/project-overview/index.ts b/src/components/organisms/project-overview/index.ts new file mode 100644 index 0000000..41f3e8c --- /dev/null +++ b/src/components/organisms/project-overview/index.ts @@ -0,0 +1 @@ +export * from './project-overview'; diff --git a/src/components/organisms/project-overview/project-overview.module.scss b/src/components/organisms/project-overview/project-overview.module.scss new file mode 100644 index 0000000..c9e4edb --- /dev/null +++ b/src/components/organisms/project-overview/project-overview.module.scss @@ -0,0 +1,40 @@ +@use "../../../styles/abstracts/functions" as fun; +@use "../../../styles/abstracts/mixins" as mix; + +.wrapper { + display: flex; + flex-flow: column wrap; + align-items: center; + width: fit-content; + row-gap: var(--spacing-md); + margin-inline: auto; + padding: var(--spacing-md); + border: fun.convert-px(1) solid var(--color-border); +} + +.heading { + margin-bottom: var(--spacing-md); +} + +.cover { + height: fit-content; + margin: 0; + + > img { + width: 100%; + max-height: fun.convert-px(250); + object-fit: cover; + } +} + +.meta { + grid-auto-flow: row; + column-gap: clamp(var(--spacing-sm), 1.5vw, var(--spacing-lg)); + row-gap: clamp(var(--spacing-2xs), 1vw, var(--spacing-sm)); + + @include mix.media("screen") { + @include mix.dimensions("xs") { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + } +} diff --git a/src/components/organisms/project-overview/project-overview.stories.tsx b/src/components/organisms/project-overview/project-overview.stories.tsx new file mode 100644 index 0000000..655dc3c --- /dev/null +++ b/src/components/organisms/project-overview/project-overview.stories.tsx @@ -0,0 +1,78 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import NextImage from 'next/image'; +import { type ProjectMeta, ProjectOverview } from './project-overview'; + +/** + * ProjectOverview - Storybook Meta + */ +export default { + title: 'Organisms/ProjectOverview', + component: ProjectOverview, + argTypes: { + cover: { + description: 'The project cover', + table: { + category: 'Options', + }, + type: { + name: 'object', + required: false, + value: {}, + }, + }, + meta: { + description: 'The overview meta.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + name: { + control: { + type: 'text', + }, + description: 'The project name.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof ProjectOverview>; + +const Template: ComponentStory<typeof ProjectOverview> = (args) => ( + <ProjectOverview {...args} /> +); + +const meta = { + creationDate: '2015-09-02', + lastUpdateDate: '2023-11-10', + license: 'MIT', +} satisfies Partial<ProjectMeta>; + +/** + * ProjectOverview Stories - Meta + */ +export const Meta = Template.bind({}); +Meta.args = { + meta, + name: 'Your project', +}; + +/** + * ProjectOverview Stories - With cover + */ +export const WithCover = Template.bind({}); +WithCover.args = { + cover: ( + <NextImage + alt="" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + ), + meta, + name: 'Your project', +}; diff --git a/src/components/organisms/project-overview/project-overview.test.tsx b/src/components/organisms/project-overview/project-overview.test.tsx new file mode 100644 index 0000000..6234368 --- /dev/null +++ b/src/components/organisms/project-overview/project-overview.test.tsx @@ -0,0 +1,127 @@ +import { describe, expect, it } from '@jest/globals'; +import NextImage from 'next/image'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { type ProjectMeta, ProjectOverview } from './project-overview'; + +describe('ProjectOverview', () => { + it('can render a meta for the creation date', () => { + const meta = { + creationDate: '2023-11-01', + } satisfies Partial<ProjectMeta>; + + render(<ProjectOverview meta={meta} name="quo" />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Created on:'); + }); + + it('can render a meta for the update date', () => { + const meta = { + lastUpdateDate: '2023-11-02', + } satisfies Partial<ProjectMeta>; + + render(<ProjectOverview meta={meta} name="quo" />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Updated on:'); + }); + + it('can render a meta for the license', () => { + const meta = { + license: 'MIT', + } satisfies Partial<ProjectMeta>; + + render(<ProjectOverview meta={meta} name="quo" />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('License:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.license); + }); + + it('can render a meta for the popularity', () => { + const meta = { + popularity: { count: 5 }, + } satisfies Partial<ProjectMeta>; + + render(<ProjectOverview meta={meta} name="quo" />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Popularity:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + `${meta.popularity.count} stars` + ); + }); + + it('can render a meta for the popularity with a link', () => { + const meta = { + popularity: { count: 3, url: '#popularity' }, + } satisfies Partial<ProjectMeta>; + + render(<ProjectOverview meta={meta} name="quo" />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Popularity:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + `${meta.popularity.count} stars` + ); + expect(rtlScreen.getByRole('link')).toHaveAttribute( + 'href', + meta.popularity.url + ); + }); + + it('can render a meta for the technologies', () => { + const meta = { + technologies: ['Javascript', 'React'], + } satisfies Partial<ProjectMeta>; + + render(<ProjectOverview meta={meta} name="quo" />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Technologies:'); + expect(rtlScreen.getAllByRole('definition')).toHaveLength( + meta.technologies.length + ); + }); + + it('can render a meta for the repositories', () => { + const meta = { + repositories: [{ id: 'Github', label: 'Github', url: '#github' }], + } satisfies Partial<ProjectMeta>; + + render(<ProjectOverview meta={meta} name="quo" />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Repositories:'); + expect(rtlScreen.getAllByRole('definition')).toHaveLength( + meta.repositories.length + ); + expect( + rtlScreen.getByRole('link', { name: meta.repositories[0].label }) + ).toHaveAttribute('href', meta.repositories[0].url); + }); + + it('can render a cover', () => { + const altTxt = 'id qui nisi'; + + render( + <ProjectOverview + cover={ + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + } + meta={{}} + name="qui" + /> + ); + + expect(rtlScreen.getByRole('img')).toHaveAccessibleName(altTxt); + }); + + it('does not render a meta if the key is undefined', () => { + const meta = { + creationDate: undefined, + } satisfies Partial<ProjectMeta>; + + render(<ProjectOverview meta={meta} name="quo" />); + + expect(rtlScreen.queryByRole('term')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/project-overview/project-overview.tsx b/src/components/organisms/project-overview/project-overview.tsx new file mode 100644 index 0000000..2b8be0e --- /dev/null +++ b/src/components/organisms/project-overview/project-overview.tsx @@ -0,0 +1,193 @@ +import { + type ForwardRefRenderFunction, + type HTMLAttributes, + forwardRef, + useCallback, + type ReactElement, +} from 'react'; +import { useIntl } from 'react-intl'; +import type { Maybe, ValueOf } from '../../../types'; +import { + Time, + type SocialWebsite, + Link, + SocialLink, + Figure, +} from '../../atoms'; +import { MetaList, type MetaItemData } from '../../molecules'; +import styles from './project-overview.module.scss'; + +export type Repository = { + id: Extract<SocialWebsite, 'Github' | 'Gitlab'>; + label: string; + url: string; +}; + +export type ProjectPopularity = { + count: number; + url?: string; +}; + +export type ProjectMeta = { + creationDate: string; + lastUpdateDate: string; + license: string; + popularity: ProjectPopularity; + repositories: Repository[]; + technologies: string[]; +}; + +const validMeta = [ + 'creationDate', + 'lastUpdateDate', + 'license', + 'popularity', + 'repositories', + 'technologies', +] satisfies (keyof ProjectMeta)[]; + +const isValidMetaKey = (key: string): key is keyof ProjectMeta => + (validMeta as string[]).includes(key); + +export type ProjectOverviewProps = Omit< + HTMLAttributes<HTMLDivElement>, + 'children' +> & { + /** + * The project cover. + */ + cover?: ReactElement; + /** + * The project meta. + */ + meta: Partial<ProjectMeta>; + /** + * The project name. + */ + name: string; +}; + +const ProjectOverviewWithRef: ForwardRefRenderFunction< + HTMLDivElement, + ProjectOverviewProps +> = ({ className = '', cover, meta, name, ...props }, ref) => { + const wrapperClass = `${styles.wrapper} ${className}`; + const intl = useIntl(); + const coverLabel = intl.formatMessage( + { + defaultMessage: 'Illustration of {projectName}', + description: 'ProjectOverview: cover accessible name', + id: '701ggm', + }, + { projectName: name } + ); + const metaLabels = { + creationDate: intl.formatMessage({ + defaultMessage: 'Created on:', + description: 'ProjectOverview: creation date label', + id: 'c0Oecl', + }), + lastUpdateDate: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'ProjectOverview: update date label', + id: 'JbT+fA', + }), + license: intl.formatMessage({ + defaultMessage: 'License:', + description: 'ProjectOverview: license label', + id: 'QtdnFV', + }), + popularity: intl.formatMessage({ + defaultMessage: 'Popularity:', + description: 'ProjectOverview: popularity label', + id: 'cIAOyy', + }), + repositories: intl.formatMessage({ + defaultMessage: 'Repositories:', + description: 'ProjectOverview: repositories label', + id: '3bKzk0', + }), + technologies: intl.formatMessage({ + defaultMessage: 'Technologies:', + description: 'ProjectOverview: technologies label', + id: 'OWkqXt', + }), + } satisfies Record<keyof ProjectMeta, string>; + + const getMetaValue = useCallback( + (key: keyof ProjectMeta, value: ValueOf<ProjectMeta>) => { + if (typeof value === 'string') { + return key === 'license' ? value : <Time date={value} />; + } + + if ( + (value instanceof Object || typeof value === 'object') && + !Array.isArray(value) + ) { + const stars = intl.formatMessage( + { + defaultMessage: + '{starsCount, plural, =0 {No stars} one {# star} other {# stars}}', + description: 'ProjectOverview: stars count', + id: 'PBdVsm', + }, + { starsCount: value.count } + ); + + return value.url ? ( + <> + ⭐ <Link href={value.url}>{stars}</Link> + </> + ) : ( + `⭐\u00A0${stars}` + ); + } + + return value.map((v) => { + if (typeof v === 'string') return { id: v, value: v }; + + return { + id: v.id, + value: <SocialLink icon={v.id} label={v.label} url={v.url} />, + }; + }); + }, + [intl] + ); + + const getMetaItems = useCallback((): MetaItemData[] => { + const keys = Object.keys(meta).filter(isValidMetaKey); + + return keys + .map((key): Maybe<MetaItemData> => { + const value = meta[key]; + + return value + ? { + id: key, + label: metaLabels[key], + value: getMetaValue(key, value), + hasBorderedValues: key === 'technologies', + hasInlinedValues: + (key === 'technologies' || key === 'repositories') && + Array.isArray(value) && + value.length > 1, + } + : undefined; + }) + .filter((item): item is MetaItemData => typeof item !== 'undefined'); + }, [getMetaValue, meta, metaLabels]); + + return ( + <div {...props} className={wrapperClass} ref={ref}> + {cover ? ( + <Figure aria-label={coverLabel} className={styles.cover} hasBorders> + {cover} + </Figure> + ) : null} + <MetaList className={styles.meta} isInline items={getMetaItems()} /> + </div> + ); +}; + +export const ProjectOverview = forwardRef(ProjectOverviewWithRef); |
