Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test --watch=false",
"coverage": "ng test --watch=false --coverage",
"lint": "eslint \"./src/**/*.ts\""
},
"prettier": {
Expand Down Expand Up @@ -68,6 +69,7 @@
"@angular/platform-browser": "^21.1.0",
"@angular/platform-server": "^21.1.0",
"@angular/router": "^21.1.0",
"@vitest/coverage-v8": "^4.1.5",
"jsdom": "^27.1.0",
"ng-packagr": "^21.1.0",
"rxjs": "~7.8.0",
Expand Down
120 changes: 89 additions & 31 deletions packages/angular/src/components/sc-form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,20 @@ const makePage = (isEditing: boolean): Page =>
isDesignLibrary: false,
designLibrary: { isVariantGeneration: false },
},
}) as unknown as Page;
} as Page);

/** Flush afterNextRender and loadForm promise (scripts / subscribe run in the same microtask). */
function formRendering(
params: Record<string, string>,
extra: Partial<ComponentRendering> = {}
): ComponentRendering {
return { componentName: 'Form', params, ...extra } as ComponentRendering;
}

/**
* Flush afterNextRender and loadForm promise (scripts / subscribe run in the same microtask).
* @param {ComponentFixture<ScFormComponent>} fixture - Host fixture under test.
* @returns {Promise<void>} Resolves when the form pipeline side effects have run.
*/
async function flushFormLoadPipeline(fixture: ComponentFixture<ScFormComponent>): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
Expand Down Expand Up @@ -97,7 +108,7 @@ describe('ScFormComponent', () => {
warnSpy.mockRestore();
});

