Skip to content

Commit

Permalink
feat(commerce): expose emitCartAction action and decouple purchase an…
Browse files Browse the repository at this point in the history
…d 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 <[email protected]>
Co-authored-by: Louis Bompart <[email protected]>
  • Loading branch information
3 people authored Aug 27, 2024
1 parent 221e1bc commit d1b5110
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 50 deletions.
1 change: 0 additions & 1 deletion packages/headless/src/commerce.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
emitCartActionEvent,
emitPurchaseEvent,
purchase,
setItems,
updateItemQuantity,
Expand Down Expand Up @@ -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();
});
});

Expand Down Expand Up @@ -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)
);
};
Expand Down Expand Up @@ -216,19 +225,20 @@ 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'});

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);
Expand All @@ -238,15 +248,15 @@ 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));

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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
};
Expand All @@ -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) {
Expand All @@ -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))
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,47 +5,85 @@ 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,
PurchasePayload,
AsyncThunkCommerceOptions<CommerceEngineState>
>;

// 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<void>;

/**
* Sets the items in the cart.
*
* @param payload - The action creator payload.
* @returns A dispatchable action.
*/
setItems(payload: SetItemsPayload): PayloadAction<SetItemsPayload>;

/**
* 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<CommerceEngineState>
>;

/**
* 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
Expand All @@ -61,6 +99,8 @@ export interface CartActionCreators {
export function loadCartActions(engine: CommerceEngine): CartActionCreators {
engine.addReducers({cart});
return {
emitPurchaseEvent,
emitCartActionEvent,
purchase,
setItems,
updateItemQuantity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<SetItemsPayload>(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<CommerceEngineState>
>(
'commerce/cart/purchase',
'commerce/cart/emit/purchaseEvent',
async (payload: PurchasePayload, {extra, getState}) => {
const relayPayload = getECPurchasePayload(payload, getState());
const {relay} = extra;
Expand All @@ -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<SetItemsPayload>(payload, setItemsPayloadDefinition)
);

export type UpdateItemQuantityPayload = CartItemWithMetadata;
export const emitCartActionEvent = createAsyncThunk<
void,
CartActionPayload,
AsyncThunkCommerceOptions<CommerceEngineState>
>(
'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
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ export const getECPurchasePayload = (
transaction,
});

export interface CartActionDetails extends Omit<Ec.CartAction, 'currency'> {}

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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand All @@ -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({
Expand All @@ -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({
Expand All @@ -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({
Expand Down
Loading

0 comments on commit d1b5110

Please sign in to comment.