aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms/project-overview
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-10 12:16:59 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:15:27 +0100
commitd7bcd93efcd4f1ae20678d0efa6777cfadc09a4e (patch)
tree714edfa84a8f3c53262c407ac9a2a79c9d2479b8 /src/components/organisms/project-overview
parentf699802b837d7d9fcf150ff2bf00cd3c5475c87a (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')
-rw-r--r--src/components/organisms/project-overview/index.ts1
-rw-r--r--src/components/organisms/project-overview/project-overview.module.scss40
-rw-r--r--src/components/organisms/project-overview/project-overview.stories.tsx78
-rw-r--r--src/components/organisms/project-overview/project-overview.test.tsx127
-rw-r--r--src/components/organisms/project-overview/project-overview.tsx193
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 ? (
+ <>
+ ⭐&nbsp;<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);