diff --git a/packages/snap-controller/src/Search/README.md b/packages/snap-controller/src/Search/README.md index 20ac5b7151..07ebae24e9 100644 --- a/packages/snap-controller/src/Search/README.md +++ b/packages/snap-controller/src/Search/README.md @@ -15,9 +15,11 @@ The `SearchController` is used when making queries to the API `search` endpoint. | settings.facets.trim | facets that do not change results will be removed | true | | | settings.facets.autoOpenActive | setting for "auto open" functionality for facets that are filtered (active), collapsed, and have no stored data | true | | | settings.facets.fields | object keyed by individual facet fields for configuration of any settings.facets options | ➖ | | +| settings.filters.fields | object keyed by individual filter fields for configuration of any settings.filters options | ➖ | | | settings.filters.hierarchy.enabled | boolean to enable/disable selected hierarchy facets from showing in the filters | false | | | settings.filters.hierarchy.showFullPath | boolean to show the full hierarchy path in the filter | false | | | settings.filters.hierarchy.displayDelimiter | string to adjust the delimiter between each level of the full hierarchy path | ' / ' | | +| settings.filters.rangeFormatValue | setting to re-format the value of a range filter using sprintf | false | | | settings.history.max | how many search terms should be kept in the history store | 25 | | | settings.history.url | allows for adjust the root URL for history store terms (default is relative URLs) | ➖ | | | settings.pagination.pageSizeOptions | setting to change the page size options available | ➖ | | diff --git a/packages/snap-controller/src/Search/SearchController.test.ts b/packages/snap-controller/src/Search/SearchController.test.ts index 348399042a..705ab20cc4 100644 --- a/packages/snap-controller/src/Search/SearchController.test.ts +++ b/packages/snap-controller/src/Search/SearchController.test.ts @@ -476,8 +476,12 @@ describe('Search Controller', () => { settings: { ...searchConfig.settings, filters: { - hierarchy: { - enabled: true, + fields: { + ss_category_hierarchy: { + hiearchy: { + enabled: true, + }, + }, }, }, }, @@ -507,8 +511,12 @@ describe('Search Controller', () => { settings: { ...searchConfig.settings, filters: { - hierarchy: { - enabled: false, + fields: { + ss_category_hierarchy: { + hiearchy: { + enabled: false, + }, + }, }, }, }, @@ -537,9 +545,13 @@ describe('Search Controller', () => { settings: { ...searchConfig.settings, filters: { - hierarchy: { - enabled: true, - showFullPath: true, + fields: { + ss_category_hierarchy: { + hiearchy: { + enabled: true, + showFullPath: true, + }, + }, }, }, }, @@ -569,10 +581,14 @@ describe('Search Controller', () => { settings: { ...searchConfig.settings, filters: { - hierarchy: { - enabled: true, - displayDelimiter: ' ? ', - showFullPath: true, + fields: { + ss_category_hierarchy: { + hiearchy: { + enabled: true, + displayDelimiter: ' ? ', + showFullPath: true, + }, + }, }, }, }, diff --git a/packages/snap-controller/src/Search/SearchController.ts b/packages/snap-controller/src/Search/SearchController.ts index 4e1599de25..45fe97d1ab 100644 --- a/packages/snap-controller/src/Search/SearchController.ts +++ b/packages/snap-controller/src/Search/SearchController.ts @@ -6,7 +6,7 @@ import { StorageStore, ErrorType } from '@searchspring/snap-store-mobx'; import { getSearchParams } from '../utils/getParams'; import { ControllerTypes, PageContextVariable } from '../types'; -import type { Product, Banner, SearchStore, ValueFacet } from '@searchspring/snap-store-mobx'; +import type { Product, Banner, SearchStore, ValueFacet, SearchStoreConfig } from '@searchspring/snap-store-mobx'; import type { SearchControllerConfig, SearchAfterSearchObj, @@ -208,49 +208,58 @@ export class SearchController extends AbstractController { this.eventManager.fire('restorePosition', { controller: this, element: elementPosition }); }); - const hierarchySettings = this.config.settings?.filters?.hierarchy; - if (hierarchySettings && hierarchySettings.enabled) { - this.eventManager.on('afterSearch', async (search: SearchAfterSearchObj, next: Next): Promise => { - await next(); + this.eventManager.on('afterSearch', async (search: SearchAfterSearchObj, next: Next): Promise => { + await next(); - const displayDelimiter = hierarchySettings.displayDelimiter ?? ' / '; // choose delimiter for label - const showFullPath = hierarchySettings.showFullPath ?? false; // display full hierarchy path or just the current level - // add hierarchy filter to filter summary - const facets = search.response.search.facets; - if (facets) { - facets.forEach((facet: any) => { - if (search.response.meta?.facets && facet.field) { - const metaFacet = search.response.meta.facets[facet.field]; - const dataDelimiter = (metaFacet as MetaResponseModelFacetHierarchy)?.hierarchyDelimiter || ' / '; - - if (metaFacet && metaFacet.display === 'hierarchy' && facet.filtered && (facet as ValueFacet).values?.length > 0) { - const filteredValues = (facet as SearchResponseModelFacetValue).values?.filter((val) => val?.filtered === true); - - if (filteredValues && filteredValues.length) { - const filterToAdd: SearchResponseModelFilter = { - field: facet.field, - //escape special charactors used in regex - label: showFullPath - ? (filteredValues[0].value ?? filteredValues[0].label ?? '').replace( - new RegExp(dataDelimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), - displayDelimiter - ) - : filteredValues[0].label, - type: 'value' as SearchResponseModelFilterTypeEnum.Value, - }; - - if (search.response.search.filters) { - search.response.search.filters.push(filterToAdd); - } else { - search.response.search.filters = [filterToAdd]; - } + // add hierarchy filter to filter summary + const facets = search.response.search.facets; + if (facets) { + facets.forEach((facet: any) => { + if (search.response.meta?.facets && facet.field) { + const field = (facet.field as string) || ''; + const metaFacet = search.response.meta.facets[field]; + + const dataDelimiter = (metaFacet as MetaResponseModelFacetHierarchy)?.hierarchyDelimiter || ' / '; + const filterSettings = (this.config as SearchStoreConfig)?.settings?.filters?.fields + ? this.config?.settings?.filters?.fields![field] + : this.config?.settings?.filters; + + const displayDelimiter = filterSettings?.hiearchy?.displayDelimiter ?? ' / '; // choose delimiter for label + const showFullPath = filterSettings?.hiearchy?.showFullPath ?? false; // display full hierarchy path or just the current level + + if ( + filterSettings?.hiearchy?.enabled && + metaFacet && + metaFacet.display === 'hierarchy' && + facet.filtered && + (facet as ValueFacet).values?.length > 0 + ) { + const filteredValues = (facet as SearchResponseModelFacetValue).values?.filter((val) => val?.filtered === true); + + if (filteredValues && filteredValues.length) { + const filterToAdd: SearchResponseModelFilter = { + field: facet.field, + //escape special charactors used in regex + label: showFullPath + ? (filteredValues[0].value ?? filteredValues[0].label ?? '').replace( + new RegExp(dataDelimiter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), + displayDelimiter + ) + : filteredValues[0].label, + type: 'value' as SearchResponseModelFilterTypeEnum.Value, + }; + + if (search.response.search.filters) { + search.response.search.filters.push(filterToAdd); + } else { + search.response.search.filters = [filterToAdd]; } } } - }); - } - }); - } + } + }); + } + }); this.eventManager.on('afterStore', async (search: AfterStoreObj, next: Next): Promise => { await next(); diff --git a/packages/snap-preact-demo/templates/src/index.ts b/packages/snap-preact-demo/templates/src/index.ts index 95b7a48016..50430d16fd 100644 --- a/packages/snap-preact-demo/templates/src/index.ts +++ b/packages/snap-preact-demo/templates/src/index.ts @@ -5,12 +5,42 @@ import { combineMerge } from '../../snap/src/middleware/functions'; import type { SnapTemplatesConfig } from '@searchspring/snap-preact'; const siteId = 'atkzs2'; +// const siteId = '8uyt2m'; + +// const clientConfig = { +// meta: { +// origin: `https://${siteId}.a.searchspring.io`, +// }, +// search: { +// origin: `https://${siteId}.a.searchspring.io`, +// }, +// autocomplete: { +// requesters: { +// suggest: { +// origin: `https://${siteId}.a.searchspring.io`, +// }, +// legacy: { +// origin: `https://${siteId}.a.searchspring.io`, +// }, +// }, +// }, +// finder: { +// origin: `https://${siteId}.a.searchspring.io`, +// }, +// recommend: { +// origin: `https://${siteId}.a.searchspring.io`, +// }, +// suggest: { +// origin: `https://${siteId}.a.searchspring.io`, +// }, +// }; let config: SnapTemplatesConfig = { config: { siteId, language: 'en', currency: 'usd', platform: 'other', + // client: clientConfig }, plugins: { common: { @@ -39,7 +69,14 @@ let config: SnapTemplatesConfig = { // }, }, style: globalStyles, - overrides: {}, + overrides: { + // default: { + // 'facet': { + // rangeInputs: true, + // rangeInputsPrefix: "$", + // } + // } + }, }, recommendation: { email: { @@ -65,6 +102,18 @@ let config: SnapTemplatesConfig = { component: 'Search', }, ], + // settings: { + // filters: { + // fields: { + // 'price': { + // filterFormatValue: '$%01.2f - $%01.2f' + // }, + // 'ss_category_hierarchy': { + // enabled: true, + // } + // } + // }, + // }, }, autocomplete: { targets: [ diff --git a/packages/snap-preact/components/src/assets/athos_icon.svg b/packages/snap-preact/components/src/assets/athos_icon.svg new file mode 100644 index 0000000000..74d6754619 --- /dev/null +++ b/packages/snap-preact/components/src/assets/athos_icon.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/snap-preact/components/src/assets/athos_logo.svg b/packages/snap-preact/components/src/assets/athos_logo.svg new file mode 100644 index 0000000000..d7eef71c52 --- /dev/null +++ b/packages/snap-preact/components/src/assets/athos_logo.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/snap-preact/components/src/assets/searchspring-logo.svg b/packages/snap-preact/components/src/assets/searchspring-logo.svg new file mode 100644 index 0000000000..bbebd35ba8 --- /dev/null +++ b/packages/snap-preact/components/src/assets/searchspring-logo.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/snap-preact/components/src/components/Molecules/Filter/Filter.stories.tsx b/packages/snap-preact/components/src/components/Molecules/Filter/Filter.stories.tsx index a455803e1a..f545967d82 100644 --- a/packages/snap-preact/components/src/components/Molecules/Filter/Filter.stories.tsx +++ b/packages/snap-preact/components/src/components/Molecules/Filter/Filter.stories.tsx @@ -6,7 +6,6 @@ import { Filter, FilterProps } from './Filter'; import { iconPaths } from '../../Atoms/Icon/paths'; import { componentArgs, highlightedCode } from '../../../utilities'; import { Snapify } from '../../../utilities/snapify'; -import { FacetType } from '../../../types'; import Readme from '../Filter/readme.md'; import type { SearchRequestModelFilterValue } from '@searchspring/snapi-types'; @@ -135,10 +134,10 @@ const snapInstance = Snapify.search({ export const Default = (args: FilterProps, { loaded: { controller } }: { loaded: { controller: SearchController } }) => ( facet.type === FacetType.VALUE).shift().label} + facetLabel={controller?.store?.facets.filter((facet) => facet.type === 'value').shift().label} valueLabel={ controller?.store?.facets - .filter((facet) => facet.type === FacetType.VALUE) + .filter((facet) => facet.type === 'value') .shift() .values.shift().value } @@ -157,10 +156,10 @@ Default.loaders = [ export const NoFacetLabel = (args: FilterProps, { loaded: { controller } }: { loaded: { controller: SearchController } }) => ( facet.type === FacetType.VALUE).shift().label} + facetLabel={controller?.store?.facets.filter((facet) => facet.type === 'value').shift().label} valueLabel={ controller?.store?.facets - .filter((facet) => facet.type === FacetType.VALUE) + .filter((facet) => facet.type === 'value') .shift() .values.shift().value } diff --git a/packages/snap-preact/components/src/components/Organisms/Facet/Facet.stories.tsx b/packages/snap-preact/components/src/components/Organisms/Facet/Facet.stories.tsx index 742910b6a6..89168c39b7 100644 --- a/packages/snap-preact/components/src/components/Organisms/Facet/Facet.stories.tsx +++ b/packages/snap-preact/components/src/components/Organisms/Facet/Facet.stories.tsx @@ -84,6 +84,47 @@ export default { }, control: { type: 'boolean' }, }, + rangeInputs: { + defaultValue: false, + description: 'Enables facet range inputs', + table: { + type: { + summary: 'boolean', + }, + defaultValue: { summary: false }, + }, + control: { type: 'boolean' }, + }, + rangeInputSubmitButtonText: { + defaultValue: 'Submit', + description: 'Range input submit button text', + table: { + type: { + summary: 'string', + }, + defaultValue: { summary: 'Submit' }, + }, + control: { type: 'text' }, + }, + rangeInputsPrefix: { + description: 'Range inputs prefix text', + table: { + type: { + summary: 'string', + }, + }, + control: { type: 'text' }, + }, + rangeInputSeparatorText: { + description: 'Range inputs separator text', + table: { + type: { + summary: 'string', + }, + defaultValue: { summary: ' - ' }, + }, + control: { type: 'text' }, + }, color: { description: 'Select color', table: { @@ -331,7 +372,7 @@ export default { }, }; -const snapInstance = Snapify.search({ id: 'Facet', globals: { siteId: '8uyt2m' } }); +const snapInstance = Snapify.search({ id: 'Facet', globals: { siteId: 'atkzs2' } }); // List Facet @@ -380,7 +421,13 @@ Slider.loaders = [ // Palette Facet const ObservablePaletteFacet = observer(({ args, controller }: { args: FacetProps; controller: SearchController }) => { - return facet.display === FacetDisplay.PALETTE).shift()} />; + const facet = controller?.store?.facets.filter((facet) => facet.display === FacetDisplay.PALETTE).shift(); + if (facet) { + return ; + } + + // prevent error when no facet is found... + return
; }); export const Palette = (args: FacetProps, { loaded: { controller } }: { loaded: { controller: SearchController } }) => ( @@ -399,7 +446,13 @@ Palette.loaders = [ // Grid Facet const ObservableGridFacet = observer(({ args, controller }: { args: FacetProps; controller: SearchController }) => { - return facet.field === 'size_dress').pop()} />; + const facet = controller?.store?.facets.filter((facet) => facet.field === 'collection_handle').pop(); + if (facet) { + return ; + } + + // prevent error when no facet is found... + return
; }); export const Grid = (args: FacetProps, { loaded: { controller } }: { loaded: { controller: SearchController } }) => ( @@ -418,7 +471,11 @@ Grid.loaders = [ const ObservableHierarchyFacet = observer(({ args, controller }: { args: FacetProps; controller: SearchController }) => { const facet = controller?.store?.facets.filter((facet) => facet.display === FacetDisplay.HIERARCHY).shift(); - return ; + if (facet) { + return ; + } + // prevent error when no facet is found... + return
; }); export const Hierarchy = (args: FacetProps, { loaded: { controller } }: { loaded: { controller: SearchController } }) => ( diff --git a/packages/snap-preact/components/src/components/Organisms/Facet/Facet.test.tsx b/packages/snap-preact/components/src/components/Organisms/Facet/Facet.test.tsx index 8826480abf..24392a25ee 100644 --- a/packages/snap-preact/components/src/components/Organisms/Facet/Facet.test.tsx +++ b/packages/snap-preact/components/src/components/Organisms/Facet/Facet.test.tsx @@ -390,6 +390,40 @@ describe('Facet Component', () => { const optionsElement = rendered.container.querySelector('.ss__facet__options'); expect(optionsElement).toHaveTextContent('Summer'); }); + + it('rangeInputs, rangeInputSubmitButtonText & rangeInputsPrefix props', () => { + const args = { + //@ts-ignore + facet: { ...sliderFacetMock, display: 'slider', type: 'range' } as RangeFacet, + rangeInputs: true, + rangeInputSubmitButtonText: 'Go', + rangeInputsPrefix: '$', + rangeInputSeparatorText: '- to -', + }; + args.facet.collapsed = false; + + const rendered = render(); + const facetElement = rendered.container.querySelector('.ss__facet__options')!; + expect(facetElement).toBeInTheDocument(); + + const rangeInputsElement = rendered.container.querySelector('.ss__facet__range-inputs'); + expect(rangeInputsElement).toBeInTheDocument(); + + const inputs = rangeInputsElement?.querySelectorAll('.ss__facet__range-input'); + expect(inputs?.length).toBe(2); + + const prefixes = rangeInputsElement?.querySelectorAll('.ss__facet__range-input__prefix'); + expect(prefixes?.length).toBe(2); + expect(prefixes?.[0]).toHaveTextContent(args.rangeInputsPrefix); + + const submitButton = rangeInputsElement?.querySelector('.ss__facet__range-input__button--submit'); + expect(submitButton).toBeInTheDocument(); + expect(submitButton).toHaveTextContent(args.rangeInputSubmitButtonText); + + const sepElem = rangeInputsElement?.querySelector('.ss__facet__range-inputs__separator'); + expect(sepElem).toBeInTheDocument(); + expect(sepElem).toHaveTextContent(args.rangeInputSeparatorText); + }); }); it('renders with classname', () => { @@ -662,7 +696,7 @@ describe('Facet Component', () => { const propTheme = { components: { facet: { - className: 'classy', + className: 'test-class', }, }, }; diff --git a/packages/snap-preact/components/src/components/Organisms/Facet/Facet.tsx b/packages/snap-preact/components/src/components/Organisms/Facet/Facet.tsx index 3446de18d1..f5db0dcf02 100644 --- a/packages/snap-preact/components/src/components/Organisms/Facet/Facet.tsx +++ b/packages/snap-preact/components/src/components/Organisms/Facet/Facet.tsx @@ -1,4 +1,5 @@ import { h, Fragment } from 'preact'; +import { MutableRef, useRef, useState } from 'preact/hooks'; import { jsx, css } from '@emotion/react'; import classnames from 'classnames'; @@ -22,6 +23,7 @@ import { useA11y } from '../../../hooks/useA11y'; import { Lang, useLang } from '../../../hooks'; import deepmerge from 'deepmerge'; import { Button, ButtonProps } from '../../Atoms/Button'; +import { LangAttributesObj } from '../../../hooks/useLang'; const defaultStyles: StyleScript = ({ disableCollapse, color, theme }) => { return css({ @@ -78,6 +80,44 @@ const defaultStyles: StyleScript = ({ disableCollapse, color, theme '& .ss__facet__header__selected-count': { margin: '0px 5px', }, + + '.ss__facet__range-inputs': { + display: 'flex', + flexDirection: 'column', + + '.ss__facet__range-inputs__separator': { + margin: '5px', + }, + }, + + '.ss__facet__range-inputs__row': { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + '&.ss__facet__range-inputs__row--button-wrapper': { + justifyContent: 'center', + + '.ss__facet__range-input__button--submit': { + margin: '10px', + }, + }, + }, + + '.ss__facet__range-input': { + flexDirection: 'row', + display: 'flex', + border: `1px solid ${theme?.variables?.colors?.secondary || '#ccc'}`, + backgroundColor: 'white', + alignItems: 'center', + '.ss__facet__range-input__prefix': { + padding: '0 5px', + }, + '.ss__facet__range-input__input': { + width: '100%', + border: 'none', + minHeight: '35px', + }, + }, }); }; @@ -94,6 +134,8 @@ export const Facet = observer((properties: FacetProps): JSX.Element => { iconOverflowMore: 'plus', iconOverflowLess: 'minus', clearAllText: 'Clear All', + rangeInputSubmitButtonText: 'Submit', + rangeInputSeparatorText: ' - ', searchable: false, treePath: globalTreePath, }; @@ -333,6 +375,9 @@ export const Facet = observer((properties: FacetProps): JSX.Element => { clearAllText: { value: facetContentProps.clearAllText, }, + submitRangeButton: { + value: facetContentProps.rangeInputSubmitButtonText, + }, }; //deep merge with props.lang @@ -340,7 +385,6 @@ export const Facet = observer((properties: FacetProps): JSX.Element => { const mergedLang = useLang(lang as any, { facet, }); - facetContentProps.lang = mergedLang; const selectedCount = (facet as ValueFacet)?.values?.filter((value) => value?.filtered).length; @@ -359,7 +403,7 @@ export const Facet = observer((properties: FacetProps): JSX.Element => { )} > {justContent ? ( - + ) : ( { } > - + )} @@ -419,7 +463,17 @@ export const Facet = observer((properties: FacetProps): JSX.Element => { ); }); -const FacetContent = (props: any) => { +const FacetContent = ( + props: FacetProps & { + limitedValues: (FacetHierarchyValue | FacetValue | FacetRangeValue | undefined)[]; + searchableFacet: { + allowableTypes: string[]; + searchFilter: (e: React.ChangeEvent) => void; + }; + subProps: FacetSubProps; + mergedLang: LangAttributesObj; + } +) => { const { searchableFacet, subProps, @@ -435,13 +489,34 @@ const FacetContent = (props: any) => { iconOverflowLess, disableOverflow, previewOnFocus, + rangeInputs, + rangeInputsPrefix, + rangeInputSeparatorText, justContent, valueProps, hideShowMoreLessText, treePath, - lang, + mergedLang, } = props; + const [low, setLow] = useState(); + const [high, setHigh] = useState(); + + const onDragcb = (vals: number[]) => { + setLow(vals[0]); + setHigh(vals[1]); + }; + + const onKeyUp = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + if (typeof low == 'number' && typeof high == 'number') { + submitButtonRef.current?.base?.click(); + } + } + }; + + const submitButtonRef: MutableRef = useRef(); + return ( {searchable && searchableFacet.allowableTypes.includes(facet.display) && ( @@ -462,7 +537,7 @@ const FacetContent = (props: any) => { // case FacetDisplay.TOGGLE: // return ; case FacetDisplay.SLIDER: - return ; + return ; case FacetDisplay.GRID: return ( { })()} + {rangeInputs && (facet.type === 'range' || facet.type === 'range-buckets') && ( +
+
+
+ {rangeInputsPrefix && {rangeInputsPrefix}} + setLow(Number(e.currentTarget.value) || 0)} + onKeyUp={onKeyUp} + /> +
+ + {rangeInputSeparatorText} + +
+ {rangeInputsPrefix && {rangeInputsPrefix}} + setHigh(Number(e.currentTarget.value) || 0)} + onKeyUp={onKeyUp} + /> +
+
+
+ +
+
+ )} + {!disableOverflow && (facet as ValueFacet)?.overflow?.enabled && (
{ : { ...(typeof iconOverflowLess == 'string' ? { icon: iconOverflowLess } : (iconOverflowLess as Partial)) })} /> {!hideShowMoreLessText && ( - 0 ? lang.showMoreText?.all : lang.showLessText?.all)}> + 0 ? mergedLang!.showMoreText?.all : mergedLang!.showLessText?.all)} + > )} )} @@ -576,6 +726,10 @@ interface OptionalFacetProps extends ComponentProps { fields?: FieldProps; display?: FieldProps; searchable?: boolean; + rangeInputs?: boolean; + rangeInputSubmitButtonText?: string; + rangeInputsPrefix?: string; + rangeInputSeparatorText?: string; justContent?: boolean; horizontal?: boolean; lang?: Partial; @@ -594,6 +748,9 @@ export interface FacetLang { clearAllText: Lang<{ facet: ValueFacet | RangeFacet; }>; + submitRangeButton: Lang<{ + facet: ValueFacet | RangeFacet; + }>; } type FieldProps = { diff --git a/packages/snap-preact/components/src/components/Organisms/Facet/readme.md b/packages/snap-preact/components/src/components/Organisms/Facet/readme.md index 85f3e2a408..865301bf0d 100644 --- a/packages/snap-preact/components/src/components/Organisms/Facet/readme.md +++ b/packages/snap-preact/components/src/components/Organisms/Facet/readme.md @@ -126,6 +126,34 @@ The `hideSelectedCountParenthesis` prop specifies if the parenthesis should rend ``` +### rangeInputs +The `rangeInputs` prop specifies if the range inputs should render. + +```jsx + +``` + +### rangeInputSubmitButtonText +The `rangeInputSubmitButtonText` prop specifies the text to be rendered in the range input submit button. + +```jsx + +``` + +### rangeInputsPrefix +The `rangeInputsPrefix` prop specifies the prefix to render next to the range inputs. + +```jsx + +``` + +### rangeInputSeparatorText +The `rangeInputSeparatorText` prop specifies the separator text to render between the range inputs. + +```jsx + +``` + ### showClearAllText The `showClearAllText` prop specifies if the clear all text should render. diff --git a/packages/snap-preact/components/src/types.ts b/packages/snap-preact/components/src/types.ts index 1bac10bb80..bbb06893a2 100644 --- a/packages/snap-preact/components/src/types.ts +++ b/packages/snap-preact/components/src/types.ts @@ -74,13 +74,6 @@ export enum ResultsLayout { list = 'list', } -// TODO: move to store or use store or snapi types -export enum FacetType { - VALUE = 'value', - RANGE = 'range', - RANGE_BUCKETS = 'range-buckets', -} - // TODO: should be added to the Facet type export enum FacetDisplay { GRID = 'grid', diff --git a/packages/snap-preact/src/Templates/Stores/library/languages/es.ts b/packages/snap-preact/src/Templates/Stores/library/languages/es.ts index da99ba1392..e8926cbbb9 100644 --- a/packages/snap-preact/src/Templates/Stores/library/languages/es.ts +++ b/packages/snap-preact/src/Templates/Stores/library/languages/es.ts @@ -173,6 +173,9 @@ export const es: LangComponents = { }`, }, }, + submitRangeButton: { + value: 'Entregar', + }, }, select: { buttonLabel: { diff --git a/packages/snap-preact/src/Templates/Stores/library/languages/fr.ts b/packages/snap-preact/src/Templates/Stores/library/languages/fr.ts index 27703ac619..68e3c83691 100644 --- a/packages/snap-preact/src/Templates/Stores/library/languages/fr.ts +++ b/packages/snap-preact/src/Templates/Stores/library/languages/fr.ts @@ -170,6 +170,9 @@ export const fr: LangComponents = { }`, }, }, + submitRangeButton: { + value: 'Soumettre', + }, }, select: { buttonLabel: { diff --git a/packages/snap-store-mobx/src/Autocomplete/AutocompleteStore.ts b/packages/snap-store-mobx/src/Autocomplete/AutocompleteStore.ts index d6cfee41a8..309a269a68 100644 --- a/packages/snap-store-mobx/src/Autocomplete/AutocompleteStore.ts +++ b/packages/snap-store-mobx/src/Autocomplete/AutocompleteStore.ts @@ -229,6 +229,7 @@ export class AutocompleteStore extends AbstractStore { } this.filters = new SearchFilterStore({ + config: this.config, services: this.services, data: { search, diff --git a/packages/snap-store-mobx/src/Search/SearchStore.ts b/packages/snap-store-mobx/src/Search/SearchStore.ts index 38d093f746..db712597a8 100644 --- a/packages/snap-store-mobx/src/Search/SearchStore.ts +++ b/packages/snap-store-mobx/src/Search/SearchStore.ts @@ -96,6 +96,7 @@ export class SearchStore extends AbstractStore { }); this.filters = new SearchFilterStore({ + config: this.config, services: this.services, data: { search, diff --git a/packages/snap-store-mobx/src/Search/Stores/SearchFacetStore.ts b/packages/snap-store-mobx/src/Search/Stores/SearchFacetStore.ts index 78fa7f465e..68213c4b29 100644 --- a/packages/snap-store-mobx/src/Search/Stores/SearchFacetStore.ts +++ b/packages/snap-store-mobx/src/Search/Stores/SearchFacetStore.ts @@ -105,7 +105,7 @@ export class SearchFacetStore extends Array { export class Facet { public services: StoreServices; - public type!: string; + public type!: 'range' | 'value' | 'range-buckets'; public field!: string; public filtered = false; public custom = {}; diff --git a/packages/snap-store-mobx/src/Search/Stores/SearchFilterStore.test.ts b/packages/snap-store-mobx/src/Search/Stores/SearchFilterStore.test.ts index 43dc8369b1..d9faedeb5b 100644 --- a/packages/snap-store-mobx/src/Search/Stores/SearchFilterStore.test.ts +++ b/packages/snap-store-mobx/src/Search/Stores/SearchFilterStore.test.ts @@ -100,4 +100,37 @@ describe('Filter Store', () => { expect(filter.url.constructor.name).toStrictEqual(services.urlManager.constructor.name); }); }); + + it('can format the range label using rangeFormatValue config', () => { + const rangeFilterInput = searchData.search.filters?.find((filter) => filter.type === 'range') as SearchResponseModelFilterRange; + expect(rangeFilterInput).toBeDefined(); + const rangeField = rangeFilterInput.field!; + + const filters = new SearchFilterStore({ + services, + data: { + search: searchData.search, + meta: searchData.meta, + }, + config: { + id: 'test', + settings: { + filters: { + fields: { + [rangeField]: { + rangeFormatValue: '$%01.2f - $%01.2f', + }, + }, + }, + }, + }, + }); + + const rangeFilter = filters.find((filter) => filter.facet.field === rangeField); + expect(rangeFilter).toBeDefined(); + + // If high=100, low=10, it prints "$10.00 - $100.00" + const expectedLabel = `$${rangeFilterInput.value?.low?.toFixed(2)} - $${rangeFilterInput.value?.high?.toFixed(2)}`; + expect(rangeFilter?.value.label).toBe(expectedLabel); + }); }); diff --git a/packages/snap-store-mobx/src/Search/Stores/SearchFilterStore.ts b/packages/snap-store-mobx/src/Search/Stores/SearchFilterStore.ts index 45aabd03fc..c166fd7e75 100644 --- a/packages/snap-store-mobx/src/Search/Stores/SearchFilterStore.ts +++ b/packages/snap-store-mobx/src/Search/Stores/SearchFilterStore.ts @@ -1,7 +1,7 @@ import { makeObservable, observable } from 'mobx'; import type { UrlManager } from '@searchspring/snap-url-manager'; -import type { StoreServices } from '../../types'; +import type { AutocompleteStoreConfig, SearchStoreConfig, StoreServices } from '../../types'; import type { SearchResponseModelFilterRange, SearchResponseModelFilterValue, @@ -10,9 +10,11 @@ import type { SearchResponseModel, MetaResponseModel, } from '@searchspring/snapi-types'; +import { sprintf } from '@searchspring/snap-toolbox'; type SearchFilterStoreConfig = { services: StoreServices; + config?: SearchStoreConfig | AutocompleteStoreConfig; data: { search: SearchResponseModel; meta: MetaResponseModel; @@ -25,7 +27,7 @@ export class SearchFilterStore extends Array { } constructor(params: SearchFilterStoreConfig) { - const { services, data } = params || {}; + const { services, data, config } = params || {}; const { search, meta } = data || {}; const { filters } = search || {}; @@ -37,6 +39,12 @@ export class SearchFilterStore extends Array { switch (filter.type) { case 'range': const rangeFilter = filter as SearchResponseModelFilterRange; + const format = + (config as SearchStoreConfig)?.settings?.filters?.fields?.[filter.field!]?.rangeFormatValue || + (config as SearchStoreConfig)?.settings?.filters?.rangeFormatValue; + if (format) { + rangeFilter.label = sprintf(format, rangeFilter.value?.low, rangeFilter.value?.high); + } return new RangeFilter(services, rangeFilter, facetMeta!); case 'value': diff --git a/packages/snap-store-mobx/src/types.ts b/packages/snap-store-mobx/src/types.ts index 639de533e0..e2568ab709 100644 --- a/packages/snap-store-mobx/src/types.ts +++ b/packages/snap-store-mobx/src/types.ts @@ -24,11 +24,9 @@ export type SearchStoreConfigSettings = { [field: string]: FacetStoreConfig; }; }; - filters?: { - hierarchy?: { - enabled?: boolean; - displayDelimiter?: string; - showFullPath?: boolean; + filters?: FilterStoreConfig & { + fields?: { + [field: string]: FilterStoreConfig; }; }; infinite?: { @@ -86,6 +84,15 @@ export type SearchStoreConfig = StoreConfig & { settings?: SearchStoreConfigSettings; }; +export type FilterStoreConfig = { + rangeFormatValue?: string; + hiearchy?: { + enabled?: boolean; + displayDelimiter?: string; + showFullPath?: boolean; + }; +}; + export type FacetStoreConfig = { trim?: boolean; pinFiltered?: boolean; diff --git a/packages/snap-toolbox/src/index.ts b/packages/snap-toolbox/src/index.ts index 165c4805a3..420b478c7a 100644 --- a/packages/snap-toolbox/src/index.ts +++ b/packages/snap-toolbox/src/index.ts @@ -11,3 +11,4 @@ export * from './url/url'; export * from './version/version'; export * from './charsParams/charsParams'; export * from './types'; +export * from './sprintf/sprintf'; diff --git a/packages/snap-toolbox/src/sprintf/sprintf.test.ts b/packages/snap-toolbox/src/sprintf/sprintf.test.ts new file mode 100644 index 0000000000..9823f9c342 --- /dev/null +++ b/packages/snap-toolbox/src/sprintf/sprintf.test.ts @@ -0,0 +1,79 @@ +import { sprintf } from './sprintf'; + +describe('sprintf', () => { + it('should substitute strings', () => { + expect(sprintf('Hello %s!', 'World')).toBe('Hello World!'); + expect(sprintf('%s %s', 'Hello', 'World')).toBe('Hello World'); + }); + + it('should percent sign', () => { + expect(sprintf('100%%')).toBe('100%'); + }); + + it('should substitute numbers', () => { + expect(sprintf('%d', 123)).toBe('123'); + expect(sprintf('%d', -123)).toBe('-123'); + expect(sprintf('%+d', 123)).toBe('+123'); + expect(sprintf('%+d', -123)).toBe('-123'); + }); + + it('should format binary', () => { + expect(sprintf('%b', 2)).toBe('10'); + expect(sprintf('%b', 10)).toBe('1010'); + }); + + it('should format characters', () => { + expect(sprintf('%c', 65)).toBe('A'); + }); + + it('should format scientific notation', () => { + expect(sprintf('%e', 100)).toBe('1e+2'); + expect(sprintf('%.2e', 100)).toBe('1.00e+2'); + }); + + it('should format floats', () => { + expect(sprintf('%.2f', 1.23456)).toBe('1.23'); + expect(sprintf('%.2f', 1.2)).toBe('1.20'); + expect(sprintf('%f', 1.2)).toBe('1.2'); // Implementation detail: default handling + }); + + it('should format octal', () => { + expect(sprintf('%o', 8)).toBe('10'); + }); + + it('should format unsigned', () => { + expect(sprintf('%u', -123)).toBe('123'); + }); + + it('should format hex', () => { + expect(sprintf('%x', 255)).toBe('ff'); + expect(sprintf('%X', 255)).toBe('FF'); + }); + + it('should handle padding', () => { + expect(sprintf('%05d', 123)).toBe('00123'); + expect(sprintf("%'a5d", 123)).toBe('aa123'); + expect(sprintf('%-5d', 123)).toBe('123 '); + }); + + it('should handle argument swapping', () => { + expect(sprintf('%2$s %1$s', 'World', 'Hello')).toBe('Hello World'); + }); + + it('should handle precision on strings', () => { + expect(sprintf('%.3s', 'abcdef')).toBe('abc'); + }); + + it('should error on too few arguments', () => { + expect(() => sprintf('%s')).toThrow('Too few arguments.'); + }); + + it('should error on invalid type', () => { + expect(() => sprintf('%d', 'string')).toThrow('Expecting number but found string'); + }); + + it('should complex combinations', () => { + // Note: The implementation places the sign after the zero-padding, which is non-standard but tested here for regression. + expect(sprintf('%+010d', 123)).toBe('000000+123'); + }); +}); diff --git a/packages/snap-toolbox/src/sprintf/sprintf.ts b/packages/snap-toolbox/src/sprintf/sprintf.ts new file mode 100644 index 0000000000..b28d7743d3 --- /dev/null +++ b/packages/snap-toolbox/src/sprintf/sprintf.ts @@ -0,0 +1,73 @@ +function str_repeat(i: string, m: number): string { + const o: string[] = []; + for (; m > 0; o[--m] = i); + return o.join(''); +} + +export function sprintf(format: string, ...args: any[]): string { + const argv = [format, ...args]; + let i = 0; + let a: any; + let f = argv[i++] as string; + let m: RegExpExecArray | null; + let p: string; + let c: string; + let x: number; + const o: string[] = []; + + while (f) { + if ((m = /^[^\x25]+/.exec(f))) { + o.push(m[0]); + } else if ((m = /^\x25{2}/.exec(f))) { + o.push('%'); + } else if ((m = /^\x25(?:(\d+)\$)?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosuxX])/.exec(f))) { + if ((a = argv[parseInt(m[1]) || i++]) == null || a == undefined) { + throw 'Too few arguments.'; + } + if (/[^s]/.test(m[7]) && typeof a != 'number') { + throw 'Expecting number but found ' + typeof a; + } + switch (m[7]) { + case 'b': + a = (a as number).toString(2); + break; + case 'c': + a = String.fromCharCode(a as number); + break; + case 'd': + a = parseInt(a as string); + break; + case 'e': + a = m[6] ? (a as number).toExponential(parseInt(m[6])) : (a as number).toExponential(); + break; + case 'f': + a = m[6] ? parseFloat(a as string).toFixed(parseInt(m[6])) : parseFloat(a as string); + break; + case 'o': + a = (a as number).toString(8); + break; + case 's': + a = (a = String(a)) && m[6] ? a.substring(0, parseInt(m[6])) : a; + break; + case 'u': + a = Math.abs(a as number); + break; + case 'x': + a = (a as number).toString(16); + break; + case 'X': + a = (a as number).toString(16).toUpperCase(); + break; + } + a = /[def]/.test(m[7]) && m[2] && a > 0 ? '+' + a : a; + c = m[3] ? (m[3] == '0' ? '0' : m[3].charAt(1)) : ' '; + x = (m[5] ? parseInt(m[5]) : 0) - String(a).length; + p = m[5] ? str_repeat(c, x) : ''; + o.push(m[4] ? a + p : p + a); + } else { + throw new Error('sprintf: Invalid format string encountered'); + } + f = f.substring(m![0].length); + } + return o.join(''); +}