From f1d9e449b38535c4cd671a4dd5effead87e7aa0f Mon Sep 17 00:00:00 2001 From: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> Date: Wed, 18 Sep 2024 08:04:14 +0200 Subject: [PATCH] App Router: Support user-defined paths for predefined pages (#2293) This is achieved by using a combination of rewrites and redirects in the middleware: - Rewrite from the page tree node path (e.g., `/aktuelles`) to the code path (e.g., `/news`) - Redirect from the code path (e.g., `/news`) to the page tree node path (e.g., `/aktuelles`) --- .changeset/dirty-clocks-wonder.md | 17 ++++ demo/api/schema.gql | 2 +- demo/site/src/header/PageLink.tsx | 10 +-- demo/site/src/middleware.ts | 24 +++--- .../src/{redirects => middleware}/cache.ts | 0 demo/site/src/middleware/predefinedPages.ts | 77 +++++++++++++++++++ .../{redirects => middleware}/redirects.ts | 0 packages/api/cms-api/schema.gql | 2 +- .../src/page-tree/createPageTreeResolver.ts | 8 +- .../paginated-page-tree-nodes-args.factory.ts | 8 +- 10 files changed, 120 insertions(+), 28 deletions(-) create mode 100644 .changeset/dirty-clocks-wonder.md rename demo/site/src/{redirects => middleware}/cache.ts (100%) create mode 100644 demo/site/src/middleware/predefinedPages.ts rename demo/site/src/{redirects => middleware}/redirects.ts (100%) diff --git a/.changeset/dirty-clocks-wonder.md b/.changeset/dirty-clocks-wonder.md new file mode 100644 index 0000000000..72e548cf01 --- /dev/null +++ b/.changeset/dirty-clocks-wonder.md @@ -0,0 +1,17 @@ +--- +"@comet/cms-api": minor +--- + +Support filtering for document types in the `paginatedPageTreeNodes` query + +**Example** + +```graphql +query PredefinedPages($scope: PageTreeNodeScopeInput!) { + paginatedPageTreeNodes(scope: $scope, documentType: "PredefinedPage") { + nodes { + id + } + } +} +``` diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 9c3568a7e4..0e6cf3af7c 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -746,7 +746,7 @@ type Query { pageTreeNode(id: ID!): PageTreeNode pageTreeNodeByPath(path: String!, scope: PageTreeNodeScopeInput!): PageTreeNode pageTreeNodeList(scope: PageTreeNodeScopeInput!, category: String): [PageTreeNode!]! - paginatedPageTreeNodes(scope: PageTreeNodeScopeInput!, category: String, sort: [PageTreeNodeSort!], offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes! + paginatedPageTreeNodes(scope: PageTreeNodeScopeInput!, category: String, sort: [PageTreeNodeSort!], documentType: String, offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes! pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability! redirects(scope: RedirectScopeInput!, query: String, type: RedirectGenerationType, active: Boolean, sortColumnName: String, sortDirection: SortDirection! = ASC): [Redirect!]! @deprecated(reason: "Use paginatedRedirects instead. Will be removed in the next version.") paginatedRedirects(scope: RedirectScopeInput!, search: String, filter: RedirectFilter, sort: [RedirectSort!], offset: Int! = 0, limit: Int! = 25): PaginatedRedirects! diff --git a/demo/site/src/header/PageLink.tsx b/demo/site/src/header/PageLink.tsx index adfff3e37a..53d0e1b29a 100644 --- a/demo/site/src/header/PageLink.tsx +++ b/demo/site/src/header/PageLink.tsx @@ -1,7 +1,5 @@ "use client"; import { LinkBlock } from "@src/blocks/LinkBlock"; -import { GQLPredefinedPage } from "@src/graphql.generated"; -import { predefinedPagePaths } from "@src/predefinedPages/predefinedPagePaths"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { PropsWithChildren } from "react"; @@ -41,14 +39,8 @@ function PageLink({ page, children, className: passedClassName, activeClassName ); } else if (page.documentType === "PredefinedPage") { - if (!page.document) { - return null; - } - - const type = (page.document as GQLPredefinedPage).type; - return ( - + {children} ); diff --git a/demo/site/src/middleware.ts b/demo/site/src/middleware.ts index 1b5a2d84ba..452047ebdb 100644 --- a/demo/site/src/middleware.ts +++ b/demo/site/src/middleware.ts @@ -1,10 +1,9 @@ -import { Rewrite } from "next/dist/lib/load-custom-routes"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { domain } from "./config"; -import { GQLRedirectScope } from "./graphql.generated"; -import { createRedirects } from "./redirects/redirects"; +import { getPredefinedPageRedirect, getPredefinedPageRewrite } from "./middleware/predefinedPages"; +import { createRedirects } from "./middleware/redirects"; export async function middleware(request: NextRequest) { const { pathname } = new URL(request.url); @@ -19,20 +18,19 @@ export async function middleware(request: NextRequest) { return NextResponse.redirect(new URL(destination, request.url), redirect.permanent ? 308 : 307); } - const rewrites = await createRewrites(scope); - const rewrite = rewrites.get(pathname); - if (rewrite) { - return NextResponse.rewrite(new URL(rewrite.destination, request.url)); + const predefinedPageRedirect = await getPredefinedPageRedirect(scope, pathname); + + if (predefinedPageRedirect) { + return NextResponse.redirect(new URL(predefinedPageRedirect, request.url), 307); } - return NextResponse.next(); -} + const predefinedPageRewrite = await getPredefinedPageRewrite(scope, pathname); -type RewritesMap = Map; + if (predefinedPageRewrite) { + return NextResponse.rewrite(new URL(predefinedPageRewrite, request.url)); + } -async function createRewrites(scope: GQLRedirectScope): Promise { - const rewritesMap = new Map(); - return rewritesMap; + return NextResponse.next(); } export const config = { diff --git a/demo/site/src/redirects/cache.ts b/demo/site/src/middleware/cache.ts similarity index 100% rename from demo/site/src/redirects/cache.ts rename to demo/site/src/middleware/cache.ts diff --git a/demo/site/src/middleware/predefinedPages.ts b/demo/site/src/middleware/predefinedPages.ts new file mode 100644 index 0000000000..d5926c00db --- /dev/null +++ b/demo/site/src/middleware/predefinedPages.ts @@ -0,0 +1,77 @@ +import { gql } from "@comet/cms-site"; +import { languages } from "@src/config"; +import { predefinedPagePaths } from "@src/predefinedPages/predefinedPagePaths"; +import { createGraphQLFetch } from "@src/util/graphQLClient"; + +import { memoryCache } from "./cache"; +import { GQLPredefinedPagesQuery, GQLPredefinedPagesQueryVariables } from "./predefinedPages.generated"; + +async function getPredefinedPageRedirect(scope: { domain: string }, pathname: string): Promise { + const pages = await fetchPredefinedPages(scope); + + const matchingPredefinedPage = pages.find((page) => pathname.startsWith(page.codePath)); + + if (matchingPredefinedPage) { + return pathname.replace(matchingPredefinedPage.codePath, matchingPredefinedPage.pageTreeNodePath); + } + + return undefined; +} + +async function getPredefinedPageRewrite(scope: { domain: string }, pathname: string): Promise { + const pages = await fetchPredefinedPages(scope); + + const matchingPredefinedPage = pages.find((page) => pathname.startsWith(page.pageTreeNodePath)); + + if (matchingPredefinedPage) { + return pathname.replace(matchingPredefinedPage.pageTreeNodePath, matchingPredefinedPage.codePath); + } + + return undefined; +} + +const predefinedPagesQuery = gql` + query PredefinedPages($scope: PageTreeNodeScopeInput!) { + paginatedPageTreeNodes(scope: $scope, documentType: "PredefinedPage") { + nodes { + id + path + document { + __typename + ... on PredefinedPage { + type + } + } + } + } + } +`; + +const graphQLFetch = createGraphQLFetch(); + +async function fetchPredefinedPages(scope: { domain: string }) { + const key = `predefinedPages-${JSON.stringify(scope)}`; + + return memoryCache.wrap(key, async () => { + const pages: Array<{ codePath: string; pageTreeNodePath: string }> = []; + + for (const language of languages) { + const { paginatedPageTreeNodes } = await graphQLFetch(predefinedPagesQuery, { + scope: { domain: scope.domain, language }, + }); + + for (const node of paginatedPageTreeNodes.nodes) { + if (node.document?.__typename === "PredefinedPage" && node.document.type) { + pages.push({ + codePath: `/${language}${predefinedPagePaths[node.document.type]}`, + pageTreeNodePath: `/${language}${node.path}`, + }); + } + } + } + + return pages; + }); +} + +export { getPredefinedPageRedirect, getPredefinedPageRewrite }; diff --git a/demo/site/src/redirects/redirects.ts b/demo/site/src/middleware/redirects.ts similarity index 100% rename from demo/site/src/redirects/redirects.ts rename to demo/site/src/middleware/redirects.ts diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index b83632f4e1..f43e214b9a 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -361,7 +361,7 @@ type Query { pageTreeNode(id: ID!): PageTreeNode pageTreeNodeByPath(path: String!, scope: PageTreeNodeScopeInput!): PageTreeNode pageTreeNodeList(scope: PageTreeNodeScopeInput!, category: String): [PageTreeNode!]! - paginatedPageTreeNodes(scope: PageTreeNodeScopeInput! = {}, category: String, sort: [PageTreeNodeSort!], offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes! + paginatedPageTreeNodes(scope: PageTreeNodeScopeInput! = {}, category: String, sort: [PageTreeNodeSort!], documentType: String, offset: Int! = 0, limit: Int! = 25): PaginatedPageTreeNodes! pageTreeNodeSlugAvailable(scope: PageTreeNodeScopeInput!, parentId: ID, slug: String!): SlugAvailability! kubernetesCronJobs: [KubernetesCronJob!]! kubernetesCronJob(name: String!): KubernetesCronJob! diff --git a/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts b/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts index 7165433b67..43ca6a616c 100644 --- a/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts +++ b/packages/api/cms-api/src/page-tree/createPageTreeResolver.ts @@ -100,10 +100,12 @@ export function createPageTreeResolver({ } @Query(() => PaginatedPageTreeNodes) - async paginatedPageTreeNodes(@Args() { scope, category, sort, offset, limit }: PaginatedPageTreeNodesArgs): Promise { + async paginatedPageTreeNodes( + @Args() { scope, category, sort, offset, limit, documentType }: PaginatedPageTreeNodesArgs, + ): Promise { await this.pageTreeReadApi.preloadNodes(scope); - const nodes = await this.pageTreeReadApi.getNodes({ scope: nonEmptyScopeOrNothing(scope), category, offset, limit, sort }); - const count = await this.pageTreeReadApi.getNodesCount({ scope: nonEmptyScopeOrNothing(scope), category }); + const nodes = await this.pageTreeReadApi.getNodes({ scope: nonEmptyScopeOrNothing(scope), category, offset, limit, sort, documentType }); + const count = await this.pageTreeReadApi.getNodesCount({ scope: nonEmptyScopeOrNothing(scope), category, documentType }); return new PaginatedPageTreeNodes(nodes, count); } diff --git a/packages/api/cms-api/src/page-tree/dto/paginated-page-tree-nodes-args.factory.ts b/packages/api/cms-api/src/page-tree/dto/paginated-page-tree-nodes-args.factory.ts index e8a6fb3ae8..a211c04257 100644 --- a/packages/api/cms-api/src/page-tree/dto/paginated-page-tree-nodes-args.factory.ts +++ b/packages/api/cms-api/src/page-tree/dto/paginated-page-tree-nodes-args.factory.ts @@ -1,7 +1,7 @@ import { Type } from "@nestjs/common"; import { ArgsType, Field } from "@nestjs/graphql"; import { Type as TransformerType } from "class-transformer"; -import { IsOptional, ValidateNested } from "class-validator"; +import { IsOptional, IsString, ValidateNested } from "class-validator"; import { OffsetBasedPaginationArgs } from "../../common/pagination/offset-based.args"; import { PageTreeNodeCategory, ScopeInterface } from "../types"; @@ -14,6 +14,7 @@ export interface PaginatedPageTreeNodesArgsInterface { sort?: PageTreeNodeSort[]; offset: number; limit: number; + documentType?: string; } export class PaginatedPageTreeNodesArgsFactory { @@ -33,6 +34,11 @@ export class PaginatedPageTreeNodesArgsFactory { @TransformerType(() => PageTreeNodeSort) @ValidateNested({ each: true }) sort?: PageTreeNodeSort[]; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + documentType?: string; } return PageTreeNodesArgs; }