generated from taylorbryant/gatsby-starter-tailwind
-
-
Notifications
You must be signed in to change notification settings - Fork 127
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: create a coordinate picker modal to edit area lat lon form (#1129…
…) (#1133) * feat: picker button that opens a basic map modal * test: add testing for AreaLatLngForm * refactor: account for mobile * feat: custom popover added
- Loading branch information
1 parent
efb5359
commit 10a2e6e
Showing
6 changed files
with
305 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
57 changes: 57 additions & 0 deletions
57
src/app/(default)/editArea/[slug]/general/components/__tests__/AreaLatLngForm.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
import React from 'react' | ||
import { render, screen, fireEvent, waitFor, RenderResult } from '@testing-library/react' | ||
import '@testing-library/jest-dom' | ||
import { AreaLatLngForm } from '../AreaLatLngForm' | ||
import { FormProvider, useForm } from 'react-hook-form' | ||
import { useSession } from 'next-auth/react' | ||
|
||
jest.mock('next-auth/react', () => ({ | ||
useSession: jest.fn() | ||
})) | ||
jest.mock('../../../../../../../js/graphql/Client.ts') | ||
jest.mock('../../../../../../../components/maps/CoordinatePickerMap.tsx', () => ({ | ||
CoordinatePickerMap: () => <div data-testid='coordinate-picker-map' /> | ||
})) | ||
|
||
const renderWithForm = (component: React.ReactElement): RenderResult => { | ||
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { | ||
const methods = useForm() | ||
return <FormProvider {...methods}>{children}</FormProvider> | ||
} | ||
return render(<Wrapper>{component}</Wrapper>) | ||
} | ||
|
||
describe('AreaLatLngForm', () => { | ||
beforeEach(() => { | ||
(useSession as jest.Mock).mockReturnValue({ data: { accessToken: 'test-token' } }) | ||
}) | ||
|
||
test('renders without crashing', async () => { | ||
renderWithForm(<AreaLatLngForm initLat={0} initLng={0} uuid='test-uuid' isLeaf areaName='Test Area' />) | ||
await waitFor(() => { | ||
expect(screen.getByText('Coordinates')).toBeInTheDocument() | ||
}) | ||
}) | ||
|
||
test('renders coordinate input when isLeaf is true', async () => { | ||
renderWithForm(<AreaLatLngForm initLat={0} initLng={0} uuid='test-uuid' isLeaf areaName='Test Area' />) | ||
await waitFor(() => { | ||
expect(screen.getByLabelText('Coordinates in latitude, longitude format.')).toBeInTheDocument() | ||
}) | ||
}) | ||
|
||
test('renders message when isLeaf is false', async () => { | ||
renderWithForm(<AreaLatLngForm initLat={0} initLng={0} uuid='test-uuid' isLeaf={false} areaName='Test Area' />) | ||
await waitFor(() => { | ||
expect(screen.getByText("Coordinates field available only when area type is either 'Crag' or 'Boulder'.")).toBeInTheDocument() | ||
}) | ||
}) | ||
|
||
test('triggers the coordinate picker map dialog', async () => { | ||
renderWithForm(<AreaLatLngForm initLat={0} initLng={0} uuid='test-uuid' isLeaf areaName='Test Area' />) | ||
fireEvent.click(screen.getByText('Coordinate Picker')) | ||
await waitFor(() => { | ||
expect(screen.getByTestId('coordinate-picker-map')).toBeInTheDocument() | ||
}) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,139 @@ | ||
'use client' | ||
import { useCallback, useState } from 'react' | ||
import { Map, FullscreenControl, ScaleControl, NavigationControl, MapLayerMouseEvent, Marker, MapInstance, MarkerDragEvent, GeolocateControl, GeolocateResultEvent } from 'react-map-gl/maplibre' | ||
import maplibregl, { MapLibreEvent } from 'maplibre-gl' | ||
import dynamic from 'next/dynamic' | ||
import { useDebouncedCallback } from 'use-debounce' | ||
import { MAP_STYLES, type MapStyles } from './MapSelector' | ||
import { useFormContext } from 'react-hook-form' | ||
import MapLayersSelector from './MapLayersSelector' | ||
import { MapPin } from '@phosphor-icons/react/dist/ssr' | ||
import { CoordinatePickerPopup } from './CoordinatePickerPopup' | ||
|
||
export interface CameraInfo { | ||
center: { | ||
lng: number | ||
lat: number | ||
} | ||
zoom: number | ||
} | ||
|
||
interface CoordinatePickerMapProps { | ||
showFullscreenControl?: boolean | ||
initialCenter?: [number, number] | ||
initialViewState?: { | ||
bounds: maplibregl.LngLatBoundsLike | ||
fitBoundsOptions: maplibregl.FitBoundsOptions | ||
} | ||
onCoordinateConfirmed?: (coordinates: [number, number] | null) => void | ||
name?: string | ||
} | ||
|
||
export const CoordinatePickerMap: React.FC<CoordinatePickerMapProps> = ({ | ||
showFullscreenControl = true, initialCenter, onCoordinateConfirmed | ||
}) => { | ||
const [selectedCoord, setSelectedCoord] = useState({ lng: 0, lat: 0 }) | ||
const [cursor, setCursor] = useState<string>('default') | ||
const [mapStyle, setMapStyle] = useState<string>(MAP_STYLES.standard.style) | ||
const [mapInstance, setMapInstance] = useState<MapInstance | null>(null) | ||
const [popupOpen, setPopupOpen] = useState(false) | ||
const initialZoom = 14 | ||
|
||
const { setValue } = useFormContext() | ||
|
||
const onLoad = useCallback((e: MapLibreEvent) => { | ||
if (e.target == null) return | ||
setMapInstance(e.target) | ||
if (initialCenter != null) { | ||
e.target.jumpTo({ center: initialCenter, zoom: initialZoom ?? 6 }) | ||
} | ||
}, [initialCenter]) | ||
|
||
const updateCoordinates = useDebouncedCallback((lng, lat) => { | ||
setSelectedCoord({ lng, lat }) | ||
setPopupOpen(true) | ||
}, 100) | ||
|
||
const onClick = useCallback((event: MapLayerMouseEvent): void => { | ||
const { lngLat } = event | ||
setPopupOpen(false) | ||
updateCoordinates(lngLat.lng, lngLat.lat) | ||
}, [updateCoordinates]) | ||
|
||
const onMarkerDragEnd = (event: MarkerDragEvent): void => { | ||
const { lngLat } = event | ||
setPopupOpen(false) | ||
updateCoordinates(lngLat.lng, lngLat.lat) | ||
} | ||
|
||
const confirmSelection = (): void => { | ||
if (selectedCoord != null) { | ||
setValue('latlngStr', `${selectedCoord.lat.toFixed(5)},${selectedCoord.lng.toFixed(5)}`, { shouldDirty: true, shouldValidate: true }) | ||
if (onCoordinateConfirmed != null) { | ||
onCoordinateConfirmed([selectedCoord.lng, selectedCoord.lat]) | ||
} | ||
setPopupOpen(false) | ||
} | ||
} | ||
|
||
const updateMapLayer = (key: keyof MapStyles): void => { | ||
const style = MAP_STYLES[key] | ||
setMapStyle(style.style) | ||
} | ||
|
||
const handleGeolocate = useCallback((e: GeolocateResultEvent) => { | ||
const { coords } = e | ||
if (coords != null) { | ||
setPopupOpen(false) | ||
updateCoordinates(coords.longitude, coords.latitude) | ||
} | ||
}, [updateCoordinates]) | ||
|
||
return ( | ||
<div className='relative w-full h-full'> | ||
<Map | ||
id='coordinate-picker-map' | ||
onLoad={onLoad} | ||
onDragStart={() => { | ||
setPopupOpen(false) | ||
setCursor('move') | ||
}} | ||
onDragEnd={() => { | ||
if (selectedCoord != null) { | ||
setPopupOpen(true) | ||
} | ||
setCursor('default') | ||
}} | ||
onClick={onClick} | ||
mapStyle={mapStyle} | ||
cursor={cursor} | ||
cooperativeGestures={showFullscreenControl} | ||
> | ||
<MapLayersSelector emit={updateMapLayer} /> | ||
<ScaleControl unit='imperial' style={{ marginBottom: 10 }} position='bottom-left' /> | ||
<ScaleControl unit='metric' style={{ marginBottom: 0 }} position='bottom-left' /> | ||
{showFullscreenControl && <FullscreenControl />} | ||
<GeolocateControl position='top-left' onGeolocate={handleGeolocate} /> | ||
<NavigationControl showCompass={false} position='bottom-right' /> | ||
{(selectedCoord.lat !== 0 && selectedCoord.lng !== 0) && ( | ||
<> | ||
<Marker longitude={selectedCoord.lng} latitude={selectedCoord.lat} draggable onDragEnd={onMarkerDragEnd} anchor='bottom'> | ||
<MapPin size={36} weight='fill' className='text-accent' /> | ||
</Marker> | ||
<CoordinatePickerPopup | ||
info={{ coordinates: selectedCoord, mapInstance }} | ||
onConfirm={confirmSelection} | ||
onClose={() => setPopupOpen(false)} | ||
open={popupOpen} | ||
/> | ||
</> | ||
)} | ||
</Map> | ||
</div> | ||
) | ||
} | ||
|
||
export const LazyCoordinatePickerMap = dynamic<CoordinatePickerMapProps>(async () => await import('./CoordinatePickerMap').then( | ||
module => module.CoordinatePickerMap), { | ||
ssr: false | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import { useResponsive } from '@/js/hooks' | ||
import * as Popover from '@radix-ui/react-popover' | ||
import { useCallback } from 'react' | ||
import { MapInstance } from 'react-map-gl' | ||
|
||
interface CoordinatePickerPopupProps { | ||
info: { | ||
coordinates: { lng: number, lat: number } | ||
mapInstance: MapInstance | null | ||
} | ||
onConfirm: () => void | ||
onClose: () => void | ||
open: boolean | ||
} | ||
|
||
export const CoordinatePickerPopup: React.FC<CoordinatePickerPopupProps> = ({ info, onConfirm, onClose, open }) => { | ||
const { coordinates, mapInstance } = info | ||
const { lng: longitude, lat: latitude } = coordinates | ||
const screenXY = mapInstance?.project(coordinates) | ||
const { isMobile } = useResponsive() | ||
|
||
const handleConfirmClick = useCallback((e: React.MouseEvent) => { | ||
e.stopPropagation() | ||
onConfirm() | ||
}, [onConfirm]) | ||
|
||
if (screenXY == null) return null | ||
|
||
const anchorClass = isMobile | ||
? 'fixed top-15 left-1/2 transform -translate-x-1/2' | ||
: 'fixed top-1/4 left-1/2 transform -translate-x-1/2' | ||
|
||
return ( | ||
<Popover.Root open={open}> | ||
<Popover.Anchor className={anchorClass} /> | ||
<Popover.Content | ||
align='center' | ||
side='top' | ||
sideOffset={8} | ||
collisionPadding={24} | ||
className='z-50 focus:outline-none cursor-pointer p-4 bg-white rounded shadow-md' | ||
onClick={(e) => e.stopPropagation()} | ||
> | ||
<div className='text-center'> | ||
<p className='text-sm'>Coordinates: {latitude.toFixed(5)}, {longitude.toFixed(5)}</p> | ||
<div className='flex justify-center mt-2'> | ||
<button | ||
className='btn btn-primary mr-2' | ||
onClick={handleConfirmClick} | ||
> | ||
Confirm | ||
</button> | ||
<button | ||
className='btn btn-secondary' | ||
onClick={onClose} | ||
> | ||
Cancel | ||
</button> | ||
</div> | ||
</div> | ||
</Popover.Content> | ||
</Popover.Root> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters