From 7ef605d282bfc28aa207e1b5e7d4698f469b6f69 Mon Sep 17 00:00:00 2001 From: Jad Mazzah Date: Tue, 30 Sep 2025 14:58:05 -0400 Subject: [PATCH 01/35] changed file name https://coveord.atlassian.net/browse/KIT-4962 --- ...-common.spec.tsx => stencil-result-template-common.spec.tsx} | 2 +- ...t-template-common.tsx => stencil-result-template-common.tsx} | 0 .../atomic-insight-result-children-template.tsx | 2 +- .../atomic-insight-result-template.tsx | 2 +- .../atomic-recs-result-template/atomic-recs-result-template.tsx | 2 +- .../atomic-field-condition/atomic-field-condition.tsx | 2 +- .../atomic-result-children-template.tsx | 2 +- .../atomic-result-template/atomic-result-template.tsx | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename packages/atomic/src/components/common/result-templates/{result-template-common.spec.tsx => stencil-result-template-common.spec.tsx} (99%) rename packages/atomic/src/components/common/result-templates/{result-template-common.tsx => stencil-result-template-common.tsx} (100%) diff --git a/packages/atomic/src/components/common/result-templates/result-template-common.spec.tsx b/packages/atomic/src/components/common/result-templates/stencil-result-template-common.spec.tsx similarity index 99% rename from packages/atomic/src/components/common/result-templates/result-template-common.spec.tsx rename to packages/atomic/src/components/common/result-templates/stencil-result-template-common.spec.tsx index 269bf6073c8..9fae7163abd 100644 --- a/packages/atomic/src/components/common/result-templates/result-template-common.spec.tsx +++ b/packages/atomic/src/components/common/result-templates/stencil-result-template-common.spec.tsx @@ -2,7 +2,7 @@ import {ResultTemplatesHelpers} from '@coveo/headless'; import { makeMatchConditions, makeDefinedConditions, -} from './result-template-common'; +} from './stencil-result-template-common'; describe('result-template-common', () => { const testField = 'field'; diff --git a/packages/atomic/src/components/common/result-templates/result-template-common.tsx b/packages/atomic/src/components/common/result-templates/stencil-result-template-common.tsx similarity index 100% rename from packages/atomic/src/components/common/result-templates/result-template-common.tsx rename to packages/atomic/src/components/common/result-templates/stencil-result-template-common.tsx diff --git a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children-template/atomic-insight-result-children-template.tsx b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children-template/atomic-insight-result-children-template.tsx index 9d7df3f1536..18f974b2496 100644 --- a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children-template/atomic-insight-result-children-template.tsx +++ b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-children-template/atomic-insight-result-children-template.tsx @@ -5,7 +5,7 @@ import { makeDefinedConditions, makeMatchConditions, ResultTemplateCommon, -} from '../../../common/result-templates/result-template-common'; +} from '../../../common/result-templates/stencil-result-template-common'; /** * @internal diff --git a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.tsx b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.tsx index 3cec912e212..a21d8471644 100644 --- a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.tsx +++ b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.tsx @@ -8,7 +8,7 @@ import { makeDefinedConditions, makeMatchConditions, ResultTemplateCommon, -} from '../../../common/result-templates/result-template-common'; +} from '../../../common/result-templates/stencil-result-template-common'; /** * @internal diff --git a/packages/atomic/src/components/recommendations/atomic-recs-result-template/atomic-recs-result-template.tsx b/packages/atomic/src/components/recommendations/atomic-recs-result-template/atomic-recs-result-template.tsx index 7e73ac8ef3a..c24a006d662 100644 --- a/packages/atomic/src/components/recommendations/atomic-recs-result-template/atomic-recs-result-template.tsx +++ b/packages/atomic/src/components/recommendations/atomic-recs-result-template/atomic-recs-result-template.tsx @@ -8,7 +8,7 @@ import { makeDefinedConditions, makeMatchConditions, ResultTemplateCommon, -} from '../../common/result-templates/result-template-common'; +} from '../../common/result-templates/stencil-result-template-common'; /** * A [result template](https://docs.coveo.com/en/atomic/latest/usage/displaying-results#defining-a-result-template) determines the format of the query results, depending on the conditions that are defined for each template. diff --git a/packages/atomic/src/components/search/result-template-components/atomic-field-condition/atomic-field-condition.tsx b/packages/atomic/src/components/search/result-template-components/atomic-field-condition/atomic-field-condition.tsx index fb0801b067b..8152ed07910 100644 --- a/packages/atomic/src/components/search/result-template-components/atomic-field-condition/atomic-field-condition.tsx +++ b/packages/atomic/src/components/search/result-template-components/atomic-field-condition/atomic-field-condition.tsx @@ -5,7 +5,7 @@ import { } from '@coveo/headless'; import {Component, Prop, h, Element} from '@stencil/core'; import {MapProp} from '../../../../utils/props-utils'; -import {makeMatchConditions} from '../../../common/result-templates/result-template-common'; +import {makeMatchConditions} from '../../../common/result-templates/stencil-result-template-common'; import {ResultContext} from '../result-template-decorators'; /** diff --git a/packages/atomic/src/components/search/result-templates/atomic-result-children-template/atomic-result-children-template.tsx b/packages/atomic/src/components/search/result-templates/atomic-result-children-template/atomic-result-children-template.tsx index da1b81dabc0..40d53ce51b5 100644 --- a/packages/atomic/src/components/search/result-templates/atomic-result-children-template/atomic-result-children-template.tsx +++ b/packages/atomic/src/components/search/result-templates/atomic-result-children-template/atomic-result-children-template.tsx @@ -4,7 +4,7 @@ import {MapProp} from '../../../../utils/props-utils'; import { makeMatchConditions, ResultTemplateCommon, -} from '../../../common/result-templates/result-template-common'; +} from '../../../common/result-templates/stencil-result-template-common'; /** * The `atomic-result-children-template` component determines the format of the child results, depending on the conditions that are defined for each template. A `template` element must be the child of an `atomic-result-children-template`, and an `atomic-result-children` must be the parent of each `atomic-result-children-template`. diff --git a/packages/atomic/src/components/search/result-templates/atomic-result-template/atomic-result-template.tsx b/packages/atomic/src/components/search/result-templates/atomic-result-template/atomic-result-template.tsx index d88f1a3225f..95339c05c67 100644 --- a/packages/atomic/src/components/search/result-templates/atomic-result-template/atomic-result-template.tsx +++ b/packages/atomic/src/components/search/result-templates/atomic-result-template/atomic-result-template.tsx @@ -4,7 +4,7 @@ import {MapProp} from '../../../../utils/props-utils'; import { makeMatchConditions, ResultTemplateCommon, -} from '../../../common/result-templates/result-template-common'; +} from '../../../common/result-templates/stencil-result-template-common'; /** * A [result template](https://docs.coveo.com/en/atomic/latest/usage/displaying-results#defining-a-result-template) determines the format of the query results, depending on the conditions that are defined for each template. From 397b0c58e1cd9d726c6e6b746c2c0fcdc29ba9c2 Mon Sep 17 00:00:00 2001 From: Jad Mazzah Date: Tue, 30 Sep 2025 14:58:48 -0400 Subject: [PATCH 02/35] migrated result template to Reactive controller https://coveord.atlassian.net/browse/KIT-4962 --- .../result-template-common.spec.ts | 0 .../result-template-common.ts | 63 ++++++++ .../result-template-controller.ts | 149 ++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 packages/atomic/src/components/common/result-templates/result-template-common.spec.ts create mode 100644 packages/atomic/src/components/common/result-templates/result-template-common.ts create mode 100644 packages/atomic/src/components/common/result-templates/result-template-controller.ts diff --git a/packages/atomic/src/components/common/result-templates/result-template-common.spec.ts b/packages/atomic/src/components/common/result-templates/result-template-common.spec.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/atomic/src/components/common/result-templates/result-template-common.ts b/packages/atomic/src/components/common/result-templates/result-template-common.ts new file mode 100644 index 00000000000..9c850c854df --- /dev/null +++ b/packages/atomic/src/components/common/result-templates/result-template-common.ts @@ -0,0 +1,63 @@ +import { + type ResultTemplateCondition, + ResultTemplatesHelpers, +} from '@coveo/headless'; +import {isElementNode, isVisualNode} from '@/src/utils/utils'; +import {tableElementTagName} from '../../search/atomic-table-result/table-element-utils'; +import {isResultSectionNode} from '../layout/sections'; + +export type TemplateContent = DocumentFragment; + +type TemplateNodeType = + | 'section' + | 'metadata' + | 'table-column-definition' + | 'other'; + +export function getTemplateNodeType(node: Node): TemplateNodeType { + if (isResultSectionNode(node)) return 'section'; + if (!isVisualNode(node)) return 'metadata'; + if ( + isElementNode(node) && + node.tagName.toLowerCase() === tableElementTagName + ) { + return 'table-column-definition'; + } + return 'other'; +} + +export function makeMatchConditions( + mustMatch: Record, + mustNotMatch: Record +): ResultTemplateCondition[] { + const conditions: ResultTemplateCondition[] = []; + for (const field in mustMatch) { + conditions.push( + ResultTemplatesHelpers.fieldMustMatch(field, mustMatch[field]) + ); + } + + for (const field in mustNotMatch) { + conditions.push( + ResultTemplatesHelpers.fieldMustNotMatch(field, mustNotMatch[field]) + ); + } + return conditions; +} + +export function makeDefinedConditions( + ifDefined?: string, + ifNotDefined?: string +): ResultTemplateCondition[] { + const conditions: ResultTemplateCondition[] = []; + if (ifDefined) { + const fieldNames = ifDefined.split(','); + conditions.push(ResultTemplatesHelpers.fieldsMustBeDefined(fieldNames)); + } + + if (ifNotDefined) { + const fieldNames = ifNotDefined.split(','); + conditions.push(ResultTemplatesHelpers.fieldsMustNotBeDefined(fieldNames)); + } + return conditions; +} diff --git a/packages/atomic/src/components/common/result-templates/result-template-controller.ts b/packages/atomic/src/components/common/result-templates/result-template-controller.ts new file mode 100644 index 00000000000..e65d10e3ce3 --- /dev/null +++ b/packages/atomic/src/components/common/result-templates/result-template-controller.ts @@ -0,0 +1,149 @@ +import type {ResultTemplate, ResultTemplateCondition} from '@coveo/headless'; +import type {ReactiveController, ReactiveControllerHost} from 'lit'; +import {aggregate} from '../../../utils/utils'; +import type {ItemTarget} from '../layout/display-options'; +import {getTemplateNodeType} from './result-template-common'; + +export type TemplateContent = DocumentFragment; + +type ResultTemplateHost = ReactiveControllerHost & + HTMLElement & {error?: Error}; + +export class ResultTemplateController implements ReactiveController { + public matchConditions: ResultTemplateCondition[] = []; + private gridCellLinkTarget?: ItemTarget; + + private static readonly ERRORS = { + invalidParent: (tag: string, parents: string[]) => + `The "${tag}" component has to be the child of one of the following: ${parents + .map((p) => `"${p.toLowerCase()}"`) + .join(', ')}.`, + missingTemplate: (tag: string) => + `The "${tag}" component must contain a "template" element as a child.`, + emptyTemplate: (tag: string) => + `The "template" tag inside "${tag}" cannot be empty.`, + }; + + private static readonly WARNINGS = { + scriptTag: + 'Any "script" tags inside "template" elements are not supported and will not be executed when the results are rendered.', + sectionMix: + 'Result templates should only contain section OR non-section elements. Future updates could unpredictably affect this template.', + }; + + constructor( + private host: ResultTemplateHost, + private validParents: string[], + private allowEmpty: boolean = false + ) { + this.host.addController(this); + } + + hostConnected() { + this.validateTemplate(); + } + + private setError(error: Error) { + this.host.error = error; + } + + private validateTemplate() { + const tag = this.host.nodeName.toLowerCase(); + const parent = this.parentElement; + + const hasValidParent = this.validParents + .map((p) => p.toUpperCase()) + .includes(parent?.nodeName || ''); + if (!hasValidParent) { + this.setError( + new Error( + ResultTemplateController.ERRORS.invalidParent(tag, this.validParents) + ) + ); + return; + } + + if (this.parentAttr('display') === 'grid') { + this.gridCellLinkTarget = this.parentAttr( + 'grid-cell-link-target' + ) as ItemTarget; + } + + if (!this.template) { + this.setError( + new Error(ResultTemplateController.ERRORS.missingTemplate(tag)) + ); + return; + } + + if (!this.allowEmpty && !this.template.innerHTML.trim()) { + this.setError( + new Error(ResultTemplateController.ERRORS.emptyTemplate(tag)) + ); + return; + } + + if (this.template.content.querySelector('script')) { + console.warn(ResultTemplateController.WARNINGS.scriptTag, this.host); + } + + const {section, other} = groupNodesByType(this.template.content.childNodes); + if (section?.length && other?.length) { + console.warn(ResultTemplateController.WARNINGS.sectionMix, this.host, { + section, + other, + }); + } + } + + getTemplate( + conditions: ResultTemplateCondition[] + ): ResultTemplate | null { + if (this.host.error) { + return null; + } + return { + conditions: conditions.concat(this.matchConditions), + content: getTemplateElement(this.host).content!, + linkContent: this.getLinkTemplateElement(this.host).content!, + priority: 1, + }; + } + + getDefaultLinkTemplateElement() { + const linkTemplate = document.createElement('template'); + linkTemplate.innerHTML = `${ + this.gridCellLinkTarget + ? `` + : '' + }`; + return linkTemplate; + } + + getLinkTemplateElement(host: HTMLElement) { + return ( + host.querySelector('template[slot="link"]') ?? + this.getDefaultLinkTemplateElement() + ); + } + + private get parentElement() { + return this.host.parentElement; + } + + private get template() { + return this.host.querySelector('template'); + } + + private parentAttr(name: string) { + return this.parentElement?.attributes.getNamedItem(name)?.value; + } +} + +function getTemplateElement(host: HTMLElement) { + return host.querySelector('template:not([slot])')!; +} + +function groupNodesByType(nodes: NodeList) { + return aggregate(Array.from(nodes), (node) => getTemplateNodeType(node)); +} From 6faf0f1a4c9f1b00d4064f910138c075fa861b1c Mon Sep 17 00:00:00 2001 From: Jad Mazzah Date: Tue, 30 Sep 2025 15:59:58 -0400 Subject: [PATCH 03/35] unit tests for result templace common and controller https://coveord.atlassian.net/browse/KIT-4962 --- .../result-template-common.spec.ts | 102 +++++++++ .../result-template-controller.spec.ts | 206 ++++++++++++++++++ 2 files changed, 308 insertions(+) create mode 100644 packages/atomic/src/components/common/result-templates/result-template-controller.spec.ts diff --git a/packages/atomic/src/components/common/result-templates/result-template-common.spec.ts b/packages/atomic/src/components/common/result-templates/result-template-common.spec.ts index e69de29bb2d..8cf299c5613 100644 --- a/packages/atomic/src/components/common/result-templates/result-template-common.spec.ts +++ b/packages/atomic/src/components/common/result-templates/result-template-common.spec.ts @@ -0,0 +1,102 @@ +import {describe, expect, it, vi} from 'vitest'; +import {isElementNode, isVisualNode} from '@/src/utils/utils'; +import {isResultSectionNode} from '../layout/sections'; +import { + getTemplateNodeType, + makeDefinedConditions, + makeMatchConditions, +} from './result-template-common'; + +vi.mock('../layout/sections', {spy: true}); +vi.mock('@/src/utils/utils', {spy: true}); +vi.mock('@coveo/headless'); + +describe('result-template-common', () => { + describe('makeMatchConditions', () => { + it('returns both mustMatch and mustNotMatch conditions when provided', () => { + const conditions = makeMatchConditions( + {field: ['value1']}, + {field: ['value2']} + ); + expect(conditions).toHaveLength(2); + expect(typeof conditions[0]).toBe('function'); + expect(typeof conditions[1]).toBe('function'); + }); + + it('returns only mustNotMatch conditions when mustMatch is empty', () => { + const conditions = makeMatchConditions({}, {field: ['nope']}); + expect(conditions).toHaveLength(1); + expect(typeof conditions[0]).toBe('function'); + }); + + it('returns only mustMatch conditions when mustNotMatch is empty', () => { + const conditions = makeMatchConditions({field: ['yep']}, {}); + expect(conditions).toHaveLength(1); + expect(typeof conditions[0]).toBe('function'); + }); + + it('returns empty array when both inputs are empty', () => { + const conditions = makeMatchConditions({}, {}); + expect(conditions).toEqual([]); + }); + }); + + describe('makeDefinedConditions', () => { + it('returns a condition for ifDefined', () => { + const conditions = makeDefinedConditions('foo,bar', undefined); + expect(conditions).toHaveLength(1); + expect(typeof conditions[0]).toBe('function'); + }); + + it('returns a condition for ifNotDefined', () => { + const conditions = makeDefinedConditions(undefined, 'baz'); + expect(conditions).toHaveLength(1); + expect(typeof conditions[0]).toBe('function'); + }); + + it('returns both conditions when both args provided', () => { + const conditions = makeDefinedConditions('foo', 'bar'); + expect(conditions).toHaveLength(2); + }); + + it('returns empty array when neither arg is provided', () => { + const conditions = makeDefinedConditions(); + expect(conditions).toEqual([]); + }); + }); + + describe('getTemplateNodeType', () => { + it('returns "section" when isResultSectionNode is true', () => { + const node: Node = document.createElement('div'); + vi.mocked(isResultSectionNode).mockReturnValueOnce(true); + + expect(getTemplateNodeType(node)).toBe('section'); + }); + + it('returns "metadata" when node is not visual', () => { + const node: Node = document.createElement('span'); + vi.mocked(isResultSectionNode).mockReturnValueOnce(false); + vi.mocked(isVisualNode).mockReturnValueOnce(false); + + expect(getTemplateNodeType(node)).toBe('metadata'); + }); + + it('returns "table-column-definition" when node matches tableElementTagName', () => { + const node: Node = document.createElement('atomic-table-element'); + vi.mocked(isResultSectionNode).mockReturnValueOnce(false); + vi.mocked(isVisualNode).mockReturnValueOnce(true); + vi.mocked(isElementNode).mockReturnValueOnce(true); + + expect(getTemplateNodeType(node)).toBe('table-column-definition'); + }); + + it('returns "other" for any other visual element', () => { + const node: Node = document.createElement('p'); + vi.mocked(isResultSectionNode).mockReturnValueOnce(false); + vi.mocked(isVisualNode).mockReturnValueOnce(true); + vi.mocked(isElementNode).mockReturnValueOnce(false); + + expect(getTemplateNodeType(node)).toBe('other'); + }); + }); +}); diff --git a/packages/atomic/src/components/common/result-templates/result-template-controller.spec.ts b/packages/atomic/src/components/common/result-templates/result-template-controller.spec.ts new file mode 100644 index 00000000000..54786ae3065 --- /dev/null +++ b/packages/atomic/src/components/common/result-templates/result-template-controller.spec.ts @@ -0,0 +1,206 @@ +import {html, LitElement, type TemplateResult} from 'lit'; +import {customElement, state} from 'lit/decorators.js'; +import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'; +import type {LitElementWithError} from '@/src/decorators/types'; +import {fixture} from '@/vitest-utils/testing-helpers/fixture'; +import {getTemplateNodeType} from './result-template-common'; +import {ResultTemplateController} from './result-template-controller'; + +vi.mock('./result-template-common', {spy: true}); + +@customElement('test-result-element') +class TestResultElement extends LitElement implements LitElementWithError { + @state() + public error!: Error; + controller = new ResultTemplateController(this, ['valid-parent'], false); +} + +@customElement('empty-test-result-element') +class EmptyTestResultElement extends LitElement implements LitElementWithError { + @state() + public error!: Error; + controller = new ResultTemplateController(this, ['valid-parent'], true); +} + +describe('ResultTemplateController', () => { + function buildTemplateHtml(html: string) { + const template = document.createElement('template'); + template.innerHTML = html; + return template; + } + + function fragmentToHTML(fragment: DocumentFragment) { + const div = document.createElement('div'); + div.appendChild(fragment.cloneNode(true)); + return div.innerHTML.trim(); + } + + async function setupElement( + template: TemplateResult<1>, + parentNode: HTMLElement = document.createElement('valid-parent') + ) { + await fixture(template, parentNode); + return document.querySelector('test-result-element')! as TestResultElement; + } + + describe('when the host has not a valid parent', () => { + it('should set an error', async () => { + const element = await setupElement( + html` + + `, + document.createElement('invalid-parent') + ); + + expect(element.error).toBeInstanceOf(Error); + expect(element.error.message).toContain('has to be the child'); + }); + }); + + describe('when the template is missing from the host', () => { + it('should set an error', async () => { + const element = await setupElement( + html`` + ); + + expect(element.error).toBeInstanceOf(Error); + expect(element.error.message).toContain('must contain a "template"'); + }); + }); + + describe('when the template is empty', () => { + it('should set an error if allowEmpty is false', async () => { + const element = await setupElement( + html` + + ` + ); + + expect(element.error).toBeInstanceOf(Error); + expect(element.error.message).toContain('cannot be empty'); + }); + + it('should not set an error if allowEmpty is true', async () => { + await setupElement( + html` + + ` + ); + + const element = document.querySelector( + 'empty-test-result-element' + ) as EmptyTestResultElement; + + expect(element.error).toBeUndefined(); + }); + }); + + describe('when the template contains script tags', () => { + it('should log a warning', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const element = await setupElement( + html` + + ` + ); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('script'), + element + ); + + warnSpy.mockRestore(); + }); + }); + + describe('when the template contains both section and other nodes', () => { + let warnSpy: MockInstance; + const getTemplateFirstNode = (template: HTMLTemplateElement) => + template.content.childNodes[0]; + + const localSetup = () => + setupElement( + html` + + ` + ); + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('should call #getTemplateNodeType with the appropriate sections', async () => { + const mockedGetTemplateNodeType = vi.mocked(getTemplateNodeType); + await localSetup(); + + const visualSectionTemplate = buildTemplateHtml( + 'section' + ); + const otherSectionTemplate = buildTemplateHtml('other'); + + expect(mockedGetTemplateNodeType).toHaveBeenCalledWith( + getTemplateFirstNode(visualSectionTemplate) + ); + + expect(mockedGetTemplateNodeType).toHaveBeenCalledWith( + getTemplateFirstNode(otherSectionTemplate) + ); + }); + + it('should log a warning', async () => { + await localSetup(); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('should only contain section'), + expect.any(TestResultElement), + expect.objectContaining({}) + ); + }); + }); + + describe('when the template is valid', () => { + let result: ReturnType; + + beforeEach(async () => { + const {controller} = await setupElement( + html` + + ` + ); + result = controller.getTemplate([])!; + }); + + it('getTemplate returns a non-null object', () => { + expect(result).not.toBeNull(); + }); + + it('getTemplate returns the correct conditions', () => { + expect(result).toHaveProperty('conditions', []); + }); + + it('getTemplate returns the correct content', () => { + const contentTemplate = buildTemplateHtml( + 'section' + ); + expect(result && fragmentToHTML(result.content!)).toBe( + fragmentToHTML(contentTemplate.content) + ); + }); + + it('getTemplate returns the correct linkContent', () => { + const linkTemplate = buildTemplateHtml( + '' + ); + expect(result).toHaveProperty('linkContent', linkTemplate.content); + }); + + it('getTemplate returns the correct priority', () => { + expect(result).toHaveProperty('priority', 1); + }); + }); +}); From 7547a67e2bdcda4496b4f497d4b28a7ae9bf9298 Mon Sep 17 00:00:00 2001 From: Jad Mazzah Date: Wed, 1 Oct 2025 15:54:17 -0400 Subject: [PATCH 04/35] naming https://coveord.atlassian.net/browse/KIT-4962 --- .../result-template-controller.spec.ts | 44 +++++++++---------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/atomic/src/components/common/result-templates/result-template-controller.spec.ts b/packages/atomic/src/components/common/result-templates/result-template-controller.spec.ts index 54786ae3065..3e51d0fbc29 100644 --- a/packages/atomic/src/components/common/result-templates/result-template-controller.spec.ts +++ b/packages/atomic/src/components/common/result-templates/result-template-controller.spec.ts @@ -8,15 +8,15 @@ import {ResultTemplateController} from './result-template-controller'; vi.mock('./result-template-common', {spy: true}); -@customElement('test-result-element') -class TestResultElement extends LitElement implements LitElementWithError { +@customElement('test-element') +class TestElement extends LitElement implements LitElementWithError { @state() public error!: Error; controller = new ResultTemplateController(this, ['valid-parent'], false); } -@customElement('empty-test-result-element') -class EmptyTestResultElement extends LitElement implements LitElementWithError { +@customElement('empty-test-element') +class EmptyTestElement extends LitElement implements LitElementWithError { @state() public error!: Error; controller = new ResultTemplateController(this, ['valid-parent'], true); @@ -40,15 +40,15 @@ describe('ResultTemplateController', () => { parentNode: HTMLElement = document.createElement('valid-parent') ) { await fixture(template, parentNode); - return document.querySelector('test-result-element')! as TestResultElement; + return document.querySelector('test-element')! as TestElement; } describe('when the host has not a valid parent', () => { it('should set an error', async () => { const element = await setupElement( - html` + html` - `, + `, document.createElement('invalid-parent') ); @@ -59,9 +59,7 @@ describe('ResultTemplateController', () => { describe('when the template is missing from the host', () => { it('should set an error', async () => { - const element = await setupElement( - html`` - ); + const element = await setupElement(html``); expect(element.error).toBeInstanceOf(Error); expect(element.error.message).toContain('must contain a "template"'); @@ -71,9 +69,9 @@ describe('ResultTemplateController', () => { describe('when the template is empty', () => { it('should set an error if allowEmpty is false', async () => { const element = await setupElement( - html` + html` - ` + ` ); expect(element.error).toBeInstanceOf(Error); @@ -82,14 +80,14 @@ describe('ResultTemplateController', () => { it('should not set an error if allowEmpty is true', async () => { await setupElement( - html` + html` - ` + ` ); const element = document.querySelector( - 'empty-test-result-element' - ) as EmptyTestResultElement; + 'empty-test-element' + ) as EmptyTestElement; expect(element.error).toBeUndefined(); }); @@ -99,9 +97,9 @@ describe('ResultTemplateController', () => { it('should log a warning', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const element = await setupElement( - html` + html` - ` + ` ); expect(warnSpy).toHaveBeenCalledWith( @@ -120,12 +118,12 @@ describe('ResultTemplateController', () => { const localSetup = () => setupElement( - html` + html` - ` + ` ); beforeEach(() => { @@ -155,7 +153,7 @@ describe('ResultTemplateController', () => { expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('should only contain section'), - expect.any(TestResultElement), + expect.any(TestElement), expect.objectContaining({}) ); }); @@ -166,11 +164,11 @@ describe('ResultTemplateController', () => { beforeEach(async () => { const {controller} = await setupElement( - html` + html` - ` + ` ); result = controller.getTemplate([])!; }); From d3113c3d3747b825f74fad5f190e685f4e615be8 Mon Sep 17 00:00:00 2001 From: Jad Mazzah Date: Tue, 7 Oct 2025 11:44:12 -0400 Subject: [PATCH 05/35] moved table element utils to common package https://coveord.atlassian.net/browse/KIT-4962 --- .../src/components/common/item-list/stencil-display-table.tsx | 2 +- packages/atomic/src/components/common/item-list/table-layout.ts | 2 +- .../common/product-template/product-template-common.ts | 2 +- packages/atomic/src/components/common/table-element-utils.ts | 1 + 4 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 packages/atomic/src/components/common/table-element-utils.ts diff --git a/packages/atomic/src/components/common/item-list/stencil-display-table.tsx b/packages/atomic/src/components/common/item-list/stencil-display-table.tsx index 0ca8d3362e2..3f41f18fb2e 100644 --- a/packages/atomic/src/components/common/item-list/stencil-display-table.tsx +++ b/packages/atomic/src/components/common/item-list/stencil-display-table.tsx @@ -1,6 +1,6 @@ // The Lit equivalent of this file is table-layout.ts import {FunctionalComponent, VNode, h} from '@stencil/core'; -import {tableElementTagName} from '../../search/atomic-table-result/table-element-utils.js'; +import {tableElementTagName} from '../table-element-utils.js'; import {AnyItem} from './unfolded-item.js'; import {ItemRenderingFunction} from './stencil-item-list-common.js'; diff --git a/packages/atomic/src/components/common/item-list/table-layout.ts b/packages/atomic/src/components/common/item-list/table-layout.ts index 7e085d49a3c..b669c8a1ed6 100644 --- a/packages/atomic/src/components/common/item-list/table-layout.ts +++ b/packages/atomic/src/components/common/item-list/table-layout.ts @@ -7,8 +7,8 @@ import type { FunctionalComponent, FunctionalComponentWithChildren, } from '@/src/utils/functional-component-utils'; -import {tableElementTagName} from '../../search/atomic-table-result/table-element-utils'; import type {AnyItem} from '../item-list/unfolded-item'; +import {tableElementTagName} from '../table-element-utils'; interface TableColumnsProps { firstItem: AnyItem; diff --git a/packages/atomic/src/components/common/product-template/product-template-common.ts b/packages/atomic/src/components/common/product-template/product-template-common.ts index b7d0681a226..614ff76919e 100644 --- a/packages/atomic/src/components/common/product-template/product-template-common.ts +++ b/packages/atomic/src/components/common/product-template/product-template-common.ts @@ -4,8 +4,8 @@ import { } from '@coveo/headless/commerce'; import {intersection} from '../../../utils/set'; import {isElementNode, isVisualNode} from '../../../utils/utils'; -import {tableElementTagName} from '../../search/atomic-table-result/table-element-utils'; import {isResultSectionNode} from '../layout/sections'; +import {tableElementTagName} from '../table-element-utils'; type TemplateNodeType = | 'section' diff --git a/packages/atomic/src/components/common/table-element-utils.ts b/packages/atomic/src/components/common/table-element-utils.ts new file mode 100644 index 00000000000..61cf76d1863 --- /dev/null +++ b/packages/atomic/src/components/common/table-element-utils.ts @@ -0,0 +1 @@ +export const tableElementTagName = 'atomic-table-element'; From 0bd8252a2e1ae15898630f1ed0e72dd12d66a7ee Mon Sep 17 00:00:00 2001 From: Jad Mazzah Date: Tue, 7 Oct 2025 11:44:56 -0400 Subject: [PATCH 06/35] parent template controller for result and product templates https://coveord.atlassian.net/browse/KIT-4962 --- .../base-template-controller.spec.ts | 380 ++++++++++++++++++ .../base-template-controller.ts | 155 +++++++ 2 files changed, 535 insertions(+) create mode 100644 packages/atomic/src/components/common/template-controller/base-template-controller.spec.ts create mode 100644 packages/atomic/src/components/common/template-controller/base-template-controller.ts diff --git a/packages/atomic/src/components/common/template-controller/base-template-controller.spec.ts b/packages/atomic/src/components/common/template-controller/base-template-controller.spec.ts new file mode 100644 index 00000000000..9660479585a --- /dev/null +++ b/packages/atomic/src/components/common/template-controller/base-template-controller.spec.ts @@ -0,0 +1,380 @@ +import {html, LitElement, type TemplateResult} from 'lit'; +import {customElement, state} from 'lit/decorators.js'; +import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'; +import type {LitElementWithError} from '@/src/decorators/types'; +import {fixture} from '@/vitest-utils/testing-helpers/fixture'; +import {BaseTemplateController} from './base-template-controller'; + +vi.mock('./template-utils', {spy: true}); + +class TestTemplateController extends BaseTemplateController<() => boolean> { + protected getWarnings() { + return { + scriptTag: 'Test script warning', + sectionMix: 'Test section mix warning', + }; + } + + protected getDefaultLinkTemplateElement() { + const linkTemplate = document.createElement('template'); + linkTemplate.innerHTML = `${this.currentGridCellLinkTarget ? `` : ''}`; + return linkTemplate; + } + + public getBaseTemplateForTesting(conditions: (() => boolean)[]) { + return this.getBaseTemplate(conditions); + } +} + +@customElement('test-element') +class TestElement extends LitElement implements LitElementWithError { + @state() + public error!: Error; + controller = new TestTemplateController(this, ['valid-parent'], false); +} + +@customElement('empty-test-element') +class EmptyTestElement extends LitElement implements LitElementWithError { + @state() + public error!: Error; + controller = new TestTemplateController(this, ['valid-parent'], true); +} + +describe('BaseTemplateController', () => { + function buildTemplateHtml(html: string) { + const template = document.createElement('template'); + template.innerHTML = html; + return template; + } + + function fragmentToHTML(fragment: DocumentFragment) { + const div = document.createElement('div'); + div.appendChild(fragment.cloneNode(true)); + return div.innerHTML.trim(); + } + + async function setupElement( + template: TemplateResult<1>, + parentNode: HTMLElement = document.createElement('valid-parent') + ) { + await fixture(template, parentNode); + return document.querySelector('test-element')! as TestElement; + } + + describe('validation', () => { + describe('when the host has not a valid parent', () => { + it('should set an error', async () => { + const element = await setupElement( + html` + + `, + document.createElement('invalid-parent') + ); + + expect(element.error).toBeInstanceOf(Error); + expect(element.error.message).toContain('has to be the child'); + }); + }); + + describe('when the template is missing from the host', () => { + it('should set an error', async () => { + const element = await setupElement(html``); + + expect(element.error).toBeInstanceOf(Error); + expect(element.error.message).toContain('must contain a "template"'); + }); + }); + + describe('when the template is empty', () => { + it('should set an error if allowEmpty is false', async () => { + const element = await setupElement( + html` + + ` + ); + + expect(element.error).toBeInstanceOf(Error); + expect(element.error.message).toContain('cannot be empty'); + }); + + it('should not set an error if allowEmpty is true', async () => { + await setupElement( + html` + + ` + ); + + const element = document.querySelector( + 'empty-test-element' + ) as EmptyTestElement; + + expect(element.error).toBeUndefined(); + }); + }); + + describe('when the template contains script tags', () => { + it('should log a warning using child class message', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const element = await setupElement( + html` + + ` + ); + + expect(warnSpy).toHaveBeenCalledWith('Test script warning', element); + + warnSpy.mockRestore(); + }); + }); + + describe('when the template contains both section and other nodes', () => { + let warnSpy: MockInstance; + const getTemplateFirstNode = (template: HTMLTemplateElement) => + template.content.childNodes[0]; + + const localSetup = () => + setupElement( + html` + + ` + ); + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + it('should call getTemplateNodeType with the appropriate sections', async () => { + const {getTemplateNodeType} = await import('./template-utils'); + const mockedGetTemplateNodeType = vi.mocked(getTemplateNodeType); + + await localSetup(); + + const visualSectionTemplate = buildTemplateHtml( + 'section' + ); + const otherSectionTemplate = buildTemplateHtml('other'); + + expect(mockedGetTemplateNodeType).toHaveBeenCalledWith( + getTemplateFirstNode(visualSectionTemplate) + ); + + expect(mockedGetTemplateNodeType).toHaveBeenCalledWith( + getTemplateFirstNode(otherSectionTemplate) + ); + }); + + it('should log a warning using child class message', async () => { + await localSetup(); + + expect(warnSpy).toHaveBeenCalledWith( + 'Test section mix warning', + expect.any(TestElement), + expect.objectContaining({}) + ); + }); + }); + }); + + describe('template generation', () => { + describe('when the template is valid', () => { + let result: ReturnType< + TestTemplateController['getBaseTemplateForTesting'] + >; + + beforeEach(async () => { + const {controller} = await setupElement( + html` + + ` + ); + result = controller.getBaseTemplateForTesting([])!; + }); + + it('getBaseTemplate returns a non-null object', () => { + expect(result).not.toBeNull(); + }); + + it('getBaseTemplate returns the correct conditions', () => { + expect(result).toHaveProperty('conditions', []); + }); + + it('getBaseTemplate returns the correct content', () => { + const contentTemplate = buildTemplateHtml('
test content
'); + expect(result && fragmentToHTML(result.content!)).toBe( + fragmentToHTML(contentTemplate.content) + ); + }); + + it('getBaseTemplate returns the correct linkContent', () => { + const linkTemplate = buildTemplateHtml(''); + expect(result).toHaveProperty('linkContent', linkTemplate.content); + }); + + it('getBaseTemplate returns the correct priority', () => { + expect(result).toHaveProperty('priority', 1); + }); + }); + + describe('when there is an error', () => { + it('getBaseTemplate returns null', async () => { + const element = await setupElement(html``); + + const result = element.controller.getBaseTemplateForTesting([]); + expect(result).toBeNull(); + }); + }); + }); + + describe('link template handling', () => { + it('should use custom link template when provided', async () => { + const {controller} = await setupElement( + html` + + + ` + ); + + const linkElement = controller.getLinkTemplateElement(controller['host']); + expect(linkElement.innerHTML.trim()).toBe(''); + }); + + it('should use default link template when none provided', async () => { + const {controller} = await setupElement( + html` + + ` + ); + + const linkElement = controller.getLinkTemplateElement(controller['host']); + expect(linkElement.innerHTML).toBe(''); + }); + }); + + describe('grid cell link target functionality', () => { + it('should include grid cell link target in default link template when parent has grid display', async () => { + const parent = document.createElement('valid-parent'); + parent.setAttribute('display', 'grid'); + parent.setAttribute('grid-cell-link-target', '_blank'); + + const element = await setupElement( + html` + + `, + parent + ); + + const result = element.controller.getBaseTemplateForTesting([]); + const linkContent = fragmentToHTML(result!.linkContent!); + + expect(linkContent).toContain('target="_blank"'); + expect(linkContent).toContain(''); + }); + + it('should not include target attribute when no grid cell link target is set', async () => { + const parent = document.createElement('valid-parent'); + parent.setAttribute('display', 'grid'); + + const element = await setupElement( + html` + + `, + parent + ); + + const result = element.controller.getBaseTemplateForTesting([]); + const linkContent = fragmentToHTML(result!.linkContent!); + + expect(linkContent).not.toContain('target='); + expect(linkContent).toBe(''); + }); + + it('should not include target attribute for non-grid display', async () => { + const parent = document.createElement('valid-parent'); + parent.setAttribute('display', 'list'); + parent.setAttribute('grid-cell-link-target', '_blank'); + + const element = await setupElement( + html` + + `, + parent + ); + + const result = element.controller.getBaseTemplateForTesting([]); + const linkContent = fragmentToHTML(result!.linkContent!); + + expect(linkContent).not.toContain('target='); + expect(linkContent).toBe(''); + }); + }); + + describe('match conditions', () => { + it('should initialize with empty match conditions', async () => { + const {controller} = await setupElement( + html` + + ` + ); + + expect(controller.matchConditions).toEqual([]); + }); + + it('should merge match conditions with provided conditions in getBaseTemplate', async () => { + const {controller} = await setupElement( + html` + + ` + ); + + controller.matchConditions = [() => true, () => false]; + const providedConditions = [() => true]; + + const result = controller.getBaseTemplateForTesting(providedConditions); + + expect(result?.conditions).toHaveLength(3); + expect(result?.conditions).toEqual([ + ...providedConditions, + ...controller.matchConditions, + ]); + }); + }); + + describe('host lifecycle', () => { + it('should validate template during initialization', async () => { + const element = await setupElement( + html` + + ` + ); + + expect(element.error).toBeUndefined(); + + const result = element.controller.getBaseTemplateForTesting([]); + expect(result).not.toBeNull(); + }); + }); + + describe('error state management', () => { + it('should preserve error state across multiple calls', async () => { + const element = await setupElement(html``); + + const firstResult = element.controller.getBaseTemplateForTesting([]); + const secondResult = element.controller.getBaseTemplateForTesting([]); + + expect(firstResult).toBeNull(); + expect(secondResult).toBeNull(); + expect(element.error).toBeInstanceOf(Error); + }); + }); +}); diff --git a/packages/atomic/src/components/common/template-controller/base-template-controller.ts b/packages/atomic/src/components/common/template-controller/base-template-controller.ts new file mode 100644 index 00000000000..cedc7fe42f3 --- /dev/null +++ b/packages/atomic/src/components/common/template-controller/base-template-controller.ts @@ -0,0 +1,155 @@ +import type {ReactiveController, ReactiveControllerHost} from 'lit'; +import type {ItemTarget} from '@/src/components/common/layout/display-options'; +import {aggregate} from '@/src/utils/utils'; +import {getTemplateNodeType} from './template-utils'; + +export type TemplateContent = DocumentFragment; + +type BaseTemplateHost = ReactiveControllerHost & HTMLElement & {error?: Error}; + +interface Template { + conditions: TCondition[]; + content: TemplateContent; + linkContent?: TemplateContent; + priority: number; +} + +export abstract class BaseTemplateController + implements ReactiveController +{ + private static readonly ERRORS = { + invalidParent: (tagName: string, validParents: string[]) => + `The "${tagName}" component has to be the child of one of the following: ${validParents + .map((p) => `"${p.toLowerCase()}"`) + .join(', ')}.`, + missingTemplate: (tagName: string) => + `The "${tagName}" component must contain a "template" element as a child.`, + emptyTemplate: (tagName: string) => + `The "template" tag inside "${tagName}" cannot be empty.`, + }; + + private gridCellLinkTarget?: ItemTarget; + public matchConditions: TCondition[] = []; + + constructor( + protected host: BaseTemplateHost, + private validParents: string[], + private allowEmpty: boolean = false + ) { + this.host.addController(this); + } + + hostConnected() { + this.validateTemplate(); + } + + getLinkTemplateElement(host: HTMLElement) { + return ( + host.querySelector('template[slot="link"]') ?? + this.getDefaultLinkTemplateElement() + ); + } + + protected abstract getWarnings(): { + scriptTag: string; + sectionMix: string; + }; + + protected abstract getDefaultLinkTemplateElement(): HTMLTemplateElement; + + protected setError(error: Error) { + this.host.error = error; + } + + protected getBaseTemplate( + conditions: TCondition[] + ): Template | null { + if (this.host.error) { + return null; + } + return { + conditions: conditions.concat(this.matchConditions), + content: getTemplateElement(this.host).content!, + linkContent: this.getLinkTemplateElement(this.host).content!, + priority: 1, + }; + } + + protected get parentElement() { + return this.host.parentElement; + } + + protected get template() { + return this.host.querySelector('template'); + } + + protected parentAttr(attribute: string) { + return this.parentElement?.attributes.getNamedItem(attribute)?.value; + } + + protected get currentGridCellLinkTarget() { + return this.gridCellLinkTarget; + } + + private validateTemplate() { + const hasValidParent = this.validParents + .map((p) => p.toUpperCase()) + .includes(this.parentElement?.nodeName || ''); + const tagName = this.host.nodeName.toLowerCase(); + + if (!hasValidParent) { + this.setError( + new Error( + BaseTemplateController.ERRORS.invalidParent( + tagName, + this.validParents + ) + ) + ); + return; + } + + if (this.parentAttr('display') === 'grid') { + this.gridCellLinkTarget = this.parentAttr( + 'grid-cell-link-target' + ) as ItemTarget; + } + + if (!this.template) { + this.setError( + new Error(BaseTemplateController.ERRORS.missingTemplate(tagName)) + ); + return; + } + + if (!this.allowEmpty && !this.template.innerHTML.trim()) { + this.setError( + new Error(BaseTemplateController.ERRORS.emptyTemplate(tagName)) + ); + return; + } + + const warnings = this.getWarnings(); + + if (this.template.content.querySelector('script')) { + console.warn(warnings.scriptTag, this.host); + } + + const {section, other} = groupNodesByType(this.template.content.childNodes); + + if (section?.length && other?.length) { + console.warn(warnings.sectionMix, this.host, { + section, + other, + }); + } + } +} + +function getTemplateElement(host: HTMLElement) { + return host.querySelector('template:not([slot])')!; +} + +function groupNodesByType(nodes: NodeList) { + return aggregate(Array.from(nodes), (node) => getTemplateNodeType(node)); +} From 56e23a1e36ca28ef83cdcecf2ab967ce83cf5d22 Mon Sep 17 00:00:00 2001 From: Jad Mazzah Date: Tue, 7 Oct 2025 11:45:18 -0400 Subject: [PATCH 07/35] product template inheriting from base template https://coveord.atlassian.net/browse/KIT-4962 --- .../product-template-controller.spec.ts | 230 ++++-------------- .../product-template-controller.ts | 155 +++--------- 2 files changed, 80 insertions(+), 305 deletions(-) diff --git a/packages/atomic/src/components/common/product-template/product-template-controller.spec.ts b/packages/atomic/src/components/common/product-template/product-template-controller.spec.ts index 84dce84c9ab..c1f7311ddb4 100644 --- a/packages/atomic/src/components/common/product-template/product-template-controller.spec.ts +++ b/packages/atomic/src/components/common/product-template/product-template-controller.spec.ts @@ -1,204 +1,78 @@ import {html, LitElement, type TemplateResult} from 'lit'; import {customElement, state} from 'lit/decorators.js'; -import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'; +import {describe, expect, it} from 'vitest'; import type {LitElementWithError} from '@/src/decorators/types'; import {fixture} from '@/vitest-utils/testing-helpers/fixture'; -import {getTemplateNodeType} from './product-template-common'; import {ProductTemplateController} from './product-template-controller'; -vi.mock('./product-template-common', {spy: true}); - -@customElement('test-element') -class TestElement extends LitElement implements LitElementWithError { +@customElement('test-product-element') +class TestProductElement extends LitElement implements LitElementWithError { @state() public error!: Error; controller = new ProductTemplateController(this, ['valid-parent'], false); } -@customElement('empty-test-element') -class EmptyTestElement extends LitElement implements LitElementWithError { - @state() - public error!: Error; - controller = new ProductTemplateController(this, ['valid-parent'], true); +async function setupProductElement( + template: TemplateResult<1>, + parentNode: HTMLElement = document.createElement('valid-parent') +) { + await fixture(template, parentNode); + return document.querySelector('test-product-element')! as TestProductElement; } describe('ProductTemplateController', () => { - function buildTemplateHtml(html: string) { - const template = document.createElement('template'); - template.innerHTML = html; - return template; - } - - function fragmentToHTML(fragment: DocumentFragment) { - const div = document.createElement('div'); - div.appendChild(fragment.cloneNode(true)); - return div.innerHTML.trim(); - } - - async function setupElement( - template: TemplateResult<1>, - parentNode: HTMLElement = document.createElement('valid-parent') - ) { - await fixture(template, parentNode); - return document.querySelector('test-element')! as TestElement; - } - - describe('when the host has not a valid parent', () => { - it('should set an error', async () => { - const element = await setupElement( - html` - - `, - document.createElement('invalid-parent') - ); - - expect(element.error).toBeInstanceOf(Error); - expect(element.error.message).toContain('has to be the child'); - }); - }); - - describe('when the template is missing from the host', () => { - it('should set an error', async () => { - const element = await setupElement(html``); - - expect(element.error).toBeInstanceOf(Error); - expect(element.error.message).toContain('must contain a "template"'); - }); - }); - - describe('when the template is empty', () => { - it('should set an error if allowEmpty is false', async () => { - const element = await setupElement( - html` - - ` - ); - - expect(element.error).toBeInstanceOf(Error); - expect(element.error.message).toContain('cannot be empty'); - }); - - it('should not set an error if allowEmpty is true', async () => { - await setupElement( - html` - - ` - ); - - const element = document.querySelector( - 'empty-test-element' - ) as EmptyTestElement; - - expect(element.error).toBeUndefined(); - }); + it('should return a ProductTemplate with proper structure from getTemplate', async () => { + const {controller} = await setupProductElement( + html` + + ` + ); + const result = controller.getTemplate([]); + + expect(result).not.toBeNull(); + expect(result).toHaveProperty('conditions', []); + expect(result).toHaveProperty('content'); + expect(result).toHaveProperty('linkContent'); + expect(result).toHaveProperty('priority', 1); }); - describe('when the template contains script tags', () => { - it('should log a warning', async () => { - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const element = await setupElement( - html` - - ` - ); - - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('script'), - element - ); + it('should return null when there is an error', async () => { + const element = await setupProductElement( + html`` + ); + const result = element.controller.getTemplate([]); - warnSpy.mockRestore(); - }); + expect(result).toBeNull(); }); - describe('when the template contains both section and other nodes', () => { - let warnSpy: MockInstance; - const getTemplateFirstNode = (template: HTMLTemplateElement) => - template.content.childNodes[0]; - - const localSetup = () => - setupElement( - html` - - ` - ); - - beforeEach(() => { - warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - }); - - it('should call #getTemplateNodeType with the appropriate sections', async () => { - const mockedGetTemplateNodeType = vi.mocked(getTemplateNodeType); - await localSetup(); - - const visualSectionTemplate = buildTemplateHtml( - 'section' - ); - const otherSectionTemplate = buildTemplateHtml('other'); - - expect(mockedGetTemplateNodeType).toHaveBeenCalledWith( - getTemplateFirstNode(visualSectionTemplate) - ); - - expect(mockedGetTemplateNodeType).toHaveBeenCalledWith( - getTemplateFirstNode(otherSectionTemplate) - ); - }); - - it('should log a warning', async () => { - await localSetup(); - - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('should only contain section elements'), - expect.any(TestElement), - expect.objectContaining({}) - ); - }); + it('should get link template element', async () => { + const {controller} = await setupProductElement( + html` + + ` + ); + const linkElement = controller.getLinkTemplateElement(controller['host']); + + expect(linkElement).toBeInstanceOf(HTMLTemplateElement); + expect(linkElement.innerHTML).toBe( + '' + ); }); - describe('when the template is valid', () => { - let result: ReturnType; - - beforeEach(async () => { - const {controller} = await setupElement( - html` -