Skip to content

Commit 13d4cf2

Browse files
ovflowdmikeestocanerakdas
authored
feat: introduced changelog modal on downloads (#6393)
* refactor: separate client and server/complex components * feat: add changelog data to release data * chore: add client components to mdx intrinsic types * feat: introduced new changelog modal components with client-side rendering * chore: updated pages with the changelog modal button * chore: fix unit tests bad import * Apply suggestions from code review Co-authored-by: Michael Esteban <[email protected]> Signed-off-by: Claudio W <[email protected]> * refactor: changelog removed from release and moved to a different endpoint * fix: generateStaticParams provides version * refactor: review updates * fix: changelog trigger accessibility --------- Signed-off-by: Claudio W <[email protected]> Co-authored-by: Michael Esteban <[email protected]> Co-authored-by: Caner Akdas <[email protected]>
1 parent d549283 commit 13d4cf2

32 files changed

+415
-107
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { provideChangelogData } from '@/next-data/providers/changelogData';
2+
import provideReleaseData from '@/next-data/providers/releaseData';
3+
import { VERCEL_REVALIDATE } from '@/next.constants.mjs';
4+
import { defaultLocale } from '@/next.locales.mjs';
5+
6+
type StaticParams = {
7+
params: { version: string };
8+
};
9+
10+
// This is the Route Handler for the `GET` method which handles the request
11+
// for generating static data related to the Node.js Changelog Data
12+
// @see https://nextjs.org/docs/app/building-your-application/routing/router-handlers
13+
export const GET = async (_: Request, { params }: StaticParams) => {
14+
const changelogData = await provideChangelogData(params.version);
15+
16+
return Response.json(changelogData);
17+
};
18+
19+
// This function generates the static paths that come from the dynamic segments
20+
// `[locale]/next-data/changelog-data/[version]` and returns an array of all available static paths
21+
// This is used for ISR static validation and generation
22+
export const generateStaticParams = async () => {
23+
const releases = provideReleaseData();
24+
25+
const mappedParams = releases.map(release => ({
26+
locale: defaultLocale.code,
27+
version: String(release.versionWithPrefix),
28+
}));
29+
30+
return mappedParams;
31+
};
32+
33+
// Enforces that only the paths from `generateStaticParams` are allowed, giving 404 on the contrary
34+
// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamicparams
35+
export const dynamicParams = false;
36+
37+
// Enforces that this route is used as static rendering
38+
// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#dynamic
39+
export const dynamic = 'error';
40+
41+
// Ensures that this endpoint is invalidated and re-executed every X minutes
42+
// so that when new deployments happen, the data is refreshed
43+
// @see https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config#revalidate
44+
export const revalidate = VERCEL_REVALIDATE;

components/Downloads/ChangelogModal/index.module.css

+11-14
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,9 @@
33
inset-0
44
flex
55
justify-center
6-
bg-white
7-
bg-opacity-90
8-
backdrop-blur-lg
9-
dark:bg-neutral-950
10-
dark:bg-opacity-80;
6+
bg-white/40
7+
backdrop-blur-[2px]
8+
dark:bg-neutral-950/40;
119

1210
.content {
1311
@apply relative
@@ -16,17 +14,16 @@
1614
inline-flex
1715
w-full
1816
flex-col
19-
overflow-y-scroll
17+
overflow-y-auto
2018
rounded
2119
border
2220
border-neutral-200
2321
bg-white
2422
p-8
2523
focus:outline-none
2624
dark:bg-neutral-950
27-
sm:mt-20
28-
lg:w-2/3
29-
xl:w-3/5
25+
sm:my-20
26+
lg:max-w-[900px]
3027
xl:p-12
3128
xs:p-6;
3229
}
@@ -38,7 +35,11 @@
3835
block
3936
size-6
4037
cursor-pointer
41-
sm:hidden;
38+
rounded
39+
focus:outline-none
40+
focus:ring-2
41+
focus:ring-neutral-200
42+
dark:focus:ring-neutral-900;
4243
}
4344

