From ce9f0be4d8b030c4325bf169c7e3dd2847a6d322 Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Tue, 27 Aug 2024 11:45:16 -0400 Subject: [PATCH 01/10] ci: disable concurrency (#4331) - **ci: prevent concurrent tests run** - **Revert "ci: prevent concurrent tests run"** --- .github/workflows/prbot.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/prbot.yml b/.github/workflows/prbot.yml index 68599f920b0..a5c5197fba8 100644 --- a/.github/workflows/prbot.yml +++ b/.github/workflows/prbot.yml @@ -4,8 +4,6 @@ on: merge_group: env: CYPRESS_VERIFY_TIMEOUT: 60000 -concurrency: - group: ci jobs: report-size: if: github.event_name == 'pull_request' From 3f957f9334708501a5fbaf73720e1646e6ea4611 Mon Sep 17 00:00:00 2001 From: Frederic Beaudoin Date: Tue, 27 Aug 2024 12:31:53 -0400 Subject: [PATCH 02/10] feat(headless commerce ssr): add SSR FacetGenerator (#4290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://coveord.atlassian.net/browse/KIT-3397 In order to make this work, I added an SSR-specific `FacetGenerator` controller. The new controller extends the CSR `FacetGenerator`. It overrides its `state` getter, omits its `facets` getter, and adds a new `getFacetController` method. Instead of returning a list of facet IDs to render, the SSR `FacetGenerator`'s `state` getter returns the full state of each facet. Note that this required me to do some refactoring across all types of Facet controllers in order to extract and export facet state "selector" functions (e.g., `getCoreFacetState`, `getRegularFacetState`). I took the opportunity to use RTK selectors when possible. The idea is that in an SSR scenario, you would use the SSR `FacetGenerator`'s `state` getter as the static state to initially render each facet component in your UI. You would use the `getFacetController` method when the `FacetGenerator` is hydrated to fetch each individual facet controller, and have their components subscribe to their respective states. Under the hood, the `getFacetController` method uses the CSR `FacetGenerator`'s `facets` getter, which relies on a memoized RTK selector that will only rebuild the facet controller if its `type` changes in the engine state (which should not happen unless the configuration changes in the CMH during the user's session). The samples for facets are mostly copied-and-pasted (and adapted for SSR) from https://github.com/coveo/ui-kit/pull/4274 and https://github.com/coveo/ui-kit/pull/4273, so if you've already reviewed these PRs, you can probably skim over those. --------- Co-authored-by: ylakhdar Co-authored-by: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com> --- .cspell.json | 1 + .../atomic-commerce-facets.tsx | 4 +- ...headless-commerce-category-facet-search.ts | 6 +- .../headless-commerce-category-facet.ts | 103 +++--- .../date/headless-commerce-date-facet.ts | 18 +- ...dless-commerce-facet-generator.ssr.test.ts | 164 ++++++++++ .../headless-commerce-facet-generator.ssr.ts | 248 +++++++++++++++ .../headless-commerce-facet-generator.test.ts | 2 +- .../headless-commerce-facet-generator.ts | 2 +- .../facets/headless-core-commerce-facet.ts | 52 +-- .../headless-commerce-numeric-facet.ts | 56 ++-- .../headless-commerce-regular-facet-search.ts | 6 +- .../headless-commerce-regular-facet.test.ts | 6 +- .../headless-commerce-regular-facet.ts | 75 ++--- .../headless-product-listing-facet-options.ts | 35 +- .../facets/headless-search-facet-options.ts | 35 +- .../facets/facet-set/facet-set-selector.ts | 17 +- .../manual-numeric-facet-selectors.ts | 8 +- .../category-facet-search-state-selector.ts | 12 + .../specific-facet-search-state-selector.ts | 12 + packages/headless/src/ssr-commerce.index.ts | 18 ++ packages/headless/src/test/mock-engine-v2.ts | 11 + .../app/_components/facets/category-facet.tsx | 300 ++++++++++++++++++ .../app/_components/facets/date-facet.tsx | 84 +++++ .../_components/facets/facet-generator.tsx | 81 +++++ .../app/_components/facets/numeric-facet.tsx | 209 ++++++++++++ .../app/_components/facets/regular-facet.tsx | 218 +++++++++++++ .../app/_components/pages/listing-page.tsx | 5 + .../app/_components/pages/search-page.tsx | 5 + .../app/_lib/commerce-engine-config.ts | 2 + 30 files changed, 1607 insertions(+), 188 deletions(-) create mode 100644 packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.test.ts create mode 100644 packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts create mode 100644 packages/headless/src/features/facets/facet-search-set/category/category-facet-search-state-selector.ts create mode 100644 packages/headless/src/features/facets/facet-search-set/specific/specific-facet-search-state-selector.ts create mode 100644 packages/samples/headless-ssr-commerce/app/_components/facets/category-facet.tsx create mode 100644 packages/samples/headless-ssr-commerce/app/_components/facets/date-facet.tsx create mode 100644 packages/samples/headless-ssr-commerce/app/_components/facets/facet-generator.tsx create mode 100644 packages/samples/headless-ssr-commerce/app/_components/facets/numeric-facet.tsx create mode 100644 packages/samples/headless-ssr-commerce/app/_components/facets/regular-facet.tsx diff --git a/.cspell.json b/.cspell.json index 58e96a1426e..31c5434cbcd 100644 --- a/.cspell.json +++ b/.cspell.json @@ -20,6 +20,7 @@ "bloup", "bpsb", "btoashim", + "CAPI", "cfcomment", "cfpage", "cfspace", diff --git a/packages/atomic/src/components/commerce/facets/atomic-commerce-facets/atomic-commerce-facets.tsx b/packages/atomic/src/components/commerce/facets/atomic-commerce-facets/atomic-commerce-facets.tsx index 30920a05627..a00bb87e78a 100644 --- a/packages/atomic/src/components/commerce/facets/atomic-commerce-facets/atomic-commerce-facets.tsx +++ b/packages/atomic/src/components/commerce/facets/atomic-commerce-facets/atomic-commerce-facets.tsx @@ -133,9 +133,7 @@ export class AtomicCommerceFacets implements InitializableComponent { > ); default: { - this.bindings.engine.logger.warn( - `Unexpected facet type ${facet.state.type}.` - ); + this.bindings.engine.logger.warn('Unexpected facet type.'); return; } } diff --git a/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet-search.ts b/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet-search.ts index 948722293c0..f67a798d854 100644 --- a/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet-search.ts +++ b/packages/headless/src/controllers/commerce/core/facets/category/headless-commerce-category-facet-search.ts @@ -28,9 +28,7 @@ export type CategoryFacetSearchState = export type CategoryFacetSearch = Omit< ReturnType, 'showMoreResults' | 'updateCaptions' | 'state' -> & { - state: CategoryFacetSearchState; -}; +>; export function buildCategoryFacetSearch( engine: CommerceEngine, @@ -40,7 +38,7 @@ export function buildCategoryFacetSearch( throw loadReducerError; } - const {showMoreResults, updateCaptions, ...restOfFacetSearch} = + const {showMoreResults, state, updateCaptions, ...restOfFacetSearch} = buildCoreCategoryFacetSearch(engine, { ...props, executeFacetSearchActionCreator: (facetId: string) => 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 index 55b10b1eca1..6922d117958 100644 --- 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 @@ -1,8 +1,4 @@ -import {createSelector} from '@reduxjs/toolkit'; -import { - CommerceEngine, - CommerceEngineState, -} from '../../../../../app/commerce-engine/commerce-engine'; +import {CommerceEngine} from '../../../../../app/commerce-engine/commerce-engine'; import {stateKey} from '../../../../../app/state-key'; import { toggleSelectCategoryFacetValue, @@ -11,6 +7,7 @@ import { import {CategoryFacetValueRequest} from '../../../../../features/commerce/facets/facet-set/interfaces/request'; import {defaultNumberOfValuesIncrement} from '../../../../../features/facets/category-facet-set/category-facet-set-actions'; import {findActiveValueAncestry} from '../../../../../features/facets/category-facet-set/category-facet-utils'; +import {categoryFacetSearchStateSelector} from '../../../../../features/facets/facet-search-set/category/category-facet-search-state-selector'; import { CategoryFacetValue, CoreCommerceFacet, @@ -32,13 +29,17 @@ export type CategoryFacetOptions = Omit< > & SearchableFacetOptions; -export type CategoryFacetState = CoreCommerceFacetState & { +export type CategoryFacetState = Omit< + CoreCommerceFacetState, + 'type' +> & { activeValue?: CategoryFacetValue; canShowLessValues: boolean; canShowMoreValues: boolean; hasActiveValues: boolean; selectedValueAncestry?: CategoryFacetValue[]; facetSearch: CategoryFacetSearchState; + type: 'hierarchical'; }; /** @@ -85,27 +86,13 @@ export function buildCategoryFacet( coreController; const {dispatch} = engine; const getFacetId = () => coreController.state.facetId; - const createFacetSearch = () => { - return buildCategoryFacetSearch(engine, { - options: {facetId: getFacetId(), ...options.facetSearch}, - select: () => { - dispatch(options.fetchProductsActionCreator()); - }, - isForFieldSuggestions: false, - }); - }; - - const facetSearch = createFacetSearch(); - const {state, ...restOfFacetSearch} = facetSearch; - const facetSearchStateSelector = createSelector( - (state: CommerceEngineState) => state.categoryFacetSearchSet[getFacetId()], - (facetSearch) => ({ - isLoading: facetSearch.isLoading, - moreValuesAvailable: facetSearch.response.moreValuesAvailable, - query: facetSearch.options.query, - values: facetSearch.response.values, - }) - ); + const facetSearch = buildCategoryFacetSearch(engine, { + options: {facetId: getFacetId(), ...options.facetSearch}, + select: () => { + dispatch(options.fetchProductsActionCreator()); + }, + isForFieldSuggestions: false, + }); return { deselectAll, @@ -136,35 +123,49 @@ export function buildCategoryFacet( dispatch(options.fetchProductsActionCreator()); }, - facetSearch: restOfFacetSearch, + facetSearch, get state() { - const selectedValueAncestry = findActiveValueAncestry( - coreController.state.values + return getCategoryFacetState( + coreController.state, + categoryFacetSearchStateSelector(engine[stateKey], getFacetId()) ); - 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, - facetSearch: facetSearchStateSelector(engine[stateKey]), - }; }, type: 'hierarchical', }; } + +export const getCategoryFacetState = ( + coreState: CoreCommerceFacetState, + facetSearchSelector: ReturnType +): CategoryFacetState => { + const {values} = coreState; + const selectedValueAncestry = findActiveValueAncestry(values); + const activeValue = selectedValueAncestry.length + ? selectedValueAncestry[selectedValueAncestry.length - 1] + : undefined; + const canShowLessValues = activeValue + ? activeValue.children.length > defaultNumberOfValuesIncrement + : false; + const canShowMoreValues = + activeValue?.moreValuesAvailable ?? coreState.canShowMoreValues ?? false; + const hasActiveValues = !!activeValue; + return { + ...coreState, + activeValue, + canShowLessValues, + canShowMoreValues, + facetSearch: { + isLoading: facetSearchSelector?.isLoading ?? false, + moreValuesAvailable: + facetSearchSelector?.response.moreValuesAvailable ?? false, + query: facetSearchSelector?.options.query ?? '', + values: facetSearchSelector?.response.values ?? [], + }, + hasActiveValues, + selectedValueAncestry, + type: 'hierarchical', + values, + }; +}; diff --git a/packages/headless/src/controllers/commerce/core/facets/date/headless-commerce-date-facet.ts b/packages/headless/src/controllers/commerce/core/facets/date/headless-commerce-date-facet.ts index 841e21a43da..3cd9385696a 100644 --- a/packages/headless/src/controllers/commerce/core/facets/date/headless-commerce-date-facet.ts +++ b/packages/headless/src/controllers/commerce/core/facets/date/headless-commerce-date-facet.ts @@ -20,7 +20,12 @@ export type DateFacetOptions = Omit< 'toggleSelectActionCreator' | 'toggleExcludeActionCreator' >; -export type DateFacetState = CoreCommerceFacetState; +export type DateFacetState = Omit< + CoreCommerceFacetState, + 'type' +> & { + type: 'dateRange'; +}; /** * The `DateFacet` sub-controller offers a high-level programming interface for implementing date commerce @@ -86,9 +91,18 @@ export function buildCommerceDateFacet( }, get state() { - return coreController.state; + return getDateFacetState(coreController.state); }, type: 'dateRange', }; } + +export const getDateFacetState = ( + coreState: CoreCommerceFacetState +): DateFacetState => { + return { + ...coreState, + type: 'dateRange', + }; +}; 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 new file mode 100644 index 00000000000..2c5c40fb653 --- /dev/null +++ b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.test.ts @@ -0,0 +1,164 @@ +import {SolutionType} from '../../../../../app/commerce-ssr-engine/types/common'; +import {AnyFacetResponse} from '../../../../../features/commerce/facets/facet-set/interfaces/response'; +import {CommerceAppState} from '../../../../../state/commerce-app-state'; +import {buildMockCategoryFacetSearch} from '../../../../../test/mock-category-facet-search'; +import {buildMockCommerceFacetRequest} from '../../../../../test/mock-commerce-facet-request'; +import { + buildMockCategoryFacetResponse, + buildMockCommerceDateFacetResponse, + buildMockCommerceNumericFacetResponse, + buildMockCommerceRegularFacetResponse, +} from '../../../../../test/mock-commerce-facet-response'; +import {buildMockCommerceState} from '../../../../../test/mock-commerce-state'; +import { + buildMockSSRCommerceEngine, + MockedCommerceEngine, +} from '../../../../../test/mock-engine-v2'; +import {buildMockFacetSearch} from '../../../../../test/mock-facet-search'; +import {buildProductListing} from '../../../product-listing/headless-product-listing'; +import {buildSearch} from '../../../search/headless-search'; +import {FacetType} from '../headless-core-commerce-facet'; +import {GeneratedFacetControllers} from './headless-commerce-facet-generator'; +import { + buildFacetGenerator, + FacetGenerator, + FacetGeneratorOptions, +} from './headless-commerce-facet-generator.ssr'; + +describe('SSR FacetGenerator', () => { + let engine: MockedCommerceEngine; + let state: CommerceAppState; + let options: FacetGeneratorOptions; + let facetGenerator: FacetGenerator; + let facetsInEngineState: {facetId: string; type: FacetType}[]; + + function initEngine(preloadedState = buildMockCommerceState()) { + engine = buildMockSSRCommerceEngine({...preloadedState}); + } + + function initCommerceFacetGenerator() { + facetGenerator = buildFacetGenerator(engine, options); + } + + function setFacetState(config = facetsInEngineState) { + for (const facet of config) { + const {facetId, type} = facet; + state.facetOrder.push(facetId); + let response: AnyFacetResponse; + switch (type) { + case 'dateRange': + response = buildMockCommerceDateFacetResponse({facetId, type}); + break; + case 'hierarchical': + response = buildMockCategoryFacetResponse({facetId, type}); + break; + case 'numericalRange': + response = buildMockCommerceNumericFacetResponse({facetId, type}); + break; + case 'regular': + default: + response = buildMockCommerceRegularFacetResponse({facetId, type}); + } + if (options.props.solutionType === SolutionType.listing) { + state.productListing.facets.push(response); + } else { + state.commerceSearch.facets.push(response); + } + + state.commerceFacetSet[facet.facetId] = { + request: buildMockCommerceFacetRequest({ + facetId: facet.facetId, + type: facet.type, + }), + }; + if (type === 'regular') { + state.facetSearchSet[facet.facetId] = buildMockFacetSearch(); + } else if (type === 'hierarchical') { + state.categoryFacetSearchSet[facet.facetId] = + buildMockCategoryFacetSearch(); + } + } + } + + beforeEach(() => { + jest.resetAllMocks(); + }); + describe.each([ + { + solutionType: SolutionType.listing, + buildGeneratedCSRFacetControllersFunction: () => + buildProductListing(engine).facetGenerator().facets, + }, + { + solutionType: SolutionType.search, + buildGeneratedCSRFacetControllersFunction: () => + buildSearch(engine).facetGenerator().facets, + }, + ])( + 'when solutionType is $solutionType', + ({solutionType, buildGeneratedCSRFacetControllersFunction}) => { + let generatedCSRFacetControllers: GeneratedFacetControllers; + beforeEach(() => { + options = {props: {solutionType}}; + facetsInEngineState = [ + { + facetId: 'category-facet', + type: 'hierarchical', + }, + { + facetId: 'date-facet', + type: 'dateRange', + }, + { + facetId: 'numeric-facet', + type: 'numericalRange', + }, + { + facetId: 'regular-facet', + type: 'regular', + }, + ]; + state = buildMockCommerceState(); + setFacetState(facetsInEngineState); + initEngine(state); + initCommerceFacetGenerator(); + + generatedCSRFacetControllers = + buildGeneratedCSRFacetControllersFunction(); + }); + + it('initialized', () => { + expect(facetGenerator).toBeTruthy(); + }); + it('#state is an array containing the state of each facet', () => { + expect(facetGenerator.state.length).toBe(4); + expect( + facetGenerator.state.map((facet) => ({ + facetId: facet.facetId, + type: facet.type, + })) + ).toEqual(facetsInEngineState); + }); + + it('#getFacetController returns facet controller for the given facet id and type', () => { + for (const facetInEngineState of facetsInEngineState) { + const {facetId, type} = facetInEngineState; + const generatedSSRFacetController = facetGenerator.getFacetController( + facetId, + type + ); + const generatedCSRFacetController = generatedCSRFacetControllers.find( + (controller) => controller.state.facetId === facetId + ); + expect(generatedSSRFacetController).toBeTruthy(); + expect(generatedSSRFacetController?.type).toBe( + generatedCSRFacetController?.type + ); + expect(generatedSSRFacetController?.state).toEqual( + generatedCSRFacetController?.state + ); + } + }); + } + ); +}); 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 new file mode 100644 index 00000000000..88e9bba9ac3 --- /dev/null +++ b/packages/headless/src/controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr.ts @@ -0,0 +1,248 @@ +import {BaseFacetSearchResult} from '../../../../../api/search/facet-search/base/base-facet-search-response'; +import {CategoryFacetSearchResult} from '../../../../../api/search/facet-search/category-facet-search/category-facet-search-response'; +import {CommerceEngine} from '../../../../../app/commerce-engine/commerce-engine'; +import {ensureAtLeastOneSolutionType} from '../../../../../app/commerce-ssr-engine/common'; +import { + ControllerDefinitionOption, + SolutionType, + SubControllerDefinitionWithoutProps, +} from '../../../../../app/commerce-ssr-engine/types/common'; +import {stateKey} from '../../../../../app/state-key'; +import {facetRequestSelector} from '../../../../../features/commerce/facets/facet-set/facet-set-selector'; +import { + AnyFacetResponse, + RegularFacetValue, +} from '../../../../../features/commerce/facets/facet-set/interfaces/response'; +import {manualNumericFacetSelector} from '../../../../../features/commerce/facets/numeric-facet/manual-numeric-facet-selectors'; +import {manualNumericFacetReducer as manualNumericFacetSet} from '../../../../../features/commerce/facets/numeric-facet/manual-numeric-facet-slice'; +import {categoryFacetSearchStateSelector} from '../../../../../features/facets/facet-search-set/category/category-facet-search-state-selector'; +import {specificFacetSearchStateSelector} from '../../../../../features/facets/facet-search-set/specific/specific-facet-search-state-selector'; +import {ManualRangeSection} from '../../../../../state/state-sections'; +import {loadReducerError} from '../../../../../utils/errors'; +import { + isFacetLoadingResponseSelector as listingIsFacetLoadingResponseSelector, + facetResponseSelector as listingFacetResponseSelector, +} from '../../../product-listing/facets/headless-product-listing-facet-options'; +import {buildProductListing} from '../../../product-listing/headless-product-listing'; +import { + isFacetLoadingResponseSelector as searchIsFacetLoadingResponseSelector, + facetResponseSelector as searchFacetResponseSelector, +} from '../../../search/facets/headless-search-facet-options'; +import {buildSearch} from '../../../search/headless-search'; +import { + CategoryFacet, + CategoryFacetState, + getCategoryFacetState, +} from '../category/headless-commerce-category-facet'; +import { + DateFacet, + DateFacetState, + getDateFacetState, +} from '../date/headless-commerce-date-facet'; +import { + CategoryFacetValue, + FacetType, + getCoreFacetState, +} from '../headless-core-commerce-facet'; +import { + getNumericFacetState, + NumericFacet, + NumericFacetState, +} from '../numeric/headless-commerce-numeric-facet'; +import { + getRegularFacetState, + RegularFacet, + RegularFacetState, +} from '../regular/headless-commerce-regular-facet'; +import { + FacetGenerator as CSRFacetGenerator, + MappedGeneratedFacetController, +} from './headless-commerce-facet-generator'; + +export type { + BaseFacetSearchResult, + CategoryFacet, + CategoryFacetState, + CategoryFacetValue, + CategoryFacetSearchResult, + DateFacet, + DateFacetState, + NumericFacet, + NumericFacetState, + RegularFacet, + RegularFacetState, + RegularFacetValue, +}; + +export type FacetGeneratorState = MappedFacetStates; + +type MappedFacetStates = Array; + +type MappedFacetState = { + [T in FacetType]: T extends 'numericalRange' + ? NumericFacetState + : T extends 'regular' + ? RegularFacetState + : T extends 'dateRange' + ? DateFacetState + : T extends 'hierarchical' + ? CategoryFacetState + : never; +}; + +export function defineFacetGenerator< + TOptions extends ControllerDefinitionOption | undefined, +>(options?: TOptions) { + ensureAtLeastOneSolutionType(options); + return { + ...options, + build: (engine, solutionType) => + buildFacetGenerator(engine, {props: {solutionType: solutionType!}}), + } as SubControllerDefinitionWithoutProps; +} + +/** + * The `FacetGenerator` headless sub-controller creates commerce facet sub-controllers from the Commerce API search or + * product listing response. + * + * Commerce facets are not requested by the implementer, but rather pre-configured through the Coveo Merchandising Hub + * (CMH). The implementer is only responsible for leveraging the facet controllers created by this sub-controller to + * properly render facets in their application. + */ +export interface FacetGenerator + extends Omit { + /** + * The state of each every facet returned by the Commerce API. + * + * In a server-side rendering (SSR) scenario, you must use this state to render the facet UI components before the + * facet controller is hydrated on the client side. + * + * Once the facet generator controller has been hydrated, you must use the `getFacetController` method to retrieve + * the individual facet controllers and subscribe to their respective states. + */ + state: FacetGeneratorState; + + /** + * Builds a facet controller for the specified facet ID. + * + * @param facetId The unique identifier of the facet. + * @param facetType The type of facet to build. + * @returns A facet controller of the specified type, or `undefined` if the facet does not exist in the state. + */ + getFacetController: ( + facetId: string, + facetType: T + ) => MappedGeneratedFacetController[T] | undefined; +} + +export interface FacetGeneratorOptions { + props: FacetGeneratorProps; +} + +export interface FacetGeneratorProps { + solutionType: SolutionType; +} + +/** + * @internal + * + * Creates a `FacetGenerator` sub-controller for server-side rendering purposes (SSR). + * + * @param engine - The SSR commerce engine. + * @param options - The facet generator options used internally. + * @returns A `FacetGenerator` sub-controller. + */ +export function buildFacetGenerator( + engine: CommerceEngine, + options: FacetGeneratorOptions +): FacetGenerator { + if (!loadFacetGeneratorReducers(engine)) { + throw loadReducerError; + } + + const getEngineState = () => engine[stateKey]; + const solutionType = options.props.solutionType; + + const getFacetResponseSelector = (facetId: string) => { + return solutionType === SolutionType.listing + ? listingFacetResponseSelector(getEngineState(), facetId) + : searchFacetResponseSelector(getEngineState(), facetId); + }; + + const isFacetLoadingResponseSelector = + solutionType === SolutionType.listing + ? listingIsFacetLoadingResponseSelector(getEngineState()) + : searchIsFacetLoadingResponseSelector(getEngineState()); + + const createFacetState = (facetResponseSelector: AnyFacetResponse) => { + const facetId = facetResponseSelector.facetId; + return getCoreFacetState( + facetRequestSelector(getEngineState(), facetId), + facetResponseSelector, + isFacetLoadingResponseSelector + ); + }; + + const baseController = + solutionType === SolutionType.listing + ? buildProductListing(engine).facetGenerator() + : buildSearch(engine).facetGenerator(); + + const {state, ...restOfBaseController} = baseController; + + return { + ...restOfBaseController, + + getFacetController: ( + facetId: string, + facetType: T + ) => { + const controller = baseController.facets.find( + (f) => f.state.facetId === facetId && f.type === facetType + ); + + return controller as MappedGeneratedFacetController[T] | undefined; + }, + + get state() { + const facetResponseSelectors = baseController.state + .map(getFacetResponseSelector) + .filter((selector) => selector !== undefined); + + return facetResponseSelectors.map((selector) => { + const facetResponseSelector = selector!; + const facetId = facetResponseSelector.facetId; + switch (facetResponseSelector.type) { + case 'hierarchical': + return getCategoryFacetState( + createFacetState(facetResponseSelector) as CategoryFacetState, + categoryFacetSearchStateSelector(getEngineState(), facetId) + ); + case 'dateRange': + return getDateFacetState( + createFacetState(facetResponseSelector) as DateFacetState + ); + + case 'numericalRange': + return getNumericFacetState( + createFacetState(facetResponseSelector) as NumericFacetState, + facetResponseSelector, + manualNumericFacetSelector(getEngineState(), facetId) + ); + case 'regular': + return getRegularFacetState( + createFacetState(facetResponseSelector) as RegularFacetState, + specificFacetSearchStateSelector(getEngineState(), facetId) + ); + } + }); + }, + }; +} + +function loadFacetGeneratorReducers( + engine: CommerceEngine +): engine is CommerceEngine { + engine.addReducers({manualNumericFacetSet}); + return true; +} 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 a5bfd28fd4c..fedd39df896 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 @@ -21,7 +21,7 @@ jest.mock( '../../../../../features/commerce/facets/core-facet/core-facet-actions' ); -describe('FacetGenerator', () => { +describe('CSR FacetGenerator', () => { let engine: MockedCommerceEngine; let state: CommerceAppState; let options: FacetGeneratorOptions; 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 215349d4fa7..8a145e325ce 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 @@ -70,7 +70,7 @@ export type GeneratedFacetControllers = Array< MappedGeneratedFacetController[FacetType] >; -type MappedGeneratedFacetController = { +export type MappedGeneratedFacetController = { [T in FacetType]: T extends 'numericalRange' ? NumericFacet : T extends 'regular' 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 9bebd4a2d17..80f548ff896 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 @@ -5,6 +5,7 @@ import { updateCoreFacetIsFieldExpanded, updateCoreFacetNumberOfValues, } from '../../../../features/commerce/facets/core-facet/core-facet-actions'; +import {facetRequestSelector} from '../../../../features/commerce/facets/facet-set/facet-set-selector'; import {commerceFacetSetReducer as commerceFacetSet} from '../../../../features/commerce/facets/facet-set/facet-set-slice'; import {FacetType} from '../../../../features/commerce/facets/facet-set/interfaces/common'; import { @@ -164,12 +165,14 @@ export function buildCoreCommerceFacet< const facetId = props.options.facetId; + const getEngineState = () => engine[stateKey]; + const getRequest = (): AnyFacetRequest | undefined => - engine[stateKey].commerceFacetSet[facetId]?.request; + facetRequestSelector(getEngineState(), facetId); const getResponse = () => - props.options.facetResponseSelector(engine[stateKey], facetId); + props.options.facetResponseSelector(getEngineState(), facetId); const getIsLoading = () => - props.options.isFacetLoadingResponseSelector(engine[stateKey]); + props.options.isFacetLoadingResponseSelector(getEngineState()); const getNumberOfActiveValues = () => { return getRequest()?.values?.filter((v) => v.state !== 'idle').length ?? 0; @@ -186,11 +189,9 @@ export function buildCoreCommerceFacet< }) ); dispatch(props.options.fetchProductsActionCreator()); - // 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( @@ -201,7 +202,6 @@ export function buildCoreCommerceFacet< dispatch(props.options.toggleExcludeActionCreator({selection, facetId})); dispatch(props.options.fetchProductsActionCreator()); - // TODO: analytics }, // Must use a function here to properly support inheritance with `this`. @@ -215,7 +215,6 @@ 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( @@ -278,23 +277,7 @@ export function buildCoreCommerceFacet< }, get state(): CoreCommerceFacetState { - const response = getResponse(); - const canShowMoreValues = response?.moreValuesAvailable ?? false; - - const values = (response?.values ?? []) as ValueResponse[]; - const hasActiveValues = values.some((v) => v.state !== 'idle'); - - return { - facetId, - type: response?.type ?? 'regular', - field: response?.field ?? '', - displayName: response?.displayName ?? '', - values, - isLoading: getIsLoading(), - canShowMoreValues, - canShowLessValues: canShowLessValues(getRequest()), - hasActiveValues, - }; + return getCoreFacetState(getRequest(), getResponse(), getIsLoading()); }, }; } @@ -319,3 +302,24 @@ const canShowLessValues = (request: AnyFacetRequest | undefined) => { hasIdleValues ); }; + +export const getCoreFacetState = ( + request: AnyFacetRequest | undefined, + response: AnyFacetResponse | undefined, + isLoading: boolean +): CoreCommerceFacetState => { + return { + canShowLessValues: canShowLessValues(request), + canShowMoreValues: response?.moreValuesAvailable ?? false, + displayName: response?.displayName ?? '', + facetId: response?.facetId ?? '', + field: response?.field ?? '', + hasActiveValues: + !response || response.type === 'hierarchical' + ? false + : response.values.some((v) => v.state !== 'idle'), + isLoading, + type: response?.type ?? 'regular', + values: response?.values ? (response.values as T[]) : [], + }; +}; diff --git a/packages/headless/src/controllers/commerce/core/facets/numeric/headless-commerce-numeric-facet.ts b/packages/headless/src/controllers/commerce/core/facets/numeric/headless-commerce-numeric-facet.ts index 8533bfe2ed8..99d7cfb7efa 100644 --- a/packages/headless/src/controllers/commerce/core/facets/numeric/headless-commerce-numeric-facet.ts +++ b/packages/headless/src/controllers/commerce/core/facets/numeric/headless-commerce-numeric-facet.ts @@ -1,6 +1,7 @@ import {CommerceEngine} from '../../../../../app/commerce-engine/commerce-engine'; import {stateKey} from '../../../../../app/state-key'; -import {selectManualRange} from '../../../../../features/commerce/facets/numeric-facet/manual-numeric-facet-selectors'; +import {NumericFacetResponse} from '../../../../../features/commerce/facets/facet-set/interfaces/response'; +import {manualNumericFacetSelector} from '../../../../../features/commerce/facets/numeric-facet/manual-numeric-facet-selectors'; import {manualNumericFacetReducer as manualNumericFacetSet} from '../../../../../features/commerce/facets/numeric-facet/manual-numeric-facet-slice'; import { toggleExcludeNumericFacetValue, @@ -24,12 +25,16 @@ export type NumericFacetOptions = Omit< 'toggleSelectActionCreator' | 'toggleExcludeActionCreator' >; -export type NumericFacetState = CoreCommerceFacetState & { +export type NumericFacetState = Omit< + CoreCommerceFacetState, + 'type' +> & { /** * The domain of the numeric facet. */ domain?: NumericFacetDomain; manualRange?: NumericRangeRequest; + type: 'numericalRange'; }; type NumericFacetDomain = { @@ -114,27 +119,12 @@ export function buildCommerceNumericFacet( get state(): NumericFacetState { const response = options.facetResponseSelector(engine[stateKey], facetId); - const manualRange = selectManualRange( - facetId, - engine[stateKey].manualNumericFacetSet - ); - - if (response?.type === 'numericalRange' && response.domain) { - const {min, max} = response.domain; - return { - ...coreController.state, - domain: { - min, - max, - }, - manualRange, - }; - } - return { - ...coreController.state, - manualRange, - }; + return getNumericFacetState( + coreController.state, + response?.type === 'numericalRange' ? response : undefined, + manualNumericFacetSelector(engine[stateKey], facetId) + ); }, type: 'numericalRange', @@ -147,3 +137,25 @@ function loadCommerceNumericFacetReducers( engine.addReducers({manualNumericFacetSet}); return true; } + +export const getNumericFacetState = ( + coreState: CoreCommerceFacetState, + facetResponseSelector: NumericFacetResponse | undefined, + manualFacetRangeSelector: NumericRangeRequest | undefined +): NumericFacetState => { + const response = + facetResponseSelector?.type === 'numericalRange' + ? facetResponseSelector + : undefined; + return { + ...coreState, + ...(response?.domain && { + domain: { + min: response.domain.min, + max: response.domain.max, + }, + }), + ...(manualFacetRangeSelector && {manualRange: manualFacetRangeSelector}), + type: 'numericalRange', + }; +}; diff --git a/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet-search.ts b/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet-search.ts index ddb40ad4a03..373afc16495 100644 --- a/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet-search.ts +++ b/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet-search.ts @@ -21,9 +21,7 @@ export type RegularFacetSearchState = export type RegularFacetSearch = Omit< ReturnType, 'showMoreResults' | 'updateCaptions' | 'state' -> & { - state: RegularFacetSearchState; -}; +>; export interface RegularFacetSearchProps extends Omit< @@ -41,7 +39,7 @@ export function buildRegularFacetSearch( throw loadReducerError; } - const {showMoreResults, updateCaptions, ...restOfFacetSearch} = + const {showMoreResults, state, updateCaptions, ...restOfFacetSearch} = buildFacetSearch(engine, { ...props, executeFacetSearchActionCreator: (facetId: string) => diff --git a/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet.test.ts b/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet.test.ts index e27a5ca6f3c..3325f0dac03 100644 --- a/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet.test.ts +++ b/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet.test.ts @@ -30,6 +30,7 @@ describe('RegularFacet', () => { let state: CommerceAppState; let options: RegularFacetOptions; let facet: RegularFacet; + const facetResponseSelector = jest.fn(); function initEngine(preloadedState = buildMockCommerceState()) { engine = buildMockCommerceEngine(preloadedState); @@ -47,6 +48,9 @@ describe('RegularFacet', () => { buildMockCommerceRegularFacetResponse({facetId}), ]; state.facetSearchSet[facetId] = buildMockFacetSearch(); + facetResponseSelector.mockReturnValue( + buildMockCommerceRegularFacetResponse({facetId}) + ); } beforeEach(() => { @@ -55,7 +59,7 @@ describe('RegularFacet', () => { options = { facetId, fetchProductsActionCreator: jest.fn(), - facetResponseSelector: jest.fn(), + facetResponseSelector, isFacetLoadingResponseSelector: jest.fn(), facetSearch: {type: 'SEARCH'}, }; diff --git a/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet.ts b/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet.ts index cf917f09f1b..71d0601e993 100644 --- a/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet.ts +++ b/packages/headless/src/controllers/commerce/core/facets/regular/headless-commerce-regular-facet.ts @@ -1,13 +1,10 @@ -import {createSelector} from '@reduxjs/toolkit'; -import { - CommerceEngine, - CommerceEngineState, -} from '../../../../../app/commerce-engine/commerce-engine'; +import {CommerceEngine} from '../../../../../app/commerce-engine/commerce-engine'; import {stateKey} from '../../../../../app/state-key'; import { toggleExcludeFacetValue, toggleSelectFacetValue, } from '../../../../../features/commerce/facets/regular-facet/regular-facet-actions'; +import {specificFacetSearchStateSelector} from '../../../../../features/facets/facet-search-set/specific/specific-facet-search-state-selector'; import { CoreCommerceFacet, CoreCommerceFacetOptions, @@ -30,8 +27,12 @@ export type RegularFacetOptions = Omit< > & SearchableFacetOptions; -export type RegularFacetState = CoreCommerceFacetState & { +export type RegularFacetState = Omit< + CoreCommerceFacetState, + 'type' +> & { facetSearch: RegularFacetSearchState; + type: 'regular'; }; /** @@ -74,44 +75,46 @@ export function buildCommerceRegularFacet( }); const getFacetId = () => coreController.state.facetId; const {dispatch} = engine; - const createFacetSearch = () => { - return buildRegularFacetSearch(engine, { - options: {facetId: getFacetId(), ...options.facetSearch}, - select: () => { - dispatch(options.fetchProductsActionCreator()); - }, - exclude: () => { - dispatch(options.fetchProductsActionCreator()); - }, - isForFieldSuggestions: false, - }); - }; - const facetSearch = createFacetSearch(); - const {state, ...restOfFacetSearch} = facetSearch; - const facetSearchStateSelector = createSelector( - (state: CommerceEngineState) => state.facetSearchSet[getFacetId()], - (facetSearch) => ({ - facetSearch: { - isLoading: facetSearch.isLoading, - moreValuesAvailable: facetSearch.response.moreValuesAvailable, - query: facetSearch.options.query, - values: facetSearch.response.values, - }, - }) - ); + const facetSearch = buildRegularFacetSearch(engine, { + options: {facetId: getFacetId(), ...options.facetSearch}, + select: () => { + dispatch(options.fetchProductsActionCreator()); + }, + exclude: () => { + dispatch(options.fetchProductsActionCreator()); + }, + isForFieldSuggestions: false, + }); return { ...coreController, - facetSearch: restOfFacetSearch, + facetSearch, get state() { - return { - ...coreController.state, - ...facetSearchStateSelector(engine[stateKey]), - }; + return getRegularFacetState( + coreController.state, + specificFacetSearchStateSelector(engine[stateKey], getFacetId()) + ); }, type: 'regular', }; } + +export const getRegularFacetState = ( + coreState: CoreCommerceFacetState, + facetSearchSelector: ReturnType +): RegularFacetState => { + return { + ...coreState, + facetSearch: { + isLoading: facetSearchSelector?.isLoading ?? false, + moreValuesAvailable: + facetSearchSelector?.response.moreValuesAvailable ?? false, + query: facetSearchSelector?.options.query ?? '', + values: facetSearchSelector?.response.values ?? [], + }, + type: 'regular', + }; +}; diff --git a/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-options.ts b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-options.ts index 37ae49df5fc..5403f9a929d 100644 --- a/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-options.ts +++ b/packages/headless/src/controllers/commerce/product-listing/facets/headless-product-listing-facet-options.ts @@ -1,22 +1,29 @@ -import {isFacetResponse} from '../../../../features/commerce/facets/facet-set/facet-set-selector'; +import {createSelector} from '@reduxjs/toolkit'; import { CommerceFacetSetSection, ProductListingSection, } from '../../../../state/state-sections'; -export const facetResponseSelector = ( - state: ProductListingSection & CommerceFacetSetSection, - facetId: string -) => { - const response = state.productListing.facets.find( - (response) => response.facetId === facetId - ); - if (isFacetResponse(state, response)) { - return response; +export const facetResponseSelector = createSelector( + ( + state: ProductListingSection & CommerceFacetSetSection, + facetId: string + ) => ({state, facetId}), + + ({state, facetId}) => { + const facetResponse = state.productListing.facets.find( + (facetResponse) => facetResponse.facetId === facetId + ); + if (facetResponse && facetResponse.facetId in state.commerceFacetSet) { + return facetResponse; + } + + return undefined; } +); - return undefined; -}; +export const isFacetLoadingResponseSelector = createSelector( + (state: ProductListingSection) => ({state}), -export const isFacetLoadingResponseSelector = (state: ProductListingSection) => - state.productListing.isLoading; + ({state}) => state.productListing.isLoading +); diff --git a/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-options.ts b/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-options.ts index 6756dfcd7eb..dcc8e7a78fd 100644 --- a/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-options.ts +++ b/packages/headless/src/controllers/commerce/search/facets/headless-search-facet-options.ts @@ -1,22 +1,29 @@ -import {isFacetResponse} from '../../../../features/commerce/facets/facet-set/facet-set-selector'; +import {createSelector} from '@reduxjs/toolkit'; import { CommerceFacetSetSection, CommerceSearchSection, } from '../../../../state/state-sections'; -export const facetResponseSelector = ( - state: CommerceSearchSection & CommerceFacetSetSection, - facetId: string -) => { - const response = state.commerceSearch.facets.find( - (response) => response.facetId === facetId - ); - if (isFacetResponse(state, response)) { - return response; +export const facetResponseSelector = createSelector( + ( + state: CommerceSearchSection & CommerceFacetSetSection, + facetId: string + ) => ({state, facetId}), + + ({state, facetId}) => { + const facetResponse = state.commerceSearch.facets.find( + (facetResponse) => facetResponse.facetId === facetId + ); + if (facetResponse && facetResponse.facetId in state.commerceFacetSet) { + return facetResponse; + } + + return undefined; } +); - return undefined; -}; +export const isFacetLoadingResponseSelector = createSelector( + (state: CommerceSearchSection) => ({state}), -export const isFacetLoadingResponseSelector = (state: CommerceSearchSection) => - state.commerceSearch.isLoading; + ({state}) => state.commerceSearch.isLoading +); diff --git a/packages/headless/src/features/commerce/facets/facet-set/facet-set-selector.ts b/packages/headless/src/features/commerce/facets/facet-set/facet-set-selector.ts index 7aa48a49296..1810f46abac 100644 --- a/packages/headless/src/features/commerce/facets/facet-set/facet-set-selector.ts +++ b/packages/headless/src/features/commerce/facets/facet-set/facet-set-selector.ts @@ -1,9 +1,12 @@ +import {createSelector} from '@reduxjs/toolkit'; import {CommerceFacetSetSection} from '../../../../state/state-sections'; -import {AnyFacetResponse} from './interfaces/response'; +import {AnyFacetRequest} from './interfaces/request'; -export function isFacetResponse( - state: CommerceFacetSetSection, - response: AnyFacetResponse | undefined -): response is AnyFacetResponse { - return !!response && response.facetId in state.commerceFacetSet; -} +export const facetRequestSelector = createSelector( + (state: CommerceFacetSetSection, facetId: string) => ({ + facetRequestSelector: state.commerceFacetSet[facetId], + }), + ({facetRequestSelector}): AnyFacetRequest | undefined => { + return facetRequestSelector?.request; + } +); diff --git a/packages/headless/src/features/commerce/facets/numeric-facet/manual-numeric-facet-selectors.ts b/packages/headless/src/features/commerce/facets/numeric-facet/manual-numeric-facet-selectors.ts index 6b59ee08930..91fba6d8142 100644 --- a/packages/headless/src/features/commerce/facets/numeric-facet/manual-numeric-facet-selectors.ts +++ b/packages/headless/src/features/commerce/facets/numeric-facet/manual-numeric-facet-selectors.ts @@ -1,8 +1,8 @@ import {createSelector} from '@reduxjs/toolkit'; -import {ManualNumericFacetSetState} from './manual-numeric-facet-state'; +import {ManualRangeSection} from '../../../../state/state-sections'; -export const selectManualRange = createSelector( - (facetId: string, state?: ManualNumericFacetSetState) => - state && state[facetId]?.manualRange, +export const manualNumericFacetSelector = createSelector( + (state: ManualRangeSection, facetId: string) => + state.manualNumericFacetSet[facetId]?.manualRange, (manualRange) => manualRange ); diff --git a/packages/headless/src/features/facets/facet-search-set/category/category-facet-search-state-selector.ts b/packages/headless/src/features/facets/facet-search-set/category/category-facet-search-state-selector.ts new file mode 100644 index 00000000000..ed38c0c36a4 --- /dev/null +++ b/packages/headless/src/features/facets/facet-search-set/category/category-facet-search-state-selector.ts @@ -0,0 +1,12 @@ +import {createSelector} from '@reduxjs/toolkit'; +import {CategoryFacetSearchSection} from '../../../../state/state-sections'; +import {CategoryFacetSearchState} from './category-facet-search-set-state'; + +export const categoryFacetSearchStateSelector = createSelector( + (state: CategoryFacetSearchSection, facetId: string) => ({ + facetSearchSelector: state.categoryFacetSearchSet[facetId], + }), + ({facetSearchSelector}): CategoryFacetSearchState | undefined => { + return facetSearchSelector; + } +); diff --git a/packages/headless/src/features/facets/facet-search-set/specific/specific-facet-search-state-selector.ts b/packages/headless/src/features/facets/facet-search-set/specific/specific-facet-search-state-selector.ts new file mode 100644 index 00000000000..d913e3b7c89 --- /dev/null +++ b/packages/headless/src/features/facets/facet-search-set/specific/specific-facet-search-state-selector.ts @@ -0,0 +1,12 @@ +import {createSelector} from '@reduxjs/toolkit'; +import {FacetSearchSection} from '../../../../state/state-sections'; +import {SpecificFacetSearchState} from './specific-facet-search-set-state'; + +export const specificFacetSearchStateSelector = createSelector( + (state: FacetSearchSection, facetId: string) => ({ + facetSearchSelector: state.facetSearchSet[facetId], + }), + ({facetSearchSelector}): SpecificFacetSearchState | undefined => { + return facetSearchSelector; + } +); diff --git a/packages/headless/src/ssr-commerce.index.ts b/packages/headless/src/ssr-commerce.index.ts index ccc808e16e9..b5431cbc0f4 100644 --- a/packages/headless/src/ssr-commerce.index.ts +++ b/packages/headless/src/ssr-commerce.index.ts @@ -59,6 +59,24 @@ export type { } from './controllers/commerce/search/did-you-mean/headless-did-you-mean.ssr'; export {defineDidYouMean} from './controllers/commerce/search/did-you-mean/headless-did-you-mean.ssr'; +export type { + BaseFacetSearchResult, + CategoryFacet, + CategoryFacetState, + CategoryFacetValue, + CategoryFacetSearchResult, + DateFacet, + DateFacetState, + FacetGenerator, + FacetGeneratorState, + NumericFacet, + NumericFacetState, + RegularFacet, + RegularFacetState, + RegularFacetValue, +} from './controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr'; +export {defineFacetGenerator} from './controllers/commerce/core/facets/generator/headless-commerce-facet-generator.ssr'; + export type { Pagination, PaginationProps, diff --git a/packages/headless/src/test/mock-engine-v2.ts b/packages/headless/src/test/mock-engine-v2.ts index 61a2c7359be..b8c5f2005c1 100644 --- a/packages/headless/src/test/mock-engine-v2.ts +++ b/packages/headless/src/test/mock-engine-v2.ts @@ -2,6 +2,7 @@ import {Relay} from '@coveo/relay'; import pino, {Logger} from 'pino'; import {CaseAssistEngine} from '../app/case-assist-engine/case-assist-engine'; import {CommerceEngine} from '../app/commerce-engine/commerce-engine'; +import {SSRCommerceEngine} from '../app/commerce-engine/commerce-engine.ssr'; import type {CoreEngine, CoreEngineNext} from '../app/engine'; import {InsightEngine} from '../app/insight-engine/insight-engine'; import {defaultNodeJSNavigatorContextProvider} from '../app/navigatorContextProvider'; @@ -195,3 +196,13 @@ export function buildMockSSRSearchEngine( waitForSearchCompletedAction: jest.fn(), }; } + +export function buildMockSSRCommerceEngine( + initialState: StateFromEngineNext +): SSRCommerceEngine { + const engine = buildMockCommerceEngine(initialState); + return { + ...engine, + waitForRequestCompletedAction: jest.fn(), + }; +} diff --git a/packages/samples/headless-ssr-commerce/app/_components/facets/category-facet.tsx b/packages/samples/headless-ssr-commerce/app/_components/facets/category-facet.tsx new file mode 100644 index 00000000000..90c499d7629 --- /dev/null +++ b/packages/samples/headless-ssr-commerce/app/_components/facets/category-facet.tsx @@ -0,0 +1,300 @@ +import { + CategoryFacetSearchResult, + CategoryFacetState, + CategoryFacetValue, + CategoryFacet as HeadlessCategoryFacet, +} from '@coveo/headless/ssr-commerce'; +import {useEffect, useRef, useState} from 'react'; + +interface ICategoryFacetProps { + controller?: HeadlessCategoryFacet; + staticState: CategoryFacetState; +} + +export default function CategoryFacet(props: ICategoryFacetProps) { + const {controller, staticState} = props; + + const [state, setState] = useState(staticState); + const [showFacetSearchResults, setShowFacetSearchResults] = useState(false); + + const facetSearchInputRef = useRef(null); + + useEffect(() => { + controller?.subscribe(() => setState(controller.state)); + }, [controller]); + + const focusFacetSearchInput = (): void => { + facetSearchInputRef.current!.focus(); + }; + + const onChangeFacetSearchInput = ( + e: React.ChangeEvent + ): void => { + if (e.target.value === '') { + setShowFacetSearchResults(false); + controller?.facetSearch.clear(); + return; + } + + controller?.facetSearch.updateText(e.target.value); + controller?.facetSearch.search(); + setShowFacetSearchResults(true); + }; + + const onClickClearFacetSearch = (): void => { + setShowFacetSearchResults(false); + controller?.facetSearch.clear(); + focusFacetSearchInput(); + }; + + const highlightFacetSearchResult = (displayValue: string): string => { + const query = state.facetSearch.query; + const regex = new RegExp(query, 'gi'); + return displayValue.replace(regex, (match) => `${match}`); + }; + + const onClickFacetSearchResult = (value: CategoryFacetSearchResult): void => { + controller?.facetSearch.select(value); + controller?.facetSearch.clear(); + setShowFacetSearchResults(false); + focusFacetSearchInput(); + }; + + const onClickClearSelectedFacetValues = (): void => { + controller?.deselectAll(); + focusFacetSearchInput(); + }; + + const toggleSelectFacetValue = (value: CategoryFacetValue) => { + if (controller?.isValueSelected(value)) { + controller.deselectAll(); + } + controller?.toggleSelect(value); + }; + + const renderFacetSearchControls = () => { + return ( + + + + + {state.facetSearch.isLoading && ( + + {' '} + Facet search is loading... + + )} + + ); + }; + + const renderFacetSearchResults = () => { + return state.facetSearch.values.length === 0 ? ( + + No results for {state.facetSearch.query} + + ) : ( +
    + {state.facetSearch.values.map((value) => ( +
  • onClickFacetSearchResult(value)} + style={{width: 'fit-content'}} + > + + + + {' '} + ({value.count}) + +
  • + ))} +
