From 9578d6b0e434aa06a3b54454cdf5556b28b928b8 Mon Sep 17 00:00:00 2001 From: Stanislav Maxymov Date: Tue, 28 Jan 2025 17:16:51 +0200 Subject: [PATCH] Extract component library code into seprate file --- .../src/editing/component-library.test.ts | 170 ++++++++++++++++++ .../src/editing/component-library.ts | 145 +++++++++++++++ packages/sitecore-jss/src/editing/index.ts | 10 +- .../sitecore-jss/src/editing/utils.test.ts | 166 ----------------- packages/sitecore-jss/src/editing/utils.ts | 134 +------------- 5 files changed, 322 insertions(+), 303 deletions(-) create mode 100644 packages/sitecore-jss/src/editing/component-library.test.ts create mode 100644 packages/sitecore-jss/src/editing/component-library.ts diff --git a/packages/sitecore-jss/src/editing/component-library.test.ts b/packages/sitecore-jss/src/editing/component-library.test.ts new file mode 100644 index 0000000000..7c2502cb8b --- /dev/null +++ b/packages/sitecore-jss/src/editing/component-library.test.ts @@ -0,0 +1,170 @@ +/* eslint-disable no-unused-expressions */ +import { expect, spy } from 'chai'; +import sinon from 'sinon'; +import { + updateComponentHandler, + getComponentLibraryStatusEvent, + ComponentLibraryStatus, +} from './component-library'; +import testComponent from '../test-data/component-editing-data'; + +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 not update fields or params when update fields and params are undefined', () => { + const changedComponent = JSON.parse(JSON.stringify(testComponent)); + changedComponent.fields = undefined; + changedComponent.params = undefined; + const message = new MessageEvent('message', { + origin: 'http://localhost', + data: { + name: 'component:update', + details: { + uid: 'test-content', + }, + }, + }); + updateComponentHandler(message, changedComponent); + expect(changedComponent.fields).to.be.undefined; + expect(changedComponent.params).to.be.undefined; + }); + + 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; + }); + }); + + describe('getComponentLibraryStatusEvent', () => { + it('should return a valid status event', () => { + const statusEvent = getComponentLibraryStatusEvent(ComponentLibraryStatus.READY, 'uid-1'); + expect(statusEvent).to.deep.equal({ + name: 'component:status', + message: { + status: ComponentLibraryStatus.READY, + uid: 'uid-1', + }, + }); + }); + }); + + afterEach(() => { + debugSpy.resetHistory(); + }); +}); diff --git a/packages/sitecore-jss/src/editing/component-library.ts b/packages/sitecore-jss/src/editing/component-library.ts new file mode 100644 index 0000000000..055136d281 --- /dev/null +++ b/packages/sitecore-jss/src/editing/component-library.ts @@ -0,0 +1,145 @@ +import { ComponentRendering, Field, GenericFieldValue } from '../layout/models'; + +/** + * Event to be sent when report status to component library + */ +export const COMPONENT_LIBRARY_STATUS_EVENT_NAME = 'component:status'; + +/** + * Represents an event indicating the status of a component in the library. + */ +export interface ComponentLibraryStatusEvent { + name: typeof COMPONENT_LIBRARY_STATUS_EVENT_NAME; + message: { + status: 'ready' | 'rendered'; + uid: string; + }; +} + +/** + * Enumeration of statuses for the component library. + */ +export enum ComponentLibraryStatus { + READY = 'ready', + RENDERED = 'rendered', +} + +/** + * Event args for Component Library `update` event + */ +export interface ComponentUpdateEventArgs { + name: string; + details?: { + uid: string; + params?: Record; + fields?: Record>; + }; +} + +/** + * 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 component with uid %s to update. Update fields: %o. Update params: %o.', + eventArgs.details.uid, + eventArgs.details.fields, + eventArgs.details.params + ); + if (eventArgs.details.fields) { + updateComponent.fields = { ...updateComponent.fields, ...eventArgs.details.fields }; + } + if (eventArgs.details.params) { + 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; +}; + +/** + * Generates a ComponentLibraryStatusEvent with the given status and uid. + * @param {ComponentLibraryStatus} status - The status of rendering. + * @param {string} uid - The unique identifier for the event. + * @returns An object representing the ComponentLibraryStatusEvent. + */ +export function getComponentLibraryStatusEvent( + status: ComponentLibraryStatus, + uid: string +): ComponentLibraryStatusEvent { + return { + name: COMPONENT_LIBRARY_STATUS_EVENT_NAME, + message: { + status, + uid, + }, + }; +} diff --git a/packages/sitecore-jss/src/editing/index.ts b/packages/sitecore-jss/src/editing/index.ts index 624f489009..ccd4186f52 100644 --- a/packages/sitecore-jss/src/editing/index.ts +++ b/packages/sitecore-jss/src/editing/index.ts @@ -8,14 +8,10 @@ export { handleEditorAnchors, Metadata, getJssPagesClientData, - addComponentUpdateHandler, EDITING_ALLOWED_ORIGINS, QUERY_PARAM_EDITING_SECRET, PAGES_EDITING_MARKER, - ComponentLibraryStatus, - ComponentLibraryStatusEvent, ComponentUpdateEventArgs, - getComponentLibraryStatusEvent, } from './utils'; export { RestComponentLayoutService, @@ -34,3 +30,9 @@ export { } from './edit-frame'; export { RenderMetadataQueryParams, RenderComponentQueryParams } from './models'; export { LayoutKind, MetadataKind } from './models'; +export { + addComponentUpdateHandler, + ComponentLibraryStatus, + ComponentLibraryStatusEvent, + getComponentLibraryStatusEvent, +} from './component-library'; diff --git a/packages/sitecore-jss/src/editing/utils.test.ts b/packages/sitecore-jss/src/editing/utils.test.ts index 1243e891a7..4e3b3a8e72 100644 --- a/packages/sitecore-jss/src/editing/utils.test.ts +++ b/packages/sitecore-jss/src/editing/utils.test.ts @@ -1,16 +1,11 @@ /* eslint-disable no-unused-expressions */ import { expect, spy } from 'chai'; -import sinon from 'sinon'; import { isEditorActive, resetEditorChromes, ChromeRediscoveryGlobalFunctionName, PAGES_EDITING_MARKER, - updateComponentHandler, - getComponentLibraryStatusEvent, - ComponentLibraryStatus, } from './utils'; -import testComponent from '../test-data/component-editing-data'; // must make TypeScript happy with `global` variable modification interface CustomWindow { @@ -121,164 +116,3 @@ 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 not update fields or params when update fields and params are undefined', () => { - const changedComponent = JSON.parse(JSON.stringify(testComponent)); - changedComponent.fields = undefined; - changedComponent.params = undefined; - const message = new MessageEvent('message', { - origin: 'http://localhost', - data: { - name: 'component:update', - details: { - uid: 'test-content', - }, - }, - }); - updateComponentHandler(message, changedComponent); - expect(changedComponent.fields).to.be.undefined; - expect(changedComponent.params).to.be.undefined; - }); - - 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; - }); - }); - - describe('getComponentLibraryStatusEvent', () => { - it('should return a valid status event', () => { - const statusEvent = getComponentLibraryStatusEvent(ComponentLibraryStatus.READY, 'uid-1'); - expect(statusEvent).to.deep.equal({ - name: 'component:status', - message: { - status: ComponentLibraryStatus.READY, - uid: 'uid-1', - }, - }); - }); - }); - - afterEach(() => { - debugSpy.resetHistory(); - }); -}); diff --git a/packages/sitecore-jss/src/editing/utils.ts b/packages/sitecore-jss/src/editing/utils.ts index 13aa51a10b..c5b37077d6 100644 --- a/packages/sitecore-jss/src/editing/utils.ts +++ b/packages/sitecore-jss/src/editing/utils.ts @@ -1,4 +1,4 @@ -import { ComponentRendering, Field, GenericFieldValue } from '../layout/models'; +import { Field, GenericFieldValue } from '../layout/models'; import isServer from '../utils/is-server'; /** @@ -11,30 +11,6 @@ export const DEFAULT_PLACEHOLDER_UID = '00000000-0000-0000-0000-000000000000'; */ export const QUERY_PARAM_EDITING_SECRET = 'secret'; -/** - * Event to be sent when report status to component library - */ -export const COMPONENT_LIBRARY_STATUS_EVENT_NAME = 'component:status'; - -/** - * Represents an event indicating the status of a component in the library. - */ -export interface ComponentLibraryStatusEvent { - name: typeof COMPONENT_LIBRARY_STATUS_EVENT_NAME; - message: { - status: 'ready' | 'rendered'; - uid: string; - }; -} - -/** - * Enumeration of statuses for the component library. - */ -export enum ComponentLibraryStatus { - READY = 'ready', - RENDERED = 'rendered', -} - /** * ID to be used as a marker for a script rendered in XMC Pages * Should identify app is in XM Cloud Pages editing mode @@ -205,111 +181,3 @@ 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 component with uid %s to update. Update fields: %o. Update params: %o.', - eventArgs.details.uid, - eventArgs.details.fields, - eventArgs.details.params - ); - if (eventArgs.details.fields) { - updateComponent.fields = { ...updateComponent.fields, ...eventArgs.details.fields }; - } - if (eventArgs.details.params) { - 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; -}; - -/** - * Generates a ComponentLibraryStatusEvent with the given status and uid. - * @param {ComponentLibraryStatus} status - The status of rendering. - * @param {string} uid - The unique identifier for the event. - * @returns An object representing the ComponentLibraryStatusEvent. - */ -export function getComponentLibraryStatusEvent( - status: ComponentLibraryStatus, - uid: string -): ComponentLibraryStatusEvent { - return { - name: COMPONENT_LIBRARY_STATUS_EVENT_NAME, - message: { - status, - uid, - }, - }; -}