diff --git a/apps/docs/docs/reference/ui/molecules/PriceRange.mdx b/apps/docs/docs/reference/ui/molecules/PriceRange.mdx index 6fab6e43ee..ef8b4e184e 100644 --- a/apps/docs/docs/reference/ui/molecules/PriceRange.mdx +++ b/apps/docs/docs/reference/ui/molecules/PriceRange.mdx @@ -21,9 +21,10 @@ function Component () { return ( window.alert(`Min: ${formatter(value.min)}, Max: ${formatter(value.max)}`)} /> ) } diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index c154532ab0..94e1082e4b 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -321,17 +321,29 @@ export type StoreCurrency = { symbol: Scalars['String']; }; -/** Search facet information. */ -export type StoreFacet = { - __typename?: 'StoreFacet'; +export type StoreFacet = StoreFacetBoolean | StoreFacetRange; + +/** Search facet boolean information. */ +export type StoreFacetBoolean = { + __typename?: 'StoreFacetBoolean'; + /** Facet key. */ + key: Scalars['String']; + /** Facet label. */ + label: Scalars['String']; + /** Array with information on each facet value. */ + values: Array; +}; + +/** Search facet range information. */ +export type StoreFacetRange = { + __typename?: 'StoreFacetRange'; /** Facet key. */ key: Scalars['String']; /** Facet label. */ label: Scalars['String']; - /** Facet type. Possible values are `BOOLEAN` and `RANGE`. */ - type: StoreFacetType; + max: StoreFacetValueRange; /** Array with information on each facet value. */ - values: Array; + min: StoreFacetValueRange; }; /** Search facet type. */ @@ -343,8 +355,8 @@ export const enum StoreFacetType { }; /** Information of a specific facet value. */ -export type StoreFacetValue = { - __typename?: 'StoreFacetValue'; +export type StoreFacetValueBoolean = { + __typename?: 'StoreFacetValueBoolean'; /** Facet value label. */ label: Scalars['String']; /** Number of items with this facet. */ @@ -355,6 +367,12 @@ export type StoreFacetValue = { value: Scalars['String']; }; +export type StoreFacetValueRange = { + __typename?: 'StoreFacetValueRange'; + absolute: Scalars['Float']; + selected: Scalars['Float']; +}; + /** Image. */ export type StoreImage = { __typename?: 'StoreImage'; diff --git a/packages/api/src/platforms/vtex/clients/search/index.ts b/packages/api/src/platforms/vtex/clients/search/index.ts index 8cd44b66a3..c877044b19 100644 --- a/packages/api/src/platforms/vtex/clients/search/index.ts +++ b/packages/api/src/platforms/vtex/clients/search/index.ts @@ -2,7 +2,11 @@ import { fetchAPI } from '../fetch' import type { IStoreSelectedFacet } from '../../../../__generated__/schema' import type { Context, Options } from '../../index' import type { SelectedFacet } from '../../utils/facets' -import type { FacetSearchResult } from './types/FacetSearchResult' +import type { + Facet, + FacetValueBoolean, + FacetSearchResult, +} from './types/FacetSearchResult' import type { ProductSearchResult, Suggestion, @@ -38,6 +42,10 @@ const POLICY_KEY = 'trade-policy' const REGION_KEY = 'region-id' const CHANNEL_KEYS = new Set([POLICY_KEY, REGION_KEY]) +export const isFacetBoolean = ( + facet: Facet +): facet is Facet => facet.type === 'TEXT' + export const IntelligentSearch = ( { account, environment, hideUnavailableItems }: Options, ctx: Context diff --git a/packages/api/src/platforms/vtex/clients/search/types/FacetSearchResult.ts b/packages/api/src/platforms/vtex/clients/search/types/FacetSearchResult.ts index da20c21a0c..17a5967559 100644 --- a/packages/api/src/platforms/vtex/clients/search/types/FacetSearchResult.ts +++ b/packages/api/src/platforms/vtex/clients/search/types/FacetSearchResult.ts @@ -4,27 +4,28 @@ export interface FacetSearchResult { breadcrumb: Breadcrumb } -export interface Facet { +export interface Facet { type: FilterType name: string hidden: boolean - values: FacetValue[] + values: T[] quantity?: number - key?: string + key: string } -export interface FacetValue { +export interface FacetValueBoolean { quantity: number name: string key: string value: string - selected?: boolean - range?: { + selected: boolean +} + +export interface FacetValueRange { + range: { from: number to: number } - children?: FacetValue[] - id?: string } interface Breadcrumb { diff --git a/packages/api/src/platforms/vtex/index.ts b/packages/api/src/platforms/vtex/index.ts index fa46bf5ee2..a46de9900d 100644 --- a/packages/api/src/platforms/vtex/index.ts +++ b/packages/api/src/platforms/vtex/index.ts @@ -1,24 +1,29 @@ import { getClients } from './clients' +import type { SearchArgs } from './clients/search' import { getLoaders } from './loaders' import { StoreAggregateOffer } from './resolvers/aggregateOffer' import { StoreAggregateRating } from './resolvers/aggregateRating' import { StoreCollection } from './resolvers/collection' -import { StoreFacet } from './resolvers/facet' -import { StoreFacetValue } from './resolvers/facetValue' +import { + StoreFacet, + StoreFacetBoolean, + StoreFacetRange, +} from './resolvers/facet' +import { StoreFacetValueBoolean } from './resolvers/faceValue' import { Mutation } from './resolvers/mutation' +import { ObjectOrString } from './resolvers/objectOrString' import { StoreOffer } from './resolvers/offer' import { StoreProduct } from './resolvers/product' import { StoreProductGroup } from './resolvers/productGroup' +import { StorePropertyValue } from './resolvers/propertyValue' import { Query } from './resolvers/query' import { StoreReview } from './resolvers/review' import { StoreSearchResult } from './resolvers/searchResult' import { StoreSeo } from './resolvers/seo' -import { ObjectOrString } from './resolvers/objectOrString' -import { StorePropertyValue } from './resolvers/propertyValue' +import ChannelMarshal from './utils/channel' import type { Loaders } from './loaders' import type { Clients } from './clients' import type { Channel } from './utils/channel' -import ChannelMarshal from './utils/channel' export interface Options { platform: 'vtex' @@ -48,6 +53,7 @@ export interface Context { channel: Required locale: string flags: FeatureFlags + searchArgs?: Omit } headers: Record } @@ -65,7 +71,9 @@ const Resolvers = { StoreProduct, StoreSeo, StoreFacet, - StoreFacetValue, + StoreFacetBoolean, + StoreFacetRange, + StoreFacetValueBoolean, StoreOffer, StoreAggregateRating, StoreReview, diff --git a/packages/api/src/platforms/vtex/resolvers/faceValue.ts b/packages/api/src/platforms/vtex/resolvers/faceValue.ts new file mode 100644 index 0000000000..ec903f1dc5 --- /dev/null +++ b/packages/api/src/platforms/vtex/resolvers/faceValue.ts @@ -0,0 +1,12 @@ +import type { Resolver } from '..' +import type { FacetValueBoolean } from '../clients/search/types/FacetSearchResult' + +export const StoreFacetValueBoolean: Record< + string, + Resolver +> = { + value: ({ value }) => value, + label: ({ name }) => name || 'unknown', + selected: ({ selected }) => selected, + quantity: ({ quantity }) => quantity, +} diff --git a/packages/api/src/platforms/vtex/resolvers/facet.ts b/packages/api/src/platforms/vtex/resolvers/facet.ts index 6b3d6b1953..e1745e4891 100644 --- a/packages/api/src/platforms/vtex/resolvers/facet.ts +++ b/packages/api/src/platforms/vtex/resolvers/facet.ts @@ -1,11 +1,72 @@ +import { parseRange } from '../utils/facets' +import { min } from '../utils/orderStatistics' +import type { + FacetValueBoolean, + Facet, + FacetValueRange, +} from '../clients/search/types/FacetSearchResult' import type { Resolver } from '..' -import type { Facet } from '../clients/search/types/FacetSearchResult' type Root = Facet export const StoreFacet: Record> = { - key: ({ key }) => key ?? '', - label: ({ name }) => name ?? 'unknown', - values: ({ values }) => values, - type: ({ type }) => (type === 'TEXT' ? 'BOOLEAN' : 'RANGE'), + __resolveType: ({ type }) => + type === 'TEXT' ? 'StoreFacetBoolean' : 'StoreFacetRange', +} + +export const StoreFacetBoolean: Record< + string, + Resolver> +> = { + key: ({ key }) => key, + label: ({ name }) => name, + values: ({ values }) => values.sort((a, b) => a.name.localeCompare(b.name)), +} + +export const StoreFacetRange: Record< + string, + Resolver> +> = { + key: ({ key }) => key, + label: ({ name }) => name, + min: ({ values, key }, _, { storage: { searchArgs } }) => { + /** + * Fetch the selected range the user queried. + * + * This is necessary because, differently from boolean facets, Search API does + * not return the selected values, making us have to implement it in here + */ + const selectedRange = parseRange( + searchArgs?.selectedFacets?.find((facet) => facet.key === key)?.value ?? + '' + ) + + const facet = min(values, (a, b) => a.range.from - b.range.from) + const globalMin = facet?.range.from ?? 0 + + return { + selected: selectedRange?.[0] ?? globalMin, + absolute: globalMin, + } + }, + max: ({ values, key }, _, { storage: { searchArgs } }) => { + /** + * Fetch the selected range the user queried. + * + * This is necessary because, differently from boolean facets, Search API does + * not return the selected values, making us have to implement it in here + */ + const selectedRange = parseRange( + searchArgs?.selectedFacets?.find((facet) => facet.key === key)?.value ?? + '' + ) + + const facet = min(values, (a, b) => b.range.to - a.range.to) + const globalMax = facet?.range.to ?? 0 + + return { + selected: selectedRange?.[1] ?? globalMax, + absolute: globalMax, + } + }, } diff --git a/packages/api/src/platforms/vtex/resolvers/facetValue.ts b/packages/api/src/platforms/vtex/resolvers/facetValue.ts deleted file mode 100644 index 330866e894..0000000000 --- a/packages/api/src/platforms/vtex/resolvers/facetValue.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Resolver } from '..' -import type { FacetValue } from '../clients/search/types/FacetSearchResult' - -type Root = FacetValue - -export const StoreFacetValue: Record> = { - value: ({ value, range }) => - value ?? `${range?.from ?? ''}-to-${range?.to ?? ''}`, - label: ({ name }) => name || 'unknown', - selected: ({ selected }) => selected, - quantity: ({ quantity }) => quantity, -} diff --git a/packages/api/src/platforms/vtex/resolvers/offer.ts b/packages/api/src/platforms/vtex/resolvers/offer.ts index 03e22084cc..d0f87da397 100644 --- a/packages/api/src/platforms/vtex/resolvers/offer.ts +++ b/packages/api/src/platforms/vtex/resolvers/offer.ts @@ -87,7 +87,7 @@ export const StoreOffer: Record> = { }, listPrice: (root) => { if (isSearchItem(root)) { - return root.ListPrice + return root.ListPrice ?? 0 } if (isOrderFormItem(root)) { diff --git a/packages/api/src/platforms/vtex/resolvers/searchResult.ts b/packages/api/src/platforms/vtex/resolvers/searchResult.ts index 1e6ed7507f..011aafc07b 100644 --- a/packages/api/src/platforms/vtex/resolvers/searchResult.ts +++ b/packages/api/src/platforms/vtex/resolvers/searchResult.ts @@ -1,11 +1,11 @@ +import { enhanceSku } from '../utils/enhanceSku' import type { Resolver } from '..' import type { SearchArgs } from '../clients/search' import type { Facet } from '../clients/search/types/FacetSearchResult' -import { enhanceSku } from '../utils/enhanceSku' type Root = Omit -const REMOVED_FACETS_FROM_COLLECTION_PAGE = ['departamento', 'Departamento'] +const isRootFacet = (facet: Facet) => facet.key === 'category-1' export const StoreSearchResult: Record> = { suggestions: async (searchArgs, _, ctx) => { @@ -90,33 +90,16 @@ export const StoreSearchResult: Record> = { clients: { search: is }, } = ctx - const { facets } = await is.facets(searchArgs) - - const isCollectionPage = !searchArgs.query - const filteredFacets = facets?.reduce((acc, currentFacet) => { - const shouldFilterFacet = REMOVED_FACETS_FROM_COLLECTION_PAGE.includes( - currentFacet.name - ) - - const shouldRemoveFacetFromCollectionPage = - isCollectionPage && shouldFilterFacet + ctx.storage.searchArgs = searchArgs - if (shouldRemoveFacetFromCollectionPage) { - return acc - } - - currentFacet.values.sort((a, b) => { - const firstItemLabel = a.name ?? '' - const secondItemLabel = b.name ?? '' + const { facets = [] } = await is.facets(searchArgs) - return firstItemLabel.localeCompare(secondItemLabel) - }) - - acc.push(currentFacet) + const isCollectionPage = !searchArgs.query - return acc - }, [] as Facet[]) + const filteredFacets = facets + // Remove root facet on category pages + .filter((facet) => !isCollectionPage || !isRootFacet(facet)) - return filteredFacets ?? [] + return filteredFacets }, } diff --git a/packages/api/src/platforms/vtex/utils/facets.ts b/packages/api/src/platforms/vtex/utils/facets.ts index 1fa7a67a17..8e9d96f9f9 100644 --- a/packages/api/src/platforms/vtex/utils/facets.ts +++ b/packages/api/src/platforms/vtex/utils/facets.ts @@ -29,11 +29,29 @@ export const transformSelectedFacet = ({ key, value }: SelectedFacet) => { return [] // remove this facet from search } + case 'price': { + return { key, value: value.replace('-to-', ':') } + } + default: return { key, value } } } +export const parseRange = (range: string): [number, number] | null => { + const splitted = range.split(':').map(Number) + + if ( + splitted.length !== 2 || + Number.isNaN(splitted[0]) || + Number.isNaN(splitted[1]) + ) { + return null + } + + return splitted as [number, number] +} + export const findSlug = (facets?: Maybe) => facets?.find((x) => x.key === 'slug')?.value ?? null diff --git a/packages/api/src/typeDefs/facet.graphql b/packages/api/src/typeDefs/facet.graphql index 0dcdc479f9..770e09b21a 100644 --- a/packages/api/src/typeDefs/facet.graphql +++ b/packages/api/src/typeDefs/facet.graphql @@ -1,7 +1,9 @@ +union StoreFacet = StoreFacetRange | StoreFacetBoolean + """ -Search facet information. +Search facet range information. """ -type StoreFacet { +type StoreFacetRange { """ Facet key. """ @@ -13,17 +15,37 @@ type StoreFacet { """ Array with information on each facet value. """ - values: [StoreFacetValue!]! + min: StoreFacetValueRange! + max: StoreFacetValueRange! +} + +""" +Search facet boolean information. +""" +type StoreFacetBoolean { + """ + Facet key. + """ + key: String! + """ + Facet label. + """ + label: String! """ - Facet type. Possible values are `BOOLEAN` and `RANGE`. + Array with information on each facet value. """ - type: StoreFacetType! + values: [StoreFacetValueBoolean!]! +} + +type StoreFacetValueRange { + absolute: Float! + selected: Float! } """ Information of a specific facet value. """ -type StoreFacetValue { +type StoreFacetValueBoolean { """ Facet value. """ diff --git a/packages/api/test/__snapshots__/queries.test.ts.snap b/packages/api/test/__snapshots__/queries.test.ts.snap index f2dd88f8ec..943153d582 100644 --- a/packages/api/test/__snapshots__/queries.test.ts.snap +++ b/packages/api/test/__snapshots__/queries.test.ts.snap @@ -621,32 +621,10 @@ Object { Object { "key": "price", "label": "Pre�o", - "type": "RANGE", - "values": Array [ - Object { - "label": "unknown", - "quantity": 646, - "selected": false, - "value": "0.23-to-150", - }, - Object { - "label": "unknown", - "quantity": 601, - "selected": false, - "value": "360-to-995.92", - }, - Object { - "label": "unknown", - "quantity": 579, - "selected": false, - "value": "150-to-360", - }, - ], }, Object { "key": "category-2", "label": "Categoria", - "type": "BOOLEAN", "values": Array [ Object { "label": "Chairs", @@ -665,7 +643,6 @@ Object { Object { "key": "brand", "label": "Marca", - "type": "BOOLEAN", "values": Array [ Object { "label": "Acer", diff --git a/packages/api/test/schema.test.ts b/packages/api/test/schema.test.ts index ce112217e1..135ec210e9 100644 --- a/packages/api/test/schema.test.ts +++ b/packages/api/test/schema.test.ts @@ -18,7 +18,10 @@ const TYPES = [ 'StoreCollectionMeta', 'StoreCollection', 'StoreFacet', - 'StoreFacetValue', + 'StoreFacetRange', + 'StoreFacetBoolean', + 'StoreFacetValueRange', + 'StoreFacetValueBoolean', 'StoreImage', 'IStoreImage', 'StoreOffer', @@ -38,7 +41,6 @@ const TYPES = [ 'StoreCollectionConnection', 'StoreSort', 'IStoreSelectedFacet', - 'StoreFacetType', 'StoreSearchResult', 'StoreReviewRating', 'StoreReview', diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 17e6e968e9..91bd820ce1 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -80,6 +80,13 @@ export { parse as parseSearchState } from './search/serializer' export { default as formatSearchState } from './utils/format' export { initialize as initSearchState } from './search/useSearchState' +export { + isSearchSort, + removeFacet, + setFacet, + toggleFacet, + toggleFacets, +} from './search/facets' export { Provider as SearchProvider } from './search/Provider' export { useSearch } from './search/useSearch' export { usePagination } from './search/usePagination' diff --git a/packages/sdk/src/search/facets.ts b/packages/sdk/src/search/facets.ts new file mode 100644 index 0000000000..6f2d972848 --- /dev/null +++ b/packages/sdk/src/search/facets.ts @@ -0,0 +1,59 @@ +import { SDKError } from '../utils/error' +import type { Facet, SearchSort } from '../types' + +const sortKeys = new Set([ + 'price_desc', + 'price_asc', + 'orders_desc', + 'name_desc', + 'name_asc', + 'release_desc', + 'discount_desc', + 'score_desc', +]) + +export const isSearchSort = (x: string): x is SearchSort => + sortKeys.has(x as any) + +export const removeFacet = (facets: Facet[], facet: Facet): Facet[] => { + const { value } = facet + + const index = facets.findIndex((x) => x.value === value) + + if (index < 0) { + throw new SDKError(`Cannot remove ${value} from search params`) + } + + return facets.filter((_, it) => it === 0 || it !== index) +} + +export const setFacet = ( + facets: Facet[], + facet: Facet, + unique?: boolean +): Facet[] => { + if (unique === true) { + const index = facets.findIndex((f) => f.key === facet.key) + + if (index > -1) { + return facets.map((f, it) => (it === index ? facet : f)) + } + } + + return [...facets, facet] +} + +export const toggleFacet = (facets: Facet[], item: Facet) => { + const found = facets.find( + (facet) => facet.key === item.key && facet.value === item.value + ) + + if (found !== undefined) { + return removeFacet(facets, item) + } + + return setFacet(facets, item, false) +} + +export const toggleFacets = (facets: Facet[], items: Facet[]) => + items.reduce((acc, curr) => toggleFacet(acc, curr), facets) diff --git a/packages/sdk/src/search/serializer.ts b/packages/sdk/src/search/serializer.ts index be30e4d41b..43861571eb 100644 --- a/packages/sdk/src/search/serializer.ts +++ b/packages/sdk/src/search/serializer.ts @@ -1,23 +1,29 @@ +import { SDKError } from '../utils/error' +import { isSearchSort, setFacet } from './facets' +import { initialize } from './useSearchState' import type { SearchSort, State } from '../types' -import { initialize, reducer } from './useSearchState' export const parse = ({ pathname, searchParams }: URL): State => { - let state = initialize({ + const state = initialize({ base: pathname, term: searchParams.get('q') ?? null, sort: (searchParams.get('sort') as SearchSort) ?? undefined, page: Number(searchParams.get('page') ?? 0), }) + if (!isSearchSort(state.sort)) { + throw new SDKError(`Uknown sorting option ${state.sort}`) + } + const facets = searchParams.get('facets')?.split(',') ?? [] for (const facet of facets) { const values = searchParams.getAll(facet) for (const value of values) { - state = reducer(state, { - type: 'setFacet' as const, - payload: { facet: { key: facet, value }, unique: false }, + state.selectedFacets = setFacet(state.selectedFacets, { + key: facet, + value, }) } } diff --git a/packages/sdk/src/search/useInfiniteSearchState.ts b/packages/sdk/src/search/useInfiniteSearchState.ts index ce213e3e67..e29089373a 100644 --- a/packages/sdk/src/search/useInfiniteSearchState.ts +++ b/packages/sdk/src/search/useInfiniteSearchState.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-case-declarations */ import { useMemo, useReducer } from 'react' import { SDKError } from '../utils/error' @@ -12,18 +11,30 @@ type Action = | { type: 'addNext' } + | { + type: 'reset' + payload: number + } const reducer = (state: State, action: Action) => { switch (action.type) { - case 'addPrev': + case 'addPrev': { const prev = state[0] - 1 return [prev, ...state] + } - case 'addNext': + case 'addNext': { const next = Number(state[state.length - 1]) + 1 return [...state, next] + } + + case 'reset': { + const { payload } = action + + return [payload] + } default: throw new SDKError('Unknown action for infinite search') @@ -31,14 +42,14 @@ const reducer = (state: State, action: Action) => { } export const useSearchInfiniteState = (initialPage: number) => { - const [pages, dispatch] = useReducer(reducer, initialPage, () => [ - initialPage, - ]) + const [pages, dispatch] = useReducer(reducer, undefined, () => [initialPage]) const actions = useMemo( () => ({ addPrevPage: () => dispatch({ type: 'addPrev' }), addNextPage: () => dispatch({ type: 'addNext' }), + resetInfiniteScroll: (page: number) => + dispatch({ type: 'reset', payload: page }), }), [] ) diff --git a/packages/sdk/src/search/useSearchState.ts b/packages/sdk/src/search/useSearchState.ts index f33e6e7ec6..7df0f49aea 100644 --- a/packages/sdk/src/search/useSearchState.ts +++ b/packages/sdk/src/search/useSearchState.ts @@ -1,19 +1,7 @@ import { useMemo } from 'react' -import type { Facet, SearchSort, State } from '../types' -import { SDKError } from '../utils/error' import format from '../utils/format' - -const sortKeys = new Set([ - 'price_desc', - 'price_asc', - 'orders_desc', - 'name_desc', - 'name_asc', - 'release_desc', - 'discount_desc', - 'score_desc', -]) +import type { State } from '../types' export const initialize = ({ sort = 'score_desc', @@ -29,197 +17,22 @@ export const initialize = ({ page, }) -const isSearchSort = (x: string): x is SearchSort => sortKeys.has(x as any) - -type Action = - | { - type: 'setSort' - payload: SearchSort - } - | { - type: 'setTerm' - payload: string | null - } - | { - type: 'setPage' - payload: number - } - | { - type: 'setFacet' - payload: { facet: Facet; unique: boolean } - } - | { - type: 'setFacets' - payload: Facet[] - } - | { - type: 'removeFacet' - payload: Facet - } - | { - type: 'toggleFacet' - payload: Facet - } - | { - type: 'toggleFacets' - payload: Facet[] - } - const equals = (s1: State, s2: State) => format(s1).href === format(s2).href -const removeFacet = (state: State, facet: Facet): State => { - const { value } = facet - - const index = state.selectedFacets.findIndex((x) => x.value === value) - - if (index < 0) { - throw new SDKError(`Cannot remove ${value} from search params`) - } - - // We can't allow removing the first facet, otherwise we would loose - // the navigation context - // - // TODO: Remove returning the base selected facets to the frontend since - // we won't be unselecting it anyways - return { - ...state, - selectedFacets: state.selectedFacets.filter( - (_, it) => it === 0 || it !== index - ), - } -} - -export const setFacet = ( - state: State, - facet: Facet, - unique: boolean -): State => { - if (unique === true) { - const index = state.selectedFacets.findIndex((f) => f.key === facet.key) - - if (index > -1) { - return { - ...state, - selectedFacets: state.selectedFacets.map((f, it) => - it === index ? facet : f - ), - } - } - } - - return { - ...state, - selectedFacets: [...state.selectedFacets, facet], - } -} - -const toggleFacet = (state: State, item: Facet) => { - const found = state.selectedFacets.find( - (facet) => facet.key === item.key && facet.value === item.value - ) - - if (found !== undefined) { - return removeFacet(state, item) - } - - return setFacet(state, item, false) -} - -const toggleFacets = (state: State, items: Facet[]) => - items.reduce((item, s) => toggleFacet(item, s), state) - -export const reducer = (state: State, action: Action) => { - switch (action.type) { - case 'setSort': - if (!isSearchSort(action.payload)) { - throw new SDKError(`Sort param ${action.payload} is unknown`) - } - - return state.sort === action.payload - ? state - : { - ...state, - sort: action.payload, - } - - case 'setTerm': - return state.term === action.payload - ? state - : { - ...state, - term: action.payload, - } - - case 'setPage': - return state.page === action.payload - ? state - : { - ...state, - page: action.payload, - } - - case 'setFacet': - return setFacet(state, action.payload.facet, action.payload.unique) - - case 'setFacets': - return state.selectedFacets !== action.payload - ? { ...state, selectedFacets: action.payload } - : state - - case 'removeFacet': - return removeFacet(state, action.payload) - - case 'toggleFacet': - return toggleFacet(state, action.payload) - - case 'toggleFacets': - return toggleFacets(state, action.payload) - - default: - throw new SDKError(`Unknown action of search state machine`) - } -} - -const dispatcher = - (onChange: (url: URL) => void, state: State) => (action: Action) => { - const newState = reducer(state, action) - - if (!equals(newState, state)) { - onChange(format(newState)) - } - } - export const useSearchState = ( initialState: Partial, onChange: (url: URL) => void ) => { const state = useMemo(() => initialize(initialState), [initialState]) - return useMemo(() => { - const dispatch = dispatcher(onChange, state) - - return { + return useMemo( + () => ({ state, - setSort: (sort: SearchSort) => - dispatch({ type: 'setSort', payload: sort }), - setTerm: (term: string | null) => - dispatch({ type: 'setTerm', payload: term }), - setPage: (page: number) => dispatch({ type: 'setPage', payload: page }), - setFacet: (facet: Facet, unique = false) => - dispatch({ type: 'setFacet', payload: { facet, unique } }), - setFacets: (facets: Facet[]) => - dispatch({ type: 'setFacets', payload: facets }), - removeFacet: (facet: Facet) => - dispatch({ type: 'removeFacet', payload: facet }), - toggleFacet: (facet: Facet) => - dispatch({ - type: 'toggleFacet', - payload: facet, - }), - toggleFacets: (facets: Facet[]) => - dispatch({ type: 'toggleFacets', payload: facets }), - } - }, [onChange, state]) + setState: (newState: State) => + !equals(newState, state) && onChange(format(newState)), + }), + [onChange, state] + ) } export type UseSearchState = ReturnType diff --git a/packages/sdk/test/search/Provider.test.tsx b/packages/sdk/test/search/Provider.test.tsx index 1212d7a36a..f4f5262cbf 100644 --- a/packages/sdk/test/search/Provider.test.tsx +++ b/packages/sdk/test/search/Provider.test.tsx @@ -6,7 +6,11 @@ import type { ComponentPropsWithoutRef } from 'react' import { formatSearchState, initSearchState, + removeFacet, SearchProvider, + setFacet, + toggleFacet, + toggleFacets, useSearch, } from '../../src' @@ -34,7 +38,12 @@ test('SearchProvider: change sort ordering', async () => { expect(result.current.state.sort).toBe('score_desc') - act(() => result.current.setSort('name_asc')) + act(() => { + result.current.setState({ + ...result.current.state, + sort: 'name_asc', + }) + }) expect(mock).toBeCalledWith(formatSearchState({ ...state, sort: 'name_asc' })) }) @@ -51,10 +60,20 @@ test('SearchProvider: Set full text term', async () => { expect(result.current.state.term).toBeNull() - act(() => result.current.setTerm(null)) + act(() => { + result.current.setState({ + ...result.current.state, + term: null, + }) + }) expect(mock).not.toHaveBeenCalled() - act(() => result.current.setTerm(fullTextTerm)) + act(() => { + result.current.setState({ + ...result.current.state, + term: fullTextTerm, + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, term: fullTextTerm }) ) @@ -72,7 +91,12 @@ test('SearchProvider: Set current page', async () => { expect(result.current.state.page).toBe(0) - act(() => result.current.setPage(page)) + act(() => { + result.current.setState({ + ...result.current.state, + page, + }) + }) expect(mock).toBeCalledWith(formatSearchState({ ...state, page })) }) @@ -92,7 +116,12 @@ test('SearchProvider: selects a simple facet', async () => { expect(result.current.state.selectedFacets).toHaveLength(0) - act(() => result.current.setFacet(facet1)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: setFacet(result.current.state.selectedFacets, facet1), + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, selectedFacets: [facet1] }) ) @@ -120,7 +149,12 @@ test('SearchProvider: selects a simple facet when more facets are inside the sta ), }) - act(() => result.current.setFacet(facet2)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: setFacet(result.current.state.selectedFacets, facet2), + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, selectedFacets: [facet1, facet2] }) ) @@ -148,10 +182,24 @@ test('SearchProvider: Facet uniqueness', async () => { ), }) - act(() => result.current.setFacet(facet2, true)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: setFacet( + result.current.state.selectedFacets, + facet2, + true + ), + }) + }) expect(mock).not.toHaveBeenCalled() - act(() => result.current.setFacet(facet1)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: setFacet(result.current.state.selectedFacets, facet1), + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, selectedFacets: [facet2, facet1] }) ) @@ -179,7 +227,12 @@ test('SearchProvider: Remove facet selection', async () => { ), }) - act(() => result.current.removeFacet(facet2)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: removeFacet(result.current.state.selectedFacets, facet2), + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, selectedFacets: [facet1, facet2] }) ) @@ -208,7 +261,12 @@ test('SearchProvider: Remove initial facet', async () => { }) /** Cannot remove the first facet */ - act(() => result.current.removeFacet(facet1)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: removeFacet(result.current.state.selectedFacets, facet1), + }) + }) expect(mock).not.toHaveBeenCalled() }) @@ -242,15 +300,30 @@ test('SearchProvider: Toggle Facet', async () => { expect(result.current.state.selectedFacets).toEqual([facet1, facet2]) /** Cannot remove the first facet */ - act(() => result.current.toggleFacet(facet1)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: toggleFacet(result.current.state.selectedFacets, facet1), + }) + }) expect(mock).not.toHaveBeenCalled() - act(() => result.current.toggleFacet(facet2)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: toggleFacet(result.current.state.selectedFacets, facet2), + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, selectedFacets: [facet1] }) ) - act(() => result.current.toggleFacet(facet3)) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: toggleFacet(result.current.state.selectedFacets, facet3), + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, selectedFacets: [facet1, facet2, facet3] }) ) @@ -283,12 +356,28 @@ test('SearchProvider: Toggle Facets', async () => { ), }) - act(() => result.current.toggleFacets([facet2, facet3])) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: toggleFacets(result.current.state.selectedFacets, [ + facet2, + facet3, + ]), + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, selectedFacets: [facet1] }) ) - act(() => result.current.toggleFacets([facet1, facet2])) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: toggleFacets(result.current.state.selectedFacets, [ + facet1, + facet2, + ]), + }) + }) expect(mock).toBeCalledWith( formatSearchState({ ...state, selectedFacets: [facet1] }) ) @@ -316,8 +405,26 @@ test('SearchProvider: onChange is called', async () => { expect(mock).toHaveBeenCalledTimes(0) - act(() => result.current.setSort('name_asc')) - act(() => result.current.setFacet({ key: 'size', value: 'xm' })) - act(() => result.current.setPage(10)) + act(() => { + result.current.setState({ + ...result.current.state, + sort: 'name_asc', + }) + }) + act(() => { + result.current.setState({ + ...result.current.state, + selectedFacets: setFacet(result.current.state.selectedFacets, { + key: 'size', + value: 'xm', + }), + }) + }) + act(() => { + result.current.setState({ + ...result.current.state, + page: 10, + }) + }) expect(mock).toHaveBeenCalledTimes(3) }) diff --git a/packages/ui/src/atoms/Slider/Slider.test.tsx b/packages/ui/src/atoms/Slider/Slider.test.tsx index fe6b197da2..86f44c9387 100644 --- a/packages/ui/src/atoms/Slider/Slider.test.tsx +++ b/packages/ui/src/atoms/Slider/Slider.test.tsx @@ -4,16 +4,27 @@ import React from 'react' import Slider from './Slider' +const props = { + min: { + absolute: 0, + selected: 0, + }, + max: { + absolute: 100, + selected: 100, + }, +} + describe('Slider', () => { it('`data-store-slider` is present', () => { - const { getByTestId } = render() + const { getByTestId } = render() expect(getByTestId('store-slider')).toHaveAttribute('data-store-slider') }) describe('Accessibility', () => { it('should have no violations', async () => { - const { getByTestId } = render() + const { getByTestId } = render() expect(await axe(getByTestId('store-slider'))).toHaveNoViolations() }) diff --git a/packages/ui/src/atoms/Slider/Slider.tsx b/packages/ui/src/atoms/Slider/Slider.tsx index 292f686b4e..f2f0a5c700 100644 --- a/packages/ui/src/atoms/Slider/Slider.tsx +++ b/packages/ui/src/atoms/Slider/Slider.tsx @@ -1,18 +1,22 @@ /** * This code is inspired by the work of [sandra-lewis](https://codesandbox.io/u/sandra-lewis) */ +import React, { useState, useMemo } from 'react' -import React, { useCallback, useEffect, useRef, useState } from 'react' +interface Range { + absolute: number + selected: number +} export type SliderProps = { /** * The minimum value of the slider. */ - min: number + min: Range /** * The maximum value of the slider. */ - max: number + max: Range /** * ID to find this component in testing tools (e.g.: cypress, testing library, and jest). * @@ -23,6 +27,10 @@ export type SliderProps = { * Callback that fires when the slider value changes. */ onChange?: (value: { min: number; max: number }) => void + /** + * Callback that fires when the slider value ends changing. + */ + onEnd?: (value: { min: number; max: number }) => void /** * A function used to set a human-readable value text based on the slider's current value. */ @@ -33,93 +41,80 @@ export type SliderProps = { className?: string } +const percent = (value: number, min: number, max: number) => + Math.round(((value - min) / (max - min)) * 100) + const Slider = ({ min, max, onChange, + onEnd, testId = 'store-slider', getAriaValueText, className, }: SliderProps) => { - const [minVal, setMinVal] = useState(min) - const [maxVal, setMaxVal] = useState(max) - - const minValRef = useRef(min) - const maxValRef = useRef(max) - const range = useRef(null) - - const getPercent = useCallback( - (value: number) => Math.round(((value - min) / (max - min)) * 100), - [min, max] + const [minPercent, setMinPercent] = useState(() => + percent(min.selected, min.absolute, max.absolute) ) - // width of the range to reduce from the left side - useEffect(() => { - const minPercent = getPercent(minVal) - const maxPercent = getPercent(maxValRef.current) - - if (range.current) { - range.current.style.left = `${minPercent}%` - range.current.style.width = `${maxPercent - minPercent}%` - } - }, [minVal, getPercent]) + const [maxPercent, setMaxPercent] = useState(() => + percent(max.selected, min.absolute, max.absolute) + ) - // width of the range to reduce from the right side - useEffect(() => { - const minPercent = getPercent(minValRef.current) - const maxPercent = getPercent(maxVal) + const { minVal, maxVal } = useMemo(() => { + const widthPercent = (max.absolute - min.absolute) / 100 - if (range.current) { - range.current.style.width = `${maxPercent - minPercent}%` + return { + minVal: min.absolute + minPercent * widthPercent, + maxVal: min.absolute + maxPercent * widthPercent, } - }, [maxVal, getPercent]) - - useEffect(() => { - onChange?.({ min: minVal, max: maxVal }) - }, [minVal, maxVal, onChange]) + }, [min, max, maxPercent, minPercent]) return (
-
+
onEnd?.({ min: minVal, max: maxVal })} + onTouchEnd={() => onEnd?.({ min: minVal, max: maxVal })} onChange={(event) => { - const value = Math.min(Number(event.target.value), maxVal - 1) + const minValue = Math.min(Number(event.target.value), maxVal) - setMinVal(value) - minValRef.current = value + setMinPercent(percent(minValue, min.absolute, max.absolute)) + onChange?.({ min: minValue, max: maxVal }) }} data-slider-thumb="left" - aria-valuemin={min} - aria-valuemax={max} + aria-valuemin={min.absolute} + aria-valuemax={max.absolute} aria-valuenow={minVal} aria-label={String(minVal)} - aria-labelledby={ - getAriaValueText ? getAriaValueText(minVal, 'min') : undefined - } + aria-labelledby={getAriaValueText?.(minVal, 'min')} /> onEnd?.({ min: minVal, max: maxVal })} + onTouchEnd={() => onEnd?.({ min: minVal, max: maxVal })} onChange={(event) => { - const value = Math.max(Number(event.target.value), minVal + 1) + const maxValue = Math.max(Number(event.target.value), minVal) - setMaxVal(value) - maxValRef.current = value + setMaxPercent(percent(maxValue, min.absolute, max.absolute)) + onChange?.({ min: minVal, max: maxValue }) }} data-slider-thumb="right" - aria-valuemin={min} - aria-valuemax={max} + aria-valuemin={min.absolute} + aria-valuemax={max.absolute} aria-valuenow={maxVal} aria-label={String(maxVal)} - aria-labelledby={ - getAriaValueText ? getAriaValueText(maxVal, 'max') : undefined - } + aria-labelledby={getAriaValueText?.(maxVal, 'max')} />
) diff --git a/packages/ui/src/molecules/PriceRange/PriceRange.test.tsx b/packages/ui/src/molecules/PriceRange/PriceRange.test.tsx index faf449fde3..36970c5c21 100644 --- a/packages/ui/src/molecules/PriceRange/PriceRange.test.tsx +++ b/packages/ui/src/molecules/PriceRange/PriceRange.test.tsx @@ -13,8 +13,14 @@ function formatter(price: number) { const props = { formatter, - min: 0, - max: 100, + min: { + absolute: 0, + selected: 0, + }, + max: { + absolute: 100, + selected: 100, + }, ariaLabel: 'My price range', } diff --git a/packages/ui/src/molecules/PriceRange/PriceRange.tsx b/packages/ui/src/molecules/PriceRange/PriceRange.tsx index 566ee312a6..33813fb88d 100644 --- a/packages/ui/src/molecules/PriceRange/PriceRange.tsx +++ b/packages/ui/src/molecules/PriceRange/PriceRange.tsx @@ -1,10 +1,10 @@ -import type { AriaAttributes } from 'react' import React, { useState } from 'react' +import type { AriaAttributes } from 'react' -import type { PriceProps } from '../../atoms/Price' import Price from '../../atoms/Price' -import type { SliderProps } from '../../atoms/Slider' import Slider from '../../atoms/Slider' +import type { PriceProps } from '../../atoms/Price' +import type { SliderProps } from '../../atoms/Slider' export type PriceRangeProps = SliderProps & { /** @@ -31,44 +31,36 @@ const PriceRange = ({ max, min, onChange, + onEnd, testId = 'store-price-range', variant, 'aria-label': ariaLabel, }: PriceRangeProps) => { - const [minVal, setMinVal] = useState(min) - const [maxVal, setMaxVal] = useState(max) - - const handleChange: SliderProps['onChange'] = (values) => { - if (values.min !== minVal) { - setMinVal(values.min) - } - - if (values.max !== maxVal) { - setMaxVal(values.max) - } - - onChange?.(values) - } + const [edges, setEdges] = useState({ min: min.selected, max: max.selected }) return (
{ + setEdges(value) + onChange?.(value) + }} aria-label={ariaLabel} />