Skip to content

Commit

Permalink
feat: Authorizations list GPS location toggle (#205)
Browse files Browse the repository at this point in the history
  • Loading branch information
ccallendar authored Jul 31, 2024
1 parent 0460a83 commit b5f85be
Show file tree
Hide file tree
Showing 13 changed files with 245 additions and 37 deletions.
7 changes: 7 additions & 0 deletions frontend/e2e/pages/auth.list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,11 @@ export const authorization_list_page = async (page: Page) => {
await expect(listItem.getByText('17268')).toBeVisible()
await expect(listItem.getByText('Active')).toBeVisible()
await expect(listItem.getByText('Notification')).toBeVisible()

// Sort by location
await page.getByTitle('Sort results by my location').click()

await expect(
page.getByRole('button', { name: 'Export Results to CSV' }),
).toBeVisible()
}
73 changes: 73 additions & 0 deletions frontend/src/components/LocationIconButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react'
import { screen } from '@testing-library/react'
import { LatLngTuple } from 'leaflet'

import { render } from '@/test-utils'
import { useUserLocation } from '@/features/omrr/omrr-slice'
import { LocationIconButton } from './LocationIconButton'
import { Mock } from 'vitest'

interface State {
userLocation?: LatLngTuple
}

describe('Test suite for LocationIconButton', () => {
it('should render LocationIconButton with geolocation granted', async () => {
const state: State = {}
const TestComponent = () => {
Object.assign(state, {
userLocation: useUserLocation(),
})
return <LocationIconButton />
}
const { user } = render(<TestComponent />, {
withStateProvider: true,
})

// Initially hidden, but the permission will be 'granted'
const btn = await screen.findByTitle('Sort results by my location')
const icon = screen.getByTitle('My location icon')
expect(icon).not.toHaveClass('search-input-icon--active')
expect(state.userLocation).toBeUndefined()
expect(navigator.permissions.query).toHaveBeenCalledOnce()
expect(navigator.permissions.query).toHaveBeenCalledWith({
name: 'geolocation',
})
expect(navigator.geolocation.getCurrentPosition).not.toHaveBeenCalled()

await user.click(btn)
expect(icon).toHaveClass('search-input-icon--active')
// See test-setup.ts
expect(state.userLocation).toEqual([48, -123])
expect(navigator.geolocation.getCurrentPosition).toHaveBeenCalledOnce()

await user.click(btn)
expect(icon).not.toHaveClass('search-input-icon--active')
expect(state.userLocation).toBeUndefined()
})

it('should render LocationIconButton with geolocation prompt then denied', async () => {
// The getCurrentPosition function is actually a vi.fn() - see test-utils.ts
const getLocationMock = navigator.geolocation.getCurrentPosition as Mock
getLocationMock.mockImplementationOnce(
(_success: any, error: (err: GeolocationPositionError) => void) => {
error({
code: 1,
message: 'User denied geolocation',
} as GeolocationPositionError)
},
)

const { user } = render(<LocationIconButton />, {
withStateProvider: true,
})

const btn = await screen.findByTitle('Sort results by my location')

await user.click(btn)
// permission will be denied, and button hidden
expect(
screen.queryByTitle('Sort results by my location'),
).not.toBeInTheDocument()
})
})
48 changes: 39 additions & 9 deletions frontend/src/components/LocationIconButton.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,56 @@
import { useEffect, useState } from 'react'
import { useDispatch } from 'react-redux'
import { IconButton } from '@mui/material'
import clsx from 'clsx'

import { sortFilteredResultsByPosition } from '@/features/omrr/omrr-slice'
import { setUserLocation, useUserLocation } from '@/features/omrr/omrr-slice'
import { getMyLocation } from '@/utils/utils'
import { useGeolocationPermission } from '@/hooks/useMyLocation'

import GpsIcon from '@/assets/svgs/fa-gps.svg?react'

export function LocationIconButton() {
const dispatch = useDispatch()
const state = useGeolocationPermission()
const [visible, setVisible] = useState<boolean>(false)
const userLocation = useUserLocation()

// Only show this button if the user hasn't denied the permission
useEffect(() => {
setVisible(state === 'prompt' || state === 'granted')
}, [state])

if (!visible) {
return null
}

const onClick = () => {
// Load user's location, then sort the filtered results by distance (closest first)
getMyLocation(({ position }) => {
if (position) {
dispatch(sortFilteredResultsByPosition(position))
}
})
if (userLocation) {
dispatch(setUserLocation(undefined))
} else {
// Load user's location, then sort the filtered results by distance (closest first)
getMyLocation(
({ position }) => {
dispatch(setUserLocation(position))
},
(err) => {
// User denied the permission
console.log('Geolocation error', err.message)
setVisible(false)
},
)
}
}

return (
<IconButton onClick={onClick} title="Search by my location">
<GpsIcon className="search-input-icon search-input-icon--location" />
<IconButton onClick={onClick} title="Sort results by my location">
<GpsIcon
className={clsx(
'search-input-icon search-input-icon--location',
Boolean(userLocation) && 'search-input-icon--active',
)}
title="My location icon"
/>
</IconButton>
)
}
7 changes: 6 additions & 1 deletion frontend/src/components/SearchInput.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,9 @@ input:-webkit-autofill:active {

.Mui-focused .search-input-icon {
color: var(--icons-color-info);
}
}

.search-input-icon--active,
.Mui-focused .search-input-icon--active {
color: var(--surface-color-primary-active-border);
}
23 changes: 12 additions & 11 deletions frontend/src/features/omrr/omrr-slice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useLocation } from 'react-router-dom'
import rfdc from 'rfdc'

