Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
5 changes: 5 additions & 0 deletions .changeset/hip-buttons-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sitecore-content-sdk/angular': minor
---

Personalize, multisite and analytics support
6 changes: 6 additions & 0 deletions packages/angular/config/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"$schema": "../node_modules/ng-packagr/ng-entrypoint.schema.json",
"lib": {
"entryFile": "../src/config/index.ts"
}
}
3 changes: 3 additions & 0 deletions packages/angular/ng-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
"allowedNonPeerDependencies": [
"@sitecore-content-sdk/core",
"@sitecore-content-sdk/content",
"@sitecore-content-sdk/analytics-core",
"@sitecore-content-sdk/events",
"@sitecore-content-sdk/personalize",
"@angular/common",
"@angular/core",
"@angular/router",
Expand Down
14 changes: 12 additions & 2 deletions packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@
},
"files": [
"dist",
"cli-node"
"dist-node"
],
"scripts": {
"ng": "ng",
"build": "del-cli dist cli-node && ng build && tsc -p tsconfig.server.json",
"build": "del-cli dist dist-node && ng build && tsc -p tsconfig.server.json",
"watch": "ng build --watch --configuration development",
"test": "ng test --watch=false",
"coverage": "ng test --watch=false --coverage",
Expand Down Expand Up @@ -51,6 +51,9 @@
"@angular/platform-server": "^21.0.0",
"@angular/ssr": "^21.0.0",
"@ngx-translate/core": "^17.0.0",
"@sitecore-content-sdk/analytics-core": "^2.1.0",
"@sitecore-content-sdk/events": "^2.1.0",
"@sitecore-content-sdk/personalize": "^2.1.0",
"zone.js": "^0.15.0"
},
"peerDependenciesMeta": {
Expand All @@ -63,8 +66,11 @@
},
"dependencies": {
"@ngx-translate/core": "^17.0.0",
"@sitecore-content-sdk/analytics-core": "^2.1.0",
"@sitecore-content-sdk/content": "^2.1.0",
"@sitecore-content-sdk/core": "^2.1.0",
"@sitecore-content-sdk/events": "^2.1.0",
"@sitecore-content-sdk/personalize": "^2.1.0",
"tslib": "^2.3.0",
"unstorage": "^1.17.5"
},
Expand Down Expand Up @@ -101,6 +107,10 @@
"types": "./dist/types/sitecore-content-sdk-angular.d.ts",
"default": "./dist/fesm2022/sitecore-content-sdk-angular.mjs"
},
"./config": {
"types": "./dist/types/sitecore-content-sdk-angular-config.d.ts",
"default": "./dist/fesm2022/sitecore-content-sdk-angular-config.mjs"
},
"./config-cli": {
"types": "./dist-node/server/config-cli/index.d.ts",
"default": "./dist-node/server/config-cli/index.js"
Expand Down
33 changes: 24 additions & 9 deletions packages/angular/src/components/sc-form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,31 @@ import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout';
import type { ComponentRendering } from '@sitecore-content-sdk/content/layout';
import { ScFormComponent } from './sc-form.component';
import { SITECORE_CONFIG_TOKEN } from '../lib/tokens';
import { SITECORE_ANALYTICS } from '../lib/analytics/sitecore-analytics';
import { provideMockSitecoreContext, setMockContextPage } from '../testing/mock-sitecore-context';

const mocks = vi.hoisted(() => ({
loadForm: vi.fn(),
executeScriptElements: vi.fn(),
subscribeToFormSubmitEvent: vi.fn(),
}));

vi.mock('@sitecore-content-sdk/content', () => {
return {
form: {
loadForm: (...args: unknown[]) => mocks.loadForm(...args),
executeScriptElements: mocks.executeScriptElements,
subscribeToFormSubmitEvent: mocks.subscribeToFormSubmitEvent,
},
};
});

describe('ScFormComponent', () => {
const mockAnalytics = {
pageView: vi.fn().mockResolvedValue(undefined),
event: vi.fn().mockResolvedValue(undefined),
identity: vi.fn().mockResolvedValue(undefined),
form: vi.fn().mockResolvedValue(undefined),
};

const testSitecoreConfig = {
api: {
edge: {
Expand Down Expand Up @@ -97,6 +103,7 @@ describe('ScFormComponent', () => {
...provideMockSitecoreContext(),
{ provide: PLATFORM_ID, useValue: 'browser' },
{ provide: SITECORE_CONFIG_TOKEN, useValue: testSitecoreConfig },
{ provide: SITECORE_ANALYTICS, useValue: mockAnalytics },
],
});
});
Expand Down Expand Up @@ -260,7 +267,7 @@ describe('ScFormComponent', () => {
expect(elArg.tagName).toBe('DIV');
});

it('should call subscribeToFormSubmitEvent when not in editing mode', async () => {
it('should dispatch form:engage events through the analytics façade when not in editing mode', async () => {
const fixture = createFixture();
setMockContextPage(makePage(false));

Expand All @@ -270,21 +277,29 @@ describe('ScFormComponent', () => {
);
await flushFormLoadPipeline(fixture);

expect(mocks.subscribeToFormSubmitEvent).toHaveBeenCalledTimes(1);
expect(mocks.subscribeToFormSubmitEvent).toHaveBeenCalledWith(
expect.any(HTMLElement),
'comp-uid-1'
const host = fixture.nativeElement.querySelector('div') as HTMLDivElement;
host.dispatchEvent(
new CustomEvent('form:engage', { detail: { formId: 'form-1', name: 'SUBMITTED' } })
);

// componentId is the rendering uid with dashes stripped.
expect(mockAnalytics.form).toHaveBeenCalledTimes(1);
expect(mockAnalytics.form).toHaveBeenCalledWith('form-1', 'SUBMITTED', 'compuid1');
});

it('should not call subscribeToFormSubmitEvent in editing mode', async () => {
it('should not dispatch form:engage events in editing mode', async () => {
const fixture = createFixture();
setMockContextPage(makePage(true));

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

expect(mocks.subscribeToFormSubmitEvent).not.toHaveBeenCalled();
const host = fixture.nativeElement.querySelector('div') as HTMLDivElement;
host.dispatchEvent(
new CustomEvent('form:engage', { detail: { formId: 'form-1', name: 'VIEWED' } })
);

expect(mockAnalytics.form).not.toHaveBeenCalled();
expect(mocks.executeScriptElements).toHaveBeenCalled();
});

Expand Down
34 changes: 32 additions & 2 deletions packages/angular/src/components/sc-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ import { isPlatformBrowser } from '@angular/common';
import { ComponentRendering } from '@sitecore-content-sdk/content/layout';
import { form } from '@sitecore-content-sdk/content';
import { SITECORE_CONFIG_TOKEN } from '../lib/tokens';
import { SITECORE_ANALYTICS } from '../lib/analytics/sitecore-analytics';
import { SitecoreContextService } from '../lib/sitecore-context.service';

const { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form;
const { executeScriptElements, loadForm } = form;

/* eslint-disable @typescript-eslint/member-ordering -- ViewChild + signal inputs + constructor ordering conflicts with default groups */
/**
Expand All @@ -37,6 +38,7 @@ export class ScFormComponent {

private readonly config = inject(SITECORE_CONFIG_TOKEN, { optional: true });
private readonly context = inject(SitecoreContextService);
private readonly analytics = inject(SITECORE_ANALYTICS, { optional: true });
private readonly platformId = inject(PLATFORM_ID);
private readonly destroyRef = inject(DestroyRef);

Expand Down Expand Up @@ -67,8 +69,10 @@ export class ScFormComponent {
}

let cancelled = false;
const abort = new AbortController();
this.destroyRef.onDestroy(() => {
cancelled = true;
abort.abort();
});

loadForm(edgeId, formId, edgeUrl)
Expand All @@ -81,7 +85,7 @@ export class ScFormComponent {

const isEditing = this.context.isEditing();
if (!isEditing) {
subscribeToFormSubmitEvent(el, this.rendering()?.uid);
this.subscribeToFormEvents(el, this.rendering()?.uid, abort.signal);
}

executeScriptElements(el);
Expand All @@ -94,6 +98,32 @@ export class ScFormComponent {
});
}

/**
* Listens for the form's `form:engage` events (VIEWED / SUBMITTED) and dispatches them through
* the {@link SITECORE_ANALYTICS} façade, which lazily initializes the events SDK on first use
* (a no-op on the server and when analytics is disabled). This is the analytics seam for
* Angular
* @param {HTMLElement} formElement - Container holding the rendered form markup.
* @param {string} [componentId] - Rendering uid used as the CDP component instance id.
* @param {AbortSignal} [signal] - Removes the listener when the component is destroyed.
*/
private subscribeToFormEvents(
Comment thread
art-alexeyenko marked this conversation as resolved.
Outdated
formElement: HTMLElement,
componentId?: string,
signal?: AbortSignal
): void {
formElement.addEventListener(
'form:engage',
((e: CustomEvent<{ formId: string; name: 'VIEWED' | 'SUBMITTED' }>) => {
const { formId, name } = e.detail;
if (formId && name) {
void this.analytics?.form(formId, name, componentId?.replace(/-/g, '') || '');
}
}) as EventListener,
{ signal }
);
}

readonly styles = () => {
const p = this.mergedFormParams();
const s = p.styles;
Expand Down
10 changes: 2 additions & 8 deletions packages/angular/src/config/define-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,7 @@ function getProcessEnv(): Record<string, string | undefined> {
* source of truth for the locale list.
* @public
*/
export interface AngularSitecoreConfigInput extends Omit<SitecoreConfigInput, 'redirects'> {
/**
* Settings for redirects functionality. `locales` is derived automatically from
* `angular.locales`; only `enabled` is configurable at this layer.
*/
redirects?: Omit<NonNullable<SitecoreConfigInput['redirects']>, 'locales'>;
export interface AngularSitecoreConfigInput extends SitecoreConfigInput {
/** Angular-specific configuration. */
angular?: {
/**
Expand Down Expand Up @@ -52,8 +47,7 @@ export interface AngularSitecoreConfigInput extends Omit<SitecoreConfigInput, 'r
* omitted at the type level — read the canonical locale list from `angular.locales`.
* @public
*/
export interface AngularSitecoreConfig extends Omit<SitecoreConfig, 'redirects'> {
redirects: Omit<SitecoreConfig['redirects'], 'locales'>;
export interface AngularSitecoreConfig extends SitecoreConfig {
angular: {
/** Resolved locales for the Angular app. Always contains at least `defaultLanguage`. */
locales: string[];
Expand Down
108 changes: 108 additions & 0 deletions packages/angular/src/lib/analytics/sitecore-analytics.browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Injectable, inject, isDevMode } from '@angular/core';
import { initContentSdk } from '@sitecore-content-sdk/core';
import { analyticsBrowserAdapter, analyticsPlugin } from '@sitecore-content-sdk/analytics-core';
import { event, eventsPlugin, form, identity, pageView } from '@sitecore-content-sdk/events';
import type { EventData, IdentityData, PageViewData } from '@sitecore-content-sdk/events';
import { SITECORE_CONFIG_TOKEN } from '../tokens';
import { normalizeCookieDomain, type SitecoreAnalyticsWrapper } from './sitecore-analytics';
import debug from '../../debug';

/** Dedup window for identical page-view fingerprints (hydration replay, transfer-state restore). */
const DEDUP_WINDOW_MS = 1000;

/**
* Browser {@link SitecoreAnalyticsWrapper} implementation. Lazily initializes the Content SDK
* events runtime on first dispatch — memoized so concurrent first callers await the same promise
* — then forwards to the `@sitecore-content-sdk/events` functions. Failures are swallowed at debug
* level: analytics must never break the app.
* @public
*/
@Injectable()
export class SitecoreAnalyticsBrowser implements SitecoreAnalyticsWrapper {
private readonly config = inject(SITECORE_CONFIG_TOKEN, { optional: true });
/** Memoized one-time init; resolves `false` when analytics is disabled (dev / missing Edge). */
private initPromise?: Promise<boolean>;
/** Last page-view fingerprint + timestamp, for same-payload dedup. */
private lastPageView?: { fingerprint: string; at: number };

pageView(data: PageViewData): Promise<void> {
const fingerprint = `${data.page ?? ''}|${data.pageVariantId ?? ''}|${data.language ?? ''}`;
const now = Date.now();
if (
this.lastPageView?.fingerprint === fingerprint &&
now - this.lastPageView.at < DEDUP_WINDOW_MS
) {
debug.common('analytics pageView deduped: %s', fingerprint);
return Promise.resolve();
}
this.lastPageView = { fingerprint, at: now };
return this.dispatch(() => pageView(data));
}

event(data: EventData): Promise<void> {
return this.dispatch(() => event(data));
}

identity(data: IdentityData): Promise<void> {
return this.dispatch(() => identity(data));
}

form(
formId: string,
interactionType: 'VIEWED' | 'SUBMITTED',
componentInstanceId: string
): Promise<void> {
return this.dispatch(() => form(formId, interactionType, componentInstanceId));
}

/**
* Ensure the SDK is initialized, then run `send`; swallow any failure at debug level.
* @param {() => Promise<unknown>} send - Events dispatch call to run once initialized.
*/
private async dispatch(send: () => Promise<unknown>): Promise<void> {
try {
const initialized = await this.ensureInit();
if (!initialized) return;
await send();
} catch (e) {
debug.common('analytics dispatch failed: %o', e);
}
}

private ensureInit(): Promise<boolean> {
if (!this.initPromise) {
this.initPromise = this.doInit();
}
return this.initPromise;
}

private async doInit(): Promise<boolean> {
if (isDevMode()) {
debug.common('Browser Events SDK is not initialized in development environment');
return false;
}
const edge = this.config?.api?.edge;
if (!edge?.clientContextId) {
debug.common('Client Edge API settings missing from configuration; analytics disabled');
return false;
}
await initContentSdk({
config: {
contextId: edge.clientContextId,
edgeUrl: edge.edgeUrl,
siteName: this.config?.defaultSite ?? '',
},
plugins: [
analyticsPlugin({
options: {
enableCookie: true,
cookieDomain: normalizeCookieDomain(window.location.hostname),
},
adapter: analyticsBrowserAdapter(),
}),
eventsPlugin(),
],
});
return true;
}
}
Loading
Loading