From d101ec38656d506ae48e154566799aa71411a8d2 Mon Sep 17 00:00:00 2001 From: Luca Forstner Date: Wed, 7 Aug 2024 12:34:31 +0200 Subject: [PATCH 1/9] ref: Refactor changelog list page --- apps/changelog/package.json | 1 + .../migration.sql | 8 + .../prisma/migrations/migration_lock.toml | 3 + apps/changelog/prisma/schema.prisma | 2 +- .../src/app/changelog/%5Fadmin/page.tsx | 9 +- .../src/client/components/article.tsx | 6 +- apps/changelog/src/client/components/date.tsx | 5 +- apps/changelog/src/client/components/list.tsx | 323 +- .../src/client/components/pagination.tsx | 82 +- apps/changelog/src/client/components/tag.tsx | 8 +- apps/changelog/yarn.lock | 6861 +++++++++++++++++ 11 files changed, 7137 insertions(+), 171 deletions(-) create mode 100644 apps/changelog/prisma/migrations/20240807101452_published_at_non_optional/migration.sql create mode 100644 apps/changelog/prisma/migrations/migration_lock.toml create mode 100644 apps/changelog/yarn.lock diff --git a/apps/changelog/package.json b/apps/changelog/package.json index 85166c69541cd..49d528b8ec952 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/prisma/migrations/20240807101452_published_at_non_optional/migration.sql b/apps/changelog/prisma/migrations/20240807101452_published_at_non_optional/migration.sql new file mode 100644 index 0000000000000..1dc77b8933282 --- /dev/null +++ b/apps/changelog/prisma/migrations/20240807101452_published_at_non_optional/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `publishedAt` on table `Changelog` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "Changelog" ALTER COLUMN "publishedAt" SET NOT NULL; diff --git a/apps/changelog/prisma/migrations/migration_lock.toml b/apps/changelog/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000000000..fbffa92c2bb7c --- /dev/null +++ b/apps/changelog/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/apps/changelog/prisma/schema.prisma b/apps/changelog/prisma/schema.prisma index ffd451e9cbb61..2c4da3e13eb74 100644 --- a/apps/changelog/prisma/schema.prisma +++ b/apps/changelog/prisma/schema.prisma @@ -58,7 +58,7 @@ model VerificationToken { model Changelog { id String @id @default(cuid()) createdAt DateTime @default(now()) - publishedAt DateTime? @default(now()) + publishedAt DateTime @default(now()) updatedAt DateTime @updatedAt title String @db.VarChar(255) slug String @unique @db.VarChar(255) 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/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..296fee52aef4d 100644 --- a/apps/changelog/src/client/components/list.tsx +++ b/apps/changelog/src/client/components/list.tsx @@ -1,73 +1,103 @@ '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 {MDXRemote, MDXRemoteSerializeResult} from 'next-mdx-remote'; +import Link from 'next/link'; +import {parseAsArrayOf, parseAsInteger, parseAsString, useQueryState} from 'nuqs'; +import {Fragment, useState} 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; +const ENTRIES_PER_PAGE = 3; -type EnhancedChangelog = Changelog & { +type EnhancedChangelog = Omit & { categories: Category[]; mdxSummary: MDXRemoteSerializeResult; + publishedAt: string; }; +/** + * 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 default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[]}) { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useSearchParams(); - - const [pageNumber, setPageNumber] = useState(1); - const [searchValue, setSearchValue] = useState(''); - const [selectedCategoriesIds, setSelectedCategoriesIds] = useState( - searchParams?.get('categories')?.split(',') || [] + const [searchValue, setSearchValue] = useQueryState('search', parseAsString); + + 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 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 filtered = selectedCategoriesIds.length || searchValue || selectedMonth; + 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 +106,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: EnhancedChangelog | 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 +170,25 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[ @@ -168,18 +199,19 @@ export default function Changelogs({changelogs}: {changelogs: EnhancedChangelog[ setSearchValue(e.target.value)} + value={searchValue ?? ''} + onChange={e => 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} + monthParam={monthAndYearParam} + selectedCategoriesIds={selectedCategoriesIds} /> )} - {!filteredChangelogs.length && ( + {paginatedChangelogs.length === 0 && (
No posts found. @@ -209,23 +246,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..24431b86994c1 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, + monthParam, + search, + selectedCategoriesIds, }: { totalPages: number; currentPage: number; + selectedCategoriesIds: string[]; + monthParam: string | null; + search: string | null; setPageNumber: (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: monthParam, + categories: selectedCategoriesIds.length === 0 ? null : selectedCategoriesIds, + page: Math.max(currentPage - 1, 1), + search, + })} + onClick={(e: MouseEvent) => { + e.preventDefault(); + setPageNumber(Math.max(currentPage - 1, 1)); + }} + condition={navigationToPrevPageAllowed} > - + ))}
setPageNumber(currentPage + 1)} - condition={nextPage} + href={serialize({ + month: monthParam, + categories: selectedCategoriesIds.length === 0 ? null : selectedCategoriesIds, + page: currentPage + 1, + search, + })} + onClick={(e: MouseEvent) => { + e.preventDefault(); + setPageNumber(currentPage + 1); + }} + condition={navigationToNextPageAllowed} >