Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DEVREL-977 - Flatten circular data, fix product filtering #57

Merged
merged 6 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,8 @@
"next/core-web-vitals",
"@kontent-ai",
"@kontent-ai/eslint-config/react"
]
],
"rules": {
"@typescript-eslint/no-unused-vars": [2, { "ignoreRestSiblings": true }]
}
}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 6 additions & 4 deletions components/shared/ui/appPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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";
Expand All @@ -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<Nav_NavigationItem>;
defaultMetadata: Pick<Metadata, "elements">;
pageType: "WebPage" | "Article" | "Product" | "Solution",
}>;

export const AppPage: FC<Props> = props => {
useSmartLink();
const siteMenu = parseFlatted(props.siteMenu);

return (
<>
Expand All @@ -31,7 +33,7 @@ export const AppPage: FC<Props> = props => {
defaultMetadata={props.defaultMetadata}
/>
<div className="min-h-full grow flex flex-col items-center overflow-hidden">
{props.siteMenu ? <Menu item={props.siteMenu} /> : <span>Missing top navigation. Please provide a valid navigation item in the web spotlight root.</span>}
<Menu item={siteMenu} />
{/* https://tailwindcss.com/docs/typography-plugin */}
<main
className="grow h-full w-screen bg-slate-50 scroll-smooth"
Expand All @@ -52,7 +54,7 @@ AppPage.displayName = "Page";
const isProductOrSolution = (item: AcceptedItem): item is Product | Solution =>
[contentTypes.solution.codename as string, contentTypes.product.codename as string].includes(item.system.type)

const PageMetadata: FC<Pick<Props, "item" | "defaultMetadata" | "pageType">> = ({ item, defaultMetadata, pageType }) => {
const PageMetadata: FC<Pick<Props, "item" | "defaultMetadata" | "pageType">> = ({ 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;
Expand Down
22 changes: 20 additions & 2 deletions lib/utils/changeUrlQueryString.ts
Original file line number Diff line number Diff line change
@@ -1,6 +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) => {
router.replace({ query: query }, undefined, { scroll: false, shallow: true });
}
// 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: newQuery,
};
router.replace({ query: query }, asPath, { scroll: false, shallow: true });
}
38 changes: 38 additions & 0 deletions lib/utils/circularityUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { parse,stringify } from "flatted";

/**
* Helper methods for managing circular references.
*/

/**
* Stringified object with a type reference for deserialization.
*/
export type Stringified<T> = 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.
* @returns A `Stringified` representation of the input item that retains type `T`.
*/
export const stringifyAsType = <T extends unknown>(item: T): Stringified<T> => {
return stringify(item) as Stringified<T>;
}

/**
* 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 = <T>(flatItem: Stringified<T>): T => {
return parse(flatItem);
}
7 changes: 4 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
56 changes: 32 additions & 24 deletions pages/[envId]/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { 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";
import { contentTypes, Metadata, Nav_NavigationItem, WSL_Page } from "../../models";

type Props = Readonly<{
page: WSL_Page;
siteMenu: Nav_NavigationItem | null;
page: Stringified<WSL_Page>;
siteMenu: Stringified<Nav_NavigationItem>;
defaultMetadata: Metadata;
}>;

Expand Down Expand Up @@ -48,41 +49,48 @@ export const getStaticProps: GetStaticProps<Props, IParams> = 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<WSL_Page>({ envId, previewApiKey }, slug, contentTypes.page.codename, !!context.preview);
const pageData = await getItemBySlug<WSL_Page>({ envId, previewApiKey }, slug, contentTypes.page.codename, !!context.preview);

if (page === null) {
if (pageData === null) {
return {
notFound: true
};
}

const page = stringifyAsType(pageData);
pokornyd marked this conversation as resolved.
Show resolved Hide resolved
const siteMenu = stringifyAsType(siteMenuData);

return {
props: { page, siteMenu, defaultMetadata },
};
}

const TopLevelPage: FC<Props> = props => (
<AppPage
siteMenu={props.siteMenu}
defaultMetadata={props.defaultMetadata}
item={props.page}
pageType="WebPage"
>
<div
{...createElementSmartLink(contentTypes.page.elements.content.codename)}
{...createFixedAddSmartLink("end")}
const TopLevelPage: FC<Props> = (props) => {
const page = parseFlatted(props.page);

return (
<AppPage
siteMenu={props.siteMenu}
defaultMetadata={props.defaultMetadata}
item={page}
pageType="WebPage"
>
{props.page.elements.content.linkedItems.map(piece => (
<Content
key={piece.system.id}
item={piece}
/>
))}
</div>
</AppPage>
);
<div
{...createElementSmartLink(contentTypes.page.elements.content.codename)}
{...createFixedAddSmartLink("end")}
>
{page.elements.content.linkedItems.map((piece) => (
<Content
key={piece.system.id}
item={piece}
/>
))}
</div>
</AppPage>
);
};

export default TopLevelPage;
33 changes: 19 additions & 14 deletions pages/[envId]/articles/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,48 @@ 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";
import { Article, Metadata, Nav_NavigationItem } from "../../../models";


type Props = Readonly<{
article: Article;
siteMenu: Nav_NavigationItem | null;
article: Stringified<Article>;
siteMenu: Stringified<Nav_NavigationItem>;
defaultMetadata: Metadata;
}>;

const ArticlePage: FC<Props> = props => {
const article = parseFlatted(props.article);
return (
<AppPage
siteMenu={props.siteMenu}
defaultMetadata={props.defaultMetadata}
item={props.article}
item={article}
pageType="Article"
>
<HeroImage
url={props.article.elements.heroImage.value[0]?.url || ""}
itemId={props.article.system.id}
url={article.elements.heroImage.value[0]?.url || ""}
itemId={article.system.id}
>
<div className={`py-1 px-3 w-full md:w-fit ${mainColorBgClass[siteCodename]} opacity-90`}>
<h1 className="m-0 text-white text-5xl tracking-wide font-semibold">{props.article.elements.title.value}</h1>
<h1 className="m-0 text-white text-5xl tracking-wide font-semibold">{article.elements.title.value}</h1>
</div>
<div className="p-4">
<p className="font-semibold text-white text-justify">
{props.article.elements.abstract.value}
{article.elements.abstract.value}
</p>
</div>
</HeroImage>
<div className="px-2 max-w-screen m-auto md:px-20">
{props.article.elements.author.linkedItems[0] && <PersonHorizontal item={props.article.elements.author.linkedItems[0]} />}
{article.elements.author.linkedItems[0] && <PersonHorizontal item={article.elements.author.linkedItems[0]} />}
<div className="flex flex-col gap-2">
<div className="w-fit p-2 bg-gray-800 text-white opacity-90 font-semibold">{props.article.elements.publishingDate.value && formatDate(props.article.elements.publishingDate.value)}</div>
<div className="w-fit p-2 bg-gray-800 text-white opacity-90 font-semibold">{article.elements.publishingDate.value && formatDate(article.elements.publishingDate.value)}</div>
<div className="flex gap-2" >
{
props.article.elements.type.value.map(type => (
article.elements.type.value.map(type => (
<div
key={type.codename}
className={`w-fit p-2 ${mainColorBgClass[siteCodename]} font-semibold text-white`}
Expand All @@ -57,7 +59,7 @@ const ArticlePage: FC<Props> = props => {
</div>
</div>
<RichTextElement
element={props.article.elements.content}
element={article.elements.content}
isInsideTable={false}
/>
</div>
Expand All @@ -73,20 +75,23 @@ export const getStaticProps: GetStaticProps<Props, { slug: string, envId: string

const previewApiKey = getPreviewApiKeyFromPreviewData(context.previewData);

const siteMenu = await getSiteMenu({ envId, previewApiKey }, !!context.preview);
const siteMenuData = await getSiteMenu({ envId, previewApiKey }, !!context.preview);
const slug = typeof context.params?.slug === "string" ? context.params.slug : "";

if (!slug) {
return { notFound: true };
}

const article = await getArticleBySlug({ envId: envId, previewApiKey: previewApiKey }, slug, !!context.preview);
const articleData = await getArticleBySlug({ envId: envId, previewApiKey: previewApiKey }, slug, !!context.preview);
const defaultMetadata = await getDefaultMetadata({ envId: envId, previewApiKey: previewApiKey }, !!context.preview);

if (!article) {
if (!articleData) {
return { notFound: true };
}

const article = stringifyAsType(articleData);
const siteMenu = stringifyAsType(siteMenuData);

return {
props: {
article,
Expand Down
Loading
Loading