import { RootState } from '@/app/store'
import { MIN_SEARCH_LENGTH } from '@/constants/constants'
import { LoadingStatusType } from '@/interfaces/loading-status'
import OmrrData from '@/interfaces/omrr'
import {
Expand All @@ -27,9 +28,7 @@ import {
filterByAuthorizationStatus,
filterData,
flattenFilters,
sortDataByPosition,
} from './omrr-utils'
import { MIN_SEARCH_LENGTH } from '@/constants/constants'

const deepClone = rfdc({ circles: true })

Expand All @@ -48,6 +47,7 @@ export interface OmrrSliceState {
page: number
searchBy: string
filters: OmrrFilter[]
userLocation?: LatLngTuple
allResults: OmrrData[]
searchByFilteredResults: OmrrData[]
filteredResults: OmrrData[]
Expand All @@ -68,6 +68,8 @@ export const initialState: OmrrSliceState = {
searchBy: SEARCH_BY_ACTIVE,
// Array of filters to keep track of which are on and which are disabled
filters: [...facilityTypeFilters],
// Set to the user's location when they want to sort results by closest facilities
userLocation: undefined,
// The data array containing all results
allResults: [],
// results filtered by the search by value
Expand Down Expand Up @@ -130,16 +132,12 @@ export const omrrSlice = createSlice({
state.filters = [...facilityTypeFilters]
performSearch(state)
},
sortFilteredResultsByPosition: (
setUserLocation: (
state,
action: PayloadAction<LatLngTuple>,
action: PayloadAction<LatLngTuple | undefined>,
) => {
state.filteredResults = sortDataByPosition(
state.filteredResults,
action.payload,
)
state.page = 1
state.lastSearchTime = Date.now()
state.userLocation = action.payload
performSearch(state)
},
setPage: (state, action: PayloadAction<number>) => {
state.page = action.payload
Expand Down Expand Up @@ -203,7 +201,7 @@ export const {
updateFilter,
setSearchBy,
resetFilters,
sortFilteredResultsByPosition,
setUserLocation,
setPage,
setSearchTextFilter,
setPolygonFilter,
Expand All @@ -225,6 +223,9 @@ export const useFilters = () => useSelector(selectFilters)
export const useHasFiltersOn = () =>
flattenFilters(useFilters()).some(({ on, disabled }) => on && !disabled)

export const selectUserLocation = (state: RootState) => state.omrr.userLocation
export const useUserLocation = () => useSelector(selectUserLocation)

export const selectSearchTextFilter = (state: RootState) =>
state.omrr.searchTextFilter
export const useSearchTextFilter = () => useSelector(selectSearchTextFilter)
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/features/omrr/omrr-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ export function filterData(state: OmrrSliceState): OmrrData[] {
const { center, radius } = state.circleFilter
filteredData = filterDataInsideCircle(filteredData, center, radius)
}
// Sort data by closest to user's location
if (state.userLocation) {
filteredData = sortDataByPosition(filteredData, state.userLocation)
}

return filteredData
}

Expand Down Expand Up @@ -214,7 +219,7 @@ function filterAndSortByDistance(
* Creates a new array and sorts it by the distance away from the given position,
* the closest items are first in the array.
*/
export function sortDataByPosition(
function sortDataByPosition(
data: OmrrData[],
position: LatLngTuple,
): OmrrData[] {
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/hooks/useMyLocation.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { useEffect, useState } from 'react'

import { MyLocationData } from '@/interfaces/location'
import { getMyLocation } from '@/utils/utils'
import { getGeolocationPermission, getMyLocation } from '@/utils/utils'

/**
* Uses the navigator geolocation to find the GPS location of the user.
* @see https://developer.mozilla.org/en-US/docs/Web/API/Geolocation/getCurrentPosition
* @see https://developer.mozilla.org/en-US/docs/Web/API/Navigator/permissions
* @returns { position, accuracy } object
*/
export function useMyLocation(): MyLocationData {
Expand All @@ -18,3 +16,15 @@ export function useMyLocation(): MyLocationData {

return data
}

export function useGeolocationPermission(): PermissionState | undefined {
const [state, setState] = useState<PermissionState | undefined>(undefined)

useEffect(() => {
// If there is an error - assume the permissions API is not supported
// And the geolocation is probably still available
getGeolocationPermission(setState, () => setState('prompt'))
}, [])

return state
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import { screen } from '@testing-library/react'
import { render } from '@/test-utils'
import { initialState } from '@/features/omrr/omrr-slice'
import { mockOmrrData } from '@/mocks/mock-omrr-data'
import { ExportResultsButton } from './ExportResultsButton'
import { omrrDataToCsv } from '@/utils/utils'
import { downloadCsvFile } from '@/utils/file-utils'
import { ExportResultsButton } from './ExportResultsButton'

vi.mock('@/utils/file-utils')

Expand All @@ -26,6 +26,10 @@ describe('Test suite for ExportResultsButton', () => {

const csv = omrrDataToCsv(mockOmrrData)
expect(downloadCsvFile).toHaveBeenCalledOnce()
expect(downloadCsvFile).toHaveBeenCalledWith(csv, 'authorizations.csv')

const expectedFilename = expect.stringMatching(
/OMMR_Authorizations_\d{8}_\d{9}\.csv/,
)
expect(downloadCsvFile).toHaveBeenCalledWith(csv, expectedFilename)
})
})
5 changes: 3 additions & 2 deletions frontend/src/pages/authorizationList/ExportResultsButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button } from '@mui/material'

import { useFilteredResults } from '@/features/omrr/omrr-slice'
import { downloadCsvFile } from '@/utils/file-utils'
import { omrrDataToCsv } from '@/utils/utils'
import { filenameDateFormat, omrrDataToCsv } from '@/utils/utils'

import ExportIcon from '@/assets/svgs/fa-file-export.svg?react'

Expand All @@ -11,7 +11,8 @@ export function ExportResultsButton() {

const onExport = () => {
const csv = omrrDataToCsv(filteredResults)
downloadCsvFile(csv, `authorizations.csv`)
const date = filenameDateFormat(new Date())
downloadCsvFile(csv, `OMMR_Authorizations_${date}.csv`)
}

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import { getByAltText, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'

import { DATA_LAYER_GROUPS } from '@/constants/data-layers'
import { render } from '@/test-utils'
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ const geoLocationResult: GeolocationPosition = {

Object.defineProperty(navigator, 'geolocation', {
value: {
getCurrentPosition: (success: (pos: GeolocationPosition) => void) => {
getCurrentPosition: vi.fn((success: (pos: GeolocationPosition) => void) => {
success(geoLocationResult)
},
}),
watchPosition: (success: (pos: GeolocationPosition) => void) => {
success(geoLocationResult)
return 0
Expand All @@ -94,12 +94,12 @@ Object.defineProperty(navigator, 'geolocation', {

Object.defineProperty(navigator, 'permissions', {
value: {
query: ({ name }: PermissionDescriptor) => {
query: vi.fn(({ name }: PermissionDescriptor) => {
return Promise.resolve({
name,
state: 'granted',
} as PermissionStatus)
},
}),
},
})

Expand Down
Loading

0 comments on commit b5f85be

Please sign in to comment.