Skip to content

Commit

Permalink
feat(headless commerce ssr): add SSR FacetGenerator (#4290)
Browse files Browse the repository at this point in the history
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 #4274 and
#4273, so if you've already reviewed
these PRs, you can probably skim over those.

---------

Co-authored-by: ylakhdar <ylakhdar@coveo.com>
Co-authored-by: Alex Prudhomme <78121423+alexprudhomme@users.noreply.github.com>
  • Loading branch information
3 people authored Aug 27, 2024
1 parent ce9f0be commit 3f957f9
Show file tree
Hide file tree
Showing 30 changed files with 1,607 additions and 188 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"bloup",
"bpsb",
"btoashim",
"CAPI",
"cfcomment",
"cfpage",
"cfspace",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,7 @@ export class AtomicCommerceFacets implements InitializableComponent<Bindings> {
></atomic-commerce-category-facet>
);
default: {
this.bindings.engine.logger.warn(
`Unexpected facet type ${facet.state.type}.`
);
this.bindings.engine.logger.warn('Unexpected facet type.');
return;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,7 @@ export type CategoryFacetSearchState =
export type CategoryFacetSearch = Omit<
ReturnType<typeof buildCoreCategoryFacetSearch>,
'showMoreResults' | 'updateCaptions' | 'state'
> & {
state: CategoryFacetSearchState;
};
>;

export function buildCategoryFacetSearch(
engine: CommerceEngine,
Expand All @@ -40,7 +38,7 @@ export function buildCategoryFacetSearch(
throw loadReducerError;
}

const {showMoreResults, updateCaptions, ...restOfFacetSearch} =
const {showMoreResults, state, updateCaptions, ...restOfFacetSearch} =
buildCoreCategoryFacetSearch(engine, {
...props,
executeFacetSearchActionCreator: (facetId: string) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -32,13 +29,17 @@ export type CategoryFacetOptions = Omit<
> &
SearchableFacetOptions;

export type CategoryFacetState = CoreCommerceFacetState<CategoryFacetValue> & {
export type CategoryFacetState = Omit<
CoreCommerceFacetState<CategoryFacetValue>,
'type'
> & {
activeValue?: CategoryFacetValue;
canShowLessValues: boolean;
canShowMoreValues: boolean;
hasActiveValues: boolean;
selectedValueAncestry?: CategoryFacetValue[];
facetSearch: CategoryFacetSearchState;
type: 'hierarchical';
};

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<CategoryFacetValue>,
facetSearchSelector: ReturnType<typeof categoryFacetSearchStateSelector>
): 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,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ export type DateFacetOptions = Omit<
'toggleSelectActionCreator' | 'toggleExcludeActionCreator'
>;

export type DateFacetState = CoreCommerceFacetState<DateFacetValue>;
export type DateFacetState = Omit<
CoreCommerceFacetState<DateFacetValue>,
'type'
> & {
type: 'dateRange';
};

/**
* The `DateFacet` sub-controller offers a high-level programming interface for implementing date commerce
Expand Down Expand Up @@ -86,9 +91,18 @@ export function buildCommerceDateFacet(
},

get state() {
return coreController.state;
return getDateFacetState(coreController.state);
},

type: 'dateRange',
};
}

export const getDateFacetState = (
coreState: CoreCommerceFacetState<DateFacetValue>
): DateFacetState => {
return {
...coreState,
type: 'dateRange',
};
};
Original file line number Diff line number Diff line change
@@ -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
);
}
});
}
);
});
Loading

0 comments on commit 3f957f9

Please sign in to comment.