diff --git a/assets/ts/ui/autocomplete/__tests__/__snapshots__/config-test.ts.snap b/assets/ts/ui/autocomplete/__tests__/__snapshots__/config-test.ts.snap deleted file mode 100644 index 3b5c48de45..0000000000 --- a/assets/ts/ui/autocomplete/__tests__/__snapshots__/config-test.ts.snap +++ /dev/null @@ -1,120 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Algolia template handles route item 1`] = ` - - -
-
-
- -
- -   - -
-
-
-
-
-
-`; - -exports[`Location template renders 1`] = ` - - -
-
-
-
- Town, MA -
-
-
-
-`; - -exports[`Popular template renders 1`] = ` - - -
-
-
-
- - South Station - -
-
-
- - - - - - Zone 1A - - -
-
- Town, MA -
-
-
-
-
- -`; diff --git a/assets/ts/ui/autocomplete/__tests__/config-test.ts b/assets/ts/ui/autocomplete/__tests__/config-test.ts index 356b2c3eb9..57d0b20482 100644 --- a/assets/ts/ui/autocomplete/__tests__/config-test.ts +++ b/assets/ts/ui/autocomplete/__tests__/config-test.ts @@ -1,10 +1,13 @@ -import { GetSourcesParams } from "@algolia/autocomplete-core"; -import { Item, LocationItem } from "../__autocomplete"; +import { + AutocompleteState, + GetSourcesParams, + OnSelectParams, + StateUpdater +} from "@algolia/autocomplete-core"; +import { Item } from "../__autocomplete"; import configs from "./../config"; import { AutocompleteSource } from "@algolia/autocomplete-js"; -import { render, screen, waitFor } from "@testing-library/react"; -import { mockTemplateParam } from "./templates-test"; -import userEvent from "@testing-library/user-event"; +import { waitFor } from "@testing-library/react"; const sourceIds = (sources: any[]) => (sources as AutocompleteSource[]).map(s => s.sourceId); @@ -189,29 +192,21 @@ describe("Trip planner configuration", () => { const { getSources } = config; const mockSetQuery = jest.fn(); // @ts-ignore - const [geolocationSource] = await getSources({ - ...baseSourceParams, - setQuery: mockSetQuery - }); - const geolocationTemplate = (geolocationSource as AutocompleteSource< - LocationItem - >).templates.item; - - render( - geolocationTemplate( - mockTemplateParam({} as LocationItem, "") - ) as React.ReactElement - ); - const button = screen.getByRole("button"); - const user = userEvent.setup(); - await user.click(button); - const locationName = `Near ${mockCoordinates.latitude}, ${mockCoordinates.longitude}`; + const [geolocationSource] = await getSources(baseSourceParams); + (geolocationSource as AutocompleteSource)?.onSelect!({ + setContext: jest.fn() as StateUpdater< + AutocompleteState<{ value: string }>["context"] + >, + setQuery: mockSetQuery as StateUpdater< + AutocompleteState<{ value: string }>["query"] + > + } as OnSelectParams<{ value: string }>); + await waitFor(() => { - expect(mockSetQuery).toHaveBeenCalledWith(locationName); - expect(pushToLiveViewMock).toHaveBeenCalledWith({ - ...mockCoordinates, - name: locationName - }); + expect(mockSetQuery).toHaveBeenCalledWith( + `${mockCoordinates.latitude}, ${mockCoordinates.longitude}` + ); + expect(pushToLiveViewMock).toHaveBeenCalledWith(mockCoordinates); }); }); }); diff --git a/assets/ts/ui/autocomplete/__tests__/sources-test.ts b/assets/ts/ui/autocomplete/__tests__/sources-test.ts index 3fb49cc30d..4056f262ab 100644 --- a/assets/ts/ui/autocomplete/__tests__/sources-test.ts +++ b/assets/ts/ui/autocomplete/__tests__/sources-test.ts @@ -1,4 +1,9 @@ -import { OnInputParams } from "@algolia/autocomplete-core"; +import { + AutocompleteState, + OnInputParams, + OnSelectParams, + StateUpdater +} from "@algolia/autocomplete-core"; import { algoliaSource, geolocationSource, @@ -6,8 +11,8 @@ import { popularLocationSource } from "../sources"; import { AutocompleteItem, LocationItem, PopularItem } from "../__autocomplete"; - -const setIsOpenMock = jest.fn(); +import { UrlType } from "../helpers"; +import { waitFor } from "@testing-library/dom"; beforeEach(() => { global.fetch = jest.fn(() => @@ -19,22 +24,160 @@ beforeEach(() => { }); afterEach(() => jest.resetAllMocks()); +const mockCoords = { + latitude: 40, + longitude: -71 +}; +const mockUrlsResponse = { + result: { + urls: { + "transit-near-me": `/transit-near-me?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}`, + "retail-sales-locations": `/fares/retail-sales-locations?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}`, + "proposed-sales-locations": `/fare-transformation/proposed-sales-locations?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}` + }, + longitude: mockCoords.longitude, + latitude: mockCoords.latitude + } +}; + +function setMocks(geoSuccess: boolean, fetchSuccess: boolean): void { + const getCurrentPositionMock = geoSuccess + ? jest.fn().mockImplementationOnce(success => + Promise.resolve( + success({ + coords: mockCoords + }) + ) + ) + : jest + .fn() + .mockImplementationOnce((success, error) => + Promise.resolve(error({ code: 1, message: "GeoLocation Error" })) + ); + + (global.navigator as any).geolocation = { + getCurrentPosition: getCurrentPositionMock + }; + + if (fetchSuccess) { + jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockUrlsResponse) + } as Response); + } else { + jest.spyOn(global, "fetch").mockRejectedValue({ + ok: false, + status: 404 + }); + } +} + describe("geolocationSource", () => { + Object.defineProperty(window, "location", { + value: { + assign: jest.fn() + } + }); + test("defines a template", () => { - expect(geolocationSource(setIsOpenMock).templates.item).toBeTruthy(); + expect(geolocationSource().templates.item).toBeTruthy(); }); test("defines a getItems function", () => { expect( - geolocationSource(setIsOpenMock).getItems( - {} as OnInputParams - ) + geolocationSource().getItems({} as OnInputParams<{ value: string }>) ).toBeTruthy(); }); test("has optional getItemUrl", () => { - expect(geolocationSource(setIsOpenMock)).not.toContainKey("getItemUrl"); - expect( - geolocationSource(setIsOpenMock, "proposed-sales-locations") - ).toContainKey("getItemUrl"); + expect(geolocationSource("proposed-sales-locations")).toContainKey( + "getItemUrl" + ); + }); + describe("onSelect", () => { + function setupGeolocationSource( + urlType?: UrlType, + onLocationFound?: () => void + ) { + const source = geolocationSource(urlType, onLocationFound); + const setContextMock = jest.fn(); + const setQueryMock = jest.fn(); + const onSelectParams = { + setContext: setContextMock as StateUpdater< + AutocompleteState<{ value: string }>["context"] + >, + setQuery: setQueryMock as StateUpdater< + AutocompleteState<{ value: string }>["query"] + > + } as OnSelectParams<{ value: string }>; + return { source, onSelectParams }; + } + + test("redirects to a URL on success", async () => { + setMocks(true, true); + const { source, onSelectParams } = setupGeolocationSource( + "transit-near-me" + ); + expect(source.getItems({} as OnInputParams<{ value: string }>)).toEqual([ + { value: "Use my location to find transit near me" } + ]); + source.onSelect!(onSelectParams); + + await waitFor(() => { + expect(onSelectParams.setQuery).toHaveBeenCalledWith( + "Getting your location..." + ); + expect(global.fetch).toHaveBeenCalledWith( + `/places/urls?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}` + ); + expect(window.location.assign).toHaveBeenCalledExactlyOnceWith( + `/transit-near-me?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}` + ); + }); + }); + test("fires onLocationFound on success", async () => { + setMocks(true, true); + const onLocationFoundMock = jest.fn(); + const { source, onSelectParams } = setupGeolocationSource( + undefined, + onLocationFoundMock + ); + source.onSelect!(onSelectParams); + await waitFor(() => { + expect(onLocationFoundMock).toHaveBeenCalledWith(mockCoords); + }); + }); + test("displays error on geolocation error", async () => { + setMocks(false, true); + const { source, onSelectParams } = setupGeolocationSource( + "transit-near-me" + ); + source.onSelect!(onSelectParams); + await waitFor(() => { + expect(onSelectParams.setQuery).toHaveBeenCalledWith( + "undefined needs permission to use your location." + ); + expect(global.fetch).not.toHaveBeenCalled(); + expect(window.location.assign).not.toHaveBeenCalled(); + }); + }); + test("displays error on fetch error", async () => { + setMocks(true, false); + const { source, onSelectParams } = setupGeolocationSource( + "proposed-sales-locations" + ); + source.onSelect!(onSelectParams); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledWith( + `/places/urls?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}` + ); + expect(window.location.assign).not.toHaveBeenCalledWith( + mockUrlsResponse.result.urls["proposed-sales-locations"] + ); + expect(onSelectParams.setQuery).toHaveBeenCalledWith( + "undefined needs permission to use your location." + ); + }); + }); }); }); diff --git a/assets/ts/ui/autocomplete/__tests__/templates-test.tsx b/assets/ts/ui/autocomplete/__tests__/templates-test.tsx index f2dcd70c9f..5d9fdc88b8 100644 --- a/assets/ts/ui/autocomplete/__tests__/templates-test.tsx +++ b/assets/ts/ui/autocomplete/__tests__/templates-test.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { @@ -7,7 +6,6 @@ import { PopularItem, RouteItem } from "../__autocomplete"; - import { AutocompleteComponents, AutocompleteRenderer, @@ -15,10 +13,14 @@ import { HTMLTemplate, VNode } from "@algolia/autocomplete-js"; -import { BaseItem } from "@algolia/autocomplete-core"; +import { + BaseItem, + OnInputParams, + OnSelectParams, + StateUpdater +} from "@algolia/autocomplete-core"; import { HighlightResultOption } from "@algolia/client-search"; import AlgoliaItemTemplate from "../templates/algolia"; -import { GeolocationComponent } from "../templates/geolocation"; import LocationItemTemplate from "../templates/location"; import PopularItemTemplate from "../templates/popular"; import { templateWithLink } from "../templates/helpers"; @@ -42,55 +44,6 @@ export function mockTemplateParam(item: T, query: string) { const renderMockTemplate = (tmpl: string | VNode | VNode[]) => render(tmpl as VNode); -const mockCoords = { - latitude: 40, - longitude: -71 -}; -const mockUrlsResponse = { - result: { - urls: { - "transit-near-me": `/transit-near-me?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}`, - "retail-sales-locations": `/fares/retail-sales-locations?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}`, - "proposed-sales-locations": `/fare-transformation/proposed-sales-locations?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}` - }, - longitude: mockCoords.longitude, - latitude: mockCoords.latitude - } -}; - -function setMocks(geoSuccess: boolean, fetchSuccess: boolean): void { - const getCurrentPositionMock = geoSuccess - ? jest.fn().mockImplementationOnce(success => - Promise.resolve( - success({ - coords: mockCoords - }) - ) - ) - : jest - .fn() - .mockImplementationOnce((success, error) => - Promise.resolve(error({ code: 1, message: "GeoLocation Error" })) - ); - - (global.navigator as any).geolocation = { - getCurrentPosition: getCurrentPositionMock - }; - - if (fetchSuccess) { - jest.spyOn(global, "fetch").mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve(mockUrlsResponse) - } as Response); - } else { - jest.spyOn(global, "fetch").mockRejectedValue({ - ok: false, - status: 404 - }); - } -} - describe("Algolia template", () => { function renderAlgoliaTemplate(item: AutocompleteItem, query = "query") { return renderMockTemplate( @@ -245,137 +198,3 @@ test("Location template renders", () => { expect(asFragment()).toMatchSnapshot(); }); - -describe("Geolocation template", () => { - Object.defineProperty(window, "location", { - value: { - assign: jest.fn() - } - }); - describe("on success", () => { - const setIsOpenMock = jest.fn(); - - test("redirects to a URL", async () => { - setMocks(true, true); - render( - - ); - const button = screen.getByRole("button"); - expect(button).toHaveTextContent( - "Use my location to find transit near me" - ); - - const user = userEvent.setup(); - await user.click(button); - await waitFor(() => { - expect(setIsOpenMock).toHaveBeenCalledWith(true); - expect(screen.queryByText("Redirecting...")).toBeTruthy(); - expect(global.fetch).toHaveBeenCalledWith( - `/places/urls?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}` - ); - expect(window.location.assign).toHaveBeenCalledExactlyOnceWith( - `/transit-near-me?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}` - ); - }); - }); - - test("fires onLocationFound", async () => { - setMocks(true, true); - const onLocationFoundMock = jest.fn(); - render( - - ); - const button = screen.getByRole("button"); - expect(button).toHaveTextContent("Use my location"); - const user = userEvent.setup(); - await user.click(button); - - await waitFor(() => { - expect(setIsOpenMock).toHaveBeenCalledWith(true); - expect(onLocationFoundMock).toHaveBeenCalledWith(mockCoords); - }); - - await waitFor( - () => { - expect(setIsOpenMock).toHaveBeenCalledWith(false); - }, - { timeout: 2000 } - ); - }); - }); - - describe("displays error", () => { - const errorText = - "needs permission to use your location. Please update your browser's settings or refresh the page and try again."; - - test("on geolocation error", async () => { - const user = userEvent.setup(); - setMocks(false, true); - const setIsOpenMock = jest.fn(); - render( - - ); - - await user.click(screen.getByRole("button")); - await waitFor(() => { - expect(setIsOpenMock).toHaveBeenCalledWith(true); - expect(global.fetch).not.toHaveBeenCalled(); - expect(window.location.assign).not.toHaveBeenCalled(); - }); - - await waitFor( - () => { - expect(setIsOpenMock).not.toHaveBeenCalledWith(false); - }, - { timeout: 3000 } - ); - expect(screen.getByText(errorText, { exact: false })).toBeTruthy(); - }); - - test("on fetch error", async () => { - const user = userEvent.setup(); - setMocks(true, false); - const setIsOpenMock = jest.fn(); - render( - - ); - const button = screen.getByRole("button"); - expect(button).toHaveTextContent(/^Use my location$/); - await user.click(button); - - await waitFor(() => { - expect(setIsOpenMock).toHaveBeenCalledWith(true); - expect(global.fetch).toHaveBeenCalledWith( - `/places/urls?latitude=${mockCoords.latitude}&longitude=${mockCoords.longitude}` - ); - expect(window.location.assign).not.toHaveBeenCalledWith( - mockUrlsResponse.result.urls["proposed-sales-locations"] - ); - expect( - screen.getByText( - "needs permission to use your location. Please update your browser's settings or refresh the page and try again.", - { exact: false } - ) - ).toBeTruthy(); - }); - await waitFor( - () => { - expect(setIsOpenMock).not.toHaveBeenCalledWith(false); - }, - { timeout: 3000 } - ); - }); - }); -}); diff --git a/assets/ts/ui/autocomplete/config.ts b/assets/ts/ui/autocomplete/config.ts index 71f1f57350..f99a47d590 100644 --- a/assets/ts/ui/autocomplete/config.ts +++ b/assets/ts/ui/autocomplete/config.ts @@ -72,8 +72,8 @@ const BASIC: Partial> = { * Get results from geolocation, AWS locations, and Algolia-stored content or * GTFS data */ - getSources({ query, setIsOpen }): AutocompleteSource[] { - if (!query) return [geolocationSource(setIsOpen, "transit-near-me")]; + getSources({ query }): AutocompleteSource[] { + if (!query) return [geolocationSource("transit-near-me")]; return debounced([ algoliaSource(query, { routes: { @@ -112,9 +112,8 @@ const TNM: Partial> = { initialState: { query: getLikelyQueryParams() }, - getSources({ query, setIsOpen }): AutocompleteSource[] { - if (!query) - return debounced([geolocationSource(setIsOpen, "transit-near-me")]); + getSources({ query }): AutocompleteSource[] { + if (!query) return debounced([geolocationSource("transit-near-me")]); return debounced([locationSource(query, 5, "transit-near-me")]); } }; @@ -128,10 +127,10 @@ const RETAIL: Partial> = { initialState: { query: getLikelyQueryParams() }, - getSources({ query, setIsOpen }): AutocompleteSource[] { + getSources({ query }): AutocompleteSource[] { if (!query) return debounced([ - geolocationSource(setIsOpen, "retail-sales-locations"), + geolocationSource("retail-sales-locations"), popularLocationSource("retail-sales-locations") ]); return debounced([locationSource(query, 5, "retail-sales-locations")]); @@ -147,10 +146,10 @@ const PROPOSED_RETAIL: Partial> = { initialState: { query: getLikelyQueryParams() }, - getSources({ query, setIsOpen }) { + getSources({ query }) { if (!query) return debounced([ - geolocationSource(setIsOpen, "proposed-sales-locations"), + geolocationSource("proposed-sales-locations"), popularLocationSource("proposed-sales-locations") ]); return debounced([locationSource(query, 5, "proposed-sales-locations")]); @@ -210,21 +209,12 @@ const TRIP_PLANNER = ({ onReset: (): void => { pushToLiveView({}); }, - getSources({ query, setIsOpen, setQuery }) { + getSources({ query }) { if (!query) return debounced([ - { - ...geolocationSource( - setIsOpen, - undefined, - ({ latitude, longitude }) => { - const name = `Near ${latitude}, ${longitude}`; - setQuery(name); - pushToLiveView({ latitude, longitude, name }); - } - ), - onSelect - }, + geolocationSource(undefined, coordinates => { + pushToLiveView(coordinates); + }), { ...popularLocationSource(), onSelect diff --git a/assets/ts/ui/autocomplete/sources.ts b/assets/ts/ui/autocomplete/sources.ts index 0372dda8ad..f34f61ad34 100644 --- a/assets/ts/ui/autocomplete/sources.ts +++ b/assets/ts/ui/autocomplete/sources.ts @@ -1,34 +1,86 @@ -import { StateUpdater } from "@algolia/autocomplete-core"; import { AutocompleteSource } from "@algolia/autocomplete-js"; import { SearchResponse } from "@algolia/client-search"; import { LocationItem, PopularItem, AutocompleteItem } from "./__autocomplete"; import { UrlType, WithUrls, itemWithUrl } from "./helpers"; import AlgoliaItemTemplate from "./templates/algolia"; -import GeolocationTemplate, { - OnLocationFoundFn -} from "./templates/geolocation"; import { templateWithLink } from "./templates/helpers"; import LocationItemTemplate from "./templates/location"; import PopularItemTemplate from "./templates/popular"; +import geolocationPromise from "../../../js/geolocation-promise"; +import { fetchJsonOrThrow } from "../../helpers/fetch-json"; /** * Renders a simple UI for requesting the browser's location. * Redirects to a page configured to the location's coordinates. */ export const geolocationSource = ( - setIsOpen: StateUpdater, urlType?: UrlType, - onLocationFound?: OnLocationFoundFn -): AutocompleteSource => ({ + onLocationFound?: (coords: GeolocationCoordinates) => void +): AutocompleteSource<{ value: string }> => ({ sourceId: "geolocation", + // Displays the "Use my location" prompt templates: { - item: GeolocationTemplate(setIsOpen, urlType, onLocationFound) + item({ item, html }) { + return html` + + + ${item.value} + + `; + } }, + // Helps display the "Use my location" prompt getItems() { - // a hack to make the template appear, no backend is queried in this case - return [{} as LocationItem]; + const value = + urlType === "transit-near-me" + ? "Use my location to find transit near me" + : "Use my location"; + return [{ value }]; }, - ...(urlType && { getItemUrl: ({ item }) => item.url }) + // This is the URL that the user will be redirected to + ...(urlType && { + getItemUrl: ({ state }) => { + return state.context.url as string | undefined; + } + }), + // Prompts for location access, and redirects to the URL + onSelect({ setContext, setQuery }) { + setQuery("Getting your location..."); + geolocationPromise() + // eslint-disable-next-line consistent-return + .then(({ coords }: GeolocationPosition) => { + const { latitude, longitude } = coords; + // Display the coordinates in the search box + setQuery(`${latitude}, ${longitude}`); + + // Call the callback function with the coordinates + if (onLocationFound) { + onLocationFound(coords); + } + + // Fetch the URL to redirect to, if needed + if (urlType) { + // Being returned, any error thrown here will be caught at the end + return fetchJsonOrThrow<{ + result: WithUrls>; + }>(`/places/urls?latitude=${latitude}&longitude=${longitude}`).then( + ({ result }) => { + const url = result.urls[urlType]; + setContext({ url }); + if (url) { + window.location.assign(url); + } + } + ); + } + }) + .catch(() => { + // User denied location access, probably + setQuery( + `${window.location.host} needs permission to use your location.` + ); + }); + } }); /** diff --git a/assets/ts/ui/autocomplete/templates/geolocation.tsx b/assets/ts/ui/autocomplete/templates/geolocation.tsx deleted file mode 100644 index 13dc0fa74e..0000000000 --- a/assets/ts/ui/autocomplete/templates/geolocation.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { StateUpdater } from "@algolia/autocomplete-core"; -import { SourceTemplates } from "@algolia/autocomplete-js"; -import { pick } from "lodash"; -import geolocationPromise from "../../../../js/geolocation-promise"; -import { LocationItem } from "../__autocomplete"; -import { fetchJsonOrThrow } from "../../../helpers/fetch-json"; -import { UrlType, WithUrls } from "../helpers"; - -const goToPositionURL = async ( - position: GeolocationPosition, - urlType: UrlType, - setHasError: React.Dispatch> -): Promise => { - const latlon = (pick(position.coords, [ - "latitude", - "longitude" - ]) as unknown) as Record; - const params = new URLSearchParams(latlon); - fetchJsonOrThrow<{ - result: WithUrls; - }>(`/places/urls?${params.toString()}`) - .then(({ result }) => { - const url = result.urls[urlType]; - if (url) window.location.assign(url); - }) - .catch(() => setHasError(true)); -}; - -export type OnLocationFoundFn = (coords: GeolocationCoordinates) => void; - -export function GeolocationComponent(props: { - setIsOpen: StateUpdater; - urlType?: UrlType; - onLocationFound?: OnLocationFoundFn; -}): React.ReactElement { - const { setIsOpen, urlType, onLocationFound } = props; - const [hasError, setHasError] = useState(false); - const [position, setPosition] = useState(); - const [loading, setLoading] = useState(); - - if (position && onLocationFound) { - onLocationFound(position.coords); - // slowly close the panel - setTimeout(() => { - setIsOpen(false); - }, 1000); - } - - useEffect(() => { - if (position && !hasError) { - if (urlType) { - setLoading("Redirecting..."); - goToPositionURL(position, urlType, setHasError); - } else { - setLoading(undefined); - } - } - }, [position, hasError, urlType]); - - if (hasError) { - return ( -
- {`${window.location.host} needs permission to use your location. Please update your browser's settings or refresh the page and try again.`} -
- ); - } - - if (loading) { - return ( -
-
-
- {loading} -
- ); - } - - return ( - - ); -} - -const GeolocationTemplate = ( - setIsOpen: StateUpdater, - urlType?: UrlType, - onLocationFound?: OnLocationFoundFn -): SourceTemplates["item"] => - function GeolocationTemplateComponent() { - return ( - - ); - }; - -export default GeolocationTemplate;