From 8da3c67a4d25cf3a61c2eab9db5585cb60b7cb06 Mon Sep 17 00:00:00 2001 From: Daniel Pokorny Date: Sun, 5 Nov 2023 22:00:12 +0100 Subject: [PATCH 1/6] add flatted, create helper methods for stringify and parse --- lib/utils/circularityUtils.ts | 36 +++++++++++++++++++++++++++++++++++ package-lock.json | 7 ++++--- package.json | 1 + 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 lib/utils/circularityUtils.ts diff --git a/lib/utils/circularityUtils.ts b/lib/utils/circularityUtils.ts new file mode 100644 index 0000000..e8c9190 --- /dev/null +++ b/lib/utils/circularityUtils.ts @@ -0,0 +1,36 @@ +import { stringify, parse } from "flatted"; + +/** + * Helper methods for managing circular references. + */ + +/** + * Stringified object with a type reference for deserialization. + */ +export type Stringified = string & T + +/** + * Stringifies the provided item as a JSON string while preserving the type information. + * This allows for the serialized string to be treated as both a string and as an object + * of type `T` during type checking. + * + * @template T The type of the item to be stringified. + * @param item The item of generic type `T` to be stringified. + * @returns A `Stringified` representation of the input item that retains type `T`. + */ +export const stringifyAsType = (item: T): Stringified => { + return stringify(item) as Stringified; +} + +/** + * Parses a stringified item that was previously converted to a JSON string + * using `stringifyAsType`. This reverses the stringification process and + * returns the original object with its type information intact. + * + * @template T The expected type of the parsed object. + * @param flatItem A `Stringified` JSON string that represents an object of type `T`. + * @returns The original object of type `T` that was stringified. + */ +export const parseFlatted = (flatItem: Stringified): T => { + return parse(flatItem); +} diff --git a/package-lock.json b/package-lock.json index f0e4579..575a64c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@vercel/analytics": "^1.0.0", "auth0-js": "^9.22.1", "cookies-next": "^4.0.0", + "flatted": "^3.2.9", "next": "^13.5.3", "react": "18.2.0", "react-dom": "18.2.0", @@ -5323,9 +5324,9 @@ } }, "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" + "version": "3.2.9", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", + "integrity": "sha512-36yxDn5H7OFZQla0/jFJmbIKTdZAQHngCedGxiMmpNfEZM0sdEeT+WczLQrjK6D7o2aiyLYDnkw0R3JK0Qv1RQ==" }, "node_modules/follow-redirects": { "version": "1.15.2", diff --git a/package.json b/package.json index dfc3ab2..afaf157 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@vercel/analytics": "^1.0.0", "auth0-js": "^9.22.1", "cookies-next": "^4.0.0", + "flatted": "^3.2.9", "next": "^13.5.3", "react": "18.2.0", "react-dom": "18.2.0", From ba0e9eee9c2268a3c5bb98910948a64322e3ab08 Mon Sep 17 00:00:00 2001 From: Daniel Pokorny Date: Sun, 5 Nov 2023 22:19:33 +0100 Subject: [PATCH 2/6] flatten potential cycles in getStaticProps, adjust types --- components/shared/ui/appPage.tsx | 10 ++-- pages/[envId]/[slug].tsx | 53 ++++++++++--------- pages/[envId]/articles/[slug].tsx | 33 +++++++----- .../category/[category]/page/[page].tsx | 49 ++++++++++------- pages/[envId]/index.tsx | 37 +++++++++---- pages/[envId]/products/[slug].tsx | 13 +++-- pages/[envId]/products/index.tsx | 28 ++++++---- pages/[envId]/solutions/[slug].tsx | 49 +++++++++-------- 8 files changed, 166 insertions(+), 106 deletions(-) diff --git a/components/shared/ui/appPage.tsx b/components/shared/ui/appPage.tsx index 5dfda41..d987c23 100644 --- a/components/shared/ui/appPage.tsx +++ b/components/shared/ui/appPage.tsx @@ -4,6 +4,7 @@ import { FC, ReactNode } from "react"; import { perCollectionSEOTitle } from "../../../lib/constants/labels"; import { ValidCollectionCodename } from "../../../lib/types/perCollection"; import { useSmartLink } from "../../../lib/useSmartLink"; +import { Stringified, parseFlatted } from "../../../lib/utils/circularityUtils"; import { siteCodename } from "../../../lib/utils/env"; import { createItemSmartLink } from "../../../lib/utils/smartLinkUtils"; import { Article, contentTypes, Metadata, Nav_NavigationItem, Product, Solution, WSL_Page, WSL_WebSpotlightRoot } from "../../../models"; @@ -15,13 +16,14 @@ type AcceptedItem = WSL_WebSpotlightRoot | Article | Product | WSL_Page | Soluti type Props = Readonly<{ children: ReactNode; item: AcceptedItem; - siteMenu: Nav_NavigationItem | null; - defaultMetadata: Metadata; + siteMenu: Stringified; + defaultMetadata: Pick; pageType: "WebPage" | "Article" | "Product" | "Solution", }>; export const AppPage: FC = props => { useSmartLink(); + const siteMenu = parseFlatted(props.siteMenu); return ( <> @@ -31,7 +33,7 @@ export const AppPage: FC = props => { defaultMetadata={props.defaultMetadata} />
- {props.siteMenu ? : Missing top navigation. Please provide a valid navigation item in the web spotlight root.} + {props.siteMenu ? : Missing top navigation. Please provide a valid navigation item in the web spotlight root.} {/* https://tailwindcss.com/docs/typography-plugin */}
[contentTypes.solution.codename as string, contentTypes.product.codename as string].includes(item.system.type) -const PageMetadata: FC> = ({ item, defaultMetadata, pageType }) => { + const PageMetadata: FC> = ({ item, defaultMetadata, pageType }) => { const pageMetaTitle = createMetaTitle(siteCodename, item); const pageMetaDescription = item.elements.metadataDescription.value || defaultMetadata.elements.metadataDescription.value; const pageMetaKeywords = item.elements.metadataKeywords.value || defaultMetadata.elements.metadataKeywords.value; diff --git a/pages/[envId]/[slug].tsx b/pages/[envId]/[slug].tsx index 08fb7c6..ad8948e 100644 --- a/pages/[envId]/[slug].tsx +++ b/pages/[envId]/[slug].tsx @@ -6,14 +6,15 @@ import { Content } from "../../components/shared/Content"; import { AppPage } from "../../components/shared/ui/appPage"; import { getDefaultMetadata, getItemBySlug, getPagesSlugs, getSiteMenu } from "../../lib/kontentClient"; import { reservedListingSlugs } from "../../lib/routing"; +import { Stringified, parseFlatted, stringifyAsType } from "../../lib/utils/circularityUtils"; import { defaultEnvId } from "../../lib/utils/env"; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from "../../lib/utils/pageUtils"; import { createElementSmartLink, createFixedAddSmartLink } from "../../lib/utils/smartLinkUtils"; import { contentTypes, Metadata, Nav_NavigationItem, WSL_Page } from "../../models"; type Props = Readonly<{ - page: WSL_Page; - siteMenu: Nav_NavigationItem | null; + page: Stringified; + siteMenu: Stringified; defaultMetadata: Metadata; }>; @@ -48,41 +49,45 @@ export const getStaticProps: GetStaticProps = async (context) => const previewApiKey = getPreviewApiKeyFromPreviewData(context.previewData); - const siteMenu = await getSiteMenu({ envId, previewApiKey }, !!context.preview); + const siteMenuData = await getSiteMenu({ envId, previewApiKey }, !!context.preview); const defaultMetadata = await getDefaultMetadata({ envId, previewApiKey }, !!context.preview); - const page = await getItemBySlug({ envId, previewApiKey }, slug, contentTypes.page.codename, !!context.preview); + const pageData = await getItemBySlug({ envId, previewApiKey }, slug, contentTypes.page.codename, !!context.preview); - if (page === null) { + if (pageData === null) { return { notFound: true }; } + const page = stringifyAsType(pageData); + const siteMenu = stringifyAsType(siteMenuData); + return { props: { page, siteMenu, defaultMetadata }, }; } -const TopLevelPage: FC = props => ( - -
= (props) => { + const page = parseFlatted(props.page); + + return ( + - {props.page.elements.content.linkedItems.map(piece => ( - - ))} -
-
-); +
+ {page.elements.content.linkedItems.map((piece) => ( + + ))} +
+ + ); +}; export default TopLevelPage; diff --git a/pages/[envId]/articles/[slug].tsx b/pages/[envId]/articles/[slug].tsx index 1f29c02..772eac9 100644 --- a/pages/[envId]/articles/[slug].tsx +++ b/pages/[envId]/articles/[slug].tsx @@ -7,6 +7,7 @@ import { RichTextElement } from "../../../components/shared/richText/RichTextEle import { AppPage } from "../../../components/shared/ui/appPage"; import { mainColorBgClass } from "../../../lib/constants/colors"; import { getAllArticles, getArticleBySlug, getDefaultMetadata, getSiteMenu } from "../../../lib/kontentClient"; +import { parseFlatted, Stringified, stringifyAsType } from "../../../lib/utils/circularityUtils"; import { formatDate } from "../../../lib/utils/dateTime"; import { defaultEnvId, siteCodename } from "../../../lib/utils/env"; import { getPreviewApiKeyFromPreviewData } from "../../../lib/utils/pageUtils"; @@ -14,39 +15,40 @@ import { Article, Metadata, Nav_NavigationItem } from "../../../models"; type Props = Readonly<{ - article: Article; - siteMenu: Nav_NavigationItem | null; + article: Stringified
; + siteMenu: Stringified; defaultMetadata: Metadata; }>; const ArticlePage: FC = props => { + const article = parseFlatted(props.article); return (
-

{props.article.elements.title.value}

+

{article.elements.title.value}

- {props.article.elements.abstract.value} + {article.elements.abstract.value}

- {props.article.elements.author.linkedItems[0] && } + {article.elements.author.linkedItems[0] && }
-
{props.article.elements.publishingDate.value && formatDate(props.article.elements.publishingDate.value)}
+
{article.elements.publishingDate.value && formatDate(article.elements.publishingDate.value)}
{ - props.article.elements.type.value.map(type => ( + article.elements.type.value.map(type => (
= props => {
@@ -73,20 +75,23 @@ export const getStaticProps: GetStaticProps; - siteMenu: Nav_NavigationItem | null, - page: WSL_Page, + articles: Stringified; + siteMenu: Stringified, + page: Stringified, itemCount: number; defaultMetadata: Metadata; }>; @@ -108,9 +109,11 @@ const FilterOptions: FC = ({ options, router }) => { const ArticlesPagingPage: FC = props => { const router = useRouter(); - const page = typeof router.query.page === 'string' ? +router.query.page : undefined; + const pageNumber = typeof router.query.page === 'string' ? +router.query.page : undefined; const category = typeof router.query.category === 'string' ? router.query.category : "all"; const filterOptions = getFilterOptions(); + const articles = parseFlatted(props.articles); + const page = parseFlatted(props.page); const pageCount = Math.ceil(props.itemCount / ArticlePageSize); @@ -118,10 +121,10 @@ const ArticlesPagingPage: FC = props => { - {props.page.elements.content.linkedItems.map(piece => ( + {page.elements.content.linkedItems.map(piece => ( = props => {
{props.articles.length > 0 ? (
    - {props.articles.map(article => ( + {articles.map(article => ( article.elements.type.value[0]?.codename && ( = props => {
  • = props => { : resolveUrlPath({ type: "article", term: category, - page: page - 1 + page: pageNumber - 1 } as ResolutionContext)} - disabled={page === 1} + disabled={pageNumber === 1} roundLeft /> @@ -188,7 +191,7 @@ const ArticlesPagingPage: FC = props => { term: category, page: i + 1 > 1 ? i + 1 : undefined } as ResolutionContext)} - highlight={(page ?? 1) === i + 1} + highlight={(pageNumber ?? 1) === i + 1} />
  • ))} @@ -198,9 +201,9 @@ const ArticlesPagingPage: FC = props => { href={resolveUrlPath({ type: "article", term: category, - page: page ? page + 1 : 2 + page: pageNumber ? pageNumber + 1 : 2 } as ResolutionContext)} - disabled={(page ?? 1) === pageCount} + disabled={(pageNumber ?? 1) === pageCount} roundRight /> @@ -248,23 +251,31 @@ export const getStaticProps: GetStaticProps = asy const previewApiKey = getPreviewApiKeyFromPreviewData(context.previewData); const pageNumber = !pageURLParameter || isNaN(+pageURLParameter) ? 1 : +pageURLParameter; - const articles = await getArticlesForListing({ envId, previewApiKey }, !!context.preview, pageNumber, selectedCategory); - const siteMenu = await getSiteMenu({ envId, previewApiKey }, !!context.preview); - const page = await getItemBySlug({ envId, previewApiKey }, "articles", contentTypes.page.codename, !!context.preview); + const articlesData = await getArticlesForListing({ envId, previewApiKey }, !!context.preview, pageNumber, selectedCategory); + const siteMenuData = await getSiteMenu({ envId, previewApiKey }, !!context.preview); + const pageData = await getItemBySlug({ envId, previewApiKey }, "articles", contentTypes.page.codename, !!context.preview); const itemCount = await getArticlesCountByCategory({ envId, previewApiKey }, !!context.preview, selectedCategory) const defaultMetadata = await getDefaultMetadata({ envId, previewApiKey }, !!context.preview); - if (page === null) { + if (pageData === null) { return { notFound: true }; } + if (!siteMenuData) { + throw new Error("Can't find main menu item."); + } + + const siteMenu = stringifyAsType(siteMenuData); + const page = stringifyAsType(pageData); + const articles = stringifyAsType(articlesData.items); + return { props: { - articles: articles.items, + articles, siteMenu, page, itemCount, - defaultMetadata + defaultMetadata, }, revalidate: 10, }; diff --git a/pages/[envId]/index.tsx b/pages/[envId]/index.tsx index 35e6a9f..8059b4b 100644 --- a/pages/[envId]/index.tsx +++ b/pages/[envId]/index.tsx @@ -7,20 +7,21 @@ import { Content } from '../../components/shared/Content'; import { AppPage } from '../../components/shared/ui/appPage'; import { getHomepage, getSiteMenu } from '../../lib/kontentClient'; import { useSmartLink } from '../../lib/useSmartLink'; +import { Stringified, parseFlatted, stringifyAsType } from '../../lib/utils/circularityUtils'; import { defaultEnvId } from '../../lib/utils/env'; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from '../../lib/utils/pageUtils'; -import { Nav_NavigationItem, WSL_WebSpotlightRoot } from '../../models'; +import { Metadata, Nav_NavigationItem, WSL_WebSpotlightRoot } from '../../models'; type Props = Readonly<{ - homepage: WSL_WebSpotlightRoot; - siteMenu: Nav_NavigationItem | null; + homepage: Stringified; + siteMenu: Stringified; + metaData: Pick; isPreview: boolean; }>; const Home: NextPage = props => { - const [homepage, setHomepage] = useState(props.homepage); - + const [homepage, setHomepage] = useState(parseFlatted(props.homepage)); const sdk = useSmartLink(); useEffect(() => { @@ -43,9 +44,9 @@ const Home: NextPage = props => { return (
    {homepage.elements.content.linkedItems.map(item => ( @@ -64,15 +65,29 @@ export const getStaticProps: GetStaticProps = async co const previewApiKey = getPreviewApiKeyFromPreviewData(context.previewData); - const homepage = await getHomepage({ envId, previewApiKey }, !!context.preview); - const siteMenu = await getSiteMenu({ envId, previewApiKey }, !!context.preview); + const homepageData = await getHomepage({ envId, previewApiKey }, !!context.preview); + const siteMenuData = await getSiteMenu({ envId, previewApiKey }, !!context.preview); - if (!homepage) { + if (!homepageData) { throw new Error("Can't find homepage item."); } + if (!siteMenuData) { + throw new Error("Can't find main menu item."); + } + + const siteMenu = stringifyAsType(siteMenuData); + const homepage = stringifyAsType(homepageData); + const metaData = { + elements: { + metadataDescription: homepageData.elements.metadataDescription, + metadataKeywords: homepageData.elements.metadataKeywords, + metadataTitle: homepageData.elements.metadataTitle + } + } as const; + return { - props: { homepage, siteMenu, isPreview: !!context.preview }, + props: { homepage, siteMenu, isPreview: !!context.preview, metaData }, }; } diff --git a/pages/[envId]/products/[slug].tsx b/pages/[envId]/products/[slug].tsx index e716005..2670fb8 100644 --- a/pages/[envId]/products/[slug].tsx +++ b/pages/[envId]/products/[slug].tsx @@ -6,6 +6,7 @@ import { FC } from "react"; import { AppPage } from "../../../components/shared/ui/appPage"; import { mainColorButtonClass, mainColorHoverClass, mainColorTextClass } from "../../../lib/constants/colors"; import { getDefaultMetadata, getProductDetail, getProductItemsWithSlugs, getSiteMenu } from "../../../lib/kontentClient"; +import { Stringified, stringifyAsType } from "../../../lib/utils/circularityUtils"; import { defaultEnvId, siteCodename } from "../../../lib/utils/env"; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from "../../../lib/utils/pageUtils"; import { createElementSmartLink } from "../../../lib/utils/smartLinkUtils"; @@ -16,7 +17,7 @@ import { contentTypes, Metadata, Nav_NavigationItem, Product } from "../../../mo type Props = Readonly<{ product: Product; defaultMetadata: Metadata; - siteMenu: Nav_NavigationItem | null; + siteMenu: Stringified; }>; interface IParams extends ParsedUrlQuery { @@ -48,18 +49,24 @@ export const getStaticProps: GetStaticProps = async (context) => const previewApiKey = getPreviewApiKeyFromPreviewData(context.previewData); const product = await getProductDetail({ envId, previewApiKey }, slug, !!context.preview); - const siteMenu = await getSiteMenu({ envId, previewApiKey }, !!context.preview); + const siteMenuData = await getSiteMenu({ envId, previewApiKey }, !!context.preview); const defaultMetadata = await getDefaultMetadata({ envId, previewApiKey }, !!context.preview); if (!product) { return { notFound: true }; } + if (!siteMenuData) { + throw new Error("Can't find main menu item."); + } + + const siteMenu = stringifyAsType(siteMenuData); + return { props: { product, siteMenu, - defaultMetadata + defaultMetadata, } }; }; diff --git a/pages/[envId]/products/index.tsx b/pages/[envId]/products/index.tsx index be3093f..d0d9f38 100644 --- a/pages/[envId]/products/index.tsx +++ b/pages/[envId]/products/index.tsx @@ -11,16 +11,16 @@ import { ProductsPageSize } from "../../../lib/constants/paging"; import { getDefaultMetadata, getItemBySlug, getProductsForListing, getSiteMenu } from "../../../lib/kontentClient"; import { createQueryString, reservedListingSlugs, resolveUrlPath } from "../../../lib/routing"; import { changeUrlQueryString } from "../../../lib/utils/changeUrlQueryString"; +import { Stringified, parseFlatted, stringifyAsType } from "../../../lib/utils/circularityUtils"; import { defaultEnvId, siteCodename } from "../../../lib/utils/env"; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from "../../../lib/utils/pageUtils"; import { contentTypes, Metadata, Nav_NavigationItem, Product, WSL_Page } from "../../../models"; - type Props = Readonly<{ - page: WSL_Page; + page: Stringified; products: ReadonlyArray | undefined; totalCount: number; - siteMenu: Nav_NavigationItem | null; + siteMenu: Stringified; isPreview: boolean; defaultMetadata: Metadata; }>; @@ -61,7 +61,8 @@ export const Products: FC = props => { const [totalCount, setTotalCount] = useState(props.totalCount); const [products, setProducts] = useState | undefined>(props.products); const [taxonomies, setTaxonomies] = useState([]); - const { page, category } = router.query + const { page, category } = router.query; + const productsPage = parseFlatted(props.page); const pageNumber = useMemo(() => !page || isNaN(+page) ? 1 : +page, [page]) @@ -159,10 +160,10 @@ export const Products: FC = props => { - {props.page.elements.content.linkedItems.map(piece => ( + {productsPage.elements.content.linkedItems.map(piece => ( = props => {

    Category

      - {taxonomies.map(renderFilterOption)} + {Array.isArray(taxonomies) && taxonomies.map(renderFilterOption)}
    @@ -205,19 +206,26 @@ export const getStaticProps: GetStaticProps = async co const previewApiKey = getPreviewApiKeyFromPreviewData(context.previewData); // We might want to bound listing pages to something else than URL slug - const page = await getItemBySlug({ envId: envId, previewApiKey: previewApiKey }, reservedListingSlugs.products, contentTypes.page.codename, !!context.preview); + const pageData = await getItemBySlug({ envId: envId, previewApiKey: previewApiKey }, reservedListingSlugs.products, contentTypes.page.codename, !!context.preview); - if (page === null) { + if (pageData === null) { return { notFound: true }; } const products = await getProductsForListing({ envId, previewApiKey }, !!context.preview); - const siteMenu = await getSiteMenu({ envId, previewApiKey }, !!context.preview); + const siteMenuData = await getSiteMenu({ envId, previewApiKey }, !!context.preview); const defaultMetadata = await getDefaultMetadata({ envId, previewApiKey }, !!context.preview); + if (!siteMenuData) { + throw new Error("Can't find the main menu item.") + } + + const siteMenu = stringifyAsType(siteMenuData); + const page = stringifyAsType(pageData); + return { props: { page, defaultMetadata, products: products.items, totalCount: products.pagination.totalCount ?? 0, siteMenu, isPreview: !!context.preview }, }; diff --git a/pages/[envId]/solutions/[slug].tsx b/pages/[envId]/solutions/[slug].tsx index 72e5e68..4054ba1 100644 --- a/pages/[envId]/solutions/[slug].tsx +++ b/pages/[envId]/solutions/[slug].tsx @@ -7,17 +7,16 @@ import { RichTextElement } from "../../../components/shared/richText/RichTextEle import { AppPage } from "../../../components/shared/ui/appPage"; import { mainColorBgClass } from "../../../lib/constants/colors"; import { getDefaultMetadata, getSiteMenu, getSolutionDetail, getSolutionsWithSlugs } from "../../../lib/kontentClient"; +import { parseFlatted, Stringified, stringifyAsType } from "../../../lib/utils/circularityUtils"; import { defaultEnvId, siteCodename } from "../../../lib/utils/env"; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from "../../../lib/utils/pageUtils"; import { createElementSmartLink } from "../../../lib/utils/smartLinkUtils"; import { contentTypes, Metadata, Nav_NavigationItem, Solution } from "../../../models"; - - type Props = Readonly<{ - solution: Solution; + solution: Stringified; defaultMetadata: Metadata; - siteMenu: Nav_NavigationItem | null; + siteMenu: Stringified; }>; interface IParams extends ParsedUrlQuery { @@ -50,34 +49,40 @@ export const getStaticProps: GetStaticProps = async ( const previewApiKey = getPreviewApiKeyFromPreviewData(context.previewData); - const solution = await getSolutionDetail({ envId, previewApiKey }, slug, !!context.preview); - const siteMenu = await getSiteMenu({ envId, previewApiKey }, !!context.preview); + const solutionData = await getSolutionDetail({ envId, previewApiKey }, slug, !!context.preview); + const siteMenuData = await getSiteMenu({ envId, previewApiKey }, !!context.preview); const defaultMetadata = await getDefaultMetadata({ envId, previewApiKey }, !!context.preview); - if (!solution) { + if (!solutionData) { return { notFound: true }; } + if (!siteMenuData) { + throw new Error("Can't find the main menu item.") + } + + const solution = stringifyAsType(solutionData); + const siteMenu = stringifyAsType(siteMenuData); + return { props: { solution, siteMenu, - defaultMetadata, + defaultMetadata }, }; }; -const SolutionDetail: FC = ({ - solution, - siteMenu, - defaultMetadata, -}) => ( - +const SolutionDetail: FC = props => { + const solution = parseFlatted(props.solution); + + return( + = ({ isInsideTable={false} />
    -
    -); + + ) + +}; export default SolutionDetail; From fb4736e2b67ef0a953b50f8546bd21055e2703e9 Mon Sep 17 00:00:00 2001 From: Daniel Pokorny Date: Mon, 6 Nov 2023 01:15:38 +0100 Subject: [PATCH 3/6] fix missing product category filtering query param, omit envId from URL --- components/shared/ui/appPage.tsx | 2 +- lib/utils/changeUrlQueryString.ts | 7 ++++++- lib/utils/circularityUtils.ts | 2 +- pages/[envId]/[slug].tsx | 7 +++++-- pages/[envId]/articles/category/[category]/page/[page].tsx | 2 +- pages/[envId]/index.tsx | 2 +- pages/[envId]/products/index.tsx | 4 ++-- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/components/shared/ui/appPage.tsx b/components/shared/ui/appPage.tsx index d987c23..5f709aa 100644 --- a/components/shared/ui/appPage.tsx +++ b/components/shared/ui/appPage.tsx @@ -4,7 +4,7 @@ import { FC, ReactNode } from "react"; import { perCollectionSEOTitle } from "../../../lib/constants/labels"; import { ValidCollectionCodename } from "../../../lib/types/perCollection"; import { useSmartLink } from "../../../lib/useSmartLink"; -import { Stringified, parseFlatted } from "../../../lib/utils/circularityUtils"; +import { parseFlatted,Stringified } from "../../../lib/utils/circularityUtils"; import { siteCodename } from "../../../lib/utils/env"; import { createItemSmartLink } from "../../../lib/utils/smartLinkUtils"; import { Article, contentTypes, Metadata, Nav_NavigationItem, Product, Solution, WSL_Page, WSL_WebSpotlightRoot } from "../../../models"; diff --git a/lib/utils/changeUrlQueryString.ts b/lib/utils/changeUrlQueryString.ts index 6395a5c..ed72376 100644 --- a/lib/utils/changeUrlQueryString.ts +++ b/lib/utils/changeUrlQueryString.ts @@ -2,5 +2,10 @@ import { NextRouter } from "next/router"; import { ParsedUrlQueryInput } from "querystring"; export const changeUrlQueryString = (query: ParsedUrlQueryInput, router: NextRouter) => { - router.replace({ query: query }, undefined, { scroll: false, shallow: true }); + const { envId, ...restQuery } = query; // get rid of envId + const asPath = { + pathname: router.asPath.split('?')[0], + query: restQuery, + }; + router.replace({ query: query }, asPath, { scroll: false, shallow: true }); } \ No newline at end of file diff --git a/lib/utils/circularityUtils.ts b/lib/utils/circularityUtils.ts index e8c9190..3b89422 100644 --- a/lib/utils/circularityUtils.ts +++ b/lib/utils/circularityUtils.ts @@ -1,4 +1,4 @@ -import { stringify, parse } from "flatted"; +import { parse,stringify } from "flatted"; /** * Helper methods for managing circular references. diff --git a/pages/[envId]/[slug].tsx b/pages/[envId]/[slug].tsx index ad8948e..00e7973 100644 --- a/pages/[envId]/[slug].tsx +++ b/pages/[envId]/[slug].tsx @@ -6,7 +6,7 @@ import { Content } from "../../components/shared/Content"; import { AppPage } from "../../components/shared/ui/appPage"; import { getDefaultMetadata, getItemBySlug, getPagesSlugs, getSiteMenu } from "../../lib/kontentClient"; import { reservedListingSlugs } from "../../lib/routing"; -import { Stringified, parseFlatted, stringifyAsType } from "../../lib/utils/circularityUtils"; +import { parseFlatted, Stringified, stringifyAsType } from "../../lib/utils/circularityUtils"; import { defaultEnvId } from "../../lib/utils/env"; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from "../../lib/utils/pageUtils"; import { createElementSmartLink, createFixedAddSmartLink } from "../../lib/utils/smartLinkUtils"; @@ -83,7 +83,10 @@ const TopLevelPage: FC = (props) => { {...createFixedAddSmartLink("end")} > {page.elements.content.linkedItems.map((piece) => ( - + ))}
diff --git a/pages/[envId]/articles/category/[category]/page/[page].tsx b/pages/[envId]/articles/category/[category]/page/[page].tsx index eea608f..f2fc681 100644 --- a/pages/[envId]/articles/category/[category]/page/[page].tsx +++ b/pages/[envId]/articles/category/[category]/page/[page].tsx @@ -12,7 +12,7 @@ import { ArticlePageSize } from "../../../../../../lib/constants/paging"; import { getArticlesCountByCategory, getArticlesForListing, getDefaultMetadata, getItemBySlug, getItemsTotalCount, getSiteMenu } from "../../../../../../lib/kontentClient"; import { ResolutionContext, resolveUrlPath } from "../../../../../../lib/routing"; import { ArticleListingUrlQuery, ArticleTypeWithAll, categoryFilterSource, isArticleType } from "../../../../../../lib/utils/articlesListing"; -import { Stringified, parseFlatted, stringifyAsType } from "../../../../../../lib/utils/circularityUtils"; +import { parseFlatted, Stringified, stringifyAsType } from "../../../../../../lib/utils/circularityUtils"; import { defaultEnvId, siteCodename } from "../../../../../../lib/utils/env"; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from "../../../../../../lib/utils/pageUtils"; import { Article, contentTypes, Metadata, Nav_NavigationItem, taxonomies, WSL_Page } from "../../../../../../models"; diff --git a/pages/[envId]/index.tsx b/pages/[envId]/index.tsx index 8059b4b..43b1fd3 100644 --- a/pages/[envId]/index.tsx +++ b/pages/[envId]/index.tsx @@ -7,7 +7,7 @@ import { Content } from '../../components/shared/Content'; import { AppPage } from '../../components/shared/ui/appPage'; import { getHomepage, getSiteMenu } from '../../lib/kontentClient'; import { useSmartLink } from '../../lib/useSmartLink'; -import { Stringified, parseFlatted, stringifyAsType } from '../../lib/utils/circularityUtils'; +import { parseFlatted, Stringified, stringifyAsType } from '../../lib/utils/circularityUtils'; import { defaultEnvId } from '../../lib/utils/env'; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from '../../lib/utils/pageUtils'; import { Metadata, Nav_NavigationItem, WSL_WebSpotlightRoot } from '../../models'; diff --git a/pages/[envId]/products/index.tsx b/pages/[envId]/products/index.tsx index d0d9f38..f225564 100644 --- a/pages/[envId]/products/index.tsx +++ b/pages/[envId]/products/index.tsx @@ -11,7 +11,7 @@ import { ProductsPageSize } from "../../../lib/constants/paging"; import { getDefaultMetadata, getItemBySlug, getProductsForListing, getSiteMenu } from "../../../lib/kontentClient"; import { createQueryString, reservedListingSlugs, resolveUrlPath } from "../../../lib/routing"; import { changeUrlQueryString } from "../../../lib/utils/changeUrlQueryString"; -import { Stringified, parseFlatted, stringifyAsType } from "../../../lib/utils/circularityUtils"; +import { parseFlatted, Stringified, stringifyAsType } from "../../../lib/utils/circularityUtils"; import { defaultEnvId, siteCodename } from "../../../lib/utils/env"; import { getEnvIdFromRouteParams, getPreviewApiKeyFromPreviewData } from "../../../lib/utils/pageUtils"; import { contentTypes, Metadata, Nav_NavigationItem, Product, WSL_Page } from "../../../models"; @@ -123,7 +123,7 @@ export const Products: FC = props => { ? [...categories, term.codename, ...term.terms.map((t) => t.codename)] : categories.filter((c) => c !== term.codename && !term.terms.map((t) => t.codename).includes(c)); - changeUrlQueryString({ category: newCategories }, router); + changeUrlQueryString({ ...router.query, category: newCategories }, router); }; return ( From 8bff0d0966b20a21777a536b64f2933cb0fa08f2 Mon Sep 17 00:00:00 2001 From: Daniel Pokorny Date: Mon, 6 Nov 2023 01:43:31 +0100 Subject: [PATCH 4/6] remove redundant conditional in appPage, override eslint to allow rest-omit --- .eslintrc.json | 5 ++++- components/shared/ui/appPage.tsx | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 34ffc4a..12ad614 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,5 +3,8 @@ "next/core-web-vitals", "@kontent-ai", "@kontent-ai/eslint-config/react" - ] + ], + "rules": { + "@typescript-eslint/no-unused-vars": [2, { "ignoreRestSiblings": true }] + } } diff --git a/components/shared/ui/appPage.tsx b/components/shared/ui/appPage.tsx index 5f709aa..246ccc2 100644 --- a/components/shared/ui/appPage.tsx +++ b/components/shared/ui/appPage.tsx @@ -33,7 +33,7 @@ export const AppPage: FC = props => { defaultMetadata={props.defaultMetadata} />
- {props.siteMenu ? : Missing top navigation. Please provide a valid navigation item in the web spotlight root.} + {/* https://tailwindcss.com/docs/typography-plugin */}
Date: Thu, 9 Nov 2023 10:14:56 +0100 Subject: [PATCH 5/6] refactor changeUrlQueryString, update comments --- lib/utils/changeUrlQueryString.ts | 21 +++++++++++++++++---- lib/utils/circularityUtils.ts | 2 ++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/utils/changeUrlQueryString.ts b/lib/utils/changeUrlQueryString.ts index ed72376..05b0a31 100644 --- a/lib/utils/changeUrlQueryString.ts +++ b/lib/utils/changeUrlQueryString.ts @@ -1,11 +1,24 @@ +import { Url } from "next/dist/shared/lib/router/router"; import { NextRouter } from "next/router"; import { ParsedUrlQueryInput } from "querystring"; +/** + * Helper method for shallow client routing. + * Adds query parameters without including `envId` in the path. + * + * @param query Parsed query parameters from the router. + * @param router Instance of NextRouter. + */ export const changeUrlQueryString = (query: ParsedUrlQueryInput, router: NextRouter) => { - const { envId, ...restQuery } = query; // get rid of envId - const asPath = { + // Clone the query object to avoid mutating the original + const newQuery = { ...query }; + // Delete the envId property + delete newQuery.envId; + + const asPath: Url = { + // nextJS mixes path and query params, this ensure envId is not in the pathname pathname: router.asPath.split('?')[0], - query: restQuery, + query: newQuery, }; router.replace({ query: query }, asPath, { scroll: false, shallow: true }); -} \ No newline at end of file +} diff --git a/lib/utils/circularityUtils.ts b/lib/utils/circularityUtils.ts index 3b89422..4d085aa 100644 --- a/lib/utils/circularityUtils.ts +++ b/lib/utils/circularityUtils.ts @@ -13,6 +13,8 @@ export type Stringified = string & T * Stringifies the provided item as a JSON string while preserving the type information. * This allows for the serialized string to be treated as both a string and as an object * of type `T` during type checking. + * + * Stringification allows passing circular data through getStaticProps. * * @template T The type of the item to be stringified. * @param item The item of generic type `T` to be stringified. From 359ddd01c1062c2310b34ddd7dfe9e86da04c988 Mon Sep 17 00:00:00 2001 From: Daniel Pokorny Date: Thu, 9 Nov 2023 15:19:37 +0100 Subject: [PATCH 6/6] add readme entry on circular data handling --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 462763b..99a0bd1 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,10 @@ You can adjust the homepage by editing `pages/[envId]/index.tsx`. The page auto- To generate new models from Kontent.ai data, just run `npm run generateModels`. Make sure you have environment variables filled in properly. +### Circular reference handling + +Next.js data fetching functions convert objects to JSON format. Since JSON doesn't support circular data, this can potentially cause crashes in situations where objects reference each other, such as with linked items or rich text elements. To avoid this, the application uses the [`flatted`](https://www.npmjs.com/package/flatted) package to implement two helper functions: `stringifyAsType` and `parseFlatted`, which allow for safe conversion of circular structures into a string form in `getStaticProps` and then accurately reconstruct the original objects from that string. + ### Use codebase as a starter > ⚠ This project is not intended as a starter project. It is a sample of a presentation channel showcasing Kontent.ai capabilities. The following hints help you use this code as a base for presentation channel for your project like a boilerplate. By doing it, you are accepting the fact you are changing the purpose of this code.