Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
54 changes: 34 additions & 20 deletions packages/angular/src/components/sc-form.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@ const makePage = (isEditing: boolean): Page =>
isDesignLibrary: false,
designLibrary: { isVariantGeneration: false },
},
}) as unknown as Page;
} as Page);

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). */
async function flushFormLoadPipeline(fixture: ComponentFixture<ScFormComponent>): Promise<void> {
Expand Down Expand Up @@ -108,7 +115,7 @@ 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));
Expand All @@ -118,7 +125,7 @@ describe('ScFormComponent', () => {

it('should not call loadForm when FormId is missing (JSS: FormId param)', 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));
Expand All @@ -128,7 +135,7 @@ describe('ScFormComponent', () => {

it('should call loadForm with edge context id, FormId, and edgeUrl from config (JSS: loadForm from Edge)', 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 @@ -146,7 +153,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 @@ -161,7 +168,7 @@ describe('ScFormComponent', () => {
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;
Expand All @@ -171,10 +178,13 @@ describe('ScFormComponent', () => {

it('should bind styles param as class with trailing whitespace trimmed (JSS: params.styles → className)', 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();

Expand All @@ -184,10 +194,13 @@ describe('ScFormComponent', () => {

it('should bind RenderingIdentifier as element id (JSS: RenderingIdentifier → 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();

Expand All @@ -200,7 +213,7 @@ describe('ScFormComponent', () => {
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);
Expand All @@ -213,8 +226,10 @@ describe('ScFormComponent', () => {
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 @@ -229,8 +244,7 @@ describe('ScFormComponent', () => {
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();
Expand All @@ -241,7 +255,7 @@ describe('ScFormComponent', () => {
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
13 changes: 8 additions & 5 deletions packages/angular/src/components/sc-form.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,14 @@ const { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form;
*/
@Component({
selector: 'sc-form',
standalone: true,
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 }>({});
Comment thread
art-alexeyenko marked this conversation as resolved.
Outdated
readonly params = input<{ [key: string]: string }>({});

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

readonly styles = () => {
const s = this.params()?.['styles'];
const p = { ...this.rendering()?.params, ...this.params() };
Comment thread
art-alexeyenko marked this conversation as resolved.
Outdated
const s = p?.['styles'];
return s ? s.replace(/\s+$/, '') : '';
};
readonly renderingId = () => this.params()?.['RenderingIdentifier'] || undefined;
readonly renderingId = () => {
const p = { ...this.rendering()?.params, ...this.params() };
return p['RenderingIdentifier'] || undefined;
};

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

const p = this.params();
const p = { ...this.rendering()?.params, ...this.params() };
const formId = p?.['FormId'];
if (!formId) return;

Expand Down
31 changes: 31 additions & 0 deletions packages/angular/src/field-directives/link-field-binding.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/* eslint-disable jsdoc/require-jsdoc */
import { describe, it, expect } from 'vitest';
import { buildHrefFromLinkField, resolveLinkFromField } from './link-field-binding';
import type { LinkField } from '@sitecore-content-sdk/content/layout';

describe('link-field-binding', () => {
describe('resolveLinkFromField', () => {
it('returns value from LinkField wrapper', () => {
const field: LinkField = { value: { href: '/x', text: 'X' } };
expect(resolveLinkFromField(field)).toEqual(field.value);
});

it('returns bare LinkFieldValue when href is set at root', () => {
const field = { href: '/y', text: 'Y' };
expect(resolveLinkFromField(field)).toBe(field);
});
});

describe('buildHrefFromLinkField', () => {
it('concatenates query and hash fragment', () => {
expect(
buildHrefFromLinkField({
href: '/p',
querystring: 'a=1',
anchor: 'sec',
linktype: 'internal',
}),
).toBe('/p?a=1#sec');
});
});
});
94 changes: 94 additions & 0 deletions packages/angular/src/field-directives/link-field-binding.ts
Comment thread
art-alexeyenko marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Renderer2 } from '@angular/core';
import { isFieldValueEmpty, LinkField, LinkFieldValue } from '@sitecore-content-sdk/content/layout';
import { getClassFromField } from './utils';

function addClassTokens(renderer: Renderer2, element: HTMLElement, classString: string): void {
for (const token of classString.trim().split(/\s+/).filter(Boolean)) {
renderer.addClass(element, token);
}
}

/**
* Normalizes a Sitecore link field input to a {@link LinkFieldValue}, or `undefined` when empty.
*/
export function resolveLinkFromField(
field: LinkField | LinkFieldValue | undefined | null
): LinkFieldValue | undefined {
if (!field || isFieldValueEmpty(field)) {
return undefined;
}
return (field as LinkFieldValue).href ? (field as LinkFieldValue) : (field as LinkField).value;
}

/**
* Builds the `href` string (path + query + hash fragment) from a link value.
*/
export function buildHrefFromLinkField(link: LinkFieldValue): string {
const anchor = link.linktype !== 'anchor' && link.anchor ? `#${link.anchor}` : '';
const querystring = link.querystring ? `?${link.querystring}` : '';
return `${link.href || ''}${querystring}${anchor}`;
}

export interface ApplyLinkFieldToAnchorOptions {
preferTextFromField: boolean;
originalClass?: string;
originalTitle?: string;
originalTarget?: string;
}

/**
* Applies Sitecore link attributes and optional text to a host anchor (shared by ScLink / ScRouterLink).
*/
export function applyLinkFieldToAnchor(
renderer: Renderer2,
element: HTMLAnchorElement,
link: LinkFieldValue,
options: ApplyLinkFieldToAnchorOptions
): void {
renderer.setAttribute(element, 'href', buildHrefFromLinkField(link));

const classValue = getClassFromField(link);
if (classValue) {
addClassTokens(renderer, element, classValue);
} else {
renderer.removeAttribute(element, 'class');
if (options.originalClass) {
addClassTokens(renderer, element, options.originalClass);
}
}

if (link.title) {
renderer.setAttribute(element, 'title', link.title);
} else {
renderer.removeAttribute(element, 'title');
if (options.originalTitle) {
renderer.setAttribute(element, 'title', options.originalTitle);
}
}
if (link.target) {
renderer.setAttribute(element, 'target', link.target);
if (link.target === '_blank' && !element.getAttribute('rel')) {
renderer.setAttribute(element, 'rel', 'noopener noreferrer');
Comment thread
art-alexeyenko marked this conversation as resolved.
Outdated
}
} else {
renderer.removeAttribute(element, 'target');
if (options.originalTarget) {
renderer.setAttribute(element, 'target', options.originalTarget);
}
}

const hasChildren = element.childNodes.length > 0 && element.textContent?.trim();
if (!hasChildren) {
const text = link.text || link.href || '';
renderer.setProperty(element, 'textContent', text);
} else if (options.preferTextFromField && link.text) {
Comment thread
art-alexeyenko marked this conversation as resolved.
Outdated
renderer.setProperty(element, 'textContent', link.text || '');
}
}

/**
* Clears link-driven attributes when the field is empty (matches ScLink behavior: drop `href` only).
*/
export function clearLinkHrefOnAnchor(renderer: Renderer2, element: HTMLAnchorElement): void {
renderer.removeAttribute(element, 'href');
Comment thread
art-alexeyenko marked this conversation as resolved.
Outdated
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { ScImageDirective, type ImageField } from './sc-image.directive';

@Component({
selector: 'test-img',
standalone: true,
imports: [ScImageDirective],
template: `<img [scImage]="field()" alt="" />`,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ function sortedClassTokens(el: HTMLElement): string[] {

@Component({
selector: 'test-link',
standalone: true,
imports: [ScLinkDirective],
template: `<a [scLink]="field()"></a>`,
})
Expand All @@ -25,7 +24,6 @@ class TestHostComponent {

@Component({
selector: 'test-link-host-class',
standalone: true,
imports: [ScLinkDirective],
template: `<a class="host-base" [scLink]="field()"></a>`,
})
Expand All @@ -35,7 +33,6 @@ class TestHostWithHostClassComponent {

@Component({
selector: 'test-link-host-title',
standalone: true,
imports: [ScLinkDirective],
template: `<a title="Host title" [scLink]="field()"></a>`,
})
Expand All @@ -45,7 +42,6 @@ class TestHostWithHostTitleComponent {

@Component({
selector: 'test-link-host-target',
standalone: true,
imports: [ScLinkDirective],
template: `<a target="_self" [scLink]="field()"></a>`,
})
Expand Down
Loading
Loading