Skip to content

Commit

Permalink
Extract component library code into seprate file
Browse files Browse the repository at this point in the history
  • Loading branch information
Stanislav Maxymov committed Jan 28, 2025
1 parent 7f15fd7 commit 9578d6b
Show file tree
Hide file tree
Showing 5 changed files with 322 additions and 303 deletions.
170 changes: 170 additions & 0 deletions packages/sitecore-jss/src/editing/component-library.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
145 changes: 145 additions & 0 deletions packages/sitecore-jss/src/editing/component-library.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
fields?: Record<string, Field<GenericFieldValue>>;
};
}

/**
* 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,
},
};
}
10 changes: 6 additions & 4 deletions packages/sitecore-jss/src/editing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Loading

0 comments on commit 9578d6b

Please sign in to comment.