-
Notifications
You must be signed in to change notification settings - Fork 278
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Extract component library code into seprate file
- Loading branch information
Stanislav Maxymov
committed
Jan 28, 2025
1 parent
7f15fd7
commit 9578d6b
Showing
5 changed files
with
322 additions
and
303 deletions.
There are no files selected for viewing
170 changes: 170 additions & 0 deletions
170
packages/sitecore-jss/src/editing/component-library.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
}, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.