4445
.title {
@@ -85,10 +86,6 @@
8586
flex-col
8687
gap-4;
8788

88-
a {
89-
@apply underline;
90-
}
91-
9289
pre {
9390
@apply overflow-auto;
9491
}

components/Downloads/ChangelogModal/index.stories.tsx

+4-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
22
import { VFile } from 'vfile';
33

4-
import Button from '@/components/Common/Button';
54
import ChangelogModal from '@/components/Downloads/ChangelogModal';
65
import { MDXRenderer } from '@/components/mdxRenderer';
76
import { compileMDX } from '@/next.mdx.compiler.mjs';
@@ -178,7 +177,7 @@ ZCVKLyezajjko28SugXGjegEjcY4o7v23XghhW6RAbEB6R8TZDo=
178177

179178
export const Default: Story = {
180179
args: {
181-
trigger: <Button>Trigger</Button>,
180+
open: false,
182181
heading: 'Node v18.17.0',
183182
subheading: "2023-07-18, Version 18.17.0 'Hydrogen' (LTS), @danielleadams",
184183
avatars: names.map(name => ({
@@ -189,15 +188,15 @@ export const Default: Story = {
189188
},
190189
render: (_, { loaded: { Content } }) => Content,
191190
loaders: [
192-
async ({ args }) => {
191+
async ({ args: { children, ...props } }) => {
193192
const { MDXContent } = await compileMDX(
194-
new VFile(args.children?.toString()),
193+
new VFile(children?.toString()),
195194
'md'
196195
);
197196

198197
return {
199198
Content: (
200-
<ChangelogModal {...args}>
199+
<ChangelogModal {...props}>
201200
<main>
202201
<MDXRenderer Component={MDXContent} />
203202
</main>

components/Downloads/ChangelogModal/index.tsx

+17-9
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,59 @@
1+
'use client';
2+
13
import { ArrowUpRightIcon, XMarkIcon } from '@heroicons/react/24/outline';
24
import * as Dialog from '@radix-ui/react-dialog';
35
import { useTranslations } from 'next-intl';
4-
import type { FC, PropsWithChildren, ReactNode, ComponentProps } from 'react';
6+
import type { FC, PropsWithChildren, ComponentProps } from 'react';
57

68
import AvatarGroup from '@/components/Common/AvatarGroup';
79
import Link from '@/components/Link';
810

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

11-
type ChangelogModalProps = {
13+
type ChangelogModalProps = PropsWithChildren<{
1214
heading: string;
1315
subheading: string;
1416
avatars: ComponentProps<typeof AvatarGroup>['avatars'];
15-
trigger: ReactNode;
16-
children: ReactNode;
17-
};
17+
open?: boolean;
18+
onOpenChange?: (open: boolean) => void;
19+
}>;
1820

19-
const ChangelogModal: FC<PropsWithChildren<ChangelogModalProps>> = ({
21+
const ChangelogModal: FC<ChangelogModalProps> = ({
2022
heading,
2123
subheading,
2224
avatars,
23-
trigger,
2425
children,
26+
open = false,
27+
onOpenChange = () => {},
2528
}) => {
2629
const t = useTranslations();
2730

2831
return (
29-
<Dialog.Root>
30-
<Dialog.Trigger asChild>{trigger}</Dialog.Trigger>
32+
<Dialog.Root open={open} onOpenChange={onOpenChange}>
3133
<Dialog.Portal>
3234
<Dialog.Overlay className={styles.overlay}>
3335
<Dialog.Content className={styles.content}>
3436
<Dialog.Trigger className={styles.close}>
3537
<XMarkIcon />
3638
</Dialog.Trigger>
39+
3740
<Dialog.Title className={styles.title}>{heading}</Dialog.Title>
41+
3842
<Dialog.Description className={styles.description}>
3943
{subheading}
4044
</Dialog.Description>
45+
4146
<div className={styles.authors}>
4247
<AvatarGroup avatars={avatars} isExpandable={false} />
48+
4349
<Link href="/about/get-involved">
4450
{t('components.downloads.changelogModal.startContributing')}
4551
<ArrowUpRightIcon />
4652
</Link>
4753
</div>
54+
4855
<div className={styles.wrapper}>{children}</div>
56+
4957
<Dialog.Close />
5058
</Dialog.Content>
5159
</Dialog.Overlay>

components/Downloads/DownloadReleasesTable.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { FC } from 'react';
33

44
import getReleaseData from '@/next-data/releaseData';
55
import { getNodeApiLink } from '@/util/getNodeApiLink';
6-
import { getNodejsChangelog } from '@/util/getNodeJsChangelog';
6+
import { getNodeJsChangelog } from '@/util/getNodeJsChangelog';
77

88
// This is a React Async Server Component
99
// Note that Hooks cannot be used in a RSC async component
@@ -38,7 +38,7 @@ const DownloadReleasesTable: FC = async () => {
3838
>
3939
{t('components.downloadReleasesTable.releases')}
4040
</a>
41-
<a href={getNodejsChangelog(release.versionWithPrefix)}>
41+
<a href={getNodeJsChangelog(release.versionWithPrefix)}>
4242
{t('components.downloadReleasesTable.changelog')}
4343
</a>
4444
<a href={getNodeApiLink(release.versionWithPrefix)}>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
'use client';
2+
3+
import type { FC, PropsWithChildren } from 'react';
4+
import { useContext } from 'react';
5+
6+
import LinkWithArrow from '@/components/Downloads/Release/LinkWithArrow';
7+
import { ReleaseContext } from '@/providers/releaseProvider';
8+
9+
const ChangelogLink: FC<PropsWithChildren> = ({ children }) => {
10+
const { modalOpen, setModalOpen } = useContext(ReleaseContext);
11+
12+
return (
13+
<button onClick={() => setModalOpen(!modalOpen)}>
14+
<LinkWithArrow className="cursor-pointer">{children}</LinkWithArrow>
15+
</button>
16+
);
17+
};
18+
19+
export default ChangelogLink;
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { ArrowUpRightIcon } from '@heroicons/react/24/solid';
2-
import type { FC, PropsWithChildren } from 'react';
2+
import type { ComponentProps, FC } from 'react';
33

44
import Link from '@/components/Link';
55

6-
type AccessibleAnchorProps = { href?: string };
7-
8-
const LinkWithArrow: FC<PropsWithChildren<AccessibleAnchorProps>> = ({
6+
const LinkWithArrow: FC<ComponentProps<typeof Link>> = ({
97
children,
108
...props
119
}) => (
@@ -14,4 +12,5 @@ const LinkWithArrow: FC<PropsWithChildren<AccessibleAnchorProps>> = ({
1412
<ArrowUpRightIcon className="ml-1 inline w-3 fill-neutral-600 dark:fill-white" />
1513
</Link>
1614
);
15+
1716
export default LinkWithArrow;

components/mdxRenderer.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import type { MDXComponents, MDXContent } from 'mdx/types';
22
import type { FC } from 'react';
33

4-
import { htmlComponents, mdxComponents } from '@/next.mdx.use.mjs';
4+
import { htmlComponents, clientMdxComponents } from '@/next.mdx.use.client.mjs';
5+
import { mdxComponents } from '@/next.mdx.use.mjs';
56

67
// Combine all MDX Components to be used
78
const combinedComponents: MDXComponents = {
89
...htmlComponents,
10+
...clientMdxComponents,
911
...mdxComponents,
1012
};
1113

components/withChangelogModal.tsx

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use client';
2+
3+
import { useState, useEffect } from 'react';
4+
import type { FC, ReactElement } from 'react';
5+
import { VFile } from 'vfile';
6+
7+
import ChangelogModal from '@/components/Downloads/ChangelogModal';
8+
import changelogData from '@/next-data/changelogData';
9+
import { compileMDX } from '@/next.mdx.compiler.mjs';
10+
import { clientMdxComponents, htmlComponents } from '@/next.mdx.use.client.mjs';
11+
import type { NodeRelease } from '@/types';
12+
import {
13+
getNodeJsChangelogAuthor,
14+
getNodeJsChangelogSlug,
15+
} from '@/util/getNodeJsChangelog';
16+
import { getGitHubAvatarUrl } from '@/util/gitHubUtils';
17+
18+
type WithChangelogModalProps = {
19+
release: NodeRelease;
20+
modalOpen: boolean;
21+
setModalOpen: (open: boolean) => void;
22+
};
23+
24+
// We only need the base components for our ChangelogModal, this avoids/eliminates
25+
// the need of Next.js bundling on the client-side all our MDX components
26+
// Note that this already might increase the client-side bundle due to Shiki
27+
const clientComponents = { ...clientMdxComponents, ...htmlComponents };
28+
29+
const WithChangelogModal: FC<WithChangelogModalProps> = ({
30+
release: { versionWithPrefix },
31+
modalOpen,
32+
setModalOpen,
33+
}) => {
34+
const [ChangelogMDX, setChangelogMDX] = useState<ReactElement>();
35+
const [changelog, setChangelog] = useState<string>('');
36+
37+
useEffect(() => {
38+
let isCancelled = false;
39+
40+
const fetchChangelog = async () => {
41+
try {
42+
const data = await changelogData(versionWithPrefix);
43+
44+
// We need to check if the component is still mounted before setting the state
45+
if (!isCancelled) {
46+
setChangelog(data);
47+
48+
// This removes the <h2> header from the changelog content, as we already
49+
// render the changelog heading as the "ChangelogModal" subheading
50+
const changelogWithoutHeader = data.split('\n').slice(2).join('\n');
51+
52+
compileMDX(new VFile(changelogWithoutHeader), 'md').then(
53+
({ MDXContent }) => {
54+
// This is a tricky one. React states does not allow you to actually store React components
55+
// hence we need to render the component within an Effect and set the state as a ReactElement
56+
// which is a function that can be eval'd by React during runtime.
57+
const renderedElement = (
58+
<MDXContent components={clientComponents} />
59+
);
60+
61+
setChangelogMDX(renderedElement);
62+
}
63+
);
64+
}
65+
} catch (_) {
66+
throw new Error(`Failed to fetch changelog for, ${versionWithPrefix}`);
67+
}
68+
};
69+
70+
if (modalOpen && versionWithPrefix) {
71+
fetchChangelog();
72+
}
73+
74+
return () => {
75+
isCancelled = true;
76+
};
77+
}, [modalOpen, versionWithPrefix]);
78+
79+
const author = getNodeJsChangelogAuthor(changelog);
80+
const slug = getNodeJsChangelogSlug(changelog);
81+
82+
const modalProps = {
83+
heading: `Node.js ${versionWithPrefix}`,
84+
avatars: [{ src: getGitHubAvatarUrl(author), alt: author }],
85+
subheading: slug,
86+
open: modalOpen && typeof ChangelogMDX !== 'undefined',
87+
onOpenChange: setModalOpen,
88+
};
89+
90+
return (
91+
<ChangelogModal {...modalProps}>
92+
<main>{ChangelogMDX}</main>
93+
</ChangelogModal>
94+
);
95+
};
96+
97+
export default WithChangelogModal;

components/withDownloadCategories.tsx

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import { getTranslations } from 'next-intl/server';
22
import type { FC, PropsWithChildren } from 'react';
33

4+
import LinkTabs from '@/components/Common/LinkTabs';
5+
import WithNodeRelease from '@/components/withNodeRelease';
46
import { useClientContext } from '@/hooks/react-server';
57
import getReleaseData from '@/next-data/releaseData';
68
import { ReleaseProvider } from '@/providers/releaseProvider';
79
import type { NodeReleaseStatus } from '@/types';
810
import { getDownloadCategory, mapCategoriesToTabs } from '@/util/downloadUtils';
911

10-
import LinkTabs from './Common/LinkTabs';
11-
import WithNodeRelease from './withNodeRelease';
12-
1312
const WithDownloadCategories: FC<PropsWithChildren> = async ({ children }) => {
1413
const t = await getTranslations();
1514
const releases = await getReleaseData();

0 commit comments

Comments
 (0)