diff --git a/packages/headless/src/features/commerce/pagination/pagination-slice.test.ts b/packages/headless/src/features/commerce/pagination/pagination-slice.test.ts index 38e728f2aea..9c6bcf0b675 100644 --- a/packages/headless/src/features/commerce/pagination/pagination-slice.test.ts +++ b/packages/headless/src/features/commerce/pagination/pagination-slice.test.ts @@ -11,8 +11,10 @@ import { toggleSelectNumericFacetValue, } from '../../facets/range-facets/numeric-facet-set/numeric-facet-actions'; import {setContext, setUser, setView} from '../context/context-actions'; +import {restoreProductListingParameters} from '../product-listing-parameters/product-listing-parameter-actions'; import {fetchProductListing} from '../product-listing/product-listing-actions'; import {fetchRecommendations} from '../recommendations/recommendations-actions'; +import {restoreSearchParameters} from '../search-parameters/search-parameters-actions'; import {executeSearch} from '../search/search-actions'; import { nextPage, @@ -206,6 +208,40 @@ describe('pagination slice', () => { ).toEqual(pagination); }); + describe.each([ + { + action: restoreSearchParameters, + actionName: 'restoreSearchParameters', + }, + { + action: restoreProductListingParameters, + actionName: 'restoreProductListingParameters', + }, + ])('$actionName', ({action}) => { + it('restores principal pagination', () => { + const parameters = { + page: 2, + perPage: 11, + }; + + const finalState = paginationReducer(state, action(parameters)); + + expect(finalState.principal.page).toBe(parameters.page); + expect(finalState.principal.perPage).toBe(parameters.perPage); + }); + + it('does not restore principal pagination when parameters are not defined', () => { + const parameters = { + page: undefined, + perPage: undefined, + }; + + const finalState = paginationReducer(state, action(parameters)); + + expect(finalState.principal).toBe(state.principal); + }); + }); + describe.each([ { actionName: '#deselectAllFacetValues', diff --git a/packages/headless/src/features/commerce/pagination/pagination-slice.ts b/packages/headless/src/features/commerce/pagination/pagination-slice.ts index 6a833d6c087..51d58f97c5e 100644 --- a/packages/headless/src/features/commerce/pagination/pagination-slice.ts +++ b/packages/headless/src/features/commerce/pagination/pagination-slice.ts @@ -9,8 +9,11 @@ import { toggleSelectNumericFacetValue, } from '../../facets/range-facets/numeric-facet-set/numeric-facet-actions'; import {setContext, setUser, setView} from '../context/context-actions'; +import {Parameters} from '../parameters/parameters-actions'; +import {restoreProductListingParameters} from '../product-listing-parameters/product-listing-parameter-actions'; import {fetchProductListing} from '../product-listing/product-listing-actions'; import {fetchRecommendations} from '../recommendations/recommendations-actions'; +import {restoreSearchParameters} from '../search-parameters/search-parameters-actions'; import {executeSearch} from '../search/search-actions'; import { nextPage, @@ -93,6 +96,8 @@ export const paginationReducer = createReducer( state.recommendations[slotId] = getCommercePaginationInitialSlice(); }) + .addCase(restoreSearchParameters, handleRestoreParameters) + .addCase(restoreProductListingParameters, handleRestoreParameters) .addCase(deselectAllFacetValues, handlePaginationReset) .addCase(toggleSelectFacetValue, handlePaginationReset) .addCase(toggleExcludeFacetValue, handlePaginationReset) @@ -116,3 +121,16 @@ function getEffectiveSlice( function handlePaginationReset(state: CommercePaginationState) { state.principal.page = getCommercePaginationInitialSlice().page; } + +function handleRestoreParameters( + state: CommercePaginationState, + action: {payload: Parameters} +) { + if (action.payload.page) { + state.principal.page = action.payload.page; + } + + if (action.payload.perPage) { + state.principal.perPage = action.payload.perPage; + } +} diff --git a/packages/headless/src/features/commerce/parameters/parameters-actions.ts b/packages/headless/src/features/commerce/parameters/parameters-actions.ts index 4112e060f32..47812869f2b 100644 --- a/packages/headless/src/features/commerce/parameters/parameters-actions.ts +++ b/packages/headless/src/features/commerce/parameters/parameters-actions.ts @@ -1,5 +1,6 @@ import {DateRangeRequest} from '../../facets/range-facets/date-facet-set/interfaces/request'; import {NumericRangeRequest} from '../../facets/range-facets/numeric-facet-set/interfaces/request'; +import {SortCriterion} from '../sort/sort'; export interface Parameters { /** @@ -25,9 +26,7 @@ export interface Parameters { /** * The sort expression to order returned results by. */ - // eslint-disable-next-line @cspell/spellchecker - // TODO CAPI-907: Handle sort and pagination - //sortCriteria?: SortCriterion; + sortCriteria?: SortCriterion; /** * The zero-based index of the active page. @@ -37,7 +36,5 @@ export interface Parameters { /** * The number of results per page. */ - // eslint-disable-next-line @cspell/spellchecker - // TODO CAPI-907: Handle sort and pagination - //perPage?: number; + perPage?: number; } diff --git a/packages/headless/src/features/commerce/parameters/parameters-selectors.ts b/packages/headless/src/features/commerce/parameters/parameters-selectors.ts index 81259e379d6..64fa05aa5cc 100644 --- a/packages/headless/src/features/commerce/parameters/parameters-selectors.ts +++ b/packages/headless/src/features/commerce/parameters/parameters-selectors.ts @@ -2,7 +2,10 @@ import {CommerceEngine} from '../../../app/commerce-engine/commerce-engine'; import {stateKey} from '../../../app/state-key'; import {CommerceFacetSetSection} from '../../../state/state-sections'; import {findActiveValueAncestry} from '../../facets/category-facet-set/category-facet-utils'; -import {getFacets} from '../../parameter-manager/parameter-manager-selectors'; +import { + getFacets, + getSortCriteria, +} from '../../parameter-manager/parameter-manager-selectors'; import {FacetType} from '../facets/facet-set/interfaces/common'; import { AnyFacetRequest, @@ -11,7 +14,11 @@ import { NumericFacetRequest, RegularFacetRequest, } from '../facets/facet-set/interfaces/request'; -import {getCommercePaginationInitialSlice} from '../pagination/pagination-state'; +import { + CommercePaginationState, + getCommercePaginationInitialSlice, +} from '../pagination/pagination-state'; +import {getCommerceSortInitialState} from '../sort/sort-state'; import {Parameters as ManagedParameters} from './parameters-actions'; export function initialParametersSelector( @@ -21,10 +28,12 @@ export function initialParametersSelector( page: state.commercePagination.principal.page ?? getCommercePaginationInitialSlice().page, - // eslint-disable-next-line @cspell/spellchecker - // TODO CAPI-907: Handle sort and pagination - // perPage: state.commercePagination.principal.perPage ?? getCommercePaginationInitialSlice().perPage, - // sortCriteria: state.commerceSort.appliedSort ?? getCommerceSortInitialState().appliedSort, + perPage: + state.commercePagination.principal.perPage ?? + getCommercePaginationInitialSlice().perPage, + sortCriteria: + state.commerceSort.appliedSort ?? + getCommerceSortInitialState().appliedSort, cf: {}, nf: {}, df: {}, @@ -36,9 +45,21 @@ export function activeParametersSelector( state: CommerceEngine[typeof stateKey] ): ManagedParameters { return { - // eslint-disable-next-line @cspell/spellchecker - // TODO CAPI-907: Handle sort and pagination - //...getSortCriteria(state?.commerceSort, (s) => s.appliedSort, getCommerceSortInitialState().appliedSort), + ...getPage( + state?.commercePagination, + (s) => s.principal.page, + getCommercePaginationInitialSlice().page + ), + ...getPerPage( + state?.commercePagination, + (s) => s.principal.perPage, + getCommercePaginationInitialSlice().perPage + ), + ...getSortCriteria( + state?.commerceSort, + (s) => s.appliedSort, + getCommerceSortInitialState().appliedSort + ), ...getFacets( state.commerceFacetSet, facetIsOfType(state, 'regular'), @@ -76,6 +97,34 @@ export function enrichedParametersSelector( }; } +export function getPage( + section: CommercePaginationState | undefined, + pageSelector: (section: CommercePaginationState) => number, + initialState: number +) { + if (section === undefined) { + return {}; + } + + const page = pageSelector(section); + const shouldInclude = page !== initialState; + return shouldInclude ? {page} : {}; +} + +export function getPerPage( + section: CommercePaginationState | undefined, + perPageSelector: (section: CommercePaginationState) => number, + initialState: number +) { + if (section === undefined) { + return {}; + } + + const perPage = perPageSelector(section); + const shouldInclude = perPage !== initialState; + return shouldInclude ? {perPage} : {}; +} + export function getSelectedValues(request: AnyFacetRequest) { return (request as RegularFacetRequest).values .filter((fv) => fv.state === 'selected') diff --git a/packages/headless/src/features/commerce/parameters/parameters-serializer.test.ts b/packages/headless/src/features/commerce/parameters/parameters-serializer.test.ts index cb6bb25aa85..263db06d2bf 100644 --- a/packages/headless/src/features/commerce/parameters/parameters-serializer.test.ts +++ b/packages/headless/src/features/commerce/parameters/parameters-serializer.test.ts @@ -1,6 +1,7 @@ import {buildDateRange} from '../../../controllers/core/facets/range-facet/date-facet/date-range'; import {buildNumericRange} from '../../../controllers/core/facets/range-facet/numeric-facet/numeric-range'; import {CommerceSearchParameters} from '../search-parameters/search-parameters-actions'; +import {buildFieldsSortCriterion, SortDirection} from '../sort/sort'; import {searchSerializer} from './parameters-serializer'; const someSpecialCharactersThatNeedsEncoding = [ @@ -91,14 +92,21 @@ describe('searchSerializer', () => { }), ], }; - const parameters: Required< - Omit - > = { + const page = 4; + const perPage = 96; + const sortCriteria = buildFieldsSortCriterion([ + {name: 'author', direction: SortDirection.Ascending}, + {name: 'created', direction: SortDirection.Descending}, + ]); + const parameters: Required = { q: 'some query', f, cf, nf, df, + page, + perPage, + sortCriteria, }; const serialized = serialize(parameters); diff --git a/packages/headless/src/features/commerce/parameters/parameters-serializer.ts b/packages/headless/src/features/commerce/parameters/parameters-serializer.ts index c36da4d067f..e0702191131 100644 --- a/packages/headless/src/features/commerce/parameters/parameters-serializer.ts +++ b/packages/headless/src/features/commerce/parameters/parameters-serializer.ts @@ -1,16 +1,33 @@ +import {isArray} from '../../../utils/utils'; import { castUnknownObject, delimiter, - isValidKey, + isFacetObject, + isObject, + isRangeFacetKey, + isRangeFacetObject, preprocessObjectPairs, - serializePair, + SearchParameterKey, + serialize as coreSerialize, + serializeFacets, + serializeRangeFacets, + serializeSpecialCharacters, splitOnFirstEqual, } from '../../search-parameters/search-parameter-serializer'; -import {serialize as coreSerialize} from '../../search-parameters/search-parameter-serializer'; import {ProductListingParameters} from '../product-listing-parameters/product-listing-parameter-actions'; import {CommerceSearchParameters} from '../search-parameters/search-parameters-actions'; +import { + buildFieldsSortCriterion, + buildRelevanceSortCriterion, + SortBy, + SortCriterion, + SortDirection, +} from '../sort/sort'; import {Parameters} from './parameters-actions'; +const sortFieldAndDirectionSeparator = ' '; +const sortFieldsJoiner = ','; + export interface Serializer { serialize: (parameters: T) => string; deserialize: (fragment: string) => T; @@ -26,6 +43,7 @@ export const productListingSerializer = { deserialize, } as Serializer; +type ParametersKey = keyof CommerceSearchParameters; type FacetParameters = keyof Pick; type FacetKey = keyof typeof supportedFacetParameters; @@ -36,12 +54,98 @@ const supportedFacetParameters: Record = { df: true, }; -// eslint-disable-next-line @cspell/spellchecker -// TODO CAPI-907: Handle sort and pagination function serialize(parameters: CommerceSearchParameters): string { return coreSerialize(serializePair)(parameters); } +function serializePair(pair: [string, unknown]) { + const [key, val] = pair; + + if (!isValidKey(key)) { + return ''; + } + + if (key === 'sortCriteria') { + return isSortCriteriaObject(val) ? serializeSortCriteria(key, val) : ''; + } + + if (keyHasObjectValue(key) && !isRangeFacetKey(key)) { + return isFacetObject(val) ? serializeFacets(key, val) : ''; + } + + if (key === 'nf' || key === 'df') { + return isRangeFacetObject(val) ? serializeRangeFacets(key, val) : ''; + } + + return serializeSpecialCharacters(key, val); +} + +function serializeSortCriteria(key: string, val: SortCriterion | undefined) { + return serializeSpecialCharacters(key, buildCriterionExpression(val)); +} + +function buildCriterionExpression(criterion: SortCriterion | undefined) { + if (!criterion) { + return ''; + } + + if (criterion.by === SortBy.Relevance) { + return 'relevance'; + } + + return criterion.fields + .map( + (field) => + `${field.name}${sortFieldAndDirectionSeparator}${field.direction}` + ) + .join(sortFieldsJoiner); +} + +function isValidKey(key: string): key is ParametersKey { + return isValidBasicKey(key) || keyHasObjectValue(key); +} + +function isSortCriteriaObject(obj: unknown): obj is SortCriterion | undefined { + if (!isObject(obj) || !('by' in obj)) { + return false; + } + + if (obj.by === 'relevance') { + return true; + } + + if (obj.by === 'fields' && 'fields' in obj && isArray(obj.fields)) { + return obj.fields.every((field) => { + return ( + isObject(field) && + 'name' in field && + typeof field.name === 'string' && + (('direction' in field && + (field.direction === SortDirection.Ascending || + field.direction === SortDirection.Descending)) || + !('direction' in field)) + ); + }); + } + + return false; +} + +export function isValidBasicKey( + key: string +): key is Exclude { + const supportedBasicParameters: Record< + Exclude, + boolean + > = { + q: true, + sortCriteria: true, + page: true, + perPage: true, + }; + return key in supportedBasicParameters; +} + function deserialize(fragment: string): T { const parts = fragment.split(delimiter); const keyValuePairs = parts @@ -58,6 +162,11 @@ function deserialize(fragment: string): T { return {...acc, [key]: mergedValues}; } + if (key === 'sortCriteria') { + const sortCriteria = deserializeSortCriteria(val as string); + return {...acc, [key]: sortCriteria}; + } + return {...acc, [key]: val}; }, {}) as T; } @@ -77,11 +186,9 @@ function isValidPair( function cast(pair: [K, string]): [K, unknown] { const [key, value] = pair; - // eslint-disable-next-line @cspell/spellchecker - // TODO CAPI-907: Handle sort and pagination - /*if (key === 'page' || key === 'perPage') { + if (key === 'page' || key === 'perPage') { return [key, parseInt(value)]; - }*/ + } if (keyHasObjectValue(key)) { return [key, castUnknownObject(value)]; @@ -89,3 +196,35 @@ function cast(pair: [K, string]): [K, unknown] { return [key, decodeURIComponent(value)]; } + +function deserializeSortCriteria(value: string): SortCriterion | undefined { + if (value === 'relevance') { + return buildRelevanceSortCriterion(); + } + + const criteria = value.split(sortFieldsJoiner); + if (!criteria.length) { + return undefined; + } + + return criteria.reduce((acc, joinedFieldAndDirection) => { + const fieldAndDirection = joinedFieldAndDirection + .trim() + .split(sortFieldAndDirectionSeparator); + + if (fieldAndDirection.length !== 2) { + return acc; + } + + const field = fieldAndDirection[0].toLowerCase(); + const direction = fieldAndDirection[1].toLowerCase(); + + return { + ...acc, + fields: [ + ...acc.fields, + {name: field, direction: direction as SortDirection}, + ], + }; + }, buildFieldsSortCriterion([])); +} diff --git a/packages/headless/src/features/commerce/sort/sort-slice.test.ts b/packages/headless/src/features/commerce/sort/sort-slice.test.ts index c8e1316ceab..a76edc414ee 100644 --- a/packages/headless/src/features/commerce/sort/sort-slice.test.ts +++ b/packages/headless/src/features/commerce/sort/sort-slice.test.ts @@ -2,7 +2,9 @@ import {buildSearchResponse} from '../../../test/mock-commerce-search'; import {buildFetchProductListingV2Response} from '../../../test/mock-product-listing-v2'; import {SortBy, SortDirection} from '../../sort/sort'; import {setContext, setUser, setView} from '../context/context-actions'; +import {restoreProductListingParameters} from '../product-listing-parameters/product-listing-parameter-actions'; import {fetchProductListing} from '../product-listing/product-listing-actions'; +import {restoreSearchParameters} from '../search-parameters/search-parameters-actions'; import {executeSearch} from '../search/search-actions'; import {applySort} from './sort-actions'; import {sortReducer} from './sort-slice'; @@ -78,6 +80,39 @@ describe('product-listing-sort-slice', () => { }); }); + describe.each([ + { + action: restoreSearchParameters, + actionName: 'restoreSearchParameters', + }, + { + action: restoreProductListingParameters, + actionName: 'restoreProductListingParameters', + }, + ])('$actionName', ({action}) => { + it('restores appliedSort', () => { + const parameters = { + sortCriteria: { + by: 'relevance' as SortBy.Relevance, + }, + }; + + const finalState = sortReducer(state, action(parameters)); + + expect(finalState.appliedSort).toEqual(parameters.sortCriteria); + }); + + it('does not restore appliedSort when parameters are not defined', () => { + const parameters = { + sortCriteria: undefined, + }; + + const finalState = sortReducer(state, action(parameters)); + + expect(finalState.appliedSort).toBe(state.appliedSort); + }); + }); + describe.each([ { actionName: '#setContext', diff --git a/packages/headless/src/features/commerce/sort/sort-slice.ts b/packages/headless/src/features/commerce/sort/sort-slice.ts index 89f0a7a3de5..50d5cfc39f1 100644 --- a/packages/headless/src/features/commerce/sort/sort-slice.ts +++ b/packages/headless/src/features/commerce/sort/sort-slice.ts @@ -7,26 +7,14 @@ import { SortCriterion, } from '../../sort/sort'; import {setContext, setUser, setView} from '../context/context-actions'; +import {Parameters} from '../parameters/parameters-actions'; +import {restoreProductListingParameters} from '../product-listing-parameters/product-listing-parameter-actions'; import {fetchProductListing} from '../product-listing/product-listing-actions'; +import {restoreSearchParameters} from '../search-parameters/search-parameters-actions'; import {executeSearch} from '../search/search-actions'; import {applySort} from './sort-actions'; import {CommerceSortState, getCommerceSortInitialState} from './sort-state'; -const mapResponseSortToStateSort = (sort: SortOption): SortCriterion => { - if (sort.sortCriteria === SortBy.Relevance) { - return buildRelevanceSortCriterion(); - } - - return { - by: SortBy.Fields, - fields: (sort.fields || []).map(({field, direction, displayName}) => ({ - name: field, - direction, - displayName, - })), - }; -}; - export const sortReducer = createReducer( getCommerceSortInitialState(), @@ -37,6 +25,8 @@ export const sortReducer = createReducer( }) .addCase(fetchProductListing.fulfilled, handleFetchFulfilled) .addCase(executeSearch.fulfilled, handleFetchFulfilled) + .addCase(restoreSearchParameters, handleRestoreParameters) + .addCase(restoreProductListingParameters, handleRestoreParameters) .addCase(setContext, getCommerceSortInitialState) .addCase(setView, getCommerceSortInitialState) .addCase(setUser, getCommerceSortInitialState); @@ -53,3 +43,27 @@ function handleFetchFulfilled( mapResponseSortToStateSort ); } + +const mapResponseSortToStateSort = (sort: SortOption): SortCriterion => { + if (sort.sortCriteria === SortBy.Relevance) { + return buildRelevanceSortCriterion(); + } + + return { + by: SortBy.Fields, + fields: (sort.fields || []).map(({field, direction, displayName}) => ({ + name: field, + direction, + displayName, + })), + }; +}; + +function handleRestoreParameters( + state: WritableDraft, + action: {payload: Parameters} +) { + if (action.payload.sortCriteria) { + state.appliedSort = action.payload.sortCriteria; + } +} diff --git a/packages/headless/src/features/search-parameters/search-parameter-serializer.ts b/packages/headless/src/features/search-parameters/search-parameter-serializer.ts index dcd05accd98..b4ea1583096 100644 --- a/packages/headless/src/features/search-parameters/search-parameter-serializer.ts +++ b/packages/headless/src/features/search-parameters/search-parameter-serializer.ts @@ -139,7 +139,7 @@ export function isRangeFacetObject( return allEntriesAreValid(obj, isRangeValue); } -function isObject(obj: unknown): obj is object { +export function isObject(obj: unknown): obj is object { return obj && typeof obj === 'object' ? true : false; } diff --git a/packages/headless/src/state/commerce-app-state.ts b/packages/headless/src/state/commerce-app-state.ts index 0a16f1f296f..9a081f26cf5 100644 --- a/packages/headless/src/state/commerce-app-state.ts +++ b/packages/headless/src/state/commerce-app-state.ts @@ -23,11 +23,11 @@ import { TriggerSection, } from './state-sections'; -// eslint-disable-next-line @cspell/spellchecker -// TODO CAPI-907: Handle sort and pagination -export type CommerceSearchParametersState = CommerceQuerySection & - CommerceFacetSetSection; -export type CommerceProductListingParametersState = CommerceFacetSetSection; +export type CommerceProductListingParametersState = CommerceFacetSetSection & + CommerceSortSection & + CommercePaginationSection; +export type CommerceSearchParametersState = + CommerceProductListingParametersState & CommerceQuerySection; export type CommerceAppState = ConfigurationSection & CommerceStandaloneSearchBoxSection &