From 9f228d3e5948cc0a265c32500781e92dc53a42ae Mon Sep 17 00:00:00 2001 From: Felix Perron-Brault Date: Thu, 8 Aug 2024 16:35:42 -0400 Subject: [PATCH] feat(atomic): add tab support for atomic-smart-snippet (#4221) This PR allows `atomic-smart-snippet` to be visible/hidden based on the currently active tab in the tab manager. https://coveord.atlassian.net/browse/CDX-1558 --------- Co-authored-by: GitHub Actions Bot <> Co-authored-by: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> --- .../src/lib/stencil-generated/components.ts | 4 +- packages/atomic/src/components.d.ts | 16 ++ .../e2e/atomic-commerce-no-products.e2e.ts | 4 +- .../atomic-smart-snippet.tsx | 48 ++++- .../atomic-tab-manager.new.stories.tsx | 41 ++++- .../e2e/atomic-tab-manager.e2e.ts | 164 +++++++++++------- .../tabs/atomic-tab-manager/e2e/fixture.ts | 6 +- 7 files changed, 214 insertions(+), 69 deletions(-) diff --git a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts index b9f59d11f27..9edae2ad224 100644 --- a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts +++ b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts @@ -2111,14 +2111,14 @@ export declare interface AtomicSegmentedFacetScrollable extends Components.Atomi @ProxyCmp({ - inputs: ['collapsedHeight', 'headingLevel', 'maximumHeight', 'snippetCollapsedHeight', 'snippetMaximumHeight', 'snippetStyle'] + inputs: ['collapsedHeight', 'headingLevel', 'maximumHeight', 'snippetCollapsedHeight', 'snippetMaximumHeight', 'snippetStyle', 'tabsExcluded', 'tabsIncluded'] }) @Component({ selector: 'atomic-smart-snippet', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['collapsedHeight', 'headingLevel', 'maximumHeight', 'snippetCollapsedHeight', 'snippetMaximumHeight', 'snippetStyle'], + inputs: ['collapsedHeight', 'headingLevel', 'maximumHeight', 'snippetCollapsedHeight', 'snippetMaximumHeight', 'snippetStyle', 'tabsExcluded', 'tabsIncluded'], }) export class AtomicSmartSnippet { protected el: HTMLElement; diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 6f4ea6aaa95..244806e18f0 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -3262,6 +3262,14 @@ export namespace Components { * 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, e.g., ```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, e.g., ```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; @@ -8778,6 +8786,14 @@ declare namespace LocalJSX { * 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, e.g., ```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, e.g., ```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; diff --git a/packages/atomic/src/components/commerce/atomic-commerce-no-products/e2e/atomic-commerce-no-products.e2e.ts b/packages/atomic/src/components/commerce/atomic-commerce-no-products/e2e/atomic-commerce-no-products.e2e.ts index 084c1fa0f07..ed65a4e1448 100644 --- a/packages/atomic/src/components/commerce/atomic-commerce-no-products/e2e/atomic-commerce-no-products.e2e.ts +++ b/packages/atomic/src/components/commerce/atomic-commerce-no-products/e2e/atomic-commerce-no-products.e2e.ts @@ -6,8 +6,8 @@ test.describe('when there are results', () => { await noProducts.load({story: 'with-results'}); }); - test('should not be visible', async ({noProducts}) => { - await expect(noProducts.ariaLive()).not.toBeVisible(); + test('should have aria live before first query', async ({noProducts}) => { + await expect(noProducts.ariaLive()).toBeVisible(); }); test.describe('after executing a search query that yields no results', () => { 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 index fd7a43e8990..578e85fde25 100644 --- 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 @@ -1,16 +1,22 @@ import { buildSmartSnippet, + buildTabManager, InlineLink, SmartSnippet, SmartSnippetState, + TabManager, + TabManagerState, } from '@coveo/headless'; -import {Component, Prop, State, Element, Listen} from '@stencil/core'; +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 {randomID} from '../../../../utils/utils'; +import {Hidden} from '../../../common/hidden'; import {getAttributesFromLinkSlot} from '../../../common/item-link/attributes-slot'; import {SmartSnippetCommon} from '../../../common/smart-snippets/atomic-smart-snippet/smart-snippet-common'; import {Bindings} from '../../atomic-search-interface/atomic-search-interface'; @@ -65,6 +71,10 @@ export class AtomicSmartSnippet implements InitializableComponent { @BindStateToController('smartSnippet') @State() public smartSnippetState!: SmartSnippetState; + public tabManager!: TabManager; + @BindStateToController('tabManager') + @State() + public tabManagerState!: TabManagerState; public error!: Error; @Element() public host!: HTMLElement; private id = randomID(); @@ -100,6 +110,32 @@ export class AtomicSmartSnippet implements InitializableComponent { */ @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, e.g., + * ```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, e.g., + * ```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; @@ -150,6 +186,7 @@ export class AtomicSmartSnippet implements InitializableComponent { this.bindings.store.waitUntilAppLoaded(() => this.smartSnippetCommon.hideDuringRender(false) ); + this.tabManager = buildTabManager(this.bindings.engine); } private setModalRef(ref: HTMLElement) { @@ -173,6 +210,15 @@ export class AtomicSmartSnippet implements InitializableComponent { } public render() { + if ( + !shouldDisplayOnCurrentTab( + [...this.tabsIncluded], + [...this.tabsExcluded], + this.tabManagerState?.activeTab + ) + ) { + return ; + } return this.smartSnippetCommon.render(); } } diff --git a/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.new.stories.tsx b/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.new.stories.tsx index ad812d7dd76..0ff28f45b00 100644 --- a/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.new.stories.tsx +++ b/packages/atomic/src/components/search/tabs/atomic-tab-manager/atomic-tab-manager.new.stories.tsx @@ -1,10 +1,41 @@ import {parameters} from '@coveo/atomic/storybookUtils/common/common-meta-parameters'; import {renderComponent} from '@coveo/atomic/storybookUtils/common/render-component'; -import {wrapInSearchInterface} from '@coveo/atomic/storybookUtils/search/search-interface-wrapper'; import type {Meta, StoryObj as Story} from '@storybook/web-components'; import {html} from 'lit/static-html.js'; +import {wrapInSearchInterface} from '../../../../../storybookUtils/search/search-interface-wrapper'; -const {decorator, play} = wrapInSearchInterface(); +const {decorator, play} = wrapInSearchInterface({ + 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 meta: Meta = { component: 'atomic-tab-manager', @@ -31,6 +62,12 @@ export const Default: Story = { decorators: [ (story) => html` ${story()} +
+ +
{ await tabManager.hydrated.waitFor(); }); + test('should display tabs area', async ({tabManager}) => { + await expect(tabManager.tabArea).toBeVisible(); + }); + + test('should not display tabs dropdown', async ({tabManager}) => { + await expect(tabManager.tabDropdown).not.toBeVisible(); + }); + + test('should display tab buttons for each atomic-tab elements', async ({ + tabManager, + }) => { + await expect(tabManager.tabDropdown).not.toBeVisible(); + + await expect(tabManager.tabButtons()).toHaveText([ + 'All', + 'Articles', + 'Documentation', + ]); + }); + test.describe('when viewport is large enough to display all tabs', () => { test('should be A11y compliant', async ({makeAxeBuilder}) => { const accessibilityResults = await makeAxeBuilder().analyze(); @@ -20,81 +40,103 @@ test.describe('AtomicTabManager', () => { await expect(tabManager.tabDropdown).not.toBeVisible(); }); - test('should display tab buttons for each atomic-tab elements', async ({ - tabManager, - }) => { - await expect(tabManager.tabDropdown).not.toBeVisible(); - - await expect(tabManager.tabButtons()).toHaveText([ - 'All', - 'Articles', - 'Documentation', - ]); - }); - - test.describe('when clicking on tab button', () => { - test.beforeEach(async ({tabManager}) => { - await tabManager.tabButtons('Articles').click(); + test.describe('should change other component visibility', async () => { + test.beforeEach(async ({facets}) => { + await facets.getFacetValue.first().waitFor({state: 'visible'}); }); - - test('should change active tab', async ({tabManager}) => { - await expect(tabManager.activeTab).toHaveText('Articles'); + test('facets', async ({tabManager}) => { + const includedFacets = await tabManager.includedFacet.all(); + for (let i = 0; i < includedFacets.length; i++) { + await expect(includedFacets[i]).not.toBeVisible(); + } + + const excludedFacets = await tabManager.excludedFacet.all(); + for (let i = 0; i < excludedFacets.length; i++) { + await expect(excludedFacets[i]).toBeVisible(); + } + }); + test('smart snippet', async ({tabManager}) => { + await expect(tabManager.smartSnippet).not.toBeVisible(); }); - test.describe('should change other component visibility', async () => { - test('facets', async ({tabManager}) => { - await tabManager.excludedFacet.last().waitFor({state: 'hidden'}); - const includedFacets = await tabManager.includedFacet.all(); - for (let i = 0; i < includedFacets.length; i++) { - await expect(includedFacets[i]).toBeVisible(); - } + test('should display tab buttons for each atomic-tab elements', async ({ + tabManager, + }) => { + await expect(tabManager.tabDropdown).not.toBeVisible(); - const excludedFacets = await tabManager.excludedFacet.all(); - for (let i = 0; i < excludedFacets.length; i++) { - await expect(excludedFacets[i]).not.toBeVisible(); - } - }); + await expect(tabManager.tabButtons()).toHaveText([ + 'All', + 'Articles', + 'Documentation', + ]); }); - test.describe('when selecting previous tab', () => { + test.describe('when clicking on tab button', () => { test.beforeEach(async ({tabManager}) => { - await tabManager.tabButtons('All').click(); + await tabManager.tabButtons('Articles').click(); + }); + + test('should change active tab', async ({tabManager}) => { + await expect(tabManager.activeTab).toHaveText('Articles'); }); test.describe('should change other component visibility', async () => { test('facets', async ({tabManager}) => { - await tabManager.includedFacet.last().waitFor({state: 'hidden'}); + await tabManager.excludedFacet.last().waitFor({state: 'hidden'}); + const includedFacets = await tabManager.includedFacet.all(); + for (let i = 0; i < includedFacets.length; i++) { + await expect(includedFacets[i]).toBeVisible(); + } const excludedFacets = await tabManager.excludedFacet.all(); for (let i = 0; i < excludedFacets.length; i++) { - await expect(excludedFacets[i]).toBeVisible(); - } - - const includedFacets = await tabManager.includedFacet.all(); - for (let i = 0; i < includedFacets.length; i++) { - await expect(includedFacets[i]).not.toBeVisible(); + await expect(excludedFacets[i]).not.toBeVisible(); } }); + test('smart snippet', async ({tabManager}) => { + await expect(tabManager.smartSnippet).toBeVisible(); + }); }); - }); - test.describe('when resizing viewport', () => { - test.beforeEach(async ({page}) => { - await page.setViewportSize({width: 300, height: 500}); - }); + test.describe('when selecting previous tab', () => { + test.beforeEach(async ({tabManager, facets}) => { + await tabManager.tabButtons('All').click(); + await facets.getFacetValue.first().waitFor({state: 'visible'}); + }); - test('should display tabs dropdown', async ({tabManager}) => { - await expect(tabManager.tabDropdown).toBeVisible(); + test.describe('should change other component visibility', async () => { + test('facets', async ({tabManager}) => { + const excludedFacets = await tabManager.excludedFacet.all(); + for (let i = 0; i < excludedFacets.length; i++) { + await expect(excludedFacets[i]).toBeVisible(); + } + + const includedFacets = await tabManager.includedFacet.all(); + for (let i = 0; i < includedFacets.length; i++) { + await expect(includedFacets[i]).not.toBeVisible(); + } + }); + }); }); - test('should hide tabs area', async ({tabManager}) => { - await expect(tabManager.tabArea).not.toBeVisible(); - }); + test.describe('when resizing viewport', () => { + test.beforeEach(async ({page}) => { + await page.setViewportSize({width: 300, height: 500}); + }); - test('should have the active tab selected in the dropdown', async ({ - tabManager, - }) => { - await expect(tabManager.tabDropdown).toHaveValue('article'); + test('should display tabs dropdown', async ({tabManager}) => { + await expect(tabManager.tabDropdown).toBeVisible(); + }); + + test('should hide tabs area', async ({tabManager}) => { + await expect(tabManager.tabArea).not.toBeVisible(); + }); + + test('should have the active tab selected in the dropdown', async ({ + tabManager, + }) => { + await expect(tabManager.tabDropdown).toHaveValue('article'); + }); }); }); }); @@ -129,8 +171,9 @@ test.describe('AtomicTabManager', () => { }); test.describe('when selecting a dropdown option', () => { - test.beforeEach(async ({tabManager}) => { + test.beforeEach(async ({tabManager, facets}) => { await tabManager.tabDropdown.selectOption('article'); + await facets.getFacetValue.first().waitFor({state: 'visible'}); }); test('should change active tab', async ({tabManager}) => { @@ -139,8 +182,6 @@ test.describe('AtomicTabManager', () => { test.describe('should change other component visibility', async () => { test('facets', async ({tabManager}) => { - await tabManager.excludedFacet.last().waitFor({state: 'hidden'}); - const includedFacets = await tabManager.includedFacet.all(); for (let i = 0; i < includedFacets.length; i++) { await expect(includedFacets[i]).toBeVisible(); @@ -151,17 +192,19 @@ test.describe('AtomicTabManager', () => { await expect(excludedFacets[i]).not.toBeVisible(); } }); + test('smart snippet', async ({tabManager}) => { + await expect(tabManager.smartSnippet).toBeVisible(); + }); }); test.describe('when selecting previous dropdown option', () => { - test.beforeEach(async ({tabManager}) => { + test.beforeEach(async ({tabManager, facets}) => { await tabManager.tabDropdown.selectOption('all'); + await facets.getFacetValue.first().waitFor({state: 'visible'}); }); test.describe('should change other component visibility', async () => { test('facets', async ({tabManager}) => { - await tabManager.includedFacet.last().waitFor({state: 'hidden'}); - const excludedFacets = await tabManager.excludedFacet.all(); for (let i = 0; i < excludedFacets.length; i++) { await expect(excludedFacets[i]).toBeVisible(); @@ -172,6 +215,9 @@ test.describe('AtomicTabManager', () => { await expect(includedFacets[i]).not.toBeVisible(); } }); + test('smart snippet', async ({tabManager}) => { + await expect(tabManager.smartSnippet).not.toBeVisible(); + }); }); }); diff --git a/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/fixture.ts b/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/fixture.ts index 5c1eac529e2..07b569da140 100644 --- a/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/fixture.ts +++ b/packages/atomic/src/components/search/tabs/atomic-tab-manager/e2e/fixture.ts @@ -3,11 +3,11 @@ import { AxeFixture, makeAxeBuilder, } from '../../../../../../playwright-utils/base-fixture'; -import {FacetsPageObject} from '../../../../commerce/facets/atomic-commerce-facets/e2e/page-object'; +import {AtomicFacetPageObject as FacetPageObject} from '../../../facets/atomic-facet/e2e/page-object'; import {TabManagerPageObject} from './page-object'; interface TestFixture { - facets: FacetsPageObject; + facets: FacetPageObject; tabManager: TabManagerPageObject; } @@ -17,7 +17,7 @@ export const test = base.extend({ await use(new TabManagerPageObject(page)); }, facets: async ({page}, use) => { - await use(new FacetsPageObject(page)); + await use(new FacetPageObject(page)); }, }); export {expect} from '@playwright/test';