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(
+ [
+ '',
+ ].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 (
+ <>
+
+
+
+
+ >
+ );
+};
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;