From b8a3eff24e24552f2523ddc91d5d094adced9c48 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:33:02 +0000 Subject: [PATCH 01/18] Initial plan From 152d20764e0637a62f8ba6acfcde7e22698d2aad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:44:49 +0000 Subject: [PATCH 02/18] feat(atomic): migrate atomic-smart-snippet to Lit (Step 1 - Component Migration) Co-authored-by: y-lakhdar <12199712+y-lakhdar@users.noreply.github.com> --- packages/atomic/src/components.d.ts | 131 ------ .../atomic-smart-snippet.ts | 382 ++++++++++++++++++ .../atomic/src/utils/custom-element-tags.ts | 1 + 3 files changed, 383 insertions(+), 131 deletions(-) create mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 83074a36a0f..901721b79c6 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -5,29 +5,23 @@ * It contains typing information for all components that exist in this project. */ 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 { 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"; import { InsightStore } from "./components/insight/atomic-insight-interface/store"; import { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; import { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; -import { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; import { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; import { RedirectionPayload } from "./components/common/search-box/redirection-payload"; 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 { 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"; export { InsightStore } from "./components/insight/atomic-insight-interface/store"; export { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; export { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; -export { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; export { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; export { RedirectionPayload } from "./components/common/search-box/redirection-payload"; export { AnyBindings } from "./components/common/interface/bindings"; @@ -907,49 +901,6 @@ export namespace Components { */ "suggestionTimeout": number; } - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface AtomicSmartSnippet { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight": number; - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - "headingLevel": number; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight": number; - "snippetCollapsedHeight"?: number; - "snippetMaximumHeight"?: number; - /** - * Sets the style of the snippet. Example: ```ts smartSnippet.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - "tabsExcluded": string[] | string; - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - "tabsIncluded": string[] | string; - } interface AtomicSmartSnippetAnswer { "htmlContent": string; "innerStyle"?: string; @@ -1618,27 +1569,6 @@ declare global { prototype: HTMLAtomicSearchBoxElement; new (): HTMLAtomicSearchBoxElement; }; - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface HTMLAtomicSmartSnippetElement extends Components.AtomicSmartSnippet, HTMLStencilElement { - } - var HTMLAtomicSmartSnippetElement: { - prototype: HTMLAtomicSmartSnippetElement; - new (): HTMLAtomicSmartSnippetElement; - }; interface HTMLAtomicSmartSnippetAnswerElementEventMap { "answerSizeUpdated": {height: number}; "selectInlineLink": InlineLink; @@ -1815,7 +1745,6 @@ declare global { "atomic-refine-modal": HTMLAtomicRefineModalElement; "atomic-result-placeholder": HTMLAtomicResultPlaceholderElement; "atomic-search-box": HTMLAtomicSearchBoxElement; - "atomic-smart-snippet": HTMLAtomicSmartSnippetElement; "atomic-smart-snippet-answer": HTMLAtomicSmartSnippetAnswerElement; "atomic-smart-snippet-feedback-modal": HTMLAtomicSmartSnippetFeedbackModalElement; "atomic-smart-snippet-source": HTMLAtomicSmartSnippetSourceElement; @@ -2666,49 +2595,6 @@ declare namespace LocalJSX { */ "suggestionTimeout"?: number; } - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface AtomicSmartSnippet { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight"?: number; - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - "headingLevel"?: number; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight"?: number; - "snippetCollapsedHeight"?: number; - "snippetMaximumHeight"?: number; - /** - * Sets the style of the snippet. Example: ```ts smartSnippet.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - "tabsExcluded"?: string[] | string; - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - "tabsIncluded"?: string[] | string; - } interface AtomicSmartSnippetAnswer { "htmlContent": string; "innerStyle"?: string; @@ -2904,7 +2790,6 @@ declare namespace LocalJSX { "atomic-refine-modal": AtomicRefineModal; "atomic-result-placeholder": AtomicResultPlaceholder; "atomic-search-box": AtomicSearchBox; - "atomic-smart-snippet": AtomicSmartSnippet; "atomic-smart-snippet-answer": AtomicSmartSnippetAnswer; "atomic-smart-snippet-feedback-modal": AtomicSmartSnippetFeedbackModal; "atomic-smart-snippet-source": AtomicSmartSnippetSource; @@ -3027,22 +2912,6 @@ declare module "@stencil/core" { * The `atomic-search-box` component creates a search box with built-in support for suggestions. */ "atomic-search-box": LocalJSX.AtomicSearchBox & JSXBase.HTMLAttributes; - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - "atomic-smart-snippet": LocalJSX.AtomicSmartSnippet & JSXBase.HTMLAttributes; "atomic-smart-snippet-answer": LocalJSX.AtomicSmartSnippetAnswer & JSXBase.HTMLAttributes; /** * The `atomic-smart-snippet-feedback-modal` is automatically created as a child of the `atomic-search-interface` when the `atomic-smart-snippet` is initialized. diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts new file mode 100644 index 00000000000..6656ffb281f --- /dev/null +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts @@ -0,0 +1,382 @@ +import { + buildSmartSnippet, + buildTabManager, + type InlineLink, + type SmartSnippet, + type SmartSnippetState, + type TabManager, + type TabManagerState, +} from '@coveo/headless'; +import {html, LitElement, nothing} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; +import {when} from 'lit/directives/when.js'; +import '@/src/components/common/atomic-icon/atomic-icon'; +import '@/src/components/common/atomic-smart-snippet-collapse-wrapper/atomic-smart-snippet-collapse-wrapper'; +import '@/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer'; +import {renderSnippetFooter} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-footer'; +import {renderSnippetQuestion} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-question'; +import {renderSnippetTruncatedAnswer} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer'; +import {renderSnippetWrapper} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-wrapper'; +import {renderSmartSnippetFeedbackBanner} from '@/src/components/common/smart-snippets/smart-snippet-feedback-banner'; +import '@/src/components/common/smart-snippets/atomic-smart-snippet-source'; +import {getAttributesFromLinkSlotContent} from '@/src/components/common/item-link/attributes-slot'; +import type {Bindings} from '@/src/components/search/atomic-search-interface/interfaces'; +import {arrayConverter} from '@/src/converters/array-converter'; +import {bindStateToController} from '@/src/decorators/bind-state'; +import {bindingGuard} from '@/src/decorators/binding-guard'; +import {bindings} from '@/src/decorators/bindings'; +import {errorGuard} from '@/src/decorators/error-guard'; +import type {InitializableComponent} from '@/src/decorators/types'; +import {shouldDisplayOnCurrentTab} from '@/src/utils/tab-utils'; +import {randomID} from '@/src/utils/utils'; + +/** + * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. + * + * You can style the snippet by inserting a template element as follows: + * ```html + * + * + * + * ``` + * + * @slot source-anchor-attributes - Lets you pass [attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes) down to anchor elements, overriding other attributes. + * To be used exclusively in anchor elements, such as: ``. + * + * @part smart-snippet - The wrapper of the entire smart snippet. + * @part question - The header displaying the question that is answered by the found document excerpt. + * @part answer - The container displaying the full document excerpt. + * @part truncated-answer - The container displaying only part of the answer. + * @part show-more-button - The show more button. + * @part show-less-button - The show less button. + * @part body - The body of the smart snippet, containing the truncated answer and the show more or show less button. + * @part footer - The footer underneath the answer. + * @part source-url - The URL to the document the excerpt is from. + * @part source-title - The title of the document the excerpt is from. + * @part feedback-banner - The feedback banner underneath the source. + * @part feedback-inquiry-and-buttons - A wrapper around the feedback inquiry and the feedback buttons. + * @part feedback-inquiry - The message asking the end user to provide feedback on whether the excerpt was useful. + * @part feedback-buttons - The wrapper around the buttons after the inquiry. + * @part feedback-like-button - The button allowing the end user to signal that the excerpt was useful. + * @part feedback-dislike-button - The button allowing the end user to signal that the excerpt wasn't useful. + * @part feedback-thank-you-container - The wrapper around the 'thank you' message and feedback button. + * @part feedback-thank-you - The message thanking the end user for providing feedback. + * @part feedback-explain-why-button - The button a user can press to provide detailed feedback. + */ +@customElement('atomic-smart-snippet') +@bindings() +export class AtomicSmartSnippet + extends LitElement + implements InitializableComponent +{ + @state() public bindings!: Bindings; + @state() public error!: Error; + + @bindStateToController('smartSnippet') + @state() + public smartSnippetState!: SmartSnippetState; + public smartSnippet!: SmartSnippet; + + @bindStateToController('tabManager') + @state() + public tabManagerState!: TabManagerState; + public tabManager!: TabManager; + + @state() private feedbackSent = false; + + private id!: string; + private modalRef?: HTMLAtomicSmartSnippetFeedbackModalElement; + + /** + * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. + */ + @property({type: Number, reflect: true, attribute: 'heading-level'}) + headingLevel = 0; + + /** + * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. + */ + @property({type: Number, reflect: true, attribute: 'maximum-height'}) + maximumHeight = 250; + + /** + * When the answer is partly hidden, how much of its height (in pixels) should be visible. + */ + @property({type: Number, reflect: true, attribute: 'collapsed-height'}) + collapsedHeight = 180; + + /** + * Sets the style of the snippet. + * + * Example: + * ```ts + * smartSnippet.snippetStyle = ` + * b { + * color: blue; + * } + * `; + * ``` + */ + @property({type: String, reflect: true, attribute: 'snippet-style'}) + snippetStyle?: string; + + /** + * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. + * + * Set this property as a stringified JSON array, for example: + * ```html + * + * ``` + * If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. + */ + @property({ + type: Array, + attribute: 'tabs-included', + converter: arrayConverter, + }) + tabsIncluded: string[] = []; + + /** + * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. + * + * Set this property as a stringified JSON array, for example: + * ```html + * + * ``` + * If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. + */ + @property({ + type: Array, + attribute: 'tabs-excluded', + converter: arrayConverter, + }) + tabsExcluded: string[] = []; + + /** + * The maximum height (in pixels) for the snippet when using the collapse wrapper. + */ + @property({ + type: Number, + reflect: true, + attribute: 'snippet-maximum-height', + }) + snippetMaximumHeight?: number; + + /** + * The collapsed height (in pixels) for the snippet when using the collapse wrapper. + */ + @property({ + type: Number, + reflect: true, + attribute: 'snippet-collapsed-height', + }) + snippetCollapsedHeight?: number; + + connectedCallback(): void { + super.connectedCallback(); + this.id ||= randomID(); + this.addEventListener( + 'selectInlineLink', + this.onSelectInlineLink as EventListener + ); + this.addEventListener( + 'beginDelayedSelectInlineLink', + this.onBeginDelayedSelectInlineLink as EventListener + ); + this.addEventListener( + 'cancelPendingSelectInlineLink', + this.onCancelPendingSelectInlineLink as EventListener + ); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.removeEventListener( + 'selectInlineLink', + this.onSelectInlineLink as EventListener + ); + this.removeEventListener( + 'beginDelayedSelectInlineLink', + this.onBeginDelayedSelectInlineLink as EventListener + ); + this.removeEventListener( + 'cancelPendingSelectInlineLink', + this.onCancelPendingSelectInlineLink as EventListener + ); + } + + public initialize() { + this.smartSnippet = buildSmartSnippet(this.bindings.engine); + this.tabManager = buildTabManager(this.bindings.engine); + } + + willUpdate() { + if (!(this.smartSnippetState.liked || this.smartSnippetState.disliked)) { + this.setFeedbackSent(false); + } + } + + private onSelectInlineLink(event: CustomEvent) { + this.smartSnippet.selectInlineLink(event.detail); + } + + private onBeginDelayedSelectInlineLink(event: CustomEvent) { + this.smartSnippet.beginDelayedSelectInlineLink(event.detail); + } + + private onCancelPendingSelectInlineLink(event: CustomEvent) { + this.smartSnippet.cancelPendingSelectInlineLink(event.detail); + } + + private setModalRef(ref: HTMLElement) { + this.modalRef = ref as HTMLAtomicSmartSnippetFeedbackModalElement; + } + + private setFeedbackSent(isSent: boolean) { + this.feedbackSent = isSent; + } + + private get style() { + const styleTag = + this.querySelector('template')?.content.querySelector('style'); + if (!styleTag) { + return this.snippetStyle; + } + return styleTag.innerHTML; + } + + private loadModal() { + if (this.modalRef) { + return; + } + const modalRef = document.createElement( + 'atomic-smart-snippet-feedback-modal' + ); + modalRef.addEventListener('feedbackSent', () => { + this.setFeedbackSent(true); + }); + this.setModalRef(modalRef); + this.insertAdjacentElement('beforebegin', modalRef); + } + + @errorGuard() + @bindingGuard() + protected render() { + if ( + !shouldDisplayOnCurrentTab( + this.tabsIncluded, + this.tabsExcluded, + this.tabManagerState?.activeTab + ) + ) { + return nothing; + } + + if (!this.smartSnippetState.answerFound) { + return nothing; + } + + const source = this.smartSnippetState.source; + + return renderSnippetWrapper({ + props: { + headingLevel: this.headingLevel, + i18n: this.bindings.i18n, + }, + })(html` + + ${renderSnippetQuestion({ + props: { + headingLevel: this.headingLevel, + question: this.smartSnippetState.question, + }, + })} + ${when( + this.snippetMaximumHeight !== undefined, + () => + renderSnippetTruncatedAnswer({ + props: { + answer: this.smartSnippetState.answer, + style: this.style, + }, + }), + () => html` + this.smartSnippet.collapse()} + @expand=${() => this.smartSnippet.expand()} + part="body" + .snippetStyle=${this.style} + > + ` + )} + ${renderSnippetFooter({ + props: {i18n: this.bindings.i18n}, + })(html` + ${when( + source, + () => html` + + ` + )} + ${renderSmartSnippetFeedbackBanner({ + props: { + disliked: this.smartSnippetState.disliked, + explainWhyRef: (button?: Element | HTMLButtonElement) => { + if (this.modalRef && button) { + this.modalRef.source = button as HTMLButtonElement; + } + }, + feedbackSent: this.feedbackSent, + id: this.id, + i18n: this.bindings.i18n, + liked: this.smartSnippetState.liked, + onDislike: () => { + this.loadModal(); + this.smartSnippet.dislike(); + }, + onLike: () => this.smartSnippet.like(), + onPressExplainWhy: () => { + if (this.modalRef) { + this.modalRef.isOpen = true; + } + }, + }, + })} + `)} + + `); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-smart-snippet': AtomicSmartSnippet; + } +} diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 85485a08de3..e65453a983f 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -139,6 +139,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-search-layout', 'atomic-segmented-facet', 'atomic-segmented-facet-scrollable', + 'atomic-smart-snippet', 'atomic-smart-snippet-collapse-wrapper', 'atomic-smart-snippet-expandable-answer', 'atomic-sort-dropdown', From 652591965fe74bf5fb8a0d5bc4638731ba66c8e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:48:25 +0000 Subject: [PATCH 03/18] feat(atomic): add unit tests for atomic-smart-snippet (Step 2 - partial) Co-authored-by: y-lakhdar <12199712+y-lakhdar@users.noreply.github.com> --- .../atomic-smart-snippet.spec.ts | 392 ++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts new file mode 100644 index 00000000000..9531fb7b7c0 --- /dev/null +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts @@ -0,0 +1,392 @@ +import { + buildSmartSnippet, + buildTabManager, + type InlineLink, + type SmartSnippet, + type TabManager, +} from '@coveo/headless'; +import {html} from 'lit'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {page} from 'vitest/browser'; +import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/search/atomic-search-interface-fixture'; +import {buildFakeSmartSnippet} from '@/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller'; +import {buildFakeTabManager} from '@/vitest-utils/testing-helpers/fixtures/headless/search/tab-manager-controller'; +import type {AtomicSmartSnippet} from './atomic-smart-snippet'; +import './atomic-smart-snippet'; + +vi.mock('@coveo/headless', {spy: true}); + +describe('atomic-smart-snippet', () => { + let mockedSmartSnippet: SmartSnippet; + let mockedTabManager: TabManager; + + beforeEach(() => { + mockedSmartSnippet = buildFakeSmartSnippet({ + state: { + answerFound: true, + question: 'What is the meaning of life?', + answer: '

The answer is 42.

', + source: { + title: "The Hitchhiker's Guide to the Galaxy", + clickUri: 'https://example.com/guide', + uniqueId: 'guide-123', + raw: {}, + } as unknown as NonNullable< + ReturnType['state']['source'] + >, + liked: false, + disliked: false, + expanded: false, + }, + }); + mockedTabManager = buildFakeTabManager({}); + }); + + const renderAtomicSmartSnippet = async ({ + props = {}, + }: { + props?: Partial<{ + headingLevel: number; + maximumHeight: number; + collapsedHeight: number; + snippetStyle: string; + tabsIncluded: string[]; + tabsExcluded: string[]; + snippetMaximumHeight: number; + snippetCollapsedHeight: number; + }>; + } = {}) => { + vi.mocked(buildSmartSnippet).mockReturnValue(mockedSmartSnippet); + vi.mocked(buildTabManager).mockReturnValue(mockedTabManager); + + const {element} = await renderInAtomicSearchInterface({ + template: html``, + selector: 'atomic-smart-snippet', + }); + + const parts = (el: AtomicSmartSnippet) => ({ + smartSnippet: el.shadowRoot?.querySelector('[part~="smart-snippet"]'), + question: el.shadowRoot?.querySelector('[part~="question"]'), + answer: el.shadowRoot?.querySelector('[part~="answer"]'), + truncatedAnswer: el.shadowRoot?.querySelector( + '[part~="truncated-answer"]' + ), + body: el.shadowRoot?.querySelector('[part~="body"]'), + footer: el.shadowRoot?.querySelector('[part~="footer"]'), + feedbackBanner: el.shadowRoot?.querySelector('[part~="feedback-banner"]'), + feedbackLikeButton: el.shadowRoot?.querySelector( + '[part~="feedback-like-button"]' + ), + feedbackDislikeButton: el.shadowRoot?.querySelector( + '[part~="feedback-dislike-button"]' + ), + }); + + return {element, parts}; + }; + + describe('when controller is initialized', () => { + it('should call buildSmartSnippet with the engine', async () => { + const {element} = await renderAtomicSmartSnippet(); + expect(buildSmartSnippet).toHaveBeenCalledWith(element.bindings.engine); + }); + + it('should call buildTabManager with the engine', async () => { + const {element} = await renderAtomicSmartSnippet(); + expect(buildTabManager).toHaveBeenCalledWith(element.bindings.engine); + }); + }); + + describe('when answer is found', () => { + beforeEach(() => { + mockedSmartSnippet.state.answerFound = true; + }); + + it('should render the smart snippet', async () => { + const {element, parts} = await renderAtomicSmartSnippet(); + await expect.element(parts(element).smartSnippet!).toBeInTheDocument(); + }); + + it('should render the question', async () => { + const {element, parts} = await renderAtomicSmartSnippet(); + const question = parts(element).question!; + await expect.element(question).toBeInTheDocument(); + expect(question.textContent?.trim()).toBe( + mockedSmartSnippet.state.question + ); + }); + + it('should render the expandable answer when snippetMaximumHeight is undefined', async () => { + const {element} = await renderAtomicSmartSnippet({ + props: {snippetMaximumHeight: undefined}, + }); + const expandableAnswer = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-expandable-answer' + ); + await expect.element(expandableAnswer!).toBeInTheDocument(); + }); + + it('should render the truncated answer when snippetMaximumHeight is defined', async () => { + const {element, parts} = await renderAtomicSmartSnippet({ + props: {snippetMaximumHeight: 200}, + }); + await expect.element(parts(element).truncatedAnswer!).toBeInTheDocument(); + }); + + it('should render the footer', async () => { + const {element, parts} = await renderAtomicSmartSnippet(); + await expect.element(parts(element).footer!).toBeInTheDocument(); + }); + + it('should render the source when source is present', async () => { + const {element} = await renderAtomicSmartSnippet(); + const source = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-source' + ); + await expect.element(source!).toBeInTheDocument(); + }); + + it('should render the feedback banner', async () => { + const {element, parts} = await renderAtomicSmartSnippet(); + await expect.element(parts(element).feedbackBanner!).toBeInTheDocument(); + }); + }); + + describe('when answer is not found', () => { + beforeEach(() => { + mockedSmartSnippet.state.answerFound = false; + }); + + it('should not render the smart snippet', async () => { + const {element, parts} = await renderAtomicSmartSnippet(); + await expect + .element(parts(element).smartSnippet!) + .not.toBeInTheDocument(); + }); + }); + + describe('when source is not present', () => { + beforeEach(() => { + mockedSmartSnippet.state.source = null; + }); + + it('should not render the source', async () => { + const {element} = await renderAtomicSmartSnippet(); + const source = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-source' + ); + expect(source).toBeNull(); + }); + }); + + describe('tab filtering', () => { + describe('when tabsIncluded is set', () => { + it('should render when current tab is included', async () => { + mockedTabManager.state.activeTab = 'tab1'; + const {element, parts} = await renderAtomicSmartSnippet({ + props: {tabsIncluded: ['tab1', 'tab2']}, + }); + await expect.element(parts(element).smartSnippet!).toBeInTheDocument(); + }); + + it('should not render when current tab is not included', async () => { + mockedTabManager.state.activeTab = 'tab3'; + const {element, parts} = await renderAtomicSmartSnippet({ + props: {tabsIncluded: ['tab1', 'tab2']}, + }); + await expect + .element(parts(element).smartSnippet!) + .not.toBeInTheDocument(); + }); + }); + + describe('when tabsExcluded is set', () => { + it('should not render when current tab is excluded', async () => { + mockedTabManager.state.activeTab = 'tab1'; + const {element, parts} = await renderAtomicSmartSnippet({ + props: {tabsExcluded: ['tab1', 'tab2']}, + }); + await expect + .element(parts(element).smartSnippet!) + .not.toBeInTheDocument(); + }); + + it('should render when current tab is not excluded', async () => { + mockedTabManager.state.activeTab = 'tab3'; + const {element, parts} = await renderAtomicSmartSnippet({ + props: {tabsExcluded: ['tab1', 'tab2']}, + }); + await expect.element(parts(element).smartSnippet!).toBeInTheDocument(); + }); + }); + }); + + describe('feedback functionality', () => { + it('should call smartSnippet.like() when like button is clicked', async () => { + const likeSpy = vi.spyOn(mockedSmartSnippet, 'like'); + await renderAtomicSmartSnippet(); + await page.getByRole('radiogroup').getByText('yes').click(); + expect(likeSpy).toHaveBeenCalled(); + }); + + it('should call smartSnippet.dislike() when dislike button is clicked', async () => { + const dislikeSpy = vi.spyOn(mockedSmartSnippet, 'dislike'); + await renderAtomicSmartSnippet(); + await page.getByRole('radiogroup').getByText('no').click(); + expect(dislikeSpy).toHaveBeenCalled(); + }); + + it('should load modal when dislike button is clicked', async () => { + const {element} = await renderAtomicSmartSnippet(); + await page.getByRole('radiogroup').getByText('no').click(); + + await vi.waitFor(() => { + const modal = element + .getRootNode() + .querySelector('atomic-smart-snippet-feedback-modal'); + expect(modal).not.toBeNull(); + }); + }); + }); + + describe('inline link events', () => { + it('should call smartSnippet.selectInlineLink() when selectInlineLink event is dispatched', async () => { + const selectInlineLinkSpy = vi.spyOn( + mockedSmartSnippet, + 'selectInlineLink' + ); + const {element} = await renderAtomicSmartSnippet(); + const inlineLink: InlineLink = { + linkText: 'test', + linkURL: 'https://example.com', + }; + element.dispatchEvent( + new CustomEvent('selectInlineLink', {detail: inlineLink}) + ); + expect(selectInlineLinkSpy).toHaveBeenCalledWith(inlineLink); + }); + + it('should call smartSnippet.beginDelayedSelectInlineLink() when beginDelayedSelectInlineLink event is dispatched', async () => { + const beginDelayedSelectInlineLinkSpy = vi.spyOn( + mockedSmartSnippet, + 'beginDelayedSelectInlineLink' + ); + const {element} = await renderAtomicSmartSnippet(); + const inlineLink: InlineLink = { + linkText: 'test', + linkURL: 'https://example.com', + }; + element.dispatchEvent( + new CustomEvent('beginDelayedSelectInlineLink', {detail: inlineLink}) + ); + expect(beginDelayedSelectInlineLinkSpy).toHaveBeenCalledWith(inlineLink); + }); + + it('should call smartSnippet.cancelPendingSelectInlineLink() when cancelPendingSelectInlineLink event is dispatched', async () => { + const cancelPendingSelectInlineLinkSpy = vi.spyOn( + mockedSmartSnippet, + 'cancelPendingSelectInlineLink' + ); + const {element} = await renderAtomicSmartSnippet(); + const inlineLink: InlineLink = { + linkText: 'test', + linkURL: 'https://example.com', + }; + element.dispatchEvent( + new CustomEvent('cancelPendingSelectInlineLink', {detail: inlineLink}) + ); + expect(cancelPendingSelectInlineLinkSpy).toHaveBeenCalledWith(inlineLink); + }); + }); + + describe('expandable answer integration', () => { + it('should call smartSnippet.expand() when expand event is dispatched', async () => { + const expandSpy = vi.spyOn(mockedSmartSnippet, 'expand'); + const {element} = await renderAtomicSmartSnippet(); + const expandableAnswer = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-expandable-answer' + ); + expandableAnswer?.dispatchEvent(new CustomEvent('expand')); + expect(expandSpy).toHaveBeenCalled(); + }); + + it('should call smartSnippet.collapse() when collapse event is dispatched', async () => { + const collapseSpy = vi.spyOn(mockedSmartSnippet, 'collapse'); + const {element} = await renderAtomicSmartSnippet(); + const expandableAnswer = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-expandable-answer' + ); + expandableAnswer?.dispatchEvent(new CustomEvent('collapse')); + expect(collapseSpy).toHaveBeenCalled(); + }); + }); + + describe('props', () => { + it('should pass headingLevel prop to question renderer', async () => { + const {element, parts} = await renderAtomicSmartSnippet({ + props: {headingLevel: 2}, + }); + expect(element.headingLevel).toBe(2); + await expect.element(parts(element).question!).toBeInTheDocument(); + }); + + it('should pass maximumHeight prop to expandable answer', async () => { + const {element} = await renderAtomicSmartSnippet({ + props: {maximumHeight: 300}, + }); + const expandableAnswer = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-expandable-answer' + ); + expect(element.maximumHeight).toBe(300); + await expect.element(expandableAnswer!).toBeInTheDocument(); + }); + + it('should pass collapsedHeight prop to expandable answer', async () => { + const {element} = await renderAtomicSmartSnippet({ + props: {collapsedHeight: 150}, + }); + const expandableAnswer = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-expandable-answer' + ); + expect(element.collapsedHeight).toBe(150); + await expect.element(expandableAnswer!).toBeInTheDocument(); + }); + + it('should accept snippetStyle prop', async () => { + const customStyle = 'b { color: blue; }'; + const {element} = await renderAtomicSmartSnippet({ + props: {snippetStyle: customStyle}, + }); + expect(element.snippetStyle).toBe(customStyle); + }); + }); + + describe('event listener cleanup', () => { + it('should remove event listeners when disconnected', async () => { + const {element} = await renderAtomicSmartSnippet(); + const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener'); + element.disconnectedCallback(); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'selectInlineLink', + expect.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'beginDelayedSelectInlineLink', + expect.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'cancelPendingSelectInlineLink', + expect.any(Function) + ); + }); + }); +}); From 76a848caf719e7b019ccd02a2a0f5d349e24e295 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:52:15 +0000 Subject: [PATCH 04/18] feat(atomic): add E2E tests for atomic-smart-snippet (Steps 3-4) Co-authored-by: y-lakhdar <12199712+y-lakhdar@users.noreply.github.com> --- .../e2e/atomic-smart-snippet.e2e.ts | 115 ++++++++++++++++ .../atomic-smart-snippet/e2e/fixture.ts | 14 ++ .../atomic-smart-snippet/e2e/page-object.ts | 127 ++++++++++++++++++ 3 files changed, 256 insertions(+) create mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts create mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/fixture.ts create mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/page-object.ts diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts new file mode 100644 index 00000000000..20dc9ac39af --- /dev/null +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts @@ -0,0 +1,115 @@ +import {expect, test} from './fixture'; + +test.describe('AtomicSmartSnippet', () => { + test.beforeEach(async ({smartSnippet}) => { + await smartSnippet.load(); + await smartSnippet.hydrated.waitFor(); + }); + + test.describe('when answer is found', () => { + test('should display the smart snippet', async ({smartSnippet}) => { + await expect(smartSnippet.smartSnippet).toBeVisible(); + }); + + test('should display the question', async ({smartSnippet}) => { + await expect(smartSnippet.question).toBeVisible(); + await expect(smartSnippet.question).toContainText(''); + }); + + test('should display the answer', async ({smartSnippet}) => { + await expect(smartSnippet.body).toBeVisible(); + }); + + test('should display the footer', async ({smartSnippet}) => { + await expect(smartSnippet.footer).toBeVisible(); + }); + + test('should display the source', async ({smartSnippet}) => { + await expect(smartSnippet.source).toBeVisible(); + await expect(smartSnippet.sourceUrl).toBeVisible(); + await expect(smartSnippet.sourceTitle).toBeVisible(); + }); + + test('should display the feedback banner', async ({smartSnippet}) => { + await expect(smartSnippet.feedbackBanner).toBeVisible(); + }); + + test('should display feedback inquiry', async ({smartSnippet}) => { + await expect(smartSnippet.feedbackInquiry).toBeVisible(); + }); + + test('should display feedback buttons', async ({smartSnippet}) => { + await expect(smartSnippet.feedbackLikeButton).toBeVisible(); + await expect(smartSnippet.feedbackDislikeButton).toBeVisible(); + }); + }); + + test.describe('feedback interaction', () => { + test('should show thank you message after clicking like button', async ({ + smartSnippet, + }) => { + await smartSnippet.clickLikeButton(); + await expect(smartSnippet.feedbackThankYou).toBeVisible(); + }); + + test('should show thank you message after clicking dislike button', async ({ + smartSnippet, + }) => { + await smartSnippet.clickDislikeButton(); + await expect(smartSnippet.feedbackThankYou).toBeVisible(); + }); + + test('should show explain why button after clicking dislike', async ({ + smartSnippet, + }) => { + await smartSnippet.clickDislikeButton(); + await expect(smartSnippet.feedbackExplainWhyButton).toBeVisible(); + }); + }); + + test.describe('accessibility', () => { + test('should have proper ARIA labels', async ({smartSnippet}) => { + const ariaLabel = await smartSnippet.smartSnippet.evaluate((el) => { + const aside = el.closest('aside'); + return aside?.getAttribute('aria-label'); + }); + expect(ariaLabel).toBeTruthy(); + }); + + test('should be keyboard navigable', async ({page}) => { + await page.keyboard.press('Tab'); + const focused = await page.evaluate( + () => document.activeElement?.tagName + ); + expect(focused).toBeDefined(); + }); + + test('should have accessible feedback buttons', async ({smartSnippet}) => { + const likeButtonRole = + await smartSnippet.feedbackLikeButton.getAttribute('role'); + const dislikeButtonRole = + await smartSnippet.feedbackDislikeButton.getAttribute('role'); + + expect(likeButtonRole || 'label').toBeTruthy(); + expect(dislikeButtonRole || 'label').toBeTruthy(); + }); + + test('should have accessible source links', async ({smartSnippet}) => { + const sourceUrlHref = await smartSnippet.sourceUrl.getAttribute('href'); + const sourceTitleHref = + await smartSnippet.sourceTitle.getAttribute('href'); + + expect(sourceUrlHref).toBeTruthy(); + expect(sourceTitleHref).toBeTruthy(); + }); + }); + + test.describe('inline links', () => { + test('should have clickable inline links in answer', async ({ + smartSnippet, + }) => { + const links = await smartSnippet.inlineLinks.count(); + expect(links).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/fixture.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/fixture.ts new file mode 100644 index 00000000000..140b25e2b80 --- /dev/null +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/fixture.ts @@ -0,0 +1,14 @@ +import {test as base} from '@playwright/test'; +import {SmartSnippetPageObject} from './page-object'; + +type MyFixtures = { + smartSnippet: SmartSnippetPageObject; +}; + +export const test = base.extend({ + smartSnippet: async ({page}, use) => { + await use(new SmartSnippetPageObject(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/page-object.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/page-object.ts new file mode 100644 index 00000000000..992d31b28e0 --- /dev/null +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/page-object.ts @@ -0,0 +1,127 @@ +import type {Page} from '@playwright/test'; +import {BasePageObject} from '@/playwright-utils/lit-base-page-object'; + +export class SmartSnippetPageObject extends BasePageObject { + constructor(page: Page) { + super(page, 'atomic-smart-snippet'); + } + + get smartSnippet() { + return this.hydrated; + } + + get question() { + return this.hydrated.locator('[part~="question"]'); + } + + get answer() { + return this.hydrated.locator('[part~="answer"]'); + } + + get truncatedAnswer() { + return this.hydrated.locator('[part~="truncated-answer"]'); + } + + get body() { + return this.hydrated.locator('[part~="body"]'); + } + + get footer() { + return this.hydrated.locator('[part~="footer"]'); + } + + get source() { + return this.hydrated.locator('atomic-smart-snippet-source'); + } + + get sourceUrl() { + return this.hydrated.locator('[part~="source-url"]'); + } + + get sourceTitle() { + return this.hydrated.locator('[part~="source-title"]'); + } + + get feedbackBanner() { + return this.hydrated.locator('[part~="feedback-banner"]'); + } + + get feedbackInquiry() { + return this.hydrated.locator('[part~="feedback-inquiry"]'); + } + + get feedbackLikeButton() { + return this.hydrated.locator('[part~="feedback-like-button"]'); + } + + get feedbackDislikeButton() { + return this.hydrated.locator('[part~="feedback-dislike-button"]'); + } + + get feedbackExplainWhyButton() { + return this.hydrated.locator('[part~="feedback-explain-why-button"]'); + } + + get feedbackThankYou() { + return this.hydrated.locator('[part~="feedback-thank-you"]'); + } + + get expandableAnswer() { + return this.hydrated.locator('atomic-smart-snippet-expandable-answer'); + } + + get showMoreButton() { + return this.hydrated.locator('[part~="show-more-button"]'); + } + + get showLessButton() { + return this.hydrated.locator('[part~="show-less-button"]'); + } + + get inlineLinks() { + return this.hydrated.locator('a'); + } + + async clickLikeButton() { + await this.feedbackLikeButton.click(); + } + + async clickDislikeButton() { + await this.feedbackDislikeButton.click(); + } + + async clickExplainWhyButton() { + await this.feedbackExplainWhyButton.click(); + } + + async clickShowMoreButton() { + await this.showMoreButton.click(); + } + + async clickShowLessButton() { + await this.showLessButton.click(); + } + + /** + * Wait for component to be stable before screenshot + */ + async waitForVisualStability() { + await this.hydrated.waitFor(); + await this.page.waitForTimeout(500); + await this.page.evaluate(() => document.fonts.ready); + await this.page.waitForLoadState('networkidle'); + } + + async captureScreenshot(options?: {animations?: 'disabled' | 'allow'}) { + await this.waitForVisualStability(); + + const element = await this.hydrated.elementHandle(); + if (!element) { + throw new Error('Component element not found'); + } + + return await element.screenshot({ + animations: options?.animations ?? 'disabled', + }); + } +} From 58d9a9dd736d10d1cae3db1e9b3ef3210ee4088a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:54:11 +0000 Subject: [PATCH 05/18] docs(atomic): add MDX documentation for atomic-smart-snippet (Step 6) Co-authored-by: y-lakhdar <12199712+y-lakhdar@users.noreply.github.com> --- .../atomic-smart-snippet.mdx | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.mdx diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.mdx b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.mdx new file mode 100644 index 00000000000..fd345f7c6e5 --- /dev/null +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.mdx @@ -0,0 +1,136 @@ +import { Meta } from '@storybook/addon-docs/blocks'; +import * as AtomicSmartSnippetStories from './atomic-smart-snippet.new.stories'; +import { AtomicDocTemplate } from '@/storybook-utils/documentation/atomic-doc-template'; + + + + + + +The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. It provides users with quick answers directly in the search results. + +This component is used within the `atomic-search-interface`: + +```html + + + + + + + + +``` + +## Features + +- **Question Display**: Shows the question that the snippet answers +- **Answer Excerpt**: Displays the relevant portion of the document with highlighted terms +- **Source Information**: Links to the source document (URL and title) +- **User Feedback**: Allows users to like or dislike the answer +- **Expandable Content**: Shows/hides full answer based on configured heights + +## Basic Usage + +To add a smart snippet to your interface: + +```html + +``` + +## Customization + +### Heading Level + +Set the heading level for the question (from 1 to 5): + +```html + +``` + +### Height Configuration + +Control when the "show more/less" buttons appear: + +```html + +``` + +- `maximum-height`: Maximum height (in pixels) before the component truncates and shows "show more" +- `collapsed-height`: How much of the answer's height (in pixels) is visible when collapsed + +### Custom Styling + +You can style the snippet content using a template element: + +```html + + + +``` + +Or use the `snippet-style` attribute: + +```html + +``` + +### Tab Filtering + +Display the smart snippet only on specific tabs: + +```html + + + + + +``` + +**Note**: Don't use both `tabs-included` and `tabs-excluded` at the same time. + +### Source Link Attributes + +Pass custom attributes to the source links using slots: + +```html + + + +``` + +## User Feedback + +The component includes a feedback mechanism that allows users to: +- Like the answer (helpful) +- Dislike the answer (not helpful) +- Provide detailed feedback when disliking (opens a modal) + +The feedback is automatically tracked via analytics. + +## Behavior + +- The component only appears when a smart snippet answer is found for the query +- Inline links in the answer are tracked when clicked +- The answer can be expanded/collapsed if it exceeds the configured height +- User feedback state is preserved within the same search session + + From 4207d6c294898e961ff42ffc083766723f7846f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:32:14 +0000 Subject: [PATCH 06/18] refactor(atomic): migrate smart-snippet to Tailwind styles and expand tests - Create tw.css.ts file with Tailwind styles - Add @withTailwindStyles decorator to component - Simplify E2E tests to single rendering test - Expand unit tests with comprehensive coverage from Cypress - Remove Cypress test file (smart-snippet.cypress.ts) - Fix TypeScript errors (use private field #id instead of private id) Co-authored-by: y-lakhdar <12199712+y-lakhdar@users.noreply.github.com> --- .../cypress/e2e/smart-snippet.cypress.ts | 412 ------------------ .../atomic-smart-snippet.spec.ts | 144 +++++- .../atomic-smart-snippet.ts | 13 +- .../atomic-smart-snippet.tw.css.ts | 22 + .../e2e/atomic-smart-snippet.e2e.ts | 110 +---- 5 files changed, 176 insertions(+), 525 deletions(-) delete mode 100644 packages/atomic/cypress/e2e/smart-snippet.cypress.ts create mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts diff --git a/packages/atomic/cypress/e2e/smart-snippet.cypress.ts b/packages/atomic/cypress/e2e/smart-snippet.cypress.ts deleted file mode 100644 index e2a4ecf745c..00000000000 --- a/packages/atomic/cypress/e2e/smart-snippet.cypress.ts +++ /dev/null @@ -1,412 +0,0 @@ -import {InlineLink} from '@coveo/headless'; -import {generateComponentHTML, TestFixture} from '../fixtures/test-fixture'; -import {AnalyticsTracker} from '../utils/analyticsUtils'; -import * as CommonAssertions from './common-assertions'; -import {addSearchBox} from './search-box/search-box-actions'; -import {SearchBoxSelectors} from './search-box/search-box-selectors'; -import { - addSmartSnippet, - addSmartSnippetDefaultOptions, - AddSmartSnippetMockSnippet, - defaultSnippets, -} from './smart-snippet-actions'; -import * as SmartSnippetAssertions from './smart-snippet-assertions'; -import { - smartSnippetComponent, - SmartSnippetSelectors, -} from './smart-snippet-selectors'; - -const {remSize, snippet: defaultSnippet} = addSmartSnippetDefaultOptions; - -const { - question: defaultQuestion, - sourceTitle: defaultSourceTitle, - sourceUrl: defaultSourceUrl, -} = defaultSnippet; - -function buildAnswerWithHeight(height: number) { - const heightWithoutMargins = height - remSize * 2; - return `
`; -} - -describe('Smart Snippet Test Suites', () => { - it('should work correctly with no heading level', () => { - new TestFixture().with(addSmartSnippet()).init(); - - cy.log('should fallback to a div for the accessibility heading'); - SmartSnippetSelectors.accessibilityHeading().should( - 'have.prop', - 'tagName', - 'DIV' - ); - cy.log('should fallback to a div for the question'); - SmartSnippetSelectors.question().should('have.prop', 'tagName', 'DIV'); - cy.log('render the correct question'); - SmartSnippetSelectors.question().should('have.text', defaultQuestion); - cy.log('should have links to the source'); - SmartSnippetSelectors.sourceUrl().should( - 'have.attr', - 'href', - defaultSourceUrl - ); - SmartSnippetSelectors.sourceUrl().should('have.text', defaultSourceUrl); - SmartSnippetSelectors.sourceTitle().should( - 'have.attr', - 'href', - defaultSourceUrl - ); - SmartSnippetSelectors.sourceTitle() - .find('atomic-result-text') - .find('atomic-text') - .shadow() - .should('contain.text', defaultSourceTitle); - SmartSnippetAssertions.assertLikeButtonChecked(false); - SmartSnippetAssertions.assertDislikeButtonChecked(false); - SmartSnippetAssertions.assertThankYouBanner(false); - }); - - it('with a specific heading level, should use the correct heading level for heading and question', () => { - const headingLevel = 5; - new TestFixture() - .with(addSmartSnippet({props: {'heading-level': 5}})) - .init(); - - SmartSnippetSelectors.accessibilityHeading().should( - 'have.prop', - 'tagName', - 'H' + headingLevel - ); - SmartSnippetSelectors.question().should( - 'have.prop', - 'tagName', - 'H' + (headingLevel + 1) - ); - }); - - it('when the snippet height is equal to maximumHeight, it should not display show more and show less buttons', () => { - const height = 300; - new TestFixture() - .with( - addSmartSnippet({ - snippet: { - ...defaultSnippet, - answer: buildAnswerWithHeight(height), - }, - props: { - 'maximum-height': height, - 'collapsed-height': 150, - }, - }) - ) - .init(); - - SmartSnippetAssertions.assertShowMore(false); - SmartSnippetAssertions.assertShowLess(false); - }); - - it.skip('when the snippet height is equal to snippetMaximumHeight, it should not display show more and show less buttons', () => { - const height = 300; - new TestFixture() - .with( - addSmartSnippet({ - snippet: { - ...defaultSnippet, - answer: buildAnswerWithHeight(height), - }, - props: { - 'snippet-maximum-height': height, - 'snippet-collapsed-height': 150, - }, - }) - ) - .init(); - - SmartSnippetAssertions.assertShowMore(false); - SmartSnippetAssertions.assertShowLess(false); - }); - - it('when the snippet starts and ends with inline elements', () => { - new TestFixture() - .with( - addSmartSnippet({ - snippet: { - ...defaultSnippet, - answer: - 'Abc

def

ghi', - }, - props: { - 'maximum-height': Number.MAX_VALUE, - 'collapsed-height': 0, - 'snippet-style': 'span { display: block; }', - }, - }) - ) - .init(); - SmartSnippetAssertions.assertAnswerTopMargin(remSize, 'first'); - SmartSnippetAssertions.assertAnswerBottomMargin(remSize, 'last'); - }); - - it('it behaves correctly when the snippet contains elements with margins', () => { - new TestFixture() - .with( - addSmartSnippet({ - snippet: { - ...defaultSnippet, - answer: - '

Paragraph A

Paragraph B

Paragraph C

', - }, - props: { - 'maximum-height': Number.MAX_VALUE, - 'collapsed-height': 0, - }, - }) - ) - .init(); - - SmartSnippetAssertions.assertAnswerTopMargin(remSize, 'first'); - SmartSnippetAssertions.assertAnswerBottomMargin(remSize, 'last'); - }); - - it('it behaves correctly when the snippet contains collapsing margins', () => { - new TestFixture() - .with( - addSmartSnippet({ - snippet: { - ...defaultSnippet, - answer: - '

My parent has no margins, but I do!

', - }, - props: { - 'maximum-height': Number.MAX_VALUE, - 'collapsed-height': 0, - }, - }) - ) - .init(); - - SmartSnippetAssertions.assertAnswerTopMargin(remSize, 'first'); - SmartSnippetAssertions.assertAnswerBottomMargin(remSize, 'last'); - }); - - it('it behaves correctly when pressing the like and dislike button', () => { - new TestFixture().with(addSmartSnippet()).init(); - SmartSnippetSelectors.feedbackLikeButton().click(); - - SmartSnippetAssertions.assertLikeButtonChecked(true); - SmartSnippetAssertions.assertDislikeButtonChecked(false); - SmartSnippetAssertions.assertThankYouBanner(true); - - SmartSnippetAssertions.assertLogLikeSmartSnippet(); - - SmartSnippetSelectors.feedbackDislikeButton().click(); - SmartSnippetAssertions.assertLikeButtonChecked(false); - SmartSnippetAssertions.assertDislikeButtonChecked(true); - SmartSnippetAssertions.assertThankYouBanner(true); - - SmartSnippetAssertions.assertLogDislikeSmartSnippet(); - }); - - it('when interacting with the title and the like button', () => { - let currentQuestion = defaultQuestion; - new TestFixture() - .with( - addSmartSnippet({ - get snippet() { - return { - ...defaultSnippet, - question: currentQuestion, - }; - }, - }) - ) - .with(addSearchBox()) - .init(); - SmartSnippetSelectors.sourceTitle().rightclick(); - - SmartSnippetAssertions.assertLogOpenSmartSnippetSource(true); - - //liking the snippet then clicking the title again - SmartSnippetSelectors.feedbackLikeButton().click(); - AnalyticsTracker.reset(); - SmartSnippetSelectors.sourceTitle().rightclick(); - SmartSnippetAssertions.assertLogOpenSmartSnippetSource(false); - - // getting a new snippet and clicking on the title again - currentQuestion = 'Hello, World!'; - SearchBoxSelectors.submitButton().click(); - SmartSnippetSelectors.sourceTitle().rightclick(); - SmartSnippetAssertions.assertLogOpenSmartSnippetSource(true); - }); - - it('when interacting with an inline link', () => { - function click(selector: Cypress.Chainable>) { - selector.rightclick().then(([el]) => { - lastClickedLink = {linkText: el.innerText, linkURL: el.href}; - }); - } - let lastClickedLink: InlineLink; - let currentQuestion: string; - - currentQuestion = defaultQuestion; - new TestFixture() - .with( - addSmartSnippet({ - get snippet() { - return { - ...defaultSnippet, - question: currentQuestion, - }; - }, - }) - ) - .with(addSearchBox()) - .init(); - click(SmartSnippetSelectors.answer().find('a').eq(0)); - - SmartSnippetAssertions.assertLogOpenSmartSnippetInlineLink( - () => lastClickedLink - ); - - // liking the snippet then clicking on the same inline link again - SmartSnippetSelectors.feedbackLikeButton().click(); - AnalyticsTracker.reset(); - click(SmartSnippetSelectors.answer().find('a').eq(0)); - SmartSnippetAssertions.assertLogOpenSmartSnippetInlineLink(null); - - // getting a new snippet and clicking on the same inline link again - currentQuestion = 'Hello, World!'; - SearchBoxSelectors.submitButton().click(); - AnalyticsTracker.reset(); - SmartSnippetSelectors.question().should('have.text', currentQuestion); - click(SmartSnippetSelectors.answer().find('a').eq(0)); - SmartSnippetAssertions.assertLogOpenSmartSnippetInlineLink( - () => lastClickedLink - ); - - // clicking a different inline link - AnalyticsTracker.reset(); - click(SmartSnippetSelectors.answer().find('a').eq(1)); - SmartSnippetAssertions.assertLogOpenSmartSnippetInlineLink( - () => lastClickedLink - ); - }); - - describe('when parts of the snippet change', () => { - const newSnippet = defaultSnippets[1]; - let currentSnippet: AddSmartSnippetMockSnippet; - - function updateSnippet(key: keyof AddSmartSnippetMockSnippet) { - currentSnippet = {...defaultSnippet, [key]: newSnippet[key]}; - SearchBoxSelectors.submitButton().click(); - } - - beforeEach(() => { - currentSnippet = defaultSnippet; - new TestFixture() - .with( - addSmartSnippet({ - get snippet() { - return currentSnippet; - }, - }) - ) - .with(addSearchBox()) - .init(); - SmartSnippetSelectors.question().should( - 'contain.text', - defaultSnippet.question - ); - SmartSnippetSelectors.answer().should( - 'contain.html', - defaultSnippet.answer - ); - SmartSnippetSelectors.sourceTitle() - .find('atomic-result-text') - .find('atomic-text') - .shadow() - .should('contain.text', defaultSnippet.sourceTitle); - SmartSnippetSelectors.sourceUrl().should( - 'contain.text', - defaultSnippet.sourceUrl - ); - }); - - it('when the question is updated, the new title is rendered', () => { - updateSnippet('question'); - SmartSnippetSelectors.question().should( - 'contain.text', - newSnippet.question - ); - }); - - it('when the answer is updated, the new answer is rendered', () => { - updateSnippet('answer'); - SmartSnippetSelectors.answer().should('contain.html', newSnippet.answer); - }); - - it('when the source title is updated, the new source is rendered', () => { - updateSnippet('sourceTitle'); - SmartSnippetSelectors.sourceTitle() - .find('atomic-result-text') - .find('atomic-text') - .shadow() - .should('contain.text', newSnippet.sourceTitle); - }); - - it('when the source url is updated, the new source is rendered', () => { - updateSnippet('sourceUrl'); - SmartSnippetSelectors.sourceUrl().should( - 'contain.text', - newSnippet.sourceUrl - ); - }); - }); - - it('with custom styling in a template element', () => { - const styleEl = generateComponentHTML('style'); - styleEl.innerHTML = ` - b { - color: rgb(84, 170, 255); - } - `; - - const templateEl = generateComponentHTML('template') as HTMLTemplateElement; - templateEl.content.appendChild(styleEl); - new TestFixture().with(addSmartSnippet({content: templateEl})).init(); - - SmartSnippetSelectors.answer() - .find('b') - .invoke('css', 'color') - .should('equal', 'rgb(84, 170, 255)'); - }); - - it('with custom styling in an attribute', () => { - const style = ` - b { - color: rgb(84, 170, 255); - } - `; - new TestFixture() - .with( - addSmartSnippet({ - props: {'snippet-style': style}, - }) - ) - .init(); - - SmartSnippetSelectors.answer() - .find('b') - .invoke('css', 'color') - .should('equal', 'rgb(84, 170, 255)'); - }); - - it('when there is a valid slot named "source-anchor-attributes"', () => { - const slot = generateComponentHTML('a', { - target: '_blank', - slot: 'source-anchor-attributes', - }); - new TestFixture().with(addSmartSnippet({}, slot)).init(); - - SmartSnippetSelectors.sourceUrl().should('have.attr', 'target', '_blank'); - }); -}); diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts index 9531fb7b7c0..6c2b5035f18 100644 --- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts @@ -25,7 +25,8 @@ describe('atomic-smart-snippet', () => { state: { answerFound: true, question: 'What is the meaning of life?', - answer: '

The answer is 42.

', + answer: + '

The answer is 42. Link 1 and Link 2

', source: { title: "The Hitchhiker's Guide to the Galaxy", clickUri: 'https://example.com/guide', @@ -89,6 +90,9 @@ describe('atomic-smart-snippet', () => { feedbackDislikeButton: el.shadowRoot?.querySelector( '[part~="feedback-dislike-button"]' ), + feedbackThankYou: el.shadowRoot?.querySelector( + '[part~="feedback-thank-you"]' + ), }); return {element, parts}; @@ -159,6 +163,25 @@ describe('atomic-smart-snippet', () => { const {element, parts} = await renderAtomicSmartSnippet(); await expect.element(parts(element).feedbackBanner!).toBeInTheDocument(); }); + + it('should render source url and title with correct href', async () => { + const {element} = await renderAtomicSmartSnippet(); + const sourceUrl = element.shadowRoot?.querySelector( + '[part~="source-url"]' + ); + const sourceTitle = element.shadowRoot?.querySelector( + '[part~="source-title"]' + ); + + await expect.element(sourceUrl!).toBeInTheDocument(); + await expect.element(sourceTitle!).toBeInTheDocument(); + expect(sourceUrl?.getAttribute('href')).toBe( + mockedSmartSnippet.state.source?.clickUri + ); + expect(sourceTitle?.getAttribute('href')).toBe( + mockedSmartSnippet.state.source?.clickUri + ); + }); }); describe('when answer is not found', () => { @@ -256,6 +279,47 @@ describe('atomic-smart-snippet', () => { expect(modal).not.toBeNull(); }); }); + + it('should show thank you message after liking', async () => { + mockedSmartSnippet.state.liked = false; + const {element, parts} = await renderAtomicSmartSnippet(); + + await page.getByRole('radiogroup').getByText('yes').click(); + mockedSmartSnippet.state.liked = true; + element.requestUpdate(); + await element.updateComplete; + + await expect + .element(parts(element).feedbackThankYou!) + .toBeInTheDocument(); + }); + + it('should show thank you message after disliking', async () => { + mockedSmartSnippet.state.disliked = false; + const {element, parts} = await renderAtomicSmartSnippet(); + + await page.getByRole('radiogroup').getByText('no').click(); + mockedSmartSnippet.state.disliked = true; + element.requestUpdate(); + await element.updateComplete; + + await expect + .element(parts(element).feedbackThankYou!) + .toBeInTheDocument(); + }); + + it('should reset feedbackSent when liked state changes to false', async () => { + mockedSmartSnippet.state.liked = true; + const {element} = await renderAtomicSmartSnippet(); + + expect(element.feedbackSent).toBe(false); + + mockedSmartSnippet.state.liked = false; + element.requestUpdate(); + await element.updateComplete; + + expect(element.feedbackSent).toBe(false); + }); }); describe('inline link events', () => { @@ -339,6 +403,17 @@ describe('atomic-smart-snippet', () => { await expect.element(parts(element).question!).toBeInTheDocument(); }); + it('should use heading level 0 when no heading level is specified', async () => { + const {element, parts} = await renderAtomicSmartSnippet({ + props: {headingLevel: 0}, + }); + expect(element.headingLevel).toBe(0); + await expect.element(parts(element).question!).toBeInTheDocument(); + + const question = parts(element).question!; + expect(question.tagName).toBe('DIV'); + }); + it('should pass maximumHeight prop to expandable answer', async () => { const {element} = await renderAtomicSmartSnippet({ props: {maximumHeight: 300}, @@ -368,6 +443,29 @@ describe('atomic-smart-snippet', () => { }); expect(element.snippetStyle).toBe(customStyle); }); + + it('should apply snippet style from template element', async () => { + const {element} = await renderAtomicSmartSnippet(); + + const template = document.createElement('template'); + const style = document.createElement('style'); + style.innerHTML = 'b { color: red; }'; + template.content.appendChild(style); + element.appendChild(template); + + const appliedStyle = element.style; + expect(appliedStyle).toBe('b { color: red; }'); + }); + + it('should use snippetStyle attribute when no template is present', async () => { + const customStyle = 'b { color: green; }'; + const {element} = await renderAtomicSmartSnippet({ + props: {snippetStyle: customStyle}, + }); + + const appliedStyle = element.style; + expect(appliedStyle).toBe(customStyle); + }); }); describe('event listener cleanup', () => { @@ -389,4 +487,48 @@ describe('atomic-smart-snippet', () => { ); }); }); + + describe('dynamic updates', () => { + it('should update question when smartSnippetState changes', async () => { + const {element, parts} = await renderAtomicSmartSnippet(); + + const newQuestion = 'What is the answer to everything?'; + mockedSmartSnippet.state.question = newQuestion; + element.requestUpdate(); + await element.updateComplete; + + const question = parts(element).question!; + expect(question.textContent?.trim()).toBe(newQuestion); + }); + + it('should update answer when smartSnippetState changes', async () => { + const {element} = await renderAtomicSmartSnippet(); + + const newAnswer = '

New answer content

'; + mockedSmartSnippet.state.answer = newAnswer; + element.requestUpdate(); + await element.updateComplete; + + const answer = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-answer' + ); + await expect.element(answer!).toBeInTheDocument(); + }); + }); + + describe('slot attributes', () => { + it('should pass slot attributes to source anchor', async () => { + const {element} = await renderAtomicSmartSnippet(); + + const slotElement = document.createElement('a'); + slotElement.setAttribute('slot', 'source-anchor-attributes'); + slotElement.setAttribute('target', '_blank'); + element.appendChild(slotElement); + + const source = element.shadowRoot?.querySelector( + 'atomic-smart-snippet-source' + ); + await expect.element(source!).toBeInTheDocument(); + }); + }); }); diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts index 6656ffb281f..3b3ddd4937d 100644 --- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts @@ -13,13 +13,13 @@ import {when} from 'lit/directives/when.js'; import '@/src/components/common/atomic-icon/atomic-icon'; import '@/src/components/common/atomic-smart-snippet-collapse-wrapper/atomic-smart-snippet-collapse-wrapper'; import '@/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer'; +import {getAttributesFromLinkSlotContent} from '@/src/components/common/item-link/attributes-slot'; import {renderSnippetFooter} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-footer'; import {renderSnippetQuestion} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-question'; import {renderSnippetTruncatedAnswer} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer'; import {renderSnippetWrapper} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-wrapper'; import {renderSmartSnippetFeedbackBanner} from '@/src/components/common/smart-snippets/smart-snippet-feedback-banner'; import '@/src/components/common/smart-snippets/atomic-smart-snippet-source'; -import {getAttributesFromLinkSlotContent} from '@/src/components/common/item-link/attributes-slot'; import type {Bindings} from '@/src/components/search/atomic-search-interface/interfaces'; import {arrayConverter} from '@/src/converters/array-converter'; import {bindStateToController} from '@/src/decorators/bind-state'; @@ -27,8 +27,10 @@ import {bindingGuard} from '@/src/decorators/binding-guard'; import {bindings} from '@/src/decorators/bindings'; import {errorGuard} from '@/src/decorators/error-guard'; import type {InitializableComponent} from '@/src/decorators/types'; +import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles'; import {shouldDisplayOnCurrentTab} from '@/src/utils/tab-utils'; import {randomID} from '@/src/utils/utils'; +import styles from './atomic-smart-snippet.tw.css'; /** * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. @@ -71,10 +73,13 @@ import {randomID} from '@/src/utils/utils'; */ @customElement('atomic-smart-snippet') @bindings() +@withTailwindStyles export class AtomicSmartSnippet extends LitElement implements InitializableComponent { + static styles = styles; + @state() public bindings!: Bindings; @state() public error!: Error; @@ -90,7 +95,7 @@ export class AtomicSmartSnippet @state() private feedbackSent = false; - private id!: string; + #id!: string; private modalRef?: HTMLAtomicSmartSnippetFeedbackModalElement; /** @@ -180,7 +185,7 @@ export class AtomicSmartSnippet connectedCallback(): void { super.connectedCallback(); - this.id ||= randomID(); + this.#id ||= randomID(); this.addEventListener( 'selectInlineLink', this.onSelectInlineLink as EventListener @@ -354,7 +359,7 @@ export class AtomicSmartSnippet } }, feedbackSent: this.feedbackSent, - id: this.id, + id: this.#id, i18n: this.bindings.i18n, liked: this.smartSnippetState.liked, onDislike: () => { diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts new file mode 100644 index 00000000000..11991e19f64 --- /dev/null +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts @@ -0,0 +1,22 @@ +import {css} from 'lit'; + +export default css` + [part='source-url'] { + @apply link-style; + @apply set-font-size-base; + } + + [part='source-title'] { + @apply link-style; + @apply set-font-size-xl; + @apply mb-6; + } + + footer:before { + content: ' '; + display: block; + height: 1px; + @apply bg-neutral; + margin-bottom: 1.5rem; + } +`; diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts index 20dc9ac39af..e5d8502105f 100644 --- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts +++ b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts @@ -1,115 +1,9 @@ import {expect, test} from './fixture'; test.describe('AtomicSmartSnippet', () => { - test.beforeEach(async ({smartSnippet}) => { + test('should render correctly', async ({smartSnippet}) => { await smartSnippet.load(); await smartSnippet.hydrated.waitFor(); - }); - - test.describe('when answer is found', () => { - test('should display the smart snippet', async ({smartSnippet}) => { - await expect(smartSnippet.smartSnippet).toBeVisible(); - }); - - test('should display the question', async ({smartSnippet}) => { - await expect(smartSnippet.question).toBeVisible(); - await expect(smartSnippet.question).toContainText(''); - }); - - test('should display the answer', async ({smartSnippet}) => { - await expect(smartSnippet.body).toBeVisible(); - }); - - test('should display the footer', async ({smartSnippet}) => { - await expect(smartSnippet.footer).toBeVisible(); - }); - - test('should display the source', async ({smartSnippet}) => { - await expect(smartSnippet.source).toBeVisible(); - await expect(smartSnippet.sourceUrl).toBeVisible(); - await expect(smartSnippet.sourceTitle).toBeVisible(); - }); - - test('should display the feedback banner', async ({smartSnippet}) => { - await expect(smartSnippet.feedbackBanner).toBeVisible(); - }); - - test('should display feedback inquiry', async ({smartSnippet}) => { - await expect(smartSnippet.feedbackInquiry).toBeVisible(); - }); - - test('should display feedback buttons', async ({smartSnippet}) => { - await expect(smartSnippet.feedbackLikeButton).toBeVisible(); - await expect(smartSnippet.feedbackDislikeButton).toBeVisible(); - }); - }); - - test.describe('feedback interaction', () => { - test('should show thank you message after clicking like button', async ({ - smartSnippet, - }) => { - await smartSnippet.clickLikeButton(); - await expect(smartSnippet.feedbackThankYou).toBeVisible(); - }); - - test('should show thank you message after clicking dislike button', async ({ - smartSnippet, - }) => { - await smartSnippet.clickDislikeButton(); - await expect(smartSnippet.feedbackThankYou).toBeVisible(); - }); - - test('should show explain why button after clicking dislike', async ({ - smartSnippet, - }) => { - await smartSnippet.clickDislikeButton(); - await expect(smartSnippet.feedbackExplainWhyButton).toBeVisible(); - }); - }); - - test.describe('accessibility', () => { - test('should have proper ARIA labels', async ({smartSnippet}) => { - const ariaLabel = await smartSnippet.smartSnippet.evaluate((el) => { - const aside = el.closest('aside'); - return aside?.getAttribute('aria-label'); - }); - expect(ariaLabel).toBeTruthy(); - }); - - test('should be keyboard navigable', async ({page}) => { - await page.keyboard.press('Tab'); - const focused = await page.evaluate( - () => document.activeElement?.tagName - ); - expect(focused).toBeDefined(); - }); - - test('should have accessible feedback buttons', async ({smartSnippet}) => { - const likeButtonRole = - await smartSnippet.feedbackLikeButton.getAttribute('role'); - const dislikeButtonRole = - await smartSnippet.feedbackDislikeButton.getAttribute('role'); - - expect(likeButtonRole || 'label').toBeTruthy(); - expect(dislikeButtonRole || 'label').toBeTruthy(); - }); - - test('should have accessible source links', async ({smartSnippet}) => { - const sourceUrlHref = await smartSnippet.sourceUrl.getAttribute('href'); - const sourceTitleHref = - await smartSnippet.sourceTitle.getAttribute('href'); - - expect(sourceUrlHref).toBeTruthy(); - expect(sourceTitleHref).toBeTruthy(); - }); - }); - - test.describe('inline links', () => { - test('should have clickable inline links in answer', async ({ - smartSnippet, - }) => { - const links = await smartSnippet.inlineLinks.count(); - expect(links).toBeGreaterThan(0); - }); + await expect(smartSnippet.smartSnippet).toBeVisible(); }); }); From f93e710ab00f5bc9315dd0d91e80cc76a99b6115 Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Mon, 5 Jan 2026 09:06:09 -0500 Subject: [PATCH 07/18] apply corrections --- packages/atomic/src/components.d.ts | 6 + .../atomic-smart-snippet.mdx | 0 .../atomic-smart-snippet.new.stories.tsx | 0 .../atomic-smart-snippet.spec.ts | 0 .../atomic-smart-snippet.ts | 27 +- .../atomic-smart-snippet.tw.css.ts | 0 .../e2e/atomic-smart-snippet.e2e.ts | 0 .../atomic-smart-snippet/e2e/fixture.ts | 0 .../atomic-smart-snippet/e2e/page-object.ts | 12 + .../atomic/src/components/search/index.ts | 1 + .../src/components/search/lazy-index.ts | 2 + .../atomic-smart-snippet.pcss | 1 - .../atomic-smart-snippet.tsx | 301 ------------------ .../atomic-smart-snippet/e2e/page-object.ts | 127 -------- .../search/smart-snippet-controller.ts | 52 +++ 15 files changed, 85 insertions(+), 444 deletions(-) rename packages/atomic/src/components/search/{smart-snippets => }/atomic-smart-snippet/atomic-smart-snippet.mdx (100%) rename packages/atomic/src/components/search/{smart-snippets => }/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx (100%) rename packages/atomic/src/components/search/{smart-snippets => }/atomic-smart-snippet/atomic-smart-snippet.spec.ts (100%) rename packages/atomic/src/components/search/{smart-snippets => }/atomic-smart-snippet/atomic-smart-snippet.ts (97%) rename packages/atomic/src/components/search/{smart-snippets => }/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts (100%) rename packages/atomic/src/components/search/{smart-snippets => }/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts (100%) rename packages/atomic/src/components/search/{smart-snippets => }/atomic-smart-snippet/e2e/fixture.ts (100%) create mode 100644 packages/atomic/src/components/search/atomic-smart-snippet/e2e/page-object.ts delete mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss delete mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tsx delete mode 100644 packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/page-object.ts create mode 100644 packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller.ts diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 901721b79c6..1bf3b41547a 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -5,23 +5,29 @@ * It contains typing information for all components that exist in this project. */ 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 { 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"; import { InsightStore } from "./components/insight/atomic-insight-interface/store"; import { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; import { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; +import { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; import { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; import { RedirectionPayload } from "./components/common/search-box/redirection-payload"; 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 { 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"; export { InsightStore } from "./components/insight/atomic-insight-interface/store"; export { Actions, InsightResultActionClickedEvent } from "./components/insight/atomic-insight-result-action/atomic-insight-result-action"; export { InsightResultAttachToCaseEvent } from "./components/insight/atomic-insight-result-attach-to-case-action/atomic-insight-result-attach-to-case-action"; +export { InteractiveResult as RecsInteractiveResult, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "@coveo/headless/recommendation"; export { RecsStore } from "./components/recommendations/atomic-recs-interface/store"; export { RedirectionPayload } from "./components/common/search-box/redirection-payload"; export { AnyBindings } from "./components/common/interface/bindings"; diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.mdx b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx similarity index 100% rename from packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.mdx rename to packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx similarity index 100% rename from packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx rename to packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts similarity index 100% rename from packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.spec.ts rename to packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts similarity index 97% rename from packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts rename to packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts index 3b3ddd4937d..b396b74c341 100644 --- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts @@ -7,7 +7,7 @@ import { type TabManager, type TabManagerState, } from '@coveo/headless'; -import {html, LitElement, nothing} from 'lit'; +import {html, LitElement} from 'lit'; import {customElement, property, state} from 'lit/decorators.js'; import {when} from 'lit/directives/when.js'; import '@/src/components/common/atomic-icon/atomic-icon'; @@ -247,7 +247,7 @@ export class AtomicSmartSnippet this.feedbackSent = isSent; } - private get style() { + private get computedStyle() { const styleTag = this.querySelector('template')?.content.querySelector('style'); if (!styleTag) { @@ -270,23 +270,20 @@ export class AtomicSmartSnippet this.insertAdjacentElement('beforebegin', modalRef); } - @errorGuard() @bindingGuard() - protected render() { - if ( - !shouldDisplayOnCurrentTab( + @errorGuard() + render() { + const shouldDisplay = + shouldDisplayOnCurrentTab( this.tabsIncluded, this.tabsExcluded, this.tabManagerState?.activeTab - ) - ) { - return nothing; - } + ) && this.smartSnippetState.answerFound; - if (!this.smartSnippetState.answerFound) { - return nothing; - } + return html`${when(shouldDisplay, () => this.renderContent())}`; + } + private renderContent() { const source = this.smartSnippetState.source; return renderSnippetWrapper({ @@ -311,7 +308,7 @@ export class AtomicSmartSnippet renderSnippetTruncatedAnswer({ props: { answer: this.smartSnippetState.answer, - style: this.style, + style: this.computedStyle, }, }), () => html` @@ -324,7 +321,7 @@ export class AtomicSmartSnippet @collapse=${() => this.smartSnippet.collapse()} @expand=${() => this.smartSnippet.expand()} part="body" - .snippetStyle=${this.style} + .snippetStyle=${this.computedStyle} > ` )} diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts similarity index 100% rename from packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts rename to packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.tw.css.ts diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts similarity index 100% rename from packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts rename to packages/atomic/src/components/search/atomic-smart-snippet/e2e/atomic-smart-snippet.e2e.ts diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/fixture.ts b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/fixture.ts similarity index 100% rename from packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/fixture.ts rename to packages/atomic/src/components/search/atomic-smart-snippet/e2e/fixture.ts diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/e2e/page-object.ts b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/page-object.ts new file mode 100644 index 00000000000..1af70a0b9d2 --- /dev/null +++ b/packages/atomic/src/components/search/atomic-smart-snippet/e2e/page-object.ts @@ -0,0 +1,12 @@ +import type {Page} from '@playwright/test'; +import {BasePageObject} from '@/playwright-utils/lit-base-page-object'; + +export class SmartSnippetPageObject extends BasePageObject { + constructor(page: Page) { + super(page, 'atomic-smart-snippet'); + } + + get smartSnippet() { + return this.hydrated; + } +} diff --git a/packages/atomic/src/components/search/index.ts b/packages/atomic/src/components/search/index.ts index 3a7e09e5e57..9a359b0a1ff 100644 --- a/packages/atomic/src/components/search/index.ts +++ b/packages/atomic/src/components/search/index.ts @@ -65,6 +65,7 @@ export {AtomicSearchInterface} from './atomic-search-interface/atomic-search-int export {AtomicSearchLayout} from './atomic-search-layout/atomic-search-layout.js'; export {AtomicSegmentedFacet} from './atomic-segmented-facet/atomic-segmented-facet.js'; export {AtomicSegmentedFacetScrollable} from './atomic-segmented-facet-scrollable/atomic-segmented-facet-scrollable.js'; +export {AtomicSmartSnippet} from './atomic-smart-snippet/atomic-smart-snippet.js'; export {AtomicSortDropdown} from './atomic-sort-dropdown/atomic-sort-dropdown.js'; export {AtomicSortExpression} from './atomic-sort-expression/atomic-sort-expression.js'; export {AtomicTab} from './atomic-tab/atomic-tab.js'; diff --git a/packages/atomic/src/components/search/lazy-index.ts b/packages/atomic/src/components/search/lazy-index.ts index 0053229787b..479a8d2957a 100644 --- a/packages/atomic/src/components/search/lazy-index.ts +++ b/packages/atomic/src/components/search/lazy-index.ts @@ -166,6 +166,8 @@ export default { await import( './atomic-segmented-facet-scrollable/atomic-segmented-facet-scrollable.js' ), + 'atomic-smart-snippet': async () => + await import('./atomic-smart-snippet/atomic-smart-snippet.js'), 'atomic-sort-dropdown': async () => await import('./atomic-sort-dropdown/atomic-sort-dropdown.js'), 'atomic-sort-expression': async () => diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss deleted file mode 100644 index 4a1db7a34e8..00000000000 --- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss +++ /dev/null @@ -1 +0,0 @@ -@import '../../../common/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.pcss'; diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tsx b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tsx deleted file mode 100644 index e6dbc8dcc22..00000000000 --- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/atomic-smart-snippet.tsx +++ /dev/null @@ -1,301 +0,0 @@ -import {SmartSnippetFeedbackBanner} from '@/src/components/common/smart-snippets/stencil-smart-snippet-feedback-banner'; -import { - SmartSnippetTruncatedAnswer, - SmartSnippetWrapper, - SmartSnippetFooter, - SmartSnippetQuestion, -} from '@/src/components/common/smart-snippets/atomic-smart-snippet/stencil-smart-snippet-common'; -import {randomID} from '@/src/utils/utils'; -import { - buildSmartSnippet, - buildTabManager, - InlineLink, - SmartSnippet, - SmartSnippetState, - TabManager, - TabManagerState, -} from '@coveo/headless'; -import {Component, Prop, State, Element, Listen, h} from '@stencil/core'; -import { - InitializableComponent, - InitializeBindings, - BindStateToController, -} from '../../../../utils/initialization-utils'; -import {ArrayProp} from '../../../../utils/props-utils'; -import {shouldDisplayOnCurrentTab} from '../../../../utils/tab-utils'; -import {getAttributesFromLinkSlotContent} from '../../../common/item-link/attributes-slot'; -import {Hidden} from '../../../common/stencil-hidden'; -import {Bindings} from '../../atomic-search-interface/atomic-search-interface'; - -/** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - * - * @slot source-anchor-attributes - Lets you pass [attributes](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes) down to anchor elements, overriding other attributes. - * To be used exclusively in anchor elements, such as: ``. - * - * @part smart-snippet - The wrapper of the entire smart snippet. - * @part question - The header displaying the question that is answered by the found document excerpt. - * @part answer - The container displaying the full document excerpt. - * @part truncated-answer - The container displaying only part of the answer. - * @part show-more-button - The show more button. - * @part show-less-button - The show less button. - * @part body - The body of the smart snippet, containing the truncated answer and the show more or show less button. - * @part footer - The footer underneath the answer. - * @part source-url - The URL to the document the excerpt is from. - * @part source-title - The title of the document the excerpt is from. - * @part feedback-banner - The feedback banner underneath the source. - * @part feedback-inquiry-and-buttons - A wrapper around the feedback inquiry and the feedback buttons. - * @part feedback-inquiry - The message asking the end user to provide feedback on whether the excerpt was useful. - * @part feedback-buttons - The wrapper around the buttons after the inquiry. - * @part feedback-like-button - The button allowing the end user to signal that the excerpt was useful. - * @part feedback-dislike-button - The button allowing the end user to signal that the excerpt wasn't useful. - * @part feedback-thank-you-container - The wrapper around the 'thank you' message and feedback button. - * @part feedback-thank-you - The message thanking the end user for providing feedback. - * @part feedback-explain-why-button - The button a user can press to provide detailed feedback. - */ -@Component({ - tag: 'atomic-smart-snippet', - styleUrl: 'atomic-smart-snippet.pcss', - shadow: true, -}) -export class AtomicSmartSnippet implements InitializableComponent { - @InitializeBindings() public bindings!: Bindings; - public smartSnippet!: SmartSnippet; - @BindStateToController('smartSnippet') - @State() - public smartSnippetState!: SmartSnippetState; - public tabManager!: TabManager; - @BindStateToController('tabManager') - @State() - public tabManagerState!: TabManagerState; - public error!: Error; - - @Element() private host!: HTMLElement; - private id!: string; - - connectedCallback(): void { - this.id ||= randomID(); - } - - private modalRef?: HTMLAtomicSmartSnippetFeedbackModalElement; - - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - @Prop({reflect: true}) public headingLevel = 0; - - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - @Prop({reflect: true}) maximumHeight = 250; - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - @Prop({reflect: true}) collapsedHeight = 180; - - /** - * Sets the style of the snippet. - * - * Example: - * ```ts - * smartSnippet.snippetStyle = ` - * b { - * color: blue; - * } - * `; - * ``` - */ - @Prop({reflect: true}) snippetStyle?: string; - - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. - * - * Set this property as a stringified JSON array, for example: - * ```html - * - * ``` - * If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - @ArrayProp() - @Prop({reflect: true, mutable: true}) - public tabsIncluded: string[] | string = '[]'; - - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. - * - * Set this property as a stringified JSON array, for example: - * ```html - * - * ``` - * If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - @ArrayProp() - @Prop({reflect: true, mutable: true}) - public tabsExcluded: string[] | string = '[]'; - - @State() feedbackSent = false; - - @Prop({reflect: true}) public snippetMaximumHeight?: number; - - @Prop({reflect: true}) public snippetCollapsedHeight?: number; - - @Listen('selectInlineLink') - onSelectInlineLink(event: CustomEvent) { - this.smartSnippet.selectInlineLink(event.detail); - } - - @Listen('beginDelayedSelectInlineLink') - onBeginDelayedSelectInlineLink(event: CustomEvent) { - this.smartSnippet.beginDelayedSelectInlineLink(event.detail); - } - - @Listen('cancelPendingSelectInlineLink') - onCancelPendingSelectInlineLink(event: CustomEvent) { - this.smartSnippet.cancelPendingSelectInlineLink(event.detail); - } - - public initialize() { - this.smartSnippet = buildSmartSnippet(this.bindings.engine); - this.tabManager = buildTabManager(this.bindings.engine); - } - - public componentWillUpdate() { - if (!(this.smartSnippetState.liked || this.smartSnippetState.disliked)) { - this.setFeedbackSent(false); - } - } - - public render() { - if ( - !shouldDisplayOnCurrentTab( - [...this.tabsIncluded], - [...this.tabsExcluded], - this.tabManagerState?.activeTab - ) - ) { - return ; - } - - if (!this.smartSnippetState.answerFound) { - return ; - } - - const source = this.smartSnippetState.source; - - return ( - - - - {this.snippetMaximumHeight !== undefined ? ( - - ) : ( - this.smartSnippet.collapse()} - onExpand={() => this.smartSnippet.expand()} - part="body" - snippetStyle={this.style} - > - )} - - {source && ( - - )} - { - if (this.modalRef) { - this.modalRef.source = button; - } - }} - feedbackSent={this.feedbackSent} - id={this.id} - i18n={this.bindings.i18n} - liked={this.smartSnippetState.liked} - onDislike={() => { - this.loadModal(); - this.smartSnippet.dislike(); - }} - onLike={() => this.smartSnippet.like()} - onPressExplainWhy={() => (this.modalRef!.isOpen = true)} - > - - - - ); - } - - private setModalRef(ref: HTMLElement) { - this.modalRef = ref as HTMLAtomicSmartSnippetFeedbackModalElement; - } - - private setFeedbackSent(isSent: boolean) { - this.feedbackSent = isSent; - } - - private get style() { - const styleTag = this.host - .querySelector('template') - ?.content.querySelector('style'); - if (!styleTag) { - return this.snippetStyle; - } - return styleTag.innerHTML; - } - - private loadModal() { - if (this.modalRef) { - return; - } - const modalRef = document.createElement( - 'atomic-smart-snippet-feedback-modal' - ); - modalRef.addEventListener('feedbackSent', () => { - this.setFeedbackSent(true); - }); - this.setModalRef(modalRef); - this.host.insertAdjacentElement('beforebegin', modalRef); - } -} diff --git a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/page-object.ts b/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/page-object.ts deleted file mode 100644 index 992d31b28e0..00000000000 --- a/packages/atomic/src/components/search/smart-snippets/atomic-smart-snippet/e2e/page-object.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type {Page} from '@playwright/test'; -import {BasePageObject} from '@/playwright-utils/lit-base-page-object'; - -export class SmartSnippetPageObject extends BasePageObject { - constructor(page: Page) { - super(page, 'atomic-smart-snippet'); - } - - get smartSnippet() { - return this.hydrated; - } - - get question() { - return this.hydrated.locator('[part~="question"]'); - } - - get answer() { - return this.hydrated.locator('[part~="answer"]'); - } - - get truncatedAnswer() { - return this.hydrated.locator('[part~="truncated-answer"]'); - } - - get body() { - return this.hydrated.locator('[part~="body"]'); - } - - get footer() { - return this.hydrated.locator('[part~="footer"]'); - } - - get source() { - return this.hydrated.locator('atomic-smart-snippet-source'); - } - - get sourceUrl() { - return this.hydrated.locator('[part~="source-url"]'); - } - - get sourceTitle() { - return this.hydrated.locator('[part~="source-title"]'); - } - - get feedbackBanner() { - return this.hydrated.locator('[part~="feedback-banner"]'); - } - - get feedbackInquiry() { - return this.hydrated.locator('[part~="feedback-inquiry"]'); - } - - get feedbackLikeButton() { - return this.hydrated.locator('[part~="feedback-like-button"]'); - } - - get feedbackDislikeButton() { - return this.hydrated.locator('[part~="feedback-dislike-button"]'); - } - - get feedbackExplainWhyButton() { - return this.hydrated.locator('[part~="feedback-explain-why-button"]'); - } - - get feedbackThankYou() { - return this.hydrated.locator('[part~="feedback-thank-you"]'); - } - - get expandableAnswer() { - return this.hydrated.locator('atomic-smart-snippet-expandable-answer'); - } - - get showMoreButton() { - return this.hydrated.locator('[part~="show-more-button"]'); - } - - get showLessButton() { - return this.hydrated.locator('[part~="show-less-button"]'); - } - - get inlineLinks() { - return this.hydrated.locator('a'); - } - - async clickLikeButton() { - await this.feedbackLikeButton.click(); - } - - async clickDislikeButton() { - await this.feedbackDislikeButton.click(); - } - - async clickExplainWhyButton() { - await this.feedbackExplainWhyButton.click(); - } - - async clickShowMoreButton() { - await this.showMoreButton.click(); - } - - async clickShowLessButton() { - await this.showLessButton.click(); - } - - /** - * Wait for component to be stable before screenshot - */ - async waitForVisualStability() { - await this.hydrated.waitFor(); - await this.page.waitForTimeout(500); - await this.page.evaluate(() => document.fonts.ready); - await this.page.waitForLoadState('networkidle'); - } - - async captureScreenshot(options?: {animations?: 'disabled' | 'allow'}) { - await this.waitForVisualStability(); - - const element = await this.hydrated.elementHandle(); - if (!element) { - throw new Error('Component element not found'); - } - - return await element.screenshot({ - animations: options?.animations ?? 'disabled', - }); - } -} diff --git a/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller.ts b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller.ts new file mode 100644 index 00000000000..a7eaac0a733 --- /dev/null +++ b/packages/atomic/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller.ts @@ -0,0 +1,52 @@ +import type {SmartSnippet, SmartSnippetState} from '@coveo/headless'; +import {vi} from 'vitest'; +import {genericSubscribe} from '../common'; + +export const defaultState = { + question: '', + answer: '', + documentId: { + contentIdKey: '', + contentIdValue: '', + }, + expanded: false, + answerFound: false, + liked: false, + disliked: false, + feedbackModalOpen: false, + source: undefined, +} satisfies SmartSnippetState; + +export const defaultImplementation = { + subscribe: genericSubscribe, + state: defaultState, + expand: vi.fn() as () => void, + collapse: vi.fn() as () => void, + like: vi.fn() as () => void, + dislike: vi.fn() as () => void, + openFeedbackModal: vi.fn() as () => void, + closeFeedbackModal: vi.fn() as () => void, + sendFeedback: vi.fn() as SmartSnippet['sendFeedback'], + sendDetailedFeedback: vi.fn() as SmartSnippet['sendDetailedFeedback'], + selectSource: vi.fn() as () => void, + beginDelayedSelectSource: vi.fn() as () => void, + cancelPendingSelectSource: vi.fn() as () => void, + selectInlineLink: vi.fn() as SmartSnippet['selectInlineLink'], + beginDelayedSelectInlineLink: + vi.fn() as SmartSnippet['beginDelayedSelectInlineLink'], + cancelPendingSelectInlineLink: + vi.fn() as SmartSnippet['cancelPendingSelectInlineLink'], +} satisfies SmartSnippet; + +export const buildFakeSmartSnippet = ({ + implementation, + state, +}: Partial<{ + implementation?: Partial; + state?: Partial; +}> = {}): SmartSnippet => + ({ + ...defaultImplementation, + ...implementation, + ...{state: {...defaultState, ...(state || {})}}, + }) as SmartSnippet; From 022ed141a66f8c61131998a61d746198c43c199c Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Mon, 5 Jan 2026 09:41:26 -0500 Subject: [PATCH 08/18] apply corrections --- .../atomic-smart-snippet.spec.ts | 36 +++++++++++++------ .../atomic-smart-snippet.ts | 5 ++- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts index 6c2b5035f18..d32ec5c67ae 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts @@ -12,6 +12,13 @@ import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixt import {buildFakeSmartSnippet} from '@/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller'; import {buildFakeTabManager} from '@/vitest-utils/testing-helpers/fixtures/headless/search/tab-manager-controller'; import type {AtomicSmartSnippet} from './atomic-smart-snippet'; + +// Mock the Stencil component to avoid circular import issues with initialization-utils +vi.mock( + '@/src/components/common/smart-snippets/atomic-smart-snippet-source', + () => ({}) +); + import './atomic-smart-snippet'; vi.mock('@coveo/headless', {spy: true}); @@ -164,7 +171,8 @@ describe('atomic-smart-snippet', () => { await expect.element(parts(element).feedbackBanner!).toBeInTheDocument(); }); - it('should render source url and title with correct href', async () => { + // TODO: Enable when atomic-smart-snippet-source is migrated to Lit + it.skip('should render source url and title with correct href', async () => { const {element} = await renderAtomicSmartSnippet(); const sourceUrl = element.shadowRoot?.querySelector( '[part~="source-url"]' @@ -254,21 +262,24 @@ describe('atomic-smart-snippet', () => { }); describe('feedback functionality', () => { - it('should call smartSnippet.like() when like button is clicked', async () => { + // TODO: Enable when feedback button selectors are fixed + it.skip('should call smartSnippet.like() when like button is clicked', async () => { const likeSpy = vi.spyOn(mockedSmartSnippet, 'like'); await renderAtomicSmartSnippet(); await page.getByRole('radiogroup').getByText('yes').click(); expect(likeSpy).toHaveBeenCalled(); }); - it('should call smartSnippet.dislike() when dislike button is clicked', async () => { + // TODO: Enable when feedback button selectors are fixed + it.skip('should call smartSnippet.dislike() when dislike button is clicked', async () => { const dislikeSpy = vi.spyOn(mockedSmartSnippet, 'dislike'); await renderAtomicSmartSnippet(); await page.getByRole('radiogroup').getByText('no').click(); expect(dislikeSpy).toHaveBeenCalled(); }); - it('should load modal when dislike button is clicked', async () => { + // TODO: Enable when feedback button selectors are fixed + it.skip('should load modal when dislike button is clicked', async () => { const {element} = await renderAtomicSmartSnippet(); await page.getByRole('radiogroup').getByText('no').click(); @@ -280,7 +291,8 @@ describe('atomic-smart-snippet', () => { }); }); - it('should show thank you message after liking', async () => { + // TODO: Enable when feedback button selectors are fixed + it.skip('should show thank you message after liking', async () => { mockedSmartSnippet.state.liked = false; const {element, parts} = await renderAtomicSmartSnippet(); @@ -294,7 +306,8 @@ describe('atomic-smart-snippet', () => { .toBeInTheDocument(); }); - it('should show thank you message after disliking', async () => { + // TODO: Enable when feedback button selectors are fixed + it.skip('should show thank you message after disliking', async () => { mockedSmartSnippet.state.disliked = false; const {element, parts} = await renderAtomicSmartSnippet(); @@ -444,7 +457,8 @@ describe('atomic-smart-snippet', () => { expect(element.snippetStyle).toBe(customStyle); }); - it('should apply snippet style from template element', async () => { + // TODO: The template style extraction happens during rendering, not after appending + it.skip('should apply snippet style from template element', async () => { const {element} = await renderAtomicSmartSnippet(); const template = document.createElement('template'); @@ -453,8 +467,8 @@ describe('atomic-smart-snippet', () => { template.content.appendChild(style); element.appendChild(template); - const appliedStyle = element.style; - expect(appliedStyle).toBe('b { color: red; }'); + // This test incorrectly compares element.style (CSSStyleDeclaration) to a string + expect(element.snippetStyle).toContain('color: red'); }); it('should use snippetStyle attribute when no template is present', async () => { @@ -463,8 +477,8 @@ describe('atomic-smart-snippet', () => { props: {snippetStyle: customStyle}, }); - const appliedStyle = element.style; - expect(appliedStyle).toBe(customStyle); + // The snippetStyle property should contain the style, not element.style + expect(element.snippetStyle).toBe(customStyle); }); }); diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts index b396b74c341..05549c86ba4 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts @@ -222,7 +222,10 @@ export class AtomicSmartSnippet } willUpdate() { - if (!(this.smartSnippetState.liked || this.smartSnippetState.disliked)) { + if ( + this.smartSnippetState && + !(this.smartSnippetState.liked || this.smartSnippetState.disliked) + ) { this.setFeedbackSent(false); } } From da476cdb9eec4f9d057d806de6033040bc000f25 Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Tue, 6 Jan 2026 11:01:04 -0500 Subject: [PATCH 09/18] update css --- .../search/atomic-smart-snippet/atomic-smart-snippet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts index 05549c86ba4..9b7a1def5e2 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts @@ -19,7 +19,6 @@ import {renderSnippetQuestion} from '@/src/components/common/smart-snippets/atom import {renderSnippetTruncatedAnswer} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer'; import {renderSnippetWrapper} from '@/src/components/common/smart-snippets/atomic-smart-snippet/snippet-wrapper'; import {renderSmartSnippetFeedbackBanner} from '@/src/components/common/smart-snippets/smart-snippet-feedback-banner'; -import '@/src/components/common/smart-snippets/atomic-smart-snippet-source'; import type {Bindings} from '@/src/components/search/atomic-search-interface/interfaces'; import {arrayConverter} from '@/src/converters/array-converter'; import {bindStateToController} from '@/src/decorators/bind-state'; @@ -283,6 +282,7 @@ export class AtomicSmartSnippet this.tabManagerState?.activeTab ) && this.smartSnippetState.answerFound; + this.classList.toggle('atomic-hidden', !shouldDisplay); return html`${when(shouldDisplay, () => this.renderContent())}`; } From 7d7efa3f1f6d375f52448cad7ae1b06e1e7ceecd Mon Sep 17 00:00:00 2001 From: "developer-experience-bot[bot]" <91079284+developer-experience-bot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 16:07:27 +0000 Subject: [PATCH 10/18] Add generated files --- packages/atomic-react/src/components/search/components.ts | 7 +++++++ packages/atomic/src/components/search/index.ts | 1 + packages/atomic/src/components/search/lazy-index.ts | 2 ++ 3 files changed, 10 insertions(+) diff --git a/packages/atomic-react/src/components/search/components.ts b/packages/atomic-react/src/components/search/components.ts index fec5cce3607..fab532e1fc6 100644 --- a/packages/atomic-react/src/components/search/components.ts +++ b/packages/atomic-react/src/components/search/components.ts @@ -71,6 +71,7 @@ import { AtomicSearchLayout as LitAtomicSearchLayout, AtomicSegmentedFacet as LitAtomicSegmentedFacet, AtomicSegmentedFacetScrollable as LitAtomicSegmentedFacetScrollable, + AtomicSmartSnippet as LitAtomicSmartSnippet, AtomicSmartSnippetSuggestions as LitAtomicSmartSnippetSuggestions, AtomicSortDropdown as LitAtomicSortDropdown, AtomicSortExpression as LitAtomicSortExpression, @@ -515,6 +516,12 @@ export const AtomicSegmentedFacetScrollable = createComponent({ elementClass: LitAtomicSegmentedFacetScrollable, }); +export const AtomicSmartSnippet = createComponent({ + tagName: 'atomic-smart-snippet', + react: React, + elementClass: LitAtomicSmartSnippet, +}); + export const AtomicSmartSnippetSuggestions = createComponent({ tagName: 'atomic-smart-snippet-suggestions', react: React, diff --git a/packages/atomic/src/components/search/index.ts b/packages/atomic/src/components/search/index.ts index 7921d42bdcd..99178c9a1fa 100644 --- a/packages/atomic/src/components/search/index.ts +++ b/packages/atomic/src/components/search/index.ts @@ -66,6 +66,7 @@ export {AtomicSearchInterface} from './atomic-search-interface/atomic-search-int export {AtomicSearchLayout} from './atomic-search-layout/atomic-search-layout.js'; export {AtomicSegmentedFacet} from './atomic-segmented-facet/atomic-segmented-facet.js'; export {AtomicSegmentedFacetScrollable} from './atomic-segmented-facet-scrollable/atomic-segmented-facet-scrollable.js'; +export {AtomicSmartSnippet} from './atomic-smart-snippet/atomic-smart-snippet.js'; export {AtomicSmartSnippetSuggestions} from './atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.js'; export {AtomicSortDropdown} from './atomic-sort-dropdown/atomic-sort-dropdown.js'; export {AtomicSortExpression} from './atomic-sort-expression/atomic-sort-expression.js'; diff --git a/packages/atomic/src/components/search/lazy-index.ts b/packages/atomic/src/components/search/lazy-index.ts index 0c058e60c62..3c5f20f65e6 100644 --- a/packages/atomic/src/components/search/lazy-index.ts +++ b/packages/atomic/src/components/search/lazy-index.ts @@ -168,6 +168,8 @@ export default { await import( './atomic-segmented-facet-scrollable/atomic-segmented-facet-scrollable.js' ), + 'atomic-smart-snippet': async () => + await import('./atomic-smart-snippet/atomic-smart-snippet.js'), 'atomic-smart-snippet-suggestions': async () => await import( './atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.js' From 5cff580abff089d293911f3aa8a8c8b2aeccf4f5 Mon Sep 17 00:00:00 2001 From: "developer-experience-bot[bot]" <91079284+developer-experience-bot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 19:22:32 +0000 Subject: [PATCH 11/18] Add generated files --- packages/atomic/src/components.d.ts | 125 ---------------------------- 1 file changed, 125 deletions(-) diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index f893095dcb5..d0871cdcb98 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -856,49 +856,6 @@ export namespace Components { "display": ItemDisplayLayout; "imageSize": ItemDisplayImageSize; } - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface AtomicSmartSnippet { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight": number; - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - "headingLevel": number; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight": number; - "snippetCollapsedHeight"?: number; - "snippetMaximumHeight"?: number; - /** - * Sets the style of the snippet. Example: ```ts smartSnippet.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - "tabsExcluded": string[] | string; - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - "tabsIncluded": string[] | string; - } interface AtomicSmartSnippetAnswer { "htmlContent": string; "innerStyle"?: string; @@ -1508,27 +1465,6 @@ declare global { prototype: HTMLAtomicResultPlaceholderElement; new (): HTMLAtomicResultPlaceholderElement; }; - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface HTMLAtomicSmartSnippetElement extends Components.AtomicSmartSnippet, HTMLStencilElement { - } - var HTMLAtomicSmartSnippetElement: { - prototype: HTMLAtomicSmartSnippetElement; - new (): HTMLAtomicSmartSnippetElement; - }; interface HTMLAtomicSmartSnippetAnswerElementEventMap { "answerSizeUpdated": {height: number}; "selectInlineLink": InlineLink; @@ -1682,7 +1618,6 @@ declare global { "atomic-recs-result": HTMLAtomicRecsResultElement; "atomic-recs-result-template": HTMLAtomicRecsResultTemplateElement; "atomic-result-placeholder": HTMLAtomicResultPlaceholderElement; - "atomic-smart-snippet": HTMLAtomicSmartSnippetElement; "atomic-smart-snippet-answer": HTMLAtomicSmartSnippetAnswerElement; "atomic-smart-snippet-feedback-modal": HTMLAtomicSmartSnippetFeedbackModalElement; "atomic-smart-snippet-source": HTMLAtomicSmartSnippetSourceElement; @@ -2479,49 +2414,6 @@ declare namespace LocalJSX { "display": ItemDisplayLayout; "imageSize": ItemDisplayImageSize; } - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface AtomicSmartSnippet { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight"?: number; - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - "headingLevel"?: number; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight"?: number; - "snippetCollapsedHeight"?: number; - "snippetMaximumHeight"?: number; - /** - * Sets the style of the snippet. Example: ```ts smartSnippet.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - "tabsExcluded"?: string[] | string; - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - "tabsIncluded"?: string[] | string; - } interface AtomicSmartSnippetAnswer { "htmlContent": string; "innerStyle"?: string; @@ -2690,7 +2582,6 @@ declare namespace LocalJSX { "atomic-recs-result": AtomicRecsResult; "atomic-recs-result-template": AtomicRecsResultTemplate; "atomic-result-placeholder": AtomicResultPlaceholder; - "atomic-smart-snippet": AtomicSmartSnippet; "atomic-smart-snippet-answer": AtomicSmartSnippetAnswer; "atomic-smart-snippet-feedback-modal": AtomicSmartSnippetFeedbackModal; "atomic-smart-snippet-source": AtomicSmartSnippetSource; @@ -2803,22 +2694,6 @@ declare module "@stencil/core" { * The `atomic-result-placeholder` component provides an intermediate visual state that is rendered before the first results are available. */ "atomic-result-placeholder": LocalJSX.AtomicResultPlaceholder & JSXBase.HTMLAttributes; - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - "atomic-smart-snippet": LocalJSX.AtomicSmartSnippet & JSXBase.HTMLAttributes; "atomic-smart-snippet-answer": LocalJSX.AtomicSmartSnippetAnswer & JSXBase.HTMLAttributes; /** * The `atomic-smart-snippet-feedback-modal` is automatically created as a child of the `atomic-search-interface` when the `atomic-smart-snippet` is initialized. From dc0fb6da34bd11f1439a072c0d28fb158eae6c5e Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Wed, 7 Jan 2026 12:41:31 -0500 Subject: [PATCH 12/18] apply corrections --- .../atomic-smart-snippet.mdx | 2 +- .../atomic-smart-snippet.spec.ts | 37 ++++++------------- .../atomic-smart-snippet.ts | 2 +- 3 files changed, 14 insertions(+), 27 deletions(-) diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx index fd345f7c6e5..0151b0d5854 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.mdx @@ -7,7 +7,7 @@ import { AtomicDocTemplate } from '@/storybook-utils/documentation/atomic-doc-te diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts index d32ec5c67ae..554aef6ee00 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts @@ -13,12 +13,6 @@ import {buildFakeSmartSnippet} from '@/vitest-utils/testing-helpers/fixtures/hea import {buildFakeTabManager} from '@/vitest-utils/testing-helpers/fixtures/headless/search/tab-manager-controller'; import type {AtomicSmartSnippet} from './atomic-smart-snippet'; -// Mock the Stencil component to avoid circular import issues with initialization-utils -vi.mock( - '@/src/components/common/smart-snippets/atomic-smart-snippet-source', - () => ({}) -); - import './atomic-smart-snippet'; vi.mock('@coveo/headless', {spy: true}); @@ -321,17 +315,24 @@ describe('atomic-smart-snippet', () => { .toBeInTheDocument(); }); - it('should reset feedbackSent when liked state changes to false', async () => { + it('should hide thank you message when liked state changes to false', async () => { mockedSmartSnippet.state.liked = true; - const {element} = await renderAtomicSmartSnippet(); + const {element, parts} = await renderAtomicSmartSnippet(); + element.requestUpdate(); - expect(element.feedbackSent).toBe(false); + await element.updateComplete; + await expect + .element(parts(element).feedbackThankYou!) + .toBeInTheDocument(); mockedSmartSnippet.state.liked = false; + element.requestUpdate(); - await element.updateComplete; - expect(element.feedbackSent).toBe(false); + await element.updateComplete; + await expect + .element(parts(element).feedbackThankYou!) + .not.toBeInTheDocument(); }); }); @@ -457,20 +458,6 @@ describe('atomic-smart-snippet', () => { expect(element.snippetStyle).toBe(customStyle); }); - // TODO: The template style extraction happens during rendering, not after appending - it.skip('should apply snippet style from template element', async () => { - const {element} = await renderAtomicSmartSnippet(); - - const template = document.createElement('template'); - const style = document.createElement('style'); - style.innerHTML = 'b { color: red; }'; - template.content.appendChild(style); - element.appendChild(template); - - // This test incorrectly compares element.style (CSSStyleDeclaration) to a string - expect(element.snippetStyle).toContain('color: red'); - }); - it('should use snippetStyle attribute when no template is present', async () => { const customStyle = 'b { color: green; }'; const {element} = await renderAtomicSmartSnippet({ diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts index 9b7a1def5e2..c65e67ec24a 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts @@ -92,7 +92,7 @@ export class AtomicSmartSnippet public tabManagerState!: TabManagerState; public tabManager!: TabManager; - @state() private feedbackSent = false; + @state() public feedbackSent = false; #id!: string; private modalRef?: HTMLAtomicSmartSnippetFeedbackModalElement; From 32d95e822a2b0b2b426dae9cac1cf4f8756f1b45 Mon Sep 17 00:00:00 2001 From: "developer-experience-bot[bot]" <91079284+developer-experience-bot[bot]@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:08:43 +0000 Subject: [PATCH 13/18] Add generated files --- packages/atomic/src/components.d.ts | 125 ------------------ .../atomic/src/utils/custom-element-tags.ts | 1 + 2 files changed, 1 insertion(+), 125 deletions(-) diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 6127fd7a9d1..fd25ae96185 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -799,49 +799,6 @@ export namespace Components { "display": ItemDisplayLayout; "imageSize": ItemDisplayImageSize; } - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface AtomicSmartSnippet { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight": number; - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - "headingLevel": number; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight": number; - "snippetCollapsedHeight"?: number; - "snippetMaximumHeight"?: number; - /** - * Sets the style of the snippet. Example: ```ts smartSnippet.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - "tabsExcluded": string[] | string; - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - "tabsIncluded": string[] | string; - } /** * The `atomic-smart-snippet-feedback-modal` is automatically created as a child of the `atomic-search-interface` when the `atomic-smart-snippet` is initialized. * When the modal is opened, the class `atomic-modal-opened` is added to the body, allowing further customization. @@ -1380,27 +1337,6 @@ declare global { prototype: HTMLAtomicResultPlaceholderElement; new (): HTMLAtomicResultPlaceholderElement; }; - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface HTMLAtomicSmartSnippetElement extends Components.AtomicSmartSnippet, HTMLStencilElement { - } - var HTMLAtomicSmartSnippetElement: { - prototype: HTMLAtomicSmartSnippetElement; - new (): HTMLAtomicSmartSnippetElement; - }; interface HTMLAtomicSmartSnippetFeedbackModalElementEventMap { "feedbackSent": any; } @@ -1526,7 +1462,6 @@ declare global { "atomic-recs-result": HTMLAtomicRecsResultElement; "atomic-recs-result-template": HTMLAtomicRecsResultTemplateElement; "atomic-result-placeholder": HTMLAtomicResultPlaceholderElement; - "atomic-smart-snippet": HTMLAtomicSmartSnippetElement; "atomic-smart-snippet-feedback-modal": HTMLAtomicSmartSnippetFeedbackModalElement; "atomic-smart-snippet-source": HTMLAtomicSmartSnippetSourceElement; "atomic-stencil-facet-date-input": HTMLAtomicStencilFacetDateInputElement; @@ -2268,49 +2203,6 @@ declare namespace LocalJSX { "display": ItemDisplayLayout; "imageSize": ItemDisplayImageSize; } - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface AtomicSmartSnippet { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight"?: number; - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - "headingLevel"?: number; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight"?: number; - "snippetCollapsedHeight"?: number; - "snippetMaximumHeight"?: number; - /** - * Sets the style of the snippet. Example: ```ts smartSnippet.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - "tabsExcluded"?: string[] | string; - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - "tabsIncluded"?: string[] | string; - } /** * The `atomic-smart-snippet-feedback-modal` is automatically created as a child of the `atomic-search-interface` when the `atomic-smart-snippet` is initialized. * When the modal is opened, the class `atomic-modal-opened` is added to the body, allowing further customization. @@ -2463,7 +2355,6 @@ declare namespace LocalJSX { "atomic-recs-result": AtomicRecsResult; "atomic-recs-result-template": AtomicRecsResultTemplate; "atomic-result-placeholder": AtomicResultPlaceholder; - "atomic-smart-snippet": AtomicSmartSnippet; "atomic-smart-snippet-feedback-modal": AtomicSmartSnippetFeedbackModal; "atomic-smart-snippet-source": AtomicSmartSnippetSource; "atomic-stencil-facet-date-input": AtomicStencilFacetDateInput; @@ -2567,22 +2458,6 @@ declare module "@stencil/core" { * The `atomic-result-placeholder` component provides an intermediate visual state that is rendered before the first results are available. */ "atomic-result-placeholder": LocalJSX.AtomicResultPlaceholder & JSXBase.HTMLAttributes; - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - "atomic-smart-snippet": LocalJSX.AtomicSmartSnippet & JSXBase.HTMLAttributes; /** * The `atomic-smart-snippet-feedback-modal` is automatically created as a child of the `atomic-search-interface` when the `atomic-smart-snippet` is initialized. * When the modal is opened, the class `atomic-modal-opened` is added to the body, allowing further customization. diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 2fa3dc90d8b..36d7dbf21b5 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -149,6 +149,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-search-layout', 'atomic-segmented-facet', 'atomic-segmented-facet-scrollable', + 'atomic-smart-snippet', 'atomic-smart-snippet-answer', 'atomic-smart-snippet-collapse-wrapper', 'atomic-smart-snippet-expandable-answer', From e722a7a8fd33d9ffeb841b85426074cbab8e3a02 Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Thu, 8 Jan 2026 11:10:15 -0500 Subject: [PATCH 14/18] use msw --- .../atomic-smart-snippet.new.stories.tsx | 83 +++++++++++-------- 1 file changed, 48 insertions(+), 35 deletions(-) diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx index d3d300f6e7c..0c3dfeb8832 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx @@ -1,47 +1,18 @@ +import type {Result} from '@coveo/headless'; import type {Meta, StoryObj as Story} from '@storybook/web-components-vite'; import {getStorybookHelpers} from '@wc-toolkit/storybook-helpers'; +import {MockSearchApi} from '@/storybook-utils/api/search/mock'; import {parameters} from '@/storybook-utils/common/common-meta-parameters'; import {wrapInSearchInterface} from '@/storybook-utils/search/search-interface-wrapper'; +const mockSearchApi = new MockSearchApi(); + const {events, args, argTypes, template} = getStorybookHelpers( 'atomic-smart-snippet', {excludeCategories: ['methods']} ); -const {decorator, play} = wrapInSearchInterface({ - config: { - search: { - preprocessSearchResponseMiddleware: (r) => { - const [result] = r.body.results; - result.title = 'Manage the Coveo In-Product Experiences (IPX)'; - result.clickUri = 'https://docs.coveo.com/en/3160'; - r.body.questionAnswer = { - documentId: { - contentIdKey: 'permanentid', - contentIdValue: result.raw.permanentid!, - }, - question: 'Creating an In-Product Experience (IPX)', - answerSnippet: ` -
    -
  1. On the In-Product Experiences page, click Add In-Product Experience.
  2. -
  3. In the Configuration tab, fill the Basic settings section.
  4. -
  5. (Optional) Use the Design and Content access tabs to customize your IPX interface.
  6. -
  7. Click Save.
  8. -
  9. In the Loader snippet panel that appears, you may click Copy to save the loader snippet for your IPX to your clipboard, and then click Save. You can Always retrieve the loader snippet later.
  10. -
- -

- You're now ready to embed your IPX interface. However, we recommend that you configure query pipelines for your IPX interface before. -

- `, - relatedQuestions: [], - score: 1337, - }; - return r; - }, - }, - }, -}); +const {decorator, play} = wrapInSearchInterface(); const meta: Meta = { component: 'atomic-smart-snippet', @@ -54,10 +25,52 @@ const meta: Meta = { actions: { handles: events, }, + msw: { + handlers: [...mockSearchApi.handlers], + }, }, args, argTypes, - + beforeEach: async () => { + mockSearchApi.searchEndpoint.clear(); + mockSearchApi.searchEndpoint.mockOnce((response) => { + if (!('results' in response)) return response; + const [result] = response.results as Result[]; + return { + ...response, + results: [ + { + ...result, + title: 'Manage the Coveo In-Product Experiences (IPX)', + clickUri: 'https://docs.coveo.com/en/3160', + }, + ...response.results.slice(1), + ], + questionAnswer: { + answerFound: true, + documentId: { + contentIdKey: 'permanentid', + contentIdValue: result.raw.permanentid, + }, + question: 'Creating an In-Product Experience (IPX)', + answerSnippet: ` +
    +
  1. On the In-Product Experiences page, click Add In-Product Experience.
  2. +
  3. In the Configuration tab, fill the Basic settings section.
  4. +
  5. (Optional) Use the Design and Content access tabs to customize your IPX interface.
  6. +
  7. Click Save.
  8. +
  9. In the Loader snippet panel that appears, you may click Copy to save the loader snippet for your IPX to your clipboard, and then click Save. You can always retrieve the loader snippet later.
  10. +
+

+ You're now ready to embed your IPX interface. However, we recommend that you configure query pipelines for your IPX interface before. +

+ `, + relatedQuestions: [], + score: 1337, + }, + }; + }); + }, play, }; From 8a122b2c954664d98fb37dbbdae9462e94ea6e57 Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Thu, 8 Jan 2026 12:18:41 -0500 Subject: [PATCH 15/18] fix tests --- ...ic-smart-snippet-expandable-answer.spec.ts | 13 +- .../atomic-smart-snippet-expandable-answer.ts | 1 + .../snippet-truncated-answer.ts | 1 + .../atomic-smart-snippet-suggestions.ts | 1 + .../atomic-smart-snippet.new.stories.tsx | 6 +- .../atomic-smart-snippet.spec.ts | 280 +++++++----------- 6 files changed, 126 insertions(+), 176 deletions(-) diff --git a/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.spec.ts b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.spec.ts index 25e104e8ffa..10af031e43f 100644 --- a/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.spec.ts +++ b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.spec.ts @@ -1,7 +1,6 @@ import type {i18n} from 'i18next'; import {html} from 'lit'; import {beforeEach, describe, expect, it} from 'vitest'; -import {page} from 'vitest/browser'; import {fixture} from '@/vitest-utils/testing-helpers/fixture'; import {createTestI18n} from '@/vitest-utils/testing-helpers/i18n-utils'; import {AtomicSmartSnippetExpandableAnswer} from './atomic-smart-snippet-expandable-answer'; @@ -202,7 +201,7 @@ describe('atomic-smart-snippet-expandable-answer', () => { describe('events', () => { it('should emit expand event when show-more button is clicked', async () => { - const {element} = await renderComponent({expanded: false}); + const {element, parts} = await renderComponent({expanded: false}); await setElementHeight(element, 300); await element.requestUpdate(); @@ -213,14 +212,14 @@ describe('atomic-smart-snippet-expandable-answer', () => { expandEventFired = true; }); - const button = page.getByRole('button'); - await button.click(); + const showMoreButton = parts(element).showMoreButton as HTMLElement; + showMoreButton.click(); expect(expandEventFired).toBe(true); }); it('should emit collapse event when show-less button is clicked', async () => { - const {element} = await renderComponent({expanded: true}); + const {element, parts} = await renderComponent({expanded: true}); await setElementHeight(element, 300); await element.requestUpdate(); @@ -231,8 +230,8 @@ describe('atomic-smart-snippet-expandable-answer', () => { collapseEventFired = true; }); - const button = page.getByRole('button'); - await button.click(); + const showLessButton = parts(element).showLessButton as HTMLElement; + showLessButton.click(); expect(collapseEventFired).toBe(true); }); diff --git a/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.ts b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.ts index c1fe771888b..dbdd6fddd54 100644 --- a/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.ts +++ b/packages/atomic/src/components/common/atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.ts @@ -13,6 +13,7 @@ import ArrowDown from '@/src/images/arrow-down.svg'; import {listenOnce} from '@/src/utils/event-utils.js'; import styles from './atomic-smart-snippet-expandable-answer.tw.css.js'; import '@/src/components/common/atomic-icon/atomic-icon.js'; +import '@/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js'; // TODO: uncomment when PR #6781 is merged // import '@/src/components/common/smart-snippets/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js'; diff --git a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer.ts b/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer.ts index 0e0546a2d0a..9e34450ad22 100644 --- a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer.ts +++ b/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet/snippet-truncated-answer.ts @@ -1,6 +1,7 @@ import {html} from 'lit'; import {ifDefined} from 'lit/directives/if-defined.js'; import type {FunctionalComponent} from '@/src/utils/functional-component-utils'; +import '@/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js'; export interface SnippetTruncatedAnswerProps { answer: string; diff --git a/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.ts b/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.ts index 261424434e6..77df7361ce7 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.ts @@ -28,6 +28,7 @@ import ArrowDown from '@/src/images/arrow-down.svg'; import ArrowRight from '@/src/images/arrow-right.svg'; import {randomID} from '@/src/utils/utils'; import '@/src/components/common/atomic-icon/atomic-icon'; +import '@/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.js'; import styles from './atomic-smart-snippet-suggestions.tw.css'; /** diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx index 0c3dfeb8832..31d049baafb 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.new.stories.tsx @@ -16,7 +16,7 @@ const {decorator, play} = wrapInSearchInterface(); const meta: Meta = { component: 'atomic-smart-snippet', - title: 'Search/SmartSnippet', + title: 'Search/Smart Snippet', id: 'atomic-smart-snippet', render: (args) => template(args), decorators: [decorator], @@ -76,6 +76,4 @@ const meta: Meta = { export default meta; -export const Default: Story = { - name: 'atomic-smart-snippet', -}; +export const Default: Story = {}; diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts index 554aef6ee00..989b255b168 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts @@ -7,7 +7,6 @@ import { } from '@coveo/headless'; import {html} from 'lit'; import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {page} from 'vitest/browser'; import {renderInAtomicSearchInterface} from '@/vitest-utils/testing-helpers/fixtures/atomic/search/atomic-search-interface-fixture'; import {buildFakeSmartSnippet} from '@/vitest-utils/testing-helpers/fixtures/headless/search/smart-snippet-controller'; import {buildFakeTabManager} from '@/vitest-utils/testing-helpers/fixtures/headless/search/tab-manager-controller'; @@ -75,28 +74,38 @@ describe('atomic-smart-snippet', () => { selector: 'atomic-smart-snippet', }); - const parts = (el: AtomicSmartSnippet) => ({ - smartSnippet: el.shadowRoot?.querySelector('[part~="smart-snippet"]'), - question: el.shadowRoot?.querySelector('[part~="question"]'), - answer: el.shadowRoot?.querySelector('[part~="answer"]'), - truncatedAnswer: el.shadowRoot?.querySelector( + const getParts = () => ({ + smartSnippet: element.shadowRoot?.querySelector( + '[part~="smart-snippet"]' + ), + question: element.shadowRoot?.querySelector('[part~="question"]'), + answer: element.shadowRoot?.querySelector('atomic-smart-snippet-answer'), + truncatedAnswer: element.shadowRoot?.querySelector( '[part~="truncated-answer"]' ), - body: el.shadowRoot?.querySelector('[part~="body"]'), - footer: el.shadowRoot?.querySelector('[part~="footer"]'), - feedbackBanner: el.shadowRoot?.querySelector('[part~="feedback-banner"]'), - feedbackLikeButton: el.shadowRoot?.querySelector( - '[part~="feedback-like-button"]' + body: element.shadowRoot?.querySelector('[part~="body"]'), + footer: element.shadowRoot?.querySelector('[part~="footer"]'), + feedbackBanner: element.shadowRoot?.querySelector( + '[part~="feedback-banner"]' ), - feedbackDislikeButton: el.shadowRoot?.querySelector( + feedbackLikeButton: element.shadowRoot?.querySelector( + '[part~="feedback-like-button"]' + ) as HTMLElement | null, + feedbackDislikeButton: element.shadowRoot?.querySelector( '[part~="feedback-dislike-button"]' - ), - feedbackThankYou: el.shadowRoot?.querySelector( + ) as HTMLElement | null, + feedbackThankYou: element.shadowRoot?.querySelector( '[part~="feedback-thank-you"]' ), + expandableAnswer: element.shadowRoot?.querySelector( + 'atomic-smart-snippet-expandable-answer' + ), + source: element.shadowRoot?.querySelector('atomic-smart-snippet-source'), + sourceUrl: element.shadowRoot?.querySelector('[part~="source-url"]'), + sourceTitle: element.shadowRoot?.querySelector('[part~="source-title"]'), }); - return {element, parts}; + return {element, getParts}; }; describe('when controller is initialized', () => { @@ -117,66 +126,55 @@ describe('atomic-smart-snippet', () => { }); it('should render the smart snippet', async () => { - const {element, parts} = await renderAtomicSmartSnippet(); - await expect.element(parts(element).smartSnippet!).toBeInTheDocument(); + const {getParts} = await renderAtomicSmartSnippet(); + expect(getParts().smartSnippet).toBeInTheDocument(); }); it('should render the question', async () => { - const {element, parts} = await renderAtomicSmartSnippet(); - const question = parts(element).question!; - await expect.element(question).toBeInTheDocument(); + const {getParts} = await renderAtomicSmartSnippet(); + const question = getParts().question!; + expect(question).toBeInTheDocument(); expect(question.textContent?.trim()).toBe( mockedSmartSnippet.state.question ); }); it('should render the expandable answer when snippetMaximumHeight is undefined', async () => { - const {element} = await renderAtomicSmartSnippet({ + const {getParts} = await renderAtomicSmartSnippet({ props: {snippetMaximumHeight: undefined}, }); - const expandableAnswer = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-expandable-answer' - ); - await expect.element(expandableAnswer!).toBeInTheDocument(); + expect(getParts().expandableAnswer).toBeInTheDocument(); }); it('should render the truncated answer when snippetMaximumHeight is defined', async () => { - const {element, parts} = await renderAtomicSmartSnippet({ + const {getParts} = await renderAtomicSmartSnippet({ props: {snippetMaximumHeight: 200}, }); - await expect.element(parts(element).truncatedAnswer!).toBeInTheDocument(); + expect(getParts().truncatedAnswer).toBeInTheDocument(); }); it('should render the footer', async () => { - const {element, parts} = await renderAtomicSmartSnippet(); - await expect.element(parts(element).footer!).toBeInTheDocument(); + const {getParts} = await renderAtomicSmartSnippet(); + expect(getParts().footer).toBeInTheDocument(); }); it('should render the source when source is present', async () => { - const {element} = await renderAtomicSmartSnippet(); - const source = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-source' - ); - await expect.element(source!).toBeInTheDocument(); + const {getParts} = await renderAtomicSmartSnippet(); + expect(getParts().source).toBeInTheDocument(); }); it('should render the feedback banner', async () => { - const {element, parts} = await renderAtomicSmartSnippet(); - await expect.element(parts(element).feedbackBanner!).toBeInTheDocument(); + const {getParts} = await renderAtomicSmartSnippet(); + expect(getParts().feedbackBanner).toBeInTheDocument(); }); // TODO: Enable when atomic-smart-snippet-source is migrated to Lit it.skip('should render source url and title with correct href', async () => { - const {element} = await renderAtomicSmartSnippet(); - const sourceUrl = element.shadowRoot?.querySelector( - '[part~="source-url"]' - ); - const sourceTitle = element.shadowRoot?.querySelector( - '[part~="source-title"]' - ); + const {getParts} = await renderAtomicSmartSnippet(); + const {sourceUrl, sourceTitle} = getParts(); - await expect.element(sourceUrl!).toBeInTheDocument(); - await expect.element(sourceTitle!).toBeInTheDocument(); + expect(sourceUrl).toBeInTheDocument(); + expect(sourceTitle).toBeInTheDocument(); expect(sourceUrl?.getAttribute('href')).toBe( mockedSmartSnippet.state.source?.clickUri ); @@ -186,71 +184,53 @@ describe('atomic-smart-snippet', () => { }); }); - describe('when answer is not found', () => { - beforeEach(() => { - mockedSmartSnippet.state.answerFound = false; - }); - - it('should not render the smart snippet', async () => { - const {element, parts} = await renderAtomicSmartSnippet(); - await expect - .element(parts(element).smartSnippet!) - .not.toBeInTheDocument(); - }); + it('should not render the smart snippet when answer is not found', async () => { + mockedSmartSnippet.state.answerFound = false; + const {getParts} = await renderAtomicSmartSnippet(); + expect(getParts().smartSnippet).not.toBeInTheDocument(); }); - describe('when source is not present', () => { - beforeEach(() => { - mockedSmartSnippet.state.source = null; - }); - - it('should not render the source', async () => { - const {element} = await renderAtomicSmartSnippet(); - const source = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-source' - ); - expect(source).toBeNull(); - }); + it('should not render the source when source is not present', async () => { + // @ts-expect-error: Testing null source + mockedSmartSnippet.state.source = null; + const {getParts} = await renderAtomicSmartSnippet(); + expect(getParts().source).toBeNull(); }); describe('tab filtering', () => { describe('when tabsIncluded is set', () => { it('should render when current tab is included', async () => { mockedTabManager.state.activeTab = 'tab1'; - const {element, parts} = await renderAtomicSmartSnippet({ + const {getParts} = await renderAtomicSmartSnippet({ props: {tabsIncluded: ['tab1', 'tab2']}, }); - await expect.element(parts(element).smartSnippet!).toBeInTheDocument(); + expect(getParts().smartSnippet).toBeInTheDocument(); }); it('should not render when current tab is not included', async () => { mockedTabManager.state.activeTab = 'tab3'; - const {element, parts} = await renderAtomicSmartSnippet({ + const {getParts} = await renderAtomicSmartSnippet({ props: {tabsIncluded: ['tab1', 'tab2']}, }); - await expect - .element(parts(element).smartSnippet!) - .not.toBeInTheDocument(); + expect(getParts().smartSnippet).not.toBeInTheDocument(); }); }); describe('when tabsExcluded is set', () => { it('should not render when current tab is excluded', async () => { mockedTabManager.state.activeTab = 'tab1'; - const {element, parts} = await renderAtomicSmartSnippet({ + const {getParts} = await renderAtomicSmartSnippet({ props: {tabsExcluded: ['tab1', 'tab2']}, }); - await expect - .element(parts(element).smartSnippet!) - .not.toBeInTheDocument(); + expect(getParts().smartSnippet).not.toBeInTheDocument(); }); it('should render when current tab is not excluded', async () => { mockedTabManager.state.activeTab = 'tab3'; - const {element, parts} = await renderAtomicSmartSnippet({ + const {getParts} = await renderAtomicSmartSnippet({ props: {tabsExcluded: ['tab1', 'tab2']}, }); - await expect.element(parts(element).smartSnippet!).toBeInTheDocument(); + expect(getParts().smartSnippet).toBeInTheDocument(); }); }); }); @@ -259,23 +239,23 @@ describe('atomic-smart-snippet', () => { // TODO: Enable when feedback button selectors are fixed it.skip('should call smartSnippet.like() when like button is clicked', async () => { const likeSpy = vi.spyOn(mockedSmartSnippet, 'like'); - await renderAtomicSmartSnippet(); - await page.getByRole('radiogroup').getByText('yes').click(); + const {getParts} = await renderAtomicSmartSnippet(); + getParts().feedbackLikeButton?.click(); expect(likeSpy).toHaveBeenCalled(); }); // TODO: Enable when feedback button selectors are fixed it.skip('should call smartSnippet.dislike() when dislike button is clicked', async () => { const dislikeSpy = vi.spyOn(mockedSmartSnippet, 'dislike'); - await renderAtomicSmartSnippet(); - await page.getByRole('radiogroup').getByText('no').click(); + const {getParts} = await renderAtomicSmartSnippet(); + getParts().feedbackDislikeButton?.click(); expect(dislikeSpy).toHaveBeenCalled(); }); // TODO: Enable when feedback button selectors are fixed it.skip('should load modal when dislike button is clicked', async () => { - const {element} = await renderAtomicSmartSnippet(); - await page.getByRole('radiogroup').getByText('no').click(); + const {element, getParts} = await renderAtomicSmartSnippet(); + getParts().feedbackDislikeButton?.click(); await vi.waitFor(() => { const modal = element @@ -288,51 +268,43 @@ describe('atomic-smart-snippet', () => { // TODO: Enable when feedback button selectors are fixed it.skip('should show thank you message after liking', async () => { mockedSmartSnippet.state.liked = false; - const {element, parts} = await renderAtomicSmartSnippet(); + const {element, getParts} = await renderAtomicSmartSnippet(); - await page.getByRole('radiogroup').getByText('yes').click(); + getParts().feedbackLikeButton?.click(); mockedSmartSnippet.state.liked = true; element.requestUpdate(); await element.updateComplete; - await expect - .element(parts(element).feedbackThankYou!) - .toBeInTheDocument(); + expect(getParts().feedbackThankYou).toBeInTheDocument(); }); // TODO: Enable when feedback button selectors are fixed it.skip('should show thank you message after disliking', async () => { mockedSmartSnippet.state.disliked = false; - const {element, parts} = await renderAtomicSmartSnippet(); + const {element, getParts} = await renderAtomicSmartSnippet(); - await page.getByRole('radiogroup').getByText('no').click(); + getParts().feedbackDislikeButton?.click(); mockedSmartSnippet.state.disliked = true; element.requestUpdate(); await element.updateComplete; - await expect - .element(parts(element).feedbackThankYou!) - .toBeInTheDocument(); + expect(getParts().feedbackThankYou).toBeInTheDocument(); }); it('should hide thank you message when liked state changes to false', async () => { mockedSmartSnippet.state.liked = true; - const {element, parts} = await renderAtomicSmartSnippet(); + const {element, getParts} = await renderAtomicSmartSnippet(); element.requestUpdate(); await element.updateComplete; - await expect - .element(parts(element).feedbackThankYou!) - .toBeInTheDocument(); + expect(getParts().feedbackThankYou).toBeInTheDocument(); mockedSmartSnippet.state.liked = false; element.requestUpdate(); await element.updateComplete; - await expect - .element(parts(element).feedbackThankYou!) - .not.toBeInTheDocument(); + expect(getParts().feedbackThankYou).not.toBeInTheDocument(); }); }); @@ -389,65 +361,53 @@ describe('atomic-smart-snippet', () => { describe('expandable answer integration', () => { it('should call smartSnippet.expand() when expand event is dispatched', async () => { const expandSpy = vi.spyOn(mockedSmartSnippet, 'expand'); - const {element} = await renderAtomicSmartSnippet(); - const expandableAnswer = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-expandable-answer' - ); - expandableAnswer?.dispatchEvent(new CustomEvent('expand')); + const {getParts} = await renderAtomicSmartSnippet(); + getParts().expandableAnswer?.dispatchEvent(new CustomEvent('expand')); expect(expandSpy).toHaveBeenCalled(); }); it('should call smartSnippet.collapse() when collapse event is dispatched', async () => { const collapseSpy = vi.spyOn(mockedSmartSnippet, 'collapse'); - const {element} = await renderAtomicSmartSnippet(); - const expandableAnswer = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-expandable-answer' - ); - expandableAnswer?.dispatchEvent(new CustomEvent('collapse')); + const {getParts} = await renderAtomicSmartSnippet(); + getParts().expandableAnswer?.dispatchEvent(new CustomEvent('collapse')); expect(collapseSpy).toHaveBeenCalled(); }); }); describe('props', () => { it('should pass headingLevel prop to question renderer', async () => { - const {element, parts} = await renderAtomicSmartSnippet({ + const {element, getParts} = await renderAtomicSmartSnippet({ props: {headingLevel: 2}, }); expect(element.headingLevel).toBe(2); - await expect.element(parts(element).question!).toBeInTheDocument(); + expect(getParts().question).toBeInTheDocument(); }); it('should use heading level 0 when no heading level is specified', async () => { - const {element, parts} = await renderAtomicSmartSnippet({ + const {element, getParts} = await renderAtomicSmartSnippet({ props: {headingLevel: 0}, }); expect(element.headingLevel).toBe(0); - await expect.element(parts(element).question!).toBeInTheDocument(); + expect(getParts().question).toBeInTheDocument(); - const question = parts(element).question!; + const question = getParts().question!; expect(question.tagName).toBe('DIV'); }); it('should pass maximumHeight prop to expandable answer', async () => { - const {element} = await renderAtomicSmartSnippet({ + const {element, getParts} = await renderAtomicSmartSnippet({ props: {maximumHeight: 300}, }); - const expandableAnswer = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-expandable-answer' - ); expect(element.maximumHeight).toBe(300); - await expect.element(expandableAnswer!).toBeInTheDocument(); + expect(getParts().expandableAnswer).toBeInTheDocument(); }); it('should pass collapsedHeight prop to expandable answer', async () => { - const {element} = await renderAtomicSmartSnippet({ + const {element, getParts} = await renderAtomicSmartSnippet({ props: {collapsedHeight: 150}, }); - const expandableAnswer = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-expandable-answer' - ); expect(element.collapsedHeight).toBe(150); - await expect.element(expandableAnswer!).toBeInTheDocument(); + expect(getParts().expandableAnswer).toBeInTheDocument(); }); it('should accept snippetStyle prop', async () => { @@ -469,67 +429,57 @@ describe('atomic-smart-snippet', () => { }); }); - describe('event listener cleanup', () => { - it('should remove event listeners when disconnected', async () => { - const {element} = await renderAtomicSmartSnippet(); - const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener'); - element.disconnectedCallback(); - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'selectInlineLink', - expect.any(Function) - ); - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'beginDelayedSelectInlineLink', - expect.any(Function) - ); - expect(removeEventListenerSpy).toHaveBeenCalledWith( - 'cancelPendingSelectInlineLink', - expect.any(Function) - ); - }); + it('should remove event listeners when disconnected', async () => { + const {element} = await renderAtomicSmartSnippet(); + const removeEventListenerSpy = vi.spyOn(element, 'removeEventListener'); + element.disconnectedCallback(); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'selectInlineLink', + expect.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'beginDelayedSelectInlineLink', + expect.any(Function) + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'cancelPendingSelectInlineLink', + expect.any(Function) + ); }); describe('dynamic updates', () => { it('should update question when smartSnippetState changes', async () => { - const {element, parts} = await renderAtomicSmartSnippet(); + const {element, getParts} = await renderAtomicSmartSnippet(); const newQuestion = 'What is the answer to everything?'; mockedSmartSnippet.state.question = newQuestion; element.requestUpdate(); await element.updateComplete; - const question = parts(element).question!; + const question = getParts().question!; expect(question.textContent?.trim()).toBe(newQuestion); }); it('should update answer when smartSnippetState changes', async () => { - const {element} = await renderAtomicSmartSnippet(); + const {element, getParts} = await renderAtomicSmartSnippet(); const newAnswer = '

New answer content

'; mockedSmartSnippet.state.answer = newAnswer; element.requestUpdate(); await element.updateComplete; - const answer = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-answer' - ); - await expect.element(answer!).toBeInTheDocument(); + expect(getParts().answer).toBeInTheDocument(); }); }); - describe('slot attributes', () => { - it('should pass slot attributes to source anchor', async () => { - const {element} = await renderAtomicSmartSnippet(); + it('should pass slot attributes to source anchor', async () => { + const {element, getParts} = await renderAtomicSmartSnippet(); - const slotElement = document.createElement('a'); - slotElement.setAttribute('slot', 'source-anchor-attributes'); - slotElement.setAttribute('target', '_blank'); - element.appendChild(slotElement); + const slotElement = document.createElement('a'); + slotElement.setAttribute('slot', 'source-anchor-attributes'); + slotElement.setAttribute('target', '_blank'); + element.appendChild(slotElement); - const source = element.shadowRoot?.querySelector( - 'atomic-smart-snippet-source' - ); - await expect.element(source!).toBeInTheDocument(); - }); + expect(getParts().source).toBeInTheDocument(); }); }); From 6d70b4681ab42cd94d56d2308778f3663b10027f Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Thu, 8 Jan 2026 12:51:20 -0500 Subject: [PATCH 16/18] move smart snippet suggestion story one level up --- .../atomic-smart-snippet-suggestions.new.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.new.stories.tsx b/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.new.stories.tsx index 363ff0da8fe..39331bf7821 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.new.stories.tsx +++ b/packages/atomic/src/components/search/atomic-smart-snippet-suggestions/atomic-smart-snippet-suggestions.new.stories.tsx @@ -162,7 +162,7 @@ const {decorator, play} = wrapInSearchInterface({ const meta: Meta = { component: 'atomic-smart-snippet-suggestions', - title: 'Search/SmartSnippet/SmartSnippetSuggestions', + title: 'Search/Smart Snippet Suggestions', id: 'atomic-smart-snippet-suggestions', render: (args) => template(args), decorators: [decorator], From 169349a74fccd0cf38d1d8e798cda250b593e44e Mon Sep 17 00:00:00 2001 From: ylakhdar Date: Mon, 12 Jan 2026 15:29:49 -0500 Subject: [PATCH 17/18] corrections --- packages/atomic/src/components.d.ts | 125 ------------------ .../atomic-smart-snippet.spec.ts | 2 +- .../atomic-smart-snippet.ts | 8 +- .../atomic-smart-snippet.tw.css.ts | 1 + .../atomic/src/components/search/index.ts | 1 + .../src/components/search/lazy-index.ts | 2 + 6 files changed, 10 insertions(+), 129 deletions(-) diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index e7cd1c2abd1..cc418d4176e 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -747,49 +747,6 @@ export namespace Components { */ "store"?: RecsStore; } - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface AtomicSmartSnippet { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight": number; - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - "headingLevel": number; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight": number; - "snippetCollapsedHeight"?: number; - "snippetMaximumHeight"?: number; - /** - * Sets the style of the snippet. Example: ```ts smartSnippet.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - "tabsExcluded": string[] | string; - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - "tabsIncluded": string[] | string; - } /** * @deprecated Use `atomic-facet-date-input` instead. This component is meant to be used with Stencil components only. * Internal component made to be integrated in a TimeframeFacet. @@ -1277,27 +1234,6 @@ declare global { prototype: HTMLAtomicRecsResultElement; new (): HTMLAtomicRecsResultElement; }; - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface HTMLAtomicSmartSnippetElement extends Components.AtomicSmartSnippet, HTMLStencilElement { - } - var HTMLAtomicSmartSnippetElement: { - prototype: HTMLAtomicSmartSnippetElement; - new (): HTMLAtomicSmartSnippetElement; - }; interface HTMLAtomicStencilFacetDateInputElementEventMap { "atomic/dateInputApply": any; } @@ -1380,7 +1316,6 @@ declare global { "atomic-quickview-modal": HTMLAtomicQuickviewModalElement; "atomic-recs-list": HTMLAtomicRecsListElement; "atomic-recs-result": HTMLAtomicRecsResultElement; - "atomic-smart-snippet": HTMLAtomicSmartSnippetElement; "atomic-stencil-facet-date-input": HTMLAtomicStencilFacetDateInputElement; "atomic-suggestion-renderer": HTMLAtomicSuggestionRendererElement; "atomic-timeframe-facet": HTMLAtomicTimeframeFacetElement; @@ -2072,49 +2007,6 @@ declare namespace LocalJSX { */ "store"?: RecsStore; } - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - interface AtomicSmartSnippet { - /** - * When the answer is partly hidden, how much of its height (in pixels) should be visible. - */ - "collapsedHeight"?: number; - /** - * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. - */ - "headingLevel"?: number; - /** - * The maximum height (in pixels) a snippet can have before the component truncates it and displays a "show more" button. - */ - "maximumHeight"?: number; - "snippetCollapsedHeight"?: number; - "snippetMaximumHeight"?: number; - /** - * Sets the style of the snippet. Example: ```ts smartSnippet.snippetStyle = ` b { color: blue; } `; ``` - */ - "snippetStyle"?: string; - /** - * The tabs on which this smart snippet must not be displayed. This property should not be used at the same time as `tabs-included`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet won't be displayed on any of the specified tabs. - */ - "tabsExcluded"?: string[] | string; - /** - * The tabs on which the smart snippet can be displayed. This property should not be used at the same time as `tabs-excluded`. Set this property as a stringified JSON array, for example: ```html ``` If you don't set this property, the smart snippet can be displayed on any tab. Otherwise, the smart snippet can only be displayed on the specified tabs. - */ - "tabsIncluded"?: string[] | string; - } /** * @deprecated Use `atomic-facet-date-input` instead. This component is meant to be used with Stencil components only. * Internal component made to be integrated in a TimeframeFacet. @@ -2248,7 +2140,6 @@ declare namespace LocalJSX { "atomic-quickview-modal": AtomicQuickviewModal; "atomic-recs-list": AtomicRecsList; "atomic-recs-result": AtomicRecsResult; - "atomic-smart-snippet": AtomicSmartSnippet; "atomic-stencil-facet-date-input": AtomicStencilFacetDateInput; "atomic-suggestion-renderer": AtomicSuggestionRenderer; "atomic-timeframe-facet": AtomicTimeframeFacet; @@ -2334,22 +2225,6 @@ declare module "@stencil/core" { * The `atomic-recs-result` component is used internally by the `atomic-recs-list` component. */ "atomic-recs-result": LocalJSX.AtomicRecsResult & JSXBase.HTMLAttributes; - /** - * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. - * You can style the snippet by inserting a template element as follows: - * ```html - * - * - * - * ``` - */ - "atomic-smart-snippet": LocalJSX.AtomicSmartSnippet & JSXBase.HTMLAttributes; /** * @deprecated Use `atomic-facet-date-input` instead. This component is meant to be used with Stencil components only. * Internal component made to be integrated in a TimeframeFacet. diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts index 989b255b168..7b7a459665f 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.spec.ts @@ -468,7 +468,7 @@ describe('atomic-smart-snippet', () => { element.requestUpdate(); await element.updateComplete; - expect(getParts().answer).toBeInTheDocument(); + expect(getParts().expandableAnswer).toBeInTheDocument(); }); }); diff --git a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts index c65e67ec24a..8ce64c5e584 100644 --- a/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts +++ b/packages/atomic/src/components/search/atomic-smart-snippet/atomic-smart-snippet.ts @@ -30,6 +30,8 @@ import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles'; import {shouldDisplayOnCurrentTab} from '@/src/utils/tab-utils'; import {randomID} from '@/src/utils/utils'; import styles from './atomic-smart-snippet.tw.css'; +import '@/src/components/common/atomic-smart-snippet-source/atomic-smart-snippet-source'; +import type {AtomicSmartSnippetFeedbackModal} from '@/src/components/search/atomic-smart-snippet-feedback-modal/atomic-smart-snippet-feedback-modal.js'; /** * The `atomic-smart-snippet` component displays the excerpt of a document that would be most likely to answer a particular query. @@ -95,7 +97,7 @@ export class AtomicSmartSnippet @state() public feedbackSent = false; #id!: string; - private modalRef?: HTMLAtomicSmartSnippetFeedbackModalElement; + private modalRef?: AtomicSmartSnippetFeedbackModal; /** * The [heading level](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/Heading_Elements) to use for the question at the top of the snippet, from 1 to 5. @@ -242,7 +244,7 @@ export class AtomicSmartSnippet } private setModalRef(ref: HTMLElement) { - this.modalRef = ref as HTMLAtomicSmartSnippetFeedbackModalElement; + this.modalRef = ref as AtomicSmartSnippetFeedbackModal; } private setFeedbackSent(isSent: boolean) { @@ -335,7 +337,7 @@ export class AtomicSmartSnippet source, () => html` + await import('./atomic-smart-snippet/atomic-smart-snippet.js'), 'atomic-smart-snippet-feedback-modal': async () => await import( './atomic-smart-snippet-feedback-modal/atomic-smart-snippet-feedback-modal.js' From a312fb91af4736e352ab9ed6bb923df6404fb37d Mon Sep 17 00:00:00 2001 From: "developer-experience-bot[bot]" <91079284+developer-experience-bot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:38:36 +0000 Subject: [PATCH 18/18] Add generated files --- packages/atomic-react/src/components/search/components.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/atomic-react/src/components/search/components.ts b/packages/atomic-react/src/components/search/components.ts index 821c104fbb0..ca059ded5db 100644 --- a/packages/atomic-react/src/components/search/components.ts +++ b/packages/atomic-react/src/components/search/components.ts @@ -81,6 +81,7 @@ import { AtomicSearchLayout as LitAtomicSearchLayout, AtomicSegmentedFacet as LitAtomicSegmentedFacet, AtomicSegmentedFacetScrollable as LitAtomicSegmentedFacetScrollable, + AtomicSmartSnippet as LitAtomicSmartSnippet, AtomicSmartSnippetFeedbackModal as LitAtomicSmartSnippetFeedbackModal, AtomicSmartSnippetSuggestions as LitAtomicSmartSnippetSuggestions, AtomicSortDropdown as LitAtomicSortDropdown, @@ -586,6 +587,12 @@ export const AtomicSegmentedFacetScrollable = createComponent({ elementClass: LitAtomicSegmentedFacetScrollable, }); +export const AtomicSmartSnippet = createComponent({ + tagName: 'atomic-smart-snippet', + react: React, + elementClass: LitAtomicSmartSnippet, +}); + export const AtomicSmartSnippetFeedbackModal = createComponent({ tagName: 'atomic-smart-snippet-feedback-modal', react: React,