From 4d3c39d8a1b9910f528be0ed9c372c92e242ff9e Mon Sep 17 00:00:00 2001 From: Nicholas Labarre Date: Fri, 8 Sep 2023 14:47:37 -0400 Subject: [PATCH] feat(commerce): create PLP v2 interactive result controller (#3129) https://coveord.atlassian.net/browse/CAPI-85 --- packages/atomic/src/components.d.ts | 4 +- packages/headless/src/commerce.index.ts | 7 ++ ...product-listing-interactive-result.test.ts | 82 +++++++++++++++++++ ...less-product-listing-interactive-result.ts | 58 +++++++++++++ 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 packages/headless/src/controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result.test.ts create mode 100644 packages/headless/src/controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result.ts diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 3adf865ad95..4a25c337027 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -1656,7 +1656,7 @@ export namespace Components { */ "suggestionTimeout": number; /** - * Whether to render the search box using a [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) element. The resulting component will expand to support multi-line queries. When customizing the dimensions of the textarea element using the `"textarea"` CSS part, it is important to also apply the styling to its ::after pseudo-element as well as the `"textarea-spacer"` part. The buttons within the search box are likely to need adjusting as well. Example: ```css ``` + * Whether to render the search box using a [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) element. The resulting component will expand to support multi-line queries. When customizing the dimensions of the textarea element using the `"textarea"` CSS part, it is important to also apply the styling to its container's ::after pseudo-element as well as the `"textarea-spacer"` part. The buttons within the search box are likely to need adjusting as well. Example: ```css ``` */ "textarea": boolean; } @@ -4588,7 +4588,7 @@ declare namespace LocalJSX { */ "suggestionTimeout"?: number; /** - * Whether to render the search box using a [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) element. The resulting component will expand to support multi-line queries. When customizing the dimensions of the textarea element using the `"textarea"` CSS part, it is important to also apply the styling to its ::after pseudo-element as well as the `"textarea-spacer"` part. The buttons within the search box are likely to need adjusting as well. Example: ```css ``` + * Whether to render the search box using a [textarea](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/textarea) element. The resulting component will expand to support multi-line queries. When customizing the dimensions of the textarea element using the `"textarea"` CSS part, it is important to also apply the styling to its container's ::after pseudo-element as well as the `"textarea-spacer"` part. The buttons within the search box are likely to need adjusting as well. Example: ```css ``` */ "textarea"?: boolean; } diff --git a/packages/headless/src/commerce.index.ts b/packages/headless/src/commerce.index.ts index 1dc1697900a..fe1d77841c7 100644 --- a/packages/headless/src/commerce.index.ts +++ b/packages/headless/src/commerce.index.ts @@ -31,3 +31,10 @@ export type { Subscribable, } from './controllers/controller/headless-controller'; export {buildController} from './controllers/controller/headless-controller'; + +export type { + InteractiveResult, + InteractiveResultOptions, + InteractiveResultProps, +} from './controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result'; +export {buildInteractiveResult} from './controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result'; diff --git a/packages/headless/src/controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result.test.ts b/packages/headless/src/controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result.test.ts new file mode 100644 index 00000000000..42c2e7626de --- /dev/null +++ b/packages/headless/src/controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result.test.ts @@ -0,0 +1,82 @@ +import {ProductRecommendation} from '../../../../api/search/search/product-recommendation'; +import {configuration} from '../../../../app/common-reducers'; +import {logProductRecommendationOpen} from '../../../../features/product-listing/product-listing-analytics'; +import {pushRecentResult} from '../../../../features/recent-results/recent-results-actions'; +import { + buildMockCommerceEngine, + MockCommerceEngine, +} from '../../../../test/mock-engine'; +import {buildMockProductRecommendation} from '../../../../test/mock-product-recommendation'; +import { + buildInteractiveResult, + InteractiveResult, +} from './headless-product-listing-interactive-result'; + +describe('InteractiveResult', () => { + let engine: MockCommerceEngine; + let mockProductRec: ProductRecommendation; + let interactiveResult: InteractiveResult; + let logDocumentOpenPendingActionType: string; + + const productRecStringParams = { + permanentid: 'permanentid', + documentUri: 'documentUri', + clickUri: 'clickUri', + }; + + function initializeInteractiveResult(delay?: number) { + const productRec = (mockProductRec = buildMockProductRecommendation( + productRecStringParams + )); + logDocumentOpenPendingActionType = + logProductRecommendationOpen(mockProductRec).pending.type; + interactiveResult = buildInteractiveResult(engine, { + options: {result: productRec, selectionDelay: delay}, + }); + } + + function findLogDocumentAction() { + return ( + engine.actions.find( + (action) => action.type === logDocumentOpenPendingActionType + ) ?? null + ); + } + + function expectLogDocumentActionPending() { + const action = findLogDocumentAction(); + expect(action).toEqual( + logProductRecommendationOpen(mockProductRec).pending( + action!.meta.requestId + ) + ); + } + + beforeEach(() => { + engine = buildMockCommerceEngine(); + initializeInteractiveResult(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('adds the correct reducers to engine', () => { + expect(engine.addReducers).toHaveBeenCalledWith({configuration}); + }); + + it('when calling select() should add the result to recent results list', () => { + interactiveResult.select(); + jest.runAllTimers(); + + expect( + engine.actions.find((a) => a.type === pushRecentResult.type) + ).toBeDefined(); + }); + + it('when calling select(), logs documentOpen', () => { + interactiveResult.select(); + expectLogDocumentActionPending(); + }); +}); diff --git a/packages/headless/src/controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result.ts b/packages/headless/src/controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result.ts new file mode 100644 index 00000000000..017833cdc0f --- /dev/null +++ b/packages/headless/src/controllers/commerce/product-listing/result-list/headless-product-listing-interactive-result.ts @@ -0,0 +1,58 @@ +import {ProductRecommendation} from '../../../../api/search/search/product-recommendation'; +import {CommerceEngine} from '../../../../app/commerce-engine/commerce-engine'; +import {logProductRecommendationOpen} from '../../../../features/product-listing/product-listing-analytics'; +import {pushRecentResult} from '../../../../features/product-listing/product-listing-recent-results'; +import { + buildInteractiveResultCore, + InteractiveResultCore, + InteractiveResultCoreOptions, + InteractiveResultCoreProps, +} from '../../../core/interactive-result/headless-core-interactive-result'; + +export interface InteractiveResultOptions extends InteractiveResultCoreOptions { + /** + * The product. + */ + result: ProductRecommendation; +} + +export interface InteractiveResultProps extends InteractiveResultCoreProps { + /** + * The options for the `InteractiveResult` controller. + * */ + options: InteractiveResultOptions; +} + +/** + * The `InteractiveProduct` controller provides an interface for handling long presses, multiple clicks, etc. to ensure that Coveo usage analytics events are logged properly when a user selects a product. + */ +export interface InteractiveResult extends InteractiveResultCore {} + +/** + * Creates an `InteractiveResult` controller instance. + * + * @param engine - The headless commerce engine. + * @param props - The configurable `InteractiveResult` properties. + * @returns An `InteractiveResult` controller instance. + */ +export function buildInteractiveResult( + engine: CommerceEngine, + props: InteractiveResultProps +): InteractiveResult { + let wasOpened = false; + + const logAnalyticsIfNeverOpened = () => { + if (wasOpened) { + return; + } + wasOpened = true; + engine.dispatch(logProductRecommendationOpen(props.options.result)); + }; + + const action = () => { + logAnalyticsIfNeverOpened(); + engine.dispatch(pushRecentResult(props.options.result)); + }; + + return buildInteractiveResultCore(engine, props, action); +}