diff --git a/CHANGELOG.md b/CHANGELOG.md index 80f42a6d05..28a801b853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,10 @@ Our versioning strategy is as follows: - `scRouterLinkEmptyFieldEditingTemplate` for _scRouterLink_ - `scTextEmptyFieldEditingTemplate` for _scText_ * `[sitecore-jss-angular]` `[templates/angular-xmcloud]` Render clientScripts / clientData. The new `sc-editing-scripts` component is exposed from `sitecore-jss-angular` package and required to be rendered on the page to enable Metadata Edit mode. ([#1924](https://github.com/Sitecore/jss/pull/1924))([#1948](https://github.com/Sitecore/jss/pull/1948)) +* `[sitecore-jss-angular]` `[templates/angular-xmcloud]` XM Cloud Forms support ([#1951](https://github.com/Sitecore/jss/pull/1951)): + * New "Form" component is introduced in the sitecore-jss-angular package. + * The Form component is declared in the Angular sample app. + * Introduced plugins technique for component factory generation. * `[sitecore-jss]` GenericFieldValue model is updated to accept Date type ([#1916](https://github.com/Sitecore/jss/pull/1916)) * `[template/node-xmcloud-proxy]` `[sitecore-jss-proxy]` Introduced /api/healthz endpoint ([#1928](https://github.com/Sitecore/jss/pull/1928)) * `[sitecore-jss]` `[sitecore-jss-angular]` Render field metdata chromes in editMode metadata - in edit mode metadata in Pages, angular package field directives will render wrapping `code` elements with field metadata required for editing; ([#1926](https://github.com/Sitecore/jss/pull/1926)) diff --git a/docs/upgrades/unreleased.md b/docs/upgrades/unreleased.md index 4d8a454fc0..ee86877c1d 100644 --- a/docs/upgrades/unreleased.md +++ b/docs/upgrades/unreleased.md @@ -261,6 +261,40 @@ If you plan to use the Angular SDK with XMCloud, you will need to perform next s ``` +* In order to be able to start using Forms in XMCloud you need to register a Form component and add required configuration: + * Update your _scripts/generate-component-builder_ template:: + * Register Form component + + ```ts + const packages = [ + { + name: '@sitecore-jss/sitecore-jss-angular', + components: [{ componentName: 'Form', moduleName: 'FormComponent' }], + }, + ] + ``` + + Make sure to don't push such components to the "declarations" list, since the related module is a part of "imports" list. + * Add to "providers" list a new InjectionToken, EDGE_CONFIG token is needed for Form component to be able to fetch the form from Sitecore Edge: + + ```ts + import { EDGE_CONFIG } from '@sitecore-jss/sitecore-jss-angular'; + import { environment } from '../../environments/environment'; + ... + 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, + }, + }, + ], + ``` + + + # @sitecore-jss/sitecore-jss-proxy * Update the import statement diff --git a/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-component-factory/plugins/packages.ts b/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-component-factory/plugins/packages.ts new file mode 100644 index 0000000000..ee621cd3b3 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular-xmcloud/scripts/generate-component-factory/plugins/packages.ts @@ -0,0 +1,34 @@ +import { ComponentFactoryPlugin, ComponentFactoryPluginConfig } from '..'; + +/** + * Provides custom packages configuration + */ +class PackagesPlugin implements ComponentFactoryPlugin { + order = 0; + + exec(config: ComponentFactoryPluginConfig) { + /** + * You can specify components which you want to import from external/internal packages + * in format: + * { + * name: 'package name', + * components: [ + * { + * componentName: 'component name', // component rendering name, + * moduleName: 'module name' // component name to import from the package + * } + * ] + * } + */ + config.packages = [ + { + name: '@sitecore-jss/sitecore-jss-angular', + components: [{ componentName: 'Form', moduleName: 'FormComponent' }], + }, + ]; + + return config; + } +} + +export const packagesPlugin = new PackagesPlugin(); 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.ts deleted file mode 100644 index e95617e52c..0000000000 --- a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory.ts +++ /dev/null @@ -1,174 +0,0 @@ -import * as fs from 'fs'; -const path = require('path'); -const chokidar = require('chokidar'); - -/* - COMPONENT FACTORY GENERATION - Generates the /src/app/components/app-components.module.ts file which maps Angular components - to JSS components. - - The component factory module defines a mapping between a string component name and a Angular component instance. - When the Sitecore Layout service returns a layout definition, it returns named components. - This mapping is used to construct the component hierarchy for the layout. - - NOTE: this script can run in two modes. The default mode, the component factory file is written once. - But if `--watch` is a process argument, the component factory source folder will be watched, - and the componentFactory.js rewritten on added or deleted files. - This is used during `jss start` to pick up new or removed components at runtime. -*/ - -export interface PackageDefinition { - name: string; - components: { - moduleName: string; - componentName: string; - }[]; -} - -const componentFactoryPath = path.resolve('src/app/components/app-components.module.ts'); -const componentRootPath = 'src/app/components'; - -const isWatch = process.argv.some((arg) => arg === '--watch'); - -if (isWatch) { - watchComponentFactory(); -} else { - writeComponentFactory(); -} - -function watchComponentFactory() { - console.log(`Watching for changes to component factory sources in ${componentRootPath}...`); - - chokidar - .watch(componentRootPath, { ignoreInitial: true, awaitWriteFinish: true }) - .on('add', writeComponentFactory) - .on('unlink', writeComponentFactory); -} - -function writeComponentFactory() { - const componentFactory = generateComponentFactory(); - - console.log(`Writing component factory to ${componentFactoryPath}`); - - fs.writeFileSync(componentFactoryPath, componentFactory, { encoding: 'utf8' }); -} - -function generateComponentFactory() { - // By convention, we expect to find Angular components - // under /src/app/components/component-name/component-name.component.ts - // If a component-name.module.ts file exists, we will treat it as lazy loaded. - // If you'd like to use your own convention, encode it below. - // NOTE: generating the component factory module is also totally optional, - // and it can be maintained manually if preferred. - - const imports: string[] = []; - /** - * You can specify components which you want to import from external/internal packages - * in format: - * { - * name: 'package name', - * components: [ - * { - * componentName: 'component name', // component rendering name, - * moduleName: 'module name' // component name to import from the package - * } - * ] - * } - */ - const packages: PackageDefinition[] = []; - const registrations: string[] = []; - const lazyRegistrations: string[] = []; - const declarations: string[] = []; - const components: string[] = []; - - packages.forEach((p) => { - 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; - }) - .join(', '); - imports.push(`import { ${variables} } from '${p.name}'`); - }); - - fs.readdirSync(componentRootPath).forEach((componentFolder) => { - // ignore ts files in component root folder - if (componentFolder.endsWith('.ts') || componentFolder === '.gitignore') { - return; - } - - const componentFilePath = path.join(componentRootPath, componentFolder, `${componentFolder}.component.ts`); - - if (!fs.existsSync(componentFilePath)) { - return; - } - - const componentFileContents = fs.readFileSync(componentFilePath, 'utf8'); - - // ASSUMPTION: your component should export a class directly that follows Angular conventions, - // i.e. `export class FooComponent` - so we can detect the component's name for auto registration. - 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.`); - return; - } - - const componentName = componentClassMatch[1]; - const importVarName = `${componentName}Component`; - - components.push(componentName); - - // check for lazy loading needs - 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) },`); - } else { - console.debug(`Registering JSS component ${componentName}`); - 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 { } -`; -} diff --git a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/index.ts b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/index.ts new file mode 100644 index 0000000000..985bbe9bab --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/index.ts @@ -0,0 +1,45 @@ +const plugins = require('scripts/temp/generate-component-factory-plugins'); +import { PackageDefinition } from '@sitecore-jss/sitecore-jss-dev-tools'; + +export interface ComponentFactoryPluginConfig { + watch?: boolean; + packages: PackageDefinition[]; + components: string[]; +} + +export interface ComponentFactoryPlugin { + /** + * Detect order when the plugin should be called, e.g. 0 - will be called first (can be a plugin which data is required for other plugins) + */ + order: number; + /** + * A function which will be called during component factory generation + * @param {JssConfig} config Current (accumulated) config + */ + exec(config: ComponentFactoryPluginConfig): ComponentFactoryPluginConfig; +} + +/* + COMPONENT FACTORY GENERATION + Generates the /src/app/components/app-components.module.ts file which maps Angular components + to JSS components. + + The component factory module defines a mapping between a string component name and a Angular component instance. + When the Sitecore Layout service returns a layout definition, it returns named components. + This mapping is used to construct the component hierarchy for the layout. + + NOTE: this script can run in two modes. The default mode, the component factory file is written once. + But if `--watch` is a process argument, the component factory source folder will be watched, + and the componentFactory.js rewritten on added or deleted files. + This is used during `jss start` to pick up new or removed components at runtime. +*/ + +const defaultConfig: ComponentFactoryPluginConfig = { + watch: process.argv.some(arg => arg === '--watch'), + packages: [], + components: [], +}; + +(Object.values(plugins) as ComponentFactoryPlugin[]) + .sort((p1, p2) => p1.order - p2.order) + .reduce((config, plugin) => plugin.exec(config), defaultConfig); diff --git a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/component-factory.ts b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/component-factory.ts new file mode 100644 index 0000000000..b09c7fe124 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/component-factory.ts @@ -0,0 +1,147 @@ + +import * as fs from 'fs'; +import path from 'path'; +import chokidar from 'chokidar'; +import { componentFactoryTemplate } from '../template'; +import { + ComponentFactoryPluginConfig, + ComponentFactoryPlugin as ComponentFactoryPluginType, +} from '..'; + +export interface PackageDefinition { + name: string; + components: { + moduleName: string; + componentName: string; + }[]; +} + +const componentFactoryPath = path.resolve('src/app/components/app-components.module.ts'); +const componentRootPath = 'src/app/components'; + +function watchComponentFactory(config: ComponentFactoryPluginConfig) { + console.log(`Watching for changes to component factory sources in ${componentRootPath}...`); + + chokidar + .watch(componentRootPath, { ignoreInitial: true, awaitWriteFinish: true }) + .on('add', writeComponentFactory.bind(null, config)) + .on('unlink', writeComponentFactory.bind(null, config)); +} + +function writeComponentFactory(config: ComponentFactoryPluginConfig) { + const componentFactory = generateComponentFactory(config); + + console.log(`Writing component factory to ${componentFactoryPath}`); + + fs.writeFileSync(componentFactoryPath, componentFactory, { encoding: 'utf8' }); +} + +function generateComponentFactory(config: ComponentFactoryPluginConfig) { + // By convention, we expect to find Angular components + // under /src/app/components/component-name/component-name.component.ts + // If a component-name.module.ts file exists, we will treat it as lazy loaded. + // If you'd like to use your own convention, encode it below. + // NOTE: generating the component factory module is also totally optional, + // and it can be maintained manually if preferred. + + const imports: string[] = []; + const registrations: string[] = []; + const lazyRegistrations: string[] = []; + const declarations: string[] = []; + + config.packages.forEach((p) => { + const variables = p.components + .map((c) => { + registrations.push(`{ name: '${c.componentName}', type: ${c.moduleName} },`); + config.components.push(c.componentName); + + return c.moduleName; + }) + .join(', '); + imports.push(`import { ${variables} } from '${p.name}'`); + }); + + fs.readdirSync(componentRootPath).forEach((componentFolder) => { + // ignore ts files in component root folder + if (componentFolder.endsWith('.ts') || componentFolder === '.gitignore') { + return; + } + + const componentFilePath = path.join( + componentRootPath, + componentFolder, + `${componentFolder}.component.ts` + ); + + if (!fs.existsSync(componentFilePath)) { + return; + } + + const componentFileContents = fs.readFileSync(componentFilePath, 'utf8'); + + // ASSUMPTION: your component should export a class directly that follows Angular conventions, + // i.e. `export class FooComponent` - so we can detect the component's name for auto registration. + 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.` + ); + return; + } + + const componentName = componentClassMatch[1]; + const importVarName = `${componentName}Component`; + + config.components.push(componentName); + + // check for lazy loading needs + 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) },` + ); + } else { + console.debug(`Registering JSS component ${componentName}`); + imports.push( + `import { ${importVarName} } from './${componentFolder}/${componentFolder}.component';` + ); + registrations.push(`{ name: '${componentName}', type: ${importVarName} },`); + declarations.push(`${importVarName},`); + } + }); + + return componentFactoryTemplate({ + imports, + components: config.components, + registrations, + lazyRegistrations, + declarations, + }); +} + +/** + * Generates the component factory file. + */ +class ComponentFactoryPlugin implements ComponentFactoryPluginType { + order = 9999; + + exec(config: ComponentFactoryPluginConfig) { + if (config.watch) { + watchComponentFactory(config); + } else { + writeComponentFactory(config); + } + + return config; + } +} + +export const componentFactoryPlugin = new ComponentFactoryPlugin(); diff --git a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/components.ts b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/components.ts new file mode 100644 index 0000000000..de1616d83d --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/components.ts @@ -0,0 +1,19 @@ +import { ComponentFactoryPlugin, ComponentFactoryPluginConfig } from '..'; + +/** + * Provides custom components configuration + */ +class ComponentsPlugin implements ComponentFactoryPlugin { + order = 0; + + exec(config: ComponentFactoryPluginConfig) { + /** + * You can specify components which you want to import using custom path + */ + config.components = []; + + return config; + } +} + +export const componentsPlugin = new ComponentsPlugin(); diff --git a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/packages.ts b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/packages.ts new file mode 100644 index 0000000000..e874ed2807 --- /dev/null +++ b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-component-factory/plugins/packages.ts @@ -0,0 +1,29 @@ +import { ComponentFactoryPlugin, ComponentFactoryPluginConfig } from '..'; + +/** + * Provides custom packages configuration + */ +class PackagesPlugin implements ComponentFactoryPlugin { + order = 0; + + exec(config: ComponentFactoryPluginConfig) { + /** + * You can specify components which you want to import from external/internal packages + * in format: + * { + * name: 'package name', + * components: [ + * { + * componentName: 'component name', // component rendering name, + * moduleName: 'module name' // component name to import from the package + * } + * ] + * } + */ + config.packages = []; + + return config; + } +} + +export const packagesPlugin = new PackagesPlugin(); 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/scripts/generate-plugins.ts b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-plugins.ts index 022c0f8c58..ce347d34ba 100644 --- a/packages/create-sitecore-jss/src/templates/angular/scripts/generate-plugins.ts +++ b/packages/create-sitecore-jss/src/templates/angular/scripts/generate-plugins.ts @@ -21,6 +21,11 @@ const pluginDefinitions: PluginDefinition[] = [ rootPath: 'scripts/config/plugins', moduleType: ModuleType.ESM, }, + { + distPath: 'scripts/temp/generate-component-factory-plugins.ts', + rootPath: 'scripts/generate-component-factory/plugins', + moduleType: ModuleType.ESM, + }, ]; pluginDefinitions.forEach((definition) => { 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..6763df21ea --- /dev/null +++ b/packages/sitecore-jss-angular/src/components/form.component.spec.ts @@ -0,0 +1,446 @@ +/* eslint-disable quotes */ +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { ElementRef } from '@angular/core'; +import { LayoutServicePageState } from '@sitecore-jss/sitecore-jss/layout'; +import { FormComponent, FormRendering } from './form.component'; +import { EDGE_CONFIG, EdgeConfigToken } from '../services/shared.token'; +import { JssStateService } from '../services/jss-state.service'; +import { cleanHtml } from '../test-utils'; + +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: [ + JssStateService, + { 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( + '' + ); + })); + + describe('when FormId is not provided', () => { + it('editing mode - should log warning and render error', fakeAsync(() => { + const mockRendering = { + params: { + FormId: '', + }, + componentName: 'test-component', + dataSource: 'test-data-source', + placeholders: {}, + uid: 'test-uid', + }; + + init({ + rendering: mockRendering, + }); + + const stateService = TestBed.inject(JssStateService); + + stateService.setState({ + sitecore: { + context: { + pageState: LayoutServicePageState.Edit, + }, + route: null, + }, + }); + + 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('preview mode - should log warning and render error', fakeAsync(() => { + const mockRendering = { + params: { + FormId: '', + }, + componentName: 'test-component', + dataSource: 'test-data-source', + placeholders: {}, + uid: 'test-uid', + }; + + init({ + rendering: mockRendering, + }); + + const stateService = TestBed.inject(JssStateService); + + stateService.setState({ + sitecore: { + context: { + pageState: LayoutServicePageState.Preview, + }, + route: null, + }, + }); + + 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('normal mode - should log warning', fakeAsync(() => { + const mockRendering = { + params: { + FormId: '', + }, + componentName: 'test-component', + dataSource: 'test-data-source', + placeholders: {}, + uid: 'test-uid', + }; + + init({ + rendering: mockRendering, + }); + + const stateService = TestBed.inject(JssStateService); + + stateService.setState({ + sitecore: { + context: { + pageState: LayoutServicePageState.Normal, + }, + route: null, + }, + }); + + 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(''); + })); + }); + + describe('when fetch fails', () => { + it('editing mode - should log warning and render error', fakeAsync(() => { + init(); + + const stateService = TestBed.inject(JssStateService); + + stateService.setState({ + sitecore: { + context: { + pageState: LayoutServicePageState.Edit, + }, + route: null, + }, + }); + + 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('preview mode - should log warning and render error', fakeAsync(() => { + init(); + + const stateService = TestBed.inject(JssStateService); + + stateService.setState({ + sitecore: { + context: { + pageState: LayoutServicePageState.Preview, + }, + route: null, + }, + }); + + 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 log warning and render error when fetch returns non-200 status', fakeAsync(() => { + init(); + + const stateService = TestBed.inject(JssStateService); + + stateService.setState({ + sitecore: { + context: { + pageState: LayoutServicePageState.Edit, + }, + route: null, + }, + }); + + 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
` + ); + })); + + it('normal mode - should log warning', fakeAsync(() => { + init(); + + const stateService = TestBed.inject(JssStateService); + + stateService.setState({ + sitecore: { + context: { + pageState: LayoutServicePageState.Normal, + }, + route: null, + }, + }); + + 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(''); + })); + }); +}); 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..29de0eab14 --- /dev/null +++ b/packages/sitecore-jss-angular/src/components/form.component.ts @@ -0,0 +1,159 @@ +import { ComponentRendering, LayoutServicePageState } from '@sitecore-jss/sitecore-jss/layout'; +import { getEdgeProxyFormsUrl } from '@sitecore-jss/sitecore-jss/graphql'; +import { + Component, + OnInit, + Input, + Inject, + ElementRef, + PLATFORM_ID, + OnDestroy, +} from '@angular/core'; +import { EDGE_CONFIG, EdgeConfigToken } from '../services/shared.token'; +import { JssStateService } from '../services/jss-state.service'; +import { isPlatformBrowser } from '@angular/common'; +import { Subscription } from 'rxjs'; + +/** + * 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, OnDestroy { + /** + * The rendering data for the component + */ + @Input() rendering: FormRendering; + + hasError = false; + + isEditing = false; + + private contextSubscription: Subscription; + + constructor( + @Inject(EDGE_CONFIG) private edgeConfig: EdgeConfigToken, + @Inject(PLATFORM_ID) private platformId: { [key: string]: unknown }, + private elRef: ElementRef, + private jssState: JssStateService + ) {} + + ngOnInit() { + if (isPlatformBrowser(this.platformId)) { + this.loadForm(); + + this.contextSubscription = this.jssState.state.subscribe(({ sitecore }) => { + this.isEditing = sitecore?.context.pageState !== LayoutServicePageState.Normal; + }); + } + } + + ngOnDestroy() { + if (this.contextSubscription) { + this.contextSubscription.unsubscribe(); + } + } + + /** + * 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