diff --git a/CHANGELOG.md b/CHANGELOG.md index 609c19d569..ec2b14f2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,10 @@ Our versioning strategy is as follows: ## Unreleased ### 🎉 New Features & Improvements -* `[sitecore-jss-react]` `[sitecore-jss-nextjs]` Introduce FieldMetadata component and functionality to render it when metadata field property is provided in the field's layout data. In such case the field component is wrapped with metadata markup to enable chromes hydration when editing in pages. Ability to render metadata has been added to the field rendering components for react and nextjs. ([#1773](https://github.com/Sitecore/jss/pull/1773)) +* Editing Integration Support: + * `[sitecore-jss-react]` `[sitecore-jss]` Introduces `PlaceholderMetadata` component which supports the hydration of chromes on Pages by rendering the components and placeholders with required metadata. ([#1776](https://github.com/Sitecore/jss/pull/1776)) + * Chromes are hydrated based on the basis of new `editMode` property derived from LayoutData, which is defined as an enum consisting of `metadata` and `chromes`. + * `[sitecore-jss-react]` `[sitecore-jss-nextjs]` Introduce FieldMetadata component and functionality to render it when metadata field property is provided in the field's layout data. In such case the field component is wrapped with metadata markup to enable chromes hydration when editing in pages. Ability to render metadata has been added to the field rendering components for react and nextjs. ([#1773](https://github.com/Sitecore/jss/pull/1773)) ### 🛠 Breaking Changes diff --git a/packages/sitecore-jss-nextjs/src/index.ts b/packages/sitecore-jss-nextjs/src/index.ts index ebebcc5a15..8fea6e3cf7 100644 --- a/packages/sitecore-jss-nextjs/src/index.ts +++ b/packages/sitecore-jss-nextjs/src/index.ts @@ -38,6 +38,7 @@ export { EDITING_COMPONENT_PLACEHOLDER, EDITING_COMPONENT_ID, getContentStylesheetLink, + EditMode, } from '@sitecore-jss/sitecore-jss/layout'; export { mediaApi } from '@sitecore-jss/sitecore-jss/media'; export { diff --git a/packages/sitecore-jss-react/src/components/Placeholder.test.tsx b/packages/sitecore-jss-react/src/components/Placeholder.test.tsx index b4d6dd7467..69a4890dfa 100644 --- a/packages/sitecore-jss-react/src/components/Placeholder.test.tsx +++ b/packages/sitecore-jss-react/src/components/Placeholder.test.tsx @@ -2,7 +2,7 @@ /* eslint-disable no-unused-expressions */ /* eslint-disable react/prop-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { ComponentRendering, RouteData } from '@sitecore-jss/sitecore-jss/layout'; +import { ComponentRendering, EditMode, RouteData } from '@sitecore-jss/sitecore-jss/layout'; import { expect } from 'chai'; import { mount, shallow } from 'enzyme'; import PropTypes from 'prop-types'; @@ -31,6 +31,7 @@ import { Placeholder } from './Placeholder'; import { ComponentProps } from './PlaceholderCommon'; import { SitecoreContext } from './SitecoreContext'; import { ComponentFactory } from './sharedTypes'; +import { PlaceholderMetadata } from './PlaceholderMetadata'; const componentFactory: ComponentFactory = (componentName: string) => { const components = new Map(); @@ -101,7 +102,9 @@ describe('', () => { const phKey = 'page-content'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.download-callout-mock').length).to.equal(1); @@ -216,6 +219,8 @@ describe('', () => { ); + console.log(renderedComponent.debug()); + expect(renderedComponent.html()).to.equal( '
My name is empty placeholder
' ); @@ -300,7 +305,9 @@ describe('', () => { const phKey = 'main'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.rendering-variant').length).to.equal(1); @@ -318,7 +325,9 @@ describe('', () => { const phKey = 'container-1'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.rendering-variant').length).to.equal(1); @@ -334,7 +343,9 @@ describe('', () => { const phKey = 'richText'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.rendering-variant').length).to.equal(0); @@ -346,7 +357,9 @@ describe('', () => { const phKey = 'dynamic-1-{*}'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.rendering-variant').length).to.equal(1); @@ -362,7 +375,9 @@ describe('', () => { const phKey = 'main-second'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.rendering-variant').length).to.equal(1); @@ -377,7 +392,9 @@ describe('', () => { const phKey = 'column-1-{*}'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.rendering-variant').length).to.equal(1); @@ -410,7 +427,9 @@ describe('', () => { )); const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.byoc-component').length).to.equal(2); @@ -443,7 +462,9 @@ describe('', () => { )); const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.feaas-component').length).to.equal(2); @@ -459,7 +480,9 @@ describe('', () => { const phKey = 'main'; const renderedComponent = mount( - + + + ); const eeChrome = renderedComponent.find({ chrometype: 'placeholder', kind: 'open', id: phKey }); @@ -473,11 +496,9 @@ describe('', () => { const phKey = 'main'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.sc-jss-empty-placeholder').length).to.equal(1); }); @@ -495,7 +516,9 @@ describe('', () => { const phKey = 'unknown'; const renderedComponent = mount( - + + + ); expect(renderedComponent.html()).to.be.empty; }); @@ -529,7 +552,9 @@ describe('', () => { const phKey = 'main'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.sc-jss-placeholder-error').length).to.equal(1); }); @@ -540,7 +565,9 @@ describe('', () => { const Home: React.FC<{ rendering?: RouteData }> = ({ rendering }) => (
- + + +
); @@ -565,12 +592,9 @@ describe('', () => { const phKey = 'main'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.custom-error').length).to.equal(1); }); @@ -595,12 +619,13 @@ it('should render MissingComponent for unknown rendering', () => { ); const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.missing-component').length).to.equal(1); }); @@ -633,7 +658,9 @@ it('should render nothing for rendering without a name', () => { const renderedComponent = mount(
- + + +
); expect(renderedComponent.children().length).to.equal(1); @@ -652,7 +679,9 @@ it('should render HiddenRendering when rendering is hidden', () => { const phKey = 'main'; const renderedComponent = mount( - + + + ); expect(renderedComponent.find(HiddenRendering).length).to.equal(1); }); @@ -677,18 +706,287 @@ it('should render custom HiddenRendering when rendering is hidden', () => { ); const renderedComponent = mount( - + + + ); expect(renderedComponent.find('.hidden-rendering').length).to.equal(1); expect(renderedComponent.find(HiddenRendering).length).to.equal(1); expect(renderedComponent.find('p').props().children).to.equal('Hidden Rendering'); }); +describe('PlaceholderMetadata', () => { + const layoutDataForNestedPlaceholder = { + sitecore: { + context: { + pageEditing: true, + editMode: EditMode.Metadata, + }, + route: { + name: 'main', + placeholders: { + main: [ + { + uid: 'root123', + componentName: 'Layout', + placeholders: { + header: [ + { + uid: 'nested123', + componentName: 'Header', + placeholders: { + logo: [ + { + uid: 'deep123', + componentName: 'Logo', + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + }, + }; + + const componentFactory: ComponentFactory = (componentName: string) => { + const components = new Map(); + + components.set('RichText', () =>
); + components.set('Header', () => ( +
+ +
+ )); + components.set('Logo', () =>
); + + return components.get(componentName) || null; + }; + + it('Placeholder component renders component when editMode is metadata', () => { + const componentFactory: ComponentFactory = (componentName: string) => { + const components = new Map(); + + components.set('RichText', () =>
); + components.set('Layout', () =>
); + + return components.get(componentName) || null; + }; + + const mockLayoutData = { + sitecore: { + context: { + pageEditing: true, + editMode: EditMode.Metadata, + }, + route: { + name: 'main', + placeholders: { + main: [ + { + uid: '123', + componentName: 'Layout', + placeholders: { + header: [ + { + uid: '456', + componentName: 'RichText', + }, + ], + }, + }, + ], + }, + }, + }, + }; + + const component = mockLayoutData.sitecore.route.placeholders.main[0]; + + const renderedComponent = mount( + + + + ); + + expect(renderedComponent.find(PlaceholderMetadata).length).to.equal(2); + }); + + it('should render component with placeholders', () => { + const layoutData = { + sitecore: { + context: { + pageEditing: true, + editMode: EditMode.Metadata, + }, + route: { + name: 'main', + placeholders: { + main: [ + { + uid: '123', + componentName: 'Layout', + placeholders: { + header: [ + { + uid: '456', + componentName: 'RichText', + }, + ], + }, + }, + ], + }, + }, + }, + }; + + const wrapper = shallow( + + + + ); + + expect(wrapper.html()).to.equal( + [ + '', + '', + '
', + '', + '', + ].join('') + ); + }); + + it('should render nested placeholder components', () => { + const wrapper = shallow( + + + + ); + + expect(wrapper.html()).to.equal( + [ + '', + '', + '
', + '', + '', + '
', + '', + '', + '
', + '', + '', + ].join('') + ); + }); + + it('should render code blocks if placeholder is empty', () => { + const layoutData = { + sitecore: { + context: { + pageEditing: true, + editMode: EditMode.Metadata, + }, + route: { + name: 'main', + placeholders: { + main: [ + { + uid: '123', + componentName: 'Layout', + placeholders: { + header: [], + }, + }, + ], + }, + }, + }, + }; + + const wrapper = shallow( + + + + ); + + expect(wrapper.html()).to.equal( + [ + '
', + '', + '', + '
', + ].join('') + ); + }); + + it('should render missing component with chromes if component is not registered', () => { + const layoutData = { + sitecore: { + context: { + pageEditing: true, + editMode: EditMode.Metadata, + }, + route: { + name: 'main', + placeholders: { + main: [ + { + uid: '123', + componentName: 'Layout', + placeholders: { + header: [ + { + uid: '456', + componentName: 'Unknown', + }, + ], + }, + }, + ], + }, + }, + }, + }; + + const wrapper = shallow( + + + + ); + + expect(wrapper.html()).to.equal( + [ + '', + '', + '

Unknown

JSS component is missing React implementation. See the developer console for more information.

', + '', + '', + ].join('') + ); + }); +}); after(() => { (global as any).window.close(); }); diff --git a/packages/sitecore-jss-react/src/components/Placeholder.tsx b/packages/sitecore-jss-react/src/components/Placeholder.tsx index 6addea7b88..9dd6fb44f0 100644 --- a/packages/sitecore-jss-react/src/components/Placeholder.tsx +++ b/packages/sitecore-jss-react/src/components/Placeholder.tsx @@ -3,6 +3,7 @@ import { PlaceholderCommon, PlaceholderProps } from './PlaceholderCommon'; import { withComponentFactory } from '../enhancers/withComponentFactory'; import { ComponentRendering, HtmlElementRendering } from '@sitecore-jss/sitecore-jss/layout'; import { HorizonEditor } from '@sitecore-jss/sitecore-jss/utils'; +import { withSitecoreContext } from '../enhancers/withSitecoreContext'; export interface PlaceholderComponentProps extends PlaceholderProps { /** @@ -120,4 +121,8 @@ class PlaceholderComponent extends PlaceholderCommon } } -export const Placeholder = withComponentFactory(PlaceholderComponent); +const PlaceholderWithComponentFactory = withComponentFactory(PlaceholderComponent); + +export const Placeholder = withSitecoreContext()( + PlaceholderWithComponentFactory +); diff --git a/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx b/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx index 6c2292801c..93c256b0f9 100644 --- a/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx +++ b/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx @@ -8,6 +8,7 @@ import { Field, Item, HtmlElementRendering, + EditMode, } from '@sitecore-jss/sitecore-jss/layout'; import { convertAttributesToReactProps } from '../utils'; import { HiddenRendering, HIDDEN_RENDERING_NAME } from './HiddenRendering'; @@ -15,6 +16,8 @@ import { FEaaSComponent, FEAAS_COMPONENT_RENDERING_NAME } from './FEaaSComponent import { FEaaSWrapper, FEAAS_WRAPPER_RENDERING_NAME } from './FEaaSWrapper'; import { BYOCComponent, BYOC_COMPONENT_RENDERING_NAME } from './BYOCComponent'; import { BYOCWrapper, BYOC_WRAPPER_RENDERING_NAME } from './BYOCWrapper'; +import { SitecoreContextValue } from './SitecoreContext'; +import { PlaceholderMetadata } from './PlaceholderMetadata'; type ErrorComponentProps = { [prop: string]: unknown; @@ -74,6 +77,10 @@ export interface PlaceholderProps { * the placeholder */ errorComponent?: React.ComponentClass | React.FC; + /** + * Context data from the Sitecore Layout Service + */ + sitecoreContext?: SitecoreContextValue; } export class PlaceholderCommon extends React.Component { @@ -102,6 +109,7 @@ export class PlaceholderCommon extends React.Compone PropTypes.func as Requireable>, ]), modifyComponentProps: PropTypes.func, + sitecoreContext: PropTypes.object as Requireable, }; nodeRefs: Element[]; @@ -186,7 +194,7 @@ export class PlaceholderCommon extends React.Compone ...placeholderProps } = this.props; - return placeholderData + const components = placeholderData .map((rendering: ComponentRendering | HtmlElementRendering, index: number) => { const key = (rendering as ComponentRendering).uid ? (rendering as ComponentRendering).uid @@ -251,12 +259,37 @@ export class PlaceholderCommon extends React.Compone rendering: componentRendering, }; - return React.createElement<{ [attr: string]: unknown }>( + const rendered = React.createElement<{ [attr: string]: unknown }>( component as React.ComponentType, this.props.modifyComponentProps ? this.props.modifyComponentProps(finalProps) : finalProps ); + + // if editMode is equal to 'metadata' then emit shallow chromes for hydration in Pages + if (this.props.sitecoreContext?.editMode === EditMode.Metadata) { + return ( + + {rendered} + + ); + } + + return rendered; }) .filter((element) => element); // remove nulls + + if (this.props.sitecoreContext?.editMode === EditMode.Metadata) { + return [ + + {components} + , + ]; + } + + return components; } getComponentForRendering(renderingDefinition: ComponentRendering): ComponentType | null { diff --git a/packages/sitecore-jss-react/src/components/PlaceholderMetadata.test.tsx b/packages/sitecore-jss-react/src/components/PlaceholderMetadata.test.tsx new file mode 100644 index 0000000000..5280baff42 --- /dev/null +++ b/packages/sitecore-jss-react/src/components/PlaceholderMetadata.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { expect } from 'chai'; +import { shallow } from 'enzyme'; +import { PlaceholderMetadata } from './PlaceholderMetadata'; + +describe('PlaceholderMetadata', () => { + it('renders rendering code blocks for metadataType rendering', () => { + const children =
; + + const wrapper = shallow({children}); + + expect(wrapper.html()).to.equal( + [ + '', + '
', + '', + ].join('') + ); + }); + + it('renders placeholder code blocks when metadataType is placeholder', () => { + const children =
; + const wrapper = shallow( + + {children} + + ); + + expect(wrapper.html()).to.equal( + [ + '', + '
', + '', + ].join('') + ); + }); +}); diff --git a/packages/sitecore-jss-react/src/components/PlaceholderMetadata.tsx b/packages/sitecore-jss-react/src/components/PlaceholderMetadata.tsx new file mode 100644 index 0000000000..bb060ba8f6 --- /dev/null +++ b/packages/sitecore-jss-react/src/components/PlaceholderMetadata.tsx @@ -0,0 +1,66 @@ +import React, { ReactNode } from 'react'; + +/** + * Props containing the component data to render. + */ +export interface PlaceholderMetadataProps { + uid: string; + placeholderName?: string; + children?: ReactNode; +} + +export type CodeBlockAttributes = { + type: string; + chrometype: string; + className: string; + kind: string; + id?: string; +}; + +/** + * A React component to generate metadata blocks for a placeholder or rendering. + * It utilizes dynamic attributes based on whether the component acts as a placeholder + * or as a rendering to properly render the surrounding code blocks. + * + * @param {object} props The properties passed to the component. + * @param {string} props.uid A unique identifier for the component instance. + * @param {string} [props.placeholderName] The name of the placeholder. + * @param {JSX.Element} props.children The child components or elements to be wrapped by the metadata code blocks. + * @returns {JSX.Element} A React fragment containing open and close code blocks surrounding the children elements. + */ +export const PlaceholderMetadata = ({ + uid, + placeholderName, + children, +}: PlaceholderMetadataProps): JSX.Element => { + const getCodeBlockAttributes = ( + kind: string, + id: string, + placeholderName?: string + ): CodeBlockAttributes => { + const chrometype = placeholderName ? 'placeholder' : 'rendering'; + + const attributes: CodeBlockAttributes = { + type: 'text/sitecore', + chrometype: chrometype, + className: 'scpm', + kind: kind, + }; + + if (kind === 'open') { + attributes.id = + chrometype === 'placeholder' && placeholderName ? `${placeholderName}_${id}` : id; + } + return attributes; + }; + + const renderComponent = (uid: string, placeholderName?: string) => ( + <> + + {children} + + + ); + + return <>{renderComponent(uid, placeholderName)}; +}; diff --git a/packages/sitecore-jss-react/src/enhancers/withSitecoreContext.tsx b/packages/sitecore-jss-react/src/enhancers/withSitecoreContext.tsx index fcb0cd1546..a01a7b0089 100644 --- a/packages/sitecore-jss-react/src/enhancers/withSitecoreContext.tsx +++ b/packages/sitecore-jss-react/src/enhancers/withSitecoreContext.tsx @@ -6,7 +6,7 @@ export interface WithSitecoreContextOptions { } export interface WithSitecoreContextProps { - sitecoreContext: SitecoreContextValue; + sitecoreContext?: SitecoreContextValue; updateSitecoreContext?: ((value: SitecoreContextValue) => void) | false; } @@ -14,10 +14,8 @@ export interface ComponentConsumerProps extends WithSitecoreContextProps { children?: ReactNode; } -export type WithSitecoreContextHocProps = Pick< - ComponentProps, - Exclude ->; +// WithSitecoreContextHocProps has been deprecated, use your component's type instead +export type WithSitecoreContextHocProps = ComponentProps; /** * @param {WithSitecoreContextOptions} [options] diff --git a/packages/sitecore-jss-react/src/index.ts b/packages/sitecore-jss-react/src/index.ts index 4e07998a8b..e510ae2951 100644 --- a/packages/sitecore-jss-react/src/index.ts +++ b/packages/sitecore-jss-react/src/index.ts @@ -28,6 +28,7 @@ export { ComponentRendering, ComponentFields, ComponentParams, + EditMode, } from '@sitecore-jss/sitecore-jss/layout'; export { trackingApi, @@ -92,6 +93,7 @@ export { ComponentConsumerProps, WithSitecoreContextOptions, WithSitecoreContextProps, + /** @deprecated use your component's props type instead */ WithSitecoreContextHocProps, } from './enhancers/withSitecoreContext'; export { withEditorChromes } from './enhancers/withEditorChromes'; diff --git a/packages/sitecore-jss/src/layout/index.ts b/packages/sitecore-jss/src/layout/index.ts index 8262ececf2..d88905dc3f 100644 --- a/packages/sitecore-jss/src/layout/index.ts +++ b/packages/sitecore-jss/src/layout/index.ts @@ -16,6 +16,7 @@ export { RenderingType, EDITING_COMPONENT_PLACEHOLDER, EDITING_COMPONENT_ID, + EditMode, } from './models'; export { getFieldValue, getChildPlaceholder } from './utils'; diff --git a/packages/sitecore-jss/src/layout/models.ts b/packages/sitecore-jss/src/layout/models.ts index 87657e0613..91a5204ede 100644 --- a/packages/sitecore-jss/src/layout/models.ts +++ b/packages/sitecore-jss/src/layout/models.ts @@ -33,6 +33,14 @@ export enum RenderingType { Component = 'component', } +/** + * Represents the possible modes for rendering content in Pages + */ +export enum EditMode { + Chromes = 'chromes', + Metadata = 'metadata', +} + /** * Shape of context data from the Sitecore Layout Service */ @@ -46,6 +54,7 @@ export interface LayoutServiceContext { site?: { name?: string; }; + editMode?: EditMode; } /**