Skip to content

Commit

Permalink
facet(commerce): add commerce numeric facets (#3449)
Browse files Browse the repository at this point in the history
  • Loading branch information
fbeaudoincoveo authored Dec 11, 2023
1 parent 464b228 commit 2365788
Show file tree
Hide file tree
Showing 35 changed files with 2,562 additions and 1,316 deletions.
22 changes: 9 additions & 13 deletions packages/headless/src/commerce.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,20 +92,16 @@ export {
SortDirection,
} from './controllers/commerce/product-listing/sort/headless-product-listing-sort';

export type {CommerceRegularFacet} from './controllers/commerce/facets/core/regular/headless-commerce-regular-facet';
export type {CommerceNumericFacet} from './controllers/commerce/facets/core/numeric/headless-commerce-numeric-facet';
export type {
FacetGenerator,
FacetGeneratorState,
} from './controllers/commerce/facets/core/headless-core-facet-generator';
export type {
FacetValue,
FacetValueState,
FacetProps,
FacetOptions,
Facet,
FacetState,
} from './controllers/commerce/facets/core/headless-core-facet';

export {buildProductListingFacet} from './controllers/commerce/product-listing/facets/headless-product-listing-facet';
FacetType,
FacetValueRequest,
RegularFacetValue,
NumericRangeRequest,
NumericFacetValue,
} from './controllers/commerce/facets/core/headless-core-commerce-facet';
export type {ProductListingFacetGenerator} from './controllers/commerce/product-listing/facets/headless-product-listing-facet-generator';
export {buildProductListingFacetGenerator} from './controllers/commerce/product-listing/facets/headless-product-listing-facet-generator';

export type {Search} from './controllers/commerce/search/headless-search';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {commerceFacetSetReducer as commerceFacetSet} from '../../../../../features/commerce/facets/facet-set/facet-set-slice';
import {FacetType} from '../../../../../features/commerce/facets/facet-set/interfaces/response';
import {facetOrderReducer as facetOrder} from '../../../../../features/facets/facet-order/facet-order-slice';
import {buildMockCommerceEngine, MockCommerceEngine} from '../../../../../test';
import {buildMockCommerceFacetRequest} from '../../../../../test/mock-commerce-facet-request';
import {
buildMockCommerceRegularFacetResponse,
buildMockCommerceNumericFacetResponse,
} from '../../../../../test/mock-commerce-facet-response';
import {buildMockCommerceState} from '../../../../../test/mock-commerce-state';
import {buildProductListingNumericFacet} from '../../../product-listing/facets/headless-product-listing-numeric-facet';
import {buildProductListingRegularFacet} from '../../../product-listing/facets/headless-product-listing-regular-facet';
import {
buildCommerceFacetGenerator,
CommerceFacetGenerator,
CommerceFacetGeneratorOptions,
} from './headless-commerce-facet-generator';

