From 854a25f52fd2c20dae2b5841d52de9d66c26fd65 Mon Sep 17 00:00:00 2001 From: jordanarldt Date: Thu, 23 Jan 2025 11:51:56 -0600 Subject: [PATCH] Add support for nested web pages --- .changeset/wild-days-bake.md | 5 ++ .../(faceted)/category/[slug]/page-data.ts | 4 +- .../app/[locale]/(default)/account/layout.tsx | 24 +++-- .../{ => [id]}/_components/web-page.tsx | 18 ++-- .../contact}/_actions/submit-contact-form.ts | 0 .../[id] => [id]/contact}/page-data.ts | 4 +- .../{contact/[id] => [id]/contact}/page.tsx | 16 +++- .../(default)/webpages/[id]/layout.tsx | 88 +++++++++++++++++++ .../webpages/[id]/normal/page-data.ts | 38 ++++++++ .../{normal/[id] => [id]/normal}/page.tsx | 16 +++- .../webpages/normal/[id]/page-data.ts | 33 ------- core/components/breadcrumbs/fragment.ts | 15 +++- core/components/footer/fragment.ts | 2 +- .../breadcrumbs-transformer.ts | 41 +++++++++ core/middlewares/with-routes.ts | 4 +- .../soul/sections/account-layout/index.tsx | 38 -------- .../soul/sections/sidebar-menu/index.tsx | 63 +++++++++++++ .../sidebar-menu-link.tsx} | 2 +- .../sidebar-menu-select.tsx} | 8 +- 19 files changed, 306 insertions(+), 113 deletions(-) create mode 100644 .changeset/wild-days-bake.md rename core/app/[locale]/(default)/webpages/{ => [id]}/_components/web-page.tsx (86%) rename core/app/[locale]/(default)/webpages/{contact/[id] => [id]/contact}/_actions/submit-contact-form.ts (100%) rename core/app/[locale]/(default)/webpages/{contact/[id] => [id]/contact}/page-data.ts (86%) rename core/app/[locale]/(default)/webpages/{contact/[id] => [id]/contact}/page.tsx (92%) create mode 100644 core/app/[locale]/(default)/webpages/[id]/layout.tsx create mode 100644 core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts rename core/app/[locale]/(default)/webpages/{normal/[id] => [id]/normal}/page.tsx (76%) delete mode 100644 core/app/[locale]/(default)/webpages/normal/[id]/page-data.ts create mode 100644 core/data-transformers/breadcrumbs-transformer.ts delete mode 100644 core/vibes/soul/sections/account-layout/index.tsx create mode 100644 core/vibes/soul/sections/sidebar-menu/index.tsx rename core/vibes/soul/sections/{account-layout/account-layout-link.tsx => sidebar-menu/sidebar-menu-link.tsx} (95%) rename core/vibes/soul/sections/{account-layout/account-layout-link-select.tsx => sidebar-menu/sidebar-menu-select.tsx} (72%) diff --git a/.changeset/wild-days-bake.md b/.changeset/wild-days-bake.md new file mode 100644 index 0000000000..a03280de75 --- /dev/null +++ b/.changeset/wild-days-bake.md @@ -0,0 +1,5 @@ +--- +"@bigcommerce/catalyst-core": minor +--- + +Adds support for nested web page children / trees. Restructure web page routing to support a layout file. diff --git a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts index dc2fa50610..6b60a11ce0 100644 --- a/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts +++ b/core/app/[locale]/(default)/(faceted)/category/[slug]/page-data.ts @@ -4,7 +4,7 @@ import { getSessionCustomerAccessToken } from '~/auth'; import { client } from '~/client'; import { graphql, VariablesOf } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; -import { BreadcrumbsFragment } from '~/components/breadcrumbs/fragment'; +import { BreadcrumbsCategoryFragment } from '~/components/breadcrumbs/fragment'; const CategoryPageQuery = graphql( ` @@ -38,7 +38,7 @@ const CategoryPageQuery = graphql( } } `, - [BreadcrumbsFragment], + [BreadcrumbsCategoryFragment], ); type Variables = VariablesOf; diff --git a/core/app/[locale]/(default)/account/layout.tsx b/core/app/[locale]/(default)/account/layout.tsx index 7e37b94a8b..94add06780 100644 --- a/core/app/[locale]/(default)/account/layout.tsx +++ b/core/app/[locale]/(default)/account/layout.tsx @@ -1,7 +1,8 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import { PropsWithChildren } from 'react'; -import { AccountLayout } from '@/vibes/soul/sections/account-layout'; +import { SidebarMenu } from '@/vibes/soul/sections/sidebar-menu'; +import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout'; import { auth } from '~/auth'; import { redirect } from '~/i18n/routing'; @@ -22,15 +23,20 @@ export default async function Layout({ children, params }: Props) { } return ( - + } + sidebarSize="small" > {children} - + ); } diff --git a/core/app/[locale]/(default)/webpages/_components/web-page.tsx b/core/app/[locale]/(default)/webpages/[id]/_components/web-page.tsx similarity index 86% rename from core/app/[locale]/(default)/webpages/_components/web-page.tsx rename to core/app/[locale]/(default)/webpages/[id]/_components/web-page.tsx index aa99c644ac..3c3664afac 100644 --- a/core/app/[locale]/(default)/webpages/_components/web-page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/_components/web-page.tsx @@ -1,12 +1,10 @@ -import { clsx } from 'clsx'; - import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; import { Breadcrumb, Breadcrumbs, BreadcrumbsSkeleton } from '@/vibes/soul/primitives/breadcrumbs'; -import { SectionLayout } from '@/vibes/soul/sections/section-layout'; export interface WebPage { title: string; content: string; + breadcrumbs: Breadcrumb[]; seo: { pageTitle: string; metaDescription: string; @@ -17,18 +15,12 @@ export interface WebPage { interface Props { webPage: Streamable; breadcrumbs?: Streamable; - className?: string; children?: React.ReactNode; } -export function WebPageContent({ - webPage: streamableWebPage, - className = '', - breadcrumbs, - children, -}: Props) { +export function WebPageContent({ webPage: streamableWebPage, breadcrumbs, children }: Props) { return ( - +
} value={streamableWebPage}> {(webPage) => { const { title, content } = webPage; @@ -52,7 +44,7 @@ export function WebPageContent({ ); }} - +
); } @@ -78,7 +70,7 @@ function WebPageBodySkeleton() { ); } -export function WebPageContentSkeleton() { +function WebPageContentSkeleton() { return (
diff --git a/core/app/[locale]/(default)/webpages/contact/[id]/_actions/submit-contact-form.ts b/core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts similarity index 100% rename from core/app/[locale]/(default)/webpages/contact/[id]/_actions/submit-contact-form.ts rename to core/app/[locale]/(default)/webpages/[id]/contact/_actions/submit-contact-form.ts diff --git a/core/app/[locale]/(default)/webpages/contact/[id]/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts similarity index 86% rename from core/app/[locale]/(default)/webpages/contact/[id]/page-data.ts rename to core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts index 0fd812ac40..2aa306f5d3 100644 --- a/core/app/[locale]/(default)/webpages/contact/[id]/page-data.ts +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page-data.ts @@ -3,6 +3,7 @@ import { cache } from 'react'; import { client } from '~/client'; import { graphql } from '~/client/graphql'; import { revalidate } from '~/client/revalidate-target'; +import { BreadcrumbsWebPageFragment } from '~/components/breadcrumbs/fragment'; const ContactPageQuery = graphql( ` @@ -12,6 +13,7 @@ const ContactPageQuery = graphql( ... on ContactPage { entityId name + ...BreadcrumbsFragment path contactFields htmlBody @@ -32,7 +34,7 @@ const ContactPageQuery = graphql( } } `, - [], + [BreadcrumbsWebPageFragment], ); export const getWebpageData = cache(async (variables: { id: string }) => { diff --git a/core/app/[locale]/(default)/webpages/contact/[id]/page.tsx b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx similarity index 92% rename from core/app/[locale]/(default)/webpages/contact/[id]/page.tsx rename to core/app/[locale]/(default)/webpages/[id]/contact/page.tsx index 67b8372bb7..7d48399956 100644 --- a/core/app/[locale]/(default)/webpages/contact/[id]/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/contact/page.tsx @@ -6,8 +6,12 @@ import { Breadcrumb } from '@/vibes/soul/primitives/breadcrumbs'; import { ButtonLink } from '@/vibes/soul/primitives/button-link'; import { DynamicForm } from '@/vibes/soul/primitives/dynamic-form'; import type { Field, FieldGroup } from '@/vibes/soul/primitives/dynamic-form/schema'; +import { + breadcrumbsTransformer, + truncateBreadcrumbs, +} from '~/data-transformers/breadcrumbs-transformer'; -import { WebPage, WebPageContent } from '../../_components/web-page'; +import { WebPage, WebPageContent } from '../_components/web-page'; import { submitContactForm } from './_actions/submit-contact-form'; import { getWebpageData } from './page-data'; @@ -46,10 +50,13 @@ async function getWebPage(id: string): Promise { return notFound(); } + const breadcrumbs = breadcrumbsTransformer(webpage.breadcrumbs); + return { entityId: webpage.entityId, title: webpage.name, path: webpage.path, + breadcrumbs, content: webpage.htmlBody, contactFields: webpage.contactFields, seo: webpage.seo, @@ -59,17 +66,20 @@ async function getWebPage(id: string): Promise { async function getWebPageBreadcrumbs(id: string): Promise { const webpage = await getWebPage(id); - - return [ + const [, ...rest] = webpage.breadcrumbs.reverse(); + const breadcrumbs = [ { label: 'Home', href: '/', }, + ...rest.reverse(), { label: webpage.title, href: '#', }, ]; + + return truncateBreadcrumbs(breadcrumbs, 5); } async function getWebPageWithSuccessContent(id: string, message: string) { diff --git a/core/app/[locale]/(default)/webpages/[id]/layout.tsx b/core/app/[locale]/(default)/webpages/[id]/layout.tsx new file mode 100644 index 0000000000..83cde53716 --- /dev/null +++ b/core/app/[locale]/(default)/webpages/[id]/layout.tsx @@ -0,0 +1,88 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { cache } from 'react'; + +import { SidebarMenu } from '@/vibes/soul/sections/sidebar-menu'; +import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout'; +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; + +interface Props extends React.PropsWithChildren { + params: Promise<{ id: string }>; +} + +const WebPageChildrenQuery = graphql(` + query WebPageChildren($id: ID!) { + node(id: $id) { + ... on WebPage { + children(first: 20) { + edges { + node { + name + ... on NormalPage { + path + } + ... on ContactPage { + path + } + ... on RawHtmlPage { + path + } + ... on ExternalLinkPage { + link + } + } + } + } + } + } + } +`); + +interface PageLink { + label: string; + href: string; +} + +const getWebPageChildren = cache(async (id: string): Promise => { + const { data } = await client.fetch({ + document: WebPageChildrenQuery, + variables: { id: decodeURIComponent(id) }, + fetchOptions: { next: { revalidate } }, + }); + + if (!data.node) { + return []; + } + + if (!('children' in data.node)) { + return []; + } + + const { children } = data.node; + + return removeEdgesAndNodes(children).reduce((acc: PageLink[], child) => { + if ('path' in child) { + return [...acc, { label: child.name, href: child.path }]; + } + + if ('link' in child) { + return [...acc, { label: child.name, href: child.link }]; + } + + return acc; + }, []); +}); + +export default async function WebPageLayout({ params, children }: Props) { + const { id } = await params; + + return ( + } + sidebarSize="small" + > + {children} + + ); +} diff --git a/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts new file mode 100644 index 0000000000..c3aefec7e0 --- /dev/null +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page-data.ts @@ -0,0 +1,38 @@ +import { cache } from 'react'; + +import { client } from '~/client'; +import { graphql } from '~/client/graphql'; +import { revalidate } from '~/client/revalidate-target'; +import { BreadcrumbsWebPageFragment } from '~/components/breadcrumbs/fragment'; + +const NormalPageQuery = graphql( + ` + query NormalPageQuery($id: ID!) { + node(id: $id) { + ... on NormalPage { + __typename + name + ...BreadcrumbsFragment + htmlBody + entityId + seo { + pageTitle + metaDescription + metaKeywords + } + } + } + } + `, + [BreadcrumbsWebPageFragment], +); + +export const getWebpageData = cache(async (variables: { id: string }) => { + const { data } = await client.fetch({ + document: NormalPageQuery, + variables, + fetchOptions: { next: { revalidate } }, + }); + + return data; +}); diff --git a/core/app/[locale]/(default)/webpages/normal/[id]/page.tsx b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx similarity index 76% rename from core/app/[locale]/(default)/webpages/normal/[id]/page.tsx rename to core/app/[locale]/(default)/webpages/[id]/normal/page.tsx index aac953857e..fb916d0d1d 100644 --- a/core/app/[locale]/(default)/webpages/normal/[id]/page.tsx +++ b/core/app/[locale]/(default)/webpages/[id]/normal/page.tsx @@ -2,8 +2,12 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { Breadcrumb } from '@/vibes/soul/primitives/breadcrumbs'; +import { + breadcrumbsTransformer, + truncateBreadcrumbs, +} from '~/data-transformers/breadcrumbs-transformer'; -import { WebPageContent, WebPage as WebPageData } from '../../_components/web-page'; +import { WebPageContent, WebPage as WebPageData } from '../_components/web-page'; import { getWebpageData } from './page-data'; @@ -19,8 +23,11 @@ async function getWebPage(id: string): Promise { return notFound(); } + const breadcrumbs = breadcrumbsTransformer(webpage.breadcrumbs); + return { title: webpage.name, + breadcrumbs, content: webpage.htmlBody, seo: webpage.seo, }; @@ -28,17 +35,20 @@ async function getWebPage(id: string): Promise { async function getWebPageBreadcrumbs(id: string): Promise { const webpage = await getWebPage(id); - - return [ + const [, ...rest] = webpage.breadcrumbs.reverse(); + const breadcrumbs = [ { label: 'Home', href: '/', }, + ...rest.reverse(), { label: webpage.title, href: '#', }, ]; + + return truncateBreadcrumbs(breadcrumbs, 5); } export async function generateMetadata({ params }: Props): Promise { diff --git a/core/app/[locale]/(default)/webpages/normal/[id]/page-data.ts b/core/app/[locale]/(default)/webpages/normal/[id]/page-data.ts deleted file mode 100644 index 0854890e35..0000000000 --- a/core/app/[locale]/(default)/webpages/normal/[id]/page-data.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { cache } from 'react'; - -import { client } from '~/client'; -import { graphql } from '~/client/graphql'; -import { revalidate } from '~/client/revalidate-target'; - -const NormalPageQuery = graphql(` - query NormalPageQuery($id: ID!) { - node(id: $id) { - ... on NormalPage { - __typename - name - htmlBody - entityId - seo { - pageTitle - metaDescription - metaKeywords - } - } - } - } -`); - -export const getWebpageData = cache(async (variables: { id: string }) => { - const { data } = await client.fetch({ - document: NormalPageQuery, - variables, - fetchOptions: { next: { revalidate } }, - }); - - return data; -}); diff --git a/core/components/breadcrumbs/fragment.ts b/core/components/breadcrumbs/fragment.ts index 53ac5a194c..9578b10d37 100644 --- a/core/components/breadcrumbs/fragment.ts +++ b/core/components/breadcrumbs/fragment.ts @@ -1,6 +1,6 @@ import { graphql } from '~/client/graphql'; -export const BreadcrumbsFragment = graphql(` +export const BreadcrumbsCategoryFragment = graphql(` fragment BreadcrumbsFragment on Category { breadcrumbs(depth: 5) { edges { @@ -12,3 +12,16 @@ export const BreadcrumbsFragment = graphql(` } } `); + +export const BreadcrumbsWebPageFragment = graphql(` + fragment BreadcrumbsFragment on WebPage { + breadcrumbs(depth: 8) { + edges { + node { + name + path + } + } + } + } +`); diff --git a/core/components/footer/fragment.ts b/core/components/footer/fragment.ts index ef2e17c803..b962c9919a 100644 --- a/core/components/footer/fragment.ts +++ b/core/components/footer/fragment.ts @@ -26,7 +26,7 @@ export const FooterFragment = graphql(` } } content { - pages(filters: { isVisibleInNavigation: true }) { + pages(filters: { parentEntityIds: [0] }) { edges { node { __typename diff --git a/core/data-transformers/breadcrumbs-transformer.ts b/core/data-transformers/breadcrumbs-transformer.ts new file mode 100644 index 0000000000..1959d00518 --- /dev/null +++ b/core/data-transformers/breadcrumbs-transformer.ts @@ -0,0 +1,41 @@ +import { removeEdgesAndNodes } from '@bigcommerce/catalyst-client'; +import { ResultOf } from 'gql.tada'; + +import { + BreadcrumbsCategoryFragment, + BreadcrumbsWebPageFragment, +} from '~/components/breadcrumbs/fragment'; +import { Breadcrumb } from '~/vibes/soul/primitives/breadcrumbs'; + +type BreadcrumbsResult = + | ResultOf + | ResultOf; + +export const breadcrumbsTransformer = (breadcrumbs: BreadcrumbsResult['breadcrumbs']) => { + return removeEdgesAndNodes(breadcrumbs).reduce((acc, crumb) => { + if (crumb.path) { + return [...acc, { label: crumb.name, href: crumb.path }]; + } + + return acc; + }, []); +}; + +export function truncateBreadcrumbs(breadcrumbs: Breadcrumb[], length: number): Breadcrumb[] { + if (breadcrumbs.length < length) { + return breadcrumbs; + } + + const middleIndex = Math.floor(breadcrumbs.length / 2); + const dropCount = breadcrumbs.length - length; + const dropEach = Math.ceil(dropCount / 2); + const dropLast = Math.floor(dropCount / 2); + const [first, last] = [ + breadcrumbs.slice(0, middleIndex - dropEach), + breadcrumbs.slice(middleIndex + dropLast), + ]; + + last[0] = { label: '...', href: '#' }; + + return [...first, ...last]; +} diff --git a/core/middlewares/with-routes.ts b/core/middlewares/with-routes.ts index 02065db862..6cbc035c5a 100644 --- a/core/middlewares/with-routes.ts +++ b/core/middlewares/with-routes.ts @@ -318,12 +318,12 @@ export const withRoutes: MiddlewareFactory = () => { } case 'NormalPage': { - url = `/${locale}/webpages/normal/${node.id}`; + url = `/${locale}/webpages/${node.id}/normal/`; break; } case 'ContactPage': { - url = `/${locale}/webpages/contact/${node.id}`; + url = `/${locale}/webpages/${node.id}/contact/`; break; } diff --git a/core/vibes/soul/sections/account-layout/index.tsx b/core/vibes/soul/sections/account-layout/index.tsx deleted file mode 100644 index 01f02ba7bb..0000000000 --- a/core/vibes/soul/sections/account-layout/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { ReactNode } from 'react'; - -import { StickySidebarLayout } from '@/vibes/soul/sections/sticky-sidebar-layout'; - -import { AccountLayoutLink } from './account-layout-link'; -import { AccountLayoutLinkSelect } from './account-layout-link-select'; - -interface Props { - links: Array<{ - href: string; - label: string; - }>; - children: ReactNode; -} - -export function AccountLayout({ links, children }: Props) { - return ( - -
    - {links.map((link, index) => ( -
  • - {link.label} -
  • - ))} -
-
- -
- - } - sidebarSize="small" - > - {children} -
- ); -} diff --git a/core/vibes/soul/sections/sidebar-menu/index.tsx b/core/vibes/soul/sections/sidebar-menu/index.tsx new file mode 100644 index 0000000000..be162303ef --- /dev/null +++ b/core/vibes/soul/sections/sidebar-menu/index.tsx @@ -0,0 +1,63 @@ +import { Stream, Streamable } from '@/vibes/soul/lib/streamable'; + +import { SidebarMenuLink } from './sidebar-menu-link'; +import { SidebarMenuSelect } from './sidebar-menu-select'; + +interface MenuLink { + href: string; + label: string; +} + +interface Props { + links: Streamable; + placeholderCount?: number; +} + +export function SidebarMenu({ links: streamableLinks, placeholderCount = 5 }: Props) { + return ( + } + value={streamableLinks} + > + {(links) => { + if (!links.length) { + return null; + } + + return ( + + ); + }} + + ); +} + +function SidebarMenuSkeleton({ placeholderCount }: { placeholderCount: number }) { + return ( + <> +
+
+ {Array.from({ length: placeholderCount }).map((_, index) => ( +
+
+
+ ))} +
+
+
+
+
+ + ); +} diff --git a/core/vibes/soul/sections/account-layout/account-layout-link.tsx b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx similarity index 95% rename from core/vibes/soul/sections/account-layout/account-layout-link.tsx rename to core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx index ab5cc1e8d1..0c5e544099 100644 --- a/core/vibes/soul/sections/account-layout/account-layout-link.tsx +++ b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-link.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { Link } from '~/components/link'; import { usePathname } from '~/i18n/routing'; -export function AccountLayoutLink({ +export function SidebarMenuLink({ className, href, ...rest diff --git a/core/vibes/soul/sections/account-layout/account-layout-link-select.tsx b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx similarity index 72% rename from core/vibes/soul/sections/account-layout/account-layout-link-select.tsx rename to core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx index 2ebb9f96a1..3d0407c00a 100644 --- a/core/vibes/soul/sections/account-layout/account-layout-link-select.tsx +++ b/core/vibes/soul/sections/sidebar-menu/sidebar-menu-select.tsx @@ -3,17 +3,13 @@ import { Select } from '@/vibes/soul/form/select'; import { usePathname, useRouter } from '~/i18n/routing'; -export function AccountLayoutLinkSelect({ - links, -}: { - links: Array<{ href: string; label: string }>; -}) { +export function SidebarMenuSelect({ links }: { links: Array<{ href: string; label: string }> }) { const pathname = usePathname(); const router = useRouter(); return (