diff --git a/packages/atomic/cypress/e2e/smart-snippet-feedback-modal.cypress.ts b/packages/atomic/cypress/e2e/smart-snippet-feedback-modal.cypress.ts deleted file mode 100644 index 42c9adb0032..00000000000 --- a/packages/atomic/cypress/e2e/smart-snippet-feedback-modal.cypress.ts +++ /dev/null @@ -1,177 +0,0 @@ -import {TestFixture} from '../fixtures/test-fixture'; -import * as CommonAssertions from './common-assertions'; -import {addSmartSnippet} from './smart-snippet-actions'; -import * as SmartSnippetFeedbackModalAssertions from './smart-snippet-feedback-modal-assertions'; -import { - smartSnippetFeedbackModalComponent, - SmartSnippetFeedbackModalSelectors, -} from './smart-snippet-feedback-modal-selectors'; -import {SmartSnippetSelectors} from './smart-snippet-selectors'; - -// TODO: click with force true is done as workaround for flakiness (with modal) -// as Cypress sometimes sees the button as invisible. Explore if there is a better solution. -describe('Smart Snippet Feedback Modal Test Suites', () => { - function setupOpenModal() { - new TestFixture().with(addSmartSnippet()).init(); - SmartSnippetSelectors.feedbackDislikeButton().click({force: true}); - SmartSnippetSelectors.feedbackExplainWhy().click({force: true}); - } - - describe('after opening the modal', () => { - beforeEach(() => { - setupOpenModal(); - }); - - SmartSnippetFeedbackModalAssertions.assertDisplayModal(true); - SmartSnippetFeedbackModalAssertions.assertDisplayDetails(false); - CommonAssertions.assertAccessibility(smartSnippetFeedbackModalComponent); - - it('should give the primary text color to the cancel button', () => { - SmartSnippetFeedbackModalSelectors.cancelButton() - .invoke('css', 'color') - .should('eq', 'rgb(48, 63, 159)'); - }); - - describe('then clicking submit', () => { - beforeEach(() => { - SmartSnippetFeedbackModalSelectors.submitButton().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertFormErrors(); - SmartSnippetFeedbackModalAssertions.assertDisplayModal(true); - }); - }); - - describe('after opening the modal then selecting some option except other', () => { - beforeEach(() => { - setupOpenModal(); - SmartSnippetFeedbackModalSelectors.reasonRadio() - .first() - .click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertDisplayDetails(false); - - describe('then clicking submit', () => { - beforeEach(() => { - SmartSnippetFeedbackModalSelectors.submitButton().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertDisplayModal(false); - }); - }); - - describe('after opening the modal then selecting other', () => { - beforeEach(() => { - setupOpenModal(); - SmartSnippetFeedbackModalSelectors.reasonRadio() - .last() - .click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertDisplayDetails(true); - - describe('then clicking submit', () => { - beforeEach(() => { - SmartSnippetFeedbackModalSelectors.submitButton().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertFormErrors(); - SmartSnippetFeedbackModalAssertions.assertDisplayModal(true); - - describe('then writing details and pressing submit', () => { - beforeEach(() => { - SmartSnippetFeedbackModalSelectors.detailsInput().type('abc', { - force: true, - }); - SmartSnippetFeedbackModalSelectors.submitButton().click({ - force: true, - }); - }); - - SmartSnippetFeedbackModalAssertions.assertDisplayModal(false); - }); - }); - }); - - describe('after opening the modal then clicking cancel', () => { - beforeEach(() => { - setupOpenModal(); - //Wait for the modal opening animation to end. - cy.wait(1000); - SmartSnippetFeedbackModalSelectors.cancelButton().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertDisplayModal(false); - - it('should focus on the explain why button', () => { - SmartSnippetSelectors.feedbackExplainWhy().should('be.focused'); - }); - - describe('then opening the modal again', () => { - beforeEach(() => { - SmartSnippetSelectors.feedbackExplainWhy().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertDisplayModal(true); - }); - }); - - describe('after opening the modal then clicking the backdrop', () => { - beforeEach(() => { - setupOpenModal(); - SmartSnippetFeedbackModalSelectors.backdrop().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertDisplayModal(false); - }); - - describe('after opening the modal, testing analytics', () => { - beforeEach(() => { - new TestFixture().with(addSmartSnippet()).init(); - SmartSnippetSelectors.feedbackDislikeButton().click({force: true}); - SmartSnippetSelectors.feedbackExplainWhy().click({force: true}); - }); - - describe('after clicking cancel', () => { - beforeEach(() => { - SmartSnippetFeedbackModalSelectors.cancelButton().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertLogCloseSmartSnippetFeedbackModal(); - }); - - describe('after clicking the backdrop', () => { - beforeEach(() => { - SmartSnippetFeedbackModalSelectors.backdrop().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertLogCloseSmartSnippetFeedbackModal(); - }); - - describe('after selecting an option and clicking submit', () => { - beforeEach(() => { - SmartSnippetFeedbackModalSelectors.reasonRadio() - .first() - .click({force: true}); - SmartSnippetFeedbackModalSelectors.submitButton().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertLogSendSpecificSmartSnippetFeedback(); - }); - - describe('after selecting other, typing a reason and clicking submit', () => { - beforeEach(() => { - SmartSnippetFeedbackModalSelectors.reasonRadio() - .last() - .click({force: true}); - SmartSnippetFeedbackModalSelectors.detailsInput().type('abc', { - force: true, - }); - SmartSnippetFeedbackModalSelectors.submitButton().click({force: true}); - }); - - SmartSnippetFeedbackModalAssertions.assertLogSendDetailedSmartSnippetFeedback(); - }); - }); -}); diff --git a/packages/atomic/cypress/e2e/smart-snippet-suggestions.cypress.ts b/packages/atomic/cypress/e2e/smart-snippet-suggestions.cypress.ts index a9873d8dd4c..288d1baa9b2 100644 --- a/packages/atomic/cypress/e2e/smart-snippet-suggestions.cypress.ts +++ b/packages/atomic/cypress/e2e/smart-snippet-suggestions.cypress.ts @@ -125,12 +125,7 @@ describe('Smart Snippet Suggestions Test Suites', () => { ) ); SmartSnippetSuggestionsSelectors.sourceTitle() - .map((el) => - el - .find('atomic-text') - .get(0) - .shadowRoot?.textContent - ) + .map((el) => el.find('atomic-text').get(0).shadowRoot?.textContent) .should( 'deep.equal', defaultRelatedQuestions.map( @@ -270,14 +265,6 @@ describe('Smart Snippet Suggestions Test Suites', () => { .first() .click(); }); - - it('applies the styling to the rendered snippet', () => { - SmartSnippetSuggestionsSelectors.answer() - .first() - .find('b') - .invoke('css', 'color') - .should('equal', 'rgb(84, 170, 255)'); - }); }); describe('with custom styling in an attribute', () => { @@ -298,13 +285,5 @@ describe('Smart Snippet Suggestions Test Suites', () => { .first() .click(); }); - - it('applies the styling to the rendered snippet', () => { - SmartSnippetSuggestionsSelectors.answer() - .first() - .find('b') - .invoke('css', 'color') - .should('equal', 'rgb(84, 170, 255)'); - }); }); }); 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 eee9d2d5186..00000000000 --- a/packages/atomic/cypress/e2e/smart-snippet.cypress.ts +++ /dev/null @@ -1,255 +0,0 @@ -import {generateComponentHTML, TestFixture} from '../fixtures/test-fixture'; -import * as CommonAssertions from './common-assertions'; -import { - addSmartSnippet, - addSmartSnippetDefaultOptions, -} 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('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/cypress/integration-insight-panel/insight-panel.cypress.ts b/packages/atomic/cypress/integration-insight-panel/insight-panel.cypress.ts index fb9d92339a4..1855121bc22 100644 --- a/packages/atomic/cypress/integration-insight-panel/insight-panel.cypress.ts +++ b/packages/atomic/cypress/integration-insight-panel/insight-panel.cypress.ts @@ -177,7 +177,6 @@ describe('Insight Panel test suites', () => { }); }); - describe('Smart Snippet Answer', () => { const visitPage = () => { cy.visit(host); @@ -215,19 +214,6 @@ describe('Insight Panel test suites', () => { it('should show the smart snippet component', () => { InsightPanelsSelectors.smartSnippetExpandableAnswer().should('exist'); }); - - describe('when giving explanatory feedback', () => { - it('should show the feedback modal', () => { - InsightPanelsSelectors.smartSnippetFeedbackModal().should( - 'not.exist' - ); - - InsightPanelsSelectors.smartSnippetFeedbackNoButton().click(); - InsightPanelsSelectors.smartSnippetsExplainWhyButton().click(); - - InsightPanelsSelectors.smartSnippetFeedbackModal().should('exist'); - }); - }); }); }); diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index c681d92b205..ef1fd2d0d95 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -5,7 +5,7 @@ * 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 { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, 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, 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"; @@ -18,7 +18,7 @@ import { RecsStore } from "./components/recommendations/atomic-recs-interface/st 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 { DateFilterRange, DateRangeRequest, FacetResultsMustMatch, GeneratedAnswer, GeneratedAnswerCitation, 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, 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"; @@ -846,10 +846,6 @@ export namespace Components { */ "tabsIncluded": string[] | string; } - interface AtomicSmartSnippetAnswer { - "htmlContent": string; - "innerStyle"?: 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. @@ -988,10 +984,6 @@ export interface AtomicQuickviewModalCustomEvent extends CustomEvent { detail: T; target: HTMLAtomicQuickviewModalElement; } -export interface AtomicSmartSnippetAnswerCustomEvent extends CustomEvent { - detail: T; - target: HTMLAtomicSmartSnippetAnswerElement; -} export interface AtomicSmartSnippetFeedbackModalCustomEvent extends CustomEvent { detail: T; target: HTMLAtomicSmartSnippetFeedbackModalElement; @@ -1425,26 +1417,6 @@ declare global { prototype: HTMLAtomicSmartSnippetElement; new (): HTMLAtomicSmartSnippetElement; }; - interface HTMLAtomicSmartSnippetAnswerElementEventMap { - "answerSizeUpdated": {height: number}; - "selectInlineLink": InlineLink; - "beginDelayedSelectInlineLink": InlineLink; - "cancelPendingSelectInlineLink": InlineLink; - } - interface HTMLAtomicSmartSnippetAnswerElement extends Components.AtomicSmartSnippetAnswer, HTMLStencilElement { - addEventListener(type: K, listener: (this: HTMLAtomicSmartSnippetAnswerElement, ev: AtomicSmartSnippetAnswerCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; - addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLAtomicSmartSnippetAnswerElement, ev: AtomicSmartSnippetAnswerCustomEvent) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; - removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; - } - var HTMLAtomicSmartSnippetAnswerElement: { - prototype: HTMLAtomicSmartSnippetAnswerElement; - new (): HTMLAtomicSmartSnippetAnswerElement; - }; interface HTMLAtomicSmartSnippetFeedbackModalElementEventMap { "feedbackSent": any; } @@ -1573,7 +1545,6 @@ declare global { "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; "atomic-stencil-facet-date-input": HTMLAtomicStencilFacetDateInputElement; @@ -2362,14 +2333,6 @@ declare namespace LocalJSX { */ "tabsIncluded"?: string[] | string; } - interface AtomicSmartSnippetAnswer { - "htmlContent": string; - "innerStyle"?: string; - "onAnswerSizeUpdated"?: (event: AtomicSmartSnippetAnswerCustomEvent<{height: number}>) => void; - "onBeginDelayedSelectInlineLink"?: (event: AtomicSmartSnippetAnswerCustomEvent) => void; - "onCancelPendingSelectInlineLink"?: (event: AtomicSmartSnippetAnswerCustomEvent) => void; - "onSelectInlineLink"?: (event: AtomicSmartSnippetAnswerCustomEvent) => void; - } /** * 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. @@ -2525,7 +2488,6 @@ declare namespace LocalJSX { "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; "atomic-stencil-facet-date-input": AtomicStencilFacetDateInput; @@ -2647,7 +2609,6 @@ declare module "@stencil/core" { * ``` */ "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. * When the modal is opened, the class `atomic-modal-opened` is added to the body, allowing further customization. diff --git a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-answer/atomic-smart-snippet-answer.pcss b/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.pcss similarity index 100% rename from packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-answer/atomic-smart-snippet-answer.pcss rename to packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.pcss diff --git a/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.spec.ts b/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.spec.ts new file mode 100644 index 00000000000..a7c30b85ba7 --- /dev/null +++ b/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.spec.ts @@ -0,0 +1,125 @@ +import {html} from 'lit'; +import {beforeEach, describe, expect, it, type MockInstance, vi} from 'vitest'; +import {bindAnalyticsToLink} from '@/src/components/common/item-link/bind-analytics-to-link'; +import {renderFunctionFixture} from '@/vitest-utils/testing-helpers/fixture'; +import './atomic-smart-snippet-answer'; + +vi.mock('@/src/components/common/item-link/bind-analytics-to-link', () => ({ + bindAnalyticsToLink: vi.fn(() => vi.fn()), +})); + +describe('atomic-smart-snippet-answer', () => { + let observeSpy: MockInstance; + + beforeEach(() => { + observeSpy = vi.spyOn(ResizeObserver.prototype, 'observe'); + }); + + const renderComponent = async ({ + htmlContent = '

