;
+
+ 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