diff --git a/CHANGELOG.md b/CHANGELOG.md index e9a59a60dd..f083d7851c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ Our versioning strategy is as follows: ## Unreleased +### 🎉 New Features & Improvements + +* `[nextjs][sitecore-jss-nextjs]` Support for Component Library feature in XMCloud ([#1987](https://github.com/Sitecore/jss/pull/1987)) + ## 22.3.0 / 22.3.1 ### 🐛 Bug Fixes diff --git a/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/Bootstrap.tsx b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/Bootstrap.tsx index c69834458b..4075b29119 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/Bootstrap.tsx +++ b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/Bootstrap.tsx @@ -13,9 +13,10 @@ const Bootstrap = (props: SitecorePageProps): JSX.Element | null => { // Browser ClientSDK init allows for page view events to be tracked useEffect(() => { const pageState = props.layoutData?.sitecore?.context.pageState; + const renderingType = props.layoutData?.sitecore?.context.renderingType; if (process.env.NODE_ENV === 'development') console.debug('Browser Events SDK is not initialized in development environment'); - else if (pageState !== LayoutServicePageState.Normal) + else if (pageState !== LayoutServicePageState.Normal || renderingType === 'component') console.debug('Browser Events SDK is not initialized in edit and preview modes'); else { CloudSDK({ diff --git a/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/preview-mode.ts b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/preview-mode.ts index f9b3125b7e..25cd93e374 100644 --- a/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/preview-mode.ts +++ b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/lib/page-props-factory/plugins/preview-mode.ts @@ -6,11 +6,14 @@ import { } from '@sitecore-jss/sitecore-jss-nextjs'; import { editingDataService, + isComponentLibraryPreviewData, isEditingMetadataPreviewData, } from '@sitecore-jss/sitecore-jss-nextjs/editing'; import { SitecorePageProps } from 'lib/page-props'; import { graphQLEditingService } from 'lib/graphql-editing-service'; import { Plugin } from '..'; +import { RestComponentLayoutService } from '@sitecore-jss/sitecore-jss-nextjs'; +import config from 'temp/config'; class PreviewModePlugin implements Plugin { order = 1; @@ -18,6 +21,48 @@ class PreviewModePlugin implements Plugin { async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) { if (!context.preview) return props; + if (isComponentLibraryPreviewData(context.previewData)) { + const { itemId, componentUid, site, language, renderingId, dataSourceId, version, variant } = + context.previewData; + + const componentService = new RestComponentLayoutService({ + apiHost: config.sitecoreApiHost, + apiKey: config.sitecoreApiKey, + siteName: site, + configurationName: config.layoutServiceConfigurationName, + }); + + const componentData = await componentService.fetchComponentData({ + siteName: site, + itemId, + language, + componentUid, + renderingId, + dataSourceId, + variant, + version, + }); + + // we can reuse editing service, fortunately + const dictionaryData = await graphQLEditingService.fetchDictionaryData({ + siteName: site, + language, + }); + + if (!componentData) { + throw new Error( + `Unable to fetch editing data for preview ${JSON.stringify(context.previewData)}` + ); + } + + props.locale = context.previewData.language; + props.layoutData = componentData; + props.headLinks = []; + props.dictionary = dictionaryData; + + return props; + } + // If we're in Pages preview (editing) Metadata Edit Mode, prefetch the editing data if (isEditingMetadataPreviewData(context.previewData)) { const { site, itemId, language, version, variantIds, layoutKind } = context.previewData; diff --git a/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/pages/component-library/render.tsx b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/pages/component-library/render.tsx new file mode 100644 index 0000000000..b939a1a7f4 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/nextjs-xmcloud/src/pages/component-library/render.tsx @@ -0,0 +1,51 @@ +import { GetServerSideProps } from 'next'; +import Head from 'next/head'; +import { + ComponentLibraryLayout, + ComponentPropsContext, + SitecoreContext, +} from '@sitecore-jss/sitecore-jss-nextjs'; +import { SitecorePageProps } from 'lib/page-props'; +import { sitecorePagePropsFactory } from 'lib/page-props-factory'; +import NotFound from 'src/NotFound'; +import { componentBuilder } from 'temp/componentBuilder'; +import config from 'temp/config'; + +const FEAASRender = ({ + notFound, + componentProps, + layoutData, + headLinks, +}: SitecorePageProps): JSX.Element => { + if (notFound) { + return ; + } + return ( + + + + Sitecore Component Library + + {headLinks.map((headLink) => ( + + ))} + + + + + ); +}; + +export const getServerSideProps: GetServerSideProps = async (context) => { + const props = await sitecorePagePropsFactory.create(context); + return { + props, + // not found when page not requested through editing render api or notFound set in page-props + notFound: props.notFound || !context.preview, + }; +}; + +export default FEAASRender; diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts index cc79b8c362..7330ad92c2 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts @@ -22,6 +22,7 @@ import { } from './editing-render-middleware'; import { spy, match } from 'sinon'; import sinonChai from 'sinon-chai'; +import { EditMode } from '@sitecore-jss/sitecore-jss/layout'; use(sinonChai); @@ -202,6 +203,86 @@ describe('EditingRenderMiddleware', () => { }); describe('metadata handler', () => { + describe('Component Library handling', () => { + const query = { + mode: 'library', + sc_itemid: '{11111111-1111-1111-1111-111111111111}', + sc_lang: 'en', + sc_site: 'website', + sc_variant: 'dev', + sc_version: 'latest', + secret: secret, + sc_renderingId: '123', + sc_datasourceId: '456', + sc_uid: '789', + }; + + it('should handle request with mode=library', async () => { + const req = mockRequest(EE_BODY, query, 'GET'); + const res = mockResponse(); + + const middleware = new EditingRenderMiddleware(); + const handler = middleware.getHandler(); + + await handler(req, res); + + expect(res.setPreviewData, 'set preview mode w/ data').to.have.been.calledWith({ + itemId: query.sc_itemid, + componentUid: query.sc_uid, + renderingId: query.sc_renderingId, + language: query.sc_lang, + site: query.sc_site, + pageState: 'normal', + mode: 'library', + dataSourceId: query.sc_datasourceId, + variant: query.sc_variant, + version: query.sc_version, + }); + + expect(res.redirect).to.have.been.calledOnce; + expect(res.redirect).to.have.been.calledWith('/component-library/render'); + expect(res.setHeader).to.have.been.calledWith( + 'Content-Security-Policy', + `frame-ancestors 'self' https://allowed.com ${EDITING_ALLOWED_ORIGINS.join(' ')}` + ); + }); + + it('should always use component library path for redirect', async () => { + const notQuiteRightQuery = { + ...query, + route: '/Styleguide', + }; + const req = mockRequest(EE_BODY, notQuiteRightQuery, 'GET'); + const res = mockResponse(); + + const middleware = new EditingRenderMiddleware(); + const handler = middleware.getHandler(); + + await handler(req, res); + + expect(res.redirect).to.have.been.calledOnce; + expect(res.redirect).to.have.been.calledWith('/component-library/render'); + }); + + it('should response with 400 for missing query params', async () => { + const req = mockRequest(EE_BODY, { sc_site: 'website', secret }, 'GET'); + const res = mockResponse(); + + const middleware = new EditingRenderMiddleware(); + const handler = middleware.getHandler(); + + await handler(req, res); + + expect(res.status).to.have.been.calledOnce; + expect(res.status).to.have.been.calledWith(400); + expect(res.json).to.have.been.calledOnce; + expect(res.json).to.have.been.calledWith({ + html: + 'Missing required query parameters: sc_itemid, sc_lang, route, mode', + }); + }); + }); + const query = { mode: 'edit', route: '/styleguide', diff --git a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts index fccbd77cc5..9f544812d1 100644 --- a/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts +++ b/packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts @@ -269,7 +269,7 @@ export type EditingRenderMiddlewareMetadataConfig = Pick< /** * Next.js API request with Metadata query parameters. */ -type MetadataNextApiRequest = NextApiRequest & { +export type MetadataNextApiRequest = NextApiRequest & { query: RenderMetadataQueryParams; }; @@ -287,6 +287,22 @@ export type EditingMetadataPreviewData = { layoutKind?: LayoutKind; }; +/** + * Data for Component Library rendering mode + */ +export interface ComponentLibraryRenderPreviewData { + site: string; + itemId: string; + renderingId: string; + componentUid: string; + language: string; + pageState: LayoutServicePageState; + mode?: 'library'; + variant?: string; + version?: string; + dataSourceId?: string; +} + /** * Type guard for EditingMetadataPreviewData * @param {object} data preview data to check @@ -302,6 +318,23 @@ export const isEditingMetadataPreviewData = (data: unknown): data is EditingMeta ); }; +/** + * Type guard for Component Library mode + * @param {object} data preview data to check + * @returns true if the data is EditingMetadataPreviewData + * @see EditingMetadataPreviewData + */ +export const isComponentLibraryPreviewData = ( + data: unknown +): data is ComponentLibraryRenderPreviewData => { + return ( + typeof data === 'object' && + data !== null && + 'mode' in data && + (data as ComponentLibraryRenderPreviewData).mode === 'library' + ); +}; + /** * Handler for the Editing Metadata GET requests. * This handler is responsible for redirecting the request to the page route. @@ -315,13 +348,20 @@ export class MetadataHandler { const startTimestamp = Date.now(); - const requiredQueryParams: (keyof RenderMetadataQueryParams)[] = [ + const mode = query.mode; + const metadataDefaultRequiredParams = ['sc_site', 'sc_itemid', 'sc_lang', 'route', 'mode']; + + const metadataComponentRequiredParams = [ 'sc_site', 'sc_itemid', + 'sc_renderingId', + 'sc_datasourceId', + 'sc_uid', 'sc_lang', - 'route', 'mode', ]; + const requiredQueryParams = + mode === 'library' ? metadataComponentRequiredParams : metadataDefaultRequiredParams; const missingQueryParams = requiredQueryParams.filter((param) => !query[param]); @@ -336,23 +376,45 @@ export class MetadataHandler { }); } - res.setPreviewData( - { - site: query.sc_site, - itemId: query.sc_itemid, - language: query.sc_lang, - // for sc_variantId we may employ multiple variants (page-layout + component level) - variantIds: query.sc_variant?.split(',') || [DEFAULT_VARIANT], - version: query.sc_version, - editMode: EditMode.Metadata, - pageState: query.mode, - layoutKind: query.sc_layoutKind, - } as EditingMetadataPreviewData, - // Cache the preview data for 3 seconds to ensure the page is rendered with the correct preview data not the cached one - { - maxAge: 3, - } - ); + if (mode === 'library') { + // dedicated route and layout to SSR component library + query.route = '/component-library/render'; + res.setPreviewData( + { + itemId: query.sc_itemid, + componentUid: query.sc_uid, + renderingId: query.sc_renderingId, + language: query.sc_lang, + site: query.sc_site, + pageState: LayoutServicePageState.Normal, + mode: 'library', + dataSourceId: query.sc_datasourceId, + variant: query.sc_variant || DEFAULT_VARIANT, + version: query.sc_version, + } as ComponentLibraryRenderPreviewData, + { + maxAge: 3, + } + ); + } else { + res.setPreviewData( + { + site: query.sc_site, + itemId: query.sc_itemid, + language: query.sc_lang, + // for sc_variantId we may employ multiple variants (page-layout + component level) + variantIds: query.sc_variant?.split(',') || [DEFAULT_VARIANT], + version: query.sc_version, + editMode: EditMode.Metadata, + pageState: query.mode, + layoutKind: query.sc_layoutKind, + } as EditingMetadataPreviewData, + // Cache the preview data for 3 seconds to ensure the page is rendered with the correct preview data not the cached one + { + maxAge: 3, + } + ); + } // Cookies with the SameSite=Lax policy set by Next.js setPreviewData function causes CORS issue // when Next.js preview mode is activated, resulting the page to render in normal mode instead. diff --git a/packages/sitecore-jss-nextjs/src/editing/index.ts b/packages/sitecore-jss-nextjs/src/editing/index.ts index 7a3fead22f..69cf39f6e6 100644 --- a/packages/sitecore-jss-nextjs/src/editing/index.ts +++ b/packages/sitecore-jss-nextjs/src/editing/index.ts @@ -7,6 +7,7 @@ export { EditingRenderMiddlewareConfig, EditingMetadataPreviewData, isEditingMetadataPreviewData, + isComponentLibraryPreviewData, } from './editing-render-middleware'; export { EditingPreviewData, @@ -23,3 +24,8 @@ export { EditingConfigMiddleware, EditingConfigMiddlewareConfig, } from './editing-config-middleware'; +export { + RenderingType, + EDITING_COMPONENT_PLACEHOLDER, + EDITING_COMPONENT_ID, +} from '@sitecore-jss/sitecore-jss/layout'; diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index a3bb4beee6..21d51d19df 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -40,6 +40,7 @@ export { getContentStylesheetLink, EditMode, } from '@sitecore-jss/sitecore-jss/layout'; +export { RestComponentLayoutService } from '@sitecore-jss/sitecore-jss/editing'; export { mediaApi } from '@sitecore-jss/sitecore-jss/media'; export { trackingApi, @@ -151,6 +152,7 @@ export { File, FileField, RichTextField, + ComponentLibraryLayout, DefaultEmptyFieldEditingComponentImage, DefaultEmptyFieldEditingComponentText, VisitorIdentification, diff --git a/packages/sitecore-jss-react/src/components/ComponentLibraryLayout.test.tsx b/packages/sitecore-jss-react/src/components/ComponentLibraryLayout.test.tsx new file mode 100644 index 0000000000..6240cacf08 --- /dev/null +++ b/packages/sitecore-jss-react/src/components/ComponentLibraryLayout.test.tsx @@ -0,0 +1,208 @@ +/* eslint-disable no-unused-expressions */ +/* eslint-disable react/prop-types */ +import React from 'react'; +import sinon from 'sinon'; +import { expect } from 'chai'; +import { mount } from 'enzyme'; +import { ComponentLibraryLayout } from './ComponentLibraryLayout'; +import { getTestLayoutData } from '../test-data/component-editing-data'; +import { ComponentFactory } from './sharedTypes'; +import { SitecoreContext } from './SitecoreContext'; +import { RichText } from './RichText'; +import { Text } from './Text'; +import { Placeholder } from '..'; +import { + COMPONENT_LIBRARY_READY_MESSAGE, + ComponentUpdateEventArgs, +} from '@sitecore-jss/sitecore-jss/editing'; + +describe('', () => { + const postMessageSpy = sinon.spy(global.window, 'postMessage'); + let rendered = mount(
); + + const componentFactory: ComponentFactory = (componentName: string) => { + const components = new Map(); + + const ContentBlock: React.FC<{ + [prop: string]: unknown; + fields?: { content: { value: string }; heading: { value: string } }; + }> = (props) => ( +
+ + +
+ ); + + const InnerBlock: React.FC<{ + [prop: string]: unknown; + fields?: { text: { value: string } }; + }> = (props) => ( +
+ +
+ ); + + components.set('ContentBlock', ContentBlock); + components.set('InnerBlock', InnerBlock); + + return components.get(componentName) || null; + }; + + it('should render', () => { + const basicPage = getTestLayoutData(); + rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + '
', + '
', + '

This is a live set of examples of how to use JSS

\n', + '
', + ].join('') + ); + }); + + it('should render component with placeholders', () => { + const placeholderPage = getTestLayoutData(true); + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + '
', + '
', + '

This is a live set of examples of how to use JSS

\n', + '
', + '
', + 'Its an inner component', + '
', + '
', + ].join('') + ); + }); + + it('should fire component:ready event', () => { + const basicPage = getTestLayoutData(); + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + '
', + '
', + '

This is a live set of examples of how to use JSS

\n', + '
', + ].join('') + ); + + expect( + postMessageSpy + .getCalls() + .some( + (call) => JSON.stringify(call.args[0]) === JSON.stringify(COMPONENT_LIBRARY_READY_MESSAGE) + ) + ).to.be.true; + }); + + it('should update root component', async () => { + const basicPage = getTestLayoutData(); + const rendered = mount( + + + + ); + + expect(rendered.html()).to.equal( + [ + '
', + '
', + '

This is a live set of examples of how to use JSS

\n', + '
', + ].join('') + ); + // jsdom performs postMessage without origin. We work around, ugly (https://github.com/jsdom/jsdom/issues/2745) + // jsdom also doesn't consider `new MessageEvent()` to be of class Event - so we go very much around to get it working + const updateEvent = document.createEvent('Event'); + const updateEventData: ComponentUpdateEventArgs = { + name: 'component:update', + details: { + uid: 'test-content', + fields: { content: { value: 'new content!' } }, + }, + }; + updateEvent.initEvent('message', false, true); + (updateEvent as any).origin = window.location.origin; + (updateEvent as any).data = updateEventData; + await window.dispatchEvent(updateEvent); + + rendered.update(); + expect(rendered.html()).to.equal( + [ + '
', + '
', + 'new content!', + '
', + ].join('') + ); + }); + + it('should update nested component', async () => { + const placeholderPage = getTestLayoutData(true); + const rendered = mount( + + + + ); + expect(rendered.html()).to.equal( + [ + '
', + '
', + '

This is a live set of examples of how to use JSS

\n', + '
', + '
', + 'Its an inner component', + '
', + '
', + ].join('') + ); + // jsdom performs postMessage without origin. We work around, ugly (https://github.com/jsdom/jsdom/issues/2745) + // jsdom also doesn't consider `new MessageEvent()` to be of class Event - so we go very much around to get it working + const updateEvent = document.createEvent('Event'); + const updateEventData: ComponentUpdateEventArgs = { + name: 'component:update', + details: { + uid: 'test-inner', + fields: { text: { value: 'new inner content!' } }, + }, + }; + updateEvent.initEvent('message'); + (updateEvent as any).origin = window.location.origin; + (updateEvent as any).data = updateEventData; + await window.dispatchEvent(updateEvent); + + rendered.update(); + + expect(rendered.html()).to.equal( + [ + '
', + '
', + '

This is a live set of examples of how to use JSS

\n', + '
', + '
', + 'new inner content!', + '
', + '
', + ].join('') + ); + }); +}); diff --git a/packages/sitecore-jss-react/src/components/ComponentLibraryLayout.tsx b/packages/sitecore-jss-react/src/components/ComponentLibraryLayout.tsx new file mode 100644 index 0000000000..2a1a177008 --- /dev/null +++ b/packages/sitecore-jss-react/src/components/ComponentLibraryLayout.tsx @@ -0,0 +1,52 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Placeholder } from './Placeholder'; +import { + ComponentRendering, + EDITING_COMPONENT_ID, + EDITING_COMPONENT_PLACEHOLDER, + LayoutServiceData, +} from '@sitecore-jss/sitecore-jss/layout'; +import { + addComponentUpdateHandler, + COMPONENT_LIBRARY_READY_MESSAGE, +} from '@sitecore-jss/sitecore-jss/editing'; +import { EditingScripts } from './EditingScripts'; + +export const ComponentLibraryLayout = (layoutData: LayoutServiceData): JSX.Element => { + const { route } = layoutData.sitecore; + const [rootUpdate, setRootUpdate] = useState(null); + const rootComponent = route?.placeholders[EDITING_COMPONENT_PLACEHOLDER][0] as ComponentRendering; + // useEffect may execute multiple times on single render (i.e. in dev) - but we only want to fire ready event once + let componentReady = false; + + // have an up-to-date layout state between re-renders (SSR re-render excluded) + const persistedRoot = useMemo(() => ({ ...(rootComponent || {}), ...rootUpdate }), [ + rootComponent, + rootUpdate, + ]); + route.placeholders[EDITING_COMPONENT_PLACEHOLDER][0] = persistedRoot; + + useEffect(() => { + // useEffect will fire when components are ready - and we inform the whole wide world of it too + if (!componentReady) { + componentReady = true; + window.top.postMessage(COMPONENT_LIBRARY_READY_MESSAGE, '*'); + } + const unsubscribe = addComponentUpdateHandler(persistedRoot, (updatedRoot) => + setRootUpdate({ ...updatedRoot }) + ); + // useEffect will cleanup event handler on re-render + return unsubscribe; + }, []); + + return ( + <> + +
+
+ {route && } +
+
+ + ); +}; diff --git a/packages/sitecore-jss-react/src/components/VisitorIdentification.test.tsx b/packages/sitecore-jss-react/src/components/VisitorIdentification.test.tsx index 6b820b4440..bb87e5db7c 100644 --- a/packages/sitecore-jss-react/src/components/VisitorIdentification.test.tsx +++ b/packages/sitecore-jss-react/src/components/VisitorIdentification.test.tsx @@ -40,7 +40,7 @@ describe('', () => { const meta = document.head.getElementsByTagName('meta')[0]; expect(script).to.not.be.equal(undefined); expect(meta).to.not.be.equal(undefined); - expect(script.src).to.equal('/layouts/system/VisitorIdentification.js'); + expect(script.src).to.equal('http://localhost/layouts/system/VisitorIdentification.js'); expect(script.defer).to.be.equal(false); expect(rendered.html()).to.be.null; }); diff --git a/packages/sitecore-jss-react/src/index.ts b/packages/sitecore-jss-react/src/index.ts index a8e38528a3..9c2d37e173 100644 --- a/packages/sitecore-jss-react/src/index.ts +++ b/packages/sitecore-jss-react/src/index.ts @@ -81,6 +81,7 @@ export { fetchFEaaSComponentServerProps, } from './components/FEaaSComponent'; export { FEaaSWrapper } from './components/FEaaSWrapper'; +export { ComponentLibraryLayout } from './components/ComponentLibraryLayout'; export { BYOCComponent, BYOCComponentParams, diff --git a/packages/sitecore-jss-react/src/test-data/component-editing-data.ts b/packages/sitecore-jss-react/src/test-data/component-editing-data.ts new file mode 100644 index 0000000000..dd150d39c2 --- /dev/null +++ b/packages/sitecore-jss-react/src/test-data/component-editing-data.ts @@ -0,0 +1,85 @@ +import { EDITING_COMPONENT_PLACEHOLDER } from '@sitecore-jss/sitecore-jss/layout'; +import { LayoutServicePageState } from '@sitecore-jss/sitecore-jss/src/layout'; + +const basicPage = { + path: '/Styleguide', + layoutData: { + sitecore: { + context: { + pageEditing: false, + user: { + domain: 'sitecore', + name: 'Admin', + }, + site: { + name: 'JssNextWeb', + }, + pageState: LayoutServicePageState.Normal, + language: 'en', + itemPath: '/Styleguide', + }, + route: { + name: 'Styleguide', + displayName: 'Styleguide', + fields: { + pageTitle: { + value: 'Styleguide | Sitecore JSS', + }, + }, + databaseName: 'master', + deviceId: 'fe5d7fdf-89c0-4d99-9aa3-b5fbd009c9f3', + itemId: '52961eea-bafd-5287-a532-a72e36bd8a36', + itemLanguage: 'en', + itemVersion: 1, + layoutId: '4092f843-b14e-5f7a-9ae6-3ed9f5c2b919', + templateId: 'ca5a5aeb-55ae-501b-bb10-d37d009a97e1', + templateName: 'App Route', + placeholders: {}, + }, + }, + }, +}; + +// default setup for placeholder-less component +const contentBlock = [ + { + uid: 'test-content', + componentName: 'ContentBlock', + dataSource: '{FC218D50-FC56-5B2B-99BA-38D570A83386}', + params: {}, + fields: { + content: { + value: '

This is a live set of examples of how to use JSS

\r\n', + }, + heading: { + value: 'JSS Styleguide', + }, + }, + placeholders: {}, + }, +]; + +// content for placeholder-yes component +const innerBlock = [ + { + uid: 'test-inner', + componentName: 'InnerBlock', + params: {}, + fields: { + text: { + value: 'Its an inner component', + }, + }, + }, +]; + +export const getTestLayoutData = (placeholder?: boolean) => { + // making hard copies to not have layout modified + const layout = JSON.parse(JSON.stringify(basicPage)); + const content = JSON.parse(JSON.stringify(contentBlock)); + if (placeholder) { + content[0].placeholders.inner = innerBlock; + } + layout.layoutData.sitecore.route.placeholders[EDITING_COMPONENT_PLACEHOLDER] = content; + return layout; +}; diff --git a/packages/sitecore-jss-react/src/tests/jsdom-setup.ts b/packages/sitecore-jss-react/src/tests/jsdom-setup.ts index 4b548e69f6..26145ee230 100644 --- a/packages/sitecore-jss-react/src/tests/jsdom-setup.ts +++ b/packages/sitecore-jss-react/src/tests/jsdom-setup.ts @@ -10,7 +10,9 @@ declare var global: NodeJS.Global; const { JSDOM } = require('jsdom'); -const jsdom = new JSDOM(''); +const jsdom = new JSDOM('', { + url: 'http://localhost', +}); const jsDomWindow = jsdom.window; /** @@ -34,6 +36,7 @@ function copyProps(src: unknown, target: { [key: string]: unknown }) { global.window = jsDomWindow; global.document = jsDomWindow.document; global.navigator['#userAgent'] = 'node.js'; +global.jsdom = jsdom; global.HTMLElement = jsDomWindow.HTMLElement; // makes chai "happy" https://github.com/chaijs/chai/issues/1029 copyProps(jsDomWindow, global); diff --git a/packages/sitecore-jss/src/editing/graphql-editing-service.test.ts b/packages/sitecore-jss/src/editing/graphql-editing-service.test.ts index d7ec53338c..1b2bbaa835 100644 --- a/packages/sitecore-jss/src/editing/graphql-editing-service.test.ts +++ b/packages/sitecore-jss/src/editing/graphql-editing-service.test.ts @@ -358,6 +358,32 @@ describe('GraphQLEditingService', () => { spy.restore(clientFactorySpy); }); + it('should request dictionary from scratch when fetchDictionaryData called on its own', async () => { + nock(hostname, { reqheaders: { sc_editMode: 'true' } }) + .post(endpointPath, /DictionaryQuery/gi) + .reply(200, mockEditingServiceDictionaryResponse.pageOne); + + nock(hostname, { reqheaders: { sc_editMode: 'true' } }) + .post(endpointPath, /DictionaryQuery/gi) + .reply(200, mockEditingServiceDictionaryResponse.pageTwo); + + const service = new GraphQLEditingService({ + clientFactory: clientFactory, + }); + + const result = await service.fetchDictionaryData({ + language, + siteName, + }); + + expect(result).to.deep.equal({ + 'foo-one': 'foo-one-phrase', + 'bar-one': 'bar-one-phrase', + 'foo-two': 'foo-two-phrase', + 'bar-two': 'bar-two-phrase', + }); + }); + it('should return empty dictionary when dictionary is not provided', async () => { const editingData = mockEditingServiceResponse(); diff --git a/packages/sitecore-jss/src/editing/graphql-editing-service.ts b/packages/sitecore-jss/src/editing/graphql-editing-service.ts index 9b95d34934..311530437f 100644 --- a/packages/sitecore-jss/src/editing/graphql-editing-service.ts +++ b/packages/sitecore-jss/src/editing/graphql-editing-service.ts @@ -149,7 +149,6 @@ export class GraphQLEditingService { throw new RangeError('The language must be a non-empty string'); } - const dictionary: DictionaryPhrases = {}; let dictionaryResults: { key: string; value: string }[] = []; let hasNext = true; let after = ''; @@ -177,6 +176,41 @@ export class GraphQLEditingService { hasNext = false; } + const dictionary = await this.fetchDictionaryData( + { siteName, language }, + dictionaryResults, + hasNext, + after + ); + + return { + layoutData: editingData?.item?.rendered || { + sitecore: { + context: { pageEditing: true, language, editMode: EditMode.Metadata }, + route: null, + }, + }, + dictionary, + }; + } + + async fetchDictionaryData( + { + siteName, + language, + }: { + siteName: string; + language: string; + }, + initDictionary: { + key: string; + value: string; + }[] = [], + hasNext = true, + after?: string + ) { + let dictionaryResults = initDictionary; + const dictionary: DictionaryPhrases = {}; while (hasNext) { const data = await this.graphQLClient.request( dictionaryQuery, @@ -195,18 +229,8 @@ export class GraphQLEditingService { hasNext = false; } } - dictionaryResults.forEach((item) => (dictionary[item.key] = item.value)); - - return { - layoutData: editingData?.item?.rendered || { - sitecore: { - context: { pageEditing: true, language, editMode: EditMode.Metadata }, - route: null, - }, - }, - dictionary, - }; + return dictionary; } /** diff --git a/packages/sitecore-jss/src/editing/index.ts b/packages/sitecore-jss/src/editing/index.ts index e198d34f6a..25c22e3edd 100644 --- a/packages/sitecore-jss/src/editing/index.ts +++ b/packages/sitecore-jss/src/editing/index.ts @@ -8,10 +8,17 @@ export { handleEditorAnchors, Metadata, getJssPagesClientData, + addComponentUpdateHandler, EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET, PAGES_EDITING_MARKER, + COMPONENT_LIBRARY_READY_MESSAGE, + ComponentUpdateEventArgs, } from './utils'; +export { + RestComponentLayoutService, + ComponentLayoutRequestParams, +} from './rest-component-layout-service'; export { DefaultEditFrameButton, DefaultEditFrameButtons, @@ -23,5 +30,5 @@ export { EditButtonTypes, mapButtonToCommand, } from './edit-frame'; -export { RenderMetadataQueryParams } from './models'; +export { RenderMetadataQueryParams, RenderComponentQueryParams } from './models'; export { LayoutKind, MetadataKind } from './models'; diff --git a/packages/sitecore-jss/src/editing/models.ts b/packages/sitecore-jss/src/editing/models.ts index ddd3b3da15..528d397c0e 100644 --- a/packages/sitecore-jss/src/editing/models.ts +++ b/packages/sitecore-jss/src/editing/models.ts @@ -3,6 +3,7 @@ import { LayoutServicePageState } from '../layout'; /** * Query parameters appended to the page route URL * Appended when XMCloud Pages preview (editing) Metadata Edit Mode is used + * `mode` is a special case as it serves editing and component library both */ export interface RenderMetadataQueryParams { [key: string]: unknown; @@ -11,12 +12,29 @@ export interface RenderMetadataQueryParams { sc_itemid: string; sc_site: string; route: string; - mode: Exclude; + mode: Exclude | 'library'; sc_layoutKind?: LayoutKind; sc_variant?: string; sc_version?: string; } +/** + * Query parameters appended for Component Library functionaity. + * Used when a single component is rendered in Pages. + */ +export interface RenderComponentQueryParams { + [key: string]: unknown; + secret: string; + sc_lang: string; + sc_itemid: string; + sc_renderingId: string; + sc_uid: string; + sc_site: string; + mode: 'library'; + sc_variant?: string; + sc_version?: string; +} + /** * Represents the Editing Layout variant. * - shared - shared layout diff --git a/packages/sitecore-jss/src/editing/rest-component-layout-service.test.ts b/packages/sitecore-jss/src/editing/rest-component-layout-service.test.ts new file mode 100644 index 0000000000..aeece086b0 --- /dev/null +++ b/packages/sitecore-jss/src/editing/rest-component-layout-service.test.ts @@ -0,0 +1,508 @@ +/* eslint-disable no-unused-expressions */ +import { expect, spy, use } from 'chai'; +import spies from 'chai-spies'; +import { IncomingMessage, ServerResponse } from 'http'; +import { AxiosRequestConfig } from 'axios'; +import { AxiosDataFetcher } from '../axios-fetcher'; +import { + ComponentLayoutRequestParams, + RestComponentLayoutService, +} from './rest-component-layout-service'; +import { EditMode, LayoutServiceData } from '../layout/models'; +import nock from 'nock'; + +use(spies); + +describe('RestComponentLayoutService', () => { + type SetHeader = (name: string, value: unknown) => void; + + const defaultTestInput: ComponentLayoutRequestParams = { + itemId: '123', + componentUid: '456', + }; + + const defaultTestData = { + sitecore: { + context: {}, + route: { + name: 'xxx', + placeholders: { + 'editing-componentmode-placeholder': [], + }, + }, + }, + }; + + afterEach(() => { + nock.cleanAll(); + }); + + it('should fetch component data', () => { + nock('http://sctest') + .get( + '/sitecore/api/layout/component/jss?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&sc_site=supersite&sc_lang=en' + ) + .reply(200, (_, requestBody) => ({ + requestBody: requestBody, + data: defaultTestData, + })); + + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + + return service + .fetchComponentData(defaultTestInput) + .then((layoutServiceData: LayoutServiceData & AxiosRequestConfig) => { + expect(layoutServiceData.data).to.deep.equal(defaultTestData); + }); + }); + + it('should fetch component data and invoke callbacks', () => { + nock('http://sctest') + .get( + '/sitecore/api/layout/component/jss?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&sc_site=supersite&sc_lang=en' + ) + .reply(200, (_, requestBody) => ({ + requestBody: requestBody, + data: { sitecore: { context: {}, route: { name: 'xxx' } } }, + headers: { + Accept: 'application/json, text/plain, */*', + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', + 'X-Forwarded-For': '192.168.1.10', + }, + })); + + const req = { + connection: { + remoteAddress: '192.168.1.10', + }, + headers: { + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', + }, + } as IncomingMessage; + + const setHeaderSpy: SetHeader = spy(); + + const res = { + setHeader: setHeaderSpy, + } as ServerResponse; + + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + + return service + .fetchComponentData(defaultTestInput, req, res) + .then((layoutServiceData: LayoutServiceData & AxiosRequestConfig) => { + expect(layoutServiceData.headers.cookie).to.equal('test-cookie-value'); + expect(layoutServiceData.headers.referer).to.equal('http://sctest'); + expect(layoutServiceData.headers['user-agent']).to.equal('test-user-agent-value'); + expect(layoutServiceData.headers['X-Forwarded-For']).to.equal('192.168.1.10'); + expect(layoutServiceData.data).to.deep.equal({ + sitecore: { + context: {}, + route: { name: 'xxx' }, + }, + }); + }); + }); + + it('should fetch component data when optional params provided', () => { + const testInput: ComponentLayoutRequestParams = { + ...defaultTestInput, + dataSourceId: '789', + }; + + const testUnexpectedData = { + sitecore: { + context: {}, + route: { + name: 'xxx', + placeholders: { + 'editing-componentmode-placeholder': [], + }, + }, + }, + }; + + const testExpectedData = { + sitecore: { + context: {}, + route: { + name: 'xxx', + placeholders: { + 'editing-componentmode-placeholder': [ + { + uid: '456', + componentName: 'RichText', + dataSource: '789', + params: { + GridParameters: 'col-12', + FieldNames: 'Default', + Styles: '', + RenderingIdentifier: '', + DynamicPlaceholderId: '3', + }, + }, + ], + }, + }, + }, + }; + + nock('http://sctest') + .get( + '/sitecore/api/layout/component/jss?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&dataSourceId=789&sc_site=supersite&sc_lang=en' + ) + .reply(200, (_, requestBody) => ({ + requestBody: requestBody, + data: testExpectedData, + headers: { + Accept: 'application/json, text/plain, */*', + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', + 'X-Forwarded-For': '192.168.1.10', + }, + })) + .get('/sitecore/api/layout/component/jss') + .query(true) + .reply(200, (_, requestBody) => ({ + requestBody: requestBody, + data: testUnexpectedData, + headers: { + Accept: 'application/json, text/plain, */*', + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', + 'X-Forwarded-For': '192.168.1.10', + }, + })); + + const req = { + connection: { + remoteAddress: '192.168.1.10', + }, + headers: { + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', + }, + } as IncomingMessage; + + const setHeaderSpy: SetHeader = spy(); + + const res = { + setHeader: setHeaderSpy, + } as ServerResponse; + + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + + return service + .fetchComponentData(testInput, req, res) + .then((layoutServiceData: LayoutServiceData & AxiosRequestConfig) => { + expect(layoutServiceData.headers.cookie).to.equal('test-cookie-value'); + expect(layoutServiceData.headers.referer).to.equal('http://sctest'); + expect(layoutServiceData.headers['user-agent']).to.equal('test-user-agent-value'); + expect(layoutServiceData.headers['X-Forwarded-For']).to.equal('192.168.1.10'); + expect(layoutServiceData.data).to.deep.equal(testExpectedData); + }); + }); + + it('should fetch component data with custom site name', () => { + const testInput: ComponentLayoutRequestParams = { + ...defaultTestInput, + siteName: 'mysite', + }; + + const testUnexpectedData = { + sitecore: { + context: {}, + route: { + name: 'xxx', + placeholders: { + 'editing-componentmode-placeholder': [], + }, + }, + }, + }; + + const testExpectedData = { + sitecore: { + context: {}, + route: { + name: 'xxx', + placeholders: { + 'editing-componentmode-placeholder': [ + { + uid: '456', + componentName: 'RichText', + dataSource: '789', + params: { + GridParameters: 'col-12', + FieldNames: 'Default', + Styles: '', + RenderingIdentifier: '', + DynamicPlaceholderId: '3', + }, + }, + ], + }, + }, + }, + }; + + nock('http://sctest') + .get( + '/sitecore/api/layout/component/jss?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&sc_site=mysite&sc_lang=en' + ) + .reply(200, (_, requestBody) => ({ + requestBody: requestBody, + data: testExpectedData, + headers: { + Accept: 'application/json, text/plain, */*', + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', + 'X-Forwarded-For': '192.168.1.10', + }, + })) + .get('/sitecore/api/layout/component/jss') + .query(true) + .reply(200, (_, requestBody) => ({ + requestBody: requestBody, + data: testUnexpectedData, + headers: { + Accept: 'application/json, text/plain, */*', + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', + 'X-Forwarded-For': '192.168.1.10', + }, + })); + + const req = { + connection: { + remoteAddress: '192.168.1.10', + }, + headers: { + cookie: 'test-cookie-value', + referer: 'http://sctest', + 'user-agent': 'test-user-agent-value', + }, + } as IncomingMessage; + + const setHeaderSpy: SetHeader = spy(); + + const res = { + setHeader: setHeaderSpy, + } as ServerResponse; + + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + + return service + .fetchComponentData(testInput, req, res) + .then((layoutServiceData: LayoutServiceData & AxiosRequestConfig) => { + expect(layoutServiceData.headers.cookie).to.equal('test-cookie-value'); + expect(layoutServiceData.headers.referer).to.equal('http://sctest'); + expect(layoutServiceData.headers['user-agent']).to.equal('test-user-agent-value'); + expect(layoutServiceData.headers['X-Forwarded-For']).to.equal('192.168.1.10'); + expect(layoutServiceData.data).to.deep.equal(testExpectedData); + }); + }); + + it('should fetch layout data using custom configuration name', () => { + nock('http://sctest') + .get( + '/sitecore/api/layout/component/listen?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&sc_site=supersite&sc_lang=en' + ) + .reply(200, (_, requestBody) => ({ + requestBody: requestBody, + data: defaultTestData, + })); + + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + configurationName: 'listen', + }); + + return service + .fetchComponentData(defaultTestInput) + .then((layoutServiceData: LayoutServiceData & AxiosRequestConfig) => { + expect(layoutServiceData.data).to.deep.equal(defaultTestData); + }); + }); + + it('should fetch layout data using custom fetcher resolver', () => { + const fetcherSpy = spy((url: string) => { + return new AxiosDataFetcher().fetch(url); + }); + + nock('http://sctest') + .get( + '/sitecore/api/layout/component/jss?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&sc_site=supersite&sc_lang=en' + ) + .reply(200, () => ({ + data: defaultTestData, + })); + + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + dataFetcherResolver: () => fetcherSpy, + }); + + return service + .fetchComponentData(defaultTestInput) + .then((layoutServiceData: LayoutServiceData) => { + expect(layoutServiceData).to.deep.equal({ data: defaultTestData }); + + expect(fetcherSpy).to.be.called.once; + expect(fetcherSpy).to.be.called.with( + 'http://sctest/sitecore/api/layout/component/jss?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&sc_site=supersite&sc_lang=en' + ); + }); + }); + + it('should catch 404 when request layout data', () => { + nock('http://sctest') + .get( + '/sitecore/api/layout/component/jss?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&sc_site=supersite&sc_lang=en' + ) + .reply(404, () => ({ + data: { + sitecore: { context: { pageEditing: false, language: 'en' }, route: null }, + }, + })); + + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + + return service + .fetchComponentData(defaultTestInput) + .then((layoutServiceData: LayoutServiceData) => { + expect(layoutServiceData).to.deep.equal({ + data: { + sitecore: { + context: { + pageEditing: false, + language: 'en', + }, + route: null, + }, + }, + }); + }); + }); + + it('should allow non 404 errors through', () => { + nock('http://sctest') + .get( + '/sitecore/api/layout/component/jss?sc_apikey=0FBFF61E-267A-43E3-9252-B77E71CEE4BA&item=123&uid=456&sc_site=supersite&sc_lang=en' + ) + .reply(401, { message: 'whoops' }); + + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + + return service.fetchComponentData(defaultTestInput).catch((error) => { + expect(error.response.status).to.equal(401); + expect(error.response.data.message).to.equal('whoops'); + }); + }); + + describe('getComponentFetchParams', () => { + it('should return params', () => { + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + const testParams = { + itemId: '123', + componentUid: '456', + dataSourceId: '789', + renderingId: '000', + version: '1', + siteName: 'notsupersite', + language: 'en', + editMode: EditMode.Metadata, + variant: 'default', + }; + + const expectedResult = { + sc_apikey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + item: testParams.itemId, + uid: testParams.componentUid, + dataSourceId: testParams.dataSourceId, + renderingItemId: testParams.renderingId, + version: testParams.version, + sc_site: testParams.siteName, + sc_lang: testParams.language, + sc_mode: testParams.editMode, + sc_variant: testParams.variant, + }; + + // eslint-disable-next-line dot-notation + expect(service['getComponentFetchParams'](testParams)).to.deep.equal(expectedResult); + }); + + it('should return params with no undefined params', () => { + const service = new RestComponentLayoutService({ + apiHost: 'http://sctest', + apiKey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + siteName: 'supersite', + }); + const testParams = { + itemId: '123', + componentUid: '456', + dataSourceId: undefined, + renderingId: '000', + version: undefined, + siteName: undefined, + language: 'en', + editMode: EditMode.Metadata, + variant: 'default', + }; + + const expectedResult = { + sc_apikey: '0FBFF61E-267A-43E3-9252-B77E71CEE4BA', + item: testParams.itemId, + uid: testParams.componentUid, + renderingItemId: testParams.renderingId, + sc_lang: testParams.language, + sc_mode: testParams.editMode, + sc_variant: testParams.variant, + }; + + // eslint-disable-next-line dot-notation + expect(service['getComponentFetchParams'](testParams)).to.deep.equal(expectedResult); + }); + }); +}); diff --git a/packages/sitecore-jss/src/editing/rest-component-layout-service.ts b/packages/sitecore-jss/src/editing/rest-component-layout-service.ts new file mode 100644 index 0000000000..3595541bd1 --- /dev/null +++ b/packages/sitecore-jss/src/editing/rest-component-layout-service.ts @@ -0,0 +1,103 @@ +import { RestLayoutServiceConfig, RestLayoutService } from '../layout/rest-layout-service'; +import { LayoutServiceData, EditMode } from '../layout/models'; +import { IncomingMessage, ServerResponse } from 'http'; +import { debug, fetchData } from '..'; + +/** + * Params for requesting component data from service in Component Library mode + */ +export interface ComponentLayoutRequestParams { + /** + * Item id to be used as context for rendering the component + */ + itemId: string; + /** + * Component identifier. Can be either taken from item's layout details or + * an arbitrary one (component renderingId and datasource would be used for identification then) + */ + componentUid: string; + /** + * language to render component in + */ + language?: string; + /** + * optional component datasource + */ + dataSourceId?: string; + /** + * ID of the component definition rendering item in Sitecore + */ + renderingId?: string; + /** + * version of the context item (latest by default) + */ + version?: string; + /** + * edit mode (edit, preview) to be rendered component in. Component is rendered in normal mode by default + */ + editMode?: EditMode; + /** + * site name to be used as context for rendering the component + */ + siteName?: string; + /** + * variant to be rendered for component if set (works with rendering existing component) + */ + variant?: string; +} + +/** + * REST service that enables Component Library functioality + * Makes a request to /sitecore/api/layout/component in 'library' mode in Pages. + * Returns layoutData for one single rendered component + */ +export class RestComponentLayoutService extends RestLayoutService { + constructor(private config: RestLayoutServiceConfig) { + super(config); + } + + fetchComponentData( + params: ComponentLayoutRequestParams, + req?: IncomingMessage, + res?: ServerResponse + ): Promise { + params.siteName = params.siteName || this.config.siteName; + const querystringParams = this.getComponentFetchParams(params); + debug.layout( + 'fetching component with uid %s for %s %s %s', + params.componentUid, + params.itemId, + params.language, + params.siteName + ); + const fetcher = this.getFetcher(req, res); + + const fetchUrl = this.resolveLayoutServiceUrl('component'); + + return fetchData(fetchUrl, fetcher, querystringParams).catch((error) => { + if (error.response?.status === 404) { + return error.response.data; + } + + throw error; + }); + } + + protected getComponentFetchParams(params: ComponentLayoutRequestParams) { + // exclude undefined params with this one simple trick + return JSON.parse( + JSON.stringify({ + sc_apikey: this.config.apiKey, + item: params.itemId, + uid: params.componentUid, + dataSourceId: params.dataSourceId, + renderingItemId: params.renderingId, + version: params.version, + sc_site: params.siteName, + sc_lang: params.language || 'en', + sc_mode: params.editMode, + sc_variant: params.variant, + }) + ); + } +} diff --git a/packages/sitecore-jss/src/editing/utils.test.ts b/packages/sitecore-jss/src/editing/utils.test.ts index 4e3b3a8e72..fe6a750489 100644 --- a/packages/sitecore-jss/src/editing/utils.test.ts +++ b/packages/sitecore-jss/src/editing/utils.test.ts @@ -1,11 +1,14 @@ /* eslint-disable no-unused-expressions */ import { expect, spy } from 'chai'; +import sinon from 'sinon'; import { isEditorActive, resetEditorChromes, ChromeRediscoveryGlobalFunctionName, PAGES_EDITING_MARKER, + updateComponentHandler, } from './utils'; +import testComponent from '../test-data/component-editing-data'; // must make TypeScript happy with `global` variable modification interface CustomWindow { @@ -116,3 +119,133 @@ describe('utils', () => { }); }); }); + +describe('component library utils', () => { + const debugSpy = sinon.spy(console, 'debug'); + describe('updateComponentHandler', () => { + it('should abort when origin is empty', () => { + const message = new MessageEvent('message'); + updateComponentHandler(message, testComponent); + expect(debugSpy.called).to.be.false; + }); + + xit('should abort when origin is not allowed', () => { + // TODO implement when security hardening in place + expect(true).to.be.true; + }); + + it('should abort when message is not component:update', () => { + const message = new MessageEvent('message', { + origin: 'http://localhost', + data: { name: 'component:degrade' }, + }); + updateComponentHandler(message, testComponent); + expect(debugSpy.called).to.be.false; + }); + + it('should abort when uid is empty', () => { + const message = new MessageEvent('message', { + origin: 'http://localhost', + data: { name: 'component:update' }, + }); + updateComponentHandler(message, testComponent); + expect(debugSpy.callCount).to.be.equal(1); + expect( + debugSpy.calledWith( + 'Received component:update event without uid, aborting event handler...' + ) + ).to.be.true; + }); + + it('should append params and fields for component', () => { + const changedComponent = JSON.parse(JSON.stringify(testComponent)); + const message = new MessageEvent('message', { + origin: 'http://localhost', + data: { + name: 'component:update', + details: { + uid: 'test-content', + fields: { + extra: 'I am extra', + }, + params: { + newparam: 12, + }, + }, + }, + }); + const expectedFields = { ...changedComponent.fields, extra: 'I am extra' }; + const expectedParams = { ...changedComponent.params, newparam: 12 }; + updateComponentHandler(message, changedComponent); + expect(changedComponent.fields).to.deep.equal(expectedFields); + expect(changedComponent.params).to.deep.equal(expectedParams); + }); + + it('should replace params and fields for component', () => { + const changedComponent = JSON.parse(JSON.stringify(testComponent)); + const message = new MessageEvent('message', { + origin: 'http://localhost', + data: { + name: 'component:update', + details: { + uid: 'test-content', + fields: { + content: { + value: 'new content', + }, + }, + params: { + nine: 'ten', + }, + }, + }, + }); + const expectedFields = { + ...changedComponent.fields, + content: { + value: 'new content', + }, + }; + const expectedParams = { nine: 'ten' }; + updateComponentHandler(message, changedComponent); + expect(changedComponent.fields).to.deep.equal(expectedFields); + expect(changedComponent.params).to.deep.equal(expectedParams); + }); + + it('should debug log when component not found', () => { + const message = new MessageEvent('message', { + origin: 'http://localhost', + data: { + name: 'component:update', + details: { + uid: 'no-content', + }, + }, + }); + updateComponentHandler(message, testComponent); + expect(debugSpy.callCount).to.be.equal(1); + const callArgs = debugSpy.getCall(0).args; + expect(callArgs).to.deep.equal(['Rendering with uid %s not found', 'no-content']); + }); + + it('should call callback when component found and updated', () => { + const changedComponent = JSON.parse(JSON.stringify(testComponent)); + const callbackStub = sinon.stub(); + const message = new MessageEvent('message', { + origin: 'http://localhost', + data: { + name: 'component:update', + details: { + uid: 'test-content', + }, + }, + }); + updateComponentHandler(message, changedComponent, callbackStub); + expect(callbackStub.called).to.be.true; + }); + }); + + afterEach(() => { + debugSpy.resetHistory(); + }); +}); diff --git a/packages/sitecore-jss/src/editing/utils.ts b/packages/sitecore-jss/src/editing/utils.ts index d970fa0cab..c351ad25a7 100644 --- a/packages/sitecore-jss/src/editing/utils.ts +++ b/packages/sitecore-jss/src/editing/utils.ts @@ -1,3 +1,4 @@ +import { ComponentRendering, Field, GenericFieldValue } from '../layout/models'; import isServer from '../utils/is-server'; /** @@ -10,6 +11,11 @@ export const DEFAULT_PLACEHOLDER_UID = '00000000-0000-0000-0000-000000000000'; */ export const QUERY_PARAM_EDITING_SECRET = 'secret'; +/** + * Event contents to be sent when component library page is ready and rendered + */ +export const COMPONENT_LIBRARY_READY_MESSAGE = { name: 'component:status', message: 'ready' }; + /** * ID to be used as a marker for a script rendered in XMC Pages * Should identify app is in XM Cloud Pages editing mode @@ -33,6 +39,17 @@ type ExtendedWindow = Window & }; }; +/** + * Event args for Component Library `update` event + */ +export interface ComponentUpdateEventArgs { + name: string; + details?: { + uid: string; + params?: Record; + fields?: Record>; + }; +} /** * Application metadata */ @@ -169,3 +186,88 @@ export const getJssPagesClientData = () => { return clientData; }; + +/** + * Adds the browser-side event handler for 'component:update' message used in Component Library + * The event should update a component on page by uid, with fields and params from event args + * @param {ComponentRendering} rootComponent root component displayed for Component Library page + * @param {Function} successCallback callback to be called after successful component update + */ +export const addComponentUpdateHandler = ( + rootComponent: ComponentRendering, + successCallback?: (updatedRootComponent: ComponentRendering) => void +) => { + if (!window) return; + const handler = (e: MessageEvent) => updateComponentHandler(e, rootComponent, successCallback); + window.addEventListener('message', handler); + // the power to remove handler outside of this function, if needed + const unsubscribe = () => { + window.removeEventListener('message', handler); + }; + return unsubscribe; +}; + +const validateOrigin = (event: MessageEvent) => { + // TODO: use `EDITING_ALLOWED_ORIGINS.concat(getAllowedOriginsFromEnv())` later + // nextjs's JSS_ALLOWED_ORIGINS is not available on the client, need to use NEXT_PUBLIC_ variable, but it's a breaking change for Deploy + const allowedOrigins = ['*']; + return allowedOrigins.some( + (origin) => + origin === event.origin || + new RegExp('^' + origin.replace('.', '\\.').replace(/\*/g, '.*') + '$').test(event.origin) + ); +}; + +export const updateComponentHandler = ( + e: MessageEvent, + rootComponent: ComponentRendering, + successCallback?: (updatedRootComponent: ComponentRendering) => void +) => { + const eventArgs: ComponentUpdateEventArgs = e.data; + if (!e.origin || !eventArgs || eventArgs.name !== 'component:update') { + // avoid extra noise in logs + if (!validateOrigin(e)) { + console.debug( + 'Component Library: event skipped: message %s from origin %s', + eventArgs.name, + e.origin + ); + } + return; + } + if (!eventArgs.details?.uid) { + console.debug('Received component:update event without uid, aborting event handler...'); + return; + } + + const findComponent = (root: ComponentRendering): ComponentRendering | null => { + if (root.uid?.toLowerCase() === eventArgs.details?.uid.toLowerCase()) return root; + if (root.placeholders) { + for (const plhName of Object.keys(root.placeholders)) { + for (const rendering of root.placeholders![plhName]) { + const result = findComponent(rendering as ComponentRendering); + if (result) return result; + } + } + } + return null; + }; + + const updateComponent = findComponent(rootComponent); + + if (updateComponent) { + console.debug( + 'Found rendering with uid %s to update. Updating with fields %o and params %o', + eventArgs.details.uid, + eventArgs.details.fields, + eventArgs.details.params + ); + updateComponent.fields = { ...updateComponent.fields, ...eventArgs.details.fields }; + updateComponent.params = { ...updateComponent.params, ...eventArgs.details.params }; + if (successCallback) successCallback(rootComponent); + } else { + console.debug('Rendering with uid %s not found', eventArgs.details.uid); + } + // strictly for testing + return rootComponent; +}; diff --git a/packages/sitecore-jss/src/layout/index.ts b/packages/sitecore-jss/src/layout/index.ts index c37bed05b2..da0c4a2fbf 100644 --- a/packages/sitecore-jss/src/layout/index.ts +++ b/packages/sitecore-jss/src/layout/index.ts @@ -16,6 +16,9 @@ export { ComponentParams, EditMode, FieldMetadata, + RenderingType, + EDITING_COMPONENT_PLACEHOLDER, + EDITING_COMPONENT_ID, } from './models'; export { diff --git a/packages/sitecore-jss/src/layout/models.ts b/packages/sitecore-jss/src/layout/models.ts index 0895c90cc5..b4b48614f7 100644 --- a/packages/sitecore-jss/src/layout/models.ts +++ b/packages/sitecore-jss/src/layout/models.ts @@ -9,6 +9,7 @@ export interface LayoutServiceData { /** * Layout Service page state enum + * library mode would render a single component */ export enum LayoutServicePageState { Preview = 'preview', @@ -39,6 +40,7 @@ export interface LayoutServiceContext { site?: { name?: string; }; + renderingType?: RenderingType; editMode?: EditMode; clientScripts?: string[]; clientData?: Record>; @@ -159,3 +161,19 @@ export interface PlaceholderData { path: string; elements: Array; } + +/** + * Editing rendering type + */ +export enum RenderingType { + Component = 'component', +} + +/** + * Static placeholder name used for component rendering + */ +export const EDITING_COMPONENT_PLACEHOLDER = 'editing-componentmode-placeholder'; +/** + * Id of wrapper for component rendering + */ +export const EDITING_COMPONENT_ID = 'editing-component'; diff --git a/packages/sitecore-jss/src/layout/rest-layout-service.ts b/packages/sitecore-jss/src/layout/rest-layout-service.ts index 8103fc9ee8..617d9f53da 100644 --- a/packages/sitecore-jss/src/layout/rest-layout-service.ts +++ b/packages/sitecore-jss/src/layout/rest-layout-service.ts @@ -88,9 +88,7 @@ export class RestLayoutService extends LayoutServiceBase { language, this.serviceConfig.siteName ); - const fetcher = this.serviceConfig.dataFetcherResolver - ? this.serviceConfig.dataFetcherResolver(req, res) - : this.getDefaultFetcher(req, res); + const fetcher = this.getFetcher(req, res); const fetchUrl = this.resolveLayoutServiceUrl('render'); @@ -172,12 +170,18 @@ export class RestLayoutService extends LayoutServiceBase { }; }; + protected getFetcher = (req?: IncomingMessage, res?: ServerResponse) => { + return this.serviceConfig.dataFetcherResolver + ? this.serviceConfig.dataFetcherResolver(req, res) + : this.getDefaultFetcher(req, res); + }; + /** * Resolves layout service url * @param {string} apiType which layout service API to call ('render' or 'placeholder') * @returns the layout service url */ - protected resolveLayoutServiceUrl(apiType: 'render' | 'placeholder'): string { + protected resolveLayoutServiceUrl(apiType: 'render' | 'placeholder' | 'component'): string { const { apiHost = '', configurationName = 'jss' } = this.serviceConfig; return `${apiHost}/sitecore/api/layout/${apiType}/${configurationName}`; diff --git a/packages/sitecore-jss/src/test-data/component-editing-data.ts b/packages/sitecore-jss/src/test-data/component-editing-data.ts new file mode 100644 index 0000000000..6250477e66 --- /dev/null +++ b/packages/sitecore-jss/src/test-data/component-editing-data.ts @@ -0,0 +1,32 @@ +// default setup for placeholder-less component +const contentBlock = { + uid: 'test-content', + componentName: 'ContentBlock', + dataSource: '{FC218D50-FC56-5B2B-99BA-38D570A83386}', + params: { + nine: 'nine', + }, + fields: { + content: { + value: '

This is a live set of examples of how to use JSS

\r\n', + }, + heading: { + value: 'JSS Styleguide', + }, + }, + placeholders: { + inner: [ + { + uid: 'test-inner', + componentName: 'InnerBlock', + fields: { + text: { + value: 'Its an inner component', + }, + }, + }, + ], + }, +}; + +export default contentBlock;