diff --git a/packages/headless/src/commerce.index.ts b/packages/headless/src/commerce.index.ts index a1b9ace2b2c..4bb4ee6ab96 100644 --- a/packages/headless/src/commerce.index.ts +++ b/packages/headless/src/commerce.index.ts @@ -51,6 +51,7 @@ export * from './features/commerce/sort/sort-actions-loader.js'; export * from './features/commerce/facets/core-facet/core-facet-actions-loader.js'; export * from './features/commerce/facets/category-facet/category-facet-actions-loader.js'; export * from './features/commerce/facets/regular-facet/regular-facet-actions-loader.js'; +// TODO COMHUB-247 export location facets actions loader export * from './features/commerce/facets/date-facet/date-facet-actions-loader.js'; export * from './features/commerce/facets/numeric-facet/numeric-facet-actions-loader.js'; export * from './features/commerce/query-set/query-set-actions-loader.js'; @@ -164,6 +165,10 @@ export type { RegularFacet, RegularFacetState, } from './controllers/commerce/core/facets/regular/headless-commerce-regular-facet.js'; +export type { + LocationFacet, + LocationFacetState, +} from './controllers/commerce/core/facets/location/headless-commerce-location-facet.js'; export type { NumericFacet, NumericFacetState, @@ -178,6 +183,8 @@ export type { FacetType, FacetValueRequest, RegularFacetValue, + LocationFacetValueRequest, + LocationFacetValue, NumericRangeRequest, NumericFacetValue, DateRangeRequest, diff --git a/packages/headless/src/controllers/commerce/core/breadcrumb-manager/headless-core-breadcrumb-manager.ts b/packages/headless/src/controllers/commerce/core/breadcrumb-manager/headless-core-breadcrumb-manager.ts index d0155a170e0..9a34e1d17d9 100644 --- a/packages/headless/src/controllers/commerce/core/breadcrumb-manager/headless-core-breadcrumb-manager.ts +++ b/packages/headless/src/controllers/commerce/core/breadcrumb-manager/headless-core-breadcrumb-manager.ts @@ -21,6 +21,7 @@ import { BaseFacetValue, CategoryFacetResponse, DateFacetResponse, + LocationFacetResponse, NumericFacetResponse, RegularFacetResponse, } from '../../../../features/commerce/facets/facet-set/interfaces/response.js'; @@ -116,7 +117,8 @@ interface ActionCreators { const facetTypeWithoutExcludeAction: FacetType = 'hierarchical'; -const actions: Record = { +// TODO: COMHUB-247 add support for location facet +const actions: Record, ActionCreators> = { regular: { toggleSelectActionCreator: toggleSelectFacetValue, toggleExcludeActionCreator: toggleExcludeFacetValue, @@ -153,7 +155,10 @@ export function buildCoreBreadcrumbManager( const controller = buildController(engine); const {dispatch} = engine; - const createBreadcrumb = (facet: AnyFacetResponse) => ({ + // TODO: COMHUB-247 add support for location facet + const createBreadcrumb = ( + facet: Exclude + ) => ({ facetId: facet.facetId, facetDisplayName: facet.displayName, field: facet.field, @@ -253,7 +258,9 @@ export function buildCoreBreadcrumbManager( (facetOrder): BreadcrumbManagerState => { const breadcrumbs = facetOrder.flatMap((facetId) => { const facet = options.facetResponseSelector(engine[stateKey], facetId); - if (hasActiveValue(facet)) { + + // TODO: COMHUB-247 add support for location facet + if (hasActiveValue(facet) && facet.type !== 'location') { return [createBreadcrumb(facet)]; } return []; diff --git a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.test.ts b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.test.ts index 169cb4e7c7e..8670d887c08 100644 --- a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.test.ts +++ b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.test.ts @@ -8,6 +8,7 @@ import { buildMockCommerceDateFacetResponse, buildMockCommerceNumericFacetResponse, buildMockCommerceRegularFacetResponse, + buildMockCommerceLocationFacetResponse, } from '../../../../../test/mock-commerce-facet-response.js'; import {buildMockCommerceState} from '../../../../../test/mock-commerce-state.js'; import { @@ -55,6 +56,9 @@ describe('SSR FacetGenerator', () => { case 'numericalRange': response = buildMockCommerceNumericFacetResponse({facetId, type}); break; + case 'location': + response = buildMockCommerceLocationFacetResponse({facetId, type}); + break; case 'regular': default: response = buildMockCommerceRegularFacetResponse({facetId, type}); @@ -117,6 +121,10 @@ describe('SSR FacetGenerator', () => { facetId: 'regular-facet', type: 'regular', }, + { + facetId: 'location-facet', + type: 'location', + }, ]; state = buildMockCommerceState(); setFacetState(facetsInEngineState); @@ -131,7 +139,7 @@ describe('SSR FacetGenerator', () => { expect(facetGenerator).toBeTruthy(); }); it('#state is an array containing the state of each facet', () => { - expect(facetGenerator.state.length).toBe(4); + expect(facetGenerator.state.length).toBe(5); expect( facetGenerator.state.map((facet) => ({ facetId: facet.facetId, diff --git a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts index 6074be97668..83449e62d2c 100644 --- a/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts +++ b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts @@ -11,6 +11,7 @@ import {stateKey} from '../../../../../app/state-key.js'; import {facetRequestSelector} from '../../../../../features/commerce/facets/facet-set/facet-set-selector.js'; import { AnyFacetResponse, + LocationFacetValue, RegularFacetValue, } from '../../../../../features/commerce/facets/facet-set/interfaces/response.js'; import {manualNumericFacetSelector} from '../../../../../features/commerce/facets/numeric-facet/manual-numeric-facet-selectors.js'; @@ -44,6 +45,11 @@ import { FacetType, getCoreFacetState, } from '../headless-core-commerce-facet.js'; +import { + getLocationFacetState, + LocationFacet, + LocationFacetState, +} from '../location/headless-commerce-location-facet.js'; import { getNumericFacetState, NumericFacet, @@ -72,6 +78,9 @@ export type { RegularFacet, RegularFacetState, RegularFacetValue, + LocationFacet, + LocationFacetState, + LocationFacetValue, }; export type FacetGeneratorState = MappedFacetStates; @@ -87,7 +96,9 @@ type MappedFacetState = { ? DateFacetState : T extends 'hierarchical' ? CategoryFacetState - : never; + : T extends 'location' + ? LocationFacetState + : never; }; export function defineFacetGenerator< @@ -235,6 +246,10 @@ export function buildFacetGenerator( createFacetState(facetResponseSelector) as RegularFacetState, specificFacetSearchStateSelector(getEngineState(), facetId) ); + case 'location': + return getLocationFacetState( + createFacetState(facetResponseSelector) as LocationFacetState + ); } }); }, 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 a22288403df..f19d6eebc7e 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 @@ -28,6 +28,7 @@ describe('CSR FacetGenerator', () => { let facetGenerator: FacetGenerator; const mockBuildNumericFacet = vi.fn(); const mockBuildRegularFacet = vi.fn(); + const mockBuildLocationFacet = vi.fn(); const mockBuildDateFacet = vi.fn(); const mockBuildCategoryFacet = vi.fn(); const mockFetchProductsActionCreator = vi.fn(); @@ -61,6 +62,7 @@ describe('CSR FacetGenerator', () => { options = { buildNumericFacet: mockBuildNumericFacet, buildRegularFacet: mockBuildRegularFacet, + buildLocationFacet: mockBuildLocationFacet, buildDateFacet: mockBuildDateFacet, buildCategoryFacet: mockBuildCategoryFacet, fetchProductsActionCreator: mockFetchProductsActionCreator, @@ -97,6 +99,14 @@ describe('CSR FacetGenerator', () => { expect(mockBuildRegularFacet).toHaveBeenCalledWith(engine, {facetId}); }); + it('when engine facet state contains a location facet, generates a location facet controller', () => { + const facetId = 'location_facet_id'; + setFacetState([{facetId, type: 'location'}]); + + expect(facetGenerator.facets.length).toEqual(1); + expect(mockBuildLocationFacet).toHaveBeenCalledWith(engine, {facetId}); + }); + it('when engine facet state contains a numeric facet, generates a numeric facet controller', () => { const facetId = 'numeric_facet_id'; setFacetState([{facetId, type: 'numericalRange'}]); @@ -127,6 +137,10 @@ describe('CSR FacetGenerator', () => { facetId: 'regular_facet_id', type: 'regular', }, + { + facetId: 'location_facet_id', + type: 'location', + }, { facetId: 'numeric_facet_id', type: 'numericalRange', @@ -142,24 +156,29 @@ describe('CSR FacetGenerator', () => { ]; setFacetState(facets); + let index = 0; mockBuildRegularFacet.mockReturnValue({ - state: {facetId: facets[0].facetId}, + state: {facetId: facets[index++].facetId}, + }); + mockBuildLocationFacet.mockReturnValue({ + state: {facetId: facets[index++].facetId}, }); mockBuildNumericFacet.mockReturnValue({ - state: {facetId: facets[1].facetId}, + state: {facetId: facets[index++].facetId}, + }); + mockBuildDateFacet.mockReturnValue({ + state: {facetId: facets[index++].facetId}, }); - mockBuildDateFacet.mockReturnValue({state: {facetId: facets[2].facetId}}); mockBuildCategoryFacet.mockReturnValue({ - state: {facetId: facets[3].facetId}, + state: {facetId: facets[index++].facetId}, }); const facetState = facetGenerator.facets; - expect(facetState.length).toEqual(4); - expect(facetState[0].state.facetId).toEqual(facets[0].facetId); - expect(facetState[1].state.facetId).toEqual(facets[1].facetId); - expect(facetState[2].state.facetId).toEqual(facets[2].facetId); - expect(facetState[3].state.facetId).toEqual(facets[3].facetId); + expect(facetState.length).toEqual(5); + expect(facetState.map((f) => f.state.facetId)).toEqual( + facets.map((f) => f.facetId) + ); }); }); 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 42e30440e2f..044173d3ca0 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 @@ -27,6 +27,7 @@ import { CommerceFacetOptions, CoreCommerceFacet, } from '../headless-core-commerce-facet.js'; +import {LocationFacet} from '../location/headless-commerce-location-facet.js'; import {NumericFacet} from '../numeric/headless-commerce-numeric-facet.js'; import {RegularFacet} from '../regular/headless-commerce-regular-facet.js'; import {SearchableFacetOptions} from '../searchable/headless-commerce-searchable-facet.js'; @@ -47,7 +48,7 @@ export interface FacetGenerator extends Controller { /** * The facet sub-controllers created by the facet generator. - * Array of [RegularFacet](./regular-facet), [DateRangeFacet](./date-range-facet), [NumericFacet](./numeric-facet), and [CategoryFacet](./category-facet). + * Array of [RegularFacet](./regular-facet), [DateRangeFacet](./date-range-facet), [NumericFacet](./numeric-facet), [CategoryFacet](./category-facet), and [LocationFacet](./location-facet). */ facets: GeneratedFacetControllers; @@ -79,7 +80,9 @@ export type MappedGeneratedFacetController = { ? DateFacet : T extends 'hierarchical' ? CategoryFacet - : never; + : T extends 'location' + ? LocationFacet + : never; }; type CommerceFacetBuilder< @@ -108,6 +111,7 @@ export interface FacetGeneratorOptions { buildNumericFacet: CommerceFacetBuilder; buildDateFacet: CommerceFacetBuilder; buildCategoryFacet: CommerceFacetBuilder; + buildLocationFacet: CommerceFacetBuilder; fetchProductsActionCreator: FetchProductsActionCreator; } @@ -159,6 +163,8 @@ export function buildFacetGenerator( return options.buildNumericFacet(engine, {facetId}); case 'regular': return options.buildRegularFacet(engine, {facetId}); + case 'location': + return options.buildLocationFacet(engine, {facetId}); } } ); 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 63a3a8b33a2..4fe382b45e0 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 @@ -11,12 +11,14 @@ import {FacetType} from '../../../../features/commerce/facets/facet-set/interfac import { AnyFacetRequest, CategoryFacetValueRequest, + LocationFacetValueRequest, } from '../../../../features/commerce/facets/facet-set/interfaces/request.js'; import { AnyFacetResponse, AnyFacetValueResponse, CategoryFacetValue, DateFacetValue, + LocationFacetValue, NumericFacetValue, RegularFacetValue, } from '../../../../features/commerce/facets/facet-set/interfaces/response.js'; @@ -37,6 +39,8 @@ export type { FacetType, FacetValueRequest, RegularFacetValue, + LocationFacetValueRequest, + LocationFacetValue, NumericRangeRequest, NumericFacetValue, DateRangeRequest, diff --git a/packages/headless/src/controllers/commerce/core/facets/location/headless-commerce-location-facet.test.ts b/packages/headless/src/controllers/commerce/core/facets/location/headless-commerce-location-facet.test.ts new file mode 100644 index 00000000000..9878ce4fca7 --- /dev/null +++ b/packages/headless/src/controllers/commerce/core/facets/location/headless-commerce-location-facet.test.ts @@ -0,0 +1,104 @@ +import {LocationFacetRequest} from '../../../../../features/commerce/facets/facet-set/interfaces/request.js'; +import { + toggleExcludeLocationFacetValue, + toggleSelectLocationFacetValue, +} from '../../../../../features/commerce/facets/location-facet/location-facet-actions.js'; +import {CommerceAppState} from '../../../../../state/commerce-app-state.js'; +import {buildMockCommerceFacetRequest} from '../../../../../test/mock-commerce-facet-request.js'; +import {buildMockCommerceLocationFacetResponse} from '../../../../../test/mock-commerce-facet-response.js'; +import {buildMockCommerceFacetSlice} from '../../../../../test/mock-commerce-facet-slice.js'; +import {buildMockCommerceLocationFacetValue} from '../../../../../test/mock-commerce-facet-value.js'; +import {buildMockCommerceState} from '../../../../../test/mock-commerce-state.js'; +import { + MockedCommerceEngine, + buildMockCommerceEngine, +} from '../../../../../test/mock-engine-v2.js'; +import { + LocationFacet, + LocationFacetOptions, + buildCommerceLocationFacet, +} from './headless-commerce-location-facet.js'; + +vi.mock( + '../../../../../features/commerce/facets/location-facet/location-facet-actions' +); + +describe('LocationFacet', () => { + const facetId: string = 'location_facet_id'; + let engine: MockedCommerceEngine; + let state: CommerceAppState; + let options: LocationFacetOptions; + let facet: LocationFacet; + const facetResponseSelector = vi.fn(); + + function initEngine(preloadedState = buildMockCommerceState()) { + engine = buildMockCommerceEngine(preloadedState); + } + + function initFacet() { + facet = buildCommerceLocationFacet(engine, options); + } + + function setFacetRequest(config: Partial = {}) { + state.commerceFacetSet[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({facetId, ...config}), + }); + state.productListing.facets = [ + buildMockCommerceLocationFacetResponse({facetId}), + ]; + facetResponseSelector.mockReturnValue( + buildMockCommerceLocationFacetResponse({facetId}) + ); + } + + beforeEach(() => { + vi.resetAllMocks(); + + options = { + facetId, + fetchProductsActionCreator: vi.fn(), + facetResponseSelector, + isFacetLoadingResponseSelector: vi.fn(), + }; + + state = buildMockCommerceState(); + setFacetRequest(); + + initEngine(state); + initFacet(); + }); + + describe('initialization', () => { + it('initializes', () => { + expect(facet).toBeTruthy(); + }); + + it('exposes #subscribe method', () => { + expect(facet.subscribe).toBeTruthy(); + }); + }); + + it('#toggleSelect dispatches #toggleSelectLocationFacetValue with correct payload', () => { + const facetValue = buildMockCommerceLocationFacetValue({value: 'TED'}); + facet.toggleSelect(facetValue); + + expect(toggleSelectLocationFacetValue).toHaveBeenCalledWith({ + facetId, + selection: facetValue, + }); + }); + + it('#toggleExclude dispatches #toggleExcludeLocationFacetValue with correct payload', () => { + const facetValue = buildMockCommerceLocationFacetValue({value: 'TED'}); + facet.toggleExclude(facetValue); + + expect(toggleExcludeLocationFacetValue).toHaveBeenCalledWith({ + facetId, + selection: facetValue, + }); + }); + + it('#type returns "location"', () => { + expect(facet.type).toBe('location'); + }); +}); diff --git a/packages/headless/src/controllers/commerce/core/facets/location/headless-commerce-location-facet.ts b/packages/headless/src/controllers/commerce/core/facets/location/headless-commerce-location-facet.ts new file mode 100644 index 00000000000..05d6a2e99e0 --- /dev/null +++ b/packages/headless/src/controllers/commerce/core/facets/location/headless-commerce-location-facet.ts @@ -0,0 +1,84 @@ +import {CommerceEngine} from '../../../../../app/commerce-engine/commerce-engine.js'; +import {LocationFacetValue} from '../../../../../features/commerce/facets/facet-set/interfaces/response.js'; +import { + toggleExcludeLocationFacetValue, + toggleSelectLocationFacetValue, +} from '../../../../../features/commerce/facets/location-facet/location-facet-actions.js'; +import { + CoreCommerceFacet, + CoreCommerceFacetOptions, + CoreCommerceFacetState, + FacetControllerType, + FacetValueRequest, + buildCoreCommerceFacet, +} from '../headless-core-commerce-facet.js'; + +export type LocationFacetOptions = Omit< + CoreCommerceFacetOptions, + 'toggleSelectActionCreator' | 'toggleExcludeActionCreator' +>; + +export type LocationFacetState = Omit< + CoreCommerceFacetState, + 'type' +> & { + type: 'location'; +}; + +/** + * The `LocationFacet` sub-controller offers a high-level programming interface for implementing a location commerce + * facet UI component. + */ +export type LocationFacet = CoreCommerceFacet< + FacetValueRequest, + LocationFacetValue +> & { + state: LocationFacetState; +} & FacetControllerType<'location'>; + +/** + * @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 `LocationFacet` sub-controller instances through the state of a `FacetGenerator` + * sub-controller. + * + * @param engine - The headless commerce engine. + * @param options - The `LocationFacet` options used internally. + * @returns A `LocationFacet` sub-controller instance. + * */ +export function buildCommerceLocationFacet( + engine: CommerceEngine, + options: LocationFacetOptions +): LocationFacet { + const coreController = buildCoreCommerceFacet< + FacetValueRequest, + LocationFacetValue + >(engine, { + options: { + ...options, + toggleSelectActionCreator: toggleSelectLocationFacetValue, + toggleExcludeActionCreator: toggleExcludeLocationFacetValue, + }, + }); + + return { + ...coreController, + + get state() { + return getLocationFacetState(coreController.state); + }, + + type: 'location', + }; +} + +export const getLocationFacetState = ( + coreState: CoreCommerceFacetState +): LocationFacetState => { + return { + ...coreState, + type: 'location', + }; +}; diff --git a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts index b84ea80e63e..8c35a08bc6a 100644 --- a/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts +++ b/packages/headless/src/controllers/commerce/core/sub-controller/headless-sub-controller.ts @@ -29,6 +29,7 @@ import { buildFacetGenerator, FacetGenerator, } from '../facets/generator/headless-commerce-facet-generator.js'; +import {buildCommerceLocationFacet} from '../facets/location/headless-commerce-location-facet.js'; import {buildCommerceNumericFacet} from '../facets/numeric/headless-commerce-numeric-facet.js'; import {buildCommerceRegularFacet} from '../facets/regular/headless-commerce-regular-facet.js'; import { @@ -277,6 +278,8 @@ export function buildSearchAndListingsSubControllers< buildCommerceDateFacet(engine, {...options, ...commonOptions}), buildCategoryFacet: (_engine, options) => buildCategoryFacet(engine, {...options, ...commonOptions}), + buildLocationFacet: (_engine, options) => + buildCommerceLocationFacet(engine, {...options, ...commonOptions}), fetchProductsActionCreator, }); }, 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 0beae165eb2..3e24102dd3a 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 @@ -9,6 +9,7 @@ import {buildMockCommerceFacetRequest} from '../../../../test/mock-commerce-face import { buildMockCategoryFacetResponse, buildMockCommerceDateFacetResponse, + buildMockCommerceLocationFacetResponse, buildMockCommerceNumericFacetResponse, buildMockCommerceRegularFacetResponse, } from '../../../../test/mock-commerce-facet-response.js'; @@ -16,6 +17,7 @@ import {buildMockCommerceFacetSlice} from '../../../../test/mock-commerce-facet- import { buildMockCategoryFacetValue, buildMockCommerceDateFacetValue, + buildMockCommerceLocationFacetValue, buildMockCommerceNumericFacetValue, buildMockCommerceRegularFacetValue, } from '../../../../test/mock-commerce-facet-value.js'; @@ -65,6 +67,10 @@ import { updateDateFacetValues, } from '../date-facet/date-facet-actions.js'; import {getFacetIdWithCommerceFieldSuggestionNamespace} from '../facet-search-set/commerce-facet-search-actions.js'; +import { + toggleExcludeLocationFacetValue, + toggleSelectLocationFacetValue, +} from '../location-facet/location-facet-actions.js'; import { toggleExcludeNumericFacetValue, toggleSelectNumericFacetValue, @@ -78,13 +84,17 @@ import * as CommerceFacetReducers from './facet-set-reducer-helpers.js'; import { commerceFacetSetReducer, convertCategoryFacetValueToRequest, + convertLocationFacetValueToRequest, } from './facet-set-slice.js'; import { CommerceFacetSetState, getCommerceFacetSetInitialState, } from './facet-set-state.js'; import {FacetType} from './interfaces/common.js'; -import {CategoryFacetValueRequest} from './interfaces/request.js'; +import { + CategoryFacetValueRequest, + LocationFacetValueRequest, +} from './interfaces/request.js'; import {AnyFacetResponse, CategoryFacetValue} from './interfaces/response.js'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -557,6 +567,10 @@ describe('commerceFacetSetReducer', () => { type: 'regular' as FacetType, facetResponseBuilder: buildMockCommerceRegularFacetResponse, }, + { + type: 'location' as FacetType, + facetResponseBuilder: buildMockCommerceLocationFacetResponse, + }, { type: 'numericalRange' as FacetType, facetResponseBuilder: buildMockCommerceNumericFacetResponse, @@ -983,6 +997,296 @@ describe('commerceFacetSetReducer', () => { }); }); + describe('for location facets', () => { + describe.each([ + { + title: + 'dispatching #toggleSelectLocationFacetValue with a registered facet id', + facetValueState: 'selected' as FacetValueState, + toggleAction: toggleSelectLocationFacetValue, + }, + { + title: + 'dispatching #toggleExcludeLocationFacetValue with a registered facet id', + facetValueState: 'excluded' as FacetValueState, + toggleAction: toggleExcludeLocationFacetValue, + }, + ])( + '$title', + ({ + facetValueState, + toggleAction, + }: { + facetValueState: FacetValueState; + toggleAction: Function; + }) => { + const facetId = '1'; + const oppositeFacetValueState = facetValueStates.find( + (valueState) => ![facetValueState, 'idle'].includes(valueState) + ); + describe('when the facet value exists', () => { + it(`sets the state of an idle value to ${facetValueState}`, () => { + const facetValue = buildMockCommerceLocationFacetValue({ + value: 'TED', + }); + const facetValueRequest = + convertLocationFacetValueToRequest(facetValue); + + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + values: [facetValueRequest], + type: 'location', + }), + }); + + const action = toggleAction({ + facetId, + selection: facetValue, + }); + const finalState = commerceFacetSetReducer(state, action); + + const targetValue = ( + finalState[facetId]?.request.values as LocationFacetValueRequest[] + ).find((req) => req.value === facetValue.value); + expect(targetValue?.state).toBe(facetValueState); + }); + + it(`sets the state of an ${oppositeFacetValueState} value to ${facetValueState}`, () => { + const facetValue = buildMockCommerceLocationFacetValue({ + value: 'TED', + state: oppositeFacetValueState, + }); + const facetValueRequest = + convertLocationFacetValueToRequest(facetValue); + + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + values: [facetValueRequest], + type: 'location', + }), + }); + + const action = toggleAction({ + facetId, + selection: facetValue, + }); + const finalState = commerceFacetSetReducer(state, action); + + const targetValue = ( + finalState[facetId]?.request.values as LocationFacetValueRequest[] + ).find((req) => req.value === facetValue.value); + expect(targetValue?.state).toBe(facetValueState); + }); + + it(`sets the state of a ${facetValueState} value to idle`, () => { + const facetValue = buildMockCommerceLocationFacetValue({ + value: 'TED', + state: facetValueState, + }); + const facetValueRequest = + convertLocationFacetValueToRequest(facetValue); + + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + values: [facetValueRequest], + type: 'location', + }), + }); + + const action = toggleAction({ + facetId, + selection: facetValue, + }); + const finalState = commerceFacetSetReducer(state, action); + + const targetValue = ( + finalState[facetId]?.request.values as LocationFacetValueRequest[] + ).find((req) => req.value === facetValue.value); + expect(targetValue?.state).toBe('idle'); + }); + + it('sets #preventAutoSelect to true', () => { + const facetValue = buildMockCommerceLocationFacetValue({ + value: 'TED', + }); + const facetValueRequest = + convertLocationFacetValueToRequest(facetValue); + + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + values: [facetValueRequest], + type: 'location', + }), + }); + + const action = toggleAction({ + facetId, + selection: facetValue, + }); + const finalState = commerceFacetSetReducer(state, action); + + expect(finalState[facetId]?.request.preventAutoSelect).toBe(true); + }); + + it('sets #freezeCurrentValues to true', () => { + const facetValue = buildMockCommerceLocationFacetValue({ + value: 'TED', + }); + const facetValueRequest = + convertLocationFacetValueToRequest(facetValue); + + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + values: [facetValueRequest], + type: 'location', + }), + }); + + const action = toggleAction({ + facetId, + selection: facetValue, + }); + const finalState = commerceFacetSetReducer(state, action); + + expect(finalState[facetId]?.request.freezeCurrentValues).toBe(true); + }); + }); + + describe.each([ + { + facetValueState: 'selected' as FacetValueState, + toggleAction: toggleSelectLocationFacetValue, + }, + { + facetValueState: 'excluded' as FacetValueState, + toggleAction: toggleExcludeLocationFacetValue, + }, + ])( + 'when the facet value does not exist', + ({ + facetValueState, + toggleAction, + }: { + facetValueState: FacetValueState; + toggleAction: Function; + }) => { + it('replaces the first idle value with the new value', () => { + const newFacetValue = buildMockCommerceLocationFacetValue({ + value: 'TED', + state: facetValueState, + }); + + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({ + type: 'location', + values: [ + buildMockCommerceLocationFacetValue({ + value: 'active1', + state: facetValueState, + }), + buildMockCommerceLocationFacetValue({ + value: 'active2', + state: facetValueState, + }), + buildMockCommerceLocationFacetValue({ + value: 'idle1', + state: 'idle', + }), + buildMockCommerceLocationFacetValue({ + value: 'idle2', + state: 'idle', + }), + ], + }), + }); + + const action = toggleAction({ + facetId, + selection: newFacetValue, + }); + + const finalState = commerceFacetSetReducer(state, action); + expect( + ( + finalState[facetId]?.request + .values as LocationFacetValueRequest[] + ).indexOf(newFacetValue) + ).toBe(2); + expect(finalState[facetId]?.request.values.length).toBe(4); + }); + + it('sets #preventAutoSelect to true', () => { + state[facetId] = buildMockCommerceFacetSlice({ + request: buildMockCommerceFacetRequest({type: 'location'}), + }); + + const action = toggleAction({ + facetId, + selection: buildMockCommerceLocationFacetValue({value: 'TED'}), + }); + const finalState = commerceFacetSetReducer(state, action); + + expect(finalState[facetId]?.request.preventAutoSelect).toBe(true); + }); + } + ); + } + ); + it('dispatching #toggleSelectLocationFacetValue with an invalid id does not throw', () => { + const facetValue = buildMockCommerceLocationFacetValue({value: 'TED'}); + const action = toggleSelectLocationFacetValue({ + facetId: '1', + selection: facetValue, + }); + + expect(() => commerceFacetSetReducer(state, action)).not.toThrow(); + }); + + it('dispatching #toggleSelectLocationFacetValue with an invalid facet type does not throw', () => { + const facetValue = buildMockCommerceLocationFacetValue({value: 'TED'}); + const facet = buildMockCommerceFacetRequest({ + type: 'numericalRange', + values: [facetValue], + }); + state[facet.facetId] = buildMockCommerceFacetSlice({ + request: facet, + }); + const action = toggleSelectLocationFacetValue({ + facetId: facet.facetId, + selection: facetValue, + }); + + expect(() => commerceFacetSetReducer(state, action)).not.toThrow(); + }); + + it('dispatching #toggleExcludeLocationFacetValue with an invalid id does not throw', () => { + const facetValue = buildMockCommerceLocationFacetValue({value: 'TED'}); + const action = toggleExcludeLocationFacetValue({ + facetId: '1', + selection: facetValue, + }); + + expect(() => commerceFacetSetReducer(state, action)).not.toThrow(); + }); + + it('dispatching #toggleExcludeLocationFacetValue with an invalid facet type does not throw', () => { + const facetValue = buildMockCommerceLocationFacetValue({value: 'TED'}); + const facet = buildMockCommerceFacetRequest({ + type: 'numericalRange', + values: [facetValue], + }); + state[facet.facetId] = buildMockCommerceFacetSlice({ + request: facet, + }); + const action = toggleExcludeLocationFacetValue({ + facetId: facet.facetId, + selection: facetValue, + }); + + expect(() => commerceFacetSetReducer(state, action)).not.toThrow(); + }); + }); + describe('for numericalRange facets', () => { describe.each([ { @@ -2365,6 +2669,7 @@ describe('commerceFacetSetReducer', () => { describe('#updateCoreFacetIsFieldExpanded', () => { describe.each([ {type: 'regular' as FacetType}, + {type: 'location' as FacetType}, {type: 'numericalRange' as FacetType}, {type: 'dateRange' as FacetType}, {type: 'hierarchical' as FacetType}, 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 f74142e0daa..25193bb8f84 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 @@ -44,6 +44,10 @@ import { executeCommerceFieldSuggest, getFacetIdWithCommerceFieldSuggestionNamespace, } from '../facet-search-set/commerce-facet-search-actions.js'; +import { + toggleExcludeLocationFacetValue, + toggleSelectLocationFacetValue, +} from '../location-facet/location-facet-actions.js'; import { toggleExcludeNumericFacetValue, toggleSelectNumericFacetValue, @@ -72,8 +76,10 @@ import { NumericFacetRequest, DateFacetRequest, CategoryFacetRequest, + LocationFacetRequest, + LocationFacetValueRequest, } from './interfaces/request.js'; -import {CategoryFacetValue} from './interfaces/response.js'; +import {CategoryFacetValue, LocationFacetValue} from './interfaces/response.js'; import {AnyFacetResponse} from './interfaces/response.js'; export const commerceFacetSetReducer = createReducer( @@ -122,6 +128,27 @@ export const commerceFacetSetReducer = createReducer( updateExistingFacetValueState(existingValue, 'select'); facetRequest.freezeCurrentValues = true; }) + .addCase(toggleSelectLocationFacetValue, (state, action) => { + const {facetId, selection} = action.payload; + const facetRequest = state[facetId]?.request; + + if (!facetRequest || !ensureLocationFacetRequest(facetRequest)) { + return; + } + + facetRequest.preventAutoSelect = true; + + const existingValue = facetRequest.values.find( + (req) => req.value === selection.value + ); + if (!existingValue) { + insertNewValue(facetRequest, selection); + return; + } + + updateExistingFacetValueState(existingValue, 'select'); + facetRequest.freezeCurrentValues = true; + }) .addCase(toggleSelectNumericFacetValue, (state, action) => { const {facetId, selection} = action.payload; const facetRequest = state[facetId]?.request; @@ -223,6 +250,27 @@ export const commerceFacetSetReducer = createReducer( updateExistingFacetValueState(existingValue, 'exclude'); facetRequest.freezeCurrentValues = true; }) + .addCase(toggleExcludeLocationFacetValue, (state, action) => { + const {facetId, selection} = action.payload; + const facetRequest = state[facetId]?.request; + + if (!facetRequest || !ensureLocationFacetRequest(facetRequest)) { + return; + } + + facetRequest.preventAutoSelect = true; + + const existingValue = facetRequest.values.find( + (req) => req.value === selection.value + ); + if (!existingValue) { + insertNewValue(facetRequest, selection); + return; + } + + updateExistingFacetValueState(existingValue, 'exclude'); + facetRequest.freezeCurrentValues = true; + }) .addCase(toggleExcludeNumericFacetValue, (state, action) => { const {facetId, selection} = action.payload; const facetRequest = state[facetId]?.request; @@ -450,6 +498,12 @@ function ensureRegularFacetRequest( return facetRequest.type === 'regular'; } +function ensureLocationFacetRequest( + facetRequest: AnyFacetRequest +): facetRequest is LocationFacetRequest { + return facetRequest.type === 'location'; +} + function ensureNumericFacetRequest( facetRequest: AnyFacetRequest ): facetRequest is NumericFacetRequest { @@ -533,7 +587,10 @@ function ensurePathAndReturnChildren( } function updateExistingFacetValueState( existingFacetValue: WritableDraft< - FacetValueRequest | NumericRangeRequest | DateRangeRequest + | FacetValueRequest + | LocationFacetValueRequest + | NumericRangeRequest + | DateRangeRequest >, toggleAction: 'select' | 'exclude' ) { @@ -613,6 +670,8 @@ function getFacetRequestValuesFromFacetResponse( : facetResponse.values.map(convertCategoryFacetValueToRequest); case 'regular': return facetResponse.values.map(convertFacetValueToRequest); + case 'location': + return facetResponse.values.map(convertLocationFacetValueToRequest); default: return; } @@ -635,6 +694,14 @@ export function convertCategoryFacetValueToRequest( }; } +export function convertLocationFacetValueToRequest( + facetValue: LocationFacetValue +): LocationFacetValueRequest { + const {value, state} = facetValue; + + return {value, state}; +} + function insertNewValue( facetRequest: AnyFacetRequest, facetValue: AnyFacetValueRequest diff --git a/packages/headless/src/features/commerce/facets/facet-set/interfaces/common.ts b/packages/headless/src/features/commerce/facets/facet-set/interfaces/common.ts index 67f2563fe21..42d7526900b 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/interfaces/common.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/interfaces/common.ts @@ -19,4 +19,5 @@ export type FacetType = | 'regular' | 'dateRange' | 'numericalRange' - | 'hierarchical'; + | 'hierarchical' + | 'location'; 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 ebe344f3a27..ed34d45d8ec 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 @@ -41,6 +41,13 @@ export type RegularFacetRequest = BaseCommerceFacetRequest< 'regular' >; +export type LocationFacetValueRequest = FacetValueRequest; + +export type LocationFacetRequest = BaseCommerceFacetRequest< + LocationFacetValueRequest, + 'location' +>; + export type BaseCommerceFacetRequest = Pick< FacetRequest, | 'facetId' @@ -59,6 +66,7 @@ export type BaseCommerceFacetRequest = Pick< export type AnyFacetValueRequest = | FacetValueRequest + | LocationFacetValueRequest | CategoryFacetValueRequest | NumericRangeRequest | DateRangeRequest; 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 6ade7b0b585..505973ea6cf 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 @@ -39,6 +39,15 @@ export interface RegularFacetValue extends BaseFacetValue { value: string; } +export type LocationFacetResponse = BaseFacetResponse< + LocationFacetValue, + 'location' +>; + +export interface LocationFacetValue extends BaseFacetValue { + value: string; +} + export interface RangeFacetValue extends BaseFacetValue { start: T; end: T; @@ -70,6 +79,7 @@ export interface BaseFacetValue { export type AnyFacetValueResponse = | RegularFacetValue + | LocationFacetValue | NumericFacetValue | DateFacetValue | CategoryFacetValue; @@ -83,7 +93,9 @@ type MappedFacetResponse = { ? DateFacetResponse : T extends 'hierarchical' ? CategoryFacetResponse - : never; + : T extends 'location' + ? LocationFacetResponse + : never; }; export type AnyFacetResponse = MappedFacetResponse[FacetType]; diff --git a/packages/headless/src/features/commerce/facets/location-facet/location-facet-actions.ts b/packages/headless/src/features/commerce/facets/location-facet/location-facet-actions.ts new file mode 100644 index 00000000000..04db303b72c --- /dev/null +++ b/packages/headless/src/features/commerce/facets/location-facet/location-facet-actions.ts @@ -0,0 +1,29 @@ +import {RecordValue} from '@coveo/bueno'; +import {createAction} from '@reduxjs/toolkit'; +import { + requiredNonEmptyString, + validatePayload, +} from '../../../../utils/validate-payload.js'; +import {facetValueDefinition} from '../../../facets/facet-set/facet-set-validate-payload.js'; +import { + ToggleExcludeFacetValuePayload, + ToggleSelectFacetValuePayload, +} from '../regular-facet/regular-facet-actions.js'; + +export const toggleExcludeLocationFacetValue = createAction( + 'commerce/facets/locationFacet/toggleExcludeValue', + (payload: ToggleExcludeFacetValuePayload) => + validatePayload(payload, { + facetId: requiredNonEmptyString, + selection: new RecordValue({values: facetValueDefinition}), + }) +); + +export const toggleSelectLocationFacetValue = createAction( + 'commerce/facets/locationFacet/toggleSelectValue', + (payload: ToggleSelectFacetValuePayload) => + validatePayload(payload, { + facetId: requiredNonEmptyString, + selection: new RecordValue({values: facetValueDefinition}), + }) +); diff --git a/packages/headless/src/features/commerce/pagination/pagination-slice.test.ts b/packages/headless/src/features/commerce/pagination/pagination-slice.test.ts index ef7374d0037..b4dca0520c6 100644 --- a/packages/headless/src/features/commerce/pagination/pagination-slice.test.ts +++ b/packages/headless/src/features/commerce/pagination/pagination-slice.test.ts @@ -11,6 +11,10 @@ import { toggleExcludeDateFacetValue, toggleSelectDateFacetValue, } from '../facets/date-facet/date-facet-actions.js'; +import { + toggleExcludeLocationFacetValue, + toggleSelectLocationFacetValue, +} from '../facets/location-facet/location-facet-actions.js'; import { toggleExcludeNumericFacetValue, toggleSelectNumericFacetValue, @@ -269,6 +273,14 @@ describe('pagination slice', () => { actionName: '#toggleExcludeFacetValue', action: toggleExcludeFacetValue, }, + { + actionName: '#toggleSelectLocationFacetValue', + action: toggleSelectLocationFacetValue, + }, + { + actionName: '#toggleExcludeLocationFacetValue', + action: toggleExcludeLocationFacetValue, + }, { actionName: '#toggleSelectNumericFacetValue', action: toggleSelectNumericFacetValue, diff --git a/packages/headless/src/features/commerce/pagination/pagination-slice.ts b/packages/headless/src/features/commerce/pagination/pagination-slice.ts index daf24ac1a3b..b7adff645b0 100644 --- a/packages/headless/src/features/commerce/pagination/pagination-slice.ts +++ b/packages/headless/src/features/commerce/pagination/pagination-slice.ts @@ -9,6 +9,10 @@ import { toggleExcludeDateFacetValue, toggleSelectDateFacetValue, } from '../facets/date-facet/date-facet-actions.js'; +import { + toggleExcludeLocationFacetValue, + toggleSelectLocationFacetValue, +} from '../facets/location-facet/location-facet-actions.js'; import { toggleExcludeNumericFacetValue, toggleSelectNumericFacetValue, @@ -109,6 +113,8 @@ export const paginationReducer = createReducer( .addCase(deselectAllValuesInCoreFacet, handlePaginationReset) .addCase(toggleSelectFacetValue, handlePaginationReset) .addCase(toggleExcludeFacetValue, handlePaginationReset) + .addCase(toggleSelectLocationFacetValue, handlePaginationReset) + .addCase(toggleExcludeLocationFacetValue, handlePaginationReset) .addCase(toggleSelectNumericFacetValue, handlePaginationReset) .addCase(toggleExcludeNumericFacetValue, handlePaginationReset) .addCase(toggleSelectDateFacetValue, handlePaginationReset) diff --git a/packages/headless/src/test/mock-commerce-facet-response.ts b/packages/headless/src/test/mock-commerce-facet-response.ts index 0fb0d568de9..9c188778637 100644 --- a/packages/headless/src/test/mock-commerce-facet-response.ts +++ b/packages/headless/src/test/mock-commerce-facet-response.ts @@ -4,6 +4,7 @@ import { DateFacetResponse, AnyFacetResponse, CategoryFacetResponse, + LocationFacetResponse, } from '../features/commerce/facets/facet-set/interfaces/response.js'; function getMockBaseCommerceFacetResponse(): Omit< @@ -32,6 +33,17 @@ export function buildMockCommerceRegularFacetResponse( }; } +export function buildMockCommerceLocationFacetResponse( + config: Partial = {} +): LocationFacetResponse { + return { + ...getMockBaseCommerceFacetResponse(), + type: 'location', + values: [], + ...config, + }; +} + export function buildMockCommerceNumericFacetResponse( config: Partial = {} ): NumericFacetResponse { diff --git a/packages/headless/src/test/mock-commerce-facet-value.ts b/packages/headless/src/test/mock-commerce-facet-value.ts index dffd169cd16..09130506392 100644 --- a/packages/headless/src/test/mock-commerce-facet-value.ts +++ b/packages/headless/src/test/mock-commerce-facet-value.ts @@ -3,6 +3,7 @@ import { NumericFacetValue, DateFacetValue, CategoryFacetValue, + LocationFacetValue, } from '../features/commerce/facets/facet-set/interfaces/response.js'; export function buildMockCommerceRegularFacetValue( @@ -19,6 +20,20 @@ export function buildMockCommerceRegularFacetValue( }; } +export function buildMockCommerceLocationFacetValue( + config: Partial = {} +): LocationFacetValue { + return { + value: '', + state: 'idle', + numberOfResults: 0, + isAutoSelected: false, + isSuggested: false, + moreValuesAvailable: false, + ...config, + }; +} + export function buildMockCommerceNumericFacetValue( config: Partial = {} ): NumericFacetValue {