diff --git a/apps/changelog/package.json b/apps/changelog/package.json index 761272b4cdebd..e7e238aee2748 100644 --- a/apps/changelog/package.json +++ b/apps/changelog/package.json @@ -27,6 +27,7 @@ "next-auth": "^4.24.5", "next-mdx-remote": "^4.4.1", "nextjs-toploader": "^1.6.6", + "nuqs": "^1.17.7", "prism-sentry": "^1.0.2", "react": "beta", "react-dom": "beta", diff --git a/apps/changelog/src/app/changelog/%5Fadmin/page.tsx b/apps/changelog/src/app/changelog/%5Fadmin/page.tsx index 3ec2f52da182c..be784197432ca 100644 --- a/apps/changelog/src/app/changelog/%5Fadmin/page.tsx +++ b/apps/changelog/src/app/changelog/%5Fadmin/page.tsx @@ -103,10 +103,11 @@ export default async function ChangelogsListPage() { {' '} - {new Date(changelog.publishedAt || '').toLocaleDateString( - undefined, - {month: 'long', day: 'numeric'} - )} + {new Date(changelog.publishedAt || '').toLocaleDateString('en-EN', { + month: 'long', + day: 'numeric', + timeZone: 'UTC', + })}
diff --git a/apps/changelog/src/app/changelog/page.tsx b/apps/changelog/src/app/changelog/page.tsx index 7d7ca617ffb09..f00acce15b6d1 100644 --- a/apps/changelog/src/app/changelog/page.tsx +++ b/apps/changelog/src/app/changelog/page.tsx @@ -1,30 +1,44 @@ -import { Fragment } from "react"; -import type { Metadata } from "next"; -import { serialize } from "next-mdx-remote/serialize"; +import {Fragment} from 'react'; +import type {Metadata} from 'next'; +import {serialize} from 'next-mdx-remote/serialize'; -import Header from "./header"; -import { getChangelogs } from "../../server/utils"; -import List from "@/client/components/list"; +import Header from './header'; +import {getChangelogs} from '../../server/utils'; +import {ChangelogEntry, ChangelogList} from '@/client/components/list'; +import {startSpan} from '@sentry/nextjs'; -export const dynamic = "force-dynamic"; +export const dynamic = 'force-dynamic'; -export default async function ChangelogList() { +export default async function Page() { const changelogs = await getChangelogs(); - const changelogsWithMdxSummaries = await Promise.all( - changelogs.map(async (changelog) => { - const mdxSummary = await serialize(changelog.summary || ""); - return { - ...changelog, - mdxSummary, - }; - }) + const changelogsWithPublishedAt = changelogs.filter(changelog => { + return changelog.publishedAt !== null; + }); + + const changelogsWithMdxSummaries = await startSpan( + {name: 'serialize changelog summaries'}, + () => + Promise.all( + changelogsWithPublishedAt.map(async (changelog): Promise => { + const mdxSummary = await serialize(changelog.summary || ''); + return { + id: changelog.id, + title: changelog.title, + slug: changelog.slug, + // Because `getChangelogs` is cached, it sometimes returns its results serialized and sometimes not. Therefore we have to deserialize the string to be able to call toUTCString(). + publishedAt: new Date(changelog.publishedAt!).toUTCString(), + categories: changelog.categories, + mdxSummary, + }; + }) + ) ); return (
- + ); } @@ -32,7 +46,7 @@ export default async function ChangelogList() { export function generateMetadata(): Metadata { return { description: - "Stay up to date on everything big and small, from product updates to SDK changes with the Sentry Changelog.", + 'Stay up to date on everything big and small, from product updates to SDK changes with the Sentry Changelog.', alternates: { canonical: `https://sentry.io/changelog/`, }, diff --git a/apps/changelog/src/client/components/article.tsx b/apps/changelog/src/client/components/article.tsx index 8d596c62c7a57..9223a60655bc8 100644 --- a/apps/changelog/src/client/components/article.tsx +++ b/apps/changelog/src/client/components/article.tsx @@ -1,6 +1,6 @@ import {ReactNode} from 'react'; -import Tag from './tag'; -import { DateComponent } from './date'; +import {DateComponent} from './date'; +import {CategoryTag} from './tag'; type ArticleProps = { children?: ReactNode; @@ -41,7 +41,7 @@ export default function Article({

{title}

- {Array.isArray(tags) && tags.map(tag => )} + {Array.isArray(tags) && tags.map(tag => )}
{children}
diff --git a/apps/changelog/src/client/components/date.tsx b/apps/changelog/src/client/components/date.tsx index 458a23ba4d449..75f59824ddc50 100644 --- a/apps/changelog/src/client/components/date.tsx +++ b/apps/changelog/src/client/components/date.tsx @@ -1,11 +1,10 @@ const formatDate = (date: string | Date) => { - const options: Intl.DateTimeFormatOptions = { + const now = new Date(date).toLocaleDateString('en-EN', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' - }; - const now = new Date(date).toLocaleDateString('en-EN', options); + }); return now; }; diff --git a/apps/changelog/src/client/components/list.tsx b/apps/changelog/src/client/components/list.tsx index d8d44d6673225..c981f577a49b5 100644 --- a/apps/changelog/src/client/components/list.tsx +++ b/apps/changelog/src/client/components/list.tsx @@ -1,73 +1,108 @@ 'use client'; -import {useEffect, useState} from 'react'; -import {type Category, type Changelog} from '@prisma/client'; -import Link from 'next/link'; -import {usePathname, useRouter, useSearchParams} from 'next/navigation'; +import type {Category} from '@prisma/client'; import {MDXRemote, MDXRemoteSerializeResult} from 'next-mdx-remote'; +import Link from 'next/link'; +import {parseAsArrayOf, parseAsInteger, parseAsString, useQueryState} from 'nuqs'; +import {Fragment} from 'react'; import Article from './article'; -import Tag from './tag'; -import Pagination from './pagination'; +import {Pagination} from './pagination'; +import {CategoryTag} from './tag'; const ENTRIES_PER_PAGE = 10; -type EnhancedChangelog = Changelog & { +export type ChangelogEntry = { + id: string; + title: string; + slug: string; + summary?: string; + image?: string | null | undefined; + publishedAt: string; // Dates are passed to client components serialized as strings categories: Category[]; mdxSummary: MDXRemoteSerializeResult; }; -export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[]}) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); +/** + * Turns a date into this format: "August 2024" + * Which is the format we use in query params and to filter. + */ +function changelogEntryPublishDateToAddressableTag(date: Date) { + return date.toLocaleString('en-EN', { + month: 'long', + year: 'numeric', + timeZone: 'UTC', + }); +} + +export function ChangelogList({changelogs}: {changelogs: ChangelogEntry[]}) { + const [searchValue, setSearchValue] = useQueryState('search', parseAsString); - const [pageNumber, setPageNumber] = useState(1); - const [searchValue, setSearchValue] = useState(''); - const [selectedCategoriesIds, setSelectedCategoriesIds] = useState( - searchParams?.get('categories')?.split(',') || [] + const [monthAndYearParam, setMonthParam] = useQueryState('month'); + const [selectedCategoriesIds, setSelectedCategoriesIds] = useQueryState( + 'categories', + parseAsArrayOf(parseAsString).withDefault([]).withOptions({clearOnDefault: true}) ); - const [selectedMonth, setSelectedMonth] = useState( - searchParams?.get('month') || null + const [pageParam, setPageParam] = useQueryState( + 'page', + parseAsInteger.withDefault(1).withOptions({clearOnDefault: true}) ); - useEffect(() => { - const params: string[] = []; - if (selectedCategoriesIds.length > 0) { - params.push(`categories=${selectedCategoriesIds.join(',')}`); - } - if (selectedMonth) { - params.push(`month=${selectedMonth}`); - } - router.push(pathname + '?' + params.join('&')); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedCategoriesIds, selectedMonth]); + const selectedPage = pageParam === null ? 1 : pageParam; - const filtered = selectedCategoriesIds.length || searchValue || selectedMonth; + const someFilterIsActive = Boolean( + selectedCategoriesIds.length > 0 || searchValue || monthAndYearParam + ); + + const filteredChangelogsWithoutMonthFilter = changelogs + // First filter by categories + .filter(changelog => { + if (selectedCategoriesIds.length === 0) { + // If no categories are selected we don't filter anything + return true; + } + + return changelog.categories.some(changelogCategory => + selectedCategoriesIds.includes(changelogCategory.id) + ); + }) + + .filter(changelog => { + if (searchValue === null) { + return true; + } + + const addressableDate = changelogEntryPublishDateToAddressableTag( + new Date(changelog.publishedAt) + ); - const filteredChangelogs = changelogs - .filter((changelog: EnhancedChangelog) => { // map all categories to a string - const categories = changelog.categories + const concatenatedCategories = changelog.categories .map((category: Category) => category.name) .join(' '); - const postDate = new Date(changelog.publishedAt || ''); - const postMonthYear = postDate.toLocaleString('default', { - month: 'long', - year: 'numeric', - }); + const searchableContent = + changelog.title + changelog.summary + concatenatedCategories + addressableDate; - const searchContent = changelog.title + changelog.summary + categories; - return ( - searchContent.toLowerCase().includes(searchValue.toLowerCase()) && - (!selectedCategoriesIds.length || - selectedCategoriesIds.some(catId => - changelog.categories.some(changelogCategory => changelogCategory.id === catId) - )) && - (!selectedMonth || selectedMonth === postMonthYear) - ); + return searchableContent.toLowerCase().includes(searchValue.toLowerCase()); }) - .slice(ENTRIES_PER_PAGE * (pageNumber - 1), ENTRIES_PER_PAGE * pageNumber); + .sort((a, b) => { + return new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(); + }); + + const filteredChangelogs = filteredChangelogsWithoutMonthFilter + // Filter by selected date + .filter(changelog => { + if (monthAndYearParam == null) { + // If no date was selected we don't filter anything + return true; + } + + const addressableDate = changelogEntryPublishDateToAddressableTag( + new Date(changelog.publishedAt) + ); + + return monthAndYearParam === addressableDate; + }); const allChangelogCategories: Record = {}; changelogs.forEach(changelog => { @@ -76,62 +111,62 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[ }); }); - // iterate over all posts and create a list of months & years - const months = changelogs.reduce((allMonths, post) => { - const date = new Date(post.publishedAt || ''); - const year = date.getFullYear(); - const month = date.toLocaleString('default', { - month: 'long', - }); - const dateMonthYear = `${month} ${year}`; - return [...new Set([...allMonths, dateMonthYear])]; - }, [] as string[]); + // contains dates in the format "August 2024" + const datesGroupedByMonthAndYear = new Set(); + changelogs.forEach(changelog => { + if (changelog.publishedAt === null) { + throw new Error('invariant'); + } - const monthsCopy = [...months]; + datesGroupedByMonthAndYear.add( + changelogEntryPublishDateToAddressableTag(new Date(changelog.publishedAt)) + ); + }); - const showChangelogs = filteredChangelogs.map(changelog => { - const monthYear = new Date(changelog.publishedAt || '').toLocaleString('default', { - month: 'long', - year: 'numeric', - }); + const sortedDatesGroupedByMonthAndYear = [...datesGroupedByMonthAndYear].sort( + (a, b) => new Date(b).getTime() - new Date(a).getTime() + ); - const monthYearIndex = months.indexOf(monthYear); - if (monthYearIndex > -1) { - // remove first entry from months array - months.splice(monthYearIndex, 1); - } + const paginatedChangelogs = filteredChangelogs + .slice(ENTRIES_PER_PAGE * (selectedPage - 1), ENTRIES_PER_PAGE * selectedPage) + .map((changelog, i, arr) => { + const monthYear = changelogEntryPublishDateToAddressableTag( + new Date(changelog.publishedAt) + ); - const monthSeparator = ( -
-
- {monthYear} -
-
- ); + const prevChangelog: ChangelogEntry | undefined = arr[i - 1]; + const prevChangelogHasDifferentMonth = + !prevChangelog || + changelogEntryPublishDateToAddressableTag(new Date(prevChangelog.publishedAt)) !== + changelogEntryPublishDateToAddressableTag(new Date(changelog.publishedAt)); - return ( - - {monthYearIndex > -1 && monthSeparator} -
category.name)} - image={changelog.image} - > - -
- - ); - }); + return ( + + {prevChangelogHasDifferentMonth && ( +
+
+ {monthYear} +
+
+ )} + +
category.name)} + image={changelog.image} + > + +
+ + + ); + }); - const pagination = { - totalPages: Math.ceil( - (filtered ? filteredChangelogs.length : changelogs.length) / ENTRIES_PER_PAGE - ), - }; + const numberOfPages = Math.ceil(filteredChangelogs.length / ENTRIES_PER_PAGE); return (
@@ -140,24 +175,25 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[ @@ -168,18 +204,22 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[ setSearchValue(e.target.value)} + value={searchValue ?? ''} + onChange={e => { + setPageParam(null); + setSearchValue(e.target.value ? e.target.value : null); + }} placeholder="Search..." className="form-input flex-1 rounded-md border border-gray-300 bg-white px-4 py-2 text-gray-900 focus:border-primary-500 focus:ring-primary-500" />
- {showChangelogs} + {paginatedChangelogs} - {pagination.totalPages > 1 && ( + {numberOfPages > 1 && ( { + setPageParam(pageNumber, {history: 'push'}); + }} + search={searchValue} + selectedMonth={monthAndYearParam} + selectedCategoriesIds={selectedCategoriesIds} /> )} - {!filteredChangelogs.length && ( + {paginatedChangelogs.length === 0 && (
No posts found. @@ -209,23 +254,35 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[

Jump to:

diff --git a/apps/changelog/src/client/components/pagination.tsx b/apps/changelog/src/client/components/pagination.tsx index 22da8932d408d..a5d8a261b5ad2 100644 --- a/apps/changelog/src/client/components/pagination.tsx +++ b/apps/changelog/src/client/components/pagination.tsx @@ -1,17 +1,31 @@ 'use client'; -import {Fragment} from 'react'; +import Link from 'next/link'; +import {createSerializer, parseAsArrayOf, parseAsInteger, parseAsString} from 'nuqs'; -export default function Pagination({ +const serialize = createSerializer({ + month: parseAsString, + categories: parseAsArrayOf(parseAsString), + page: parseAsInteger.withDefault(1).withOptions({clearOnDefault: true}), + search: parseAsString, +}); + +export function Pagination({ totalPages, currentPage, - setPageNumber, + onPageNumberChange, + selectedMonth, + search, + selectedCategoriesIds, }: { totalPages: number; currentPage: number; - setPageNumber: (pageNumber: number) => void; + selectedCategoriesIds: string[]; + selectedMonth: string | null; + search: string | null; + onPageNumberChange: (pageNumber: number) => void; }) { - const prevPage = currentPage - 1 > 0; - const nextPage = currentPage + 1 <= parseInt(totalPages.toString(), 10); + const navigationToPrevPageAllowed = currentPage - 1 > 0; + const navigationToNextPageAllowed = currentPage + 1 <= totalPages; const pages: Array = []; let pushedMiddle = false; @@ -27,12 +41,20 @@ export default function Pagination({ return (
setPageNumber(Math.max(currentPage - 1, 1))} - condition={prevPage} + href={serialize({ + month: selectedMonth, + categories: selectedCategoriesIds.length === 0 ? null : selectedCategoriesIds, + page: Math.max(currentPage - 1, 1), + search, + })} + onClick={(e: MouseEvent) => { + e.preventDefault(); + onPageNumberChange(Math.max(currentPage - 1, 1)); + }} + condition={navigationToPrevPageAllowed} > - + ))}
setPageNumber(currentPage + 1)} - condition={nextPage} + href={serialize({ + month: selectedMonth, + categories: selectedCategoriesIds.length === 0 ? null : selectedCategoriesIds, + page: currentPage + 1, + search, + })} + onClick={(e: MouseEvent) => { + e.preventDefault(); + onPageNumberChange(currentPage + 1); + }} + condition={navigationToNextPageAllowed} >