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 current location button to map #650

Merged
merged 9 commits into from
Jan 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/public/next-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
8 changes: 8 additions & 0 deletions apps/public/public/icon-select-marker.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,12 +1,103 @@
import { render } from '@testing-library/react'
import { screen, render, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import leaflet from 'leaflet'
import { vi } from 'vitest'

import { BaseLayer } from './BaseLayer'
import { marker } from './Marker/Marker'

vi.mock('leaflet', async (importOriginal) => {
const actual = await importOriginal()

const markerMock = {
addTo: vi.fn(),
}

return {
default: {
...(typeof actual === 'object' ? actual : {}),
marker: vi.fn(() => markerMock),
},
}
})

describe('BaseLayer', () => {
it('renders the component', () => {
const { container } = render(<BaseLayer />)
expect(container.firstChild).toBeInTheDocument()
})
})

// TODO: add tests
it('renders the current location button', () => {
render(<BaseLayer />)

const button = screen.getByRole('button', { name: 'Mijn locatie' })

expect(button).toBeInTheDocument()
})

it('displays a notification on geolocation error', async () => {
const mockGeolocation = {
getCurrentPosition: vi.fn().mockImplementationOnce((_, error) =>
error({
code: 1,
message: 'User denied Geolocation',
}),
),
}

// @ts-expect-error: This isn't a problem in tests
global.navigator.geolocation = mockGeolocation

const user = userEvent.setup()

render(<BaseLayer />)

const button = screen.getByRole('button', { name: 'Mijn locatie' })

await user.click(button)

const notification = screen.getByRole('heading', {
name: 'meldingen.amsterdam.nl heeft geen toestemming om uw locatie te gebruiken.',
})

expect(notification).toBeInTheDocument()
})

it('adds a marker on geolocation success', async () => {
const mockGeolocation = {
getCurrentPosition: vi.fn().mockImplementationOnce((success) =>
success({
coords: {
latitude: 52.370216,
longitude: 4.895168,
},
}),
),
}

// @ts-expect-error: This isn't a problem in tests
global.navigator.geolocation = mockGeolocation

const user = userEvent.setup()

render(<BaseLayer />)

const button = screen.getByRole('button', { name: 'Mijn locatie' })

await user.click(button)

expect(mockGeolocation.getCurrentPosition).toHaveBeenCalled()

await waitFor(() => {
expect(leaflet.marker).toHaveBeenCalledWith(
{
lat: 52.370216,
lng: 4.895168,
},
{
icon: marker,
},
)
})
})
})
55 changes: 51 additions & 4 deletions apps/public/src/app/(map)/locatie/kies/_components/BaseLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
import { Button, Paragraph } from '@amsterdam/design-system-react'
import L from 'leaflet'
import { useEffect, useRef, useState } from 'react'

import 'leaflet/dist/leaflet.css'
import styles from './map.module.css'
import { marker } from './Marker/Marker'
import { Notification } from './Notification/Notification'

export const BaseLayer = () => {
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<HTMLDivElement>(null)

// Use state instead of a ref for storing the Leaflet map object otherwise you may run into DOM issues when React StrictMode is enabled
const [mapInstance, setMapInstance] = useState<L.Map | null>(null)

const [markerLayer, setMarkerLayer] = useState<L.Marker | null>(null)

const [notification, setNotification] = useState<{ heading: string; description: string } | null>(null)

// This could be a useState but as we don't expect this to fire more than once, use ref as it is mutable and won't trigger any further re-render
const createdMapInstance = useRef(false)

useEffect(() => {
// Ensure that the target DOM element exists and that the map doesn't already exist (to prevent duplicate renders in StrictMode)
if (containerRef.current === null || createdMapInstance.current !== false) {
if (mapRef.current === null || createdMapInstance.current !== false) {
return undefined
}

const map = new L.Map(containerRef.current, {
const map = new L.Map(mapRef.current, {
center: L.latLng([52.370216, 4.895168]),
zoom: 14,
layers: [
Expand Down Expand Up @@ -57,5 +64,45 @@ export const BaseLayer = () => {
}
}, [mapInstance])

return <div className={styles.container} ref={containerRef} />
const onSuccess: PositionCallback = ({ coords }) => {
// TODO: is this correct? What should happen when you click the button without a map instance?
if (!mapInstance) return

const { latitude, longitude } = coords

// Remove existing marker layer
markerLayer?.remove()

// Create marker layer and add to map
const newMarker = L.marker(L.latLng([latitude, longitude]), { icon: marker }).addTo(mapInstance)

// Store marker layer in state
setMarkerLayer(newMarker)
}

const onError = () => {
// TODO: these texts should come from the BE, or a config / env vars
setNotification({
heading: 'meldingen.amsterdam.nl heeft geen toestemming om uw locatie te gebruiken.',
description: 'Dit kunt u wijzigen in de voorkeuren of instellingen van uw browser of systeem.',
})
}

const handleCurrentLocationButtonClick = () => navigator.geolocation.getCurrentPosition(onSuccess, onError)

return (
<div className={styles.container}>
<div className={styles.map} ref={mapRef} />
<div className={styles.overlay}>
<Button variant="secondary" onClick={handleCurrentLocationButtonClick}>
Mijn locatie
</Button>
{notification && (
<Notification heading={notification.heading} closeable onClose={() => setNotification(null)}>
<Paragraph>{notification.description}</Paragraph>
</Notification>
)}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import L from 'leaflet'

export const marker = L.icon({
iconUrl: '/icon-select-marker.svg',
iconSize: [40, 40],
iconAnchor: [20, 39],
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.notification {
background-color: white;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { AlertProps } from '@amsterdam/design-system-react'
import { Alert } from '@amsterdam/design-system-react'

import styles from './Notification.module.css'

export const Notification = (props: AlertProps) => <Alert {...props} className={styles.notification} severity="error" />
21 changes: 21 additions & 0 deletions apps/public/src/app/(map)/locatie/kies/_components/map.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
.container {
position: relative;
}

.map {
height: 100vh;
}

.overlay {
align-items: flex-start;
display: flex;
flex-direction: column;
gap: var(--ams-space-sm);
padding-block: var(--ams-space-sm);
padding-inline: var(--ams-space-sm);
position: absolute;
top: 0;

/*
Leaflet uses z-indexes to stack layers. The highest Leaflet z-index is 700.
https://leafletjs.com/reference.html#map-pane
*/
z-index: 701;
}
Loading