Skip to content

Commit

Permalink
feat(commerce): extract core sort for plp and search (#3474)
Browse files Browse the repository at this point in the history
* extract core sort for plp and search

* fix documentation

* update sort on search fulfilled

* apply suggestions

* clarify test group name

* handle pagination reset consistently

* use shared loader

* Revert "handle pagination reset consistently"

This reverts commit 25aa31a.
  • Loading branch information
Spuffynism authored Dec 12, 2023
1 parent 74c3b65 commit 74c899a
Show file tree
Hide file tree
Showing 14 changed files with 423 additions and 258 deletions.
8 changes: 5 additions & 3 deletions packages/headless/src/commerce.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,14 +84,16 @@ export type {
SortInitialState,
Sort,
SortState,
} from './controllers/commerce/product-listing/sort/headless-product-listing-sort';
} from './controllers/commerce/sort/core/headless-core-commerce-sort';
export {
buildSort,
buildRelevanceSortCriterion,
buildFieldsSortCriterion,
SortBy,
SortDirection,
} from './controllers/commerce/product-listing/sort/headless-product-listing-sort';
} from './controllers/commerce/sort/core/headless-core-commerce-sort';

export {buildProductListingSort} from './controllers/commerce/product-listing/sort/headless-product-listing-sort';
export {buildSearchSort} from './controllers/commerce/search/sort/headless-search-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';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,96 +1,30 @@
import {Action} from '@reduxjs/toolkit';
import {fetchProductListing} from '../../../../features/commerce/product-listing/product-listing-actions';
import {productListingV2Reducer} from '../../../../features/commerce/product-listing/product-listing-slice';
import {applySort} from '../../../../features/commerce/sort/sort-actions';
import {sortReducer} from '../../../../features/commerce/sort/sort-slice';
import {updatePage} from '../../../../features/pagination/pagination-actions';
import {buildMockCommerceEngine, MockCommerceEngine} from '../../../../test';
import {buildMockCommerceState} from '../../../../test/mock-commerce-state';
import {
buildRelevanceSortCriterion,
buildSort,
Sort,
SortBy,
} from './headless-product-listing-sort';
} from '../../sort/core/headless-core-commerce-sort';
import {buildProductListingSort} from './headless-product-listing-sort';

