From d1b51101c91f6539ee80a7d0fe68449fd88532a6 Mon Sep 17 00:00:00 2001 From: Frederic Beaudoin Date: Tue, 27 Aug 2024 10:27:23 -0400 Subject: [PATCH] feat(commerce): expose emitCartAction action and decouple purchase and emitPurchase (#4227) https://coveord.atlassian.net/browse/KIT-3346 This PR includes a minor breaking change, but I very much doubt it will affect anyone as the cart controller's behavior remains unchanged. That being said, I can revert this part of the change if we feel uncomfortable about it. What changes is that the `purchase` and `emitPurchase` actions are now decoupled. So theoretically, someone who was dispatching the `purchase` action directly (without going through the controller) would now have to dispatch the `emitPurchase` action first to get the same behavior as before (i.e., emitting the `ec.purchase` event and then clearing the cart). Other than that, this PR also decouples the `updateItemQuantity` and `emitCartAction` actions so that the two can be dispatched separately. This does not affect the behavior of the `updateItemQuantity` method on the cart controller, and is not a breaking change because we're simply introducing a new dispatchable action. --------- Co-authored-by: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> Co-authored-by: Louis Bompart --- packages/headless/src/commerce.index.ts | 1 - .../context/cart/headless-cart.test.ts | 32 +++++++---- .../commerce/context/cart/headless-cart.ts | 20 +++---- .../context/cart/cart-actions-loader.ts | 48 ++++++++++++++-- .../commerce/context/cart/cart-actions.ts | 55 +++++++++++++------ .../commerce/context/cart/cart-selector.ts | 10 ++++ .../commerce/context/cart/cart-slice.test.ts | 8 +-- .../commerce/context/cart/cart-slice.ts | 2 +- 8 files changed, 126 insertions(+), 50 deletions(-) diff --git a/packages/headless/src/commerce.index.ts b/packages/headless/src/commerce.index.ts index 0ff4df75fef..69fff8d0b8a 100644 --- a/packages/headless/src/commerce.index.ts +++ b/packages/headless/src/commerce.index.ts @@ -60,7 +60,6 @@ export * from './features/commerce/triggers/triggers-actions-loader'; export * from './features/commerce/instant-products/instant-products-actions-loader'; export * from './features/commerce/recent-queries/recent-queries-actions-loader'; export * from './features/commerce/standalone-search-box-set/standalone-search-box-set-actions-loader'; -// TODO: KIT-3350: Create/use/export remaining commerce actions/loaders // Selectors export {Selectors}; diff --git a/packages/headless/src/controllers/commerce/context/cart/headless-cart.test.ts b/packages/headless/src/controllers/commerce/context/cart/headless-cart.test.ts index 76a6569f986..023f7ed8459 100644 --- a/packages/headless/src/controllers/commerce/context/cart/headless-cart.test.ts +++ b/packages/headless/src/controllers/commerce/context/cart/headless-cart.test.ts @@ -1,4 +1,6 @@ import { + emitCartActionEvent, + emitPurchaseEvent, purchase, setItems, updateItemQuantity, @@ -119,13 +121,21 @@ describe('headless commerce cart', () => { jest.resetAllMocks(); }); - it('dispatches #purchase with the transaction payload', () => { + it('dispatches #emitPurchase with the transaction payload', () => { jest.mocked(itemsSelector).mockReturnValue([]); + const mockedEmitPurchaseEvent = jest.mocked(emitPurchaseEvent); + const transaction = {id: 'transaction-id', revenue: 0}; + cart.purchase(transaction); + + expect(mockedEmitPurchaseEvent).toHaveBeenCalledWith(transaction); + }); + + it('dispatches #purchase', () => { const mockedPurchase = jest.mocked(purchase); const transaction = {id: 'transaction-id', revenue: 0}; cart.purchase(transaction); - expect(mockedPurchase).toHaveBeenCalledWith(transaction); + expect(mockedPurchase).toHaveBeenCalled(); }); }); @@ -160,16 +170,15 @@ describe('headless commerce cart', () => { action, product: productWithoutQuantity, quantity, - currency: 'USD', }); const expectCartAction = ( action: 'add' | 'remove', quantity: number | undefined = undefined ) => { - expect(engine.relay.emit).toHaveBeenCalledTimes(1); - expect(engine.relay.emit).toHaveBeenCalledWith( - 'ec.cartAction', + const mockedEmitCartActionEvent = jest.mocked(emitCartActionEvent); + expect(mockedEmitCartActionEvent).toHaveBeenCalledTimes(1); + expect(mockedEmitCartActionEvent).toHaveBeenCalledWith( getExpectedCartActionPayload(action, quantity) ); }; @@ -216,8 +225,9 @@ describe('headless commerce cart', () => { expect(mockedUpdateItem).toHaveBeenCalledTimes(1); }); - it('dispatches #updateItemQuantity but does not emit #ec.cartAction when the item.quantity = cartItem.quantity but item != cartItem', () => { + it('dispatches #updateItemQuantity but does not dispatch #emitCartAction when the item.quantity = cartItem.quantity but item != cartItem', () => { const mockedUpdateItem = jest.mocked(updateItemQuantity); + const mockedEmitCartActionEvent = jest.mocked(emitCartActionEvent); jest .mocked(itemSelector) .mockReturnValue({...productWithQuantity(3), name: 'bap'}); @@ -225,10 +235,10 @@ describe('headless commerce cart', () => { cart.updateItemQuantity(productWithQuantity(3)); expect(mockedUpdateItem).toHaveBeenCalledTimes(1); - expect(engine.relay.emit).toHaveBeenCalledTimes(0); + expect(mockedEmitCartActionEvent).toHaveBeenCalledTimes(0); }); - it('emits #ec.cartAction with "add" action and correct payload if quantity > 0 and item does not exist in cart', () => { + it('dispatches #emitCartAction with "add" action and correct payload if quantity > 0 and item does not exist in cart', () => { jest .mocked(itemSelector) .mockReturnValue(undefined as unknown as CartItemWithMetadata); @@ -238,7 +248,7 @@ describe('headless commerce cart', () => { expectCartAction('add', 3); }); - it('emits #ec.cartAction with "add" action and correct payload if item exists in cart and new quantity > current', () => { + it('dispatches #emitCartAction with "add" action and correct payload if item exists in cart and new quantity > current', () => { jest.mocked(itemSelector).mockReturnValue(productWithQuantity(1)); cart.updateItemQuantity(productWithQuantity(5)); @@ -246,7 +256,7 @@ describe('headless commerce cart', () => { expectCartAction('add', 4); }); - it('emits #ec.cartAction with "remove" action and correct payload if item exists in cart and new quantity < current', () => { + it('dispatches #emitCartAction with "remove" action and correct payload if item exists in cart and new quantity < current', () => { jest.mocked(itemSelector).mockReturnValue(productWithQuantity(3)); cart.updateItemQuantity(productWithQuantity(1)); diff --git a/packages/headless/src/controllers/commerce/context/cart/headless-cart.ts b/packages/headless/src/controllers/commerce/context/cart/headless-cart.ts index ef3813bc2f6..855f59c054c 100644 --- a/packages/headless/src/controllers/commerce/context/cart/headless-cart.ts +++ b/packages/headless/src/controllers/commerce/context/cart/headless-cart.ts @@ -1,7 +1,9 @@ -import {CurrencyCodeISO4217, Ec} from '@coveo/relay-event-types'; import {CommerceEngine} from '../../../../app/commerce-engine/commerce-engine'; import {stateKey} from '../../../../app/state-key'; import { + CartActionPayload, + emitCartActionEvent, + emitPurchaseEvent, purchase, setItems, updateItemQuantity, @@ -171,10 +173,6 @@ export function buildCart(engine: CommerceEngine, props: CartProps = {}): Cart { return isCurrentQuantityGreater ? 'add' : 'remove'; } - function getCurrency(): CurrencyCodeISO4217 { - return engine[stateKey].commerceContext.currency; - } - function isEqual( currentItem: CartItem, prevItem: CartItemWithMetadata | undefined @@ -189,17 +187,15 @@ export function buildCart(engine: CommerceEngine, props: CartProps = {}): Cart { function createEcCartActionPayload( currentItem: CartItem, prevItem: CartItemWithMetadata | undefined - ): Ec.CartAction { + ): CartActionPayload { const {quantity: currentQuantity, ...product} = currentItem; const action = getCartAction(currentItem, prevItem); const quantity = !prevItem ? currentQuantity : Math.abs(currentQuantity - prevItem.quantity); - const currency = getCurrency(); return { action, - currency, quantity, product, }; @@ -215,7 +211,8 @@ export function buildCart(engine: CommerceEngine, props: CartProps = {}): Cart { }, purchase(transaction: Transaction) { - dispatch(purchase(transaction)); + dispatch(emitPurchaseEvent(transaction)); + dispatch(purchase()); }, updateItemQuantity(item: CartItem) { @@ -227,9 +224,8 @@ export function buildCart(engine: CommerceEngine, props: CartProps = {}): Cart { } if (isNewQuantityDifferent(item, prevItem)) { - engine.relay.emit( - 'ec.cartAction', - createEcCartActionPayload(item, prevItem) + dispatch( + emitCartActionEvent(createEcCartActionPayload(item, prevItem)) ); } diff --git a/packages/headless/src/features/commerce/context/cart/cart-actions-loader.ts b/packages/headless/src/features/commerce/context/cart/cart-actions-loader.ts index c2e7b1a2359..e696912b56d 100644 --- a/packages/headless/src/features/commerce/context/cart/cart-actions-loader.ts +++ b/packages/headless/src/features/commerce/context/cart/cart-actions-loader.ts @@ -5,27 +5,38 @@ import { CommerceEngineState, } from '../../../../app/commerce-engine/commerce-engine'; import { + CartActionPayload, PurchasePayload, SetItemsPayload, UpdateItemQuantityPayload, + emitCartActionEvent, + emitPurchaseEvent, purchase, setItems, updateItemQuantity, } from './cart-actions'; import {cartReducer as cart} from './cart-slice'; -export type {PurchasePayload, SetItemsPayload, UpdateItemQuantityPayload}; +export type { + CartActionPayload, + PurchasePayload, + SetItemsPayload, + UpdateItemQuantityPayload, +}; /** * The cart action creators. */ export interface CartActionCreators { /** - * Emits an ec_purchase analytics events with the current cart state. + * Emits an `ec.purchase` analytics event with the current cart state. + * + * Should be dispatched before the `purchase` action. * * @param payload - The action creator payload. + * @returns A dispatchable action. */ - purchase( + emitPurchaseEvent( payload: PurchasePayload ): AsyncThunkAction< void, @@ -33,19 +44,46 @@ export interface CartActionCreators { AsyncThunkCommerceOptions >; - // TODO KIT-3346: Add/expose action to emit ec_cartAction analytics events + /** + * Marks the items in the cart as purchased and empties the cart. + * + * Should be dispatched after the `emitPurchase` action. + * + * @returns A dispatchable action. + */ + purchase(): PayloadAction; /** * Sets the items in the cart. * * @param payload - The action creator payload. + * @returns A dispatchable action. */ setItems(payload: SetItemsPayload): PayloadAction; + /** + * Emits an `ec.cartAction` analytics event. + * + * Should be dispatched before the `updateItemQuantity` action. + * + * @param payload - The action creator payload. + * @returns A dispatchable action. + */ + emitCartActionEvent( + payload: CartActionPayload + ): AsyncThunkAction< + void, + CartActionPayload, + AsyncThunkCommerceOptions + >; + /** * Updates the quantity of an item in the cart. * + * Should be dispatched after the `emitCartAction` action. + * * @param payload - The action creator payload. + * @returns A dispatchable action. */ updateItemQuantity( payload: UpdateItemQuantityPayload @@ -61,6 +99,8 @@ export interface CartActionCreators { export function loadCartActions(engine: CommerceEngine): CartActionCreators { engine.addReducers({cart}); return { + emitPurchaseEvent, + emitCartActionEvent, purchase, setItems, updateItemQuantity, diff --git a/packages/headless/src/features/commerce/context/cart/cart-actions.ts b/packages/headless/src/features/commerce/context/cart/cart-actions.ts index b2bc7e3ee04..c4510e62bc0 100644 --- a/packages/headless/src/features/commerce/context/cart/cart-actions.ts +++ b/packages/headless/src/features/commerce/context/cart/cart-actions.ts @@ -2,21 +2,44 @@ import {createAction, createAsyncThunk} from '@reduxjs/toolkit'; import {AsyncThunkCommerceOptions} from '../../../../api/commerce/commerce-api-client'; import {CommerceEngineState} from '../../../../app/commerce-engine/commerce-engine'; import {validatePayload} from '../../../../utils/validate-payload'; -import {Transaction, getECPurchasePayload} from './cart-selector'; +import { + CartActionDetails, + Transaction, + getECCartActionPayload, + getECPurchasePayload, +} from './cart-selector'; import {CartItemWithMetadata} from './cart-state'; import { setItemsPayloadDefinition, itemPayloadDefinition, } from './cart-validation'; +export type SetItemsPayload = CartItemWithMetadata[]; + +export const setItems = createAction( + 'commerce/cart/setItems', + (payload: SetItemsPayload) => + validatePayload(payload, setItemsPayloadDefinition) +); + +export type UpdateItemQuantityPayload = CartItemWithMetadata; + +export const updateItemQuantity = createAction( + 'commerce/cart/updateItemQuantity', + (payload: UpdateItemQuantityPayload) => + validatePayload(payload, itemPayloadDefinition) +); + +export const purchase = createAction('commerce/cart/purchase'); + export type PurchasePayload = Transaction; -export const purchase = createAsyncThunk< +export const emitPurchaseEvent = createAsyncThunk< void, PurchasePayload, AsyncThunkCommerceOptions >( - 'commerce/cart/purchase', + 'commerce/cart/emit/purchaseEvent', async (payload: PurchasePayload, {extra, getState}) => { const relayPayload = getECPurchasePayload(payload, getState()); const {relay} = extra; @@ -25,20 +48,18 @@ export const purchase = createAsyncThunk< } ); -export type SetItemsPayload = CartItemWithMetadata[]; +export type CartActionPayload = CartActionDetails; -export const setItems = createAction( - 'commerce/cart/setItems', - (payload: SetItemsPayload) => - validatePayload(payload, setItemsPayloadDefinition) -); - -export type UpdateItemQuantityPayload = CartItemWithMetadata; +export const emitCartActionEvent = createAsyncThunk< + void, + CartActionPayload, + AsyncThunkCommerceOptions +>( + 'commerce/cart/emit/cartActionEvent', + async (payload: CartActionPayload, {extra, getState}) => { + const relayPayload = getECCartActionPayload(payload, getState()); + const {relay} = extra; -export const updateItemQuantity = createAction( - 'commerce/cart/updateItemQuantity', - (payload: UpdateItemQuantityPayload) => - validatePayload(payload, itemPayloadDefinition) + relay.emit('ec.cartAction', relayPayload); + } ); - -// TODO KIT-3346: Add/expose action to emit ec_cartAction analytics events diff --git a/packages/headless/src/features/commerce/context/cart/cart-selector.ts b/packages/headless/src/features/commerce/context/cart/cart-selector.ts index 43ad6992e3d..6d13f811fa8 100644 --- a/packages/headless/src/features/commerce/context/cart/cart-selector.ts +++ b/packages/headless/src/features/commerce/context/cart/cart-selector.ts @@ -29,6 +29,16 @@ export const getECPurchasePayload = ( transaction, }); +export interface CartActionDetails extends Omit {} + +export const getECCartActionPayload = ( + cartActionDetails: CartActionDetails, + state: CommerceEngineState +): Ec.CartAction => ({ + currency: getCurrency(state.commerceContext), + ...cartActionDetails, +}); + export const itemsSelector = createSelector( (cartState: CartState) => cartState.cart, (cartState: CartState) => cartState.cartItems, diff --git a/packages/headless/src/features/commerce/context/cart/cart-slice.test.ts b/packages/headless/src/features/commerce/context/cart/cart-slice.test.ts index 3a7c56d2701..3f2ac5b128d 100644 --- a/packages/headless/src/features/commerce/context/cart/cart-slice.test.ts +++ b/packages/headless/src/features/commerce/context/cart/cart-slice.test.ts @@ -52,7 +52,7 @@ describe('cart-slice', () => { [someItemKey]: someItem, [secondItemKey]: secondItem, }; - const fakePurchaseAction = createAction(purchase.fulfilled.type); + const fakePurchaseAction = createAction(purchase.type); const updatedState = cartReducer(state, fakePurchaseAction()); expect(updatedState.cartItems).toEqual([]); expect(updatedState.cart).toEqual({}); @@ -64,7 +64,7 @@ describe('cart-slice', () => { [someItemKey]: someItem, [secondItemKey]: secondItem, }; - const fakePurchaseAction = createAction(purchase.fulfilled.type); + const fakePurchaseAction = createAction(purchase.type); const updatedState = cartReducer(state, fakePurchaseAction()); expect(updatedState.purchasedItems).toEqual([someItemKey, secondItemKey]); expect(updatedState.purchased).toEqual({ @@ -85,7 +85,7 @@ describe('cart-slice', () => { [secondItemKey]: secondItem, }; - const fakePurchaseAction = createAction(purchase.fulfilled.type); + const fakePurchaseAction = createAction(purchase.type); const updatedState = cartReducer(state, fakePurchaseAction()); expect(updatedState.purchasedItems).toEqual([someItemKey, secondItemKey]); expect(updatedState.purchased).toEqual({ @@ -110,7 +110,7 @@ describe('cart-slice', () => { [someItemKey]: someItem, }; - const fakePurchaseAction = createAction(purchase.fulfilled.type); + const fakePurchaseAction = createAction(purchase.type); const updatedState = cartReducer(state, fakePurchaseAction()); expect(updatedState.purchasedItems).toEqual([someItemKey, secondItemKey]); expect(updatedState.purchased).toEqual({ diff --git a/packages/headless/src/features/commerce/context/cart/cart-slice.ts b/packages/headless/src/features/commerce/context/cart/cart-slice.ts index 1e7631efe26..0b238ac4101 100644 --- a/packages/headless/src/features/commerce/context/cart/cart-slice.ts +++ b/packages/headless/src/features/commerce/context/cart/cart-slice.ts @@ -43,7 +43,7 @@ export const cartReducer = createReducer( state.cart[key] = payload; return; }) - .addCase(purchase.fulfilled, (state) => { + .addCase(purchase, (state) => { setItemsAsPurchased(state); const {cart, cartItems} = getCartInitialState(); setItemsInState(state, cartItems, cart);