From acea493694ceac422fff006f6238ea922f84c5bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:10:20 +0000 Subject: [PATCH 1/7] Initial plan From b637e8df87b4a750bfe5e16e303cdf34141fe940 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:17:15 +0000 Subject: [PATCH 2/7] feat(atomic): migrate atomic-insight-result-template to Lit Co-authored-by: alexprudhomme <78121423+alexprudhomme@users.noreply.github.com> --- .../atomic-insight-result-template.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.ts diff --git a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.ts b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.ts new file mode 100644 index 00000000000..aea49e2e714 --- /dev/null +++ b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.ts @@ -0,0 +1,104 @@ +import type { + ResultTemplate as InsightResultTemplate, + ResultTemplateCondition as InsightResultTemplateCondition, +} from '@coveo/headless/insight'; +import {ResultTemplatesHelpers as InsightResultTemplatesHelpers} from '@coveo/headless/insight'; +import {html, LitElement, nothing} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {ResultTemplateController} from '@/src/components/common/result-templates/result-template-controller'; +import {makeMatchConditions} from '@/src/components/common/template-controller/template-utils'; +import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles.js'; +import {mapProperty} from '@/src/utils/props-utils'; +import '@/src/components/common/atomic-component-error/atomic-component-error'; +import {arrayConverter} from '@/src/converters/array-converter'; +import {errorGuard} from '@/src/decorators/error-guard'; +import type {LitElementWithError} from '@/src/decorators/types'; + +/** + * The `atomic-insight-result-template` component determines the format of the query results for Insight interfaces, depending on the conditions that are defined for each template. + * + * @slot default - The default slot where to insert the template element. + * @slot link - A `template` element that contains a single `atomic-result-link` component. + * @internal + */ +@customElement('atomic-insight-result-template') +@withTailwindStyles +export class AtomicInsightResultTemplate + extends LitElement + implements LitElementWithError +{ + private resultTemplateController: ResultTemplateController; + + @state() error!: Error; + + /** + * A function that must return true on results for the result template to apply. + * Set programmatically before initialization, not via attribute. + * + * For example, the following targets a template and sets a condition to make it apply only to results whose `title` contains `singapore`: + * `document.querySelector('#target-template').conditions = [(result) => /singapore/i.test(result.title)];` + */ + @property({attribute: false, type: Array, converter: arrayConverter}) + conditions: InsightResultTemplateCondition[] = []; + + /** + * The field and values that define which result items the condition must be applied to. + * + * For example, a template with the following attribute only applies to result items whose `filetype` is `lithiummessage` or `YouTubePlaylist`: `must-match-filetype="lithiummessage,YouTubePlaylist"` + * @type {Record} + * @default {} + */ + @mapProperty({splitValues: true, attributePrefix: 'must-match'}) + mustMatch!: Record; + + /** + * The field and values that define which result items the condition must not be applied to. + * + * For example, a template with the following attribute only applies to result items whose `filetype` is not `lithiummessage`: `must-not-match-filetype="lithiummessage"` + * @type {Record} + * @default {} + */ + @mapProperty({splitValues: true, attributePrefix: 'must-not-match'}) + mustNotMatch!: Record; + + constructor() { + super(); + const validParent = [ + 'atomic-insight-result-list', + 'atomic-insight-folded-result-list', + ]; + const allowEmpty = true; + this.resultTemplateController = new ResultTemplateController( + this, + validParent, + allowEmpty + ); + } + + connectedCallback() { + super.connectedCallback(); + this.resultTemplateController.matchConditions = makeMatchConditions( + this.mustMatch, + this.mustNotMatch, + InsightResultTemplatesHelpers + ); + } + + @errorGuard() + render() { + return html`${nothing}`; + } + + /** + * Gets the appropriate result template based on conditions applied. + */ + public async getTemplate(): Promise | null> { + return this.resultTemplateController?.getTemplate(this.conditions) || null; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-insight-result-template': AtomicInsightResultTemplate; + } +} From a1dc190e184656dbf1815c821142e3cb77c01753 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:22:46 +0000 Subject: [PATCH 3/7] test(atomic): add unit tests for atomic-insight-result-template Co-authored-by: alexprudhomme <78121423+alexprudhomme@users.noreply.github.com> --- packages/atomic/src/components.d.ts | 73 +-------- .../atomic-insight-result-template.spec.ts | 143 ++++++++++++++++++ .../atomic/src/utils/custom-element-tags.ts | 1 + 3 files changed, 146 insertions(+), 71 deletions(-) create mode 100644 packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.spec.ts diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 83074a36a0f..841f460da5d 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -6,7 +6,7 @@ */ import { HTMLStencilElement, JSXBase } from "@stencil/core/internal"; import { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; -import { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; +import { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, UserAction as IUserAction } from "@coveo/headless/insight"; import { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; import { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; import { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; @@ -20,7 +20,7 @@ import { AnyBindings } from "./components/common/interface/bindings"; import { i18n } from "i18next"; import { SearchBoxSuggestionElement } from "./components/common/suggestions/suggestions-types"; export { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, InlineLink, InteractiveCitation, RangeFacetSortCriterion, Result, ResultTemplate, ResultTemplateCondition } from "@coveo/headless"; -export { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, ResultTemplate as InsightResultTemplate, ResultTemplateCondition as InsightResultTemplateCondition, UserAction as IUserAction } from "@coveo/headless/insight"; +export { FacetSortCriterion as InsightFacetSortCriterion, FoldedResult as InsightFoldedResult, InteractiveResult as InsightInteractiveResult, RangeFacetRangeAlgorithm as InsightRangeFacetRangeAlgorithm, RangeFacetSortCriterion as InsightRangeFacetSortCriterion, Result as InsightResult, UserAction as IUserAction } from "@coveo/headless/insight"; export { ItemDisplayBasicLayout, ItemDisplayDensity, ItemDisplayImageSize, ItemDisplayLayout } from "./components/common/layout/display-options"; export { ItemRenderingFunction } from "./components/common/item-list/stencil-item-list-common"; export { NumberInputType } from "./components/common/facets/facet-number-input/number-input-type"; @@ -397,38 +397,6 @@ export namespace Components { */ "sandbox": string; } - interface AtomicInsightResultTemplate { - /** - * A function that must return true on results for the result template to apply. Set programmatically before initialization, not via attribute. For example, the following targets a template and sets a condition to make it apply only to results whose `title` contains `singapore`: `document.querySelector('#target-template').conditions = [(result) => /singapore/i.test(result.title)];` - */ - "conditions": InsightResultTemplateCondition[]; - /** - * Gets the appropriate result template based on conditions applied. - */ - "getTemplate": () => Promise | null>; - /** - * The field that, when defined on a result item, would allow the template to be applied. For example, a template with the following attribute only applies to result items whose `filetype` and `sourcetype` fields are defined: `if-defined="filetype,sourcetype"` - */ - "ifDefined"?: string; - /** - * The field that, when defined on a result item, would prevent the template from being applied. For example, a template with the following attribute only applies to result items whose `filetype` and `sourcetype` fields are NOT defined: `if-not-defined="filetype,sourcetype"` - */ - "ifNotDefined"?: string; - /** - * The field and values that define which result items the condition must be applied to. For example, a template with the following attribute only applies to result items whose `filetype` is `lithiummessage` or `YouTubePlaylist`: `must-match-filetype="lithiummessage,YouTubePlaylist"` - */ - "mustMatch": Record< - string, - string[] - >; - /** - * The field and values that define which result items the condition must not be applied to. For example, a template with the following attribute only applies to result items whose `filetype` is not `lithiummessage`: `must-not-match-filetype="lithiummessage"` - */ - "mustNotMatch": Record< - string, - string[] - >; - } interface AtomicInsightSearchBox { /** * Whether to prevent the user from triggering a search from the component. Perfect for use cases where you need to disable the search conditionally, like when the input is empty. @@ -1343,12 +1311,6 @@ declare global { prototype: HTMLAtomicInsightResultQuickviewActionElement; new (): HTMLAtomicInsightResultQuickviewActionElement; }; - interface HTMLAtomicInsightResultTemplateElement extends Components.AtomicInsightResultTemplate, HTMLStencilElement { - } - var HTMLAtomicInsightResultTemplateElement: { - prototype: HTMLAtomicInsightResultTemplateElement; - new (): HTMLAtomicInsightResultTemplateElement; - }; interface HTMLAtomicInsightSearchBoxElement extends Components.AtomicInsightSearchBox, HTMLStencilElement { } var HTMLAtomicInsightSearchBoxElement: { @@ -1789,7 +1751,6 @@ declare global { "atomic-insight-result-children-template": HTMLAtomicInsightResultChildrenTemplateElement; "atomic-insight-result-list": HTMLAtomicInsightResultListElement; "atomic-insight-result-quickview-action": HTMLAtomicInsightResultQuickviewActionElement; - "atomic-insight-result-template": HTMLAtomicInsightResultTemplateElement; "atomic-insight-search-box": HTMLAtomicInsightSearchBoxElement; "atomic-insight-smart-snippet": HTMLAtomicInsightSmartSnippetElement; "atomic-insight-smart-snippet-feedback-modal": HTMLAtomicInsightSmartSnippetFeedbackModalElement; @@ -2181,34 +2142,6 @@ declare namespace LocalJSX { */ "sandbox"?: string; } - interface AtomicInsightResultTemplate { - /** - * A function that must return true on results for the result template to apply. Set programmatically before initialization, not via attribute. For example, the following targets a template and sets a condition to make it apply only to results whose `title` contains `singapore`: `document.querySelector('#target-template').conditions = [(result) => /singapore/i.test(result.title)];` - */ - "conditions"?: InsightResultTemplateCondition[]; - /** - * The field that, when defined on a result item, would allow the template to be applied. For example, a template with the following attribute only applies to result items whose `filetype` and `sourcetype` fields are defined: `if-defined="filetype,sourcetype"` - */ - "ifDefined"?: string; - /** - * The field that, when defined on a result item, would prevent the template from being applied. For example, a template with the following attribute only applies to result items whose `filetype` and `sourcetype` fields are NOT defined: `if-not-defined="filetype,sourcetype"` - */ - "ifNotDefined"?: string; - /** - * The field and values that define which result items the condition must be applied to. For example, a template with the following attribute only applies to result items whose `filetype` is `lithiummessage` or `YouTubePlaylist`: `must-match-filetype="lithiummessage,YouTubePlaylist"` - */ - "mustMatch"?: Record< - string, - string[] - >; - /** - * The field and values that define which result items the condition must not be applied to. For example, a template with the following attribute only applies to result items whose `filetype` is not `lithiummessage`: `must-not-match-filetype="lithiummessage"` - */ - "mustNotMatch"?: Record< - string, - string[] - >; - } interface AtomicInsightSearchBox { /** * Whether to prevent the user from triggering a search from the component. Perfect for use cases where you need to disable the search conditionally, like when the input is empty. @@ -2878,7 +2811,6 @@ declare namespace LocalJSX { "atomic-insight-result-children-template": AtomicInsightResultChildrenTemplate; "atomic-insight-result-list": AtomicInsightResultList; "atomic-insight-result-quickview-action": AtomicInsightResultQuickviewAction; - "atomic-insight-result-template": AtomicInsightResultTemplate; "atomic-insight-search-box": AtomicInsightSearchBox; "atomic-insight-smart-snippet": AtomicInsightSmartSnippet; "atomic-insight-smart-snippet-feedback-modal": AtomicInsightSmartSnippetFeedbackModal; @@ -2949,7 +2881,6 @@ declare module "@stencil/core" { "atomic-insight-result-children-template": LocalJSX.AtomicInsightResultChildrenTemplate & JSXBase.HTMLAttributes; "atomic-insight-result-list": LocalJSX.AtomicInsightResultList & JSXBase.HTMLAttributes; "atomic-insight-result-quickview-action": LocalJSX.AtomicInsightResultQuickviewAction & JSXBase.HTMLAttributes; - "atomic-insight-result-template": LocalJSX.AtomicInsightResultTemplate & JSXBase.HTMLAttributes; "atomic-insight-search-box": LocalJSX.AtomicInsightSearchBox & JSXBase.HTMLAttributes; "atomic-insight-smart-snippet": LocalJSX.AtomicInsightSmartSnippet & JSXBase.HTMLAttributes; "atomic-insight-smart-snippet-feedback-modal": LocalJSX.AtomicInsightSmartSnippetFeedbackModal & JSXBase.HTMLAttributes; diff --git a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.spec.ts b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.spec.ts new file mode 100644 index 00000000000..7aa6a6f2560 --- /dev/null +++ b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.spec.ts @@ -0,0 +1,143 @@ +import type {Result as InsightResult} from '@coveo/headless/insight'; +import {ResultTemplatesHelpers as InsightResultTemplatesHelpers} from '@coveo/headless/insight'; +import {html} from 'lit'; +import {describe, expect, it, vi} from 'vitest'; +import {ResultTemplateController} from '@/src/components/common/result-templates/result-template-controller.js'; +import {makeMatchConditions} from '@/src/components/common/template-controller/template-utils'; +import {fixture} from '@/vitest-utils/testing-helpers/fixture'; +import {sanitizeHtml} from '@/vitest-utils/testing-helpers/testing-utils/sanitize-html'; +import {AtomicInsightResultTemplate} from './atomic-insight-result-template.js'; + +vi.mock('@coveo/headless/insight', {spy: true}); +vi.mock('@/src/components/common/template-controller/template-utils', { + spy: true, +}); + +describe('atomic-insight-result-template', () => { + type AtomicInsightResultTemplateProps = Pick< + AtomicInsightResultTemplate, + 'conditions' | 'mustMatch' | 'mustNotMatch' + >; + + const setupElement = async ( + options: Partial = {} + ) => { + const defaultProps: AtomicInsightResultTemplateProps = { + conditions: [], + mustMatch: {}, + mustNotMatch: {}, + }; + + const container = document.createElement('atomic-insight-result-list'); + const element = await fixture( + html` + + + + `, + container + ); + return element; + }; + + it('should instantiate without errors', async () => { + const element = await setupElement(); + expect(element).toBeInstanceOf(AtomicInsightResultTemplate); + }); + + it('should have default empty mustMatch, mustNotMatch, and conditions', async () => { + const element = await setupElement(); + expect(element.mustMatch).toEqual({}); + expect(element.mustNotMatch).toEqual({}); + expect(element.conditions).toEqual([]); + }); + + describe('when added to the DOM (#connectedCallback)', () => { + it('should call the #makeMatchConditions util function with the correct arguments', async () => { + const mockMakeMatchConditions = vi.mocked(makeMatchConditions); + await setupElement({ + mustMatch: {filetype: ['pdf']}, + mustNotMatch: {source: ['spam']}, + }); + expect(mockMakeMatchConditions).toHaveBeenCalledExactlyOnceWith( + {filetype: ['pdf']}, + {source: ['spam']}, + InsightResultTemplatesHelpers + ); + }); + + it('should set matchConditions on ResultTemplateController with correct value', async () => { + const mustMatch = {filetype: ['pdf']}; + const mustNotMatch = {source: ['spam']}; + const element = await setupElement({mustMatch, mustNotMatch}); + + const expected = makeMatchConditions( + mustMatch, + mustNotMatch, + InsightResultTemplatesHelpers + ); + + const template = await element.getTemplate(); + expect(template).not.toBeNull(); + expect(template!.conditions).toHaveLength(expected.length); + }); + }); + + describe('#getTemplate', () => { + it('should call getTemplate on the controller', async () => { + const titleConditions = (result: InsightResult) => + result.title === 'Coveo'; + const fakeTemplate = { + conditions: [], + content: document.createDocumentFragment(), + priority: 1, + }; + const spy = vi + .spyOn(ResultTemplateController.prototype, 'getTemplate') + .mockResolvedValue(fakeTemplate); + const element = await setupElement({conditions: [titleConditions]}); + const result = await element.getTemplate(); + + expect(spy).toHaveBeenCalledWith([titleConditions]); + expect(result).toBe(fakeTemplate); + spy.mockRestore(); + }); + }); + + it('should render nothing by default', async () => { + const element = await setupElement(); + expect(sanitizeHtml(element.shadowRoot!.innerHTML)).toBe(''); + }); + + it('should render an atomic-component-error if error is thrown', async () => { + const mockedConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const container = await fixture(html` +
+ + + +
+ `); + + const element = container.querySelector( + 'atomic-insight-result-template' + ) as AtomicInsightResultTemplate; + + await element.updateComplete; + + const errorComponent = element.shadowRoot?.querySelector( + 'atomic-component-error' + ); + expect(errorComponent).toBeDefined(); + mockedConsoleError.mockRestore(); + }); +}); diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 85485a08de3..a921f3a176a 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -59,6 +59,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-icon', 'atomic-insight-generate-answer-button', 'atomic-insight-interface', + 'atomic-insight-result-template', 'atomic-insight-tab', 'atomic-insight-tabs', 'atomic-ipx-tab', From 3b77ab93010302a90a336c74e9352fd5fab124b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Dec 2025 19:25:16 +0000 Subject: [PATCH 4/7] docs(atomic): add MDX documentation for atomic-insight-result-template Co-authored-by: alexprudhomme <78121423+alexprudhomme@users.noreply.github.com> --- .../atomic-insight-result-template.mdx | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.mdx diff --git a/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.mdx b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.mdx new file mode 100644 index 00000000000..89167f9606d --- /dev/null +++ b/packages/atomic/src/components/insight/result-templates/atomic-insight-result-template/atomic-insight-result-template.mdx @@ -0,0 +1,190 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import * as AtomicInsightInterfaceStories from '../../atomic-insight-interface/atomic-insight-interface.new.stories'; +import { AtomicDocTemplate } from '@/storybook-utils/documentation/atomic-doc-template'; + + + +# atomic-insight-result-template + +The `atomic-insight-result-template` component defines the UI display of your search results within an Insight interface. + +A `template` element must be the child of an `atomic-insight-result-template`. Furthermore, an `atomic-insight-result-list` or `atomic-insight-folded-result-list` must be the parent of each `atomic-insight-result-template`. + +**Note:** Any `