describe('CommerceFacetGenerator', () => {
let engine: MockCommerceEngine;
let options: CommerceFacetGeneratorOptions;
let facetGenerator: CommerceFacetGenerator;

function initFacetGenerator(
facetId: string = 'regular_facet_id',
type: FacetType = 'regular'
) {
const mockState = buildMockCommerceState();
const facets = [];
switch (type) {
case 'numericalRange':
facets.push(
buildMockCommerceNumericFacetResponse({
facetId,
field: 'some_numeric_field',
})
);
break;
case 'regular':
case 'dateRange': // TODO
case 'hierarchical': // TODO
default:
facets.push(
buildMockCommerceRegularFacetResponse({
facetId,
field: 'some_regular_field',
})
);
break;
}
engine = buildMockCommerceEngine({
state: {
...mockState,
productListing: {
...mockState.productListing,
facets: [
buildMockCommerceRegularFacetResponse({
facetId,
field: 'some_regular_field',
}),
],
},
facetOrder: [facetId],
commerceFacetSet: {
[facetId]: {request: buildMockCommerceFacetRequest({facetId, type})},
},
},
});
options = {
buildNumericFacet: buildProductListingNumericFacet,
buildRegularFacet: buildProductListingRegularFacet,
};
facetGenerator = buildCommerceFacetGenerator(engine, options);
}

describe('upon initialization', () => {
describe('regardless of the current facet state', () => {
beforeEach(() => {
initFacetGenerator();
});

it('initializes', () => {
expect(facetGenerator).toBeTruthy();
});

it('adds correct reducers to engine', () => {
expect(engine.addReducers).toHaveBeenCalledWith({
facetOrder,
commerceFacetSet,
});
});

it('exposes #subscribe method', () => {
expect(facetGenerator.subscribe).toBeTruthy();
});
});
describe('when facet state contains regular facets', () => {
it('should generate regular facet controllers', () => {
const facetId = 'regular_facet_id';
initFacetGenerator(facetId, 'regular');

expect(facetGenerator.state.facets.length).toEqual(1);
expect(facetGenerator.state.facets[0].state).toEqual(
buildProductListingRegularFacet(engine, {facetId}).state
);
});
});

describe('when facet state contains numeric facets', () => {
it('should generate numeric facet controllers', () => {
const facetId = 'numeric_facet_id';
initFacetGenerator(facetId, 'numericalRange');

expect(facetGenerator.state.facets.length).toEqual(1);
expect(facetGenerator.state.facets[0].state).toEqual(
buildProductListingNumericFacet(engine, {facetId}).state
);
});
});
});

it('should generate date facet controllers', () => {
// TODO
});

it('should generate category facet controllers', () => {
// TODO
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {CommerceEngine} from '../../../../../app/commerce-engine/commerce-engine';
import {commerceFacetSetReducer as commerceFacetSet} from '../../../../../features/commerce/facets/facet-set/facet-set-slice';
import {AnyFacetValueResponse} from '../../../../../features/commerce/facets/facet-set/interfaces/response';
import {facetOrderReducer as facetOrder} from '../../../../../features/facets/facet-order/facet-order-slice';
import {AnyFacetValueRequest} from '../../../../../features/facets/generic/interfaces/generic-facet-request';
import {
CommerceFacetSetSection,
FacetOrderSection,
} from '../../../../../state/state-sections';
import {loadReducerError} from '../../../../../utils/errors';
import {
buildController,
Controller,
} from '../../../../controller/headless-controller';
import {ProductListingNumericFacetBuilder} from '../../../product-listing/facets/headless-product-listing-numeric-facet';
import {ProductListingRegularFacetBuilder} from '../../../product-listing/facets/headless-product-listing-regular-facet';
import {CoreCommerceFacet} from '../headless-core-commerce-facet';

/**
* The `CommerceFacetGenerator` headless controller creates commerce facet controllers from the Commerce API search or
* product listing response.
*
* Commerce facets are not requested by the implementer, but rather pre-configured through the Coveo Merchandising Hub
* (CMH). The implementer is only responsible for leveraging the facet controllers created by this controller to
* properly render facets in their application.
*/
export interface CommerceFacetGenerator extends Controller {
/**
* The state of the facet generator controller.
*/
state: CommerceFacetGeneratorState;
}

/**
* A scoped and simplified part of the headless state that is relevant to the facet generator controller.
*/
export interface CommerceFacetGeneratorState {
/**
* The generated commerce facet controllers.
*/
facets: CoreCommerceFacet<AnyFacetValueRequest, AnyFacetValueResponse>[];
}

type CommerceRegularFacetBuilder = ProductListingRegularFacetBuilder; // TODO: | CommerceSearchRegularFacetBuilder;
type CommerceNumericFacetBuilder = ProductListingNumericFacetBuilder; // TODO: | CommerceSearchNumericFacetBuilder;
// TODO: type CommerceDateFacetBuilder = ProductListingDateFacetBuilder | CommerceSearchDateFacetBuilder;
// TODO: type CommerceCategoryFacetBuilder = ProductListingCategoryFacetBuilder | CommerceSearchCategoryFacetBuilder;

/**
* @internal
*
* The `CommerceFacetGenerator` options used internally.
*/
export interface CommerceFacetGeneratorOptions {
buildRegularFacet: CommerceRegularFacetBuilder;
buildNumericFacet: CommerceNumericFacetBuilder;
// TODO: buildDateFacet: CommerceDateFacetBuilder;
// TODO: buildCategoryFacet: CommerceNumericFacetBuilder;
}

/**
* @internal
*
* Creates a `CommerceFacetGenerator` instance.
*
* @param engine - The headless commerce engine.
* @param options - The facet generator options used internally.
* @returns A `CommerceFacetGenerator` controller instance.
*/
export function buildCommerceFacetGenerator(
engine: CommerceEngine,
options: CommerceFacetGeneratorOptions
): CommerceFacetGenerator {
if (!loadCommerceFacetGeneratorReducers(engine)) {
throw loadReducerError;
}

const controller = buildController(engine);

const createFacet = (facetId: string) => {
const {type} = engine.state.commerceFacetSet[facetId].request;

switch (type) {
case 'numericalRange':
return options.buildNumericFacet(engine, {facetId});
case 'dateRange': // TODO: return options.buildDateFacet(engine, {facetId});
case 'hierarchical': // TODO return options.buildCategoryFacet(engine, {facetId});
case 'regular':
default:
return options.buildRegularFacet(engine, {facetId});
}
};

return {
...controller,

get state() {
return {
facets: engine.state.facetOrder.map(createFacet) ?? [],
};
},
};
}

function loadCommerceFacetGeneratorReducers(
engine: CommerceEngine
): engine is CommerceEngine<FacetOrderSection & CommerceFacetSetSection> {
engine.addReducers({facetOrder, commerceFacetSet});
return true;
}
Loading

0 comments on commit 2365788

Please sign in to comment.