Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[nextjs-xmcloud][sitecore-jss-nextjs] Component Library endpoint #1987

Merged
merged 28 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e2d40db
[nextjs-xmcloud][sitecore-jss-nextjs] Draft component library impleme…
art-alexeyenko Nov 28, 2024
b951054
right plugin naming
art-alexeyenko Nov 28, 2024
c8f97b3
correct plugin
art-alexeyenko Nov 28, 2024
c00f2c2
move component service to base package, add unit tests
art-alexeyenko Nov 29, 2024
f260800
refactor, draft nextjs unit tests
art-alexeyenko Nov 29, 2024
9f64afb
lint
art-alexeyenko Nov 29, 2024
6931391
fix imports in component service for type compliance
art-alexeyenko Nov 29, 2024
7c5739a
adjust test results
art-alexeyenko Nov 29, 2024
b2014d8
small adjustments
art-alexeyenko Nov 29, 2024
758a462
update event handler added
art-alexeyenko Dec 1, 2024
5695324
correct update logic on event
art-alexeyenko Dec 2, 2024
e9920ec
misc cleanups
art-alexeyenko Dec 2, 2024
6e30f47
Introduce component-library submodule
art-alexeyenko Dec 4, 2024
767333e
move ComponentLayout into jss-react
art-alexeyenko Dec 4, 2024
b4fbcff
small prettifier
art-alexeyenko Dec 4, 2024
307e39d
Merge branch 'dev' of https://github.com/Sitecore/jss into feature/js…
art-alexeyenko Dec 4, 2024
67a2c68
minor cleanups
art-alexeyenko Dec 4, 2024
120266d
bring eol back for render middleware
art-alexeyenko Dec 4, 2024
60d0032
remove dictionaryService mention
art-alexeyenko Dec 4, 2024
a2f22d1
changelog
art-alexeyenko Dec 4, 2024
e861449
use dictionary for componentlibrary
art-alexeyenko Dec 4, 2024
2a214a0
fix tests, address some PR comments
art-alexeyenko Dec 4, 2024
651693b
Implement a cleaner approach to component library route
art-alexeyenko Dec 6, 2024
5136259
prettier
art-alexeyenko Dec 6, 2024
b7a5b8c
final (?) small cleanup
art-alexeyenko Dec 6, 2024
94fae80
minor style cleanup in ComponentLibraryLayout
art-alexeyenko Dec 6, 2024
39c2475
Merge branch 'dev' into feature/jss-4556-render-component
art-alexeyenko Dec 9, 2024
d0c7d49
fix middleare test
art-alexeyenko Dec 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/
import React from 'react';
import Head from 'next/head';
import { Placeholder, LayoutServiceData, Field, HTMLLink } from '@sitecore-jss/sitecore-jss-nextjs';
import { Placeholder, LayoutServiceData, Field, HTMLLink, ComponentLibraryLayout } from '@sitecore-jss/sitecore-jss-nextjs';
import config from 'temp/config';
import Scripts from 'src/Scripts';

Expand Down Expand Up @@ -37,19 +37,29 @@ const Layout = ({ layoutData, headLinks }: LayoutProps): JSX.Element => {
<link rel={headLink.rel} key={headLink.href} href={headLink.href} />
))}
</Head>

<% if (xmcloud) {-%>
{/* root placeholder for the app, which we add components to using route data */}
<div className={mainClassPageEditing}>
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
<header>
<div id="header">{route && <Placeholder name="headless-header" rendering={route} />}</div>
</header>
<main>
<div id="content">{route && <Placeholder name="headless-main" rendering={route} />}</div>
</main>
<footer>
<div id="footer">{route && <Placeholder name="headless-footer" rendering={route} />}</div>
</footer>
</div>
{layoutData.sitecore.context.renderingType === 'component' ? (
<ComponentLibraryLayout layoutData={layoutData} config={config} />
) : <% } -%> (
<div className={mainClassPageEditing}>
<header>
<div id="header">
{route && <Placeholder name="headless-header" rendering={route} />}
</div>
</header>
<main>
<div id="content">
{route && <Placeholder name="headless-main" rendering={route} />}
</div>
</main>
<footer>
<div id="footer">
{route && <Placeholder name="headless-footer" rendering={route} />}
</div>
</footer>
</div>
)}
</>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { GetServerSidePropsContext, GetStaticPropsContext } from 'next';
import { RestComponentLibraryService } from '@sitecore-jss/sitecore-jss-nextjs';
import config from 'temp/config';
import { isComponentLibraryPreviewData } from '@sitecore-jss/sitecore-jss-nextjs/editing';
import { SitecorePageProps } from 'lib/page-props';
import { Plugin } from '..';

class ComponentLibraryModePlugin implements Plugin {
order = 1;

async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) {
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
if (!context.preview) return props;
if (isComponentLibraryPreviewData(context.previewData)) {
const { itemId, componentUid, site, language, renderingId, dataSourceId, version, variant } =
context.previewData;

const componentService = new RestComponentLibraryService({
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,
});

if (!componentData) {
throw new Error(
`Unable to fetch editing data for preview ${JSON.stringify(context.previewData)}`
);
}

props.locale = context.previewData.language;
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
props.layoutData = componentData;
props.headLinks = [];
// props.dictionary = componentData.dictionary;

return props;
}

return props;
}
}

