From 89900aa3130d0f73e2a5007d05ce8fce7ac87111 Mon Sep 17 00:00:00 2001 From: illiakovalenko Date: Wed, 16 Oct 2024 18:24:40 +0300 Subject: [PATCH 1/6] [Angular] XM Cloud Forms support --- .../generate-component-factory/template.ts | 57 ++++ .../src/templates/angular/package.json | 2 +- .../index.ts} | 74 +++-- .../generate-component-factory/template.ts | 46 +++ .../angular/src/app/components/gitignore | 2 +- .../src/components/form.component.spec.ts | 267 ++++++++++++++++++ .../src/components/form.component.ts | 132 +++++++++ .../sitecore-jss-angular/src/lib.module.ts | 3 + .../sitecore-jss-angular/src/public_api.ts | 2 + .../src/services/shared.token.ts | 14 + .../src/graphql/graphql-edge-proxy.test.ts | 29 +- .../src/graphql/graphql-edge-proxy.ts | 13 + packages/sitecore-jss/src/graphql/index.ts | 2 +- 13 files changed, 595 insertions(+), 48 deletions(-) create mode 100644 packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-component-factory/template.ts rename packages/create-sitecore-jss/src/templates/angular/scripts/{generate-component-factory.ts => generate-component-factory/index.ts} (72%) create mode 100644 packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/template.ts create mode 100644 packages/sitecore-jss-angular/src/components/form.component.spec.ts create mode 100644 packages/sitecore-jss-angular/src/components/form.component.ts create mode 100644 packages/sitecore-jss-angular/src/services/shared.token.ts diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-component-factory/template.ts b/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-component-factory/template.ts new file mode 100644 index 0000000000..961218eb8e --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-component-factory/template.ts @@ -0,0 +1,57 @@ +export const componentFactoryTemplate = ({ + imports, + components, + registrations, + lazyRegistrations, + declarations, +}: { + imports: string[]; + components: string[]; + registrations: string[]; + lazyRegistrations: string[]; + declarations: string[]; +}) => `// Do not edit this file, it is auto-generated at build time! +// See scripts/generate-component-factory/index.ts to modify the generation of this file. +// Use app-components.shared.module.ts to modify the imports, etc of this module. +// Note: code-generation is optional! See ./.gitignore for directions to remove it, +// if you do not want it. + +import { NgModule } from '@angular/core'; +import { EDGE_CONFIG, JssModule } from '@sitecore-jss/sitecore-jss-angular'; +import { AppComponentsSharedModule } from './app-components.shared.module'; +import { environment } from '../../environments/environment'; +${imports.join('\n')} + +export const components = [ + ${components.map((c) => `'${c}'`).join(',\n ')} +]; + +@NgModule({ + imports: [ + AppComponentsSharedModule, + JssModule.withComponents([ + ${registrations.join('\n ')} + ], [ + ${lazyRegistrations.join('\n ')} + ]), + ], + providers: [ + { + // This configuration is used to be able to integrate sitecore-jss-angular SDK with Sitecore Edge + provide: EDGE_CONFIG, + useValue: { + sitecoreEdgeUrl: environment.sitecoreEdgeUrl, + sitecoreEdgeContextId: environment.sitecoreEdgeContextId, + }, + }, + ], + exports: [ + JssModule, + AppComponentsSharedModule, + ], + declarations: [ + ${declarations.join('\n ')} + ], +}) +export class AppComponentsModule { } +`; diff --git a/packages/create-sitecore-jss/src/templates/angular/package.json b/packages/create-sitecore-jss/src/templates/angular/package.json index f1e29f009b..0aaf5d313d 100644 --- a/packages/create-sitecore-jss/src/templates/angular/package.json +++ b/packages/create-sitecore-jss/src/templates/angular/package.json @@ -22,7 +22,7 @@ "build": "npm-run-all --serial bootstrap build:client build:server", "scaffold": "ng generate @sitecore-jss/sitecore-jss-angular-schematics:jss-component --no-manifest", "start:angular": "ng serve -o", - "start:watch-components": "ts-node --project src/tsconfig.webpack-server.json scripts/generate-component-factory.ts --watch", + "start:watch-components": "ts-node --project src/tsconfig.webpack-server.json scripts/generate-component-factory/index.ts --watch", "build:client": "cross-env-shell ng build --configuration=production --base-href $npm_package_config_sitecoreDistPath/browser/ --output-path=$npm_package_config_buildArtifactsPath/browser/", "build:server": "cross-env-shell ng run <%- appName %>:server:production --output-path=$npm_package_config_buildArtifactsPath", "postbuild:server": "move-cli ./dist/main.js ./dist/server.bundle.js", diff --git a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory.ts b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/index.ts similarity index 72% rename from packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory.ts rename to packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/index.ts index e95617e52c..dc6bb4b913 100644 --- a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory.ts +++ b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/index.ts @@ -1,6 +1,7 @@ import * as fs from 'fs'; const path = require('path'); const chokidar = require('chokidar'); +const { componentFactoryTemplate } = require('./template'); /* COMPONENT FACTORY GENERATION @@ -75,7 +76,12 @@ function generateComponentFactory() { * ] * } */ - const packages: PackageDefinition[] = []; + const packages: PackageDefinition[] = [ + { + name: '@sitecore-jss/sitecore-jss-angular', + components: [{ componentName: 'Form', moduleName: 'FormComponent' }], + }, + ]; const registrations: string[] = []; const lazyRegistrations: string[] = []; const declarations: string[] = []; @@ -85,7 +91,6 @@ function generateComponentFactory() { const variables = p.components .map((c) => { registrations.push(`{ name: '${c.componentName}', type: ${c.moduleName} },`); - declarations.push(`${c.moduleName},`); components.push(c.componentName); return c.moduleName; @@ -100,7 +105,11 @@ function generateComponentFactory() { return; } - const componentFilePath = path.join(componentRootPath, componentFolder, `${componentFolder}.component.ts`); + const componentFilePath = path.join( + componentRootPath, + componentFolder, + `${componentFolder}.component.ts` + ); if (!fs.existsSync(componentFilePath)) { return; @@ -113,7 +122,9 @@ function generateComponentFactory() { const componentClassMatch = /export class (.+?)Component\b/g.exec(componentFileContents); if (componentClassMatch === null) { - console.debug(`Component ${componentFilePath} did not seem to export a component class. It will be skipped.`); + console.debug( + `Component ${componentFilePath} did not seem to export a component class. It will be skipped.` + ); return; } @@ -123,52 +134,33 @@ function generateComponentFactory() { components.push(componentName); // check for lazy loading needs - const moduleFilePath = path.join(componentRootPath, componentFolder, `${componentFolder}.module.ts`); + const moduleFilePath = path.join( + componentRootPath, + componentFolder, + `${componentFolder}.module.ts` + ); const isLazyLoaded = fs.existsSync(moduleFilePath); if (isLazyLoaded) { console.debug(`Registering JSS component (lazy) ${componentName}`); - lazyRegistrations.push(`{ path: '${componentName}', loadChildren: () => import('./${componentFolder}/${componentFolder}.module').then(m => m.${componentName}Module) },`); + lazyRegistrations.push( + `{ path: '${componentName}', loadChildren: () => import('./${componentFolder}/${componentFolder}.module').then(m => m.${componentName}Module) },` + ); } else { console.debug(`Registering JSS component ${componentName}`); - imports.push(`import { ${importVarName} } from './${componentFolder}/${componentFolder}.component';`); + imports.push( + `import { ${importVarName} } from './${componentFolder}/${componentFolder}.component';` + ); registrations.push(`{ name: '${componentName}', type: ${importVarName} },`); declarations.push(`${importVarName},`); } }); - return `// Do not edit this file, it is auto-generated at build time! -// See scripts/generate-component-factory.js to modify the generation of this file. -// Use app-components.shared.module.ts to modify the imports, etc of this module. -// Note: code-generation is optional! See ./.gitignore for directions to remove it, -// if you do not want it. - -import { NgModule } from '@angular/core'; -import { JssModule } from '@sitecore-jss/sitecore-jss-angular'; -import { AppComponentsSharedModule } from './app-components.shared.module'; -${imports.join('\n')} - -export const components = [ - ${components.map((c) => `'${c}'`).join(',\n ')} -]; - -@NgModule({ - imports: [ - AppComponentsSharedModule, - JssModule.withComponents([ - ${registrations.join('\n ')} - ], [ - ${lazyRegistrations.join('\n ')} - ]), - ], - exports: [ - JssModule, - AppComponentsSharedModule, - ], - declarations: [ - ${declarations.join('\n ')} - ], -}) -export class AppComponentsModule { } -`; + return componentFactoryTemplate({ + imports, + components, + registrations, + lazyRegistrations, + declarations, + }); } diff --git a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/template.ts b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/template.ts new file mode 100644 index 0000000000..d15b69c26a --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/template.ts @@ -0,0 +1,46 @@ +export const componentFactoryTemplate = ({ + imports, + components, + registrations, + lazyRegistrations, + declarations, +}: { + imports: string[]; + components: string[]; + registrations: string[]; + lazyRegistrations: string[]; + declarations: string[]; +}) => `// Do not edit this file, it is auto-generated at build time! +// See scripts/generate-component-factory/index.ts to modify the generation of this file. +// Use app-components.shared.module.ts to modify the imports, etc of this module. +// Note: code-generation is optional! See ./.gitignore for directions to remove it, +// if you do not want it. + +import { NgModule } from '@angular/core'; +import { JssModule } from '@sitecore-jss/sitecore-jss-angular'; +import { AppComponentsSharedModule } from './app-components.shared.module'; +${imports.join('\n')} + +export const components = [ + ${components.map((c) => `'${c}'`).join(',\n ')} +]; + +@NgModule({ + imports: [ + AppComponentsSharedModule, + JssModule.withComponents([ + ${registrations.join('\n ')} + ], [ + ${lazyRegistrations.join('\n ')} + ]), + ], + exports: [ + JssModule, + AppComponentsSharedModule, + ], + declarations: [ + ${declarations.join('\n ')} + ], +}) +export class AppComponentsModule { } +`; diff --git a/packages/create-sitecore-jss/src/templates/angular/src/app/components/gitignore b/packages/create-sitecore-jss/src/templates/angular/src/app/components/gitignore index d6896c77a0..f3afec4d4d 100644 --- a/packages/create-sitecore-jss/src/templates/angular/src/app/components/gitignore +++ b/packages/create-sitecore-jss/src/templates/angular/src/app/components/gitignore @@ -1,7 +1,7 @@ # App component module is auto-generated by default. # To manually maintain the module, # - Remove this ignore file -# - Delete /scripts/generate-component-factory.ts +# - Delete /scripts/generate-component-factory/index.ts # - Remove the reference from /scripts/bootstrap.ts # - Consider merging app-components.shared and app-components modules app-components.module.ts diff --git a/packages/sitecore-jss-angular/src/components/form.component.spec.ts b/packages/sitecore-jss-angular/src/components/form.component.spec.ts new file mode 100644 index 0000000000..14a75deeae --- /dev/null +++ b/packages/sitecore-jss-angular/src/components/form.component.spec.ts @@ -0,0 +1,267 @@ +/* eslint-disable quotes */ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { FormComponent, FormRendering } from './form.component'; +import { EDGE_CONFIG, EdgeConfigToken } from '../services/shared.token'; +import { ElementRef } from '@angular/core'; + +/** + * Remove angular comments and angular-specific bindings + * @param {string} html + */ +function cleanHtml(html: string): string { + return html + .replace(//g, '') + .replace(/\s*ng-reflect-[^=]*="[^"]*"/g, '') + .trim(); +} + +describe('FormComponent', () => { + let component: FormComponent; + let fixture: ComponentFixture; + let mockElementRef: ElementRef; + + let elementRef: ElementRef; + + const mockRendering: FormRendering = { + params: { + FormId: 'test-form-id', + }, + componentName: 'test-component', + dataSource: 'test-data-source', + placeholders: {}, + uid: 'test-uid', + }; + + const mockEdgeConfig: EdgeConfigToken = { + sitecoreEdgeContextId: 'test-context-id', + sitecoreEdgeUrl: 'http://test-url.com', + }; + + const init = ({ + rendering = mockRendering, + edgeConfig = mockEdgeConfig, + }: { + rendering?: FormRendering; + edgeConfig?: EdgeConfigToken; + } = {}) => { + mockElementRef = { + nativeElement: document.createElement('div'), + }; + + TestBed.configureTestingModule({ + declarations: [FormComponent], + providers: [ + { provide: EDGE_CONFIG, useValue: edgeConfig }, + { provide: ElementRef, useValue: mockElementRef }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(FormComponent); + component = fixture.componentInstance; + component.rendering = rendering; + elementRef = fixture.debugElement.injector.get(ElementRef); + }; + + it('should load form', fakeAsync(() => { + init(); + + spyOn(component, 'loadForm').and.callThrough(); + + // Mock request to Forms API + const mockResponse = { + text: () => + Promise.resolve( + '
Form Content
\n' + + '\n' + + '' + ), + status: 200, + }; + spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse as Response)); + spyOn(component, 'executeScriptElements').and.callThrough(); + + const createElementSpy = spyOn(document, 'createElement').and.callThrough(); + const replaceChildSpy = spyOn(elementRef.nativeElement, 'replaceChild').and.callThrough(); + + fixture.detectChanges(); + + tick(); + + expect(component.loadForm).toHaveBeenCalled(); + + expect(window.fetch).toHaveBeenCalledWith( + 'http://test-url.com/v1/forms/publisher/test-form-id?sitecoreContextId=test-context-id', + { + method: 'GET', + cache: 'no-cache', + } + ); + + expect(elementRef.nativeElement.innerHTML).toBe( + '
Form Content
\n' + + '\n' + + '' + ); + expect(component.executeScriptElements).toHaveBeenCalled(); + + expect(createElementSpy).toHaveBeenCalledTimes(2); + expect(createElementSpy.calls.allArgs()).toEqual([['script'], ['script']]); + + expect(replaceChildSpy).toHaveBeenCalledTimes(2); + + const scriptElements = elementRef.nativeElement.querySelectorAll('script'); + + expect(scriptElements.length).toBe(2); + expect(scriptElements[0].outerHTML).toBe( + '' + ); + expect(scriptElements[1].outerHTML).toBe( + '' + ); + })); + + it('should load form with no sitecoreEdgeUrl', fakeAsync(() => { + init({ + edgeConfig: { + sitecoreEdgeContextId: 'test-context-id', + }, + }); + + spyOn(component, 'loadForm').and.callThrough(); + + const mockResponse = { + text: () => + Promise.resolve( + '
Form Content
\n' + + '\n' + + '' + ), + status: 200, + }; + spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse as Response)); + spyOn(component, 'executeScriptElements').and.callThrough(); + + const createElementSpy = spyOn(document, 'createElement').and.callThrough(); + const replaceChildSpy = spyOn(elementRef.nativeElement, 'replaceChild').and.callThrough(); + + fixture.detectChanges(); + + tick(); + + expect(component.loadForm).toHaveBeenCalled(); + + expect(window.fetch).toHaveBeenCalledWith( + 'https://edge-platform.sitecorecloud.io/v1/forms/publisher/test-form-id?sitecoreContextId=test-context-id', + { + method: 'GET', + cache: 'no-cache', + } + ); + expect(elementRef.nativeElement.innerHTML).toBe( + '
Form Content
\n' + + '\n' + + '' + ); + expect(component.executeScriptElements).toHaveBeenCalled(); + + expect(createElementSpy).toHaveBeenCalledTimes(2); + expect(createElementSpy.calls.allArgs()).toEqual([['script'], ['script']]); + + expect(replaceChildSpy).toHaveBeenCalledTimes(2); + + const scriptElements = elementRef.nativeElement.querySelectorAll('script'); + + expect(scriptElements.length).toBe(2); + expect(scriptElements[0].outerHTML).toBe( + '' + ); + expect(scriptElements[1].outerHTML).toBe( + '' + ); + })); + + it('should throw error when FormId is not provided', fakeAsync(() => { + const mockRendering = { + params: { + FormId: '', + }, + componentName: 'test-component', + dataSource: 'test-data-source', + placeholders: {}, + uid: 'test-uid', + }; + + init({ + rendering: mockRendering, + }); + + spyOn(console, 'warn').and.callThrough(); + + fixture.detectChanges(); + + tick(); + + expect(console.warn).toHaveBeenCalledWith( + `Form was not able to render since FormId is not provided in the rendering data`, + JSON.stringify(mockRendering, null, 2) + ); + + expect(cleanHtml(elementRef.nativeElement.innerHTML)).toEqual( + `
` + + `

test-component

` + + `

JSS component is missing FormId rendering parameter.

` + + `
` + ); + })); + + it('should throw error when fetch fails', fakeAsync(() => { + init(); + + spyOn(console, 'warn').and.callThrough(); + + spyOn(window, 'fetch').and.throwError('Fetch failed'); + + fixture.detectChanges(); + + tick(); + + expect(console.warn).toHaveBeenCalledWith( + `Form 'test-form-id' was not able to render with the current rendering data`, + JSON.stringify(mockRendering, null, 2), + new Error('Fetch failed') + ); + + expect(cleanHtml(elementRef.nativeElement.innerHTML)).toEqual( + `
There was a problem loading this section
` + ); + })); + + it('should throw error when fetch returns non-200 status', fakeAsync(() => { + init(); + + spyOn(console, 'warn').and.callThrough(); + + const mockResponse = { + text: () => Promise.resolve('Some error message'), + status: 500, + }; + + spyOn(window, 'fetch').and.returnValue(Promise.resolve(mockResponse as Response)); + + fixture.detectChanges(); + + tick(); + + fixture.detectChanges(); + + expect(console.warn).toHaveBeenCalledWith( + `Form 'test-form-id' was not able to render with the current rendering data`, + JSON.stringify(mockRendering, null, 2), + 'Some error message' + ); + + expect(cleanHtml(elementRef.nativeElement.innerHTML)).toEqual( + `
There was a problem loading this section
` + ); + })); +}); diff --git a/packages/sitecore-jss-angular/src/components/form.component.ts b/packages/sitecore-jss-angular/src/components/form.component.ts new file mode 100644 index 0000000000..00304b91c3 --- /dev/null +++ b/packages/sitecore-jss-angular/src/components/form.component.ts @@ -0,0 +1,132 @@ +import { ComponentRendering } from '@sitecore-jss/sitecore-jss/layout'; +import { getEdgeProxyFormsUrl } from '@sitecore-jss/sitecore-jss/graphql'; +import { Component, OnInit, Input, Inject, ElementRef, PLATFORM_ID } from '@angular/core'; +import { EDGE_CONFIG, EdgeConfigToken } from '../services/shared.token'; +import { isPlatformBrowser } from '@angular/common'; + +/** + * Shape of the Form component rendering data. + * FormId is the rendering parameter that specifies the ID of the Sitecore Form to render. + */ +export type FormRendering = { + params: { + FormId: string; + }; +} & ComponentRendering; + +/** + * A component that renders a Sitecore Form. + * It fetches the form markup from the Sitecore Edge service and renders it in the component's template. + */ +@Component({ + selector: 'app-form', + template: ` + +
+

{{ rendering.componentName }}

+

JSS component is missing FormId rendering parameter.

+
+
+ +
There was a problem loading this section
+
+ `, +}) +export class FormComponent implements OnInit { + /** + * The rendering data for the component + */ + @Input() rendering: FormRendering; + + hasError = false; + + constructor( + @Inject(EDGE_CONFIG) private edgeConfig: EdgeConfigToken, + @Inject(PLATFORM_ID) private platformId: { [key: string]: unknown }, + private elRef: ElementRef + ) {} + + ngOnInit() { + if (isPlatformBrowser(this.platformId)) { + this.loadForm(); + } + } + + /** + * Fetches the form markup from the Sitecore Edge service and renders it in the component's template. + */ + async loadForm() { + const { sitecoreEdgeContextId, sitecoreEdgeUrl } = this.edgeConfig; + + if (!this.rendering.params.FormId) { + console.warn( + 'Form was not able to render since FormId is not provided in the rendering data', + JSON.stringify(this.rendering, null, 2) + ); + + return; + } + + const url = getEdgeProxyFormsUrl( + sitecoreEdgeContextId, + this.rendering.params.FormId, + sitecoreEdgeUrl + ); + + try { + const rsp = await fetch(url, { + method: 'GET', + cache: 'no-cache', + }); + + const content = await rsp.text(); + + if (rsp.status !== 200) { + this.hasError = true; + + console.warn( + `Form '${this.rendering.params.FormId}' was not able to render with the current rendering data`, + JSON.stringify(this.rendering, null, 2), + content + ); + + return; + } + + this.elRef.nativeElement.innerHTML = content; + + this.executeScriptElements(); + } catch (error) { + console.warn( + `Form '${this.rendering.params.FormId}' was not able to render with the current rendering data`, + JSON.stringify(this.rendering, null, 2), + error + ); + + this.hasError = true; + } + } + + /** + * When you set the innerHTML property of an element, the browser does not execute any