Skip to content

Commit

Permalink
feat: create a coordinate picker modal to edit area lat lon form (#1129
Browse files Browse the repository at this point in the history
…) (#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
clintonlunn authored Jun 3, 2024
1 parent efb5359 commit 10a2e6e
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 13 deletions.
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
'use client'
import { useSession } from 'next-auth/react'
import { useState } from 'react'
import { MobileDialog, DialogContent, DialogTrigger } from '@/components/ui/MobileDialog'

import { SingleEntryForm } from '@/app/(default)/editArea/[slug]/components/SingleEntryForm'
import { AREA_LATLNG_FORM_VALIDATION_RULES } from '@/components/edit/EditAreaForm'
import { DashboardInput } from '@/components/ui/form/Input'
import useUpdateAreasCmd from '@/js/hooks/useUpdateAreasCmd'
import { parseLatLng } from '@/components/crag/cragSummary'
import { CoordinatePickerMap } from '@/components/maps/CoordinatePickerMap'
import { useResponsive } from '@/js/hooks'

export const AreaLatLngForm: React.FC<{ initLat: number, initLng: number, uuid: string, isLeaf: boolean }> = ({ uuid, initLat, initLng, isLeaf }) => {
export const AreaLatLngForm: React.FC<{ initLat: number, initLng: number, uuid: string, isLeaf: boolean, areaName: string }> = ({ uuid, initLat, initLng, isLeaf, areaName }) => {
const session = useSession({ required: true })
const { updateOneAreaCmd } = useUpdateAreasCmd({
areaId: uuid,
accessToken: session?.data?.accessToken as string
}
)
})
const latlngStr = `${initLat.toString()},${initLng.toString()}`
const [pickerSelected, setPickerSelected] = useState(false)
const { isMobile } = useResponsive()

return (
<SingleEntryForm<{ latlngStr: string }>
initialValues={{ latlngStr }}
Expand All @@ -30,14 +36,40 @@ export const AreaLatLngForm: React.FC<{ initLat: number, initLng: number, uuid:
}}
>
{isLeaf
? (<DashboardInput
name='latlngStr'
label='Coordinates in latitude, longitude format.'
className='w-80'
registerOptions={AREA_LATLNG_FORM_VALIDATION_RULES}
readOnly={!isLeaf}
/>)
: (<p className='text-secondary'>Coordinates field available only when area type is either 'Crag' or 'Boulder'.</p>)}
? (
<div className='flex flex-wrap items-end'>
<div className='pr-1'>
<DashboardInput
name='latlngStr'
label='Coordinates in latitude, longitude format.'
registerOptions={AREA_LATLNG_FORM_VALIDATION_RULES}
readOnly={!isLeaf}
/>
</div>
<MobileDialog open={pickerSelected} onOpenChange={setPickerSelected}>
<DialogTrigger asChild>
<button type='button' onClick={() => setPickerSelected(true)} className='btn btn-link p-0'>
Coordinate Picker
</button>
</DialogTrigger>
<DialogContent title={`${areaName}`} fullScreen={!!isMobile}>
<div className='w-full h-100vh'>
<div className='h-[90vh] lg:h-[50vh] w-full'>
<CoordinatePickerMap
initialCenter={[initLng, initLat]}
onCoordinateConfirmed={() => {
setPickerSelected(false)
}}
/>
</div>
</div>
</DialogContent>
</MobileDialog>
</div>
)
: (
<p className='text-secondary'>Coordinates field available only when area type is either 'Crag' or 'Boulder'.</p>
)}
</SingleEntryForm>
)
}
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()
})
})
})
2 changes: 1 addition & 1 deletion src/app/(default)/editArea/[slug]/general/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export default async function AreaEditPage ({ params }: DashboardPageProps): Pro
</SectionContainer>

<SectionContainer id='location'>
<AreaLatLngForm initLat={lat} initLng={lng} uuid={uuid} isLeaf={leaf} />
<AreaLatLngForm initLat={lat} initLng={lng} uuid={uuid} isLeaf={leaf} areaName={areaName} />
</SectionContainer>

<SectionContainer id='areaType'>
Expand Down
139 changes: 139 additions & 0 deletions src/components/maps/CoordinatePickerMap.tsx
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
})
64 changes: 64 additions & 0 deletions src/components/maps/CoordinatePickerPopup.tsx
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>
)
}
2 changes: 1 addition & 1 deletion src/components/maps/MapLayersSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const MapLayersSelector: React.FC<Props> = ({ emit }) => {
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content className=' pt-1.5 bg-base-100 rounded grid grid-cols-1' side='top' sideOffset={20}>
<Popover.Content className=' pt-1.5 bg-base-100 rounded grid grid-cols-1 z-[1000]' side='top' sideOffset={20}>
<button className='cursor-pointer col-span-1'>
{Object.keys(MAP_STYLES).map((key) => {
const mapKey = key as keyof typeof MAP_STYLES
Expand Down

0 comments on commit 10a2e6e

Please sign in to comment.