From 1143caee7f781378fbb8b6f0b095224fada67dcf Mon Sep 17 00:00:00 2001 From: Frederic Beaudoin Date: Mon, 28 Oct 2024 13:37:11 -0400 Subject: [PATCH 1/5] fix(headless): do not require numberOfValues in commerce numeric facet action payloads (#4488) https://coveord.atlassian.net/browse/KIT-3627 --- .../facets/facet-set/facet-set-slice.test.ts | 5 +- .../facets/facet-set/facet-set-slice.ts | 3 +- .../numeric-facet/numeric-facet-actions.ts | 68 ++++++++++++------- 3 files changed, 48 insertions(+), 28 deletions(-) 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}), +}; From 5c235a8a9a3eaab77a42b819a4e8b92b59aa3b36 Mon Sep 17 00:00:00 2001 From: Felix Perron-Brault Date: Mon, 28 Oct 2024 15:56:35 -0400 Subject: [PATCH 2/5] feat(atomic): support highlights in atomic-product-description (#4541) This PR adds support for highlights in atomic-product-description. Highlights are only supported when using the excerpt field. https://coveord.atlassian.net/browse/KIT-3307 --------- Co-authored-by: GitHub Actions Bot <> Co-authored-by: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Co-authored-by: Frederic Beaudoin --- .../atomic-angular.module.ts | 2 + .../src/lib/stencil-generated/components.ts | 26 ++- .../stencil-generated/commerce/index.ts | 1 + packages/atomic/src/components.d.ts | 55 +++++- ...atomic-product-description.new.stories.tsx | 68 +++++++ .../atomic-product-description.pcss | 1 - .../atomic-product-description.tsx | 87 ++++----- .../e2e/atomic-product-description.e2e.ts | 172 ++++++++++++++++++ .../atomic-product-description/e2e/fixture.ts | 19 ++ .../e2e/page-object.ts | 24 +++ .../atomic-product-excerpt.new.stories.tsx | 57 ++++++ .../atomic-product-excerpt.tsx | 118 ++++++++++++ .../e2e/atomic-product-excerpt.e2e.ts | 142 +++++++++++++++ .../atomic-product-excerpt/e2e/fixture.ts | 19 ++ .../atomic-product-excerpt/e2e/page-object.ts | 24 +++ .../atomic-product-text.new.stories.tsx | 63 +++++++ .../e2e/atomic-product-text.e2e.ts | 95 ++++++++++ .../atomic-product-text/e2e/fixture.ts | 24 +++ .../atomic-product-text/e2e/page-object.ts | 16 ++ .../expandable-text/expandable-text.tsx | 93 ++++++++++ .../examples/commerce-website/search.html | 2 +- 21 files changed, 1047 insertions(+), 61 deletions(-) create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.new.stories.tsx delete mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/atomic-product-description.pcss create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/atomic-product-description.e2e.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/fixture.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-description/e2e/page-object.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.new.stories.tsx create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/atomic-product-excerpt.tsx create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/atomic-product-excerpt.e2e.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/fixture.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-excerpt/e2e/page-object.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-text/atomic-product-text.new.stories.tsx create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/atomic-product-text.e2e.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/fixture.ts create mode 100644 packages/atomic/src/components/commerce/product-template-components/atomic-product-text/e2e/page-object.ts create mode 100644 packages/atomic/src/components/common/expandable-text/expandable-text.tsx 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

- + From 3b02591d3789d5ff56102449768857b9bb98798c Mon Sep 17 00:00:00 2001 From: Danny Gauthier Date: Mon, 28 Oct 2024 15:57:46 -0400 Subject: [PATCH 3/5] fix(answerAPI): controller search listener (#4531) https://coveord.atlassian.net/browse/SVCC-4254 SVCC-4254 # headless-answerapi-generated-answer fix ## Two triggers to fetchAnswer are made after an empty request. uselessly hitting the RTK query cache ### Problem description When the answer-api is enabled **TLDR** Whenever a search request was made after a precedent search request is made with an empty query, the answerAPI was called two times with the same parameters. hitting the RTK query cache while painting red in the console. **Further** The last params with which a search was triggered are kept in a variable used to compare with the current params the listener current run uses. The comparison between the lastParams and the current ones determines if we should call the answerAPI or not. Because the query in the state is not updated at the same time than the request ID,the listener is run twice. If we do not update the `lastTriggerParams` when the query is empty, the condition will receive a wrong request ID the first run and let it pass through, calling the answerAPI twice with the same params. ### Fix description The current fix is not changing the user behavior. Since RTK query was preventing the API to be called twice anyway. But we esteem that the implementation should not rely on RTK query being nice. The `lastTriggerParams` are now updated even when the query is empty when the listener is called. Preventing the condition to let pass the query with the exact same parameters. Co-authored-by: Danny Gauthier --- ...eadless-answerapi-generated-answer.test.ts | 46 +++++++++++++++++-- .../headless-answerapi-generated-answer.ts | 14 ++++-- 2 files changed, 53 insertions(+), 7 deletions(-) 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); }; From 52f4a6b0b7a1a2f30ee0385f76addb0f4bd14408 Mon Sep 17 00:00:00 2001 From: mmitiche <86681870+mmitiche@users.noreply.github.com> Date: Tue, 29 Oct 2024 10:30:46 -0400 Subject: [PATCH 4/5] fix(quantic): fixed sf proxy issue with excludedCustomActions property (#4566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [SFINT-5778](https://coveord.atlassian.net/browse/SFINT-5778) The following issue is being logged in the Service console when passing an array of string as the excludedCustomActions property to the user actions toggle component: Screenshot 2024-10-21 at 3 34 20 PM This issue was not impacting the feature but it was painting the browser dev console in red. The manipulation added in this PR avoids it. [SFINT-5778]: https://coveord.atlassian.net/browse/SFINT-5778?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --- .../lwc/quanticUserActionsToggle/quanticUserActionsToggle.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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] : [], }, }); From 44563fd244db1aabd64dc1bc9539642362963b04 Mon Sep 17 00:00:00 2001 From: mmitiche <86681870+mmitiche@users.noreply.github.com> Date: Tue, 29 Oct 2024 11:04:51 -0400 Subject: [PATCH 5/5] feat(quantic): added support to the custom sort property in the quantic facet component (#4600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [SFINT-5316](https://coveord.atlassian.net/browse/SFINT-5316) - Exposed the property `customSort` in the Quantic Facet, this property allows the end user to specify a custom order of the facet values. #### Example: with `customSort` set to `[]`: Screenshot 2024-10-28 at 9 12 24 AM #### Example: with `customSort` set to `['HTML', 'incident']`: Screenshot 2024-10-28 at 9 11 37 AM - This new logic is being unit tested. the unit tests of remaining logic of the component will be added in a later PR after doing the migration to the E2E Playwright tests. [SFINT-5316]: https://coveord.atlassian.net/browse/SFINT-5316?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ --------- Co-authored-by: Simon Milord Co-authored-by: Etienne Rocheleau --- .../__tests__/quanticFacet.test.js | 102 ++++++++++++++++++ .../default/lwc/quanticFacet/quanticFacet.js | 14 +++ .../templates/disabledDynamicNavigation.html | 1 + .../templates/dynamicNavigation.html | 1 + packages/quantic/jest.config.js | 2 + 5 files changed, 120 insertions(+) create mode 100644 packages/quantic/force-app/main/default/lwc/quanticFacet/__tests__/quanticFacet.test.js 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/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