diff --git a/CHANGELOG.md b/CHANGELOG.md index d7f2271b04..e8e12783b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Our versioning strategy is as follows: * `[create-sitecore-jss]` Rework Angular initializer to support XMCloud and SXP journeys ([#1845](https://github.com/Sitecore/jss/pull/1845))([#1858](https://github.com/Sitecore/jss/pull/1858)) * `[create-sitecore-jss]` Allows proxy apps to be installed alongside main apps ([#1858](https://github.com/Sitecore/jss/pull/1858)) * `nodeAppDestination` arg can be passed into `create-sitecore-jss` command to define path for proxy to be installed in +* `[sitecore-jss-angular]` Angular placeholder now supports SXA components ([#1870](https://github.com/Sitecore/jss/pull/1870)) ### 🛠 Breaking Change diff --git a/packages/sitecore-jss-angular/src/components/placeholder.component.spec.ts b/packages/sitecore-jss-angular/src/components/placeholder.component.spec.ts index cb397d021e..e2b39d1e08 100644 --- a/packages/sitecore-jss-angular/src/components/placeholder.component.spec.ts +++ b/packages/sitecore-jss-angular/src/components/placeholder.component.spec.ts @@ -1,4 +1,13 @@ -import { Component, DebugElement, EventEmitter, Injectable, Input, Output } from '@angular/core'; +import { + Component, + DebugElement, + EventEmitter, + Injectable, + Input, + Output, + TemplateRef, + ViewChild, +} from '@angular/core'; import { Router } from '@angular/router'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { Location } from '@angular/common'; @@ -10,6 +19,9 @@ import { convertedData as eeData } from '../test-data/ee-data'; import { convertedDevData as nonEeDevData, convertedLayoutServiceData as nonEeLsData, + sxaRenderingData, + sxaRenderingDynamicPlaceholderData, + sxaRenderingDoubleDigitDynamicPlaceholderData, } from '../test-data/non-ee-data'; import { LazyComponent } from '../test-data/lazy-loading/lazy-component.component'; import { JssCanActivate, JssCanActivateFn, JssResolve } from '../services/placeholder.token'; @@ -675,3 +687,160 @@ describe(' with lazy loaded modules', () => { }); }); }); + +@Component({ + selector: 'test-rich-text', + template: ` + + Rich text + + + +
+
+
+ +
+ +
+ `, +}) +class TestRichTextComponent { + @Input() rendering: ComponentRendering; + @ViewChild('default', { static: true }) defaultVariant: TemplateRef; + @ViewChild('withTitle', { static: true }) withTitleVariant: TemplateRef; + public get variant(): TemplateRef { + return this.rendering.params?.FieldNames === 'WithTitle' + ? this.withTitleVariant + : this.defaultVariant; + } +} + +describe('SXA components', () => { + let fixture: ComponentFixture; + let de: DebugElement; + let comp: TestPlaceholderComponent; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [TestPlaceholderComponent, TestRichTextComponent], + imports: [ + RouterTestingModule, + JssModule.withComponents([{ name: 'RichText', type: TestRichTextComponent }]), + ], + providers: [], + }); + + fixture = TestBed.createComponent(TestPlaceholderComponent); + de = fixture.debugElement; + + comp = fixture.componentInstance; + fixture.detectChanges(); + }) + ); + + it( + 'should render', + waitForAsync(async () => { + const component = sxaRenderingData.sitecore.route; + const phKey = 'main'; + comp.name = phKey; + comp.rendering = (component as unknown) as ComponentRendering; + fixture.detectChanges(); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(de.children.length).toBe(1); + + const richText = de.query(By.directive(TestRichTextComponent)); + expect(richText).not.toBeNull(); + expect(richText.nativeElement.innerHTML).toContain('rendering-variant'); + + const container = de.query(By.css('.rendering-variant')); + expect(container).not.toBeNull(); + expect(container.attributes.class).toEqual( + 'col-9|col-sm-10|col-md-12|col-lg-6|col-xl-7|col-xxl-8 rendering-variant test-css-class-x' + ); + + const title = de.query(By.css('.title')); + expect(title).not.toBeNull(); + expect(title.nativeElement.innerHTML).toEqual('Rich Text Rendering Variant'); + + const text = de.query(By.css('.text')); + expect(text).not.toBeNull(); + expect(text.nativeElement.innerHTML).toEqual('Test RichText'); + }) + ); + + it( + 'should render another rendering variant', + waitForAsync(async () => { + const component = sxaRenderingData.sitecore.route; + const phKey = 'main-second'; + comp.name = phKey; + comp.rendering = (component as unknown) as ComponentRendering; + fixture.detectChanges(); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(de.children.length).toBe(1); + + const richText = de.query(By.directive(TestRichTextComponent)); + expect(richText).not.toBeNull(); + expect(richText.nativeElement.innerHTML).toContain('rendering-variant'); + + const container = de.query(By.css('.rendering-variant')); + expect(container).not.toBeNull(); + expect(container.attributes.class).toEqual( + 'col-9|col-sm-10|col-md-12|col-lg-6|col-xl-7|col-xxl-8 rendering-variant test-css-class-y' + ); + + const span = de.query(By.css('.default')); + expect(span).not.toBeNull(); + expect(span.nativeElement.innerHTML).toEqual('Rich text'); + }) + ); + + it( + 'should render with container-{*} type dynamic placeholder', + waitForAsync(async () => { + const component = sxaRenderingDynamicPlaceholderData.sitecore.route; + const phKey = 'container-1'; + comp.name = phKey; + comp.rendering = (component as unknown) as ComponentRendering; + fixture.detectChanges(); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(de.children.length).toBe(1); + + const richText = de.query(By.directive(TestRichTextComponent)); + expect(richText).not.toBeNull(); + expect(richText.nativeElement.innerHTML).toContain('rendering-variant'); + }) + ); + + it( + 'should render with dynamic-1-{*} type dynamic placeholder', + waitForAsync(async () => { + const component = sxaRenderingDoubleDigitDynamicPlaceholderData.sitecore.route; + const phKey = 'dynamic-1-{*}'; + comp.name = phKey; + comp.rendering = (component as unknown) as ComponentRendering; + fixture.detectChanges(); + + await fixture.whenStable(); + fixture.detectChanges(); + + expect(de.children.length).toBe(1); + + const richText = de.query(By.directive(TestRichTextComponent)); + expect(richText).not.toBeNull(); + expect(richText.nativeElement.innerHTML).toContain('rendering-variant'); + }) + ); +}); diff --git a/packages/sitecore-jss-angular/src/components/placeholder.component.ts b/packages/sitecore-jss-angular/src/components/placeholder.component.ts index b34c614164..41378f5ee9 100644 --- a/packages/sitecore-jss-angular/src/components/placeholder.component.ts +++ b/packages/sitecore-jss-angular/src/components/placeholder.component.ts @@ -23,7 +23,11 @@ import { ViewContainerRef, } from '@angular/core'; import { Data, Router, UrlTree } from '@angular/router'; -import { ComponentRendering, HtmlElementRendering } from '@sitecore-jss/sitecore-jss/layout'; +import { + ComponentRendering, + HtmlElementRendering, + EditMode, +} from '@sitecore-jss/sitecore-jss/layout'; import { Observable } from 'rxjs'; import { takeWhile } from 'rxjs/operators'; import { JssCanActivateRedirectError } from '../services/jss-can-activate-error'; @@ -40,6 +44,10 @@ import { PLACEHOLDER_MISSING_COMPONENT_COMPONENT, } from '../services/placeholder.token'; import { constants } from '@sitecore-jss/sitecore-jss'; +import { + isDynamicPlaceholder, + getDynamicPlaceholderPattern, +} from '@sitecore-jss/sitecore-jss/layout'; import { PlaceholderLoadingDirective } from './placeholder-loading.directive'; import { RenderEachDirective } from './render-each.directive'; import { RenderEmptyDirective } from './render-empty.directive'; @@ -48,10 +56,34 @@ import { isRawRendering } from './rendering'; /** * @param {ComponentRendering} rendering * @param {string} name + * @param {EditMode} [editMode] */ -function getPlaceholder(rendering: ComponentRendering, name: string) { +function getPlaceholder(rendering: ComponentRendering, name: string, editMode?: EditMode) { + let phName = name.slice(); + + /** + * Process (SXA) dynamic placeholders + * Find and replace the matching dynamic placeholder e.g 'nameOfContainer-{*}' with the requested e.g. 'nameOfContainer-1'. + * For Metadata EditMode, we need to keep the raw placeholder name in place. + */ + rendering?.placeholders && + Object.keys(rendering.placeholders).forEach((placeholder) => { + const patternPlaceholder = isDynamicPlaceholder(placeholder) + ? getDynamicPlaceholderPattern(placeholder) + : null; + + if (patternPlaceholder && patternPlaceholder.test(phName)) { + if (editMode === EditMode.Metadata) { + phName = placeholder; + } else { + rendering.placeholders![phName] = rendering.placeholders![placeholder]; + delete rendering.placeholders![placeholder]; + } + } + }); + if (rendering && rendering.placeholders && Object.keys(rendering.placeholders).length > 0) { - return rendering.placeholders[name]; + return rendering.placeholders[phName]; } return null; } @@ -95,6 +127,7 @@ export class PlaceholderComponent implements OnInit, OnChanges, DoCheck, OnDestr private _componentInstances: { [prop: string]: unknown }[] = []; private destroyed = false; private parentStyleAttribute = ''; + private editMode?: EditMode = undefined; constructor( private differs: KeyValueDiffers, @@ -208,7 +241,8 @@ export class PlaceholderComponent implements OnInit, OnChanges, DoCheck, OnDestr return; } - const placeholder = this.renderings || getPlaceholder(this.rendering, this.name || ''); + const placeholder = + this.renderings || getPlaceholder(this.rendering, this.name || '', this.editMode); if (!placeholder) { console.warn( diff --git a/packages/sitecore-jss-angular/src/services/jss-component-factory.service.ts b/packages/sitecore-jss-angular/src/services/jss-component-factory.service.ts index 943b9e2762..fbf89439fa 100644 --- a/packages/sitecore-jss-angular/src/services/jss-component-factory.service.ts +++ b/packages/sitecore-jss-angular/src/services/jss-component-factory.service.ts @@ -51,7 +51,7 @@ export class JssComponentFactoryService { if (loadedComponent) { return Promise.resolve({ - componentDefinition: component, + componentDefinition: this.applySXAParams(component), componentImplementation: loadedComponent.type, canActivate: loadedComponent.canActivate, resolve: loadedComponent.resolve, @@ -85,7 +85,7 @@ export class JssComponentFactoryService { } return { - componentDefinition: component, + componentDefinition: this.applySXAParams(component), componentImplementation: componentType, componentModuleRef: moduleRef, canActivate: lazyComponent.canActivate, @@ -116,4 +116,21 @@ export class JssComponentFactoryService { componentDefinition: component, }); } + + private applySXAParams(rendering: ComponentRendering) { + if (!rendering.params?.FieldNames) { + // Not SXA component + return rendering; + } + // Provide aggregated SXA styles on params 'styles' + const styles = []; + if (rendering.params.GridParameters) { + styles.push(rendering.params.GridParameters); + } + if (rendering.params.Styles) { + styles.push(rendering.params.Styles); + } + rendering.params.styles = styles.join(' '); + return rendering; + } } diff --git a/packages/sitecore-jss-angular/src/test-data/non-ee-data.ts b/packages/sitecore-jss-angular/src/test-data/non-ee-data.ts index 863289f818..4b337c693a 100644 --- a/packages/sitecore-jss-angular/src/test-data/non-ee-data.ts +++ b/packages/sitecore-jss-angular/src/test-data/non-ee-data.ts @@ -157,3 +157,140 @@ export const convertedLayoutServiceData = { }, }, }; + +export const sxaRenderingData = { + sitecore: { + context: { + pageEditing: false, + }, + route: { + name: 'Home', + displayName: 'Home', + fields: { + key: { + value: 'This is a some sample <p>field data</p> o'boy! "wow"', + }, + }, + placeholders: { + main: [ + { + uid: 'c4d5d43b-5aa8-4e03-8f16-9428f3e02d5c', + componentName: 'RichText', + dataSource: '/sitecore/content/SxaSample/SxaSampleSite/Home/Data/RichText', + params: { + GridParameters: 'col-9|col-sm-10|col-md-12|col-lg-6|col-xl-7|col-xxl-8', + FieldNames: 'WithTitle', + Styles: 'test-css-class-x', + }, + fields: { + Text: { + value: 'Test RichText', + }, + Title: { + value: 'Rich Text Rendering Variant', + }, + }, + }, + ], + 'main-second': [ + { + uid: 'c4d5d43b-5aa8-4e03-8f16-9428f3e02d5c', + componentName: 'RichText', + dataSource: '/sitecore/content/SxaSample/SxaSampleSite/Home/Data/RichText', + params: { + GridParameters: 'col-9|col-sm-10|col-md-12|col-lg-6|col-xl-7|col-xxl-8', + FieldNames: 'Default', + Styles: 'test-css-class-y', + }, + fields: { + Text: { + value: 'Test RichText', + }, + Title: { + value: 'Rich Text Rendering Variant', + }, + }, + }, + ], + }, + }, + }, +}; + +export const sxaRenderingDynamicPlaceholderData = { + sitecore: { + context: { + pageEditing: false, + }, + route: { + name: 'Home', + displayName: 'Home', + fields: { + key: { + value: 'This is a some sample <p>field data</p> o'boy! "wow"', + }, + }, + placeholders: { + 'container-{*}': [ + { + uid: 'c4d5d43b-5aa8-4e03-8f16-9428f3e02d5c', + componentName: 'RichText', + dataSource: '/sitecore/content/SxaSample/SxaSampleSite/Home/Data/RichText', + params: { + GridParameters: 'col-9|col-sm-10|col-md-12|col-lg-6|col-xl-7|col-xxl-8', + FieldNames: 'WithTitle', + Styles: 'test-css-class-x', + }, + fields: { + Text: { + value: 'Test RichText', + }, + Title: { + value: 'Rich Text Rendering Variant', + }, + }, + }, + ], + }, + }, + }, +}; + +export const sxaRenderingDoubleDigitDynamicPlaceholderData = { + sitecore: { + context: { + pageEditing: false, + }, + route: { + name: 'Home', + displayName: 'Home', + fields: { + key: { + value: 'This is a some sample <p>field data</p> o'boy! "wow"', + }, + }, + placeholders: { + 'dynamic-1-{*}': [ + { + uid: 'c4d5d43b-5aa8-4e03-8f16-9428f3e02d5c', + componentName: 'RichText', + dataSource: '/sitecore/content/SxaSample/SxaSampleSite/Home/Data/RichText', + params: { + GridParameters: 'col-9|col-sm-10|col-md-12|col-lg-6|col-xl-7|col-xxl-8', + FieldNames: 'WithTitle', + Styles: 'test-css-class-x', + }, + fields: { + Text: { + value: 'Test RichText', + }, + Title: { + value: 'Rich Text Rendering Variant', + }, + }, + }, + ], + }, + }, + }, +}; diff --git a/packages/sitecore-jss-react/src/components/Placeholder.test.tsx b/packages/sitecore-jss-react/src/components/Placeholder.test.tsx index 485e4fc384..87c43eb881 100644 --- a/packages/sitecore-jss-react/src/components/Placeholder.test.tsx +++ b/packages/sitecore-jss-react/src/components/Placeholder.test.tsx @@ -932,22 +932,6 @@ describe('PlaceholderMetadata', () => { }); }); -it('isDynamicPlaceholder', () => { - expect(isDynamicPlaceholder('container-{*}')).to.be.true; - expect(isDynamicPlaceholder('container-1-{*}')).to.be.true; - expect(isDynamicPlaceholder('container-1-2')).to.be.false; - expect(isDynamicPlaceholder('container-1')).to.be.false; - expect(isDynamicPlaceholder('container-1-2-3')).to.be.false; - expect(isDynamicPlaceholder('container-1-{*}-3')).to.be.true; -}); - -it('getDynamicPlaceholderPattern', () => { - expect(getDynamicPlaceholderPattern('container-{*}').test('container-1')).to.be.true; - expect(getDynamicPlaceholderPattern('container-{*}').test('container-1-2')).to.be.false; - expect(getDynamicPlaceholderPattern('container-1-{*}').test('container-1-2')).to.be.true; - expect(getDynamicPlaceholderPattern('container-1-{*}').test('container-1-2-3')).to.be.false; -}); - after(() => { (global as any).window.close(); }); diff --git a/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx b/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx index cff5643817..ad1cc228a2 100644 --- a/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx +++ b/packages/sitecore-jss-react/src/components/PlaceholderCommon.tsx @@ -9,6 +9,8 @@ import { Item, HtmlElementRendering, EditMode, + isDynamicPlaceholder, + getDynamicPlaceholderPattern, } from '@sitecore-jss/sitecore-jss/layout'; import { constants } from '@sitecore-jss/sitecore-jss'; import { convertAttributesToReactProps } from '../utils'; @@ -31,22 +33,6 @@ export type ComponentProps = { rendering: ComponentRendering; }; -/** - * Returns a regular expression pattern for a dynamic placeholder name. - * @param {string} placeholder Placeholder name with a dynamic segment (e.g. 'main-{*}') - * @returns Regular expression pattern for the dynamic segment - */ -export const getDynamicPlaceholderPattern = (placeholder: string) => { - return new RegExp(`^${placeholder.replace(/\{\*\}+/i, '\\d+')}$`); -}; - -/** - * Checks if the placeholder name is dynamic. - * @param {string} placeholder Placeholder name - * @returns True if the placeholder name is dynamic - */ -export const isDynamicPlaceholder = (placeholder: string) => placeholder.indexOf('{*}') !== -1; - export interface PlaceholderProps { [key: string]: unknown; /** Name of the placeholder to render. */ @@ -155,9 +141,9 @@ export class PlaceholderCommon extends React.Compone let phName = name.slice(); /** - * [Chromes Mode]: [SXA] it needs for deleting dynamics placeholder when we set him number(props.name) of container. - * from backend side we get common name of placeholder is called 'nameOfContainer-{*}' where '{*}' marker for replacing. - * [Metadata Mode]: We need to keep the raw placeholder name. e.g 'nameOfContainer-{*}' instead of 'nameOfContainer-1' + * Process (SXA) dynamic placeholders + * Find and replace the matching dynamic placeholder e.g 'nameOfContainer-{*}' with the requested e.g. 'nameOfContainer-1'. + * For Metadata EditMode, we need to keep the raw placeholder name in place. */ if (rendering?.placeholders) { Object.keys(rendering.placeholders).forEach((placeholder) => { diff --git a/packages/sitecore-jss-react/src/components/PlaceholderMetadata.tsx b/packages/sitecore-jss-react/src/components/PlaceholderMetadata.tsx index c5969c211c..4f7932fd6c 100644 --- a/packages/sitecore-jss-react/src/components/PlaceholderMetadata.tsx +++ b/packages/sitecore-jss-react/src/components/PlaceholderMetadata.tsx @@ -1,6 +1,9 @@ import React, { ReactNode } from 'react'; -import { ComponentRendering } from '@sitecore-jss/sitecore-jss/layout'; -import { getDynamicPlaceholderPattern, isDynamicPlaceholder } from './PlaceholderCommon'; +import { + ComponentRendering, + getDynamicPlaceholderPattern, + isDynamicPlaceholder, +} from '@sitecore-jss/sitecore-jss/layout'; /** * Props containing the component data to render. diff --git a/packages/sitecore-jss/src/layout/index.ts b/packages/sitecore-jss/src/layout/index.ts index b601e6ed18..9b4eb5cdad 100644 --- a/packages/sitecore-jss/src/layout/index.ts +++ b/packages/sitecore-jss/src/layout/index.ts @@ -22,6 +22,8 @@ export { getFieldValue, getChildPlaceholder, isFieldValueEmpty, + isDynamicPlaceholder, + getDynamicPlaceholderPattern, EMPTY_DATE_FIELD_VALUE, } from './utils'; diff --git a/packages/sitecore-jss/src/layout/utils.test.ts b/packages/sitecore-jss/src/layout/utils.test.ts index 4085625777..a6174cf027 100644 --- a/packages/sitecore-jss/src/layout/utils.test.ts +++ b/packages/sitecore-jss/src/layout/utils.test.ts @@ -5,6 +5,8 @@ import { getFieldValue, getChildPlaceholder, isFieldValueEmpty, + isDynamicPlaceholder, + getDynamicPlaceholderPattern, EMPTY_DATE_FIELD_VALUE, } from './utils'; @@ -249,4 +251,27 @@ describe('sitecore-jss layout utils', () => { }); }); }); + + describe('isDynamicPlaceholder', () => { + it('should return true if placeholder is dynamic', () => { + expect(isDynamicPlaceholder('container-{*}')).to.be.true; + expect(isDynamicPlaceholder('container-1-{*}')).to.be.true; + }); + + it('should return false if placeholder is not dynamic', () => { + expect(isDynamicPlaceholder('container-1-2')).to.be.false; + expect(isDynamicPlaceholder('container-1')).to.be.false; + expect(isDynamicPlaceholder('container-1-2-3')).to.be.false; + expect(isDynamicPlaceholder('container-1-{*}-3')).to.be.true; + }); + }); + + describe('getDynamicPlaceholderPattern', () => { + it('should return dynamic placeholder pattern', () => { + expect(getDynamicPlaceholderPattern('container-{*}').test('container-1')).to.be.true; + expect(getDynamicPlaceholderPattern('container-{*}').test('container-1-2')).to.be.false; + expect(getDynamicPlaceholderPattern('container-1-{*}').test('container-1-2')).to.be.true; + expect(getDynamicPlaceholderPattern('container-1-{*}').test('container-1-2-3')).to.be.false; + }); + }); }); diff --git a/packages/sitecore-jss/src/layout/utils.ts b/packages/sitecore-jss/src/layout/utils.ts index bb09356476..bd991e5953 100644 --- a/packages/sitecore-jss/src/layout/utils.ts +++ b/packages/sitecore-jss/src/layout/utils.ts @@ -79,6 +79,22 @@ export function getChildPlaceholder( return rendering.placeholders[placeholderName]; } +/** + * Returns a regular expression pattern for a dynamic placeholder name. + * @param {string} placeholder Placeholder name with a dynamic segment (e.g. 'main-{*}') + * @returns Regular expression pattern for the dynamic segment + */ +export const getDynamicPlaceholderPattern = (placeholder: string) => { + return new RegExp(`^${placeholder.replace(/\{\*\}+/i, '\\d+')}$`); +}; + +/** + * Checks if the placeholder name is dynamic. + * @param {string} placeholder Placeholder name + * @returns True if the placeholder name is dynamic + */ +export const isDynamicPlaceholder = (placeholder: string) => placeholder.indexOf('{*}') !== -1; + /** * The default value for an empty Date field. * This value is defined as a default one by .NET