Skip to content

Commit

Permalink
fix(headless): update product interface to reflect how the commerce a…
Browse files Browse the repository at this point in the history
…pi behaves (#4104)

This is less changes that what I had originally thought.

The `BaseProduct` interface should now properly reflect what's returned
by the commerce API, following the source of truth (the code of the
service itself).

* Most of the change are about changing from `foo | undefined` to `foo |
null`.
* Array values are never null, but might be an empty array (due to how
the service is coded)
* ec_category is always an array, not a string
* Added couple missing properties (non breaking)

There is only "one lie" in this change: `excerpts, nameHighlights,
excerptsHighlights` are "always defined when in a search use case", and
"always undefined when in a listing or recommendation use case".

I thought about splitting and creating multiple interface
`SearchProduct, ListingProduct, RecommendationProduct`, and remove
`Product` as it exists right now.

However this would be a very noisy change, for existing deployments.
`Product` is an *important* interface, which is nearly always referenced
by any implementer using TypeScript.

So I made the judgment call to "lie a little bit, for the greater good"
😂 They are marked as possibly undefined for all use cases.

https://coveord.atlassian.net/browse/KIT-3164
  • Loading branch information
olamothe authored Jun 25, 2024
1 parent bb4f76a commit 5e514c5
Show file tree
Hide file tree
Showing 10 changed files with 81 additions and 38 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export class AtomicProductChildren
return (
<button
class={`product-child${child.permanentid === this.activeChildId ? this.activeChildClasses : ' '}`}
title={child.ec_name}
title={child.ec_name || undefined}
onKeyPress={(event) =>
event.key === 'Enter' &&
this.onSelectChild(child.permanentid, this.product.permanentid)
Expand All @@ -144,7 +144,7 @@ export class AtomicProductChildren
<img
class="aspect-square p-1"
src={this.getImageUrl(child)}
alt={child.ec_name}
alt={child.ec_name || undefined}
loading="lazy"
/>
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ export class AtomicProductPrice

public render() {
const hasPromotionalPrice =
this.product?.ec_promo_price !== undefined &&
this.product?.ec_price !== undefined &&
this.product?.ec_promo_price < this.product?.ec_price;
this.product.ec_promo_price !== null &&
this.product.ec_price !== null &&
this.product.ec_promo_price < this.product.ec_price;

const {currency} = this.contextState;

Expand Down
56 changes: 41 additions & 15 deletions packages/headless/src/api/commerce/common/product.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {HighlightKeyword} from '../../../utils/highlight';

export type ChildProduct = Omit<
BaseProduct,
'children' | 'totalNumberOfChildren'
Expand All @@ -18,81 +20,90 @@ export interface BaseProduct {
*
* From the `ec_name` field.
*/
ec_name?: string;
ec_name: string | null;
/**
* The description of the product.
*
* From the `ec_description` field.
*/
ec_description?: string;
ec_description: string | null;
/**
* The brand of the product.
*
* From the `ec_brand` field.
*/
ec_brand?: string;
ec_brand: string | null;
/**
* The category of the product (e.g., `"Electronics;Electronics|Televisions;Electronics|Televisions|4K Televisions"`).
*
* From the `ec_category` field.
*/
ec_category?: string;
ec_category: string[];
/**
* The ID used for the purpose of [product grouping](https://docs.coveo.com/en/l78i2152).
*
* From the `ec_item_group_id` field.
*/
ec_item_group_id?: string;
ec_item_group_id: string | null;
/**
* The base price of the product.
*
* From the `ec_price` field.
*/
ec_price?: number;
ec_price: number | null;
/**
* The promotional price of the product.
*
* From the `ec_promo_price` field.
*/
ec_promo_price?: number;
ec_promo_price: number | null;
/**
* A short description of the product.
*
* From the `ec_shortdesc` field.
*/
ec_shortdesc?: string;
ec_shortdesc: string | null;
/**
* The URLs of the product image thumbnails.
*
* From the `ec_thumbnails` field.
*/
ec_thumbnails?: string[];
ec_thumbnails: string[];
/**
* The URLs of additional product images.
*
* From the `ec_images` field.
*/
ec_images?: string[];
ec_images: string[];
/**
* Whether the product is currently in stock.
*
* From the `ec_in_stock` field.
*/
ec_in_stock?: boolean;
ec_in_stock: boolean | null;
/**
* The product rating, from 0 to 10.
*
* From the `ec_rating` field.
*/
ec_rating?: number;
ec_rating: number | null;
/**
* The gender the product is intended for.
*/
ec_gender?: string;
ec_gender: string | null;
/**
* The product ID.
*/
ec_product_id?: string;
ec_product_id: string | null;
/**
* The color of the product.
*/
ec_color: string | null;
/**
* The listing that the product belongs to.
*/
ec_listing: string | null;

/**
* The requested additional fields for the product.
*/
Expand All @@ -106,7 +117,22 @@ export interface BaseProduct {
/**
* The total number of child products fetched through [product grouping](https://docs.coveo.com/en/l78i2152).
*/
totalNumberOfChildren: number;
totalNumberOfChildren: number | null;
/**
* The contextual excerpt generated for the product.
*
* @example
* ... This orange sea kayak is perfect for beginner and experienced kayakers ... and want to enjoy the water without having to deal with the hassle of attaching a boat to their kayak.
*/
excerpt?: string | null;
/**
* The length and offset of each word to highlight in the product name.
*/
nameHighlights?: HighlightKeyword[];
/**
* The length and offset of each word to highlight in the product excerpt string.
*/
excerptsHighlights?: HighlightKeyword[];
}

export interface Product extends BaseProduct {
Expand Down
2 changes: 2 additions & 0 deletions packages/headless/src/commerce.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {polyfillCryptoNode} from './api/analytics/analytics-crypto-polyfill';
import * as Selectors from './selectors/commerce-selectors.index';
import * as HighlightUtils from './utils/highlight';

export type {HighlightKeyword} from './utils/highlight';

polyfillCryptoNode();
export type {Unsubscribe, Middleware} from '@reduxjs/toolkit';
export type {Relay} from '@coveo/relay';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ describe('core interactive result', () => {
});
});

it('when ec_name is not defined on the product, falls back to the permanentid field to set the product name', () => {
it('when ec_name is null on the product, falls back to the permanentid field to set the product name', () => {
const controller = buildCoreInteractiveProduct(engine, {
options: {
product: {
...product,
ec_name: undefined,
ec_name: null,
},
},
responseIdSelector: () => 'responseId',
Expand All @@ -89,12 +89,12 @@ describe('core interactive result', () => {
});
});

it('when ec_product_id is not defined on the product, falls back to the permanentid field to set the product id', () => {
it('when ec_product_id is null on the product, falls back to the permanentid field to set the product id', () => {
const controller = buildCoreInteractiveProduct(engine, {
options: {
product: {
...product,
ec_product_id: undefined,
ec_product_id: null,
},
},
responseIdSelector: () => 'responseId',
Expand All @@ -113,13 +113,13 @@ describe('core interactive result', () => {
});
});

it('when ec_promo_price and ec_price are not defined on the product, falls back to NaN to set the product price', () => {
it('when ec_promo_price and ec_price are null on the product, falls back to NaN to set the product price', () => {
const controller = buildCoreInteractiveProduct(engine, {
options: {
product: {
...product,
ec_promo_price: undefined,
ec_price: undefined,
ec_promo_price: null,
ec_price: null,
},
},
responseIdSelector: () => 'responseId',
Expand Down Expand Up @@ -150,12 +150,12 @@ describe('core interactive result', () => {
expect((controller as InteractiveProduct).warningMessage).toBeUndefined();
});

it('when ec_name is not defined on the product, warningMessage is defined', () => {
it('when ec_name is null on the product, warningMessage is defined', () => {
const controller = buildCoreInteractiveProduct(engine, {
options: {
product: {
...product,
ec_name: undefined,
ec_name: null,
},
},
responseIdSelector: () => 'responseId',
Expand All @@ -164,12 +164,12 @@ describe('core interactive result', () => {
expect((controller as InteractiveProduct).warningMessage).toBeDefined();
});

it('when ec_product_id is not defined on the product, warningMessage is defined', () => {
it('when ec_product_id is null on the product, warningMessage is defined', () => {
const controller = buildCoreInteractiveProduct(engine, {
options: {
product: {
...product,
ec_product_id: undefined,
ec_product_id: null,
},
},
responseIdSelector: () => 'responseId',
Expand All @@ -178,13 +178,13 @@ describe('core interactive result', () => {
expect((controller as InteractiveProduct).warningMessage).toBeDefined();
});

it('when ec_promo_price and ec_price are not defined on the product, warningMessage is defined', () => {
it('when ec_promo_price and ec_price are null on the product, warningMessage is defined', () => {
const controller = buildCoreInteractiveProduct(engine, {
options: {
product: {
...product,
ec_promo_price: undefined,
ec_price: undefined,
ec_promo_price: null,
ec_price: null,
},
},
responseIdSelector: () => 'responseId',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ describe('instant products slice', () => {
additionalFields: {test: 'test'},
clickUri: 'child-uri',
ec_brand: 'child brand',
ec_category: 'child category',
ec_category: ['child category'],
ec_description: 'child description',
ec_gender: 'child gender',
ec_images: ['child image'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ describe('product-listing-slice', () => {
additionalFields: {test: 'test'},
clickUri: 'child-uri',
ec_brand: 'child brand',
ec_category: 'child category',
ec_category: ['child category'],
ec_description: 'child description',
ec_gender: 'child gender',
ec_images: ['child image'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ describe('recommendation-slice', () => {
additionalFields: {test: 'test'},
clickUri: 'child-uri',
ec_brand: 'child brand',
ec_category: 'child category',
ec_category: ['child category'],
ec_description: 'child description',
ec_gender: 'child gender',
ec_images: ['child image'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ describe('search-slice', () => {
additionalFields: {test: 'test'},
clickUri: 'child-uri',
ec_brand: 'child brand',
ec_category: 'child category',
ec_category: ['child category'],
ec_description: 'child description',
ec_gender: 'child gender',
ec_images: ['child image'],
Expand Down
15 changes: 15 additions & 0 deletions packages/headless/src/test/mock-product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@ export function buildMockChildProduct(
clickUri: '',
ec_name: '',
additionalFields: {},
ec_description: '',
ec_brand: '',
ec_category: [],
ec_item_group_id: '',
ec_price: 0,
ec_color: '',
ec_gender: '',
ec_images: [],
ec_in_stock: false,
ec_listing: '',
ec_product_id: '',
ec_promo_price: 0,
ec_rating: 0,
ec_shortdesc: '',
ec_thumbnails: [],
...config,
};
}
Expand Down

0 comments on commit 5e514c5

Please sign in to comment.