Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Geolocation component #336

Merged
merged 13 commits into from
Nov 18, 2022
2 changes: 1 addition & 1 deletion docs/search-ui-react.geolocationprops.handleclick.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## GeolocationProps.handleClick property

A function which is called when the geolocation button is clicked.
A function which is called when the geolocation button is clicked, after user's position is successfully determined.

<b>Signature:</b>

Expand Down
2 changes: 1 addition & 1 deletion docs/search-ui-react.geolocationprops.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface GeolocationProps
| [customCssClasses?](./search-ui-react.geolocationprops.customcssclasses.md) | [GeolocationCssClasses](./search-ui-react.geolocationcssclasses.md) | <i>(Optional)</i> CSS classes for customizing the component styling. |
| [GeolocationIcon?](./search-ui-react.geolocationprops.geolocationicon.md) | React.FunctionComponent | <i>(Optional)</i> Custom icon component to display along with the button. |
| [geolocationOptions?](./search-ui-react.geolocationprops.geolocationoptions.md) | PositionOptions | <i>(Optional)</i> 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)<!-- -->. |
| [handleClick?](./search-ui-react.geolocationprops.handleclick.md) | (position: GeolocationPosition) =&gt; void | <i>(Optional)</i> A function which is called when the geolocation button is clicked. |
| [handleClick?](./search-ui-react.geolocationprops.handleclick.md) | (position: GeolocationPosition) =&gt; void | <i>(Optional)</i> A function which is called when the geolocation button is clicked, after user's position is successfully determined. |
| [label?](./search-ui-react.geolocationprops.label.md) | string | <i>(Optional)</i> The label for the button. Defaults to 'Use my location'. |
| [radius?](./search-ui-react.geolocationprops.radius.md) | number | <i>(Optional)</i> 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. |

59 changes: 13 additions & 46 deletions src/components/Geolocation.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
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 { useState } from 'react';
import LoadingIndicator from '../icons/LoadingIndicator';
import { YextIcon } from '../icons/YextIcon';
import { useGeolocationHandler } from '../hooks/useGeolocationHandler';