it('should not call loadForm on the server (JSS: isPlatformBrowser guard)', async () => {
it('should not call loadForm on the server', async () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [ScFormComponent],
Expand All @@ -108,27 +119,27 @@ describe('ScFormComponent', () => {
});

const fixture = createFixture();
fixture.componentRef.setInput('params', { FormId: 'form-1' });
fixture.componentRef.setInput('rendering', formRendering({ FormId: 'form-1' }));
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((r) => setTimeout(r, 50));

expect(mocks.loadForm).not.toHaveBeenCalled();
});

it('should not call loadForm when FormId is missing (JSS: FormId param)', async () => {
it('should not call loadForm when FormId is missing', async () => {
const fixture = createFixture();
fixture.componentRef.setInput('params', {});
fixture.componentRef.setInput('rendering', formRendering({}));
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((r) => setTimeout(r, 50));

expect(mocks.loadForm).not.toHaveBeenCalled();
});

it('should call loadForm with edge context id, FormId, and edgeUrl from config (JSS: loadForm from Edge)', async () => {
it('should call loadForm with edge context id, FormId, and edgeUrl from config', async () => {
const fixture = createFixture();
fixture.componentRef.setInput('params', { FormId: 'my-form-id' });
fixture.componentRef.setInput('rendering', formRendering({ FormId: 'my-form-id' }));
await flushFormLoadPipeline(fixture);

expect(mocks.loadForm).toHaveBeenCalledWith(
Expand All @@ -138,6 +149,46 @@ describe('ScFormComponent', () => {
);
});

it('should use merged params when params input supplies FormId missing on rendering', async () => {
const fixture = createFixture();
fixture.componentRef.setInput('rendering', formRendering({ RenderingIdentifier: 'r-1' }));
fixture.componentRef.setInput('params', { FormId: 'form-from-params-only' });
await flushFormLoadPipeline(fixture);

expect(mocks.loadForm).toHaveBeenCalledWith(
'test-edge-context-id',
'form-from-params-only',
'https://edge.example.com'
);
});

it('should prefer FormId from params input over rendering when both are provided', async () => {
const fixture = createFixture();
fixture.componentRef.setInput(
'rendering',
formRendering({ FormId: 'rendering-form-id' })
);
fixture.componentRef.setInput('params', { FormId: 'component-form-id' });
await flushFormLoadPipeline(fixture);

expect(mocks.loadForm).toHaveBeenCalledWith(
'test-edge-context-id',
'component-form-id',
'https://edge.example.com'
);
});

it('should apply styles from params when rendering has no styles param', async () => {
const fixture = createFixture();
fixture.componentRef.setInput('rendering', formRendering({ FormId: 'f1' }));
fixture.componentRef.setInput('params', { styles: ' from-params-style ' });
fixture.detectChanges();
await fixture.whenStable();

const host = fixture.nativeElement.querySelector('div') as HTMLDivElement;
expect(host.className.trim()).toBe('from-params-style');
});

it('should not call loadForm when clientContextId is missing (no SITECORE_CONFIG_TOKEN)', async () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
Expand All @@ -146,7 +197,7 @@ describe('ScFormComponent', () => {
});

const fixture = createFixture();
fixture.componentRef.setInput('params', { FormId: 'fid' });
fixture.componentRef.setInput('rendering', formRendering({ FormId: 'fid' }));
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((r) => setTimeout(r, 50));
Expand All @@ -156,65 +207,73 @@ describe('ScFormComponent', () => {
expect(String(warnSpy.mock.calls[0][0])).toContain('clientContextId');
});

it('should set loaded HTML into the container via innerHTML (JSS: innerHTML = content)', async () => {
it('should set loaded HTML into the container via innerHTML', async () => {
// Markup is assigned on the container element ref (not [innerHTML], which sanitizes scripts).
mocks.loadForm.mockResolvedValue('<p class="sc-form-inner" data-f="1">Inner</p>');

const fixture = createFixture();
fixture.componentRef.setInput('params', { FormId: 'f1' });
fixture.componentRef.setInput('rendering', formRendering({ FormId: 'f1' }));
await flushFormLoadPipeline(fixture);

const host = fixture.nativeElement.querySelector('div') as HTMLDivElement;
expect(host.querySelector('p.sc-form-inner')).toBeTruthy();
expect(host.textContent).toContain('Inner');
});

it('should bind styles param as class with trailing whitespace trimmed (JSS: params.styles → className)', async () => {
it('should bind styles param as class with trailing whitespace trimmed', async () => {
const fixture = createFixture();
fixture.componentRef.setInput('params', {
FormId: 'f1',
styles: ' my-form-style ',
});
fixture.componentRef.setInput(
'rendering',
formRendering({
FormId: 'f1',
styles: ' my-form-style ',
})
);
fixture.detectChanges();
await fixture.whenStable();

const host = fixture.nativeElement.querySelector('div') as HTMLDivElement;
expect(host.className.trim()).toBe('my-form-style');
});

it('should bind RenderingIdentifier as element id (JSS: RenderingIdentifier → id)', async () => {
it('should bind RenderingIdentifier as element id', async () => {
const fixture = createFixture();
fixture.componentRef.setInput('params', {
FormId: 'f1',
RenderingIdentifier: 'form-rendering-42',
});
fixture.componentRef.setInput(
'rendering',
formRendering({
FormId: 'f1',
RenderingIdentifier: 'form-rendering-42',
})
);
fixture.detectChanges();
await fixture.whenStable();

const host = fixture.nativeElement.querySelector('div') as HTMLDivElement;
expect(host.id).toBe('form-rendering-42');
});

it('should call executeScriptElements on the container after load (JSS: executeScriptElements)', async () => {
it('should call executeScriptElements on the container after load', async () => {
const fixture = createFixture();
const ctx = TestBed.inject(SitecoreContextService);
ctx.setPage(makePage(false));

fixture.componentRef.setInput('params', { FormId: 'f1' });
fixture.componentRef.setInput('rendering', formRendering({ FormId: 'f1' }));
await flushFormLoadPipeline(fixture);

expect(mocks.executeScriptElements).toHaveBeenCalledTimes(1);
const elArg = mocks.executeScriptElements.mock.calls[0][0] as HTMLDivElement;
expect(elArg.tagName).toBe('DIV');
});

it('should call subscribeToFormSubmitEvent when not in editing mode (JSS: !isEditing)', async () => {
it('should call subscribeToFormSubmitEvent when not in editing mode', async () => {
const fixture = createFixture();
const ctx = TestBed.inject(SitecoreContextService);
ctx.setPage(makePage(false));

fixture.componentRef.setInput('params', { FormId: 'f1' });
fixture.componentRef.setInput('rendering', { uid: 'comp-uid-1' } as ComponentRendering);
fixture.componentRef.setInput(
'rendering',
formRendering({ FormId: 'f1' }, { uid: 'comp-uid-1' })
);
await flushFormLoadPipeline(fixture);

expect(mocks.subscribeToFormSubmitEvent).toHaveBeenCalledTimes(1);
Expand All @@ -224,24 +283,23 @@ describe('ScFormComponent', () => {
);
});

it('should not call subscribeToFormSubmitEvent in editing mode (JSS: isEditing)', async () => {
it('should not call subscribeToFormSubmitEvent in editing mode', async () => {
const fixture = createFixture();
const ctx = TestBed.inject(SitecoreContextService);
ctx.setPage(makePage(true));

fixture.componentRef.setInput('params', { FormId: 'f1' });
fixture.componentRef.setInput('rendering', { uid: 'x' } as ComponentRendering);
fixture.componentRef.setInput('rendering', formRendering({ FormId: 'f1' }, { uid: 'x' }));
await flushFormLoadPipeline(fixture);

expect(mocks.subscribeToFormSubmitEvent).not.toHaveBeenCalled();
expect(mocks.executeScriptElements).toHaveBeenCalled();
});

it('should log when loadForm rejects (JSS: catch / hasError)', async () => {
it('should log when loadForm rejects', async () => {
mocks.loadForm.mockRejectedValue(new Error('network'));

const fixture = createFixture();
fixture.componentRef.setInput('params', { FormId: 'bad-form' });
fixture.componentRef.setInput('rendering', formRendering({ FormId: 'bad-form' }));
fixture.detectChanges();
await fixture.whenStable();
await vi.waitFor(() => errorSpy.mock.calls.length > 0, { timeout: 3000, interval: 5 });
Expand Down
33 changes: 21 additions & 12 deletions packages/angular/src/components/sc-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { SitecoreContextService } from '../lib/sitecore-context.service';

const { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form;

/* eslint-disable @typescript-eslint/member-ordering -- ViewChild + signal inputs + constructor ordering conflicts with default groups */
/**
* Angular wrapper for Sitecore Forms.
* Loads form HTML from Edge, executes embedded scripts, and subscribes to form events.
Expand All @@ -25,15 +26,11 @@ const { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form;
*/
@Component({
selector: 'sc-form',
standalone: true,
template: `
<div #formContainer [class]="styles()" [id]="renderingId()"></div>
`,
template: ` <div #formContainer [class]="styles()" [id]="renderingId()"></div> `,
})
export class ScFormComponent {
readonly rendering = input<ComponentRendering>();
readonly params = input<{ [key: string]: string }>({});
readonly fields = input<{ [key: string]: unknown }>({});

@ViewChild('formContainer', { static: true })
private formContainerRef!: ElementRef<HTMLDivElement>;
Expand All @@ -43,18 +40,19 @@ export class ScFormComponent {
private readonly platformId = inject(PLATFORM_ID);
private readonly destroyRef = inject(DestroyRef);

readonly styles = () => {
const s = this.params()?.['styles'];
return s ? s.replace(/\s+$/, '') : '';
};
readonly renderingId = () => this.params()?.['RenderingIdentifier'] || undefined;
/**
* Merges `rendering.params` with the `params` input: the component `params()` values override layout for the same key.
*/
private mergedFormParams(): { [key: string]: string } {
return { ...(this.rendering()?.params ?? {}), ...this.params() };
}

constructor() {
afterNextRender(() => {
if (!isPlatformBrowser(this.platformId)) return;

const p = this.params();
const formId = p?.['FormId'];
const p = this.mergedFormParams();
const formId = p.FormId;
if (!formId) return;

const cfg = this.config;
Expand Down Expand Up @@ -95,4 +93,15 @@ export class ScFormComponent {
});
});
}

readonly styles = () => {
const p = this.mergedFormParams();
const s = p.styles;
return s ? s.replace(/\s+$/, '') : '';
};
readonly renderingId = () => {
const p = this.mergedFormParams();
return p.RenderingIdentifier || undefined;
};
}
/* eslint-enable @typescript-eslint/member-ordering */
7 changes: 7 additions & 0 deletions packages/angular/src/config/define-config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { SitecoreConfig, SitecoreConfigInput } from '@sitecore-content-sdk/content/config';
import { defineConfig as baseDefineConfig } from '@sitecore-content-sdk/content/config';

/**
* Reads `process.env` when running under Node; otherwise returns an empty object.
* @returns {Record<string, string | undefined>} Environment map for merging into config.
*/
function getProcessEnv(): Record<string, string | undefined> {
// Use globalThis so we do not need @types/node (lib tsconfig uses "types": []).
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process
Expand All @@ -10,6 +14,9 @@ function getProcessEnv(): Record<string, string | undefined> {
/**
* Merges `clientEnv` (browser-safe `environment*.ts`) with `process.env` for server-only variables.
* On Node/SSR, load `.env` in the app entry before importing `sitecore.config` (see `load-env.ts` in the sample).
* @param {SitecoreConfigInput} [config] - Base Sitecore configuration input.
* @param {Record<string, string | undefined>} [clientEnv] - Browser-safe env from `environment*.ts`.
* @returns {SitecoreConfig} Fully merged Sitecore configuration.
* @public
*/
export function defineConfig(
Expand Down
Loading