Skip to content

Commit 130f133

Browse files
[Next.js][Editing] Partial rendering implementation #1169
[Next.js][Editing] Partial rendering implementation
2 parents 2086948 + 96246b4 commit 130f133

File tree

11 files changed

+251
-3
lines changed

11 files changed

+251
-3
lines changed

packages/create-sitecore-jss/src/templates/nextjs/src/pages/[[...path]].tsx

+13-1
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import { GetServerSideProps } from 'next';
77
import NotFound from 'src/NotFound';
88
import Layout from 'src/Layout';
99
import {
10+
RenderingType,
1011
SitecoreContext,
1112
ComponentPropsContext,
1213
handleEditorFastRefresh,
14+
EditingComponentPlaceholder,
1315
<% if (prerender === 'SSG') { -%>
1416
StaticPath,
1517
<% } -%>
@@ -35,14 +37,24 @@ const SitecorePage = ({ notFound, componentProps, layoutData }: SitecorePageProp
3537
}
3638

3739
const isEditing = layoutData.sitecore.context.pageEditing;
40+
const isComponentRendering =
41+
layoutData.sitecore.context.renderingType === RenderingType.Component;
3842

3943
return (
4044
<ComponentPropsContext value={componentProps}>
4145
<SitecoreContext
4246
componentFactory={isEditing ? editingComponentFactory : componentFactory}
4347
layoutData={layoutData}
4448
>
45-
<Layout layoutData={layoutData} />
49+
{/*
50+
Sitecore Pages supports component rendering to avoid refreshing the entire page during component editing.
51+
If you are using Experience Editor only, this logic can be removed, Layout can be left.
52+
*/}
53+
{isComponentRendering ? (
54+
<EditingComponentPlaceholder rendering={layoutData.sitecore.route} />
55+
) : (
56+
<Layout layoutData={layoutData} />
57+
)}
4658
</SitecoreContext>
4759
</ComponentPropsContext>
4860
);

packages/sitecore-jss-nextjs/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"@sitecore-jss/sitecore-jss": "^21.0.0-canary.185",
7474
"@sitecore-jss/sitecore-jss-dev-tools": "^21.0.0-canary.185",
7575
"@sitecore-jss/sitecore-jss-react": "^21.0.0-canary.185",
76+
"node-html-parser": "^6.0.0",
7677
"prop-types": "^15.7.2",
7778
"regex-parser": "^2.2.11",
7879
"sync-disk-cache": "^2.1.0"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React from 'react';
2+
import {
3+
EDITING_COMPONENT_ID,
4+
EDITING_COMPONENT_PLACEHOLDER,
5+
RouteData,
6+
} from '@sitecore-jss/sitecore-jss/layout';
7+
import { EditingComponentPlaceholder } from './EditingComponentPlaceholder';
8+
import * as PlaceholderModule from './Placeholder';
9+
import { expect } from 'chai';
10+
import { mount } from 'enzyme';
11+
import sinon from 'sinon';
12+
13+
describe('<EditingComponentPlaceholder />', () => {
14+
it('should render component', () => {
15+
sinon.stub(PlaceholderModule, 'Placeholder').returns(<div className="test"></div>);
16+
const rendering: RouteData = {
17+
name: 'ComponentRendering',
18+
placeholders: {
19+
[EDITING_COMPONENT_PLACEHOLDER]: [
20+
{
21+
componentName: 'Home',
22+
},
23+
],
24+
},
25+
};
26+
27+
const c = mount(<EditingComponentPlaceholder rendering={rendering} />);
28+
29+
const component = c.find(`#${EDITING_COMPONENT_ID}`);
30+
31+
expect(component.length).to.equal(1);
32+
33+
expect(component.find(PlaceholderModule.Placeholder).length).to.equal(1);
34+
expect(component.find('.test').length).to.equal(1);
35+
});
36+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from 'react';
2+
import {
3+
EDITING_COMPONENT_ID,
4+
EDITING_COMPONENT_PLACEHOLDER,
5+
RouteData,
6+
} from '@sitecore-jss/sitecore-jss/layout';
7+
import { Placeholder } from './Placeholder';
8+
9+
export const EditingComponentPlaceholder = ({
10+
rendering,
11+
}: {
12+
rendering: RouteData;
13+
}): JSX.Element => (
14+
<div id={EDITING_COMPONENT_ID}>
15+
<Placeholder name={EDITING_COMPONENT_PLACEHOLDER} rendering={rendering} />
16+
</div>
17+
);

packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.test.ts

+88-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@ import {
88
EditingPreviewData,
99
QUERY_PARAM_EDITING_SECRET,
1010
} from './editing-data-service';
11-
import { EE_PATH, EE_LANGUAGE, EE_LAYOUT, EE_DICTIONARY, EE_BODY } from '../test-data/ee-data';
11+
import {
12+
EE_PATH,
13+
EE_LANGUAGE,
14+
EE_LAYOUT,
15+
EE_DICTIONARY,
16+
EE_BODY,
17+
EE_COMPONENT_BODY,
18+
} from '../test-data/ee-data';
1219
import { EditingRenderMiddleware, extractEditingData } from './editing-render-middleware';
1320
import { spy, match } from 'sinon';
1421
import sinonChai from 'sinon-chai';
@@ -123,6 +130,86 @@ describe('EditingRenderMiddleware', () => {
123130
});
124131
});
125132

133+
it('should handle component rendering request', async () => {
134+
const html =
135+
'<html phkey="test1"><body phkey="test2"><div id="editing-component"><h1>Hello world</h1><p>Something amazing</p></div></body></html>';
136+
const query = {} as Query;
137+
query[QUERY_PARAM_EDITING_SECRET] = secret;
138+
const previewData = { key: 'key1234' } as EditingPreviewData;
139+
140+
const fetcher = mockFetcher(html);
141+
const dataService = mockDataService(previewData);
142+
const req = mockRequest(EE_COMPONENT_BODY, query);
143+
const res = mockResponse();
144+
145+
const middleware = new EditingRenderMiddleware({
146+
dataFetcher: fetcher,
147+
editingDataService: dataService,
148+
});
149+
const handler = middleware.getHandler();
150+
151+
await handler(req, res);
152+
153+
expect(dataService.setEditingData, 'stash editing data').to.have.been.called;
154+
expect(res.setPreviewData, 'set preview mode w/ data').to.have.been.calledWith(previewData);
155+
expect(res.getHeader, 'get preview cookies').to.have.been.calledWith('Set-Cookie');
156+
expect(fetcher.get).to.have.been.calledOnce;
157+
expect(fetcher.get, 'pass along preview cookies').to.have.been.calledWith(
158+
match('http://localhost:3000/test/path?timestamp'),
159+
{
160+
headers: {
161+
Cookie: mockNextJsPreviewCookies.join(';'),
162+
},
163+
}
164+
);
165+
expect(res.status).to.have.been.calledOnce;
166+
expect(res.status).to.have.been.calledWith(200);
167+
expect(res.json).to.have.been.calledOnce;
168+
expect(res.json).to.have.been.calledWith({
169+
html: '<h1>Hello world</h1><p>Something amazing</p>',
170+
});
171+
});
172+
173+
it('should throw error when component rendering markup is missing', async () => {
174+
const html = '<html phkey="test1"><body phkey="test2"><div></div></body></html>';
175+
const query = {} as Query;
176+
query[QUERY_PARAM_EDITING_SECRET] = secret;
177+
const previewData = { key: 'key1234' } as EditingPreviewData;
178+
179+
const fetcher = mockFetcher(html);
180+
const dataService = mockDataService(previewData);
181+
const req = mockRequest(EE_COMPONENT_BODY, query);
182+
const res = mockResponse();
183+
184+
const middleware = new EditingRenderMiddleware({
185+
dataFetcher: fetcher,
186+
editingDataService: dataService,
187+
});
188+
const handler = middleware.getHandler();
189+
190+
await handler(req, res);
191+
192+
expect(dataService.setEditingData, 'stash editing data').to.have.been.called;
193+
expect(res.setPreviewData, 'set preview mode w/ data').to.have.been.calledWith(previewData);
194+
expect(res.getHeader, 'get preview cookies').to.have.been.calledWith('Set-Cookie');
195+
expect(fetcher.get).to.have.been.calledOnce;
196+
expect(fetcher.get, 'pass along preview cookies').to.have.been.calledWith(
197+
match('http://localhost:3000/test/path?timestamp'),
198+
{
199+
headers: {
200+
Cookie: mockNextJsPreviewCookies.join(';'),
201+
},
202+
}
203+
);
204+
expect(res.status).to.have.been.calledOnce;
205+
expect(res.status).to.have.been.calledWith(500);
206+
expect(res.json).to.have.been.calledOnce;
207+
expect(res.json).to.have.been.calledWith({
208+
html:
209+
'<html><body>Error: Failed to render component for http://localhost:3000/test/path</body></html>',
210+
});
211+
});
212+
126213
it('should handle 404 for route data request', async () => {
127214
const html = '<html phkey="test1"><body phkey="test2">Page not found</body></html>';
128215
const query = {} as Query;

packages/sitecore-jss-nextjs/src/editing/editing-render-middleware.ts

+9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { NextApiRequest, NextApiResponse } from 'next';
22
import { STATIC_PROPS_ID, SERVER_PROPS_ID } from 'next/constants';
33
import { AxiosDataFetcher, debug } from '@sitecore-jss/sitecore-jss';
4+
import { EDITING_COMPONENT_ID, RenderingType } from '@sitecore-jss/sitecore-jss/layout';
5+
import { parse } from 'node-html-parser';
46
import { EditingData } from './editing-data';
57
import {
68
EditingDataService,
@@ -160,6 +162,13 @@ export class EditingRenderMiddleware {
160162
// The following line will trick it into thinking we're SSR, thus avoiding any router.replace.
161163
html = html.replace(STATIC_PROPS_ID, SERVER_PROPS_ID);
162164

165+
if (editingData.layoutData.sitecore.context.renderingType === RenderingType.Component) {
166+
// Handle component rendering. Extract component markup only
167+
html = parse(html).getElementById(EDITING_COMPONENT_ID)?.innerHTML;
168+
169+
if (!html) throw new Error(`Failed to render component for ${requestUrl}`);
170+
}
171+
163172
const body = { html };
164173

165174
// Return expected JSON result

packages/sitecore-jss-nextjs/src/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ export {
3131
ComponentRendering,
3232
ComponentFields,
3333
ComponentParams,
34+
RenderingType,
35+
EDITING_COMPONENT_PLACEHOLDER,
36+
EDITING_COMPONENT_ID,
3437
} from '@sitecore-jss/sitecore-jss/layout';
3538
export { mediaApi } from '@sitecore-jss/sitecore-jss/media';
3639
export {
@@ -103,6 +106,7 @@ export { handleEditorFastRefresh, getPublicUrl } from './utils';
103106
export { Link, LinkProps } from './components/Link';
104107
export { RichText, RichTextProps } from './components/RichText';
105108
export { Placeholder } from './components/Placeholder';
109+
export { EditingComponentPlaceholder } from './components/EditingComponentPlaceholder';
106110
export { NextImage } from './components/NextImage';
107111

108112
export {

packages/sitecore-jss-nextjs/src/test-data/ee-data.ts

+12
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const EE_PATH = '/test/path';
1919
export const EE_LANGUAGE = 'en';
2020
export const EE_LAYOUT = `{"sitecore":{"context":{"pageEditing":true,"site":{"name":"JssNext"},"pageState":"normal","language":"en","itemPath":"${EE_PATH}"},"route":{"name":"home","displayName":"home","fields":{"pageTitle":{"value":"Welcome to Sitecore JSS"}},"databaseName":"master","deviceId":"fe5d7fdf-89c0-4d99-9aa3-b5fbd009c9f3","itemId":"d6ac9d26-9474-51cf-982d-4f8d44951229","itemLanguage":"en","itemVersion":1,"layoutId":"4092f843-b14e-5f7a-9ae6-3ed9f5c2b919","templateId":"ca5a5aeb-55ae-501b-bb10-d37d009a97e1","templateName":"App Route","placeholders":{"jss-main":[{"uid":"2c4a53cc-9da8-5f51-9d79-6ee2fc671b2d","componentName":"ContentBlock","dataSource":"{FF0E7D28-D8EF-539C-9CEC-28E1175F8C1D}","params":{},"fields":{"heading":{"value":"Welcome to Sitecore JSS"},"content":{"value":"<p>Thanks for using JSS!! Here are some resources to get you started:</p>"}}}]}}}}`;
2121
export const EE_DICTIONARY = '{"entry1":"Entry One","entry2":"Entry Two"}';
22+
export const EE_COMPONENT_LAYOUT = `{"sitecore":{"context":{"pageEditing":true,"renderingType":"component","site":{"name":"JssNext"},"pageState":"normal","language":"en","itemPath":"${EE_PATH}"},"route":{"name":"home","displayName":"home","fields":{"pageTitle":{"value":"Welcome to Sitecore JSS"}},"databaseName":"master","deviceId":"fe5d7fdf-89c0-4d99-9aa3-b5fbd009c9f3","itemId":"d6ac9d26-9474-51cf-982d-4f8d44951229","itemLanguage":"en","itemVersion":1,"layoutId":"4092f843-b14e-5f7a-9ae6-3ed9f5c2b919","templateId":"ca5a5aeb-55ae-501b-bb10-d37d009a97e1","templateName":"App Route","placeholders":{"editing-componentmode-placeholder":[{"uid":"2c4a53cc-9da8-5f51-9d79-6ee2fc671b2d","componentName":"ContentBlock","dataSource":"{FF0E7D28-D8EF-539C-9CEC-28E1175F8C1D}","params":{},"fields":{"heading":{"value":"Welcome to Sitecore JSS"},"content":{"value":"<p>Thanks for using JSS!! Here are some resources to get you started:</p>"}}}]}}}}`;
2223

2324
export const EE_BODY = {
2425
id: 'JssApp',
@@ -27,5 +28,16 @@ export const EE_BODY = {
2728
moduleName: 'server.bundle',
2829
};
2930

31+
export const EE_COMPONENT_BODY = {
32+
id: 'JssApp',
33+
args: [
34+
'/',
35+
EE_COMPONENT_LAYOUT,
36+
`{\"language\":\"${EE_LANGUAGE}\",\"dictionary\":${EE_DICTIONARY}}`,
37+
],
38+
functionName: 'renderView',
39+
moduleName: 'server.bundle',
40+
};
41+
3042
export const imageField =
3143
'<input id=\'fld_F5201E35767444EBB903E52488A0EB5A_B7F425624A1F4F3F925C4A4381197239_en_1_0f581df6173e468f9c0b36bd730739e4_13\' class=\'scFieldValue\' name=\'fld_F5201E35767444EBB903E52488A0EB5A_B7F425624A1F4F3F925C4A4381197239_en_1_0f581df6173e468f9c0b36bd730739e4_13\' type=\'hidden\' value="&lt;image mediaid=&quot;{B013777F-C6CA-4880-9562-B9B7688AF63A}&quot; /&gt;" /><code id="fld_F5201E35767444EBB903E52488A0EB5A_B7F425624A1F4F3F925C4A4381197239_en_1_0f581df6173e468f9c0b36bd730739e4_13_edit" type="text/sitecore" chromeType="field" scFieldType="image" class="scpm" kind="open">{"commands":[{"click":"chrome:field:editcontrol({command:\\"webedit: chooseimage\\"})","header":"Choose Image","icon":"/sitecore/shell/themes/standard/custom/16x16/photo_landscape2.png","disabledIcon":"/temp/photo_landscape2_disabled16x16.png","isDivider":false,"tooltip":"Choose an image.","type":""},{"click":"chrome:field:editcontrol({command:\\"webedit: editimage\\"})","header":"Properties","icon":"/sitecore/shell/themes/standard/custom/16x16/photo_landscape2_edit.png","disabledIcon":"/temp/photo_landscape2_edit_disabled16x16.png","isDivider":false,"tooltip":"Modify image appearance.","type":""},{"click":"chrome:field:editcontrol({command:\\"webedit: clearimage\\"})","header":"Clear","icon":"/sitecore/shell/themes/standard/custom/16x16/photo_landscape2_delete.png","disabledIcon":"/temp/photo_landscape2_delete_disabled16x16.png","isDivider":false,"tooltip":"Remove the image.","type":""},{"click":"chrome:common:edititem({command:\\"webedit: open\\"})","header":"Edit the related item","icon":"/temp/iconcache/office/16x16/cubes.png","disabledIcon":"/temp/cubes_disabled16x16.png","isDivider":false,"tooltip":"Edit the related item in the Content Editor.","type":"common"},{"click":"chrome:rendering:personalize({command:\\"webedit: personalize\\"})","header":"Personalize","icon":"/temp/iconcache/office/16x16/users_family.png","disabledIcon":"/temp/users_family_disabled16x16.png","isDivider":false,"tooltip":"Create or edit personalization for this component.","type":"sticky"},{"click":"chrome:rendering:editvariations({command:\\"webedit: editvariations\\"})","header":"Edit variations","icon":"/temp/iconcache/office/16x16/windows.png","disabledIcon":"/temp/windows_disabled16x16.png","isDivider":false,"tooltip":"Edit the variations.","type":"sticky"}],"contextItemUri":"sitecore://master/{F5201E35-7674-44EB-B903-E52488A0EB5A}?lang=en&ver=1","custom":{},"displayName":"Image","expandedDisplayName":null}</code><img src="http://jssadvancedapp/sitecore/shell/-/media/JssAdvancedApp/assets/img/portfolio/1.ashx?h=350&amp;la=en&amp;w=650&amp;hash=B973470AA333773341C62A76511361C88897E2D4" alt="" width="650" height="350" /><code class="scpm" type="text/sitecore" chromeType="field" kind="close"></code>';

packages/sitecore-jss/src/layout/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export {
1313
PlaceholdersData,
1414
ComponentFields,
1515
ComponentParams,
16+
RenderingType,
17+
EDITING_COMPONENT_PLACEHOLDER,
18+
EDITING_COMPONENT_ID,
1619
} from './models';
1720

1821
export { getFieldValue, getChildPlaceholder } from './utils';

packages/sitecore-jss/src/layout/models.ts

+18
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
1+
/**
2+
* Static placeholder name used for component rendering
3+
*/
4+
export const EDITING_COMPONENT_PLACEHOLDER = 'editing-componentmode-placeholder';
5+
6+
/**
7+
* Id of wrapper for component rendering
8+
*/
9+
export const EDITING_COMPONENT_ID = 'editing-component';
10+
111
/**
212
* A reply from the Sitecore Layout Service
313
*/
@@ -16,11 +26,19 @@ export enum LayoutServicePageState {
1626
Normal = 'normal',
1727
}
1828

29+
/**
30+
* Editing rendering type
31+
*/
32+
export enum RenderingType {
33+
Component = 'component',
34+
}
35+
1936
/**
2037
* Shape of context data from the Sitecore Layout Service
2138
*/
2239
export interface LayoutServiceContext {
2340
[key: string]: unknown;
41+
renderingType?: RenderingType;
2442
pageEditing?: boolean;
2543
language?: string;
2644
pageState?: LayoutServicePageState;

0 commit comments

Comments
 (0)