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: - 'Abcdef
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 AtomicQuickviewModalCustomEventTest content
', + innerStyle, + }: { + htmlContent?: string; + innerStyle?: string; + } = {}) => { + const element = await renderFunctionFixture( + html`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