diff --git a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts index f523722e45a..200e7f34a41 100644 --- a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts +++ b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts @@ -62,6 +62,7 @@ AtomicPopover, AtomicProduct, AtomicProductChildren, AtomicProductDescription, +AtomicProductExcerpt, AtomicProductFieldCondition, AtomicProductImage, AtomicProductLink, @@ -205,6 +206,7 @@ AtomicPopover, AtomicProduct, AtomicProductChildren, AtomicProductDescription, +AtomicProductExcerpt, AtomicProductFieldCondition, AtomicProductImage, AtomicProductLink, 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 5fa2cee9ec5..c83226b2dee 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 @@ -1278,14 +1278,14 @@ export declare interface AtomicProductChildren extends Components.AtomicProductC @ProxyCmp({ - inputs: ['field', 'truncateAfter'] + inputs: ['field', 'isCollapsible', 'truncateAfter'] }) @Component({ selector: 'atomic-product-description', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['field', 'truncateAfter'], + inputs: ['field', 'isCollapsible', 'truncateAfter'], }) export class AtomicProductDescription { protected el: HTMLElement; @@ -1299,6 +1299,28 @@ export class AtomicProductDescription { export declare interface AtomicProductDescription extends Components.AtomicProductDescription {} +@ProxyCmp({ + inputs: ['isCollapsible', 'truncateAfter'] +}) +@Component({ + selector: 'atomic-product-excerpt', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['isCollapsible', 'truncateAfter'], +}) +export class AtomicProductExcerpt { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface AtomicProductExcerpt extends Components.AtomicProductExcerpt {} + + @ProxyCmp({ inputs: ['ifDefined', 'ifNotDefined', 'mustMatch', 'mustNotMatch'] }) diff --git a/packages/atomic-react/src/components/stencil-generated/commerce/index.ts b/packages/atomic-react/src/components/stencil-generated/commerce/index.ts index 1d1213dc90c..ed83c86f8f1 100644 --- a/packages/atomic-react/src/components/stencil-generated/commerce/index.ts +++ b/packages/atomic-react/src/components/stencil-generated/commerce/index.ts @@ -41,6 +41,7 @@ export const AtomicNumericRange = /*@__PURE__*/createReactComponent('atomic-product'); export const AtomicProductChildren = /*@__PURE__*/createReactComponent('atomic-product-children'); export const AtomicProductDescription = /*@__PURE__*/createReactComponent('atomic-product-description'); +export const AtomicProductExcerpt = /*@__PURE__*/createReactComponent('atomic-product-excerpt'); export const AtomicProductFieldCondition = /*@__PURE__*/createReactComponent('atomic-product-field-condition'); export const AtomicProductImage = /*@__PURE__*/createReactComponent('atomic-product-image'); export const AtomicProductLink = /*@__PURE__*/createReactComponent('atomic-product-link'); diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index a2ac7066608..beb42d4e7ae 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -28,6 +28,7 @@ import { InsightResultActionClickedEvent } from "./components/insight/atomic-ins import { Section } from "./components/common/atomic-layout-section/sections"; import { AtomicCommonStore, AtomicCommonStoreData } from "./components/common/interface/store"; import { SelectChildProductEventArgs } from "./components/commerce/product-template-components/atomic-product-children/atomic-product-children"; +import { TruncateAfter } from "./components/common/expandable-text/expandable-text"; import { RecommendationEngine } from "@coveo/headless/recommendation"; import { InteractiveResult as RecsInteractiveResult, LogLevel as RecsLogLevel, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "./components/recommendations"; import { RecsInitializationOptions } from "./components/recommendations/atomic-recs-interface/atomic-recs-interface"; @@ -58,6 +59,7 @@ export { InsightResultActionClickedEvent } from "./components/insight/atomic-ins export { Section } from "./components/common/atomic-layout-section/sections"; export { AtomicCommonStore, AtomicCommonStoreData } from "./components/common/interface/store"; export { SelectChildProductEventArgs } from "./components/commerce/product-template-components/atomic-product-children/atomic-product-children"; +export { TruncateAfter } from "./components/common/expandable-text/expandable-text"; export { RecommendationEngine } from "@coveo/headless/recommendation"; export { InteractiveResult as RecsInteractiveResult, LogLevel as RecsLogLevel, Result as RecsResult, ResultTemplate as RecsResultTemplate, ResultTemplateCondition as RecsResultTemplateCondition } from "./components/recommendations"; export { RecsInitializationOptions } from "./components/recommendations/atomic-recs-interface/atomic-recs-interface"; @@ -2072,10 +2074,27 @@ export namespace Components { * The name of the description field to use. */ "field": 'ec_description' | 'ec_shortdesc'; + /** + * Whether the description should be collapsible after being expanded. + */ + "isCollapsible": boolean; /** * The number of lines after which the product description should be truncated. A value of "none" will disable truncation. */ - "truncateAfter": 'none' | '1' | '2' | '3' | '4'; + "truncateAfter": TruncateAfter; + } + /** + * @alpha The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ + interface AtomicProductExcerpt { + /** + * Whether the excerpt should be collapsible after being expanded. + */ + "isCollapsible": boolean; + /** + * The number of lines after which the product excerpt should be truncated. A value of "none" will disable truncation. + */ + "truncateAfter": TruncateAfter; } /** * The `atomic-product-field-condition` component takes a list of conditions that, if fulfilled, apply the template in which it's defined. @@ -4909,6 +4928,15 @@ declare global { prototype: HTMLAtomicProductDescriptionElement; new (): HTMLAtomicProductDescriptionElement; }; + /** + * @alpha The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ + interface HTMLAtomicProductExcerptElement extends Components.AtomicProductExcerpt, HTMLStencilElement { + } + var HTMLAtomicProductExcerptElement: { + prototype: HTMLAtomicProductExcerptElement; + new (): HTMLAtomicProductExcerptElement; + }; /** * The `atomic-product-field-condition` component takes a list of conditions that, if fulfilled, apply the template in which it's defined. * The condition properties can be based on any top-level product property of the `product` object, not restricted to fields (e.g., `ec_name`). @@ -6061,6 +6089,7 @@ declare global { "atomic-product": HTMLAtomicProductElement; "atomic-product-children": HTMLAtomicProductChildrenElement; "atomic-product-description": HTMLAtomicProductDescriptionElement; + "atomic-product-excerpt": HTMLAtomicProductExcerptElement; "atomic-product-field-condition": HTMLAtomicProductFieldConditionElement; "atomic-product-image": HTMLAtomicProductImageElement; "atomic-product-link": HTMLAtomicProductLinkElement; @@ -8080,10 +8109,27 @@ declare namespace LocalJSX { * The name of the description field to use. */ "field"?: 'ec_description' | 'ec_shortdesc'; + /** + * Whether the description should be collapsible after being expanded. + */ + "isCollapsible"?: boolean; /** * The number of lines after which the product description should be truncated. A value of "none" will disable truncation. */ - "truncateAfter"?: 'none' | '1' | '2' | '3' | '4'; + "truncateAfter"?: TruncateAfter; + } + /** + * @alpha The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ + interface AtomicProductExcerpt { + /** + * Whether the excerpt should be collapsible after being expanded. + */ + "isCollapsible"?: boolean; + /** + * The number of lines after which the product excerpt should be truncated. A value of "none" will disable truncation. + */ + "truncateAfter"?: TruncateAfter; } /** * The `atomic-product-field-condition` component takes a list of conditions that, if fulfilled, apply the template in which it's defined. @@ -9801,6 +9847,7 @@ declare namespace LocalJSX { "atomic-product": AtomicProduct; "atomic-product-children": AtomicProductChildren; "atomic-product-description": AtomicProductDescription; + "atomic-product-excerpt": AtomicProductExcerpt; "atomic-product-field-condition": AtomicProductFieldCondition; "atomic-product-image": AtomicProductImage; "atomic-product-link": AtomicProductLink; @@ -10251,6 +10298,10 @@ declare module "@stencil/core" { * @alpha The `atomic-product-description` component renders the description of a product. */ "atomic-product-description": LocalJSX.AtomicProductDescription & JSXBase.HTMLAttributes; + /** + * @alpha The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ + "atomic-product-excerpt": LocalJSX.AtomicProductExcerpt & JSXBase.HTMLAttributes; /** * The `atomic-product-field-condition` component takes a list of conditions that, if fulfilled, apply the template in which it's defined. * The condition properties can be based on any top-level product property of the `product` object, not restricted to fields (e.g., `ec_name`). diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.new.stories.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.new.stories.tsx new file mode 100644 index 00000000000..dd7b6388d57 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.new.stories.tsx @@ -0,0 +1,68 @@ +import {wrapInCommerceInterface} from '@coveo/atomic-storybook-utils/commerce/commerce-interface-wrapper'; +import {wrapInCommerceProductList} from '@coveo/atomic-storybook-utils/commerce/commerce-product-list-wrapper'; +import {wrapInProductTemplate} from '@coveo/atomic-storybook-utils/commerce/commerce-product-template-wrapper'; +import {parameters} from '@coveo/atomic-storybook-utils/common/common-meta-parameters'; +import {renderComponent} from '@coveo/atomic-storybook-utils/common/render-component'; +import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import {updateQuery} from '../../../../../../headless/src/features/commerce/query/query-actions'; + +const { + decorator: commerceInterfaceDecorator, + play: initializeCommerceInterface, +} = wrapInCommerceInterface({ + skipFirstSearch: true, +}); + +const {decorator: commerceProductListDecorator} = wrapInCommerceProductList(); +const {decorator: productTemplateDecorator} = wrapInProductTemplate(); + +const meta: Meta = { + component: 'atomic-product-description', + title: 'Atomic-Commerce/Product Template Components/ProductDescription', + id: 'atomic-product-description', + render: renderComponent, + parameters, + argTypes: { + 'attributes-truncate-after': { + name: 'truncate-after', + type: 'string', + }, + 'attributes-field': { + name: 'field', + type: 'string', + }, + 'attributes-is-collapsible': { + name: 'is-collapsible', + type: 'boolean', + }, + }, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-product-description', + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + decorators: [ + productTemplateDecorator, + commerceProductListDecorator, + commerceInterfaceDecorator, + ], + play: async (context) => { + await initializeCommerceInterface(context); + + const searchInterface = context.canvasElement.querySelector( + 'atomic-commerce-interface' + ); + searchInterface?.engine?.dispatch(updateQuery({query: 'kayak'})); + + await searchInterface!.executeFirstRequest(); + }, + args: { + 'attributes-field': 'ec_description', + }, +}; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.pcss b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.pcss deleted file mode 100644 index 7a0133e5e82..00000000000 --- a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.pcss +++ /dev/null @@ -1 +0,0 @@ -@import '../../../../global/global.pcss'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.tsx index 770a7fcb127..2fbc9e15f97 100644 --- a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.tsx +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.tsx @@ -1,13 +1,15 @@ import {Schema, StringValue} from '@coveo/bueno'; import {Product} from '@coveo/headless/commerce'; import {Component, State, h, Element, Prop} from '@stencil/core'; -import PlusIcon from '../../../../images/plus.svg'; -import {getFieldValueCaption} from '../../../../utils/field-utils'; import { InitializableComponent, InitializeBindings, } from '../../../../utils/initialization-utils'; -import {Button} from '../../../common/button'; +import { + ExpandableText, + TruncateAfter, +} from '../../../common/expandable-text/expandable-text'; +import {Hidden} from '../../../common/hidden'; import {CommerceBindings} from '../../atomic-commerce-interface/atomic-commerce-interface'; import {ProductContext} from '../product-template-decorators'; @@ -17,7 +19,6 @@ import {ProductContext} from '../product-template-decorators'; */ @Component({ tag: 'atomic-product-description', - styleUrl: 'atomic-product-description.pcss', shadow: false, }) export class AtomicProductDescription @@ -39,13 +40,18 @@ export class AtomicProductDescription /** * The number of lines after which the product description should be truncated. A value of "none" will disable truncation. */ - @Prop() public truncateAfter: 'none' | '1' | '2' | '3' | '4' = '2'; + @Prop() public truncateAfter: TruncateAfter = '2'; /** * The name of the description field to use. */ @Prop() public field: 'ec_description' | 'ec_shortdesc' = 'ec_shortdesc'; + /** + * Whether the description should be collapsible after being expanded. + */ + @Prop() public isCollapsible = true; + constructor() { this.resizeObserver = new ResizeObserver(() => { if ( @@ -65,7 +71,9 @@ export class AtomicProductDescription truncateAfter: new StringValue({ constrainTo: ['none', '1', '2', '3', '4'], }), - field: new StringValue({constrainTo: ['ec_shortdesc', 'ec_description']}), + field: new StringValue({ + constrainTo: ['ec_shortdesc', 'ec_description'], + }), }).validate({ truncateAfter: this.truncateAfter, field: this.field, @@ -74,7 +82,7 @@ export class AtomicProductDescription componentDidLoad() { this.descriptionText = this.hostElement.querySelector( - '.product-description-text' + '.expandable-text' ) as HTMLDivElement; if (this.descriptionText) { this.resizeObserver.observe(this.descriptionText); @@ -89,62 +97,31 @@ export class AtomicProductDescription this.isExpanded = !this.isExpanded; } - private getLineClampClass() { - const lineClampMap: Record = { - none: 'line-clamp-none', - 1: 'line-clamp-1', - 2: 'line-clamp-2', - 3: 'line-clamp-3', - 4: 'line-clamp-4', - }; - return lineClampMap[this.truncateAfter] || 'line-clamp-2'; - } - disconnectedCallback() { this.resizeObserver.disconnect(); } - private renderProductDescription() { - const productDescription = this.product[this.field] ?? ''; - - if (productDescription !== null) { - return ( - - ); + public render() { + const productDescription = this.product[this.field] ?? null; + + if (!productDescription) { + return ; } - } - private renderShowMoreButton() { return ( - - ); - } - - public render() { - return ( -
- {this.renderProductDescription()} - {this.renderShowMoreButton()} -
+ + {productDescription} + + ); } } diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/atomic-product-description.e2e.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/atomic-product-description.e2e.ts new file mode 100644 index 00000000000..2e7d62f538e --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/atomic-product-description.e2e.ts @@ -0,0 +1,172 @@ +import {test, expect} from './fixture'; + +test.describe('atomic-product-description', async () => { + test.beforeEach(async ({page, productDescription}) => { + await page.setViewportSize({width: 375, height: 667}); + await productDescription.load(); + await productDescription.hydrated.first().waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test.describe('when providing an invalid field', async () => { + test('should return error', async ({page, productDescription}) => { + await productDescription.load({ + args: { + // @ts-expect-error needed to test error on invalid field + field: 'ec_name', + }, + }); + + const errorMessage = await page.waitForEvent('console', (msg) => { + return msg.type() === 'error'; + }); + + expect(errorMessage.text()).toContain( + 'field: value should be one of: ec_shortdesc, ec_description.' + ); + }); + }); + test.describe('when providing an invalid truncate-after value', async () => { + const invalidValues = ['foo', '0', '-1', '5']; + + invalidValues.forEach((value) => { + test(`should return error for value: ${value}`, async ({ + page, + productDescription, + }) => { + await productDescription.load({ + args: { + // @ts-expect-error needed to test error on invalid value + truncateAfter: value, + }, + }); + + const errorMessage = await page.waitForEvent('console', (msg) => { + return msg.type() === 'error'; + }); + + expect(errorMessage.text()).toContain( + 'truncateAfter: value should be one of: none, 1, 2, 3, 4' + ); + }); + }); + }); + + const fields: Array<'ec_description' | 'ec_shortdesc'> = [ + 'ec_description', + 'ec_shortdesc', + ]; + + fields.forEach((field) => { + test(`should render description for ${field} field`, async ({ + productDescription, + }) => { + await productDescription.load({args: {field}}); + await productDescription.hydrated.first().waitFor(); + + expect(productDescription.textContent.first()).toBeVisible(); + }); + }); + + test.describe('when description is truncated', async () => { + const truncateValues: Array<{ + value: '1' | '2' | '3' | '4'; + expectedClass: RegExp; + }> = [ + {value: '1', expectedClass: /line-clamp-1/}, + {value: '2', expectedClass: /line-clamp-2/}, + {value: '3', expectedClass: /line-clamp-3/}, + {value: '4', expectedClass: /line-clamp-4/}, + ]; + + truncateValues.forEach(({value, expectedClass}) => { + test.describe(`when truncateAfter is set to ${value}`, async () => { + test(`should truncate description after ${value} lines`, async ({ + productDescription, + }) => { + await productDescription.load({ + args: {truncateAfter: value}, + }); + await productDescription.hydrated.first().waitFor(); + + const descriptionText = productDescription.textContent.first(); + expect(descriptionText).toHaveClass(expectedClass); + }); + + test('should show "Show More" button', async ({productDescription}) => { + const showMoreButton = productDescription.showMoreButton.first(); + expect(showMoreButton).toBeVisible(); + }); + + test.describe('when clicking the "Show More" button', async () => { + test.describe('when isCollapsible is true', async () => { + test.beforeEach(async ({productDescription}) => { + await productDescription.load({ + args: {truncateAfter: value, isCollapsible: true}, + }); + await productDescription.hydrated.first().waitFor(); + await productDescription.showMoreButton.first().click(); + }); + + test('should expand description', async ({productDescription}) => { + const descriptionText = productDescription.textContent.first(); + expect(descriptionText).not.toHaveClass(expectedClass); + }); + + test('should show "Show Less" button', async ({ + productDescription, + }) => { + const showLessButton = productDescription.showLessButton.first(); + expect(showLessButton).toBeVisible(); + }); + + test('should collapse description when clicking the "Show Less" button', async ({ + productDescription, + }) => { + const descriptionText = productDescription.textContent.first(); + await productDescription.showLessButton.first().click(); + + expect(descriptionText).toHaveClass(expectedClass); + }); + }); + + test.describe('when isCollapsible is false', async () => { + test.beforeEach(async ({productDescription}) => { + await productDescription.load({ + args: {truncateAfter: value, isCollapsible: false}, + }); + await productDescription.hydrated.first().waitFor(); + await productDescription.showMoreButton.first().click(); + }); + + test('should expand description', async ({productDescription}) => { + const descriptionText = productDescription.textContent.first(); + expect(descriptionText).not.toHaveClass(expectedClass); + }); + + test('should not show "Show Less" button', async ({ + productDescription, + }) => { + expect(productDescription.showLessButton).not.toBeVisible(); + }); + }); + }); + }); + }); + }); + + test.describe('when description is not truncated', async () => { + test('should hide "Show More" button ', async ({productDescription}) => { + await productDescription.load({ + args: {truncateAfter: 'none'}, + }); + await productDescription.hydrated.first().waitFor(); + + expect(productDescription.showMoreButton).not.toBeVisible(); + }); + }); +}); diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/fixture.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/fixture.ts new file mode 100644 index 00000000000..0641f0049af --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/fixture.ts @@ -0,0 +1,19 @@ +import {test as base} from '@playwright/test'; +import { + makeAxeBuilder, + AxeFixture, +} from '../../../../../../playwright-utils/base-fixture'; +import {ProductDescriptionPageObject as ProductDescription} from './page-object'; + +type MyFixtures = { + productDescription: ProductDescription; +}; + +export const test = base.extend({ + makeAxeBuilder, + productDescription: async ({page}, use) => { + await use(new ProductDescription(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/page-object.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/page-object.ts new file mode 100644 index 00000000000..ada969ac4e8 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/page-object.ts @@ -0,0 +1,24 @@ +import {Page} from '@playwright/test'; +import {BasePageObject} from '../../../../../../playwright-utils/base-page-object'; + +export class ProductDescriptionPageObject extends BasePageObject<'atomic-product-description'> { + constructor(page: Page) { + super(page, 'atomic-product-description'); + } + + get textContent() { + return this.page.locator('.expandable-text'); + } + + get highlightedText() { + return this.page.locator('atomic-product-text b'); + } + + get showMoreButton() { + return this.page.getByRole('button', {name: 'Show more'}); + } + + get showLessButton() { + return this.page.getByRole('button', {name: 'Show less'}); + } +} diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.new.stories.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.new.stories.tsx new file mode 100644 index 00000000000..0b46b949bbf --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.new.stories.tsx @@ -0,0 +1,57 @@ +import {wrapInCommerceInterface} from '@coveo/atomic-storybook-utils/commerce/commerce-interface-wrapper'; +import {wrapInCommerceProductList} from '@coveo/atomic-storybook-utils/commerce/commerce-product-list-wrapper'; +import {wrapInProductTemplate} from '@coveo/atomic-storybook-utils/commerce/commerce-product-template-wrapper'; +import {parameters} from '@coveo/atomic-storybook-utils/common/common-meta-parameters'; +import {renderComponent} from '@coveo/atomic-storybook-utils/common/render-component'; +import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import {updateQuery} from '../../../../../../headless/src/features/commerce/query/query-actions'; + +const { + decorator: commerceInterfaceDecorator, + play: initializeCommerceInterface, +} = wrapInCommerceInterface({ + skipFirstSearch: true, +}); + +const {decorator: commerceProductListDecorator} = wrapInCommerceProductList(); +const {decorator: productTemplateDecorator} = wrapInProductTemplate(); + +const meta: Meta = { + component: 'atomic-product-excerpt', + title: 'Atomic-Commerce/Product Template Components/ProductExcerpt', + id: 'atomic-product-excerpt', + render: renderComponent, + parameters, + argTypes: { + 'attributes-truncate-after': { + name: 'truncate-after', + type: 'string', + }, + }, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-product-excerpt', + parameters: { + viewport: { + defaultViewport: 'mobile1', + }, + }, + decorators: [ + productTemplateDecorator, + commerceProductListDecorator, + commerceInterfaceDecorator, + ], + play: async (context) => { + await initializeCommerceInterface(context); + + const searchInterface = context.canvasElement.querySelector( + 'atomic-commerce-interface' + ); + searchInterface?.engine?.dispatch(updateQuery({query: 'kayak'})); + + await searchInterface!.executeFirstRequest(); + }, +}; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.tsx new file mode 100644 index 00000000000..1b53322af2a --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.tsx @@ -0,0 +1,118 @@ +import {Schema, StringValue} from '@coveo/bueno'; +import {Product} from '@coveo/headless/commerce'; +import {Component, State, h, Element, Prop} from '@stencil/core'; +import { + InitializableComponent, + InitializeBindings, +} from '../../../../utils/initialization-utils'; +import { + ExpandableText, + TruncateAfter, +} from '../../../common/expandable-text/expandable-text'; +import {Hidden} from '../../../common/hidden'; +import {CommerceBindings} from '../../atomic-commerce-interface/atomic-commerce-interface'; +import {ProductContext} from '../product-template-decorators'; + +/** + * @alpha + * The `atomic-product-excerpt` component renders the excerpt of a product generated at query time. + */ +@Component({ + tag: 'atomic-product-excerpt', + shadow: false, +}) +export class AtomicProductExcerpt + implements InitializableComponent +{ + @InitializeBindings() public bindings!: CommerceBindings; + @ProductContext() private product!: Product; + + @Element() hostElement!: HTMLElement; + + public error!: Error; + + @State() private isExpanded = false; + @State() private isTruncated = false; + + private excerptText!: HTMLDivElement; + private resizeObserver: ResizeObserver; + + /** + * The number of lines after which the product excerpt should be truncated. A value of "none" will disable truncation. + */ + @Prop() public truncateAfter: TruncateAfter = '2'; + + /** + * Whether the excerpt should be collapsible after being expanded. + */ + @Prop() public isCollapsible = false; + + constructor() { + this.resizeObserver = new ResizeObserver(() => { + if ( + this.excerptText && + this.excerptText.scrollHeight > this.excerptText.offsetHeight + ) { + this.isTruncated = true; + } else { + this.isTruncated = false; + } + }); + this.validateProps(); + } + + private validateProps() { + new Schema({ + truncateAfter: new StringValue({ + constrainTo: ['none', '1', '2', '3', '4'], + }), + }).validate({ + truncateAfter: this.truncateAfter, + }); + } + + componentDidLoad() { + this.excerptText = this.hostElement.querySelector( + '.expandable-text' + ) as HTMLDivElement; + if (this.excerptText) { + this.resizeObserver.observe(this.excerptText); + } + } + + private onToggleExpand(e?: MouseEvent) { + if (e) { + e.stopPropagation(); + } + + this.isExpanded = !this.isExpanded; + } + + disconnectedCallback() { + this.resizeObserver.disconnect(); + } + + public render() { + const productExcerpt = this.product['excerpt'] ?? null; + + if (!productExcerpt) { + return ; + } + + return ( + this.onToggleExpand(e)} + showMoreLabel={this.bindings.i18n.t('show-more')} + showLessLabel={this.bindings.i18n.t('show-less')} + isCollapsible={this.isCollapsible} + > + + {productExcerpt} + + + ); + } +} diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/atomic-product-excerpt.e2e.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/atomic-product-excerpt.e2e.ts new file mode 100644 index 00000000000..4210f88afd3 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/atomic-product-excerpt.e2e.ts @@ -0,0 +1,142 @@ +import {test, expect} from './fixture'; + +test.describe('atomic-product-excerpt', async () => { + test.beforeEach(async ({page, productExcerpt}) => { + await page.setViewportSize({width: 200, height: 667}); + await productExcerpt.load(); + await productExcerpt.hydrated.first().waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test.describe('when providing an invalid truncate-after value', async () => { + const invalidValues = ['foo', '0', '-1', '5']; + + invalidValues.forEach((value) => { + test(`should return error for value: ${value}`, async ({ + page, + productExcerpt, + }) => { + await productExcerpt.load({ + args: { + // @ts-expect-error needed to test error on invalid value + truncateAfter: value, + }, + }); + + const errorMessage = await page.waitForEvent('console', (msg) => { + return msg.type() === 'error'; + }); + + expect(errorMessage.text()).toContain( + 'truncateAfter: value should be one of: none, 1, 2, 3, 4' + ); + }); + }); + }); + + test('should render excerpt text', async ({productExcerpt}) => { + await productExcerpt.hydrated.first().waitFor(); + + expect(productExcerpt.textContent.first()).toBeVisible(); + }); + + test.describe('when excerpt is truncated', async () => { + const truncateValues: Array<{ + value: '1' | '2' | '3' | '4'; + expectedClass: RegExp; + }> = [ + {value: '1', expectedClass: /line-clamp-1/}, + {value: '2', expectedClass: /line-clamp-2/}, + {value: '3', expectedClass: /line-clamp-3/}, + {value: '4', expectedClass: /line-clamp-4/}, + ]; + + truncateValues.forEach(({value, expectedClass}) => { + test.describe(`when truncateAfter is set to ${value}`, async () => { + test(`should truncate excerpt after ${value} lines`, async ({ + productExcerpt, + }) => { + await productExcerpt.load({ + args: {truncateAfter: value}, + }); + await productExcerpt.hydrated.first().waitFor(); + + const excerptText = productExcerpt.textContent.first(); + expect(excerptText).toHaveClass(expectedClass); + }); + + test('should show "Show More" button', async ({productExcerpt}) => { + const showMoreButton = productExcerpt.showMoreButton.first(); + expect(showMoreButton).toBeVisible(); + }); + + test.describe('when clicking the "Show More" button', async () => { + test.describe('when isCollapsible is true', async () => { + test.beforeEach(async ({productExcerpt}) => { + await productExcerpt.load({ + args: {truncateAfter: value, isCollapsible: true}, + }); + await productExcerpt.hydrated.first().waitFor(); + await productExcerpt.showMoreButton.first().click(); + }); + + test('should expand excerpt', async ({productExcerpt}) => { + const excerptText = productExcerpt.textContent.first(); + expect(excerptText).not.toHaveClass(expectedClass); + }); + + test('should show "Show Less" button', async ({productExcerpt}) => { + const showLessButton = productExcerpt.showLessButton.first(); + expect(showLessButton).toBeVisible(); + }); + + test('should collapse excerpt when clicking the "Show Less" button', async ({ + productExcerpt, + }) => { + const excerptText = productExcerpt.textContent.first(); + await productExcerpt.showLessButton.first().click(); + + expect(excerptText).toHaveClass(expectedClass); + }); + }); + + test.describe('when isCollapsible is false', async () => { + test.beforeEach(async ({productExcerpt}) => { + await productExcerpt.load({ + args: {truncateAfter: value, isCollapsible: false}, + }); + await productExcerpt.hydrated.first().waitFor(); + await productExcerpt.showMoreButton.first().click(); + }); + + test('should expand excerpt', async ({productExcerpt}) => { + const excerptText = productExcerpt.textContent.first(); + expect(excerptText).not.toHaveClass(expectedClass); + }); + + test('should not show "Show Less" button', async ({ + productExcerpt, + }) => { + expect(productExcerpt.showLessButton).not.toBeVisible(); + }); + }); + }); + }); + }); + }); + + test.describe('when excerpt is not truncated', async () => { + test('should hide "Show More" button ', async ({productExcerpt}) => { + await productExcerpt.load({ + args: {truncateAfter: 'none'}, + }); + await productExcerpt.hydrated.first().waitFor(); + + expect(productExcerpt.showMoreButton).not.toBeVisible(); + }); + }); +}); diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/fixture.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/fixture.ts new file mode 100644 index 00000000000..f04f298d0cd --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/fixture.ts @@ -0,0 +1,19 @@ +import {test as base} from '@playwright/test'; +import { + makeAxeBuilder, + AxeFixture, +} from '../../../../../../playwright-utils/base-fixture'; +import {ProductExcerptPageObject as ProductExcerpt} from './page-object'; + +type MyFixtures = { + productExcerpt: ProductExcerpt; +}; + +export const test = base.extend({ + makeAxeBuilder, + productExcerpt: async ({page}, use) => { + await use(new ProductExcerpt(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/page-object.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/page-object.ts new file mode 100644 index 00000000000..c2e527ccdc5 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/page-object.ts @@ -0,0 +1,24 @@ +import {Page} from '@playwright/test'; +import {BasePageObject} from '../../../../../../playwright-utils/base-page-object'; + +export class ProductExcerptPageObject extends BasePageObject<'atomic-product-excerpt'> { + constructor(page: Page) { + super(page, 'atomic-product-excerpt'); + } + + get textContent() { + return this.page.locator('.expandable-text'); + } + + get highlightedText() { + return this.page.locator('atomic-product-text b'); + } + + get showMoreButton() { + return this.page.getByRole('button', {name: 'Show more'}); + } + + get showLessButton() { + return this.page.getByRole('button', {name: 'Show less'}); + } +} diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/atomic-product-text.new.stories.tsx b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/atomic-product-text.new.stories.tsx new file mode 100644 index 00000000000..f0b94f8530e --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/atomic-product-text.new.stories.tsx @@ -0,0 +1,63 @@ +import {wrapInCommerceInterface} from '@coveo/atomic-storybook-utils/commerce/commerce-interface-wrapper'; +import {wrapInCommerceProductList} from '@coveo/atomic-storybook-utils/commerce/commerce-product-list-wrapper'; +import {wrapInProductTemplate} from '@coveo/atomic-storybook-utils/commerce/commerce-product-template-wrapper'; +import {parameters} from '@coveo/atomic-storybook-utils/common/common-meta-parameters'; +import {renderComponent} from '@coveo/atomic-storybook-utils/common/render-component'; +import type {Meta, StoryObj as Story} from '@storybook/web-components'; +import {updateQuery} from '../../../../../../headless/src/features/commerce/query/query-actions'; + +const { + decorator: commerceInterfaceDecorator, + play: initializeCommerceInterface, +} = wrapInCommerceInterface({ + skipFirstSearch: true, +}); + +const {decorator: commerceProductListDecorator} = wrapInCommerceProductList(); +const {decorator: productTemplateDecorator} = wrapInProductTemplate(); + +const meta: Meta = { + component: 'atomic-product-text', + title: 'Atomic-Commerce/Product Template Components/ProductText', + id: 'atomic-product-text', + render: renderComponent, + parameters, + argTypes: { + 'attributes-default': { + name: 'default', + type: 'string', + }, + 'attributes-field': { + name: 'field', + type: 'string', + }, + 'attributes-should-highlight': { + name: 'should-highlight', + type: 'boolean', + }, + }, +}; + +export default meta; + +export const Default: Story = { + name: 'atomic-product-text', + decorators: [ + productTemplateDecorator, + commerceProductListDecorator, + commerceInterfaceDecorator, + ], + play: async (context) => { + await initializeCommerceInterface(context); + + const searchInterface = context.canvasElement.querySelector( + 'atomic-commerce-interface' + ); + searchInterface?.engine?.dispatch(updateQuery({query: 'kayak'})); + + await searchInterface!.executeFirstRequest(); + }, + args: { + 'attributes-field': 'excerpt', + }, +}; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/atomic-product-text.e2e.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/atomic-product-text.e2e.ts new file mode 100644 index 00000000000..4e9f9406913 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/atomic-product-text.e2e.ts @@ -0,0 +1,95 @@ +import {test, expect} from './fixture'; + +test.describe('default', async () => { + test.beforeEach(async ({productText}) => { + await productText.load(); + await productText.hydrated.first().waitFor(); + }); + + test('should be accessible', async ({makeAxeBuilder}) => { + const accessibilityResults = await makeAxeBuilder().analyze(); + expect(accessibilityResults.violations.length).toEqual(0); + }); + + test.describe('when field has no value and default is set', async () => { + test('should render default text', async ({productText}) => { + await productText.load({ + args: {field: 'nonexistentField', default: 'Default Text'}, + }); + await productText.hydrated.first().waitFor(); + + expect(productText.textContent.first()).toContainText('Default Text'); + }); + }); +}); + +test.describe('when using a field that supports highlights', async () => { + const fields = ['excerpt', 'ec_name']; + + fields.forEach((field) => { + test.describe(`when displaying the ${field}`, async () => { + test.beforeEach(async ({productText}) => { + await productText.load({args: {field}}); + await productText.hydrated.first().waitFor(); + }); + + test(`should highlight the keywords in the ${field}`, async ({ + productText, + }) => { + const keywordPattern = /^kayak/i; + + const highlightedText = + await productText.highlightedText.allTextContents(); + + highlightedText.forEach((text) => { + expect(text).toMatch(keywordPattern); + }); + }); + }); + + test(`should not highlight the keywords in the ${field} when shouldHighlight is false`, async ({ + productText, + }) => { + await productText.load({ + args: {field: 'excerpt', shouldHighlight: false}, + }); + await productText.hydrated.first().waitFor(); + + expect(productText.textContent.first()).toContainText(/kayak/i); + + const highlightedText = + await productText.highlightedText.allTextContents(); + expect(highlightedText.length).toEqual(0); + }); + }); +}); + +test.describe('when displaying a field that does not support highlights', async () => { + test.beforeEach(async ({productText}) => { + await productText.load({args: {field: 'ec_description'}}); + await productText.hydrated.first().waitFor(); + }); + + test('should render the field value', async ({productText}) => { + expect(productText.textContent.first()).toBeVisible(); + }); + + test('should not highlight the keywords in the excerpt', async ({ + productText, + }) => { + const highlightedText = await productText.highlightedText.allTextContents(); + expect(productText.textContent.first()).toContainText(/kayak/i); + expect(highlightedText).not.toContain(/kayak/i); + }); +}); + +test.describe('when using a non-string field', async () => { + test.beforeEach(async ({productText, product}) => { + await productText.load({args: {field: 'ec_price'}}); + await product.hydrated.waitFor(); + }); + + test('should not render the field value', async ({productText}) => { + expect(productText.textContent.first()).not.toBeVisible(); + }); +}); diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/fixture.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/fixture.ts new file mode 100644 index 00000000000..ff281071361 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/fixture.ts @@ -0,0 +1,24 @@ +import {test as base} from '@playwright/test'; +import { + makeAxeBuilder, + AxeFixture, +} from '../../../../../../playwright-utils/base-fixture'; +import {ProductsPageObject as Product} from '../../../atomic-product/e2e/page-object'; +import {ProductTextPageObject as ProductText} from './page-object'; + +type MyFixtures = { + productText: ProductText; + product: Product; +}; + +export const test = base.extend({ + makeAxeBuilder, + productText: async ({page}, use) => { + await use(new ProductText(page)); + }, + product: async ({page}, use) => { + await use(new Product(page)); + }, +}); + +export {expect} from '@playwright/test'; diff --git a/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/page-object.ts b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/page-object.ts new file mode 100644 index 00000000000..9db396c5e24 --- /dev/null +++ b/packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/page-object.ts @@ -0,0 +1,16 @@ +import {Page} from '@playwright/test'; +import {BasePageObject} from '../../../../../../playwright-utils/base-page-object'; + +export class ProductTextPageObject extends BasePageObject<'atomic-product-text'> { + constructor(page: Page) { + super(page, 'atomic-product-text'); + } + + get textContent() { + return this.page.locator('atomic-product-text'); + } + + get highlightedText() { + return this.page.locator('atomic-product-text b'); + } +} diff --git a/packages/atomic/src/components/common/expandable-text/expandable-text.tsx b/packages/atomic/src/components/common/expandable-text/expandable-text.tsx new file mode 100644 index 00000000000..5920ac79a9a --- /dev/null +++ b/packages/atomic/src/components/common/expandable-text/expandable-text.tsx @@ -0,0 +1,93 @@ +import {FunctionalComponent, h} from '@stencil/core'; +import MinusIcon from '../../../images/minus.svg'; +import PlusIcon from '../../../images/plus.svg'; +import {Button} from '../button'; + +export type TruncateAfter = 'none' | '1' | '2' | '3' | '4'; + +interface ExpandableTextProps { + isExpanded: boolean; + isTruncated: boolean; + isCollapsible?: boolean; + truncateAfter: TruncateAfter; + onToggleExpand: (e: MouseEvent | undefined) => void; + showMoreLabel: string; + showLessLabel: string; +} + +const getLineClampClass = (truncateAfter: TruncateAfter) => { + const lineClampMap: Record = { + none: 'line-clamp-none', + 1: 'line-clamp-1', + 2: 'line-clamp-2', + 3: 'line-clamp-3', + 4: 'line-clamp-4', + }; + return lineClampMap[truncateAfter] || 'line-clamp-2'; +}; + +const renderShowHideButton = ( + isExpanded: boolean, + isTruncated: boolean, + isCollapsible: boolean, + onToggleExpand: (e?: MouseEvent) => void, + showMoreLabel: string, + showLessLabel: string +) => { + if (!isTruncated && !isExpanded) { + return null; + } + + if (!isCollapsible && !isTruncated && isExpanded) { + return null; + } + + const label = isExpanded ? showLessLabel : showMoreLabel; + + return ( + + ); +}; + +export const ExpandableText: FunctionalComponent = ( + { + isExpanded, + isTruncated, + truncateAfter, + onToggleExpand, + showMoreLabel, + showLessLabel, + isCollapsible = false, + }, + children +) => { + return ( +
+
+ {children} +
+ {renderShowHideButton( + isExpanded, + isTruncated, + isCollapsible, + onToggleExpand, + showMoreLabel, + showLessLabel + )} +
+ ); +}; diff --git a/packages/atomic/src/pages/examples/commerce-website/search.html b/packages/atomic/src/pages/examples/commerce-website/search.html index 3420e6fd93d..0cc0e676b55 100644 --- a/packages/atomic/src/pages/examples/commerce-website/search.html +++ b/packages/atomic/src/pages/examples/commerce-website/search.html @@ -97,7 +97,7 @@

Search page

- + diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.test.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.test.ts index fb7a584a334..298ef4ecbf5 100644 --- a/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.test.ts +++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.test.ts @@ -31,16 +31,30 @@ vi.mock( '../../../features/generated-answer/generated-answer-analytics-actions' ); vi.mock('../../../features/search/search-actions'); + vi.mock('../../../api/knowledge/stream-answer-api', async () => { const originalStreamAnswerApi = await vi.importActual( '../../../api/knowledge/stream-answer-api' ); + const queryCounter = {count: 0}; + const queries = [ + {q: '', requestId: ''}, + {q: 'this est une question', requestId: '12'}, + {q: 'this est une another question', requestId: '12'}, + {q: '', requestId: '34'}, + {q: 'this est une yet another question', requestId: '56'}, + ]; return { ...originalStreamAnswerApi, fetchAnswer: vi.fn(), selectAnswer: () => ({ data: {answer: 'This est une answer', answerId: '12345_6'}, }), + selectAnswerTriggerParams: () => { + const query = {...queries[queryCounter.count]}; + queryCounter.count++; + return query; + }, }; }); vi.mock('../../../api/knowledge/post-answer-evaluation', () => ({ @@ -54,10 +68,6 @@ vi.mock('../../../api/knowledge/post-answer-evaluation', () => ({ })); describe('knowledge-generated-answer', () => { - it('should be tested', () => { - expect(true).toBe(true); - }); - let engine: MockedSearchEngine; const createGeneratedAnswer = (props: GeneratedAnswerProps = {}) => @@ -175,4 +185,32 @@ describe('knowledge-generated-answer', () => { expectedArgs ); }); + + describe('subscribeToSearchRequest', () => { + it('triggers a fetchAnswer only when there is a request id, a query, and the request is made with another request than the last one', () => { + createGeneratedAnswer(); + + const listener = engine.subscribe.mock.calls[0][0]; + + // no request id, no call + listener(); + expect(fetchAnswer).not.toHaveBeenCalled(); + + // first request id, call + listener(); + expect(fetchAnswer).toHaveBeenCalledTimes(1); + + // same request id, no call + listener(); + expect(fetchAnswer).toHaveBeenCalledTimes(1); + + // empty query, no call + listener(); + expect(fetchAnswer).toHaveBeenCalledTimes(1); + + // new request id, call + listener(); + expect(fetchAnswer).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts b/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts index 54bae540828..1fbbb17e8eb 100644 --- a/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts +++ b/packages/headless/src/controllers/knowledge/generated-answer/headless-answerapi-generated-answer.ts @@ -96,16 +96,24 @@ const subscribeToSearchRequest = ( const strictListener = () => { const state = engine.state; const triggerParams = selectAnswerTriggerParams(state); - if (triggerParams.q.length === 0 || triggerParams.requestId.length === 0) { - return; + + if (!lastTriggerParams || triggerParams.q.length === 0) { + lastTriggerParams = triggerParams; } - if (triggerParams?.requestId === lastTriggerParams?.requestId) { + + if ( + triggerParams.q.length === 0 || + triggerParams.requestId.length === 0 || + triggerParams.requestId === lastTriggerParams.requestId + ) { return; } + lastTriggerParams = triggerParams; engine.dispatch(resetAnswer()); engine.dispatch(fetchAnswer(state)); }; + engine.subscribe(strictListener); }; diff --git a/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.test.ts b/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.test.ts index 2925314e154..99236d332f1 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.test.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.test.ts @@ -2539,12 +2539,11 @@ describe('commerceFacetSetReducer', () => { buildMockCommerceNumericFacetValue({start: 0, end: 5}), buildMockCommerceNumericFacetValue({start: 6, end: 10}), ]; - const valuesRequest = convertToNumericRangeRequests(values); state[facetId] = buildMockCommerceFacetSlice({ request: buildMockCommerceFacetRequest({ type: 'numericalRange', - values: valuesRequest, + values, }), }); @@ -2561,7 +2560,7 @@ describe('commerceFacetSetReducer', () => { const finalState = commerceFacetSetReducer(state, action); const targetValues = finalState[facetId]?.request.values; - expect(targetValues).toEqual(convertToNumericRangeRequests(newValues)); + expect(targetValues).toEqual(newValues); }); }); diff --git a/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.ts b/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.ts index 25193bb8f84..fe1b06ba9b5 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.ts @@ -414,8 +414,7 @@ export const commerceFacetSetReducer = createReducer( return; } - // TODO: KIT-3226 No need for this function if the values in the payload already contains appropriate parameters - request.values = convertToNumericRangeRequests(values); + request.values = values; request.numberOfValues = values.length; }) .addCase(updateDateFacetValues, (state, action) => { diff --git a/packages/headless/src/features/commerce/facets/numeric-facet/numeric-facet-actions.ts b/packages/headless/src/features/commerce/facets/numeric-facet/numeric-facet-actions.ts index a4b2a01c6c6..712f9c343a5 100644 --- a/packages/headless/src/features/commerce/facets/numeric-facet/numeric-facet-actions.ts +++ b/packages/headless/src/features/commerce/facets/numeric-facet/numeric-facet-actions.ts @@ -12,44 +12,55 @@ import { validatePayload, validatePayloadAndThrow, } from '../../../../utils/validate-payload.js'; -import {numericFacetValueDefinition} from '../../../facets/range-facets/generic/range-facet-validate-payload.js'; import {NumericRangeRequest} from '../../../facets/range-facets/numeric-facet-set/interfaces/request.js'; -import { - ToggleSelectNumericFacetValueActionCreatorPayload, - UpdateNumericFacetValuesActionCreatorPayload, - validateManualNumericRanges, -} from '../../../facets/range-facets/numeric-facet-set/numeric-facet-actions.js'; +import {validateManualNumericRanges} from '../../../facets/range-facets/numeric-facet-set/numeric-facet-actions.js'; -export type ToggleSelectNumericFacetValuePayload = - ToggleSelectNumericFacetValueActionCreatorPayload; +export interface ToggleSelectNumericFacetValuePayload { + /** + * The unique identifier of the facet (e.g., `"1"`). + */ + facetId: string; + /** + * The target numeric facet value. + */ + selection: NumericRangeRequest; +} export const toggleSelectNumericFacetValue = createAction( 'commerce/facets/numericFacet/toggleSelectValue', (payload: ToggleSelectNumericFacetValuePayload) => validatePayload(payload, { facetId: requiredNonEmptyString, - selection: new RecordValue({values: numericFacetValueDefinition}), + selection: new RecordValue({ + values: numericFacetValueDefinition, + }), }) ); export type ToggleExcludeNumericFacetValuePayload = - ToggleSelectNumericFacetValueActionCreatorPayload; + ToggleSelectNumericFacetValuePayload; export const toggleExcludeNumericFacetValue = createAction( 'commerce/facets/numericFacet/toggleExcludeValue', (payload: ToggleExcludeNumericFacetValuePayload) => validatePayload(payload, { facetId: requiredNonEmptyString, - selection: new RecordValue({values: numericFacetValueDefinition}), + selection: new RecordValue({ + values: numericFacetValueDefinition, + }), }) ); -export type UpdateNumericFacetValuesPayload = - UpdateNumericFacetValuesActionCreatorPayload; - -export type UpdateManualNumericFacetRangePayload = { +export interface UpdateNumericFacetValuesPayload { + /** + * The unique identifier of the facet (e.g., `"1"`). + */ facetId: string; -} & NumericRangeRequest; + /** + * The numeric facet values. + */ + values: NumericRangeRequest[]; +} export const updateNumericFacetValues = createAction( 'commerce/facets/numericFacet/updateValues', @@ -69,17 +80,28 @@ export const updateNumericFacetValues = createAction( } ); +export type UpdateManualNumericFacetRangePayload = { + /** + * The unique identifier of the facet (e.g., `"1"`). + */ + facetId: string; +} & NumericRangeRequest; + export const updateManualNumericFacetRange = createAction( 'commerce/facets/numericFacet/updateManualRange', (payload: UpdateManualNumericFacetRangePayload) => validatePayloadAndThrow(payload, { facetId: requiredNonEmptyString, - start: new NumberValue({required: true, min: 0}), - end: new NumberValue({required: true, min: 0}), - endInclusive: new BooleanValue({required: true}), - state: new StringValue<'idle' | 'selected' | 'excluded'>({ - required: true, - constrainTo: ['idle', 'selected', 'excluded'], - }), + ...numericFacetValueDefinition, }) ); + +const numericFacetValueDefinition = { + state: new StringValue<'idle' | 'selected' | 'excluded'>({ + required: true, + constrainTo: ['idle', 'selected', 'excluded'], + }), + start: new NumberValue({required: true}), + end: new NumberValue({required: true}), + endInclusive: new BooleanValue({required: true}), +}; diff --git a/packages/quantic/force-app/main/default/lwc/quanticFacet/__tests__/quanticFacet.test.js b/packages/quantic/force-app/main/default/lwc/quanticFacet/__tests__/quanticFacet.test.js new file mode 100644 index 00000000000..04ed94b5425 --- /dev/null +++ b/packages/quantic/force-app/main/default/lwc/quanticFacet/__tests__/quanticFacet.test.js @@ -0,0 +1,102 @@ +/* eslint-disable no-import-assign */ +// @ts-ignore +import {createElement} from 'lwc'; +import QuanticFacet from 'c/quanticFacet'; +import * as mockHeadlessLoader from 'c/quanticHeadlessLoader'; + +jest.mock('c/quanticHeadlessLoader'); + +function createTestComponent(options = {}) { + prepareHeadlessState(); + + const element = createElement('c-quantic-facet', { + is: QuanticFacet, + }); + for (const [key, value] of Object.entries(options)) { + element[key] = value; + } + + document.body.appendChild(element); + return element; +} + +const functionsMocks = { + buildFacet: jest.fn(() => ({ + subscribe: jest.fn((callback) => callback()), + state: { + values: [], + }, + })), + buildSearchStatus: jest.fn(() => ({ + subscribe: jest.fn((callback) => callback()), + state: {}, + })), +}; + +function prepareHeadlessState() { + // @ts-ignore + mockHeadlessLoader.getHeadlessBundle = () => { + return { + buildFacet: functionsMocks.buildFacet, + buildSearchStatus: functionsMocks.buildSearchStatus, + }; + }; +} + +// Helper function to wait until the microtask queue is empty. +function flushPromises() { + // eslint-disable-next-line @lwc/lwc/no-async-operation + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +const exampleEngine = { + id: 'dummy engine', +}; +let isInitialized = false; + +function mockSuccessfulHeadlessInitialization() { + // @ts-ignore + mockHeadlessLoader.initializeWithHeadless = (element, _, initialize) => { + if (element instanceof QuanticFacet && !isInitialized) { + isInitialized = true; + initialize(exampleEngine); + } + }; +} + +function cleanup() { + // The jsdom instance is shared across test cases in a single file so reset the DOM + while (document.body.firstChild) { + document.body.removeChild(document.body.firstChild); + } + jest.clearAllMocks(); + isInitialized = false; +} + +describe('c-quantic-facet', () => { + beforeAll(() => { + mockSuccessfulHeadlessInitialization(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('controller initialization', () => { + it('should initialize the controller with the correct customSort value', async () => { + const exampleCustomSortValues = ['test']; + createTestComponent({customSort: exampleCustomSortValues}); + await flushPromises(); + + expect(functionsMocks.buildFacet).toHaveBeenCalledTimes(1); + expect(functionsMocks.buildFacet).toHaveBeenCalledWith( + exampleEngine, + expect.objectContaining({ + options: expect.objectContaining({ + customSort: exampleCustomSortValues, + }), + }) + ); + }); + }); +}); diff --git a/packages/quantic/force-app/main/default/lwc/quanticFacet/quanticFacet.js b/packages/quantic/force-app/main/default/lwc/quanticFacet/quanticFacet.js index 7994301b74d..2443cb6dbca 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticFacet/quanticFacet.js +++ b/packages/quantic/force-app/main/default/lwc/quanticFacet/quanticFacet.js @@ -129,6 +129,16 @@ export default class QuanticFacet extends LightningElement { * @defaultValue `1000` */ @api injectionDepth = 1000; + /** + * Identifies the facet values that must appear at the top, in order. + * This parameter can be used in conjunction with the `sortCriteria` parameter. + * Facet values not part of the `customSort` list will be sorted according to the `sortCriteria`. + * The maximum amount of custom sort values is 25. + * The default value is `undefined`, and the facet values will be sorted using only the `sortCriteria`. + * @api + * @type {String[]} + */ + @api customSort; /** * Whether the facet is collapsed. * @api @@ -154,6 +164,7 @@ export default class QuanticFacet extends LightningElement { 'displayValuesAs', 'noFilterFacetCount', 'injectionDepth', + 'customSort', ]; /** @type {FacetState} */ @@ -249,6 +260,9 @@ export default class QuanticFacet extends LightningElement { facetId: this.facetId ?? this.field, filterFacetCount: !this.noFilterFacetCount, injectionDepth: Number(this.injectionDepth), + customSort: Array.isArray(this.customSort) + ? [...this.customSort] + : undefined, }; this.facet = this.headless.buildFacet(engine, {options}); this.unsubscribe = this.facet.subscribe(() => this.updateState()); diff --git a/packages/quantic/force-app/main/default/lwc/quanticRefineModalContent/templates/disabledDynamicNavigation.html b/packages/quantic/force-app/main/default/lwc/quanticRefineModalContent/templates/disabledDynamicNavigation.html index af19c4c8f75..944ad407846 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticRefineModalContent/templates/disabledDynamicNavigation.html +++ b/packages/quantic/force-app/main/default/lwc/quanticRefineModalContent/templates/disabledDynamicNavigation.html @@ -120,6 +120,7 @@ no-filter-facet-count={facet.noFilterFacetCount} injection-depth={facet.injectionDepth} key={facet.field} + custom-sort={facet.customSort} is-collapsed > diff --git a/packages/quantic/force-app/main/default/lwc/quanticRefineModalContent/templates/dynamicNavigation.html b/packages/quantic/force-app/main/default/lwc/quanticRefineModalContent/templates/dynamicNavigation.html index 058def95d12..0e0f37c4601 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticRefineModalContent/templates/dynamicNavigation.html +++ b/packages/quantic/force-app/main/default/lwc/quanticRefineModalContent/templates/dynamicNavigation.html @@ -121,6 +121,7 @@ no-filter-facet-count={facet.noFilterFacetCount} injection-depth={facet.injectionDepth} key={facet.field} + custom-sort={facet.customSort} is-collapsed > diff --git a/packages/quantic/force-app/main/default/lwc/quanticUserActionsToggle/quanticUserActionsToggle.js b/packages/quantic/force-app/main/default/lwc/quanticUserActionsToggle/quanticUserActionsToggle.js index 953633db3c3..e466c3f0c43 100644 --- a/packages/quantic/force-app/main/default/lwc/quanticUserActionsToggle/quanticUserActionsToggle.js +++ b/packages/quantic/force-app/main/default/lwc/quanticUserActionsToggle/quanticUserActionsToggle.js @@ -77,8 +77,8 @@ export default class QuanticUserActionsToggle extends LightningElement { this.userActions = this.headless.buildUserActions(engine, { options: { ticketCreationDate: this.ticketCreationDateTime, - excludedCustomActions: this.excludedCustomActions?.length - ? this.excludedCustomActions + excludedCustomActions: Array.isArray(this.excludedCustomActions) + ? [...this.excludedCustomActions] : [], }, }); diff --git a/packages/quantic/jest.config.js b/packages/quantic/jest.config.js index d41019db77f..79116280322 100644 --- a/packages/quantic/jest.config.js +++ b/packages/quantic/jest.config.js @@ -29,6 +29,8 @@ module.exports = { '/force-app/main/default/lwc/quanticResultActionStyles/quanticResultActionStyles', '^c/searchBoxStyle$': '/force-app/main/default/lwc/searchBoxStyle/searchBoxStyle', + '^c/quanticFacetStyles$': + '/force-app/main/default/lwc/quanticFacetStyles/quanticFacetStyles', }, modulePathIgnorePatterns: ['.cache'], // add any custom configurations here