+ ); + }; + + const renderActiveFacetValueTree = () => { + if (!state.hasActiveValues) { + return null; + } + + const ancestry = state.selectedValueAncestry!; + const activeValueChildren = ancestry[ancestry.length - 1]?.children ?? []; + + return ( +
    + {ancestry.map((ancestryValue) => { + const checkboxId = `ancestryFacetValueCheckbox-${ancestryValue.value}`; + return ( +
  • + toggleSelectFacetValue(ancestryValue)} + type="checkbox" + > + +
  • + ); + })} + {activeValueChildren.length > 0 && ( +
      + {activeValueChildren.map((child) => { + const checkboxId = `facetValueChildCheckbox-${child.value}`; + return ( +
    • + toggleSelectFacetValue(child)} + > + +
    • + ); + })} +
    + )} +
+ ); + }; + + const renderRootValues = () => { + if (state.hasActiveValues) { + return null; + } + + return ( +
    + {state.values.map((root) => { + return ( +
  • + toggleSelectFacetValue(root)} + > + + + {' '} + ({root.numberOfResults}) + +
  • + ); + })} +
+ ); + }; + + const renderFacetValues = () => { + return ( +
+ + {state.isLoading && ( + Facet is loading... + )} + {renderRootValues()} + {renderActiveFacetValueTree()} + + +
+ ); + }; + + return ( +
+ + {state.displayName ?? state.facetId} + + {renderFacetSearchControls()} + {showFacetSearchResults + ? renderFacetSearchResults() + : renderFacetValues()} +
+ ); +} diff --git a/packages/samples/headless-ssr-commerce/app/_components/facets/date-facet.tsx b/packages/samples/headless-ssr-commerce/app/_components/facets/date-facet.tsx new file mode 100644 index 00000000000..e6e944c7148 --- /dev/null +++ b/packages/samples/headless-ssr-commerce/app/_components/facets/date-facet.tsx @@ -0,0 +1,84 @@ +import { + DateFacetState, + DateFacet as HeadlessDateFacet, +} from '@coveo/headless/ssr-commerce'; +import {useEffect, useState} from 'react'; + +interface IDateFacetProps { + controller?: HeadlessDateFacet; + staticState: DateFacetState; +} + +export default function DateFacet(props: IDateFacetProps) { + const {controller, staticState} = props; + + const [state, setState] = useState(staticState); + + useEffect(() => { + controller?.subscribe(() => setState(controller.state)); + }, [controller]); + + const renderFacetValues = () => { + return ( +
    + {state.values.map((value) => { + const id = `${value.start}-${value.end}-${value.endInclusive}`; + return ( +
  • + controller?.toggleSelect(value)} + type="checkbox" + > + +
  • + ); + })} +
+ ); + }; + + return ( +
+ + {state.displayName ?? state.facetId} + + + {state.isLoading && ( + Facet is loading... + )} + {renderFacetValues()} + + +
+ ); +} diff --git a/packages/samples/headless-ssr-commerce/app/_components/facets/facet-generator.tsx b/packages/samples/headless-ssr-commerce/app/_components/facets/facet-generator.tsx new file mode 100644 index 00000000000..4c0ce296bf0 --- /dev/null +++ b/packages/samples/headless-ssr-commerce/app/_components/facets/facet-generator.tsx @@ -0,0 +1,81 @@ +import { + FacetGenerator as HeadlessFacetGenerator, + FacetGeneratorState, +} from '@coveo/headless/ssr-commerce'; +import {useEffect, useState} from 'react'; +import CategoryFacet from './category-facet'; +import DateFacet from './date-facet'; +import NumericFacet from './numeric-facet'; +import RegularFacet from './regular-facet'; + +interface IFacetGeneratorProps { + controller?: HeadlessFacetGenerator; + staticState: FacetGeneratorState; +} + +export default function FacetGenerator(props: IFacetGeneratorProps) { + const {controller, staticState} = props; + + const [state, setState] = useState(staticState); + + useEffect(() => { + controller?.subscribe(() => { + setState(controller.state); + }); + }, [controller]); + + return ( + + ); +} diff --git a/packages/samples/headless-ssr-commerce/app/_components/facets/numeric-facet.tsx b/packages/samples/headless-ssr-commerce/app/_components/facets/numeric-facet.tsx new file mode 100644 index 00000000000..7de9f474815 --- /dev/null +++ b/packages/samples/headless-ssr-commerce/app/_components/facets/numeric-facet.tsx @@ -0,0 +1,209 @@ +import { + NumericFacet as HeadlessNumericFacet, + NumericFacetState, +} from '@coveo/headless/ssr-commerce'; +import {useEffect, useRef, useState} from 'react'; + +interface INumericFacetProps { + controller?: HeadlessNumericFacet; + staticState: NumericFacetState; +} + +export default function NumericFacet(props: INumericFacetProps) { + const {controller, staticState} = props; + + const [state, setState] = useState(staticState); + const [currentManualRange, setCurrentManualRange] = useState({ + start: + controller?.state.manualRange?.start ?? + controller?.state.domain?.min ?? + controller?.state.values[0]?.start ?? + 0, + end: + controller?.state.manualRange?.end ?? + controller?.state.domain?.max ?? + controller?.state.values[0]?.end ?? + 0, + }); + + const manualRangeStartInputRef = useRef(null); + + useEffect(() => { + controller?.subscribe(() => { + setState(controller.state), + setCurrentManualRange({ + start: + controller.state.manualRange?.start ?? + controller.state.domain?.min ?? + controller.state.values[0]?.start ?? + 0, + end: + controller.state.manualRange?.end ?? + controller.state.domain?.max ?? + controller.state.values[0]?.end ?? + 0, + }); + }); + }, [controller]); + + const focusManualRangeStartInput = (): void => { + manualRangeStartInputRef.current!.focus(); + }; + + const invalidRange = + currentManualRange.start >= currentManualRange.end || + isNaN(currentManualRange.start) || + isNaN(currentManualRange.end); + + const onChangeManualRangeStart = (e: React.ChangeEvent) => { + setCurrentManualRange({ + start: Number.parseInt(e.target.value), + end: currentManualRange.end, + }); + }; + + const onChangeManualRangeEnd = (e: React.ChangeEvent) => { + setCurrentManualRange({ + start: currentManualRange.start, + end: Number.parseInt(e.target.value), + }); + }; + + const onClickManualRangeSelect = () => { + const start = + state.domain && currentManualRange.start < state.domain.min + ? state.domain.min + : currentManualRange.start; + const end = + state.domain && currentManualRange.end > state.domain.max + ? state.domain.max + : currentManualRange.end; + controller?.setRanges([ + { + start, + end, + endInclusive: true, + state: 'selected', + }, + ]); + focusManualRangeStartInput(); + }; + + const onClickClearSelectedFacetValues = (): void => { + controller?.deselectAll(); + focusManualRangeStartInput(); + }; + + const renderManualRangeControls = () => { + return ( +
+ + + + + +
+ ); + }; + + const renderFacetValues = () => { + return ( +
+ + {state.isLoading && Facet is loading...} +
    + {state.values.map((value, index) => { + const checkboxId = `${value.start}-${value.end}-${value.endInclusive}`; + return ( +
  • + controller?.toggleSelect(value)} + type="checkbox" + > + +
  • + ); + })} +
+ + +
+ ); + }; + + return ( +
+ + {state.displayName ?? state.facetId} + + {renderManualRangeControls()} + {renderFacetValues()} +
+ ); +} diff --git a/packages/samples/headless-ssr-commerce/app/_components/facets/regular-facet.tsx b/packages/samples/headless-ssr-commerce/app/_components/facets/regular-facet.tsx new file mode 100644 index 00000000000..c071b865cbb --- /dev/null +++ b/packages/samples/headless-ssr-commerce/app/_components/facets/regular-facet.tsx @@ -0,0 +1,218 @@ +import { + BaseFacetSearchResult, + RegularFacet as HeadlessRegularFacet, + RegularFacetState, + RegularFacetValue, +} from '@coveo/headless/ssr-commerce'; +import {useEffect, useRef, useState} from 'react'; + +interface IRegularFacetProps { + controller?: HeadlessRegularFacet; + staticState: RegularFacetState; +} + +export default function RegularFacet(props: IRegularFacetProps) { + const {controller, staticState} = props; + + const [state, setState] = useState(staticState); + const [showFacetSearchResults, setShowFacetSearchResults] = useState(false); + + const facetSearchInputRef = useRef(null); + + useEffect(() => { + controller?.subscribe(() => setState(controller.state)); + }, [controller]); + + const focusFacetSearchInput = (): void => { + facetSearchInputRef.current!.focus(); + }; + + const onChangeFacetSearchInput = ( + e: React.ChangeEvent + ): void => { + if (e.target.value === '') { + setShowFacetSearchResults(false); + controller?.facetSearch.clear(); + return; + } + + controller?.facetSearch.updateText(e.target.value); + controller?.facetSearch.search(); + setShowFacetSearchResults(true); + }; + + const highlightFacetSearchResult = (displayValue: string): string => { + const query = state.facetSearch.query; + const regex = new RegExp(query, 'gi'); + return displayValue.replace(regex, (match) => `${match}`); + }; + + const onClickFacetSearchResult = (value: BaseFacetSearchResult): void => { + controller?.facetSearch.select(value); + controller?.facetSearch.clear(); + setShowFacetSearchResults(false); + focusFacetSearchInput(); + }; + + const onClickFacetSearchClear = (): void => { + setShowFacetSearchResults(false); + controller?.facetSearch.clear(); + focusFacetSearchInput(); + }; + + const onClickClearSelectedFacetValues = (): void => { + controller?.deselectAll(); + focusFacetSearchInput(); + }; + + const onChangeFacetValue = (facetValue: RegularFacetValue): void => { + controller?.toggleSelect(facetValue); + focusFacetSearchInput(); + }; + + const renderFacetSearchControls = () => { + return ( + + + + + {state.facetSearch.isLoading && ( + + {' '} + Facet search is loading... + + )} + + ); + }; + + const renderFacetSearchResults = () => { + return state.facetSearch.values.length === 0 ? ( + + No results for {state.facetSearch.query} + + ) : ( +
    + {state.facetSearch.values.map((value) => ( +
  • onClickFacetSearchResult(value)} + style={{width: 'fit-content'}} + > + + + + {' '} + ({value.count}) + +
  • + ))} +
+ ); + }; + + const renderFacetValues = () => { + return ( +
+ + {state.isLoading && ( + Facet is loading... + )} +
    + {state.values.map((value) => ( +
  • + onChangeFacetValue(value)} + type="checkbox" + > + +
  • + ))} +
+ + +
+ ); + }; + + return ( +
+ + {state.displayName ?? state.facetId} + + {renderFacetSearchControls()} + {showFacetSearchResults + ? renderFacetSearchResults() + : renderFacetValues()} +
+ ); +} diff --git a/packages/samples/headless-ssr-commerce/app/_components/pages/listing-page.tsx b/packages/samples/headless-ssr-commerce/app/_components/pages/listing-page.tsx index 416ea0f8409..41def63c9bc 100644 --- a/packages/samples/headless-ssr-commerce/app/_components/pages/listing-page.tsx +++ b/packages/samples/headless-ssr-commerce/app/_components/pages/listing-page.tsx @@ -8,6 +8,7 @@ import { ListingStaticState, } from '../../_lib/commerce-engine'; import Cart from '../cart'; +import FacetGenerator from '../facets/facet-generator'; import Pagination from '../pagination'; import ProductList from '../product-list'; import Sort from '../sort'; @@ -52,6 +53,10 @@ export default function ListingPage({ } instantProductsController={hydratedState?.controllers.instantProducts} /> + + Date: Tue, 27 Aug 2024 12:44:05 -0400 Subject: [PATCH 03/10] feat(headless commerce): add totalCount to InstantProducts controller state (#4321) https://coveord.atlassian.net/browse/KIT-3491 Co-authored-by: Louis Bompart --- .../commerce/instant-products/headless-instant-products.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/headless/src/controllers/commerce/instant-products/headless-instant-products.ts b/packages/headless/src/controllers/commerce/instant-products/headless-instant-products.ts index 55bbe7a38d6..bf5f0d52c19 100644 --- a/packages/headless/src/controllers/commerce/instant-products/headless-instant-products.ts +++ b/packages/headless/src/controllers/commerce/instant-products/headless-instant-products.ts @@ -112,6 +112,10 @@ export interface InstantProductsState { * An error returned when executing an instant products request, if any. This is `null` otherwise. */ error: CommerceAPIErrorResponse | SerializedError | null; + /** + * The total number of products that match the current query. + */ + totalCount: number; } /** @@ -220,6 +224,7 @@ export function buildInstantProducts( isLoading: cached?.isLoading || false, error: cached?.error || null, products: getProducts(), + totalCount: cached?.totalCountFiltered || 0, }; }, }; From e5961ca33490499a2916bb9eb8818d360ff85c18 Mon Sep 17 00:00:00 2001 From: Louis Bompart Date: Tue, 27 Aug 2024 15:30:12 -0400 Subject: [PATCH 04/10] feat(atomic): allow user to customize grid card click behavior/links (#4287) Fixes #4267 https://coveord.atlassian.net/browse/KIT-3470 --------- Co-authored-by: GitHub Actions Bot <> --- .prettierignore | 3 +- .../atomic-angular.module.ts | 2 + .../src/lib/stencil-generated/components.ts | 27 ++- .../commerce/CommerceProductListWrapper.tsx | 38 +++- .../CommerceRecommendationListWrapper.tsx | 38 +++- .../recommendation/RecsListWrapper.tsx | 31 +++- .../components/search/ResultListWrapper.tsx | 31 +++- .../result-components/result-link.cypress.ts | 2 +- .../result-text-selectors.ts | 2 +- packages/atomic/src/components.d.ts | 50 ++++- ...omic-commerce-product-list.new.stories.tsx | 3 - .../atomic-commerce-product-list.tsx | 34 ++-- .../e2e/atomic-commerce-product-list.e2e.ts | 7 +- .../atomic-commerce-recommendation-list.tsx | 32 ++-- .../atomic-product/atomic-product.tsx | 38 +++- .../product-list/product-template-provider.ts | 73 +++++--- .../atomic-product-template.tsx | 1 + .../product-template-common.tsx | 26 ++- ...c-commerce-search-box-instant-products.tsx | 4 +- .../common/item-list/display-grid.tsx | 30 +-- .../common/item-list/item-list-common.tsx | 6 +- .../item-list/item-template-provider.ts | 35 +++- .../result-template-common.tsx | 30 ++- .../template-provider/template-provider.ts | 33 ++-- .../template-system/template-system.pcss | 4 + .../atomic-recs-list/atomic-ipx-recs-list.tsx | 33 ++-- .../atomic-recs-list/atomic-recs-list.tsx | 33 ++-- .../search/atomic-result/atomic-result.tsx | 38 +++- .../atomic-result-list/atomic-result-list.tsx | 35 ++-- .../result-template-decorators.spec.tsx | 6 - .../atomic-result-template.tsx | 3 +- .../atomic-search-box-instant-results.tsx | 4 +- packages/atomic/src/pages/index.html | 1 + packages/atomic/src/utils/dom-utils.tsx | 6 + .../product-templates-manager.ts | 17 +- .../result-templates-manager.ts | 27 ++- .../features/templates/templates-manager.ts | 41 ++++- .../atomic-react/src/pages/ResultListPage.tsx | 173 ++++++++---------- 38 files changed, 678 insertions(+), 319 deletions(-) diff --git a/.prettierignore b/.prettierignore index 1bbac2f56e2..26b8de76282 100644 --- a/.prettierignore +++ b/.prettierignore @@ -22,4 +22,5 @@ packages/atomic/src/external-builds/**/* packages/atomic/src/generated/** packages/quantic/docs/out/quantic-docs.json packages/samples/headless-react/build/**/* -packages/samples/angular/src/lang/*.json \ No newline at end of file +packages/samples/angular/src/lang/*.json +packages/samples/vuejs/public/lang/*.json \ No newline at end of file diff --git a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts index 64a2bbc46c5..cd71a0561c3 100644 --- a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts +++ b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/atomic-angular.module.ts @@ -17,6 +17,7 @@ AtomicCommerceFacets, AtomicCommerceInterface, AtomicCommerceLoadMoreProducts, AtomicCommercePager, +AtomicCommerceProductList, AtomicCommerceProductsPerPage, AtomicCommerceQuerySummary, AtomicCommerceRefineModal, @@ -128,6 +129,7 @@ AtomicCommerceFacets, AtomicCommerceInterface, AtomicCommerceLoadMoreProducts, AtomicCommercePager, +AtomicCommerceProductList, AtomicCommerceProductsPerPage, AtomicCommerceQuerySummary, AtomicCommerceRefineModal, diff --git a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts index 1fdba07a8bb..f828f2684bd 100644 --- a/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts +++ b/packages/atomic-angular/projects/atomic-angular/src/lib/stencil-generated/components.ts @@ -274,6 +274,29 @@ export declare interface AtomicCommercePager extends Components.AtomicCommercePa } +@ProxyCmp({ + inputs: ['density', 'display', 'gridCellLinkTarget', 'imageSize', 'numberOfPlaceholders'], + methods: ['setRenderFunction'] +}) +@Component({ + selector: 'atomic-commerce-product-list', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['density', 'display', 'gridCellLinkTarget', 'imageSize', 'numberOfPlaceholders'], +}) +export class AtomicCommerceProductList { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface AtomicCommerceProductList extends Components.AtomicCommerceProductList {} + + @ProxyCmp({ inputs: ['choicesDisplayed', 'initialChoice'] }) @@ -1279,14 +1302,14 @@ export declare interface AtomicRelevanceInspector extends Components.AtomicRelev @ProxyCmp({ - inputs: ['classes', 'content', 'density', 'display', 'imageSize', 'result', 'stopPropagation'] + inputs: ['classes', 'content', 'density', 'display', 'imageSize', 'linkContent', 'result', 'stopPropagation'] }) @Component({ selector: 'atomic-result', changeDetection: ChangeDetectionStrategy.OnPush, template: '', // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['classes', 'content', 'density', 'display', 'imageSize', 'result', 'stopPropagation'], + inputs: ['classes', 'content', 'density', 'display', 'imageSize', 'linkContent', 'result', 'stopPropagation'], }) export class AtomicResult { protected el: HTMLElement; diff --git a/packages/atomic-react/src/components/commerce/CommerceProductListWrapper.tsx b/packages/atomic-react/src/components/commerce/CommerceProductListWrapper.tsx index d10f7441ca3..4769ae01562 100644 --- a/packages/atomic-react/src/components/commerce/CommerceProductListWrapper.tsx +++ b/packages/atomic-react/src/components/commerce/CommerceProductListWrapper.tsx @@ -3,7 +3,15 @@ import type {Product} from '@coveo/headless/commerce'; import React, {useEffect, useRef} from 'react'; import {createRoot} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; -import {AtomicCommerceProductList} from '../stencil-generated/commerce'; +import { + AtomicCommerceProductList, + AtomicProductLink, +} from '../stencil-generated/commerce'; + +interface Template { + contentTemplate: JSX.Element; + linkTemplate: JSX.Element; +} /** * The properties of the AtomicCommerceProductList component @@ -13,7 +21,7 @@ interface WrapperProps extends AtomicJSX.AtomicCommerceProductList { * A template function that takes a result item and outputs its target rendering as a JSX element. * It can be used to conditionally render different type of result templates based on the properties of each result. */ - template: (result: Product) => JSX.Element; + template: (result: Product) => JSX.Element | Template; } /** @@ -27,12 +35,30 @@ export const ListWrapper: React.FC = (props) => { const commerceProductListRef = useRef(null); useEffect(() => { - commerceProductListRef.current?.setRenderFunction((result, root) => { - createRoot(root).render(template(result as Product)); - return renderToString(template(result as Product)); - }); + commerceProductListRef.current?.setRenderFunction( + (product, root, linkContainer) => { + const templateResult = template(product as Product); + if (hasLinkTemplate(templateResult)) { + createRoot(linkContainer!).render(templateResult.linkTemplate); + createRoot(root).render(templateResult.contentTemplate); + return renderToString(templateResult.contentTemplate); + } else { + createRoot(root).render(templateResult); + createRoot(linkContainer!).render( + + ); + return renderToString(templateResult); + } + } + ); }, [commerceProductListRef]); return ( ); }; + +const hasLinkTemplate = ( + template: JSX.Element | Template +): template is Template => { + return (template as Template).linkTemplate !== undefined; +}; diff --git a/packages/atomic-react/src/components/commerce/CommerceRecommendationListWrapper.tsx b/packages/atomic-react/src/components/commerce/CommerceRecommendationListWrapper.tsx index 8bc4b731be8..dd9b5de1d80 100644 --- a/packages/atomic-react/src/components/commerce/CommerceRecommendationListWrapper.tsx +++ b/packages/atomic-react/src/components/commerce/CommerceRecommendationListWrapper.tsx @@ -3,7 +3,15 @@ import type {Product} from '@coveo/headless/commerce'; import React, {useEffect, useRef} from 'react'; import {createRoot} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; -import {AtomicCommerceRecommendationList} from '../stencil-generated/commerce'; +import { + AtomicCommerceRecommendationList, + AtomicProductLink, +} from '../stencil-generated/commerce'; + +interface Template { + contentTemplate: JSX.Element; + linkTemplate: JSX.Element; +} /** * The properties of the AtomicCommerceRecommendationList component @@ -13,7 +21,7 @@ interface WrapperProps extends AtomicJSX.AtomicCommerceRecommendationList { * A template function that takes a result item and outputs its target rendering as a JSX element. * It can be used to conditionally render different type of result templates based on the properties of each result. */ - template: (result: Product) => JSX.Element; + template: (result: Product) => JSX.Element | Template; } /** @@ -27,10 +35,22 @@ export const ListWrapper: React.FC = (props) => { const commerceRecsListRef = useRef(null); useEffect(() => { - commerceRecsListRef.current?.setRenderFunction((result, root) => { - createRoot(root).render(template(result as Product)); - return renderToString(template(result as Product)); - }); + commerceRecsListRef.current?.setRenderFunction( + (product, root, linkContainer) => { + const templateResult = template(product as Product); + if (hasLinkTemplate(templateResult)) { + createRoot(linkContainer!).render(templateResult.linkTemplate); + createRoot(root).render(templateResult.contentTemplate); + return renderToString(templateResult.contentTemplate); + } else { + createRoot(root).render(templateResult); + createRoot(linkContainer!).render( + + ); + return renderToString(templateResult); + } + } + ); }, [commerceRecsListRef]); return ( = (props) => { /> ); }; + +const hasLinkTemplate = ( + template: JSX.Element | Template +): template is Template => { + return (template as Template).linkTemplate !== undefined; +}; diff --git a/packages/atomic-react/src/components/recommendation/RecsListWrapper.tsx b/packages/atomic-react/src/components/recommendation/RecsListWrapper.tsx index 43a594887cd..21f610cae81 100644 --- a/packages/atomic-react/src/components/recommendation/RecsListWrapper.tsx +++ b/packages/atomic-react/src/components/recommendation/RecsListWrapper.tsx @@ -3,7 +3,12 @@ import type {Result} from '@coveo/headless/recommendation'; import React, {useEffect, useRef} from 'react'; import {createRoot} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; -import {AtomicRecsList} from '../stencil-generated/search'; +import {AtomicRecsList, AtomicResultLink} from '../stencil-generated/search'; + +interface Template { + contentTemplate: JSX.Element; + linkTemplate: JSX.Element; +} /** * The properties of the AtomicRecsList component @@ -13,7 +18,7 @@ interface WrapperProps extends AtomicJSX.AtomicRecsList { * A template function that takes a result item and outputs its target rendering as a JSX element. * It can be used to conditionally render different type of result templates based on the properties of each result. */ - template: (result: Result) => JSX.Element; + template: (result: Result) => JSX.Element | Template; } /** @@ -26,10 +31,26 @@ export const RecsListWrapper: React.FC = (props) => { const {template, ...otherProps} = props; const recsListRef = useRef(null); useEffect(() => { - recsListRef.current?.setRenderFunction((result, root) => { - createRoot(root).render(template(result as Result)); - return renderToString(template(result as Result)); + recsListRef.current?.setRenderFunction((result, root, linkContainer) => { + const templateResult = template(result as Result); + if (hasLinkTemplate(templateResult)) { + createRoot(linkContainer!).render(templateResult.linkTemplate); + createRoot(root).render(templateResult.contentTemplate); + return renderToString(templateResult.contentTemplate); + } else { + createRoot(root).render(templateResult); + createRoot(linkContainer!).render( + + ); + return renderToString(templateResult); + } }); }, [recsListRef]); return ; }; + +const hasLinkTemplate = ( + template: JSX.Element | Template +): template is Template => { + return (template as Template).linkTemplate !== undefined; +}; diff --git a/packages/atomic-react/src/components/search/ResultListWrapper.tsx b/packages/atomic-react/src/components/search/ResultListWrapper.tsx index e685d9ba2fe..183c1489a04 100644 --- a/packages/atomic-react/src/components/search/ResultListWrapper.tsx +++ b/packages/atomic-react/src/components/search/ResultListWrapper.tsx @@ -3,7 +3,12 @@ import type {Result} from '@coveo/headless'; import React, {useEffect, useRef} from 'react'; import {createRoot} from 'react-dom/client'; import {renderToString} from 'react-dom/server'; -import {AtomicResultList} from '../stencil-generated/search'; +import {AtomicResultLink, AtomicResultList} from '../stencil-generated/search'; + +interface Template { + contentTemplate: JSX.Element; + linkTemplate: JSX.Element; +} /** * The properties of the AtomicResultList component @@ -13,7 +18,7 @@ interface WrapperProps extends AtomicJSX.AtomicResultList { * A template function that takes a result item and outputs its target rendering as a JSX element. * It can be used to conditionally render different type of result templates based on the properties of each result. */ - template: (result: Result) => JSX.Element; + template: (result: Result) => JSX.Element | Template; } /** @@ -26,10 +31,26 @@ export const ResultListWrapper: React.FC = (props) => { const {template, ...otherProps} = props; const resultListRef = useRef(null); useEffect(() => { - resultListRef.current?.setRenderFunction((result, root) => { - createRoot(root).render(template(result as Result)); - return renderToString(template(result as Result)); + resultListRef.current?.setRenderFunction((result, root, linkContainer) => { + const templateResult = template(result as Result); + if (hasLinkTemplate(templateResult)) { + createRoot(linkContainer!).render(templateResult.linkTemplate); + createRoot(root).render(templateResult.contentTemplate); + return renderToString(templateResult.contentTemplate); + } else { + createRoot(root).render(templateResult); + createRoot(linkContainer!).render( + + ); + return renderToString(templateResult); + } }); }, [resultListRef]); return ; }; + +const hasLinkTemplate = ( + template: JSX.Element | Template +): template is Template => { + return (template as Template).linkTemplate !== undefined; +}; diff --git a/packages/atomic/cypress/e2e/result-list/result-components/result-link.cypress.ts b/packages/atomic/cypress/e2e/result-list/result-components/result-link.cypress.ts index 1f4ece4229b..23cdfea9cdb 100644 --- a/packages/atomic/cypress/e2e/result-list/result-components/result-link.cypress.ts +++ b/packages/atomic/cypress/e2e/result-list/result-components/result-link.cypress.ts @@ -81,7 +81,7 @@ describe('Result Link Component', () => { }); it('should render an "atomic-result-text" component containing the title', () => { - ResultLinkSelectors.firstInResult().should('have.text', title); + ResultLinkSelectors.firstInResult().first().should('have.text', title); }); }); diff --git a/packages/atomic/cypress/e2e/result-list/result-components/result-text-selectors.ts b/packages/atomic/cypress/e2e/result-list/result-components/result-text-selectors.ts index 4806a1cdd37..bd24d7265e1 100644 --- a/packages/atomic/cypress/e2e/result-list/result-components/result-text-selectors.ts +++ b/packages/atomic/cypress/e2e/result-list/result-components/result-text-selectors.ts @@ -5,6 +5,6 @@ export const resultTextComponent = 'atomic-result-text'; export const ResultTextSelectors = { shadow: () => cy.get(resultTextComponent), firstInResult: () => - ResultListSelectors.firstResult().find(resultTextComponent), + ResultListSelectors.firstResult().find(resultTextComponent).first(), highlight: () => ResultTextSelectors.firstInResult().find('b'), }; diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index c2bc9d757d5..f0a27c8119d 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -479,6 +479,9 @@ export namespace Components { */ "previousButtonIcon": string; } + /** + * @alpha The `atomic-commerce-product-list` component is responsible for displaying products. + */ interface AtomicCommerceProductList { /** * The spacing of various elements in the product list, including the gap between products, the gap between parts of a product, and the font sizes of different parts in a product. @@ -491,6 +494,7 @@ export namespace Components { /** * The target location to open the product link (see [target](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target)). This property is only leveraged when `display` is `grid`. * @defaultValue `_self` + * @deprecated - Instead of using this property, provide an `atomic-product-link` in the `link` slot of the `atomic-product-template` component. */ "gridCellLinkTarget": ItemTarget; /** @@ -593,6 +597,7 @@ export namespace Components { /** * The [target](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target) location to open the product link. This property is ignored unless the `display` property is set to `grid`. * @defaultValue `_self` + * @deprecated - Instead of using this property, provide an `atomic-product-link` in the `link` slot of the `atomic-product-template` component. */ "gridCellLinkTarget": ItemTarget; /** @@ -1638,6 +1643,7 @@ export namespace Components { /** * The target location to open the result link (see [target](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target)). This property is only leveraged when `display` is `grid`. * @defaultValue `_self` + * @deprecated - Instead of using this property, provide an `atomic-result-link` in the `link` slot of the `atomic-result-template` component. */ "gridCellLinkTarget": ItemTarget1; /** @@ -1929,6 +1935,11 @@ export namespace Components { * The InteractiveProduct item. */ "interactiveProduct": InteractiveProduct; + /** + * The product link to use when the product is clicked in a grid layout. + * @default - An `atomic-result-link` without any customization. + */ + "linkContent": ParentNode; "loadingFlag"?: string; /** * The product item. @@ -2373,6 +2384,7 @@ export namespace Components { /** * The target location to open the result link (see [target](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target)). This property is only leveraged when `display` is `grid`. * @defaultValue `_self` + * @deprecated - Instead of using this property, provide an `atomic-result-link` in the `link` slot of the `atomic-result-template` component. */ "gridCellLinkTarget": ItemTarget; /** @@ -2544,6 +2556,11 @@ export namespace Components { * The InteractiveResult item. */ "interactiveResult": InteractiveResult; + /** + * The result link to use when the result is clicked in a grid layout. + * @default - An `atomic-result-link` without any customization. + */ + "linkContent": ParentNode; "loadingFlag"?: string; /** * Internal function used by atomic-recs-list in advanced setups, which lets you bypass the standard HTML template system. Particularly useful for Atomic React @@ -2723,6 +2740,7 @@ export namespace Components { /** * The target location to open the result link (see [target](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target)). This property is only leveraged when `display` is `grid`. * @defaultValue `_self` + * @deprecated - Instead of using this property, provide an `atomic-result-link` in the `link` slot of the `atomic-result-template` component. */ "gridCellLinkTarget": ItemTarget; /** @@ -2923,8 +2941,6 @@ export namespace Components { } /** * A [result template](https://docs.coveo.com/en/atomic/latest/usage/displaying-results#defining-a-result-template) determines the format of the query results, depending on the conditions that are defined for each template. - * A `template` element must be the child of an `atomic-result-template`, and either an `atomic-result-list` or `atomic-folded-result-list` must be the parent of each `atomic-result-template`. - * **Note:** Any `