Skip to content

feat: introduced changelog modal on downloads #6393

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions app/[locale]/next-data/changelog-data/[version]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { provideChangelogData } from '@/next-data/providers/changelogData';
import provideReleaseData from '@/next-data/providers/releaseData';
import { VERCEL_REVALIDATE } from '@/next.constants.mjs';
import { defaultLocale } from '@/next.locales.mjs';

type StaticParams = {
params: { version: string };
};

// This is the Route Handler for the `GET` method which handles the request
// for generating static data related to the Node.js Changelog Data
// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers
export const GET = async (_: Request, { params }: StaticParams) => {
const changelogData = await provideChangelogData(params.version);

return Response.json(changelogData);
};

// This function generates the static paths that come from the dynamic segments
// `[locale]/next-data/changelog-data/[version]` and returns an array of all available static paths
// This is used for ISR static validation and generation
export const generateStaticParams = async () => {
const releases = provideReleaseData();

const mappedParams = releases.map(release => ({
locale: defaultLocale.code,
version: String(release.versionWithPrefix),
}));

return mappedParams;
};

// Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary
// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams
export const dynamicParams = false;

// Enforces that this route is used as static rendering
// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic
export const dynamic = 'error';

// Ensures that this endpoint is invalidated and re-executed every X minutes
// so that when new deployments happen, the data is refreshed
// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate
export const revalidate = VERCEL_REVALIDATE;
25 changes: 11 additions & 14 deletions components/Downloads/ChangelogModal/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
inset-0
flex
justify-center
bg-white
bg-opacity-90
backdrop-blur-lg
dark:bg-neutral-950
dark:bg-opacity-80;
bg-white/40
backdrop-blur-[2px]
dark:bg-neutral-950/40;

.content {
@apply relative
Expand All @@ -16,17 +14,16 @@
inline-flex
w-full
flex-col
overflow-y-scroll
overflow-y-auto
rounded
border
border-neutral-200
bg-white
p-8
focus:outline-none
dark:bg-neutral-950
sm:mt-20
lg:w-2/3
xl:w-3/5
sm:my-20
lg:max-w-[900px]
xl:p-12
xs:p-6;
}
Expand All @@ -38,7 +35,11 @@
block
size-6
cursor-pointer
sm:hidden;
rounded
focus:outline-none
focus:ring-2
focus:ring-neutral-200
dark:focus:ring-neutral-900;
}