Test content

', + innerStyle, + }: { + htmlContent?: string; + innerStyle?: string; + } = {}) => { + const element = await renderFunctionFixture( + html`` + ); + + const component = element.querySelector('atomic-smart-snippet-answer')!; + + return { + element: component, + get answer() { + return component.shadowRoot?.querySelector('[part="answer"]'); + }, + get wrapper() { + return component.shadowRoot?.querySelector('.wrapper'); + }, + get style() { + return component.shadowRoot?.querySelector('style'); + }, + get links() { + return Array.from( + component.shadowRoot?.querySelectorAll('[part="answer"] a') || [] + ); + }, + }; + }; + + describe('rendering', () => { + it('should render answer part with safe HTML content', async () => { + const {answer} = await renderComponent({ + htmlContent: '

Test content

', + }); + expect(answer).toBeInTheDocument(); + expect(answer?.querySelector('strong')).toHaveTextContent('content'); + }); + + it('should sanitize dangerous HTML content', async () => { + const {answer} = await renderComponent({ + htmlContent: '

Test

', + }); + expect(answer?.querySelector('script')).not.toBeInTheDocument(); + }); + }); + + describe('style sanitization', () => { + it('should render sanitized style when innerStyle is provided', async () => { + const {style} = await renderComponent({ + innerStyle: 'p { color: blue; }', + }); + expect(style).toBeInTheDocument(); + expect(style?.innerHTML).toContain('color: blue'); + }); + }); + + describe('lifecycle', () => { + it('should create ResizeObserver', async () => { + await renderComponent(); + await vi.waitFor(() => { + expect(observeSpy).toHaveBeenCalled(); + }); + }); + }); + + it('should dispatch answerSizeUpdated event with height', async () => { + const {element} = await renderComponent(); + const eventSpy = vi.fn(); + element.addEventListener('answerSizeUpdated', eventSpy); + + await vi.waitFor(() => { + expect(eventSpy).toHaveBeenCalled(); + expect(eventSpy.mock.calls[0][0].detail).toHaveProperty('height'); + expect(eventSpy.mock.calls[0][0].bubbles).toBe(false); + }); + }); + + it('should bind analytics to links and emit selectInlineLink event', async () => { + const {element, links} = await renderComponent({ + htmlContent: 'Test Link', + }); + + const eventSpy = vi.fn(); + element.addEventListener('selectInlineLink', eventSpy); + + await vi.waitFor(() => { + expect(bindAnalyticsToLink).toHaveBeenCalled(); + }); + + const bindCall = vi + .mocked(bindAnalyticsToLink) + .mock.calls.find((call) => call[0] === links[0]); + const {onSelect} = bindCall![1]; + onSelect(); + + expect(eventSpy).toHaveBeenCalled(); + expect(eventSpy.mock.calls[0][0].detail).toMatchObject({ + linkText: 'Test Link', + linkURL: 'https://example.com/', + }); + }); +}); diff --git a/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.ts b/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.ts new file mode 100644 index 00000000000..2de295204d5 --- /dev/null +++ b/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.ts @@ -0,0 +1,182 @@ +import type {InlineLink} from '@coveo/headless'; +import DOMPurify from 'dompurify'; +import {type CSSResultGroup, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; +import {createRef, type Ref, ref} from 'lit/directives/ref.js'; +import {unsafeHTML} from 'lit/directives/unsafe-html.js'; +import {when} from 'lit/directives/when.js'; +import {bindAnalyticsToLink} from '@/src/components/common/item-link/bind-analytics-to-link'; +import {withTailwindStyles} from '@/src/decorators/with-tailwind-styles'; +import {sanitizeStyle} from '@/src/utils/utils'; +import styles from './atomic-smart-snippet-answer.tw.css'; + +/** + * The `atomic-smart-snippet-answer` component displays a relevant snippet from the document. + * + * @part answer - The container displaying the snippet from the document. + * + * @event answerSizeUpdated - Dispatched when the content of the snippet size changes. + * @event selectInlineLink - Dispatched when an inline link is selected. + * @event beginDelayedSelectInlineLink - Dispatched when a delayed selection begins for an inline link. + * @event cancelPendingSelectInlineLink - Dispatched when a pending selection is canceled for an inline link. + * + * @internal + */ +@customElement('atomic-smart-snippet-answer') +@withTailwindStyles +export class AtomicSmartSnippetAnswer extends LitElement { + static styles: CSSResultGroup = styles; + + /** + * The HTML content to display in the answer. + */ + @property({type: String, attribute: 'html-content'}) htmlContent!: string; + + /** + * The inline style to apply to the content (sanitized before use). + */ + @property({type: String, attribute: 'inner-style'}) innerStyle?: string; + + private wrapperRef: Ref = createRef(); + private contentRef: Ref = createRef(); + private isRendering = true; + private resizeObserver: ResizeObserver | undefined; + private cleanupAnalyticsFunctions: (() => void)[] = []; + + disconnectedCallback() { + super.disconnectedCallback(); + this.resizeObserver?.disconnect(); + this.cleanupAnalyticsFunctions.forEach((cleanup) => cleanup()); + this.cleanupAnalyticsFunctions = []; + } + + willUpdate() { + this.isRendering = true; + } + + updated() { + this.isRendering = false; + this.emitCurrentHeight(); + this.bindAnalyticsToLinks(); + } + + firstUpdated() { + // Prevents initial transition + requestAnimationFrame(() => { + this.classList.add('loaded'); + }); + + this.setupResizeObserver(); + } + + render() { + return html`${this.renderStyle()} ${this.renderContent()}`; + } + + private setupResizeObserver() { + if (this.wrapperRef.value) { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + this.resizeObserver = new ResizeObserver(() => this.emitCurrentHeight()); + this.resizeObserver.observe(this.wrapperRef.value); + } + } + + private get sanitizedStyle() { + if (!this.innerStyle) { + return undefined; + } + return sanitizeStyle(this.innerStyle); + } + + private emitCurrentHeight() { + if (this.isRendering || !this.wrapperRef.value) { + return; + } + this.dispatchEvent( + new CustomEvent('answerSizeUpdated', { + detail: {height: this.wrapperRef.value.scrollHeight}, + bubbles: false, + }) + ); + } + + private bindAnalyticsToLink(element: HTMLAnchorElement) { + const link: InlineLink = { + linkText: element.innerText, + linkURL: element.href, + }; + const cleanup = bindAnalyticsToLink(element, { + stopPropagation: false, + onSelect: () => + this.dispatchEvent( + new CustomEvent('selectInlineLink', { + detail: link, + bubbles: false, + }) + ), + onBeginDelayedSelect: () => + this.dispatchEvent( + new CustomEvent('beginDelayedSelectInlineLink', { + detail: link, + bubbles: false, + }) + ), + onCancelPendingSelect: () => + this.dispatchEvent( + new CustomEvent('cancelPendingSelectInlineLink', { + detail: link, + bubbles: false, + }) + ), + }); + this.cleanupAnalyticsFunctions.push(cleanup); + } + + private bindAnalyticsToLinks() { + if (!this.contentRef.value) { + return; + } + + // Clean up previous bindings + this.cleanupAnalyticsFunctions.forEach((cleanup) => cleanup()); + this.cleanupAnalyticsFunctions = []; + + // Bind analytics to all links in the content + Array.from(this.contentRef.value.querySelectorAll('a')).forEach((link) => + this.bindAnalyticsToLink(link) + ); + } + + private renderStyle() { + const style = this.sanitizedStyle; + return when( + style, + () => + html`` + ); + } + + private renderContent() { + return html` +
+
+ ${unsafeHTML( + DOMPurify.sanitize(this.htmlContent, { + USE_PROFILES: {html: true}, + }) + )} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'atomic-smart-snippet-answer': AtomicSmartSnippetAnswer; + } +} diff --git a/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.tw.css.ts b/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.tw.css.ts new file mode 100644 index 00000000000..317f980693b --- /dev/null +++ b/packages/atomic/src/components/common/atomic-smart-snippet-answer/atomic-smart-snippet-answer.tw.css.ts @@ -0,0 +1,32 @@ +import {css} from 'lit'; + +export default css` + @reference '../../../utils/tailwind.global.tw.css'; + + @layer components { + :host { + @apply text-on-background text-lg; + line-height: calc(var(--text-lg) * var(--atomic-line-height-ratio)); + + .wrapper { + display: flow-root; + .margin { + margin: 1rem 0; + } + } + } + + p { + @apply mt-4 mb-4; + } + + ul, + ol { + @apply mb-4 list-outside list-decimal pl-10; + } + + a { + @apply text-primary underline; + } + } +`; diff --git a/packages/atomic/src/components/common/index.ts b/packages/atomic/src/components/common/index.ts index d34638fdcc4..fc8b39e0de3 100644 --- a/packages/atomic/src/components/common/index.ts +++ b/packages/atomic/src/components/common/index.ts @@ -9,6 +9,7 @@ export {AtomicIcon} from './atomic-icon/atomic-icon.js'; export {AtomicLayoutSection} from './atomic-layout-section/atomic-layout-section.js'; export {AtomicModal} from './atomic-modal/atomic-modal.js'; export {AtomicNumericRange} from './atomic-numeric-range/atomic-numeric-range.js'; +export {AtomicSmartSnippetAnswer} from './atomic-smart-snippet-answer/atomic-smart-snippet-answer.js'; export {AtomicSmartSnippetCollapseWrapper} from './atomic-smart-snippet-collapse-wrapper/atomic-smart-snippet-collapse-wrapper.js'; export {AtomicSmartSnippetExpandableAnswer} from './atomic-smart-snippet-expandable-answer/atomic-smart-snippet-expandable-answer.js'; export {AtomicTabBar} from './atomic-tab-bar/atomic-tab-bar.js'; diff --git a/packages/atomic/src/components/common/lazy-index.ts b/packages/atomic/src/components/common/lazy-index.ts index a8c6227fb46..21111143a6e 100644 --- a/packages/atomic/src/components/common/lazy-index.ts +++ b/packages/atomic/src/components/common/lazy-index.ts @@ -18,6 +18,10 @@ export default { 'atomic-modal': async () => await import('./atomic-modal/atomic-modal.js'), 'atomic-numeric-range': async () => await import('./atomic-numeric-range/atomic-numeric-range.js'), + 'atomic-smart-snippet-answer': async () => + await import( + './atomic-smart-snippet-answer/atomic-smart-snippet-answer.js' + ), 'atomic-smart-snippet-collapse-wrapper': async () => await import( './atomic-smart-snippet-collapse-wrapper/atomic-smart-snippet-collapse-wrapper.js' diff --git a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-answer/atomic-smart-snippet-answer.tsx b/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-answer/atomic-smart-snippet-answer.tsx deleted file mode 100644 index 71e090dbd70..00000000000 --- a/packages/atomic/src/components/common/smart-snippets/atomic-smart-snippet-answer/atomic-smart-snippet-answer.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import {InlineLink} from '@coveo/headless'; -import { - Component, - h, - Prop, - Event, - EventEmitter, - Host, - Element, -} from '@stencil/core'; -import DOMPurify from 'dompurify'; -import {sanitizeStyle} from '../../../../utils/utils'; -import {bindAnalyticsToLink} from '../../item-link/bind-analytics-to-link'; - -/** - * @part answer - The container displaying the full document excerpt. - * @internal - */ -@Component({ - tag: 'atomic-smart-snippet-answer', - styleUrl: 'atomic-smart-snippet-answer.pcss', - shadow: true, -}) -export class AtomicSmartSnippetAnswer { - @Prop() htmlContent!: string; - @Prop() innerStyle?: string; - - @Element() public host!: HTMLElement; - - @Event({bubbles: false}) - private answerSizeUpdated!: EventEmitter<{height: number}>; - @Event({bubbles: false}) - private selectInlineLink!: EventEmitter; - @Event({bubbles: false}) - private beginDelayedSelectInlineLink!: EventEmitter; - @Event({bubbles: false}) - private cancelPendingSelectInlineLink!: EventEmitter; - private wrapperElement?: HTMLElement; - private isRendering = true; - private resizeObserver: ResizeObserver | undefined; - - public componentWillRender() { - this.isRendering = true; - } - - public componentDidRender() { - this.isRendering = false; - this.emitCurrentHeight(); - } - - public componentDidLoad() { - // Prevents initial transition - setTimeout(() => { - this.host.classList.add('loaded'); - }, 0); - } - - public connectedCallback() { - if (this.wrapperElement && this.resizeObserver) { - this.resizeObserver.observe(this.wrapperElement); - } - } - - public disconnectedCallback() { - this.resizeObserver?.disconnect(); - } - - public setWrapperElement(element: HTMLElement) { - this.wrapperElement = element; - if (this.resizeObserver) { - this.resizeObserver.disconnect(); - } - this.resizeObserver = new ResizeObserver(() => this.emitCurrentHeight()); - this.resizeObserver.observe(element); - } - - private get sanitizedStyle() { - if (!this.innerStyle) { - return undefined; - } - return sanitizeStyle(this.innerStyle); - } - - private emitCurrentHeight() { - if (this.isRendering) { - return; - } - this.answerSizeUpdated.emit({height: this.wrapperElement!.scrollHeight}); - } - - private bindAnalyticsToLink(element: HTMLAnchorElement) { - const link: InlineLink = { - linkText: element.innerText, - linkURL: element.href, - }; - bindAnalyticsToLink(element, { - stopPropagation: false, - onSelect: () => this.selectInlineLink.emit(link), - onBeginDelayedSelect: () => this.beginDelayedSelectInlineLink.emit(link), - onCancelPendingSelect: () => - this.cancelPendingSelectInlineLink.emit(link), - }); - } - - private bindAnalyticsToLinks(root: HTMLElement) { - Array.from(root.querySelectorAll('a')).forEach((link) => - this.bindAnalyticsToLink(link) - ); - } - - private renderStyle() { - const style = this.sanitizedStyle; - if (!style) { - return; - } - // deepcode ignore ReactSetInnerHtml: Defined by implementer and sanitized by dompurify - return ; - } - - private renderContent() { - return ( -
element && this.setWrapperElement(element)} - > - {/* deepcode ignore ReactSetInnerHtml: Sanitized by back-end + dompurify */} -
element && this.bindAnalyticsToLinks(element)} - part="answer" - class="margin" - >
-
- ); - } - - public render() { - return ( - - {this.renderStyle()} - {this.renderContent()} - - ); - } -} diff --git a/packages/atomic/src/components/insight/smart-snippets/atomic-insight-smart-snippet-suggestions/atomic-insight-smart-snippet-suggestions.tsx b/packages/atomic/src/components/insight/smart-snippets/atomic-insight-smart-snippet-suggestions/atomic-insight-smart-snippet-suggestions.tsx index 001398b7945..97301c26aec 100644 --- a/packages/atomic/src/components/insight/smart-snippets/atomic-insight-smart-snippet-suggestions/atomic-insight-smart-snippet-suggestions.tsx +++ b/packages/atomic/src/components/insight/smart-snippets/atomic-insight-smart-snippet-suggestions/atomic-insight-smart-snippet-suggestions.tsx @@ -130,19 +130,19 @@ export class AtomicInsightSmartSnippetSuggestions exportparts="answer" htmlContent={relatedQuestion.answer} innerStyle={this.style} - onSelectInlineLink={(e) => + onSelectInlineLink={(e: CustomEvent) => this.smartSnippetQuestionsList.selectInlineLink( relatedQuestion.questionAnswerId, e.detail ) } - onBeginDelayedSelectInlineLink={(e) => + onBeginDelayedSelectInlineLink={(e: CustomEvent) => this.smartSnippetQuestionsList.beginDelayedSelectInlineLink( relatedQuestion.questionAnswerId, e.detail ) } - onCancelPendingSelectInlineLink={(e) => + onCancelPendingSelectInlineLink={(e: CustomEvent) => this.smartSnippetQuestionsList.cancelPendingSelectInlineLink( relatedQuestion.questionAnswerId, e.detail diff --git a/packages/atomic/src/utils/custom-element-tags.ts b/packages/atomic/src/utils/custom-element-tags.ts index 2d65ed9b20a..c8356bb50f6 100644 --- a/packages/atomic/src/utils/custom-element-tags.ts +++ b/packages/atomic/src/utils/custom-element-tags.ts @@ -147,6 +147,7 @@ export const ATOMIC_CUSTOM_ELEMENT_TAGS = new Set([ 'atomic-search-layout', 'atomic-segmented-facet', 'atomic-segmented-facet-scrollable', + 'atomic-smart-snippet-answer', 'atomic-smart-snippet-collapse-wrapper', 'atomic-smart-snippet-expandable-answer', 'atomic-smart-snippet-suggestions',