describe('headless product-listing-sort', () => {
describe('headless product listing sort', () => {
let sort: Sort;
let engine: MockCommerceEngine;

beforeEach(() => {
engine = buildMockCommerceEngine();
sort = buildSort(engine);
sort = buildProductListingSort(engine);
});

const expectContainAction = (action: Action) => {
const found = engine.actions.find((a) => a.type === action.type);
expect(engine.actions).toContainEqual(found);
};

it('adds the correct reducers to engine', () => {
expect(engine.addReducers).toHaveBeenCalledWith({
productListing: productListingV2Reducer,
commerceSort: sortReducer,
});
});

it('dispatches #applySort on load when sort is specified', () => {
sort = buildSort(engine, {
initialState: {
criterion: buildRelevanceSortCriterion(),
},
});
expectContainAction(applySort);
});

it('sortBy dispatches #applySort, #updatePage, and #fetchProductListing', () => {
it('#sortBy dispatches #fetchProductListing', () => {
sort.sortBy(buildRelevanceSortCriterion());
expectContainAction(applySort);
expectContainAction(updatePage);
expectContainAction(fetchProductListing.pending);
});

describe('when sort is populated', () => {
const appliedSort = {
by: SortBy.Fields,
fields: [{name: 'some_field'}],
};

beforeEach(() => {
engine = buildMockCommerceEngine({
state: {
...buildMockCommerceState(),
commerceSort: {
appliedSort: appliedSort,
availableSorts: [appliedSort],
},
},
});
sort = buildSort(engine);
});

it('calling #isSortedBy with a criterion equal to the one in state returns true', () => {
expect(sort.isSortedBy(appliedSort)).toBe(true);
});

it('calling #isSortedBy with a criterion different from the one in state returns false', () => {
const notAppliedSort = {
by: SortBy.Fields,
fields: [{name: 'some_other_field'}],
};
expect(sort.isSortedBy(notAppliedSort)).toBe(false);
});

it('calling #isAvailable with an available criterion returns true', () => {
expect(sort.isAvailable(appliedSort)).toBe(true);
});

it('calling #isAvailable with an unavailable criterion returns false', () => {
const unavailableSort = {
by: SortBy.Fields,
fields: [{name: 'some_other_field'}],
};
expect(sort.isAvailable(unavailableSort)).toBe(false);
});
const action = engine.findAsyncAction(fetchProductListing.pending);
expect(action).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -1,162 +1,30 @@
import {Schema} from '@coveo/bueno';
import {CommerceEngine} from '../../../../app/commerce-engine/commerce-engine';
import {CoreEngine} from '../../../../app/engine';
import {fetchProductListing} from '../../../../features/commerce/product-listing/product-listing-actions';
import {productListingV2Reducer as productListing} from '../../../../features/commerce/product-listing/product-listing-slice';
import {
buildFieldsSortCriterion,
buildRelevanceSortCriterion,
SortBy,
SortByFields,
SortByFieldsFields,
SortByRelevance,
SortCriterion,
SortDirection,
sortCriterionDefinition,
} from '../../../../features/commerce/sort/sort';
import {applySort} from '../../../../features/commerce/sort/sort-actions';
import {sortReducer as commerceSort} from '../../../../features/commerce/sort/sort-slice';
import {updatePage} from '../../../../features/pagination/pagination-actions';
import {ProductListingV2Section} from '../../../../state/state-sections';
import {loadReducerError} from '../../../../utils/errors';
import {validateInitialState} from '../../../../utils/validate-payload';
import {
buildController,
Controller,
} from '../../../controller/headless-controller';

export type {SortByRelevance, SortByFields, SortByFieldsFields, SortCriterion};
export {
SortBy,
SortDirection,
buildRelevanceSortCriterion,
buildFieldsSortCriterion,
};

export interface SortProps {
/**
* The initial state that should be applied to this `Sort` controller.
*/
initialState?: SortInitialState;
}

export interface SortInitialState {
/**
* The initial sort criterion to register in state.
*/
criterion?: SortCriterion;
}

function validateSortInitialState(
engine: CoreEngine<ProductListingV2Section>,
state: SortInitialState | undefined
) {
if (!state) {
return;
}

const schema = new Schema<SortInitialState>({
criterion: sortCriterionDefinition,
});

validateInitialState(engine, schema, state, 'buildSort');
}

export interface Sort extends Controller {
/**
* Updates the sort criterion and executes a new query.
*
* @param criterion - The new sort criterion.
*/
sortBy(criterion: SortCriterion): void;

/**
* Checks whether the specified sort criterion matches the value in state.
*
* @param criterion - The criterion to compare.
* @returns `true` if the passed sort criterion matches the value in state, and `false` otherwise.
*/
isSortedBy(criterion: SortCriterion): boolean;

/**
* Checks whether the specified sort criterion is available.
*
* @param criterion - The criterion to check for.
* @returns `true` if the passed sort criterion is available, and `false` otherwise.
*/
isAvailable(criterion: SortCriterion): boolean;

/**
* A scoped and simplified part of the headless state that is relevant to the `Sort` controller.
*/
state: SortState;
}

export interface SortState {
/**
* The current sort criterion.
*/
appliedSort: SortCriterion;

/**
* The available sort criteria.
*/
availableSorts: SortCriterion[];
}
Sort,
buildCoreSort,
SortProps,
} from '../../sort/core/headless-core-commerce-sort';
import {loadProductListingReducer} from '../utils/load-product-listing-reducers';

/**
* Creates a `Sort` controller instance for the product listing.
*
* @param engine - The headless engine.
* @param engine - The headless commerce engine.
* @param props - The configurable `Sort` controller properties.
* @returns A `Sort` controller instance.
*/
export function buildSort(engine: CommerceEngine, props: SortProps = {}): Sort {
if (!loadSortReducers(engine)) {
export function buildProductListingSort(
engine: CommerceEngine,
props: SortProps = {}
): Sort {
if (!loadProductListingReducer(engine)) {
throw loadReducerError;
}

const {dispatch} = engine;
const controller = buildController(engine);
const getState = () => engine.state;

validateSortInitialState(engine, props.initialState);

const criterion = props.initialState?.criterion;

if (criterion) {
dispatch(applySort(criterion));
}

return {
...controller,

get state() {
return getState().commerceSort;
},

sortBy(criterion: SortCriterion) {
dispatch(applySort(criterion));
dispatch(updatePage(0));
dispatch(fetchProductListing());
},

isSortedBy(criterion: SortCriterion) {
return (
JSON.stringify(this.state.appliedSort) === JSON.stringify(criterion)
);
},

isAvailable(criterion: SortCriterion) {
return this.state.availableSorts.some(
(availableCriterion) =>
JSON.stringify(availableCriterion) === JSON.stringify(criterion)
);
},
};
}

function loadSortReducers(engine: CommerceEngine): engine is CommerceEngine {
engine.addReducers({productListing, commerceSort});
return true;
return buildCoreSort(engine, {
...props,
fetchResultsActionCreator: fetchProductListing,
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {executeSearch} from '../../../../features/commerce/search/search-actions';
import {commerceSearchReducer as commerceSearch} from '../../../../features/commerce/search/search-slice';
import {buildMockCommerceEngine, MockCommerceEngine} from '../../../../test';
import {
buildRelevanceSortCriterion,
Sort,
} from '../../sort/core/headless-core-commerce-sort';
import {buildSearchSort} from './headless-search-sort';

describe('commerce search sort', () => {
let sort: Sort;
let engine: MockCommerceEngine;

beforeEach(() => {
engine = buildMockCommerceEngine();
sort = buildSearchSort(engine);
});

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

it('#sortBy dispatches #fetchProductListing', () => {
sort.sortBy(buildRelevanceSortCriterion());
const action = engine.findAsyncAction(executeSearch.pending);
expect(action).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {CommerceEngine} from '../../../../app/commerce-engine/commerce-engine';
import {executeSearch} from '../../../../features/commerce/search/search-actions';
import {loadReducerError} from '../../../../utils/errors';
import {
Sort,
buildCoreSort,
SortProps,
} from '../../sort/core/headless-core-commerce-sort';
import {loadSearchReducer} from '../utils/headless-search-reducers';

/**
* Creates a `Sort` controller instance for commerce search.
*
* @param engine - The headless commerce engine.
* @param props - The configurable `Sort` controller properties.
* @returns A `Sort` controller instance.
*/
export function buildSearchSort(
engine: CommerceEngine,
props: SortProps = {}
): Sort {
if (!loadSearchReducer(engine)) {
throw loadReducerError;
}

return buildCoreSort(engine, {
...props,
fetchResultsActionCreator: executeSearch,
});
}
Loading

0 comments on commit 74c899a

Please sign in to comment.