From bb343f52cce07e5cb81330aef199049560e9e5ce Mon Sep 17 00:00:00 2001 From: Yen Truong Date: Wed, 16 Nov 2022 12:06:03 -0500 Subject: [PATCH] add geolocation component, with jest tests and storybook stories --- .storybook/preview.js | 1 + docs/search-ui-react.geolocation_2.md | 26 ++ ...h-ui-react.geolocationcssclasses.button.md | 11 + ...locationcssclasses.geolocationcontainer.md | 11 + ...act.geolocationcssclasses.iconcontainer.md | 11 + docs/search-ui-react.geolocationcssclasses.md | 22 ++ ...react.geolocationprops.customcssclasses.md | 13 + ...-react.geolocationprops.geolocationicon.md | 13 + ...act.geolocationprops.geolocationoptions.md | 13 + .../search-ui-react.geolocationprops.label.md | 13 + docs/search-ui-react.geolocationprops.md | 25 ++ ...earch-ui-react.geolocationprops.onclick.md | 13 + ...search-ui-react.geolocationprops.radius.md | 13 + docs/search-ui-react.md | 3 + etc/search-ui-react.api.md | 24 ++ package.json | 2 +- src/components/AppliedFilters.tsx | 4 +- src/components/Geolocation.tsx | 124 ++++++++++ src/components/index.ts | 6 + test-site/src/pages/ProductsPage.tsx | 4 +- tests/components/Geolocation.stories.tsx | 39 +++ tests/components/Geolocation.test.tsx | 226 ++++++++++++++++++ 22 files changed, 612 insertions(+), 5 deletions(-) create mode 100644 docs/search-ui-react.geolocation_2.md create mode 100644 docs/search-ui-react.geolocationcssclasses.button.md create mode 100644 docs/search-ui-react.geolocationcssclasses.geolocationcontainer.md create mode 100644 docs/search-ui-react.geolocationcssclasses.iconcontainer.md create mode 100644 docs/search-ui-react.geolocationcssclasses.md create mode 100644 docs/search-ui-react.geolocationprops.customcssclasses.md create mode 100644 docs/search-ui-react.geolocationprops.geolocationicon.md create mode 100644 docs/search-ui-react.geolocationprops.geolocationoptions.md create mode 100644 docs/search-ui-react.geolocationprops.label.md create mode 100644 docs/search-ui-react.geolocationprops.md create mode 100644 docs/search-ui-react.geolocationprops.onclick.md create mode 100644 docs/search-ui-react.geolocationprops.radius.md create mode 100644 src/components/Geolocation.tsx create mode 100644 tests/components/Geolocation.stories.tsx create mode 100644 tests/components/Geolocation.test.tsx diff --git a/.storybook/preview.js b/.storybook/preview.js index 9bf99b604..f0e0b4f44 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -36,6 +36,7 @@ export const parameters = { 'AlternativeVerticals', 'SpellCheck', 'ResultsCount', + 'Geolocation', 'LocationBias', 'Dropdown' ] diff --git a/docs/search-ui-react.geolocation_2.md b/docs/search-ui-react.geolocation_2.md new file mode 100644 index 000000000..857d35992 --- /dev/null +++ b/docs/search-ui-react.geolocation_2.md @@ -0,0 +1,26 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [Geolocation\_2](./search-ui-react.geolocation_2.md) + +## Geolocation\_2() function + +A React Component which collects location information to create a location filter and perform a new search. + +Signature: + +```typescript +export declare function Geolocation({ geolocationOptions, radius, label, GeolocationIcon, onClick, customCssClasses, }: GeolocationProps): JSX.Element | null; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| { geolocationOptions, radius, label, GeolocationIcon, onClick, customCssClasses, } | [GeolocationProps](./search-ui-react.geolocationprops.md) | | + +Returns: + +JSX.Element \| null + +A react component for geolocation + diff --git a/docs/search-ui-react.geolocationcssclasses.button.md b/docs/search-ui-react.geolocationcssclasses.button.md new file mode 100644 index 000000000..a140dab75 --- /dev/null +++ b/docs/search-ui-react.geolocationcssclasses.button.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationCssClasses](./search-ui-react.geolocationcssclasses.md) > [button](./search-ui-react.geolocationcssclasses.button.md) + +## GeolocationCssClasses.button property + +Signature: + +```typescript +button?: string; +``` diff --git a/docs/search-ui-react.geolocationcssclasses.geolocationcontainer.md b/docs/search-ui-react.geolocationcssclasses.geolocationcontainer.md new file mode 100644 index 000000000..377c506d5 --- /dev/null +++ b/docs/search-ui-react.geolocationcssclasses.geolocationcontainer.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationCssClasses](./search-ui-react.geolocationcssclasses.md) > [geolocationContainer](./search-ui-react.geolocationcssclasses.geolocationcontainer.md) + +## GeolocationCssClasses.geolocationContainer property + +Signature: + +```typescript +geolocationContainer?: string; +``` diff --git a/docs/search-ui-react.geolocationcssclasses.iconcontainer.md b/docs/search-ui-react.geolocationcssclasses.iconcontainer.md new file mode 100644 index 000000000..467401468 --- /dev/null +++ b/docs/search-ui-react.geolocationcssclasses.iconcontainer.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationCssClasses](./search-ui-react.geolocationcssclasses.md) > [iconContainer](./search-ui-react.geolocationcssclasses.iconcontainer.md) + +## GeolocationCssClasses.iconContainer property + +Signature: + +```typescript +iconContainer?: string; +``` diff --git a/docs/search-ui-react.geolocationcssclasses.md b/docs/search-ui-react.geolocationcssclasses.md new file mode 100644 index 000000000..d56002fda --- /dev/null +++ b/docs/search-ui-react.geolocationcssclasses.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationCssClasses](./search-ui-react.geolocationcssclasses.md) + +## GeolocationCssClasses interface + +The CSS class interface for the Geolocation component. + +Signature: + +```typescript +export interface GeolocationCssClasses +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [button?](./search-ui-react.geolocationcssclasses.button.md) | string | (Optional) | +| [geolocationContainer?](./search-ui-react.geolocationcssclasses.geolocationcontainer.md) | string | (Optional) | +| [iconContainer?](./search-ui-react.geolocationcssclasses.iconcontainer.md) | string | (Optional) | + diff --git a/docs/search-ui-react.geolocationprops.customcssclasses.md b/docs/search-ui-react.geolocationprops.customcssclasses.md new file mode 100644 index 000000000..b4b7a71f1 --- /dev/null +++ b/docs/search-ui-react.geolocationprops.customcssclasses.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationProps](./search-ui-react.geolocationprops.md) > [customCssClasses](./search-ui-react.geolocationprops.customcssclasses.md) + +## GeolocationProps.customCssClasses property + +CSS classes for customizing the component styling. + +Signature: + +```typescript +customCssClasses?: GeolocationCssClasses; +``` diff --git a/docs/search-ui-react.geolocationprops.geolocationicon.md b/docs/search-ui-react.geolocationprops.geolocationicon.md new file mode 100644 index 000000000..31b97945c --- /dev/null +++ b/docs/search-ui-react.geolocationprops.geolocationicon.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationProps](./search-ui-react.geolocationprops.md) > [GeolocationIcon](./search-ui-react.geolocationprops.geolocationicon.md) + +## GeolocationProps.GeolocationIcon property + +Custom icon component to display along with the button. + +Signature: + +```typescript +GeolocationIcon?: React.FC; +``` diff --git a/docs/search-ui-react.geolocationprops.geolocationoptions.md b/docs/search-ui-react.geolocationprops.geolocationoptions.md new file mode 100644 index 000000000..2b3a2020f --- /dev/null +++ b/docs/search-ui-react.geolocationprops.geolocationoptions.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationProps](./search-ui-react.geolocationprops.md) > [geolocationOptions](./search-ui-react.geolocationprops.geolocationoptions.md) + +## GeolocationProps.geolocationOptions property + +Configuration used when collecting the user's location. Definition: [https://w3c.github.io/geolocation-api/\#position\_options\_interface](https://w3c.github.io/geolocation-api/#position_options_interface). + +Signature: + +```typescript +geolocationOptions?: PositionOptions; +``` diff --git a/docs/search-ui-react.geolocationprops.label.md b/docs/search-ui-react.geolocationprops.label.md new file mode 100644 index 000000000..128d01079 --- /dev/null +++ b/docs/search-ui-react.geolocationprops.label.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationProps](./search-ui-react.geolocationprops.md) > [label](./search-ui-react.geolocationprops.label.md) + +## GeolocationProps.label property + +The label for the button. Defaults to 'Use my location'. + +Signature: + +```typescript +label?: string; +``` diff --git a/docs/search-ui-react.geolocationprops.md b/docs/search-ui-react.geolocationprops.md new file mode 100644 index 000000000..5dedd4a7c --- /dev/null +++ b/docs/search-ui-react.geolocationprops.md @@ -0,0 +1,25 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationProps](./search-ui-react.geolocationprops.md) + +## GeolocationProps interface + +The props for the Geolocation component. + +Signature: + +```typescript +export interface GeolocationProps +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [customCssClasses?](./search-ui-react.geolocationprops.customcssclasses.md) | [GeolocationCssClasses](./search-ui-react.geolocationcssclasses.md) | (Optional) CSS classes for customizing the component styling. | +| [GeolocationIcon?](./search-ui-react.geolocationprops.geolocationicon.md) | React.FC | (Optional) Custom icon component to display along with the button. | +| [geolocationOptions?](./search-ui-react.geolocationprops.geolocationoptions.md) | PositionOptions | (Optional) Configuration used when collecting the user's location. Definition: [https://w3c.github.io/geolocation-api/\#position\_options\_interface](https://w3c.github.io/geolocation-api/#position_options_interface). | +| [label?](./search-ui-react.geolocationprops.label.md) | string | (Optional) The label for the button. Defaults to 'Use my location'. | +| [onClick?](./search-ui-react.geolocationprops.onclick.md) | () => void | (Optional) A function which is called when the geolocation button is clicked. This is called prior to executing the existing click behavior. | +| [radius?](./search-ui-react.geolocationprops.radius.md) | number | (Optional) The radius, in miles, around the user's location to find results. Defaults to 50. If location accuracy is low, a larger radius may be used automatically. | + diff --git a/docs/search-ui-react.geolocationprops.onclick.md b/docs/search-ui-react.geolocationprops.onclick.md new file mode 100644 index 000000000..b1f591ab1 --- /dev/null +++ b/docs/search-ui-react.geolocationprops.onclick.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationProps](./search-ui-react.geolocationprops.md) > [onClick](./search-ui-react.geolocationprops.onclick.md) + +## GeolocationProps.onClick property + +A function which is called when the geolocation button is clicked. This is called prior to executing the existing click behavior. + +Signature: + +```typescript +onClick?: () => void; +``` diff --git a/docs/search-ui-react.geolocationprops.radius.md b/docs/search-ui-react.geolocationprops.radius.md new file mode 100644 index 000000000..62d171e06 --- /dev/null +++ b/docs/search-ui-react.geolocationprops.radius.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@yext/search-ui-react](./search-ui-react.md) > [GeolocationProps](./search-ui-react.geolocationprops.md) > [radius](./search-ui-react.geolocationprops.radius.md) + +## GeolocationProps.radius property + +The radius, in miles, around the user's location to find results. Defaults to 50. If location accuracy is low, a larger radius may be used automatically. + +Signature: + +```typescript +radius?: number; +``` diff --git a/docs/search-ui-react.md b/docs/search-ui-react.md index b5da0d550..d85031326 100644 --- a/docs/search-ui-react.md +++ b/docs/search-ui-react.md @@ -18,6 +18,7 @@ | [executeSearch(searchActions)](./search-ui-react.executesearch.md) | Executes a universal/vertical search. | | [FilterDivider({ className })](./search-ui-react.filterdivider.md) | A divider component used to separate NumericalFacets, HierarchicalFacets, StandardFacets, and StaticFilters. | | [FilterSearch({ searchFields, label, placeholder, searchOnSelect, onSelect, sectioned, customCssClasses })](./search-ui-react.filtersearch.md) | A component which allows a user to search for filters associated with specific entities and fields. | +| [Geolocation\_2({ geolocationOptions, radius, label, GeolocationIcon, onClick, customCssClasses, })](./search-ui-react.geolocation_2.md) | A React Component which collects location information to create a location filter and perform a new search. | | [getSearchIntents(searchActions)](./search-ui-react.getsearchintents.md) | Get search intents of the current query stored in headless using autocomplete request. | | [getUserLocation(geolocationOptions)](./search-ui-react.getuserlocation.md) | Retrieves user's location using navigator.geolocation API. | | [HierarchicalFacets({ searchOnChange, collapsible, defaultExpanded, includedFieldIds, customCssClasses, delimiter, showMoreLimit })](./search-ui-react.hierarchicalfacets.md) | A component that displays hierarchical facets, in a tree level structure, applicable to the current vertical search. | @@ -64,6 +65,8 @@ | [FilterOptionConfig](./search-ui-react.filteroptionconfig.md) | The configuration data for a field value filter option. | | [FilterSearchCssClasses](./search-ui-react.filtersearchcssclasses.md) | The CSS class interface for [FilterSearch()](./search-ui-react.filtersearch.md). | | [FilterSearchProps](./search-ui-react.filtersearchprops.md) | The props for the [FilterSearch()](./search-ui-react.filtersearch.md) component. | +| [GeolocationCssClasses](./search-ui-react.geolocationcssclasses.md) | The CSS class interface for the Geolocation component. | +| [GeolocationProps](./search-ui-react.geolocationprops.md) | The props for the Geolocation component. | | [HierarchicalFacetDisplayCssClasses](./search-ui-react.hierarchicalfacetdisplaycssclasses.md) | The CSS class interface for HierarchicalFacetDisplay. | | [HierarchicalFacetsCssClasses](./search-ui-react.hierarchicalfacetscssclasses.md) | The CSS class interface for [HierarchicalFacets()](./search-ui-react.hierarchicalfacets.md). | | [HierarchicalFacetsProps](./search-ui-react.hierarchicalfacetsprops.md) | Props for the [HierarchicalFacets()](./search-ui-react.hierarchicalfacets.md) component. | diff --git a/etc/search-ui-react.api.md b/etc/search-ui-react.api.md index a05aa14d7..7115cc624 100644 --- a/etc/search-ui-react.api.md +++ b/etc/search-ui-react.api.md @@ -279,6 +279,30 @@ export interface FilterSearchProps { // @public export type FocusedItemData = Record; +// @public +function Geolocation_2({ geolocationOptions, radius, label, GeolocationIcon, onClick, customCssClasses, }: GeolocationProps): JSX.Element | null; +export { Geolocation_2 as Geolocation } + +// @public +export interface GeolocationCssClasses { + // (undocumented) + button?: string; + // (undocumented) + geolocationContainer?: string; + // (undocumented) + iconContainer?: string; +} + +// @public +export interface GeolocationProps { + customCssClasses?: GeolocationCssClasses; + GeolocationIcon?: React.FC; + geolocationOptions?: PositionOptions; + label?: string; + onClick?: () => void; + radius?: number; +} + // @public export function getSearchIntents(searchActions: SearchActions): Promise; diff --git a/package.json b/package.json index 95049f18b..2232a505f 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,6 @@ "restoreMocks": true }, "dependencies": { - "lodash": "^4.17.21", "@microsoft/api-documenter": "^7.15.3", "@microsoft/api-extractor": "^7.19.4", "@reach/auto-id": "^0.18.0", @@ -143,6 +142,7 @@ "@yext/analytics": "^0.2.0-beta.3", "classnames": "^2.3.1", "mapbox-gl": "^2.9.2", + "lodash": "^4.17.21", "prop-types": "^15.8.1", "react-collapsed": "^3.3.0", "recent-searches": "^1.0.5", diff --git a/src/components/AppliedFilters.tsx b/src/components/AppliedFilters.tsx index 26f20d6d4..311f66475 100644 --- a/src/components/AppliedFilters.tsx +++ b/src/components/AppliedFilters.tsx @@ -45,7 +45,7 @@ export interface AppliedFiltersProps { customCssClasses?: AppliedFiltersCssClasses } -const DEFUALT_HIDDEN_FIELDS = ['builtin.entityType']; +const DEFAULT_HIDDEN_FIELDS = ['builtin.entityType']; /** * A component that displays a list of filters applied to the current vertical @@ -61,7 +61,7 @@ export function AppliedFilters(props: AppliedFiltersProps): JSX.Element { const isLoading = useSearchState(state => state.searchStatus.isLoading); const { - hiddenFields = DEFUALT_HIDDEN_FIELDS, + hiddenFields = DEFAULT_HIDDEN_FIELDS, customCssClasses = {}, hierarchicalFacetsDelimiter = DEFAULT_HIERARCHICAL_DELIMITER, hierarchicalFacetsFieldIds diff --git a/src/components/Geolocation.tsx b/src/components/Geolocation.tsx new file mode 100644 index 000000000..a77d87dda --- /dev/null +++ b/src/components/Geolocation.tsx @@ -0,0 +1,124 @@ +import { Matcher, SelectableStaticFilter, useSearchActions, useSearchState } from '@yext/search-headless-react'; +import { executeSearch } from '../utils/search-operations'; +import { getUserLocation } from '../utils/location-operations'; +import { useComposedCssClasses } from '../hooks/useComposedCssClasses'; +import { useCallback, useState } from 'react'; +import LoadingIndicator from '../icons/LoadingIndicator'; +import { YextIcon } from '../icons/YextIcon'; + +/** + * The CSS class interface for the Geolocation component. + * + * @public + */ +export interface GeolocationCssClasses { + geolocationContainer?: string, + button?: string, + iconContainer?: string +} + +const builtInCssClasses: Readonly = { + geolocationContainer: 'text-sm text-neutral text-center justify-center items-center flex flex-row', + button: 'text-primary font-semibold hover:underline focus:underline', + iconContainer: 'w-4 ml-2' +}; + +/** + * The props for the Geolocation component. + * + * @public + */ +export interface GeolocationProps { + /** + * Configuration used when collecting the user's location. + * Definition: {@link https://w3c.github.io/geolocation-api/#position_options_interface}. + */ + geolocationOptions?: PositionOptions, + /** + * The radius, in miles, around the user's location to find results. Defaults to 50. + * If location accuracy is low, a larger radius may be used automatically. + */ + radius?: number, + /** The label for the button. Defaults to 'Use my location'. */ + label?: string, + /** Custom icon component to display along with the button. */ + GeolocationIcon?: React.FC, + /** + * A function which is called when the geolocation button is clicked. + * This is called prior to executing the existing click behavior. + */ + onClick?: () => void, + /** CSS classes for customizing the component styling. */ + customCssClasses?: GeolocationCssClasses +} + +const LOCATION_FIELD_ID = 'builtin.location'; +const METERS_PER_MILE = 1609.344; + +/** + * A React Component which collects location information to create a + * location filter and perform a new search. + * + * @public + * + * @param props - {@link GeolocationProps} + * @returns A react component for geolocation + */ +export function Geolocation({ + geolocationOptions, + radius = 50, + label = 'Use my location', + //TODO: replace default icon with SVG create from design team + GeolocationIcon = YextIcon, + onClick, + customCssClasses, +}: GeolocationProps): JSX.Element | null { + const searchActions = useSearchActions(); + const staticFilters = useSearchState(s => s.filters.static || []); + const [isFetchingLocation, setIsFetchingLocation] = useState(false); + const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses); + + const handleGeolocationClick = useCallback(async () => { + onClick?.(); + setIsFetchingLocation(true); + try { + const position = await getUserLocation(geolocationOptions); + const { latitude, longitude, accuracy } = position.coords; + const locationFilter: SelectableStaticFilter = { + displayName: 'Current Location', + selected: true, + filter: { + kind: 'fieldValue', + fieldId: LOCATION_FIELD_ID, + matcher: Matcher.Near, + value: { + lat: latitude, + lng: longitude, + radius: Math.max(accuracy, radius * METERS_PER_MILE) + }, + } + }; + const nonLocationFilters = staticFilters?.filter(filter => { + return !(filter.filter.kind === 'fieldValue' + && filter.filter.fieldId === LOCATION_FIELD_ID); + }) ?? []; + searchActions.setStaticFilters([...nonLocationFilters, locationFilter]); + executeSearch(searchActions); + } catch (e) { + console.warn(e); + } finally { + setIsFetchingLocation(false); + } + }, [geolocationOptions, onClick, radius, searchActions, staticFilters]); + + return ( +
+ +
+ {isFetchingLocation ? : } +
+
+ ); +} \ No newline at end of file diff --git a/src/components/index.ts b/src/components/index.ts index 44dd1e1a5..3ad4b3992 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -45,6 +45,12 @@ export { LocationBiasProps } from './LocationBias'; +export { + Geolocation, + GeolocationCssClasses, + GeolocationProps +} from './Geolocation'; + export { AppliedFilters, AppliedFiltersCssClasses, diff --git a/test-site/src/pages/ProductsPage.tsx b/test-site/src/pages/ProductsPage.tsx index 5bc2af79c..52c6a0640 100644 --- a/test-site/src/pages/ProductsPage.tsx +++ b/test-site/src/pages/ProductsPage.tsx @@ -5,7 +5,7 @@ import { SearchBar, StandardCard, VerticalResults, - LocationBias, + Geolocation, NumericalFacets, Pagination } from '@yext/search-ui-react'; @@ -34,7 +34,7 @@ export function ProductsPage() { CardComponent={StandardCard} /> - + diff --git a/tests/components/Geolocation.stories.tsx b/tests/components/Geolocation.stories.tsx new file mode 100644 index 000000000..e0fee93fb --- /dev/null +++ b/tests/components/Geolocation.stories.tsx @@ -0,0 +1,39 @@ +import { ComponentMeta, Story } from '@storybook/react'; +import { SearchHeadlessContext } from '@yext/search-headless-react'; + +import { decorator as LocationOperationDecorator } from '../__fixtures__/utils/location-operations'; +import { generateMockedHeadless } from '../__fixtures__/search-headless'; +import { VerticalSearcherState } from '../__fixtures__/headless-state'; +import { userEvent, within } from '@storybook/testing-library'; +import { Geolocation, GeolocationProps } from '../../src/components/Geolocation'; + +const meta: ComponentMeta = { + title: 'Geolocation', + component: Geolocation, + argTypes: { + geolocationOptions: { + control: false + } + } +}; +export default meta; + +export const Primary: Story = (args) => { + return ( + + + + ); +}; + +export const Loading = Primary.bind({}); +Loading.decorators = [LocationOperationDecorator]; +Loading.parameters = { + geoLocation: { + isFetching: true + } +}; +Loading.play = ({ canvasElement }) => { + const canvas = within(canvasElement); + userEvent.click(canvas.getByText('Use my location')); +}; diff --git a/tests/components/Geolocation.test.tsx b/tests/components/Geolocation.test.tsx new file mode 100644 index 000000000..b8848f0ef --- /dev/null +++ b/tests/components/Geolocation.test.tsx @@ -0,0 +1,226 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Geolocation } from '../../src/components/Geolocation'; +import { Matcher, SelectableStaticFilter, State } from '@yext/search-headless-react'; +import * as locationOperations from '../../src/utils/location-operations'; +import { mockAnswersHooks, mockAnswersState, spyOnActions } from '../__utils__/mocks'; + +jest.mock('@yext/search-headless-react'); + +const mockedState: Partial = { + filters: { + static: [] + }, + vertical: { + verticalKey: 'jobs', + }, + searchStatus: { + isLoading: false + }, + meta: { + searchType: 'vertical' + } +}; + +const mockedStateWithFilters: Partial = { + ...mockedState, + filters: { + static: [ + { + displayName: 'Some Location', + selected: true, + filter: { + kind: 'fieldValue', + fieldId: 'builtin.location', + matcher: Matcher.Near, + value: { + lat: 1, + lng: 1, + radius: 10 + }, + } + }, + { + displayName: 'Current Location', + selected: true, + filter: { + kind: 'fieldValue', + fieldId: 'builtin.location', + matcher: Matcher.Near, + value: { + lat: 2, + lng: 3, + radius: 10 + }, + } + }, + { + displayName: 'My name', + selected: true, + filter: { + kind: 'fieldValue', + fieldId: 'employeeName', + matcher: Matcher.Equals, + value: 'Bob', + } + } + ] + } +}; + +const newGeoPosition: GeolocationPosition = { + coords: { + accuracy: 0, + altitude: null, + altitudeAccuracy: null, + heading: null, + latitude: 40.741591687843005, + longitude: -74.00530254443494, + speed: null, + }, + timestamp: 0 +}; + +const newGeoPositionWithLowAccuracy: GeolocationPosition = { + coords: { + ...newGeoPosition.coords, + accuracy: 100000, + }, + timestamp: 0 +}; + +beforeEach(() => { + mockAnswersHooks({ + mockedState, + mockedActions: { + state: mockedState, + setStaticFilters: jest.fn(), + executeVerticalQuery: jest.fn() + } + }); + jest.spyOn(locationOperations, 'getUserLocation').mockResolvedValue(newGeoPosition); +}); + +it('renders custom label when provided', () => { + render(); + const updateLocationButton = screen.getByRole('button', { name: 'Click me!' }); + expect(updateLocationButton).toBeDefined(); +}); + +it('renders custom icon when provided', () => { + render( Custom Icon} />); + const LocationIcon = screen.getByAltText('Custom Icon'); + expect(LocationIcon).toBeDefined(); +}); + +it('executes onClick when provided', () => { + const mockedOnClickFn = jest.fn(); + render(); + clickUpdateLocation(); + expect(mockedOnClickFn).toBeCalledTimes(1); +}); + +it('sets a location filter with user\'s coordinates in static filters state when clicked', async () => { + const actions = spyOnActions(); + render(); + clickUpdateLocation(); + + const expectedLocationFilter: SelectableStaticFilter = createLocationFilter(); + expect(locationOperations.getUserLocation).toBeCalled(); + await waitFor(() => { + expect(actions.setStaticFilters).toBeCalledWith([expectedLocationFilter]); + }); +}); + +it('sets a location filter using provided radius', async () => { + const actions = spyOnActions(); + render(); + clickUpdateLocation(); + + const expectedLocationFilter: SelectableStaticFilter = createLocationFilter(10 * 1609.344); + await waitFor(() => { + expect(actions.setStaticFilters).toBeCalledWith([expectedLocationFilter]); + }); +}); + +it('sets a location filter using a larger radius than provided value due to low accuracy of user coordinate', async () => { + jest.spyOn(locationOperations, 'getUserLocation').mockResolvedValue(newGeoPositionWithLowAccuracy); + const actions = spyOnActions(); + render(); + clickUpdateLocation(); + + const accuracy = newGeoPositionWithLowAccuracy.coords.accuracy; + const expectedLocationFilter: SelectableStaticFilter = createLocationFilter(accuracy); + await waitFor(() => { + expect(actions.setStaticFilters).toBeCalledWith([expectedLocationFilter]); + }); +}); + +it('replace existing location filters with a new location filter in static filters state', async () => { + mockAnswersState(mockedStateWithFilters); + const actions = spyOnActions(); + render(); + clickUpdateLocation(); + + const expectedStaticFilters = [ + { + displayName: 'My name', + selected: true, + filter: { + kind: 'fieldValue', + fieldId: 'employeeName', + matcher: Matcher.Equals, + value: 'Bob', + } + }, + createLocationFilter() + ]; + await waitFor(() => { + expect(actions.setStaticFilters).toBeCalledWith(expectedStaticFilters); + }); +}); + +it('executes a new search when clicked', async () => { + const actions = spyOnActions(); + render(); + clickUpdateLocation(); + + await waitFor(() => { + expect(actions.executeVerticalQuery).toBeCalled(); + }); +}); + +it('handles erorr when collecting user\'s location', async () => { + const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation(); + jest.spyOn(locationOperations, 'getUserLocation').mockRejectedValue('mocked error!'); + const actions = spyOnActions(); + render(); + clickUpdateLocation(); + await waitFor(() => { + expect(consoleWarnSpy).toBeCalledWith('mocked error!'); + }); + expect(actions.setStaticFilters).not.toBeCalled(); + expect(actions.executeVerticalQuery).not.toBeCalled(); +}); + +function clickUpdateLocation() { + const updateLocationButton = screen.getByRole('button'); + userEvent.click(updateLocationButton); +} + +function createLocationFilter(radius: number = 50 * 1609.344): SelectableStaticFilter { + return { + displayName: 'Current Location', + selected: true, + filter: { + kind: 'fieldValue', + fieldId: 'builtin.location', + matcher: Matcher.Near, + value: { + lat: newGeoPosition.coords.latitude, + lng: newGeoPosition.coords.longitude, + radius + }, + } + }; +} \ No newline at end of file