From f43645ff25ed15f610e409ba72ffb7be7566875f Mon Sep 17 00:00:00 2001 From: Addy Pathania <89087450+sc-addypathania@users.noreply.github.com> Date: Fri, 1 Apr 2022 15:09:32 -0400 Subject: [PATCH] [Nextjs][Personalize] Productize layout response transform (#962) * created submodule for layout-personalizer * added unit tests * fixed nextjs middleware * added test data to diff folder, added doc comments * changed test data variable names * fixed review comments, added more test coverage * fixed review comments * updated jsDoc, refactored personalizePlaceholder function --- .../src/lib/component-props/personalize.ts | 8 -- .../src/lib/layout-personalizer.ts | 64 ---------- .../page-props-factory/plugins/personalize.ts | 2 +- .../templates/nextjs/src/pages/_middleware.ts | 6 +- packages/sitecore-jss-nextjs/src/index.ts | 1 + packages/sitecore-jss/personalize.d.ts | 1 + packages/sitecore-jss/personalize.js | 1 + .../sitecore-jss/src/personalize/index.ts | 1 + .../personalize/layout-personalizer.test.ts | 68 +++++++++++ .../src/personalize/layout-personalizer.ts | 76 ++++++++++++ .../src/test-data/personalizeData.ts | 111 ++++++++++++++++++ packages/sitecore-jss/tsconfig.json | 1 + 12 files changed, 264 insertions(+), 76 deletions(-) delete mode 100644 packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/component-props/personalize.ts delete mode 100644 packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/layout-personalizer.ts create mode 100644 packages/sitecore-jss/personalize.d.ts create mode 100644 packages/sitecore-jss/personalize.js create mode 100644 packages/sitecore-jss/src/personalize/index.ts create mode 100644 packages/sitecore-jss/src/personalize/layout-personalizer.test.ts create mode 100644 packages/sitecore-jss/src/personalize/layout-personalizer.ts create mode 100644 packages/sitecore-jss/src/test-data/personalizeData.ts diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/component-props/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/component-props/personalize.ts deleted file mode 100644 index 880fc1e8ce..0000000000 --- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/component-props/personalize.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { - ComponentRendering, -} from '@sitecore-jss/sitecore-jss-nextjs'; - -// NULL means Hidden by this experience -export type ComponentRenderingWithExpiriences = ComponentRendering & { - experiences: { [name: string]: ComponentRenderingWithExpiriences | null }; -}; diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/layout-personalizer.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/layout-personalizer.ts deleted file mode 100644 index ae86fc5dcd..0000000000 --- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/layout-personalizer.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { LayoutServiceData } from '@sitecore-jss/sitecore-jss-nextjs'; -import { ComponentRendering, HtmlElementRendering } from '@sitecore-jss/sitecore-jss-nextjs'; -import type { ComponentRenderingWithExpiriences } from './component-props/personalize'; - -// recursive go through all placeholders/components and check expirinces node, replace default with object from specific experience -export function personalizeLayout(layout: LayoutServiceData, segment: string): void { - const placeholders = layout.sitecore.route?.placeholders; - if (!placeholders) { - return; - } - Object.keys(placeholders).forEach((placeholder) => { - placeholders[placeholder] = personalizePlaceholder(placeholders[placeholder], segment); - }); -} - -function personalizePlaceholder( - components: Array, - segment: string -): Array { - const newComponents = new Array(); - for (let i = 0; i < components.length; i++) { - if ((components[i]).experiences !== undefined) { - const personalizedComponent = personalizeComponent( - components[i], - segment - ); - if (personalizedComponent) { - newComponents.push(personalizedComponent); - } - } else { - newComponents.push(components[i]); - } - } - return newComponents; -} - -function personalizeComponent( - component: ComponentRenderingWithExpiriences, - segment: string -): ComponentRendering | null { - const segmentVariant = component.experiences[segment]; - if (segmentVariant === null) { - // HIDDEN - return null; - } else if (segmentVariant === undefined && component.componentName === undefined) { - // DEFAULT IS HIDDEN - return null; - } else if (segmentVariant) { - component = segmentVariant; - } - - if (component.placeholders) { - Object.keys(component.placeholders).forEach((placeholder) => { - if (component.placeholders) { - component.placeholders[placeholder] = personalizePlaceholder( - component.placeholders[placeholder], - segment - ); - } - }); - } - - return component; -} diff --git a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts index d60f102417..670a4ce2ea 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-personalize/src/lib/page-props-factory/plugins/personalize.ts @@ -1,6 +1,6 @@ import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; import { Plugin } from '..'; -import { personalizeLayout } from 'lib/layout-personalizer'; +import { personalizeLayout } from '@sitecore-jss/sitecore-jss-nextjs'; import { SitecorePageProps } from 'lib/page-props'; class PersonalizePlugin implements Plugin { diff --git a/packages/create-sitecore-jss/src/templates/nextjs/src/pages/_middleware.ts b/packages/create-sitecore-jss/src/templates/nextjs/src/pages/_middleware.ts index 1c2717558b..23a11a4ab1 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs/src/pages/_middleware.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs/src/pages/_middleware.ts @@ -1,7 +1,7 @@ -import type { NextRequest } from 'next/server'; +import type { NextRequest, NextFetchEvent } from 'next/server'; import middleware from 'lib/middleware'; // eslint-disable-next-line -export default async function (req: NextRequest) { - return middleware(req); +export default async function (req: NextRequest, ev: NextFetchEvent) { + return middleware(req, ev); } diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index 2daa3b0b28..f8f36b14d6 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -51,6 +51,7 @@ export { RestDictionaryService, RestDictionaryServiceConfig, } from '@sitecore-jss/sitecore-jss/i18n'; +export { personalizeLayout } from '@sitecore-jss/sitecore-jss/personalize'; export { RobotsQueryResult, GraphQLRobotsService, diff --git a/packages/sitecore-jss/personalize.d.ts b/packages/sitecore-jss/personalize.d.ts new file mode 100644 index 0000000000..f42ce6854a --- /dev/null +++ b/packages/sitecore-jss/personalize.d.ts @@ -0,0 +1 @@ +export * from './types/personalize/index'; diff --git a/packages/sitecore-jss/personalize.js b/packages/sitecore-jss/personalize.js new file mode 100644 index 0000000000..9edc3d505f --- /dev/null +++ b/packages/sitecore-jss/personalize.js @@ -0,0 +1 @@ +module.exports = require('./dist/cjs/personalize/index'); diff --git a/packages/sitecore-jss/src/personalize/index.ts b/packages/sitecore-jss/src/personalize/index.ts new file mode 100644 index 0000000000..1480629f09 --- /dev/null +++ b/packages/sitecore-jss/src/personalize/index.ts @@ -0,0 +1 @@ +export { personalizeLayout } from './layout-personalizer'; diff --git a/packages/sitecore-jss/src/personalize/layout-personalizer.test.ts b/packages/sitecore-jss/src/personalize/layout-personalizer.test.ts new file mode 100644 index 0000000000..fc33240f6a --- /dev/null +++ b/packages/sitecore-jss/src/personalize/layout-personalizer.test.ts @@ -0,0 +1,68 @@ +import { expect } from 'chai'; +import * as personalize from './layout-personalizer'; +import { ComponentRenderingWithExperiences } from './layout-personalizer'; +import { + layoutData, + componentsArray, + component, + componentsWithExperiencesArray, + componentWithExperiences, + layoutDataWithoutPlaceholder, + withoutComponentName, + segmentIsNull, +} from '../test-data/personalizeData'; + +const { personalizeLayout, personalizePlaceholder, personalizeComponent } = personalize; + +describe('layout-personalizer', () => { + describe('personalizeLayout', () => { + it('should not return anything', () => { + const segment = 'test'; + const personalizedLayoutResult = personalizeLayout(layoutData, segment); + expect(personalizedLayoutResult).to.equal(undefined); + }); + + it('should return undefined if no placeholders', () => { + const segment = 'test'; + const personalizedLayoutResult = personalizeLayout(layoutDataWithoutPlaceholder, segment); + expect(personalizedLayoutResult).to.equal(undefined); + }); + }); + + describe('personalizePlaceholder', () => { + it('should return array of personalized components', () => { + const segment = 'mountain_bike_audience'; + const personalizedPlaceholderResult = personalizePlaceholder(componentsArray, segment); + expect(personalizedPlaceholderResult).to.deep.equal(componentsWithExperiencesArray); + }); + }); + + describe('personalizeComponent', () => { + it('should return personalized component', () => { + const segment = 'mountain_bike_audience'; + const personalizedComponentResult = personalizeComponent( + (component as unknown) as ComponentRenderingWithExperiences, + segment + ); + expect(personalizedComponentResult).to.deep.equal(componentWithExperiences); + }); + + it('should return null when segmentVariant is null', () => { + const segment = 'mountain_bike_audience'; + const personalizedComponentResult = personalizeComponent( + (segmentIsNull as unknown) as ComponentRenderingWithExperiences, + segment + ); + expect(personalizedComponentResult).to.equal(null); + }); + + it('should return null when segmentVariant and componentName is undefined', () => { + const segment = 'test'; + const personalizedComponentResult = personalizeComponent( + (withoutComponentName as unknown) as ComponentRenderingWithExperiences, + segment + ); + expect(personalizedComponentResult).to.equal(null); + }); + }); +}); diff --git a/packages/sitecore-jss/src/personalize/layout-personalizer.ts b/packages/sitecore-jss/src/personalize/layout-personalizer.ts new file mode 100644 index 0000000000..592692a5ed --- /dev/null +++ b/packages/sitecore-jss/src/personalize/layout-personalizer.ts @@ -0,0 +1,76 @@ +import { LayoutServiceData, ComponentRendering, HtmlElementRendering } from './../layout/models'; + +// NULL means Hidden by this experience +export type ComponentRenderingWithExperiences = ComponentRendering & { + experiences: { [name: string]: ComponentRenderingWithExperiences | null }; +}; + +/** + * Apply personalization to layout data. This will recursively go through all placeholders/components, check experiences nodes and replace default with object from specific experience. + * @param {LayoutServiceData} layout Layout data + * @param {string} segment segmentId + */ +export function personalizeLayout(layout: LayoutServiceData, segment: string): void { + const placeholders = layout.sitecore.route?.placeholders; + if (Object.keys(placeholders ?? {}).length === 0) { + return; + } + if (placeholders) { + Object.keys(placeholders).forEach((placeholder) => { + placeholders[placeholder] = personalizePlaceholder(placeholders[placeholder], segment); + }); + } +} + +/** + + * @param {Array} components components within placeholder + * @param {string} segment segmentId + */ +export function personalizePlaceholder( + components: Array, + segment: string +): Array { + return components + .map((component) => + (component as ComponentRenderingWithExperiences).experiences !== undefined + ? (personalizeComponent(component as ComponentRenderingWithExperiences, segment) as + | ComponentRendering + | HtmlElementRendering) + : component + ) + .filter(Boolean); +} + +/** + * @param {ComponentRenderingWithExperiences} component component with experiences + * @param {string} segment segmentId + */ +export function personalizeComponent( + component: ComponentRenderingWithExperiences, + segment: string +): ComponentRendering | null { + const segmentVariant = component.experiences[segment]; + if (segmentVariant === undefined && component.componentName === undefined) { + // DEFAULT IS HIDDEN + return null; + } else if (Object.keys(segmentVariant ?? {}).length === 0) { + // HIDDEN + return null; + } else if (segmentVariant) { + component = segmentVariant; + } + + if (!component.placeholders) return component; + + Object.keys(component?.placeholders).forEach((placeholder) => { + if (component.placeholders) { + component.placeholders[placeholder] = personalizePlaceholder( + component.placeholders[placeholder], + segment + ); + } + }); + + return component; +} diff --git a/packages/sitecore-jss/src/test-data/personalizeData.ts b/packages/sitecore-jss/src/test-data/personalizeData.ts new file mode 100644 index 0000000000..d2e621ebe7 --- /dev/null +++ b/packages/sitecore-jss/src/test-data/personalizeData.ts @@ -0,0 +1,111 @@ +const mountain_bike_audience = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: '20679cd4-356b-4452-b507-453beeb0be39', + fields: { + content: { + value: + '

', + }, + heading: { value: 'Mountain Bike' }, + }, +}; + +const city_bike_audience = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: '36e02581-2056-4c55-a4d5-f4b700ba1ae2', + fields: { + content: { + value: + '

', + }, + heading: { value: 'Mountain Bike' }, + }, +}; + +export const layoutData = { + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + visitorIdentificationTimestamp: 1038543, + language: 'en', + }, + route: { + name: 'landingpage', + placeholders: { + 'jss-main': [ + { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: 'e020fb58-1be8-4537-aab8-67916452ecf2', + fields: { content: { value: '' }, heading: { value: 'Default Content' } }, + experiences: { + mountain_bike_audience: mountain_bike_audience, + city_bike_audience: city_bike_audience, + }, + }, + ], + }, + }, + }, +}; + +export const layoutDataWithoutPlaceholder = { + sitecore: { + context: { + pageEditing: false, + site: { name: 'JssNextWeb' }, + visitorIdentificationTimestamp: 1038543, + language: 'en', + }, + route: { + name: 'landingpage', + placeholders: {}, + }, + }, +}; + +export const componentWithExperiences = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: '20679cd4-356b-4452-b507-453beeb0be39', + fields: mountain_bike_audience.fields, +}; + +export const componentsWithExperiencesArray = [componentWithExperiences]; + +export const component = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: 'ContentBlock', + dataSource: 'e020fb58-1be8-4537-aab8-67916452ecf2', + fields: { content: { value: '' }, heading: { value: 'Default Content' } }, + experiences: { + mountain_bike_audience: mountain_bike_audience, + city_bike_audience: city_bike_audience, + }, +}; + +export const componentsArray = [component]; + +export const withoutComponentName = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: undefined, + dataSource: 'e020fb58-1be8-4537-aab8-67916452ecf2', + fields: { content: { value: '' }, heading: { value: 'Default Content' } }, + experiences: { + mountain_bike_audience: mountain_bike_audience, + city_bike_audience: city_bike_audience, + }, +}; + +export const segmentIsNull = { + uid: '0b6d23d8-c50e-4e79-9eca-317ec43e82b0', + componentName: undefined, + dataSource: 'e020fb58-1be8-4537-aab8-67916452ecf2', + fields: { content: { value: '' }, heading: { value: 'Default Content' } }, + experiences: { + mountain_bike_audience: {}, + }, +}; diff --git a/packages/sitecore-jss/tsconfig.json b/packages/sitecore-jss/tsconfig.json index 74a97f0ad8..f0819c9d84 100644 --- a/packages/sitecore-jss/tsconfig.json +++ b/packages/sitecore-jss/tsconfig.json @@ -38,6 +38,7 @@ "i18n.d.ts", "tracking.d.ts", "site.d.ts", + "personalize.d.ts", "src/**/*.test.ts", "src/testData/*", ]