.title {
Expand Down Expand Up @@ -85,10 +86,6 @@
flex-col
gap-4;

a {
@apply underline;
}

pre {
@apply overflow-auto;
}
Expand Down
9 changes: 4 additions & 5 deletions components/Downloads/ChangelogModal/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
import { VFile } from 'vfile';

import Button from '@/components/Common/Button';
import ChangelogModal from '@/components/Downloads/ChangelogModal';
import { MDXRenderer } from '@/components/mdxRenderer';
import { compileMDX } from '@/next.mdx.compiler.mjs';
Expand Down Expand Up @@ -178,7 +177,7 @@ ZCVKLyezajjko28SugXGjegEjcY4o7v23XghhW6RAbEB6R8TZDo=

export const Default: Story = {
args: {
trigger: <Button>Trigger</Button>,
open: false,
heading: 'Node v18.17.0',
subheading: "2023-07-18, Version 18.17.0 'Hydrogen' (LTS), @danielleadams",
avatars: names.map(name => ({
Expand All @@ -189,15 +188,15 @@ export const Default: Story = {
},
render: (_, { loaded: { Content } }) => Content,
loaders: [
async ({ args }) => {
async ({ args: { children, ...props } }) => {
const { MDXContent } = await compileMDX(
new VFile(args.children?.toString()),
new VFile(children?.toString()),
'md'
);

return {
Content: (
<ChangelogModal {...args}>
<ChangelogModal {...props}>
<main>
<MDXRenderer Component={MDXContent} />
</main>
Expand Down
26 changes: 17 additions & 9 deletions components/Downloads/ChangelogModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,59 @@
'use client';

import { ArrowUpRightIcon, XMarkIcon } from '@heroicons/react/24/outline';
import * as Dialog from '@radix-ui/react-dialog';
import { useTranslations } from 'next-intl';
import type { FC, PropsWithChildren, ReactNode, ComponentProps } from 'react';
import type { FC, PropsWithChildren, ComponentProps } from 'react';

import AvatarGroup from '@/components/Common/AvatarGroup';
import Link from '@/components/Link';

import styles from './index.module.css';

type ChangelogModalProps = {
type ChangelogModalProps = PropsWithChildren<{
heading: string;
subheading: string;
avatars: ComponentProps<typeof AvatarGroup>['avatars'];
trigger: ReactNode;
children: ReactNode;
};
open?: boolean;
onOpenChange?: (open: boolean) => void;
}>;

const ChangelogModal: FC<PropsWithChildren<ChangelogModalProps>> = ({
const ChangelogModal: FC<ChangelogModalProps> = ({
heading,
subheading,
avatars,
trigger,
children,
open = false,
onOpenChange = () => {},
}) => {
const t = useTranslations();

return (
<Dialog.Root>
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className={styles.overlay}>
<Dialog.Content className={styles.content}>
<Dialog.Trigger className={styles.close}>
<XMarkIcon />
</Dialog.Trigger>

<Dialog.Title className={styles.title}>{heading}</Dialog.Title>

<Dialog.Description className={styles.description}>
{subheading}
</Dialog.Description>

<div className={styles.authors}>
<AvatarGroup avatars={avatars} isExpandable={false} />

<Link href="/about/get-involved">
{t('components.downloads.changelogModal.startContributing')}
<ArrowUpRightIcon />
</Link>
</div>

<div className={styles.wrapper}>{children}</div>

<Dialog.Close />
</Dialog.Content>
</Dialog.Overlay>
Expand Down
4 changes: 2 additions & 2 deletions components/Downloads/DownloadReleasesTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { FC } from 'react';

import getReleaseData from '@/next-data/releaseData';
import { getNodeApiLink } from '@/util/getNodeApiLink';
import { getNodejsChangelog } from '@/util/getNodeJsChangelog';
import { getNodeJsChangelog } from '@/util/getNodeJsChangelog';

// This is a React Async Server Component
// Note that Hooks cannot be used in a RSC async component
Expand Down Expand Up @@ -38,7 +38,7 @@ const DownloadReleasesTable: FC = async () => {
>
{t('components.downloadReleasesTable.releases')}
</a>
<a href={getNodejsChangelog(release.versionWithPrefix)}>
<a href={getNodeJsChangelog(release.versionWithPrefix)}>
{t('components.downloadReleasesTable.changelog')}
</a>
<a href={getNodeApiLink(release.versionWithPrefix)}>
Expand Down
19 changes: 19 additions & 0 deletions components/Downloads/Release/ChangelogLink.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client';

import type { FC, PropsWithChildren } from 'react';
import { useContext } from 'react';

import LinkWithArrow from '@/components/Downloads/Release/LinkWithArrow';
import { ReleaseContext } from '@/providers/releaseProvider';

const ChangelogLink: FC<PropsWithChildren> = ({ children }) => {
const { modalOpen, setModalOpen } = useContext(ReleaseContext);

return (
<button onClick={() => setModalOpen(!modalOpen)}>
<LinkWithArrow className="cursor-pointer">{children}</LinkWithArrow>
</button>
);
};

export default ChangelogLink;
7 changes: 3 additions & 4 deletions components/Downloads/Release/LinkWithArrow.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { ArrowUpRightIcon } from '@heroicons/react/24/solid';
import type { FC, PropsWithChildren } from 'react';
import type { ComponentProps, FC } from 'react';

import Link from '@/components/Link';

type AccessibleAnchorProps = { href?: string };

const LinkWithArrow: FC<PropsWithChildren<AccessibleAnchorProps>> = ({
const LinkWithArrow: FC<ComponentProps<typeof Link>> = ({
children,
...props
}) => (
Expand All @@ -14,4 +12,5 @@ const LinkWithArrow: FC<PropsWithChildren<AccessibleAnchorProps>> = ({
<ArrowUpRightIcon className="ml-1 inline w-3 fill-neutral-600 dark:fill-white" />
</Link>
);

export default LinkWithArrow;
4 changes: 3 additions & 1 deletion components/mdxRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { MDXComponents, MDXContent } from 'mdx/types';
import type { FC } from 'react';

import { htmlComponents, mdxComponents } from '@/next.mdx.use.mjs';
import { htmlComponents, clientMdxComponents } from '@/next.mdx.use.client.mjs';
import { mdxComponents } from '@/next.mdx.use.mjs';

// Combine all MDX Components to be used
const combinedComponents: MDXComponents = {
...htmlComponents,
...clientMdxComponents,
...mdxComponents,
};

Expand Down
97 changes: 97 additions & 0 deletions components/withChangelogModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
'use client';

import { useState, useEffect } from 'react';
import type { FC, ReactElement } from 'react';
import { VFile } from 'vfile';

import ChangelogModal from '@/components/Downloads/ChangelogModal';
import changelogData from '@/next-data/changelogData';
import { compileMDX } from '@/next.mdx.compiler.mjs';
import { clientMdxComponents, htmlComponents } from '@/next.mdx.use.client.mjs';
import type { NodeRelease } from '@/types';
import {
getNodeJsChangelogAuthor,
getNodeJsChangelogSlug,
} from '@/util/getNodeJsChangelog';
import { getGitHubAvatarUrl } from '@/util/gitHubUtils';

type WithChangelogModalProps = {
release: NodeRelease;
modalOpen: boolean;
setModalOpen: (open: boolean) => void;
};

// We only need the base components for our ChangelogModal, this avoids/eliminates
// the need of Next.js bundling on the client-side all our MDX components
// Note that this already might increase the client-side bundle due to Shiki
const clientComponents = { ...clientMdxComponents, ...htmlComponents };

const WithChangelogModal: FC<WithChangelogModalProps> = ({
release: { versionWithPrefix },
modalOpen,
setModalOpen,
}) => {
const [ChangelogMDX, setChangelogMDX] = useState<ReactElement>();
const [changelog, setChangelog] = useState<string>('');

useEffect(() => {
let isCancelled = false;

const fetchChangelog = async () => {
try {
const data = await changelogData(versionWithPrefix);

// We need to check if the component is still mounted before setting the state
if (!isCancelled) {
setChangelog(data);

// This removes the <h2> header from the changelog content, as we already
// render the changelog heading as the "ChangelogModal" subheading
const changelogWithoutHeader = data.split('\n').slice(2).join('\n');

compileMDX(new VFile(changelogWithoutHeader), 'md').then(
({ MDXContent }) => {
// This is a tricky one. React states does not allow you to actually store React components
// hence we need to render the component within an Effect and set the state as a ReactElement
// which is a function that can be eval'd by React during runtime.
const renderedElement = (
<MDXContent components={clientComponents} />
);

setChangelogMDX(renderedElement);
}
);
}
} catch (_) {
throw new Error(`Failed to fetch changelog for, ${versionWithPrefix}`);
}
};

if (modalOpen && versionWithPrefix) {
fetchChangelog();
}

return () => {
isCancelled = true;
};
}, [modalOpen, versionWithPrefix]);

const author = getNodeJsChangelogAuthor(changelog);
const slug = getNodeJsChangelogSlug(changelog);

const modalProps = {
heading: `Node.js ${versionWithPrefix}`,
avatars: [{ src: getGitHubAvatarUrl(author), alt: author }],
subheading: slug,
open: modalOpen && typeof ChangelogMDX !== 'undefined',
onOpenChange: setModalOpen,
};

return (
<ChangelogModal {...modalProps}>
<main>{ChangelogMDX}</main>
</ChangelogModal>
);
};

export default WithChangelogModal;
5 changes: 2 additions & 3 deletions components/withDownloadCategories.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { getTranslations } from 'next-intl/server';
import type { FC, PropsWithChildren } from 'react';

import LinkTabs from '@/components/Common/LinkTabs';
import WithNodeRelease from '@/components/withNodeRelease';
import { useClientContext } from '@/hooks/react-server';
import getReleaseData from '@/next-data/releaseData';
import { ReleaseProvider } from '@/providers/releaseProvider';
import type { NodeReleaseStatus } from '@/types';
import { getDownloadCategory, mapCategoriesToTabs } from '@/util/downloadUtils';

import LinkTabs from './Common/LinkTabs';
import WithNodeRelease from './withNodeRelease';

const WithDownloadCategories: FC<PropsWithChildren> = async ({ children }) => {
const t = await getTranslations();
const releases = await getReleaseData();
Expand Down
Loading
Loading