diff --git a/packages/headless/src/api/commerce/commerce-api-params.ts b/packages/headless/src/api/commerce/commerce-api-params.ts index a35784324ce..75f6a816795 100644 --- a/packages/headless/src/api/commerce/commerce-api-params.ts +++ b/packages/headless/src/api/commerce/commerce-api-params.ts @@ -1,4 +1,4 @@ -import {AnyCommerceFacetRequest} from '../../features/commerce/facets/facet-set/interfaces/request'; +import {AnyFacetRequest} from '../../features/commerce/facets/facet-set/interfaces/request'; import {SortOption} from './common/sort'; export interface TrackingIdParam { @@ -62,7 +62,7 @@ export interface CartItemParam { } export interface FacetsParam { - facets?: AnyCommerceFacetRequest[]; + facets?: AnyFacetRequest[]; } export interface PageParam { diff --git a/packages/headless/src/commerce.index.ts b/packages/headless/src/commerce.index.ts index 7756cd25809..49e0e1e64fa 100644 --- a/packages/headless/src/commerce.index.ts +++ b/packages/headless/src/commerce.index.ts @@ -97,6 +97,7 @@ export { export {buildProductListingSort} from './controllers/commerce/product-listing/sort/headless-product-listing-sort'; export {buildSearchSort} from './controllers/commerce/search/sort/headless-search-sort'; +export type {CategoryFacet} from './controllers/commerce/core/facets/category/headless-commerce-category-facet'; export type {RegularFacet} from './controllers/commerce/core/facets/regular/headless-commerce-regular-facet'; export type {NumericFacet} from './controllers/commerce/core/facets/numeric/headless-commerce-numeric-facet'; export type {DateFacet} from './controllers/commerce/core/facets/date/headless-commerce-date-facet'; @@ -108,6 +109,8 @@ export type { NumericFacetValue, DateRangeRequest, DateFacetValue, + CategoryFacetValueRequest, + CategoryFacetValue, } from './controllers/commerce/core/facets/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'; diff --git a/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet.test.ts b/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet.test.ts new file mode 100644 index 00000000000..ad87d9449d4 --- /dev/null +++ b/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet.test.ts @@ -0,0 +1,315 @@ +import { + CategoryFacetValueRequest, + CommerceFacetRequest, +} from '../../../../../features/commerce/facets/facet-set/interfaces/request'; +import {CategoryFacetValue} from '../../../../../features/commerce/facets/facet-set/interfaces/response'; +import { + toggleSelectCategoryFacetValue, + updateCategoryFacetNumberOfValues, +} from '../../../../../features/facets/category-facet-set/category-facet-set-actions'; +import {CommerceAppState} from '../../../../../state/commerce-app-state'; +import {buildMockCommerceFacetRequest} from '../../../../../test/mock-commerce-facet-request'; +import {buildMockCategoryFacetResponse} from '../../../../../test/mock-commerce-facet-response'; +import {buildMockCommerceFacetSlice} from '../../../../../test/mock-commerce-facet-slice'; +import {buildMockCategoryFacetValue} from '../../../../../test/mock-commerce-facet-value'; +import {buildMockCommerceState} from '../../../../../test/mock-commerce-state'; +import { + MockedCommerceEngine, + buildMockCommerceEngine, +} from '../../../../../test/mock-engine-v2'; +import {commonOptions} from '../../../product-listing/facets/headless-product-listing-facet-options'; +import { + CategoryFacet, + CategoryFacetOptions, + buildCategoryFacet, +} from './headless-commerce-category-facet'; + +jest.mock( + '../../../../../features/facets/category-facet-set/category-facet-set-actions' +); + +describe('CategoryFacet', () => { + const facetId: string = 'category_facet_id'; + let engine: MockedCommerceEngine; + let state: CommerceAppState; + let options: CategoryFacetOptions; + let facet: CategoryFacet; + + function initEngine(preloadedState = buildMockCommerceState()) { + engine = buildMockCommerceEngine(preloadedState); + } + + function initCategoryFacet() { + facet = buildCategoryFacet(engine, options); + } + + function setFacetState( + config: Partial> = {}, + moreValuesAvailable = false + ) { + state.commerceFacetSet[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + facetId, + type: 'hierarchical', + ...config, + }), + }); + state.productListing.facets = [ + buildMockCategoryFacetResponse({ + moreValuesAvailable, + facetId, + type: 'hierarchical', + values: (config.values as CategoryFacetValue[]) ?? [], + }), + ]; + } + + // eslint-disable-next-line @cspell/spellchecker + // TODO CAPI-90: Test facet search + /*function setFacetSearch() { + state.facetSearchSet[facetId] = buildMockFacetSearch(); + }*/ + + beforeEach(() => { + jest.resetAllMocks(); + + options = { + facetId, + ...commonOptions, + }; + + state = buildMockCommerceState(); + setFacetState(); + // eslint-disable-next-line @cspell/spellchecker + // TODO CAPI-90: Test facet search + // setFacetSearch(); + + initEngine(state); + initCategoryFacet(); + }); + + describe('initialization', () => { + it('initializes', () => { + expect(facet).toBeTruthy(); + }); + + it('exposes #subscribe method', () => { + expect(facet.subscribe).toBeTruthy(); + }); + }); + + it('#toggleSelect dispatches #toggleSelectCategoryFacetValue with correct payload', () => { + const facetValue = buildMockCategoryFacetValue(); + facet.toggleSelect(facetValue); + + expect(toggleSelectCategoryFacetValue).toHaveBeenCalledWith({ + facetId, + selection: facetValue, + }); + }); + + it('#showLessValues dispatches #updateCategoryFacetNumberOfValues with correct payload', () => { + facet.showLessValues(); + + expect(updateCategoryFacetNumberOfValues).toHaveBeenCalledWith({ + facetId, + numberOfValues: 5, + }); + }); + + it('#showMoreValues dispatches #updateCategoryFacetNumberOfValues with correct payload', () => { + facet.showMoreValues(); + + expect(updateCategoryFacetNumberOfValues).toHaveBeenCalledWith({ + facetId, + numberOfValues: 5, + }); + }); + + describe('#state', () => { + describe('#activeValue', () => { + it('when no value is selected, returns undefined', () => { + expect(facet.state.activeValue).toBeUndefined(); + }); + it('when a value is selected, returns the selected value', () => { + const activeValue = buildMockCategoryFacetValue({ + state: 'selected', + }); + setFacetState({ + values: [activeValue, buildMockCategoryFacetValue()], + }); + + expect(facet.state.activeValue).toBe(activeValue); + }); + }); + + describe('#canShowLessValues', () => { + describe('when no value is selected', () => { + it('when there are no values, returns false', () => { + expect(facet.state.canShowLessValues).toBe(false); + }); + it('when there are fewer values than default number of values, returns false', () => { + setFacetState({ + values: [buildMockCategoryFacetValue()], + }); + + expect(facet.state.canShowLessValues).toBe(false); + }); + it('when there are more values than default number of values, returns true', () => { + setFacetState({ + values: [ + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + ], + }); + + expect(facet.state.canShowLessValues).toBe(false); + }); + }); + + describe('when a value is selected', () => { + it('when selected value has no children, returns false', () => { + setFacetState({ + values: [ + buildMockCategoryFacetValue({ + state: 'selected', + }), + ], + }); + + expect(facet.state.canShowLessValues).toBe(false); + }); + it('when selected value fewer children than default number of values, returns false', () => { + setFacetState({ + values: [ + buildMockCategoryFacetValue({ + state: 'selected', + children: [buildMockCategoryFacetValue()], + }), + ], + }); + + expect(facet.state.canShowLessValues).toBe(false); + }); + it('when selected value has more children than default number of values, return true', () => { + setFacetState({ + values: [ + buildMockCategoryFacetValue({ + state: 'selected', + children: [ + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + buildMockCategoryFacetValue(), + ], + }), + ], + }); + + expect(facet.state.canShowLessValues).toBe(true); + }); + }); + }); + + describe('#canShowMoreValues', () => { + describe('when no value is selected', () => { + it('when there are no more values available, returns false', () => { + expect(facet.state.canShowMoreValues).toBe(false); + }); + + it('when there are more values available, returns true', () => { + setFacetState({}, true); + + expect(facet.state.canShowMoreValues).toBe(true); + }); + }); + + describe('when a value is selected', () => { + it('when selected values has no more values available, returns false', () => { + setFacetState({ + values: [buildMockCategoryFacetValue({state: 'selected'})], + }); + + expect(facet.state.canShowMoreValues).toBe(false); + }); + it('when selected value has more values available, returns true', () => { + setFacetState({ + values: [ + buildMockCategoryFacetValue({ + state: 'selected', + moreValuesAvailable: true, + }), + ], + }); + + expect(facet.state.canShowMoreValues).toBe(true); + }); + }); + }); + + describe('#hasActiveValues', () => { + it('when no value is selected, returns false', () => { + expect(facet.state.hasActiveValues).toBe(false); + }); + + it('when a value is selected, returns true', () => { + setFacetState({ + values: [buildMockCategoryFacetValue({state: 'selected'})], + }); + + expect(facet.state.hasActiveValues).toBe(true); + }); + }); + + describe('#selectedValueAncestry', () => { + it('when no value is selected, returns empty array', () => { + expect(facet.state.selectedValueAncestry).toEqual([]); + }); + + it('when a value is selected, returns the selected value ancestry', () => { + const activeValue = buildMockCategoryFacetValue({ + value: 'c', + path: ['a', 'b', 'c'], + state: 'selected', + children: [ + buildMockCategoryFacetValue({ + value: 'd', + path: ['a', 'b', 'c', 'd'], + }), + buildMockCategoryFacetValue({ + value: 'e', + path: ['a', 'b', 'c', 'e'], + }), + ], + }); + const parentValue = buildMockCategoryFacetValue({ + value: 'b', + path: ['a', 'b'], + children: [activeValue], + }); + + const rootValue = buildMockCategoryFacetValue({ + value: 'a', + path: ['a'], + children: [parentValue], + }); + + setFacetState({ + values: [rootValue], + }); + + expect(facet.state.selectedValueAncestry).toEqual([ + rootValue, + parentValue, + activeValue, + ]); + }); + }); + }); +}); diff --git a/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet.ts b/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet.ts new file mode 100644 index 00000000000..e6b78ce5674 --- /dev/null +++ b/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet.ts @@ -0,0 +1,130 @@ +import {CommerceEngine} from '../../../../../app/commerce-engine/commerce-engine'; +import {CategoryFacetValueRequest} from '../../../../../features/commerce/facets/facet-set/interfaces/request'; +import { + defaultNumberOfValuesIncrement, + toggleSelectCategoryFacetValue, + updateCategoryFacetNumberOfValues, +} from '../../../../../features/facets/category-facet-set/category-facet-set-actions'; +import {findActiveValueAncestry} from '../../../../../features/facets/category-facet-set/category-facet-utils'; +import { + CategoryFacetValue, + CoreCommerceFacet, + CoreCommerceFacetOptions, + CoreCommerceFacetState, + buildCoreCommerceFacet, +} from '../headless-core-commerce-facet'; + +export type CategoryFacetOptions = Omit< + CoreCommerceFacetOptions, + 'toggleExcludeActionCreator' | 'toggleSelectActionCreator' +>; + +export type CategoryFacetState = CoreCommerceFacetState & { + activeValue?: CategoryFacetValue; + canShowLessValues: boolean; + canShowMoreValues: boolean; + hasActiveValues: boolean; + selectedValueAncestry?: CategoryFacetValue[]; +}; + +/** + * The `CategoryFacet` controller offers a high-level programming interface for implementing a commerce category + * facet UI component. + */ +export type CategoryFacet = Omit< + CoreCommerceFacet, + | 'isValueExcluded' + | 'toggleExclude' + | 'toggleSingleExclude' + | 'toggleSingleSelect' + | 'state' +> & { + state: CategoryFacetState; +}; + +/** + * @internal + * + * **Important:** This initializer is meant for internal use by headless only. + * As an implementer, you must not import or use this initializer directly in your code. + * You will instead interact with `CategoryFacet` controller instances through the state of a `FacetGenerator` + * controller. + * + * @param engine - The headless commerce engine. + * @param options - The `CategoryFacet` options used internally. + * @returns A `CategoryFacet` controller instance. + * */ +export function buildCategoryFacet( + engine: CommerceEngine, + options: CategoryFacetOptions +): CategoryFacet { + const coreController = buildCoreCommerceFacet< + CategoryFacetValueRequest, + CategoryFacetValue + >(engine, { + options: { + ...options, + toggleSelectActionCreator: toggleSelectCategoryFacetValue, + }, + }); + const {deselectAll, isValueSelected, subscribe, toggleSelect} = + coreController; + + return { + deselectAll, + isValueSelected, + subscribe, + toggleSelect, + + showMoreValues() { + const {facetId} = options; + const {activeValue, values} = this.state; + const numberOfValues = + (activeValue?.children.length ?? values.length) + + defaultNumberOfValuesIncrement; + + engine.dispatch( + updateCategoryFacetNumberOfValues({facetId, numberOfValues}) + ); + engine.dispatch(options.fetchResultsActionCreator()); + }, + + showLessValues() { + const {facetId} = options; + + engine.dispatch( + updateCategoryFacetNumberOfValues({ + facetId, + numberOfValues: defaultNumberOfValuesIncrement, + }) + ); + engine.dispatch(options.fetchResultsActionCreator()); + }, + + get state() { + const selectedValueAncestry = findActiveValueAncestry( + coreController.state.values + ); + const activeValue = selectedValueAncestry.length + ? selectedValueAncestry[selectedValueAncestry.length - 1] + : undefined; + const canShowLessValues = activeValue + ? activeValue.children.length > defaultNumberOfValuesIncrement + : false; + const canShowMoreValues = + activeValue?.moreValuesAvailable ?? + coreController.state.canShowMoreValues ?? + false; + const hasActiveValues = !!activeValue; + + return { + ...coreController.state, + activeValue, + canShowLessValues, + canShowMoreValues, + hasActiveValues, + selectedValueAncestry, + }; + }, + }; +} diff --git a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.test.ts b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.test.ts index 2b293138e9c..e6fc4293111 100644 --- a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.test.ts +++ b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.test.ts @@ -9,6 +9,7 @@ import { buildMockCommerceEngine, } from '../../../../../test/mock-engine-v2'; import {buildMockFacetSearch} from '../../../../../test/mock-facet-search'; +import {buildProductListingCategoryFacet} from '../../../product-listing/facets/headless-product-listing-category-facet'; import {buildProductListingDateFacet} from '../../../product-listing/facets/headless-product-listing-date-facet'; import {buildProductListingNumericFacet} from '../../../product-listing/facets/headless-product-listing-numeric-facet'; import {buildProductListingRegularFacet} from '../../../product-listing/facets/headless-product-listing-regular-facet'; @@ -52,6 +53,7 @@ describe('FacetGenerator', () => { buildNumericFacet: buildProductListingNumericFacet, buildRegularFacet: buildProductListingRegularFacet, buildDateFacet: buildProductListingDateFacet, + buildCategoryFacet: buildProductListingCategoryFacet, }; state = buildMockCommerceState(); @@ -61,100 +63,97 @@ describe('FacetGenerator', () => { initCommerceFacetGenerator(); }); - describe('initialization', () => { - describe('regardless of the current facet state', () => { - beforeEach(() => { - initCommerceFacetGenerator(); - }); - - 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(); - }); + it('initializes', () => { + expect(facetGenerator).toBeTruthy(); + }); + + it('adds correct reducers to engine', () => { + expect(engine.addReducers).toHaveBeenCalledWith({ + facetOrder, + commerceFacetSet, }); + }); - describe('when facet state contains a regular facet', () => { - it('generates a regular facet controller', () => { - const facetId = 'regular_facet_id'; - setFacetState([{facetId, type: 'regular'}]); + it('exposes #subscribe method', () => { + expect(facetGenerator.subscribe).toBeTruthy(); + }); - expect(facetGenerator.state.facets.length).toEqual(1); - expect(facetGenerator.state.facets[0].state).toEqual( - buildProductListingRegularFacet(engine, {facetId}).state - ); - }); - }); + it('when facet state contains a regular facet, generates a regular facet controller', () => { + const facetId = 'regular_facet_id'; + setFacetState([{facetId, type: 'regular'}]); - describe('when facet state contains a numeric facet', () => { - it('generates a numeric facet controller', () => { - const facetId = 'numeric_facet_id'; - setFacetState([{facetId, type: 'numericalRange'}]); + expect(facetGenerator.state.facets.length).toEqual(1); + expect(facetGenerator.state.facets[0].state).toEqual( + buildProductListingRegularFacet(engine, {facetId}).state + ); + }); - expect(facetGenerator.state.facets.length).toEqual(1); - expect(facetGenerator.state.facets[0].state).toEqual( - buildProductListingNumericFacet(engine, {facetId}).state - ); - }); - }); + it('when facet state contains a numeric facet, generates a numeric facet controller', () => { + const facetId = 'numeric_facet_id'; + setFacetState([{facetId, type: 'numericalRange'}]); - describe('when facet state contains a date facet', () => { - it('generates a date facet controller', () => { - const facetId = 'date_facet_id'; - setFacetState([{facetId, type: 'dateRange'}]); + expect(facetGenerator.state.facets.length).toEqual(1); + expect(facetGenerator.state.facets[0].state).toEqual( + buildProductListingNumericFacet(engine, {facetId}).state + ); + }); - expect(facetGenerator.state.facets.length).toEqual(1); - expect(facetGenerator.state.facets[0].state).toEqual( - buildProductListingDateFacet(engine, {facetId}).state - ); - }); - }); + it('when facet state contains a date facet, generates a date facet controller', () => { + const facetId = 'date_facet_id'; + setFacetState([{facetId, type: 'dateRange'}]); - describe('when facet state contains multiple facets', () => { - it('generates the proper facet controllers', () => { - const facets: {facetId: string; type: FacetType}[] = [ - { - facetId: 'regular_facet_id', - type: 'regular', - }, - { - facetId: 'numeric_facet_id', - type: 'numericalRange', - }, - { - facetId: 'date_facet_id', - type: 'dateRange', - }, - ]; - setFacetState(facets); - - expect(facetGenerator.state.facets.length).toEqual(3); - expect(facetGenerator.state.facets[0].state).toEqual( - buildProductListingRegularFacet(engine, {facetId: facets[0].facetId}) - .state - ); - expect(facetGenerator.state.facets[1].state).toEqual( - buildProductListingNumericFacet(engine, {facetId: facets[1].facetId}) - .state - ); - expect(facetGenerator.state.facets[2].state).toEqual( - buildProductListingDateFacet(engine, {facetId: facets[2].facetId}) - .state - ); - }); - }); + expect(facetGenerator.state.facets.length).toEqual(1); + expect(facetGenerator.state.facets[0].state).toEqual( + buildProductListingDateFacet(engine, {facetId}).state + ); + }); + + it('when facet state contains a category facet, generates a category facet controller', () => { + const facetId = 'category_facet_id'; + setFacetState([{facetId, type: 'hierarchical'}]); + + expect(facetGenerator.state.facets.length).toEqual(1); + expect(facetGenerator.state.facets[0].state).toEqual( + buildProductListingCategoryFacet(engine, {facetId}).state + ); }); - it('should generate category facet controllers', () => { - // TODO + it('when facet state contains multiple facets, generates the proper facet controllers', () => { + const facets: {facetId: string; type: FacetType}[] = [ + { + facetId: 'regular_facet_id', + type: 'regular', + }, + { + facetId: 'numeric_facet_id', + type: 'numericalRange', + }, + { + facetId: 'date_facet_id', + type: 'dateRange', + }, + { + facetId: 'category_facet_id', + type: 'hierarchical', + }, + ]; + setFacetState(facets); + + expect(facetGenerator.state.facets.length).toEqual(4); + expect(facetGenerator.state.facets[0].state).toEqual( + buildProductListingRegularFacet(engine, {facetId: facets[0].facetId}) + .state + ); + expect(facetGenerator.state.facets[1].state).toEqual( + buildProductListingNumericFacet(engine, {facetId: facets[1].facetId}) + .state + ); + expect(facetGenerator.state.facets[2].state).toEqual( + buildProductListingDateFacet(engine, {facetId: facets[2].facetId}).state + ); + expect(facetGenerator.state.facets[3].state).toEqual( + buildProductListingCategoryFacet(engine, {facetId: facets[3].facetId}) + .state + ); }); }); diff --git a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ts b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ts index 6c0c9805155..8598b2ac314 100644 --- a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ts +++ b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ts @@ -16,6 +16,7 @@ import { buildController, Controller, } from '../../../../controller/headless-controller'; +import {CategoryFacet} from '../category/headless-commerce-category-facet'; import {DateFacet} from '../date/headless-commerce-date-facet'; import { CommerceFacetOptions, @@ -47,11 +48,23 @@ export interface FacetGeneratorState { /** * The generated commerce facet controllers. */ - facets: CoreCommerceFacet[]; + facets: Omit< + CoreCommerceFacet, + | 'isValueExcluded' + | 'toggleExclude' + | 'toggleSingleExclude' + | 'toggleSingleSelect' + >[]; } type CommerceFacetBuilder< - Facet extends CoreCommerceFacet, + Facet extends Omit< + CoreCommerceFacet, + | 'isValueExcluded' + | 'toggleExclude' + | 'toggleSingleExclude' + | 'toggleSingleSelect' + >, > = (engine: CommerceEngine, options: CommerceFacetOptions) => Facet; type CommerceSearchableFacetBuilder< @@ -69,9 +82,15 @@ export interface FacetGeneratorOptions { buildRegularFacet: CommerceSearchableFacetBuilder; buildNumericFacet: CommerceFacetBuilder; buildDateFacet: CommerceFacetBuilder; - // TODO: buildCategoryFacet: CommerceFacetBuilder; + buildCategoryFacet: CommerceFacetBuilder; } +export type AnyCommerceFacetController = + | RegularFacet + | NumericFacet + | DateFacet + | CategoryFacet; + /** * @internal * @@ -100,13 +119,13 @@ export function buildFacetGenerator( const {type} = engine.state.commerceFacetSet[facetId].request; switch (type) { - case 'numericalRange': - return options.buildNumericFacet(engine, {facetId}); case 'dateRange': return options.buildDateFacet(engine, {facetId}); - case 'hierarchical': // TODO return options.buildCategoryFacet(engine, {facetId}); + case 'hierarchical': + return options.buildCategoryFacet(engine, {facetId}); + case 'numericalRange': + return options.buildNumericFacet(engine, {facetId}); case 'regular': - default: return options.buildRegularFacet(engine, {facetId}); } }; diff --git a/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.test.ts b/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.test.ts index 04d98171cba..df8ed1424ad 100644 --- a/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.test.ts +++ b/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.test.ts @@ -1,5 +1,5 @@ import {commerceFacetSetReducer as commerceFacetSet} from '../../../../features/commerce/facets/facet-set/facet-set-slice'; -import {AnyCommerceFacetRequest} from '../../../../features/commerce/facets/facet-set/interfaces/request'; +import {AnyFacetRequest} from '../../../../features/commerce/facets/facet-set/interfaces/request'; import { AnyFacetValueResponse, RegularFacetResponse, @@ -54,7 +54,7 @@ describe('CoreCommerceFacet', () => { facet = buildCoreCommerceFacet(engine, {options}); } - function setFacetRequest(config: Partial = {}) { + function setFacetRequest(config: Partial = {}) { state.commerceFacetSet[facetId] = buildMockCommerceFacetSlice({ request: buildMockCommerceFacetRequest({facetId, field, type, ...config}), }); @@ -87,24 +87,26 @@ describe('CoreCommerceFacet', () => { initFacet(); }); - it('initializes', () => { - expect(facet).toBeTruthy(); - }); + describe('initialization', () => { + it('initializes', () => { + expect(facet).toBeTruthy(); + }); - it('adds #commerceFacetSet reducer to engine', () => { - expect(engine.addReducers).toHaveBeenCalledWith({ - commerceFacetSet, + it('adds #commerceFacetSet reducer to engine', () => { + expect(engine.addReducers).toHaveBeenCalledWith({ + commerceFacetSet, + }); }); - }); - it('exposes #subscribe method', () => { - expect(facet.subscribe).toBeTruthy(); + it('exposes #subscribe method', () => { + expect(facet.subscribe).toBeTruthy(); + }); }); describe('#toggleSelect', () => { - const facetValue = () => buildMockCommerceRegularFacetValue({value: 'TED'}); + const facetValue = () => buildMockCommerceRegularFacetValue({}); - it('dispatches #toggleSelectActionCreatorwith the passed facet value', () => { + it('dispatches #toggleSelectActionCreatorwith with correct payload', () => { facet.toggleSelect(facetValue()); expect(toggleSelectActionCreator).toHaveBeenCalledWith({ facetId, @@ -119,26 +121,56 @@ describe('CoreCommerceFacet', () => { }); describe('#toggleExclude', () => { - const facetValue = () => buildMockCommerceRegularFacetValue({value: 'TED'}); + const facetValue = () => buildMockCommerceRegularFacetValue({}); + describe('when #toggleExcludeActionCreator is undefined', () => { + beforeEach(() => { + options = { + facetId, + toggleSelectActionCreator, + ...commonOptions, + }; - it('dispatches #toggleExcludeActionCreator with the passed facet value', () => { - facet.toggleExclude(facetValue()); - expect(toggleExcludeActionCreator).toHaveBeenCalledWith({ - facetId, - selection: facetValue(), + initFacet(); + }); + + it('logs a warning', () => { + jest.spyOn(engine.logger, 'warn'); + facet.toggleExclude(facetValue()); + + expect(engine.logger.warn).toHaveBeenCalledTimes(1); + }); + + it('does not dispatch #fetchResultsActionCreator', () => { + facet.toggleExclude(facetValue()); + expect(fetchResultsActionCreator).not.toHaveBeenCalled(); }); }); - it('dispatches #fetchResultsActionCreator', () => { - facet.toggleExclude(facetValue()); - expect(fetchResultsActionCreator).toHaveBeenCalled(); + describe('when #toggleExcludeActionCreator is defined', () => { + it('dispatches #toggleExcludeActionCreator with correct payload', () => { + facet.toggleExclude(facetValue()); + expect(toggleExcludeActionCreator).toHaveBeenCalledWith({ + facetId, + selection: facetValue(), + }); + }); + + it('dispatches #fetchResultsActionCreator', () => { + facet.toggleExclude(facetValue()); + expect(fetchResultsActionCreator).toHaveBeenCalled(); + }); }); }); describe('#toggleSingleSelect', () => { describe('when toggled facet value state is "idle"', () => { const facetValue = () => - buildMockCommerceRegularFacetValue({value: 'TED', state: 'idle'}); + buildMockCommerceRegularFacetValue({state: 'idle'}); + + it('dispatches #deselectAllFacetValues with correct payload', () => { + facet.toggleSingleSelect(facetValue()); + expect(deselectAllFacetValues).toHaveBeenCalledWith(facetId); + }); it('calls #toggleSelect', () => { jest.spyOn(facet, 'toggleSelect'); @@ -146,11 +178,6 @@ describe('CoreCommerceFacet', () => { expect(facet.toggleSelect).toHaveBeenCalled(); }); - - it('dispatches #deselectAllFacetValues with the facetId', () => { - facet.toggleSingleSelect(facetValue()); - expect(deselectAllFacetValues).toHaveBeenCalledWith(facetId); - }); }); describe.each([ @@ -161,8 +188,7 @@ describe('CoreCommerceFacet', () => { state: 'selected' as FacetValueState, }, ])('when toggled facet value state is $state', ({state}) => { - const facetValue = () => - buildMockCommerceRegularFacetValue({value: 'TED', state}); + const facetValue = () => buildMockCommerceRegularFacetValue({state}); it('calls #toggleSelect', () => { jest.spyOn(facet, 'toggleSelect'); @@ -171,7 +197,7 @@ describe('CoreCommerceFacet', () => { expect(facet.toggleSelect).toHaveBeenCalled(); }); - it('does not dispatch the #deselectAllFacetValues action', () => { + it('does not dispatch #deselectAllFacetValues', () => { facet.toggleSingleSelect(facetValue()); expect(deselectAllFacetValues).not.toHaveBeenCalled(); }); @@ -179,44 +205,81 @@ describe('CoreCommerceFacet', () => { }); describe('#toggleSingleExclude', () => { - describe('when toggled facet value state is "idle"', () => { - const facetValue = () => - buildMockCommerceRegularFacetValue({value: 'TED', state: 'idle'}); + const facetValue = () => + buildMockCommerceRegularFacetValue({state: 'idle'}); + describe('when #toggleExcludeActionCreator is undefined', () => { + beforeEach(() => { + options = { + facetId, + toggleSelectActionCreator, + ...commonOptions, + }; - it('calls #toggleExclude', () => { - jest.spyOn(facet, 'toggleExclude'); + initFacet(); + }); + + it('logs a warning', () => { + jest.spyOn(engine.logger, 'warn'); facet.toggleSingleExclude(facetValue()); - expect(facet.toggleExclude).toHaveBeenCalled(); + expect(engine.logger.warn).toHaveBeenCalledTimes(1); }); - it('dispatches the #deselectAllFacetValues action with the facetId', () => { + it('does not dispatch #deselectAllFacetValues', () => { facet.toggleSingleExclude(facetValue()); - expect(deselectAllFacetValues).toHaveBeenCalledWith(facetId); + expect(deselectAllFacetValues).not.toHaveBeenCalled(); }); - }); - describe.each([ - { - state: 'excluded' as FacetValueState, - }, - { - state: 'selected' as FacetValueState, - }, - ])('when toggled facet value state is "$state"', ({state}) => { - const facetValue = () => - buildMockCommerceRegularFacetValue({value: 'TED', state}); - - it('calls #toggleExclude', () => { + it('does not call #toggleExclude', () => { jest.spyOn(facet, 'toggleExclude'); facet.toggleSingleExclude(facetValue()); - expect(facet.toggleExclude).toHaveBeenCalled(); + expect(facet.toggleExclude).not.toHaveBeenCalled(); }); + }); - it('does not dispatch the #deselectAllFacetValues action', () => { - facet.toggleSingleExclude(facetValue()); - expect(deselectAllFacetValues).not.toHaveBeenCalled(); + describe('when #toggleExcludeActionCreator is defined', () => { + describe('when toggled facet value state is "idle"', () => { + it('dispatches #deselectAllFacetValues with correct payload', () => { + facet.toggleSingleExclude(facetValue()); + + expect(deselectAllFacetValues).toHaveBeenCalled(); + }); + + it('calls #toggleExclude', () => { + jest.spyOn(facet, 'toggleExclude'); + facet.toggleSingleExclude(facetValue()); + + expect(facet.toggleExclude).toHaveBeenCalled(); + }); + }); + + describe.each([ + { + state: 'excluded' as FacetValueState, + }, + { + state: 'selected' as FacetValueState, + }, + ])('when toggled facet value state is $state', ({state}) => { + it('calls #toggleExclude', () => { + jest.spyOn(facet, 'toggleExclude'); + const excludedFacetValue = buildMockCommerceRegularFacetValue({ + state, + }); + + facet.toggleSingleExclude(excludedFacetValue); + expect(facet.toggleExclude).toHaveBeenCalled(); + }); + + it('does not dispatch #deselectAllFacetValues', () => { + const excludedFacetValue = buildMockCommerceRegularFacetValue({ + state, + }); + + facet.toggleSingleExclude(excludedFacetValue); + expect(deselectAllFacetValues).not.toHaveBeenCalled(); + }); }); }); }); @@ -227,7 +290,7 @@ describe('CoreCommerceFacet', () => { {state: 'excluded', expected: false}, {state: 'idle', expected: false}, ])( - 'when the passed value is "$state", returns $expected', + 'when passed value state is "$state", returns $expected', ({state, expected}) => { const facetValue = buildMockCommerceRegularFacetValue({ state: state as FacetValueState, @@ -243,7 +306,7 @@ describe('CoreCommerceFacet', () => { {state: 'excluded', expected: true}, {state: 'idle', expected: false}, ])( - 'when the passed value is "$state", returns $expected', + 'when passed value state is "$state", returns $expected', ({state, expected}) => { const facetValue = buildMockCommerceRegularFacetValue({ state: state as FacetValueState, @@ -254,14 +317,15 @@ describe('CoreCommerceFacet', () => { }); describe('#deselectAll', () => { - it('dispatches #deselectAllFacetValues with the facet id', () => { + it('dispatches #deselectAllFacetValues with correct payload', () => { facet.deselectAll(); + expect(deselectAllFacetValues).toHaveBeenCalledWith(facetId); }); }); describe('#showMoreValues', () => { - it('increases the number of values on the request by the configured amount', () => { + it('dispatches #updateFacetNumberOfValues with the correct payload', () => { const numberOfValues = 10; setFacetRequest({numberOfValues, initialNumberOfValues: 10}); @@ -278,7 +342,7 @@ describe('CoreCommerceFacet', () => { }); }); - it('updates isFieldExpanded to true', () => { + it('dispatches #updateFacetIsFieldExpanded with the correct payload', () => { facet.showMoreValues(); expect(updateFacetIsFieldExpanded).toHaveBeenCalledWith({ @@ -294,13 +358,16 @@ describe('CoreCommerceFacet', () => { }); describe('#showLessValues', () => { - it('sets the number of values to the original number', () => { - const initialNumberOfValues = 10; - setFacetRequest({numberOfValues: 25, initialNumberOfValues: 10}); - setFacetResponse({ - values: Array(initialNumberOfValues).fill( - buildMockCommerceRegularFacetValue({value: 'Value'}) - ), + it('when number of active values is less than initial number of values, dispatches #updateFacetNumberOfValues with numberOfValues: in payload', () => { + const activeValues = [ + buildMockCommerceRegularFacetValue({ + state: 'selected', + }), + ]; + const initialNumberOfValues = activeValues.length + 1; + setFacetRequest({ + initialNumberOfValues, + values: activeValues, }); initFacet(); @@ -312,27 +379,26 @@ describe('CoreCommerceFacet', () => { }); }); - it('when number of non-idle values > original number, sets number of values to non-idle number', () => { - const selectedValue = buildMockCommerceRegularFacetValue({ - state: 'selected', - }); - const values = [selectedValue, selectedValue]; - - setFacetRequest({values, numberOfValues: 2}); - setFacetResponse({ - values: [buildMockCommerceRegularFacetValue({value: 'Some Value'})], + it('when number of active values is greater than initial number of values, dispatches #updateFacetNumberOfValues with numberOfValues: in payload', () => { + const activeValues = [ + buildMockCommerceRegularFacetValue({state: 'selected'}), + buildMockCommerceRegularFacetValue({state: 'selected'}), + ]; + const initialNumberOfValues = activeValues.length - 1; + setFacetRequest({ + initialNumberOfValues, + values: activeValues, }); initFacet(); - facet.showLessValues(); expect(updateFacetNumberOfValues).toHaveBeenCalledWith({ facetId, - numberOfValues: 2, + numberOfValues: activeValues.length, }); }); - it('updates isFieldExpanded to "false"', () => { + it('dispatches #updateFacetIsFieldExpanded with isFieldExpanded: false payload', () => { facet.showLessValues(); expect(updateFacetIsFieldExpanded).toHaveBeenCalledWith({ @@ -348,23 +414,23 @@ describe('CoreCommerceFacet', () => { }); describe('#state', () => { - it('#state.facetId exposes the facetId', () => { + it('#facetId exposes the facetId', () => { expect(facet.state.facetId).toBe(facetId); }); - it('#state.type exposes the type', () => { + it('#type exposes the type', () => { expect(facet.state.type).toBe(type); }); - it('#state.field exposes the field', () => { + it('#field exposes the field', () => { expect(facet.state.field).toBe(field); }); - it('#state.displayName exposes the displayName', () => { + it('#displayName exposes the displayName', () => { expect(facet.state.displayName).toBe(displayName); }); - it('#state.values uses #facetResponseSelector', () => { + it('#values uses #facetResponseSelector', () => { const values = [buildMockCommerceRegularFacetValue()]; options = { ...options, @@ -379,7 +445,7 @@ describe('CoreCommerceFacet', () => { expect(facet.state.values).toBe(values); }); - it('#state.isLoading uses #isFacetLoadingResponseSelector', () => { + it('#isLoading uses #isFacetLoadingResponseSelector', () => { options = { ...options, isFacetLoadingResponseSelector: () => true, @@ -388,29 +454,7 @@ describe('CoreCommerceFacet', () => { expect(facet.state.isLoading).toBe(true); }); - describe('#state.hasActiveValues', () => { - it('when #state.values has a value with a non-idle state, returns "true"', () => { - setFacetResponse({ - ...buildMockCommerceRegularFacetResponse({facetId}), - values: [buildMockCommerceRegularFacetValue({state: 'selected'})], - }); - initFacet(); - - expect(facet.state.hasActiveValues).toBe(true); - }); - - it('when #state.values only has idle values, returns "false"', () => { - setFacetResponse({ - ...buildMockCommerceRegularFacetResponse({facetId}), - values: [buildMockCommerceRegularFacetValue({state: 'idle'})], - }); - initFacet(); - - expect(facet.state.hasActiveValues).toBe(false); - }); - }); - - describe('#state.canShowMoreValues', () => { + describe('#canShowMoreValues', () => { it('when there is no response, returns "false"', () => { expect(facet.state.canShowMoreValues).toBe(false); }); @@ -439,9 +483,8 @@ describe('CoreCommerceFacet', () => { expect(facet.state.canShowMoreValues).toBe(false); }); }); - - describe('#state.canShowLessValues', () => { - it('when the number of currentValues is equal to the configured number, it returns false', () => { + describe('#canShowLessValues', () => { + it('when the number of currentValues is equal to the configured number, returns "false"', () => { const values = [buildMockCommerceRegularFacetValue()]; setFacetRequest({values, initialNumberOfValues: 1, numberOfValues: 1}); setFacetResponse({ @@ -453,7 +496,7 @@ describe('CoreCommerceFacet', () => { expect(facet.state.canShowLessValues).toBe(false); }); - it('when the number of currentValues is greater than the configured number, it returns true', () => { + it('when the number of currentValues is greater than the configured number, returns "true"', () => { const value = buildMockCommerceRegularFacetValue(); setFacetRequest({values: [value, value]}); @@ -479,5 +522,50 @@ describe('CoreCommerceFacet', () => { expect(facet.state.canShowLessValues).toBe(false); }); }); + + describe('#hasActiveValues', () => { + it('when there are no values, returns "false"', () => { + setFacetResponse({values: []}); + initFacet(); + + expect(facet.state.hasActiveValues).toBe(false); + }); + + it('when there is at least one value with state "selected", returns "true"', () => { + setFacetResponse({ + values: [ + buildMockCommerceRegularFacetValue({state: 'selected'}), + buildMockCommerceRegularFacetValue({state: 'idle'}), + ], + }); + initFacet(); + + expect(facet.state.hasActiveValues).toBe(true); + }); + + it('when there is at least one value with state "excluded", returns "true"', () => { + setFacetResponse({ + values: [ + buildMockCommerceRegularFacetValue({state: 'excluded'}), + buildMockCommerceRegularFacetValue({state: 'idle'}), + ], + }); + initFacet(); + + expect(facet.state.hasActiveValues).toBe(true); + }); + + it('when all values have state "idle", returns "false"', () => { + setFacetResponse({ + values: [ + buildMockCommerceRegularFacetValue({state: 'idle'}), + buildMockCommerceRegularFacetValue({state: 'idle'}), + ], + }); + initFacet(); + + expect(facet.state.hasActiveValues).toBe(false); + }); + }); }); }); diff --git a/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.ts b/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.ts index 6db1aeb99b0..b9524aed792 100644 --- a/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.ts +++ b/packages/headless/src/controllers/commerce/core/facets/headless-core-commerce-facet.ts @@ -1,15 +1,20 @@ import { - ActionCreatorWithPreparedPayload, AsyncThunkAction, + PayloadActionCreator, + PrepareAction, } from '@reduxjs/toolkit'; import {AsyncThunkOptions} from '../../../../app/async-thunk-options'; import {CommerceEngine} from '../../../../app/commerce-engine/commerce-engine'; import {ThunkExtraArguments} from '../../../../app/thunk-extra-arguments'; import {commerceFacetSetReducer as commerceFacetSet} from '../../../../features/commerce/facets/facet-set/facet-set-slice'; -import {AnyCommerceFacetRequest} from '../../../../features/commerce/facets/facet-set/interfaces/request'; +import { + AnyFacetRequest, + CategoryFacetValueRequest, +} from '../../../../features/commerce/facets/facet-set/interfaces/request'; import { AnyFacetResponse, AnyFacetValueResponse, + CategoryFacetValue, DateFacetValue, FacetType, NumericFacetValue, @@ -40,13 +45,10 @@ export type { NumericFacetValue, DateRangeRequest, DateFacetValue, + CategoryFacetValueRequest, + CategoryFacetValue, }; -interface AnyToggleFacetValueActionCreatorPayload { - selection: any; // eslint-disable-line @typescript-eslint/no-explicit-any - facetId: string; -} - /** * @internal * @@ -58,19 +60,15 @@ export interface CoreCommerceFacetProps { export interface CoreCommerceFacetOptions { facetId: string; - toggleSelectActionCreator: ActionCreatorWithPreparedPayload< - [payload: AnyToggleFacetValueActionCreatorPayload], + toggleSelectActionCreator: PayloadActionCreator< unknown, string, - never, - never + PrepareAction >; - toggleExcludeActionCreator: ActionCreatorWithPreparedPayload< - [payload: AnyToggleFacetValueActionCreatorPayload], + toggleExcludeActionCreator?: PayloadActionCreator< unknown, string, - never, - never + PrepareAction >; fetchResultsActionCreator: () => AsyncThunkAction< unknown, @@ -184,7 +182,7 @@ export function buildCoreCommerceFacet< const facetId = props.options.facetId; - const getRequest = (): AnyCommerceFacetRequest | undefined => + const getRequest = (): AnyFacetRequest | undefined => engine.state.commerceFacetSet[facetId]?.request; const getResponse = () => props.options.facetResponseSelector(engine.state, facetId); @@ -195,28 +193,30 @@ export function buildCoreCommerceFacet< return getRequest()?.values?.filter((v) => v.state !== 'idle').length ?? 0; }; - const computeCanShowLessValues = () => { - const request = getRequest(); - if (!request) { - return false; - } - - const initialNumberOfValues = request.initialNumberOfValues; - const hasIdleValues = !!request.values.find((v) => v.state === 'idle'); - - return initialNumberOfValues < request.numberOfValues && hasIdleValues; - }; - return { ...controller, toggleSelect: (selection: ValueRequest) => { - dispatch(props.options.toggleSelectActionCreator({selection, facetId})); + dispatch( + props.options.toggleSelectActionCreator({ + selection, + facetId, + }) + ); dispatch(props.options.fetchResultsActionCreator()); // TODO: analytics }, toggleExclude: (selection: ValueRequest) => { + // eslint-disable-next-line @cspell/spellchecker + // TODO CAPI-409: Rework facet type definitions + if (!props.options.toggleExcludeActionCreator) { + engine.logger.warn( + 'No toggle exclude action creator provided; calling #toggleExclude had no effect.' + ); + return; + } + dispatch(props.options.toggleExcludeActionCreator({selection, facetId})); dispatch(props.options.fetchResultsActionCreator()); // TODO: analytics @@ -233,6 +233,15 @@ export function buildCoreCommerceFacet< // Must use a function here to properly support inheritance with `this`. toggleSingleExclude: function (selection: ValueRequest) { + // eslint-disable-next-line @cspell/spellchecker + // TODO CAPI-409: Rework facet type definitions + if (!props.options.toggleExcludeActionCreator) { + engine.logger.warn( + 'No toggle exclude action creator provided; calling #toggleSingleExclude had no effect.' + ); + return; + } + if (selection.state === 'idle') { dispatch(deselectAllFacetValues(facetId)); } @@ -281,12 +290,10 @@ export function buildCoreCommerceFacet< get state() { const response = getResponse(); + const canShowMoreValues = response?.moreValuesAvailable ?? false; const values = (response?.values ?? []) as ValueResponse[]; - const hasActiveValues = values.some( - (facetValue) => facetValue.state !== 'idle' - ); - const canShowMoreValues = response?.moreValuesAvailable ?? false; + const hasActiveValues = values.some((v) => v.state !== 'idle'); return { facetId, @@ -295,9 +302,9 @@ export function buildCoreCommerceFacet< displayName: response?.displayName ?? '', values, isLoading: getIsLoading(), - hasActiveValues, canShowMoreValues, - canShowLessValues: computeCanShowLessValues(), + canShowLessValues: canShowLessValues(getRequest()), + hasActiveValues, }; }, }; @@ -309,3 +316,17 @@ function loadCommerceFacetReducers( engine.addReducers({commerceFacetSet}); return true; } + +const canShowLessValues = (request: AnyFacetRequest | undefined) => { + if (!request) { + return false; + } + + const initialNumberOfValues = request.initialNumberOfValues; + const hasIdleValues = !!request.values.find((v) => v.state === 'idle'); + + return ( + (initialNumberOfValues ?? 0) < (request.numberOfValues ?? 0) && + hasIdleValues + ); +}; diff --git a/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-category-facet.test.ts b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-category-facet.test.ts new file mode 100644 index 00000000000..8ca0b577322 --- /dev/null +++ b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-category-facet.test.ts @@ -0,0 +1,97 @@ +import { + CategoryFacetValueRequest, + CommerceFacetRequest, +} from '../../../../features/commerce/facets/facet-set/interfaces/request'; +import {fetchProductListing} from '../../../../features/commerce/product-listing/product-listing-actions'; +import {productListingV2Reducer as productListing} from '../../../../features/commerce/product-listing/product-listing-slice'; +import {CommerceAppState} from '../../../../state/commerce-app-state'; +import {buildMockCommerceFacetRequest} from '../../../../test/mock-commerce-facet-request'; +import {buildMockCategoryFacetResponse} from '../../../../test/mock-commerce-facet-response'; +import {buildMockCommerceFacetSlice} from '../../../../test/mock-commerce-facet-slice'; +import {buildMockCategoryFacetValue} from '../../../../test/mock-commerce-facet-value'; +import {buildMockCommerceState} from '../../../../test/mock-commerce-state'; +import { + MockedCommerceEngine, + buildMockCommerceEngine, +} from '../../../../test/mock-engine-v2'; +import {CategoryFacet} from '../../core/facets/category/headless-commerce-category-facet'; +import {CommerceFacetOptions} from '../../core/facets/headless-core-commerce-facet'; +import {buildProductListingCategoryFacet} from './headless-product-listing-category-facet'; + +jest.mock( + '../../../../features/commerce/product-listing/product-listing-actions' +); + +describe('ProductListingCategoryFacet', () => { + const facetId: string = 'category_facet_id'; + let engine: MockedCommerceEngine; + let state: CommerceAppState; + let options: CommerceFacetOptions; + let facet: CategoryFacet; + + function initEngine(preloadedState = buildMockCommerceState()) { + engine = buildMockCommerceEngine(preloadedState); + } + + function initFacet() { + facet = buildProductListingCategoryFacet(engine, options); + } + + function setFacetState( + config: Partial> = {} + ) { + state.commerceFacetSet[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({facetId, ...config}), + }); + state.productListing.facets = [buildMockCategoryFacetResponse({facetId})]; + } + + beforeEach(() => { + jest.resetAllMocks(); + + options = { + facetId, + }; + + state = buildMockCommerceState(); + setFacetState(); + + initEngine(); + initFacet(); + }); + + describe('initialization', () => { + it('initializes', () => { + expect(facet).toBeTruthy(); + }); + + it('adds #productListing reducer to engine', () => { + expect(engine.addReducers).toHaveBeenCalledWith({productListing}); + }); + }); + + it('#toggleSelect dispatches #fetchProductListing', () => { + const facetValue = buildMockCategoryFacetValue(); + facet.toggleSelect(facetValue); + + expect(fetchProductListing).toHaveBeenCalled(); + }); + + it('#deselectAll dispatches #fetchProductListing', () => { + facet.deselectAll(); + + expect(fetchProductListing).toHaveBeenCalled(); + }); + + it('#showMoreValues dispatches #fetchProductListing', () => { + facet.showMoreValues(); + + expect(fetchProductListing).toHaveBeenCalled(); + }); + + it('#showLessValues dispatches #fetchProductListing', () => { + facet.showLessValues(); + + expect(fetchProductListing).toHaveBeenCalled(); + }); +}); diff --git a/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-category-facet.ts b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-category-facet.ts new file mode 100644 index 00000000000..e551af1a16c --- /dev/null +++ b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-category-facet.ts @@ -0,0 +1,26 @@ +import {CommerceEngine} from '../../../../app/commerce-engine/commerce-engine'; +import {loadReducerError} from '../../../../utils/errors'; +import { + CategoryFacet, + buildCategoryFacet, +} from '../../core/facets/category/headless-commerce-category-facet'; +import {CommerceFacetOptions} from '../../core/facets/headless-core-commerce-facet'; +import {loadProductListingReducer} from '../utils/load-product-listing-reducers'; +import {commonOptions} from './headless-product-listing-facet-options'; + +export type ProductListingCategoryFacetBuilder = + typeof buildProductListingCategoryFacet; + +export function buildProductListingCategoryFacet( + engine: CommerceEngine, + options: CommerceFacetOptions +): CategoryFacet { + if (!loadProductListingReducer(engine)) { + throw loadReducerError; + } + + return buildCategoryFacet(engine, { + ...options, + ...commonOptions, + }); +} diff --git a/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-generator.test.ts b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-generator.test.ts index f19cec6b284..321e664fe69 100644 --- a/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-generator.test.ts +++ b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-generator.test.ts @@ -82,4 +82,12 @@ describe('ProductListingFacetGenerator', () => { expect(fetchProductListing).toHaveBeenCalled(); }); + + it('generated category facet controller dispatches #fetchProductListing', () => { + setFacetState([{facetId: 'category_facet_id', type: 'hierarchical'}]); + + facetGenerator.state.facets[0].deselectAll(); + + expect(fetchProductListing).toHaveBeenCalled(); + }); }); diff --git a/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-generator.ts b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-generator.ts index 3d2ba4b35a3..6c1c8e1f085 100644 --- a/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-generator.ts +++ b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-generator.ts @@ -3,6 +3,7 @@ import { buildFacetGenerator, FacetGenerator, } from '../../core/facets/generator/headless-commerce-facet-generator'; +import {buildProductListingCategoryFacet} from './headless-product-listing-category-facet'; import {buildProductListingDateFacet} from './headless-product-listing-date-facet'; import {buildProductListingNumericFacet} from './headless-product-listing-numeric-facet'; import {buildProductListingRegularFacet} from './headless-product-listing-regular-facet'; @@ -30,6 +31,6 @@ export function buildProductListingFacetGenerator( buildRegularFacet: buildProductListingRegularFacet, buildNumericFacet: buildProductListingNumericFacet, buildDateFacet: buildProductListingDateFacet, - // TODO: buildCategoryFacet: buildProductListingCategoryFacet, + buildCategoryFacet: buildProductListingCategoryFacet, }) as ProductListingFacetGenerator; } diff --git a/packages/headless/src/controllers/commerce/search/facets/headless-search-category-facet.test.ts b/packages/headless/src/controllers/commerce/search/facets/headless-search-category-facet.test.ts new file mode 100644 index 00000000000..94c07aa715b --- /dev/null +++ b/packages/headless/src/controllers/commerce/search/facets/headless-search-category-facet.test.ts @@ -0,0 +1,95 @@ +import { + CategoryFacetValueRequest, + CommerceFacetRequest, +} from '../../../../features/commerce/facets/facet-set/interfaces/request'; +import {executeSearch} from '../../../../features/commerce/search/search-actions'; +import {commerceSearchReducer as commerceSearch} from '../../../../features/commerce/search/search-slice'; +import {CommerceAppState} from '../../../../state/commerce-app-state'; +import {buildMockCommerceFacetRequest} from '../../../../test/mock-commerce-facet-request'; +import {buildMockCategoryFacetResponse} from '../../../../test/mock-commerce-facet-response'; +import {buildMockCommerceFacetSlice} from '../../../../test/mock-commerce-facet-slice'; +import {buildMockCategoryFacetValue} from '../../../../test/mock-commerce-facet-value'; +import {buildMockCommerceState} from '../../../../test/mock-commerce-state'; +import { + MockedCommerceEngine, + buildMockCommerceEngine, +} from '../../../../test/mock-engine-v2'; +import {CategoryFacet} from '../../core/facets/category/headless-commerce-category-facet'; +import {CommerceFacetOptions} from '../../core/facets/headless-core-commerce-facet'; +import {buildSearchCategoryFacet} from './headless-search-category-facet'; + +jest.mock('../../../../features/commerce/search/search-actions'); + +describe('SearchCategoryFacet', () => { + const facetId: string = 'category_facet_id'; + let engine: MockedCommerceEngine; + let state: CommerceAppState; + let options: CommerceFacetOptions; + let facet: CategoryFacet; + + function initEngine(preloadedState = buildMockCommerceState()) { + engine = buildMockCommerceEngine(preloadedState); + } + + function initFacet() { + facet = buildSearchCategoryFacet(engine, options); + } + + function setFacetState( + config: Partial> = {} + ) { + state.commerceFacetSet[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({facetId, ...config}), + }); + state.productListing.facets = [buildMockCategoryFacetResponse({facetId})]; + } + + beforeEach(() => { + jest.resetAllMocks(); + + options = { + facetId, + }; + + state = buildMockCommerceState(); + setFacetState(); + + initEngine(); + initFacet(); + }); + + describe('initialization', () => { + it('initializes', () => { + expect(facet).toBeTruthy(); + }); + + it('adds #commerceSearch reducer to engine', () => { + expect(engine.addReducers).toHaveBeenCalledWith({commerceSearch}); + }); + }); + + it('#toggleSelect dispatches #executeSearch', () => { + const facetValue = buildMockCategoryFacetValue(); + facet.toggleSelect(facetValue); + + expect(executeSearch).toHaveBeenCalled(); + }); + + it('#deselectAll dispatches #executeSearch', () => { + facet.deselectAll(); + + expect(executeSearch).toHaveBeenCalled(); + }); + + it('#showMoreValues dispatches #executeSearch', () => { + facet.showMoreValues(); + + expect(executeSearch).toHaveBeenCalled(); + }); + + it('#showLessValues dispatches #executeSearch', () => { + facet.showLessValues(); + + expect(executeSearch).toHaveBeenCalled(); + }); +}); diff --git a/packages/headless/src/controllers/commerce/search/facets/headless-search-category-facet.ts b/packages/headless/src/controllers/commerce/search/facets/headless-search-category-facet.ts new file mode 100644 index 00000000000..89574916132 --- /dev/null +++ b/packages/headless/src/controllers/commerce/search/facets/headless-search-category-facet.ts @@ -0,0 +1,25 @@ +import {CommerceEngine} from '../../../../app/commerce-engine/commerce-engine'; +import {loadReducerError} from '../../../../utils/errors'; +import { + CategoryFacet, + buildCategoryFacet, +} from '../../core/facets/category/headless-commerce-category-facet'; +import {CommerceFacetOptions} from '../../core/facets/headless-core-commerce-facet'; +import {loadSearchReducer} from '../utils/load-search-reducers'; +import {commonOptions} from './headless-search-facet-options'; + +export type SearchCategoryFacetBuilder = typeof buildSearchCategoryFacet; + +export function buildSearchCategoryFacet( + engine: CommerceEngine, + options: CommerceFacetOptions +): CategoryFacet { + if (!loadSearchReducer(engine)) { + throw loadReducerError; + } + + return buildCategoryFacet(engine, { + ...options, + ...commonOptions, + }); +} diff --git a/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-generator.test.ts b/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-generator.test.ts index 23df2bd721d..797de7de9a9 100644 --- a/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-generator.test.ts +++ b/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-generator.test.ts @@ -77,4 +77,11 @@ describe('SearchFacetGenerator', () => { facetGenerator.state.facets[0].deselectAll(); expect(executeSearch).toHaveBeenCalled(); }); + + it('generated category facet controllers dispatch #executeSearch', () => { + setFacetState([{facetId: 'category_facet_id', type: 'hierarchical'}]); + + facetGenerator.state.facets[0].deselectAll(); + expect(executeSearch).toHaveBeenCalled(); + }); }); diff --git a/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-generator.ts b/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-generator.ts index e83ecce52fa..bd5a2b74fa9 100644 --- a/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-generator.ts +++ b/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-generator.ts @@ -3,6 +3,7 @@ import { buildFacetGenerator, FacetGenerator, } from '../../core/facets/generator/headless-commerce-facet-generator'; +import {buildSearchCategoryFacet} from './headless-search-category-facet'; import {buildSearchDateFacet} from './headless-search-date-facet'; import {buildSearchNumericFacet} from './headless-search-numeric-facet'; import {buildSearchRegularFacet} from './headless-search-regular-facet'; @@ -30,6 +31,6 @@ export function buildSearchFacetGenerator( buildRegularFacet: buildSearchRegularFacet, buildNumericFacet: buildSearchNumericFacet, buildDateFacet: buildSearchDateFacet, - // TODO: buildCategoryFacet: buildSearchCategoryFacet, + buildCategoryFacet: buildSearchCategoryFacet, }) as SearchFacetGenerator; } diff --git a/packages/headless/src/controllers/core/facets/category-facet/headless-core-category-facet.test.ts b/packages/headless/src/controllers/core/facets/category-facet/headless-core-category-facet.test.ts index abd452cf172..a4e0a8d9133 100644 --- a/packages/headless/src/controllers/core/facets/category-facet/headless-core-category-facet.test.ts +++ b/packages/headless/src/controllers/core/facets/category-facet/headless-core-category-facet.test.ts @@ -2,6 +2,7 @@ import {configuration} from '../../../../app/common-reducers'; import {updateFacetOptions} from '../../../../features/facet-options/facet-options-actions'; import {facetOptionsReducer as facetOptions} from '../../../../features/facet-options/facet-options-slice'; import { + defaultNumberOfValuesIncrement, deselectAllCategoryFacetValues, registerCategoryFacet, toggleSelectCategoryFacetValue, @@ -507,7 +508,7 @@ describe('category facet', () => { it('dispatches #updateCategoryFacetNumberOfResults with the correct numberOfValues', () => { expect(updateCategoryFacetNumberOfValues).toHaveBeenCalledWith({ facetId, - numberOfValues: 5, + numberOfValues: defaultNumberOfValuesIncrement, }); }); diff --git a/packages/headless/src/features/commerce/facets/facet-set/facet-set-reducer-helpers.ts b/packages/headless/src/features/commerce/facets/facet-set/facet-set-reducer-helpers.ts new file mode 100644 index 00000000000..13d3fa08b6b --- /dev/null +++ b/packages/headless/src/features/commerce/facets/facet-set/facet-set-reducer-helpers.ts @@ -0,0 +1,19 @@ +import {CommerceFacetSetState} from './facet-set-state'; +import {CategoryFacetValueRequest} from './interfaces/request'; + +export function handleCategoryFacetNestedNumberOfValuesUpdate( + state: CommerceFacetSetState, + payload: {facetId: string; numberOfValues: number} +) { + const {facetId, numberOfValues} = payload; + let selectedValue = state[facetId]?.request + .values[0] as CategoryFacetValueRequest; + if (!selectedValue) { + return; + } + + while (selectedValue.children.length && selectedValue?.state !== 'selected') { + selectedValue = selectedValue.children[0]; + } + selectedValue.retrieveCount = numberOfValues; +} diff --git a/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.test.ts b/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.test.ts index 061a45e6b89..53c8315386b 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.test.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.test.ts @@ -6,12 +6,14 @@ import { } from '../../../../controllers/commerce/core/facets/headless-core-commerce-facet'; import {buildMockCommerceFacetRequest} from '../../../../test/mock-commerce-facet-request'; import { + buildMockCategoryFacetResponse, buildMockCommerceDateFacetResponse, buildMockCommerceNumericFacetResponse, buildMockCommerceRegularFacetResponse, } from '../../../../test/mock-commerce-facet-response'; import {buildMockCommerceFacetSlice} from '../../../../test/mock-commerce-facet-slice'; import { + buildMockCategoryFacetValue, buildMockCommerceDateFacetValue, buildMockCommerceNumericFacetValue, buildMockCommerceRegularFacetValue, @@ -20,6 +22,11 @@ import {buildSearchResponse} from '../../../../test/mock-commerce-search'; import {buildMockFacetSearchResult} from '../../../../test/mock-facet-search-result'; import {buildFetchProductListingV2Response} from '../../../../test/mock-product-listing-v2'; import {deselectAllBreadcrumbs} from '../../../breadcrumb/breadcrumb-actions'; +import { + defaultNumberOfValuesIncrement, + toggleSelectCategoryFacetValue, + updateCategoryFacetNumberOfValues, +} from '../../../facets/category-facet-set/category-facet-set-actions'; import { FacetValueState, facetValueStates, @@ -29,12 +36,14 @@ import { selectFacetSearchResult, } from '../../../facets/facet-search-set/specific/specific-facet-search-actions'; import { + deselectAllFacetValues, toggleExcludeFacetValue, toggleSelectFacetValue, updateFacetIsFieldExpanded, } from '../../../facets/facet-set/facet-set-actions'; import {convertFacetValueToRequest} from '../../../facets/facet-set/facet-set-slice'; import {updateFacetAutoSelection} from '../../../facets/generic/facet-actions'; +import * as FacetReducers from '../../../facets/generic/facet-reducer-helpers'; import { toggleExcludeDateFacetValue, toggleSelectDateFacetValue, @@ -49,12 +58,21 @@ import {convertToNumericRangeRequests} from '../../../facets/range-facets/numeri import {setContext, setUser, setView} from '../../context/context-actions'; import {fetchProductListing} from '../../product-listing/product-listing-actions'; import {executeSearch} from '../../search/search-actions'; -import {commerceFacetSetReducer} from './facet-set-slice'; +import * as CommerceFacetReducers from './facet-set-reducer-helpers'; +import { + commerceFacetSetReducer, + convertCategoryFacetValueToRequest, +} from './facet-set-slice'; import { CommerceFacetSetState, getCommerceFacetSetInitialState, } from './facet-set-state'; -import {AnyFacetResponse, FacetType} from './interfaces/response'; +import {CategoryFacetValueRequest} from './interfaces/request'; +import { + AnyFacetResponse, + CategoryFacetValue, + FacetType, +} from './interfaces/response'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type ActionCreator = (payload: any) => Action; @@ -92,6 +110,7 @@ describe('commerceFacetSetReducer', () => { responseBuilder: () => ReturnType; }) => { const facetId = '1'; + function buildQueryAction(facets: AnyFacetResponse[]) { const response = responseBuilder(); response.response.facets = facets; @@ -99,81 +118,370 @@ describe('commerceFacetSetReducer', () => { return action(response, ''); } - it('updates the values of regular facet requests to the corresponding values in the response', () => { - const facetValue = buildMockCommerceRegularFacetValue({value: 'TED'}); - const facet = buildMockCommerceRegularFacetResponse({ - facetId, - values: [facetValue], - }); - + it('when a previously registered facet is not found in the response, removes that facet from the state', () => { state[facetId] = buildMockCommerceFacetSlice({ - request: buildMockCommerceFacetRequest({type: 'regular', facetId}), + request: buildMockCommerceFacetRequest({facetId}), }); - const action = buildQueryAction([facet]); + const action = buildQueryAction([]); const finalState = commerceFacetSetReducer(state, action); - const expectedFacetValueRequest = - convertFacetValueToRequest(facetValue); - expect(finalState[facetId]?.request.values).toEqual([ - expectedFacetValueRequest, - ]); + expect(finalState[facetId]).toBeUndefined(); }); - it('updates the values of numeric facet requests to the corresponding values in the response', () => { - const facetValue = buildMockCommerceNumericFacetValue(); - const facet = buildMockCommerceNumericFacetResponse({ - facetId, - values: [facetValue], + describe('when a facet is found in the response', () => { + describe('when found facet is not already registered in the state', () => { + it('registers the facet in the state', () => { + const facet = buildMockCommerceRegularFacetResponse({ + facetId, + }); + + const action = buildQueryAction([facet]); + const finalState = commerceFacetSetReducer(state, action); + + expect(finalState[facetId]).toBeDefined(); + }); + it('sets the facet #initialNumberOfResults to #values length from facet response', () => { + const facet = buildMockCommerceRegularFacetResponse({ + facetId, + values: [ + buildMockCommerceRegularFacetValue(), + buildMockCommerceRegularFacetValue(), + buildMockCommerceRegularFacetValue(), + ], + }); + + const action = buildQueryAction([facet]); + const finalState = commerceFacetSetReducer(state, action); + + expect(finalState[facetId]?.request.initialNumberOfValues).toEqual( + facet.values.length + ); + }); }); - state[facetId] = buildMockCommerceFacetSlice({ - request: buildMockCommerceFacetRequest({ - type: 'numericalRange', + it('when found facet is already registered in the state, does not update the facet #initialNumberOfResults in the state', () => { + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + type: 'regular', + facetId, + initialNumberOfValues: 1, + values: [buildMockCommerceRegularFacetValue()], + }), + }); + + const facet = buildMockCommerceRegularFacetResponse({ facetId, - }), + values: [ + buildMockCommerceRegularFacetValue(), + buildMockCommerceRegularFacetValue(), + buildMockCommerceRegularFacetValue(), + ], + }); + + const action = buildQueryAction([facet]); + const finalState = commerceFacetSetReducer(state, action); + + expect(finalState[facetId]?.request.initialNumberOfValues).toEqual(1); }); - const action = buildQueryAction([facet]); - const finalState = commerceFacetSetReducer(state, action); + it('sets/updates facet request #displayName in state from response', () => { + const facetResponse1 = buildMockCommerceRegularFacetResponse({ + facetId, + displayName: 'original display name', + }); - const expectedFacetValueRequests = convertToNumericRangeRequests([ - facetValue, - ]); - expect(finalState[facetId]?.request.values).toEqual( - expectedFacetValueRequests - ); - }); + const action1 = buildQueryAction([facetResponse1]); + const updatedState1 = commerceFacetSetReducer(state, action1); - it('updates the values of date facet requests to the corresponding values in the response', () => { - const facetValue = buildMockCommerceDateFacetValue({ - start: '2023-01-01', - end: '2024-01-01', + expect(updatedState1[facetId]?.request.displayName).toEqual( + facetResponse1.displayName + ); + + const facetResponse2 = buildMockCommerceRegularFacetResponse({ + facetId, + displayName: 'new display name', + }); + + const action2 = buildQueryAction([facetResponse2]); + const updatedState = commerceFacetSetReducer(state, action2); + + expect(updatedState[facetId]?.request.displayName).toEqual( + facetResponse2.displayName + ); }); - const facet = buildMockCommerceDateFacetResponse({ - facetId, - values: [facetValue], + + it('sets/updates facet request #field in state from response', () => { + const field1 = 'original field'; + const facetResponse1 = buildMockCommerceRegularFacetResponse({ + facetId, + field: field1, + }); + + const action1 = buildQueryAction([facetResponse1]); + const updatedState1 = commerceFacetSetReducer(state, action1); + + expect(updatedState1[facetId]?.request.field).toEqual(field1); + + const field2 = 'new field'; + + const facetResponse2 = buildMockCommerceRegularFacetResponse({ + facetId, + field: field2, + }); + + const action2 = buildQueryAction([facetResponse2]); + const updatedState2 = commerceFacetSetReducer(state, action2); + + expect(updatedState2[facetId]?.request.field).toEqual(field2); }); - state[facetId] = buildMockCommerceFacetSlice({ - request: buildMockCommerceFacetRequest({ - type: 'dateRange', + it('sets/updates facet request #numberOfValues in state from #values length in response', () => { + const facetResponse1 = buildMockCommerceRegularFacetResponse({ facetId, - }), + values: [buildMockCommerceRegularFacetValue()], + }); + + const action1 = buildQueryAction([facetResponse1]); + const updatedState1 = commerceFacetSetReducer(state, action1); + + expect(updatedState1[facetId]?.request.numberOfValues).toEqual(1); + + const facetResponse2 = buildMockCommerceRegularFacetResponse({ + facetId, + values: [ + buildMockCommerceRegularFacetValue(), + buildMockCommerceRegularFacetValue(), + buildMockCommerceRegularFacetValue(), + ], + }); + + const action2 = buildQueryAction([facetResponse2]); + const updatedState2 = commerceFacetSetReducer(state, action2); + + expect(updatedState2[facetId]?.request.numberOfValues).toEqual(3); }); - const action = buildQueryAction([facet]); - const finalState = commerceFacetSetReducer(state, action); + it('sets/updates #type in state from response', () => { + const facetResponse1 = buildMockCommerceRegularFacetResponse({ + facetId, + }); - const expectedFacetValueRequests = convertToDateRangeRequests([ - facetValue, - ]); - expect(finalState[facetId]?.request.values).toEqual( - expectedFacetValueRequests - ); - }); + const action1 = buildQueryAction([facetResponse1]); + const updatedState1 = commerceFacetSetReducer(state, action1); + + expect(updatedState1[facetId]?.request.type).toEqual('regular'); + + const facetResponse2 = buildMockCommerceDateFacetResponse({ + facetId, + }); - // TODO: it('updates the values of category facet requests to the corresponding values in the response', () => { + const action2 = buildQueryAction([facetResponse2]); + const updatedState2 = commerceFacetSetReducer(state, action2); + + expect(updatedState2[facetId]?.request.type).toEqual('dateRange'); + }); + + it('when found facet #type is "hierarchical", sets/updates #delimitingCharacter in state from response', () => { + const delimitingCharacter1 = '>'; + const facetResponse1 = buildMockCategoryFacetResponse({ + facetId, + delimitingCharacter: delimitingCharacter1, + }); + + const action1 = buildQueryAction([facetResponse1]); + const initialState = commerceFacetSetReducer(state, action1); + + expect(initialState[facetId]?.request.delimitingCharacter).toEqual( + delimitingCharacter1 + ); + + const delimitingCharacter2 = '|'; + const facetResponse2 = buildMockCategoryFacetResponse({ + facetId, + delimitingCharacter: delimitingCharacter2, + }); + + const action2 = buildQueryAction([facetResponse2]); + const updatedState = commerceFacetSetReducer(state, action2); + + expect(updatedState[facetId]?.request.delimitingCharacter).toEqual( + delimitingCharacter2 + ); + }); + + it('when found facet #type is "regular", sets/updates #values in state from response', () => { + const facetValue1 = buildMockCommerceRegularFacetValue({ + value: 'TED', + }); + const facetResponse1 = buildMockCommerceRegularFacetResponse({ + facetId, + values: [facetValue1], + }); + + const action1 = buildQueryAction([facetResponse1]); + const updatedState1 = commerceFacetSetReducer(state, action1); + + const expectedFacetValueRequest1 = + convertFacetValueToRequest(facetValue1); + expect(updatedState1[facetId]?.request.values).toEqual([ + expectedFacetValueRequest1, + ]); + + const facetValue2 = buildMockCommerceRegularFacetValue({ + value: 'BILL', + }); + const facetResponse2 = buildMockCommerceRegularFacetResponse({ + facetId, + values: [facetValue2], + }); + + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({type: 'regular', facetId}), + }); + + const action2 = buildQueryAction([facetResponse2]); + const updatedState2 = commerceFacetSetReducer(state, action2); + + const expectedFacetValueRequest2 = + convertFacetValueToRequest(facetValue2); + expect(updatedState2[facetId]?.request.values).toEqual([ + expectedFacetValueRequest2, + ]); + }); + + it('when found facet #type is "numericalRange", sets/updates #values in facet state from response', () => { + const facetValue1 = buildMockCommerceNumericFacetValue({ + start: 0, + end: 5, + }); + const facetResponse1 = buildMockCommerceNumericFacetResponse({ + facetId, + values: [facetValue1], + }); + + const action1 = buildQueryAction([facetResponse1]); + const state1 = commerceFacetSetReducer(state, action1); + + const expectedFacetValueRequests1 = convertToNumericRangeRequests([ + facetValue1, + ]); + expect(state1[facetId]?.request.values).toEqual( + expectedFacetValueRequests1 + ); + + const facetValue2 = buildMockCommerceNumericFacetValue({ + start: 5, + end: 10, + }); + const facetResponse2 = buildMockCommerceNumericFacetResponse({ + facetId, + values: [facetValue2], + }); + + const action2 = buildQueryAction([facetResponse2]); + const state2 = commerceFacetSetReducer(state, action2); + + const expectedFacetValueRequests2 = convertToNumericRangeRequests([ + facetValue2, + ]); + expect(state2[facetId]?.request.values).toEqual( + expectedFacetValueRequests2 + ); + }); + + it('when found facet #type is "dateRange", sets/updates #values in request from response', () => { + const facetValue1 = buildMockCommerceDateFacetValue({ + start: '2023-01-01', + end: '2024-01-01', + }); + const facetResponse1 = buildMockCommerceDateFacetResponse({ + facetId, + values: [facetValue1], + }); + + const action1 = buildQueryAction([facetResponse1]); + const updatedState1 = commerceFacetSetReducer(state, action1); + + const expectedFacetValueRequests1 = convertToDateRangeRequests([ + facetValue1, + ]); + expect(updatedState1[facetId]?.request.values).toEqual( + expectedFacetValueRequests1 + ); + + const facetValue2 = buildMockCommerceDateFacetValue({ + start: '2024-01-01', + end: '2025-01-01', + }); + const facetResponse2 = buildMockCommerceDateFacetResponse({ + facetId, + values: [facetValue2], + }); + + const action2 = buildQueryAction([facetResponse2]); + const updatedState2 = commerceFacetSetReducer(state, action2); + + const expectedFacetValueRequests2 = convertToDateRangeRequests([ + facetValue2, + ]); + expect(updatedState2[facetId]?.request.values).toEqual( + expectedFacetValueRequests2 + ); + }); + + it('when found facet #type is "hierarchical", sets/updates #values in state from response', () => { + const facetValue1 = buildMockCategoryFacetValue({ + path: ['Food'], + value: 'Food', + children: [ + buildMockCategoryFacetValue({ + path: ['Food', 'Burgers'], + value: 'Burgers', + }), + ], + }); + const facetResponse1 = buildMockCategoryFacetResponse({ + facetId, + values: [facetValue1], + }); + + const action1 = buildQueryAction([facetResponse1]); + const updatedState1 = commerceFacetSetReducer(state, action1); + + const expectedFacetValueRequests1 = [ + convertCategoryFacetValueToRequest(facetValue1), + ]; + expect(updatedState1[facetId]?.request.values).toEqual( + expectedFacetValueRequests1 + ); + + const facetValue2 = buildMockCategoryFacetValue({ + path: ['Beverages'], + value: 'Beverages', + children: [ + buildMockCategoryFacetValue({ + path: ['Beverages', 'Soft drinks'], + value: 'Soft drinks', + }), + ], + }); + const facetResponse2 = buildMockCategoryFacetResponse({ + facetId, + values: [facetValue2], + }); + + const action2 = buildQueryAction([facetResponse2]); + const updatedState2 = commerceFacetSetReducer(state, action2); + + const expectedFacetValueRequests2 = [ + convertCategoryFacetValueToRequest(facetValue2), + ]; + expect(updatedState2[facetId]?.request.values).toEqual( + expectedFacetValueRequests2 + ); + }); + }); describe.each([ { @@ -188,7 +496,10 @@ describe('commerceFacetSetReducer', () => { type: 'dateRange' as FacetType, facetResponseBuilder: buildMockCommerceDateFacetResponse, }, - // TODO: { type: 'hierarchical' as FacetType, facetResponseBuilder: buildMockCommerceCategoryFacetResponse, }, + { + type: 'hierarchical' as FacetType, + facetResponseBuilder: buildMockCategoryFacetResponse, + }, ])( 'for $type facets', ({ @@ -1090,7 +1401,380 @@ describe('commerceFacetSetReducer', () => { }); }); - // TODO describe('for hierarchical facets', () => { /* ... */ }); + describe('for category facets', () => { + let facetId: string; + + beforeEach(() => { + facetId = 'category_facet_id'; + }); + + describe('#toggleSelectCategoryFacetValue', () => { + describe('when called on an unregistered #facetId', () => { + it('does not throw', () => { + const selection = buildMockCategoryFacetValue({value: 'A'}); + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: 6, + }); + + expect(() => commerceFacetSetReducer(state, action)).not.toThrow(); + }); + }); + + describe('when #values is empty', () => { + beforeEach(() => { + const request = buildMockCommerceFacetRequest({ + type: 'hierarchical', + values: [], + numberOfValues: 5, + }); + state[facetId] = buildMockCommerceFacetSlice({request}); + }); + + it('builds request from selection and adds it to #values', () => { + const selection = buildMockCategoryFacetValue({ + value: 'A', + path: ['A'], + }); + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: 6, + }); + const finalState = commerceFacetSetReducer(state, action); + const currentValues = finalState[facetId]?.request.values; + + expect(currentValues).toEqual([ + { + value: selection.value, + state: 'selected', + children: [], + retrieveCount: 6, + }, + ]); + }); + + it('sets #numberOfValues of request to 1', () => { + const selection = buildMockCategoryFacetValue({ + value: 'A', + path: ['A'], + }); + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: 6, + }); + + const finalState = commerceFacetSetReducer(state, action); + + expect(finalState[facetId].request.numberOfValues).toBe(1); + }); + + describe('when #path contains multiple segments', () => { + it('selects last segment', () => { + const selection = buildMockCategoryFacetValue({ + value: 'B', + path: ['A', 'B'], + }); + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: defaultNumberOfValuesIncrement, + }); + const finalState = commerceFacetSetReducer(state, action); + const currentValues = finalState[facetId].request.values; + + const parent = convertCategoryFacetValueToRequest( + buildMockCategoryFacetValue({ + value: 'A', + state: 'idle', + children: [ + buildMockCategoryFacetValue({ + value: 'B', + state: 'selected', + }), + ], + }) + ); + + expect(currentValues).toEqual([parent]); + }); + }); + }); + + describe('when #values contains one parent', () => { + beforeEach(() => { + const parent = convertCategoryFacetValueToRequest( + buildMockCategoryFacetValue({ + value: 'A', + state: 'selected', + }) + ); + const request = buildMockCommerceFacetRequest({ + type: 'hierarchical', + values: [parent], + }); + + state[facetId] = buildMockCommerceFacetSlice({request}); + }); + + describe('when #path contains the parent', () => { + let selection: CategoryFacetValue; + let finalState: CommerceFacetSetState; + beforeEach(() => { + selection = buildMockCategoryFacetValue({ + value: 'B', + path: ['A', 'B'], + }); + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: defaultNumberOfValuesIncrement, + }); + finalState = commerceFacetSetReducer(state, action); + }); + it("adds selection to parent's #children", () => { + const expected = convertCategoryFacetValueToRequest( + buildMockCategoryFacetValue({ + value: selection.value, + state: 'selected', + }) + ); + + const children = ( + finalState[facetId].request.values[0] as CategoryFacetValueRequest + ).children; + expect(children).toEqual([expected]); + }); + + it('sets parent #state to "idle"', () => { + expect(finalState[facetId].request.values[0].state).toBe('idle'); + }); + }); + + describe('when #path does not contain the parent', () => { + it("overwrites parent, adding selection to new parent's #children", () => { + const selection = buildMockCategoryFacetValue({ + value: 'B', + path: ['C', 'B'], + }); + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: defaultNumberOfValuesIncrement, + }); + const finalState = commerceFacetSetReducer(state, action); + + const currentValues = finalState[facetId].request.values; + + const parent = convertCategoryFacetValueToRequest( + buildMockCategoryFacetValue({ + value: 'C', + state: 'idle', + children: [ + buildMockCategoryFacetValue({ + value: 'B', + state: 'selected', + }), + ], + }) + ); + + expect(currentValues).toEqual([parent]); + }); + }); + }); + + describe('when #values contains two parents', () => { + beforeEach(() => { + const parentB = buildMockCategoryFacetValue({value: 'B'}); + const parentA = convertCategoryFacetValueToRequest( + buildMockCategoryFacetValue({ + value: 'A', + children: [parentB], + }) + ); + + const request = buildMockCommerceFacetRequest({ + type: 'hierarchical', + values: [parentA], + }); + state[facetId] = buildMockCommerceFacetSlice({request}); + }); + + describe('when #path contains both parents', () => { + it("adds selection to second parent's #children", () => { + const selection = buildMockCategoryFacetValue({ + value: 'C', + path: ['A', 'B', 'C'], + }); + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: defaultNumberOfValuesIncrement, + }); + const finalState = commerceFacetSetReducer(state, action); + + const expected = convertCategoryFacetValueToRequest( + buildMockCategoryFacetValue({ + value: selection.value, + state: 'selected', + }) + ); + + expect( + ( + finalState[facetId].request + .values[0] as CategoryFacetValueRequest + ).children[0].children + ).toEqual([expected]); + }); + }); + + describe('when selecting a parent value', () => { + let finalState: CommerceFacetSetState; + beforeEach(() => { + const selection = buildMockCategoryFacetValue({ + value: 'A', + path: ['A'], + }); + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: 6, + }); + finalState = commerceFacetSetReducer(state, action); + }); + + it("clears that parent's #children", () => { + const parent = finalState[facetId]?.request.values[0]; + + expect((parent as CategoryFacetValueRequest).children).toEqual([]); + }); + + it('sets that parent #state to "selected"', () => { + const parent = finalState[facetId]?.request.values[0]; + + expect(parent.state).toBe('selected'); + }); + }); + }); + + describe('when selection is invalid', () => { + it('dispatches an action containing an error', () => { + const selection = buildMockCategoryFacetValue({ + value: 'A', + children: [ + buildMockCategoryFacetValue({value: 'B'}), + buildMockCategoryFacetValue({ + value: 'C', + children: [ + buildMockCategoryFacetValue({ + value: 'D', + numberOfResults: -1, + }), + ], + }), + ], + }); + + const action = toggleSelectCategoryFacetValue({ + facetId, + selection, + retrieveCount: 6, + }); + expect(action.error).toBeDefined(); + }); + }); + }); + describe('#updateCategoryFacetNumberOfValues', () => { + it('calls #handleFacetUpdateNumberOfValues if there are no nested children', () => { + const spy = jest.spyOn( + FacetReducers, + 'handleFacetUpdateNumberOfValues' + ); + const request = buildMockCommerceFacetRequest({ + facetId, + type: 'hierarchical', + }); + state[facetId] = buildMockCommerceFacetSlice({request}); + + commerceFacetSetReducer( + state, + updateCategoryFacetNumberOfValues({ + facetId, + numberOfValues: 20, + }) + ); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('calls #handleCategoryFacetNestedNumberOfValuesUpdate if there are nested children', () => { + const spy = jest.spyOn( + CommerceFacetReducers, + 'handleCategoryFacetNestedNumberOfValuesUpdate' + ); + const request = buildMockCommerceFacetRequest({ + facetId, + type: 'hierarchical', + values: [ + convertCategoryFacetValueToRequest( + buildMockCategoryFacetValue({value: 'test'}) + ), + ], + }); + state[facetId] = buildMockCommerceFacetSlice({request}); + + commerceFacetSetReducer( + state, + updateCategoryFacetNumberOfValues({ + facetId, + numberOfValues: 20, + }) + ); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('sets correct retrieve count to the appropriate number', () => { + const request = buildMockCommerceFacetRequest({ + type: 'hierarchical', + values: [ + convertCategoryFacetValueToRequest( + buildMockCategoryFacetValue({ + value: 'test', + state: 'selected', + }) + ), + ], + }); + + state[facetId] = buildMockCommerceFacetSlice({request}); + const finalState = commerceFacetSetReducer( + state, + updateCategoryFacetNumberOfValues({facetId, numberOfValues: 10}) + ); + expect( + (finalState[facetId]?.request.values[0] as CategoryFacetValueRequest) + .retrieveCount + ).toBe(10); + }); + + it('should not throw when facetId does not exist', () => { + expect(() => + commerceFacetSetReducer( + state, + updateCategoryFacetNumberOfValues({ + facetId: 'notRegistred', + numberOfValues: 20, + }) + ) + ).not.toThrow(); + }); + }); + }); describe.each([ { @@ -1195,17 +1879,11 @@ describe('commerceFacetSetReducer', () => { describe('#updateFacetIsFieldExpanded', () => { describe.each([ - { - type: 'regular' as FacetType, - }, - { - type: 'numericalRange' as FacetType, - }, - { - type: 'dateRange' as FacetType, - }, - // TODO: { type: 'hierarchical' as FacetType }, - ])('for $type facets', ({type}: {type: FacetType}) => { + {type: 'regular' as FacetType}, + {type: 'numericalRange' as FacetType}, + {type: 'dateRange' as FacetType}, + {type: 'hierarchical' as FacetType}, + ])('for $type facets', ({type}) => { it('dispatching with a registered facet id updates the value', () => { const facetId = '1'; const isFieldExpanded = true; @@ -1252,6 +1930,72 @@ describe('commerceFacetSetReducer', () => { expect(finalState[anotherFacetId]!.request.preventAutoSelect).toBe(false); }); + describe('#deselectAllFacetValues', () => { + it('when called on an unregistered facet id, does not throw', () => { + const action = deselectAllFacetValues('1'); + expect(() => commerceFacetSetReducer(state, action)).not.toThrow(); + }); + + describe('when called on a hierarchical facet', () => { + const facetId = '1'; + let finalState: CommerceFacetSetState; + beforeEach(() => { + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + type: 'hierarchical', + values: [ + buildMockCategoryFacetValue({ + state: 'idle', + children: [buildMockCategoryFacetValue({state: 'selected'})], + }), + ], + numberOfValues: 1, + preventAutoSelect: false, + }), + }); + + finalState = commerceFacetSetReducer( + state, + deselectAllFacetValues(facetId) + ); + }); + it('sets #request.numberOfValues to 0', () => { + expect(finalState[facetId]?.request.numberOfValues).toBe(0); + }); + it('sets #request.values to an empty array', () => { + expect(finalState[facetId]?.request.values).toEqual([]); + }); + + it('sets #request.preventAutoSelect to "true"', () => { + expect(finalState[facetId]?.request.preventAutoSelect).toBe(true); + }); + }); + + it('when called on a non-hierarchical facet, sets all values to "idle"', () => { + const facetId = '1'; + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + type: 'regular', + values: [ + buildMockCommerceRegularFacetValue({state: 'selected'}), + buildMockCommerceRegularFacetValue({state: 'excluded'}), + ], + }), + }); + + const finalState = commerceFacetSetReducer( + state, + deselectAllFacetValues(facetId) + ); + + expect( + finalState[facetId]?.request.values.every( + (value) => value.state === 'idle' + ) + ).toBe(true); + }); + }); + describe.each([ { actionName: '#deselectAllBreadcrumbs', diff --git a/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.ts b/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.ts index ca3f88397cc..ffc4bed2c65 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/facet-set-slice.ts @@ -4,17 +4,23 @@ import { type Draft as WritableDraft, } from '@reduxjs/toolkit'; import { + CategoryFacetValueRequest, DateRangeRequest, FacetValueRequest, NumericRangeRequest, } from '../../../../controllers/commerce/core/facets/headless-core-commerce-facet'; import {deselectAllBreadcrumbs} from '../../../breadcrumb/breadcrumb-actions'; -import {CategoryFacetValueRequest} from '../../../facets/category-facet-set/interfaces/request'; +import { + defaultNumberOfValuesIncrement, + toggleSelectCategoryFacetValue, + updateCategoryFacetNumberOfValues, +} from '../../../facets/category-facet-set/category-facet-set-actions'; import { excludeFacetSearchResult, selectFacetSearchResult, } from '../../../facets/facet-search-set/specific/specific-facet-search-actions'; import { + deselectAllFacetValues, toggleExcludeFacetValue, toggleSelectFacetValue, updateFacetIsFieldExpanded, @@ -22,6 +28,7 @@ import { } from '../../../facets/facet-set/facet-set-actions'; import {convertFacetValueToRequest} from '../../../facets/facet-set/facet-set-slice'; import {updateFacetAutoSelection} from '../../../facets/generic/facet-actions'; +import {handleFacetUpdateNumberOfValues} from '../../../facets/generic/facet-reducer-helpers'; import { toggleExcludeDateFacetValue, toggleSelectDateFacetValue, @@ -36,15 +43,18 @@ import {convertToNumericRangeRequests} from '../../../facets/range-facets/numeri import {setContext, setUser, setView} from '../../context/context-actions'; import {fetchProductListing} from '../../product-listing/product-listing-actions'; import {executeSearch} from '../../search/search-actions'; +import {handleCategoryFacetNestedNumberOfValuesUpdate} from './facet-set-reducer-helpers'; import { CommerceFacetSetState, getCommerceFacetSetInitialState, } from './facet-set-state'; import { - AnyCommerceFacetRequest, + AnyFacetRequest, CommerceFacetRequest, + AnyFacetValueRequest, } from './interfaces/request'; -import {AnyFacetResponse, RegularFacetValue} from './interfaces/response'; +import {CategoryFacetValue} from './interfaces/response'; +import {AnyFacetResponse} from './interfaces/response'; export const commerceFacetSetReducer = createReducer( getCommerceFacetSetInitialState(), @@ -113,7 +123,38 @@ export const commerceFacetSetReducer = createReducer( } updateExistingFacetValueState(existingValue, 'select'); }) - // TODO: toggleSelectCategoryFacetValue + .addCase(toggleSelectCategoryFacetValue, (state, action) => { + const {facetId, selection, retrieveCount} = action.payload; + const request = state[facetId]?.request; + + if (!request || !ensureCategoryFacetRequest(request)) { + return; + } + + const {path} = selection; + const pathToSelection = path.slice(0, path.length - 1); + const children = ensurePathAndReturnChildren( + request, + pathToSelection, + retrieveCount + ); + + if (children.length) { + const lastSelectedParent = children[0]; + + lastSelectedParent.state = 'selected'; + lastSelectedParent.children = []; + return; + } + + const newParent = buildCategoryFacetValueRequest( + selection.value, + retrieveCount + ); + newParent.state = 'selected'; + children.push(newParent); + request.numberOfValues = 1; + }) .addCase(toggleExcludeFacetValue, (state, action) => { const {facetId, selection} = action.payload; const facetRequest = state[facetId]?.request; @@ -176,7 +217,20 @@ export const commerceFacetSetReducer = createReducer( updateExistingFacetValueState(existingValue, 'exclude'); }) - // TODO: toggleExcludeCategoryFacetValue + .addCase(updateCategoryFacetNumberOfValues, (state, action) => { + const {facetId, numberOfValues} = action.payload; + const request = state[facetId]?.request; + if (!request) { + return; + } + if (!request.values.length) { + return handleFacetUpdateNumberOfValues( + request, + numberOfValues + ); + } + handleCategoryFacetNestedNumberOfValuesUpdate(state, action.payload); + }) .addCase(selectFacetSearchResult, (state, action) => { const {facetId, value} = action.payload; const facetRequest = state[facetId]?.request; @@ -260,6 +314,16 @@ export const commerceFacetSetReducer = createReducer( slice.request.preventAutoSelect = !action.payload.allow; }) ) + .addCase(deselectAllFacetValues, (state, action) => { + const facetId = action.payload; + const request = state[facetId]?.request; + + if (!request) { + return; + } + + handleDeselectAllFacetValues(request); + }) .addCase(deselectAllBreadcrumbs, resetAllFacetValues) .addCase(setContext, resetAllFacetValues) .addCase(setView, resetAllFacetValues) @@ -268,23 +332,29 @@ export const commerceFacetSetReducer = createReducer( ); function ensureRegularFacetRequest( - facetRequest: AnyCommerceFacetRequest + facetRequest: AnyFacetRequest ): facetRequest is CommerceFacetRequest { return facetRequest.type === 'regular'; } function ensureNumericFacetRequest( - facetRequest: AnyCommerceFacetRequest + facetRequest: AnyFacetRequest ): facetRequest is CommerceFacetRequest { return facetRequest.type === 'numericalRange'; } function ensureDateFacetRequest( - facetRequest: AnyCommerceFacetRequest + facetRequest: AnyFacetRequest ): facetRequest is CommerceFacetRequest { return facetRequest.type === 'dateRange'; } +function ensureCategoryFacetRequest( + facetRequest: AnyFacetRequest +): facetRequest is CommerceFacetRequest { + return facetRequest.type === 'hierarchical'; +} + function handleQueryFulfilled( state: WritableDraft, action: AnyAction @@ -300,6 +370,52 @@ function handleQueryFulfilled( } } +function handleDeselectAllFacetValues(request: AnyFacetRequest) { + if (request.type === 'hierarchical') { + request.numberOfValues = request.initialNumberOfValues; + request.values = []; + request.preventAutoSelect = true; + } else { + request.values.forEach((value) => (value.state = 'idle')); + } +} + +function ensurePathAndReturnChildren( + request: CommerceFacetRequest, + path: string[], + retrieveCount: number +) { + let children = request.values; + + for (const segment of path) { + let parent = children[0]; + const missingParent = !parent; + + if (missingParent || segment !== parent.value) { + parent = buildCategoryFacetValueRequest(segment, retrieveCount); + children.length = 0; + children.push(parent); + } + + parent.state = 'idle'; + children = parent.children; + } + + return children; +} + +function buildCategoryFacetValueRequest( + value: string, + retrieveCount: number +): CategoryFacetValueRequest { + return { + children: [], + state: 'idle', + value, + retrieveCount, + }; +} + function updateExistingFacetValueState( existingFacetValue: WritableDraft< FacetValueRequest | NumericRangeRequest | DateRangeRequest @@ -330,9 +446,10 @@ function updateStateFromFacetResponse( facetsToRemove: Set ) { const facetId = facetResponse.facetId ?? facetResponse.field; + let facetRequest = state[facetId]?.request; if (!facetRequest) { - state[facetId] = {request: {} as AnyCommerceFacetRequest}; + state[facetId] = {request: {} as AnyFacetRequest}; facetRequest = state[facetId].request; facetRequest.initialNumberOfValues = facetResponse.values.length; } else { @@ -347,6 +464,12 @@ function updateStateFromFacetResponse( facetRequest.values = getFacetRequestValuesFromFacetResponse(facetResponse) ?? []; facetRequest.preventAutoSelect = false; + if ( + facetResponse.type === 'hierarchical' && + ensureCategoryFacetRequest(facetRequest) + ) { + facetRequest.delimitingCharacter = facetResponse.delimitingCharacter; + } } function getFacetRequestValuesFromFacetResponse( @@ -357,19 +480,41 @@ function getFacetRequestValuesFromFacetResponse( return convertToNumericRangeRequests(facetResponse.values); case 'dateRange': return convertToDateRangeRequests(facetResponse.values); + case 'hierarchical': + return facetResponse.values.every( + (f) => f.state === 'idle' && f.children.length === 0 + ) + ? [] + : facetResponse.values.map(convertCategoryFacetValueToRequest); case 'regular': return facetResponse.values.map(convertFacetValueToRequest); - case 'hierarchical': // TODO default: return; } } +export function convertCategoryFacetValueToRequest( + responseValue: CategoryFacetValue +): CategoryFacetValueRequest { + const children = responseValue.children.every( + (c) => c.state === 'idle' && c.children.length === 0 + ) + ? [] + : responseValue.children.map(convertCategoryFacetValueToRequest); + const {state, value} = responseValue; + return { + children, + state, + value, + retrieveCount: defaultNumberOfValuesIncrement, + }; +} + function insertNewValue( - facetRequest: AnyCommerceFacetRequest, - facetValue: FacetValueRequest | NumericRangeRequest | DateRangeRequest + facetRequest: AnyFacetRequest, + facetValue: AnyFacetValueRequest ) { - const {type, values} = facetRequest; + const {values} = facetRequest; const firstIdleIndex = values.findIndex((v) => v.state === 'idle'); const indexToInsertAt = firstIdleIndex === -1 ? values.length : firstIdleIndex; @@ -377,32 +522,7 @@ function insertNewValue( const valuesBefore = values.slice(0, indexToInsertAt); const valuesAfter = values.slice(indexToInsertAt + 1); - switch (type) { - case 'regular': - facetRequest.values = [ - ...(valuesBefore as FacetValueRequest[]), - facetValue as FacetValueRequest, - ...(valuesAfter as RegularFacetValue[]), - ]; - break; - case 'numericalRange': - facetRequest.values = [ - ...(valuesBefore as NumericRangeRequest[]), - facetValue as NumericRangeRequest, - ...(valuesAfter as NumericRangeRequest[]), - ]; - break; - case 'dateRange': - facetRequest.values = [ - ...(valuesBefore as DateRangeRequest[]), - facetValue as DateRangeRequest, - ...(valuesAfter as DateRangeRequest[]), - ]; - break; - case 'hierarchical': // TODO - default: - break; - } + facetRequest.values = [...valuesBefore, facetValue, ...valuesAfter]; facetRequest.numberOfValues = facetRequest.values.length; } diff --git a/packages/headless/src/features/commerce/facets/facet-set/facet-set-state.ts b/packages/headless/src/features/commerce/facets/facet-set/facet-set-state.ts index 7f4d11ea6d2..81ab925503f 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/facet-set-state.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/facet-set-state.ts @@ -1,7 +1,7 @@ -import {AnyCommerceFacetRequest} from './interfaces/request'; +import {AnyFacetRequest} from './interfaces/request'; export type CommerceFacetSlice = { - request: AnyCommerceFacetRequest; + request: AnyFacetRequest; }; /** diff --git a/packages/headless/src/features/commerce/facets/facet-set/interfaces/request.ts b/packages/headless/src/features/commerce/facets/facet-set/interfaces/request.ts index 63790f0ca77..6be6a27bf94 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/interfaces/request.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/interfaces/request.ts @@ -1,8 +1,21 @@ -import {FacetRequest} from '../../../../facets/facet-set/interfaces/request'; -import {AnyFacetValueRequest} from '../../../../facets/generic/interfaces/generic-facet-request'; +import { + DateRangeRequest, + NumericRangeRequest, +} from '../../../../../controllers/commerce/core/facets/headless-core-commerce-facet'; +import {BaseFacetValueRequest} from '../../../../facets/facet-api/request'; +import { + FacetRequest, + FacetValueRequest, +} from '../../../../facets/facet-set/interfaces/request'; import {FacetType} from './response'; -export type AnyCommerceFacetRequest = Pick< +export type AnyFacetValueRequest = + | FacetValueRequest + | CategoryFacetValueRequest + | NumericRangeRequest + | DateRangeRequest; + +export type AnyFacetRequest = Pick< FacetRequest, | 'facetId' | 'field' @@ -14,10 +27,17 @@ export type AnyCommerceFacetRequest = Pick< type: FacetType; values: AnyFacetValueRequest[]; initialNumberOfValues: number; + numberOfValues?: number; + delimitingCharacter?: string; }; +export interface CategoryFacetValueRequest extends BaseFacetValueRequest { + children: CategoryFacetValueRequest[]; + value: string; + retrieveCount?: number; +} export type CommerceFacetRequest = Omit< - AnyCommerceFacetRequest, + AnyFacetRequest, 'values' > & { values: T[]; diff --git a/packages/headless/src/features/commerce/facets/facet-set/interfaces/response.ts b/packages/headless/src/features/commerce/facets/facet-set/interfaces/response.ts index 009653cbda3..7193e45bd2e 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/interfaces/response.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/interfaces/response.ts @@ -26,7 +26,9 @@ export type NumericFacetResponse = BaseFacetResponse< export type CategoryFacetResponse = BaseFacetResponse< CategoryFacetValue, 'hierarchical' ->; +> & { + delimitingCharacter: string; +}; export type FacetType = | 'regular' | 'dateRange' diff --git a/packages/headless/src/features/facets/category-facet-set/category-facet-set-actions.ts b/packages/headless/src/features/facets/category-facet-set/category-facet-set-actions.ts index 542b46659a4..71b3d50d068 100644 --- a/packages/headless/src/features/facets/category-facet-set/category-facet-set-actions.ts +++ b/packages/headless/src/features/facets/category-facet-set/category-facet-set-actions.ts @@ -96,6 +96,8 @@ const categoryFacetPayloadDefinition = { filterByBasePath: new BooleanValue({required: false}), }; +export const defaultNumberOfValuesIncrement = 5; + export const registerCategoryFacet = createAction( 'categoryFacet/register', (payload: RegisterCategoryFacetActionCreatorPayload) => diff --git a/packages/headless/src/features/facets/category-facet-set/category-facet-utils.ts b/packages/headless/src/features/facets/category-facet-set/category-facet-utils.ts index 3be3fedcf87..fbc7d187776 100644 --- a/packages/headless/src/features/facets/category-facet-set/category-facet-utils.ts +++ b/packages/headless/src/features/facets/category-facet-set/category-facet-utils.ts @@ -1,3 +1,4 @@ +import {CategoryFacetValue as CommerceCategoryFacetValue} from '../../commerce/facets/facet-set/interfaces/response'; import {CategoryFacetValueCommon} from './interfaces/commons'; import {CategoryFacetValueRequest} from './interfaces/request'; import {CategoryFacetValue} from './interfaces/response'; @@ -37,6 +38,9 @@ export function partitionIntoParentsAndValues< export function findActiveValueAncestry( valuesAsTress: CategoryFacetValueRequest[] ): CategoryFacetValueRequest[]; +export function findActiveValueAncestry( + valuesAsTress: CommerceCategoryFacetValue[] +): CommerceCategoryFacetValue[]; export function findActiveValueAncestry( valuesAsTress: CategoryFacetValue[] ): CategoryFacetValue[]; diff --git a/packages/headless/src/features/facets/generic/facet-reducer-helpers.ts b/packages/headless/src/features/facets/generic/facet-reducer-helpers.ts index 943d3c53271..5c79b096e01 100644 --- a/packages/headless/src/features/facets/generic/facet-reducer-helpers.ts +++ b/packages/headless/src/features/facets/generic/facet-reducer-helpers.ts @@ -1,3 +1,4 @@ +import {AnyFacetRequest as AnyCommerceFacetRequest} from '../../commerce/facets/facet-set/interfaces/request'; import {FacetRequest} from '../facet-set/interfaces/request'; import {AnyFacetRequest} from './interfaces/generic-facet-request'; import {AnyFacetSlice} from './interfaces/generic-facet-section'; @@ -32,10 +33,9 @@ export function handleFacetDeselectAll(facetRequest: FacetRequest) { facetRequest.preventAutoSelect = true; } -export function handleFacetUpdateNumberOfValues( - facetRequest: T | undefined, - numberOfValues: number -) { +export function handleFacetUpdateNumberOfValues< + T extends AnyFacetRequest | AnyCommerceFacetRequest, +>(facetRequest: T | undefined, numberOfValues: number) { if (!facetRequest) { return; } diff --git a/packages/headless/src/test/mock-commerce-facet-request.ts b/packages/headless/src/test/mock-commerce-facet-request.ts index 2547bc61802..76950e45b80 100644 --- a/packages/headless/src/test/mock-commerce-facet-request.ts +++ b/packages/headless/src/test/mock-commerce-facet-request.ts @@ -1,8 +1,8 @@ -import {AnyCommerceFacetRequest} from '../features/commerce/facets/facet-set/interfaces/request'; +import {AnyFacetRequest} from '../features/commerce/facets/facet-set/interfaces/request'; export function buildMockCommerceFacetRequest( - config: Partial = {} -): AnyCommerceFacetRequest { + config: Partial = {} +): AnyFacetRequest { return { facetId: '', displayName: '', diff --git a/packages/headless/src/test/mock-commerce-facet-response.ts b/packages/headless/src/test/mock-commerce-facet-response.ts index cc4d5b6c130..a972dac4145 100644 --- a/packages/headless/src/test/mock-commerce-facet-response.ts +++ b/packages/headless/src/test/mock-commerce-facet-response.ts @@ -3,6 +3,7 @@ import { NumericFacetResponse, DateRangeFacetResponse, AnyFacetResponse, + CategoryFacetResponse, } from '../features/commerce/facets/facet-set/interfaces/response'; function getMockBaseCommerceFacetResponse(): Omit< @@ -51,3 +52,15 @@ export function buildMockCommerceDateFacetResponse( ...config, }; } + +export function buildMockCategoryFacetResponse( + config: Partial = {} +): CategoryFacetResponse { + return { + ...getMockBaseCommerceFacetResponse(), + type: 'hierarchical', + values: [], + delimitingCharacter: '', + ...config, + }; +} diff --git a/packages/headless/src/test/mock-commerce-facet-value.ts b/packages/headless/src/test/mock-commerce-facet-value.ts index aebe73ce92d..d29cdf35387 100644 --- a/packages/headless/src/test/mock-commerce-facet-value.ts +++ b/packages/headless/src/test/mock-commerce-facet-value.ts @@ -2,6 +2,7 @@ import { RegularFacetValue, NumericFacetValue, DateFacetValue, + CategoryFacetValue, } from '../features/commerce/facets/facet-set/interfaces/response'; export function buildMockCommerceRegularFacetValue( @@ -49,3 +50,20 @@ export function buildMockCommerceDateFacetValue( ...config, }; } + +export function buildMockCategoryFacetValue( + config: Partial = {} +): CategoryFacetValue { + return { + children: [], + isAutoSelected: false, + isLeafValue: false, + isSuggested: false, + moreValuesAvailable: false, + numberOfResults: 0, + path: [], + state: 'idle', + value: '', + ...config, + }; +}