export const componentLibraryModePlugin = new ComponentLibraryModePlugin();
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@sitecore-jss/sitecore-jss-nextjs';
import {
editingDataService,
isComponentLibraryPreviewData,
isEditingMetadataPreviewData,
} from '@sitecore-jss/sitecore-jss-nextjs/editing';
import { SitecorePageProps } from 'lib/page-props';
Expand All @@ -17,6 +18,7 @@ class PreviewModePlugin implements Plugin {

async exec(props: SitecorePageProps, context: GetServerSidePropsContext | GetStaticPropsContext) {
if (!context.preview) return props;
if (isComponentLibraryPreviewData(context.previewData)) return props;

// If we're in Pages preview (editing) Metadata Edit Mode, prefetch the editing data
if (isEditingMetadataPreviewData(context.previewData)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from 'react';
import { expect } from 'chai';
import { mount } from 'enzyme';
import { ComponentLibraryLayout } from './ComponentLibraryLayout';
import { spy, stub } from 'sinon';
import componentLayoutData from '../test-data/component-editing-data';

// must make TypeScript happy with `global` variable modification
interface CustomWindow {
[key: string]: unknown;
dispatchEvent: unknown;
}

interface Global {
window: CustomWindow | undefined;
}

declare const global: Global;

describe('<ComponentLibraryLayout />', () => {
const eventStub = stub();
const testLayoutData = componentLayoutData.layoutData;
global.window = {
dispatchEvent: eventStub,
};

it('should render', () => {
const rendered = mount(<ComponentLibraryLayout {...testLayoutData} />);

expect(rendered.html()).to.equal(
[
'<p>This is a live set of examples of how to use JSS. For more information on using JSS',
'please see <a href="https://jss.sitecore.com" target="_blank" rel="noopener noreferrer">the documentation</a>.',
'</p>\r\n<p>The content and layout of this page is defined in <code>/data/routes/styleguide/en.yml</code></p>\r\n',
].join('')
);
});

it('should fire component:status event', () => {
const effectSpy = spy(React, 'useEffect');
mount(<ComponentLibraryLayout {...testLayoutData} />);
expect(effectSpy.called).to.be.true;
expect(eventStub.called).to.be.true;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
'use client';

import React, { useEffect } from 'react';
import { Placeholder } from './Placeholder';
import { debug } from '@sitecore-jss/sitecore-jss';
import {
ComponentRendering,
EDITING_COMPONENT_ID,
EDITING_COMPONENT_PLACEHOLDER,
Field,
GenericFieldValue,
LayoutServiceData,
RestComponentLibraryService,
} from '@sitecore-jss/sitecore-jss/layout';

export interface ComponentUpdateEvent extends CustomEvent {
uid: string;
params: Record<string, string>;
fields: Record<string, Field<GenericFieldValue>>;
}

export interface ComponentLibraryLayoutProps {
layoutData: LayoutServiceData;
config: Record<string, string>;
}

export const ComponentLibraryLayout = ({
layoutData,
config,
}: ComponentLibraryLayoutProps): JSX.Element => {
// useEffect may execute multiple times - but we only want to subscribe and fire the events once
const { route, context } = layoutData.sitecore;
const component = route?.placeholders[EDITING_COMPONENT_PLACEHOLDER][0] as ComponentRendering;
let componentReady = false;
let updateListenerReady = false;
let ssrComponentDetails = {
siteName: context.site?.name,
itemId: route?.itemId || '',
language: context.language,
componentUid: component?.uid || '',
renderingId: component?.params ? component.params.RenderingIdentifier : '',
dataSourceId: component.dataSource,
version: route?.itemVersion ? route.itemVersion.toString() : 'latest',
};
const componentService = new RestComponentLibraryService({
apiHost: config.sitecoreApiHost,
apiKey: config.sitecoreApiKey,
siteName: config.site,
configurationName: config.layoutServiceConfigurationName,
});
useEffect(() => {
// useEffect will fire when components are ready - and we inform the wide world of it too
if (!componentReady) {
const readyEvent = new CustomEvent('component:status', { detail: 'ready' });
art-alexeyenko marked this conversation as resolved.
Show resolved Hide resolved
window.dispatchEvent(readyEvent);
componentReady = true;
}
if (!updateListenerReady) {
window.addEventListener('component:update', async (e) => {
const event = e as ComponentUpdateEvent;
const eventDetail = event.detail;
if (!eventDetail.uid) {
debug.editing('Received component:update event without uid, aborting event handler...');
return;
}
ssrComponentDetails = { ...ssrComponentDetails, ...eventDetail.params };
if (ssrComponentDetails.itemId && ssrComponentDetails.componentUid) {
layoutData = await componentService.fetchComponentData(ssrComponentDetails);
}
const fields = (route?.placeholders[EDITING_COMPONENT_PLACEHOLDER][0] as ComponentRendering)
.fields;
(route?.placeholders[EDITING_COMPONENT_PLACEHOLDER][0] as ComponentRendering).fields = {
...fields,
...eventDetail.fields,
};
});
updateListenerReady = true;
}
}, [layoutData]);

const mainClassPageEditing = 'component-mode';
return (
<>
<div className={mainClassPageEditing}>
<main>
<div id={EDITING_COMPONENT_ID}>
{route && <Placeholder name={EDITING_COMPONENT_PLACEHOLDER} rendering={route} />}
</div>
</main>
</div>
</>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -202,6 +203,69 @@ 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: 'edit',
mode: 'library',
dataSourceId: query.sc_datasourceId,
editMode: EditMode.Metadata,
variant: query.sc_variant,
version: query.sc_version,
});

expect(res.redirect).to.have.been.calledOnce;
expect(res.redirect).to.have.been.calledWith('/styleguide');
expect(res.setHeader).to.have.been.calledWith(
'Content-Security-Policy',
`frame-ancestors 'self' https://allowed.com ${EDITING_ALLOWED_ORIGINS.join(' ')}`
);
});
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:
'<html><body>Missing required query parameters: sc_itemid, sc_lang, route, mode</body></html>',
});
});
});

const query = {
mode: 'edit',
route: '/styleguide',
Expand Down
Loading
Loading