/**
* The CSS class interface for the Geolocation component.
Copy link
Contributor Author

@yen-tt yen-tt Nov 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I cannot use {@link Geolocation} here due to an issue with API Extractor adding _2 suffix to duplicate interface/class names. In search-ui-react, Geolocation from Typescript's "lib.dom.d.ts" file conflicts with Geolocation component.
See: microsoft/rushstack#2534 and microsoft/rushstack#2976
Open PR: microsoft/rushstack#2608

Screen Shot 2022-11-16 at 12 15 08 PM

It does make it a little odd in the doc but the signature of the component is correct. What to know if the team have any thoughts on this. A work around, which is a little hacky, is to add a script to manually update the files generated from API extractor.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with it, the api extractor docs aren't made to be extremely beginner friendly

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we link to Geolocation_2? or would that not resolve correctly?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it would link correctly in the doc pages but we would see this in search-ui-react.api.md file:
Screen Shot 2022-11-16 at 3 28 29 PM

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh ok, I'm fine leaving it without a link

Expand Down Expand Up @@ -43,15 +41,15 @@ export interface GeolocationProps {
label?: string,
/** Custom icon component to display along with the button. */
GeolocationIcon?: React.FunctionComponent,
/** A function which is called when the geolocation button is clicked. */
/**
* A function which is called when the geolocation button is clicked,
* after user's position is successfully determined.
*/
handleClick?: (position: GeolocationPosition) => 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.
Expand All @@ -70,46 +68,15 @@ export function Geolocation({
handleClick,
customCssClasses,
}: GeolocationProps): JSX.Element | null {
const searchActions = useSearchActions();
const staticFilters = useSearchState(s => s.filters.static || []);
const [isFetchingLocation, setIsFetchingLocation] = useState<boolean>(false);
const cssClasses = useComposedCssClasses(builtInCssClasses, customCssClasses);

const handleGeolocationClick = useCallback(async () => {
setIsFetchingLocation(true);
try {
const position = await getUserLocation(geolocationOptions);
if (handleClick) {
handleClick(position);
return;
}
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, handleClick, radius, searchActions, staticFilters]);
const handleGeolocationClick = useGeolocationHandler({
setIsFetchingLocation,
geolocationOptions,
radius,
handleUserPosition: handleClick
});

return (
<div className={cssClasses.geolocationContainer}>
Expand All @@ -121,4 +88,4 @@ export function Geolocation({
</div>
</div>
);
}
}
82 changes: 82 additions & 0 deletions src/hooks/useGeolocationHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@

yen-tt marked this conversation as resolved.
Show resolved Hide resolved
import { Matcher, SelectableStaticFilter, useSearchActions, useSearchState } from '@yext/search-headless-react';
import { executeSearch } from '../utils/search-operations';
import { getUserLocation } from '../utils/location-operations';
import { useCallback } from 'react';

const LOCATION_FIELD_ID = 'builtin.location';
const METERS_PER_MILE = 1609.344;

/**
* The props for {@link useGeolocationHandler} hook.
*
* @internal
*/
interface GeolocationhandlerProps {
yen-tt marked this conversation as resolved.
Show resolved Hide resolved
yen-tt marked this conversation as resolved.
Show resolved Hide resolved
/** A React dispatch function use to update the status of fetching user's location. */
setIsFetchingLocation: React.Dispatch<React.SetStateAction<boolean>>,
/** Configuration used when collecting the user's location. */
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,
/** Custom handler function to call after user's position is successfully determined. */
handleUserPosition?: (position: GeolocationPosition) => void
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit: I'm not wild about the names handleUserPosition or setIsFetchingLocation. I think the latter is a bit long and convoluted. The former is a bit vague: handleUserPosition doesn't sound like a callback that fires when the user's position is obtained.

Copy link
Contributor Author

@yen-tt yen-tt Nov 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setIsFetchingLocation is following the common pattern -- setIsBlah -- we use for a boolean setter function of react useState (e.g. setIsFeedbackProvided or setIsOptionsDisabled) so I thought the naming here would make things consistent. Outside of the pattern, maybe setIsUserLocationFound or updateGetLocationStatus but I don't think it conveys that it can be set to a boolean true, regardless if the location was determined successfully or not, as long as the fetching process is done? Similarly with our callbacks handleBlah pattern. Let me know if you have other names you prefer!

edit: end up moving setIsFetchingLocation > setIsFetchingUserLocation into the hook instead of having the component pass it in

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't totally agree with the idea that we should never use the word "on" and always use "handle". I think if done correctly, we could use both and each could convey a different meaning. But, that's a higher-level team convo.

}

/**
* Returns a function that will collect user's geolocation and, by default, will set
* a built-in location filter and execute a search.
*
* @internal
*
* @param props - {@link GeolocationProps}
yen-tt marked this conversation as resolved.
Show resolved Hide resolved
* @returns A function to collect and process user's geolocation
*/
export function useGeolocationHandler({
setIsFetchingLocation,
geolocationOptions,
radius = 50,
handleUserPosition
tmeyer2115 marked this conversation as resolved.
Show resolved Hide resolved
}: GeolocationhandlerProps): () => Promise<void> {
const searchActions = useSearchActions();
const staticFilters = useSearchState(s => s.filters.static || []);

const defaultHandleUserPosition = useCallback((position: GeolocationPosition) => {
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);
}, [radius, searchActions, staticFilters]);

return useCallback(async () => {
setIsFetchingLocation(true);
try {
const position = await getUserLocation(geolocationOptions);
(handleUserPosition ?? defaultHandleUserPosition)(position);
} catch (e) {
console.warn(e);
} finally {
setIsFetchingLocation(false);
}
}, [setIsFetchingLocation, geolocationOptions, handleUserPosition, defaultHandleUserPosition]);
}
164 changes: 90 additions & 74 deletions tests/components/Geolocation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,98 +113,114 @@ it('renders custom icon when provided', () => {
expect(LocationIcon).toBeDefined();
});

it('executes handleClick when provided', async () => {
const mockedHandleClickFn = jest.fn();
const actions = spyOnActions();
render(<Geolocation handleClick={mockedHandleClickFn} />);
clickUpdateLocation();
await waitFor(() => {
expect(mockedHandleClickFn).toHaveBeenCalledWith(newGeoPosition);
describe('custom click handler', () => {
it('executes handleClick when user\'s location is successfully determined', async () => {
const mockedHandleClickFn = jest.fn();
const actions = spyOnActions();
render(<Geolocation handleClick={mockedHandleClickFn} />);
clickUpdateLocation();
await waitFor(() => {
expect(mockedHandleClickFn).toHaveBeenCalledWith(newGeoPosition);
});
expect(actions.executeVerticalQuery).not.toBeCalled();
});

it('does not execute handleClick when error occurs from collecting user\'s location', async () => {
const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation();
jest.spyOn(locationOperations, 'getUserLocation').mockRejectedValue('mocked error!');
const mockedHandleClickFn = jest.fn();
render(<Geolocation handleClick={mockedHandleClickFn} />);
clickUpdateLocation();
await waitFor(() => {
expect(consoleWarnSpy).toBeCalledWith('mocked error!');
});
expect(mockedHandleClickFn).not.toBeCalled();
});
expect(actions.executeVerticalQuery).not.toBeCalled();
});

it('sets a location filter with user\'s coordinates in static filters state when clicked', async () => {
const actions = spyOnActions();
render(<Geolocation />);
clickUpdateLocation();
describe('default click handler', () => {
it('sets a location filter using provided radius', async () => {
const actions = spyOnActions();
render(<Geolocation radius={10}/>);
clickUpdateLocation();

const expectedLocationFilter: SelectableStaticFilter = createLocationFilter();
expect(locationOperations.getUserLocation).toBeCalled();
await waitFor(() => {
expect(actions.setStaticFilters).toBeCalledWith([expectedLocationFilter]);
const expectedLocationFilter: SelectableStaticFilter = createLocationFilter(10 * 1609.344);
await waitFor(() => {
expect(actions.setStaticFilters).toBeCalledWith([expectedLocationFilter]);
});
});
});

it('sets a location filter using provided radius', async () => {
const actions = spyOnActions();
render(<Geolocation radius={10}/>);
clickUpdateLocation();
it('sets a location filter with user\'s coordinates in static filters state when clicked', async () => {
const actions = spyOnActions();
render(<Geolocation />);
clickUpdateLocation();

const expectedLocationFilter: SelectableStaticFilter = createLocationFilter(10 * 1609.344);
await waitFor(() => {
expect(actions.setStaticFilters).toBeCalledWith([expectedLocationFilter]);
const expectedLocationFilter: SelectableStaticFilter = createLocationFilter();
expect(locationOperations.getUserLocation).toBeCalled();
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(<Geolocation radius={10}/>);
clickUpdateLocation();
it('replace existing location filters with a new location filter in static filters state', async () => {
mockAnswersState(mockedStateWithFilters);
const actions = spyOnActions();
render(<Geolocation />);
clickUpdateLocation();

const accuracy = newGeoPositionWithLowAccuracy.coords.accuracy;
const expectedLocationFilter: SelectableStaticFilter = createLocationFilter(accuracy);
await waitFor(() => {
expect(actions.setStaticFilters).toBeCalledWith([expectedLocationFilter]);
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('replace existing location filters with a new location filter in static filters state', async () => {
mockAnswersState(mockedStateWithFilters);
const actions = spyOnActions();
render(<Geolocation />);
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('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(<Geolocation radius={10}/>);
clickUpdateLocation();

const accuracy = newGeoPositionWithLowAccuracy.coords.accuracy;
const expectedLocationFilter: SelectableStaticFilter = createLocationFilter(accuracy);
await waitFor(() => {
expect(actions.setStaticFilters).toBeCalledWith([expectedLocationFilter]);
});
});
});

it('executes a new search when clicked', async () => {
const actions = spyOnActions();
render(<Geolocation />);
clickUpdateLocation();
it('executes a new search when clicked', async () => {
const actions = spyOnActions();
render(<Geolocation />);
clickUpdateLocation();

await waitFor(() => {
expect(actions.executeVerticalQuery).toBeCalled();
await waitFor(() => {
expect(actions.executeVerticalQuery).toBeCalled();
});
});
});

it('handles error 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(<Geolocation />);
clickUpdateLocation();
await waitFor(() => {
expect(consoleWarnSpy).toBeCalledWith('mocked error!');
it('does not execute default handleClick when error occurs from collecting user\'s location', async () => {
const consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation();
jest.spyOn(locationOperations, 'getUserLocation').mockRejectedValue('mocked error!');
const actions = spyOnActions();
render(<Geolocation />);
clickUpdateLocation();
await waitFor(() => {
expect(consoleWarnSpy).toBeCalledWith('mocked error!');
});
expect(actions.setStaticFilters).not.toBeCalled();
expect(actions.executeVerticalQuery).not.toBeCalled();
});
expect(actions.setStaticFilters).not.toBeCalled();
expect(actions.executeVerticalQuery).not.toBeCalled();
});

function clickUpdateLocation() {
Expand Down