From 10a2e6e1c46c60ebb914141f102595d6d62c2bd5 Mon Sep 17 00:00:00 2001 From: Clinton Lunn Date: Mon, 3 Jun 2024 10:39:03 -0600 Subject: [PATCH] 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 --- .../general/components/AreaLatLngForm.tsx | 54 +++++-- .../components/__tests__/AreaLatLngForm.tsx | 57 +++++++ .../editArea/[slug]/general/page.tsx | 2 +- src/components/maps/CoordinatePickerMap.tsx | 139 ++++++++++++++++++ src/components/maps/CoordinatePickerPopup.tsx | 64 ++++++++ src/components/maps/MapLayersSelector.tsx | 2 +- 6 files changed, 305 insertions(+), 13 deletions(-) create mode 100644 src/app/(default)/editArea/[slug]/general/components/__tests__/AreaLatLngForm.tsx create mode 100644 src/components/maps/CoordinatePickerMap.tsx create mode 100644 src/components/maps/CoordinatePickerPopup.tsx diff --git a/src/app/(default)/editArea/[slug]/general/components/AreaLatLngForm.tsx b/src/app/(default)/editArea/[slug]/general/components/AreaLatLngForm.tsx index 051955d19..b0f9e0436 100644 --- a/src/app/(default)/editArea/[slug]/general/components/AreaLatLngForm.tsx +++ b/src/app/(default)/editArea/[slug]/general/components/AreaLatLngForm.tsx @@ -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 ( initialValues={{ latlngStr }} @@ -30,14 +36,40 @@ export const AreaLatLngForm: React.FC<{ initLat: number, initLng: number, uuid: }} > {isLeaf - ? () - : (

Coordinates field available only when area type is either 'Crag' or 'Boulder'.

)} + ? ( +
+
+ +
+ + + + + +
+
+ { + setPickerSelected(false) + }} + /> +
+
+
+
+
+ ) + : ( +

Coordinates field available only when area type is either 'Crag' or 'Boulder'.

+ )} ) } diff --git a/src/app/(default)/editArea/[slug]/general/components/__tests__/AreaLatLngForm.tsx b/src/app/(default)/editArea/[slug]/general/components/__tests__/AreaLatLngForm.tsx new file mode 100644 index 000000000..f8beebde9 --- /dev/null +++ b/src/app/(default)/editArea/[slug]/general/components/__tests__/AreaLatLngForm.tsx @@ -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: () =>
+})) + +const renderWithForm = (component: React.ReactElement): RenderResult => { + const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const methods = useForm() + return {children} + } + return render({component}) +} + +describe('AreaLatLngForm', () => { + beforeEach(() => { + (useSession as jest.Mock).mockReturnValue({ data: { accessToken: 'test-token' } }) + }) + + test('renders without crashing', async () => { + renderWithForm() + await waitFor(() => { + expect(screen.getByText('Coordinates')).toBeInTheDocument() + }) + }) + + test('renders coordinate input when isLeaf is true', async () => { + renderWithForm() + await waitFor(() => { + expect(screen.getByLabelText('Coordinates in latitude, longitude format.')).toBeInTheDocument() + }) + }) + + test('renders message when isLeaf is false', async () => { + renderWithForm() + 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() + fireEvent.click(screen.getByText('Coordinate Picker')) + await waitFor(() => { + expect(screen.getByTestId('coordinate-picker-map')).toBeInTheDocument() + }) + }) +}) diff --git a/src/app/(default)/editArea/[slug]/general/page.tsx b/src/app/(default)/editArea/[slug]/general/page.tsx index e61d06e46..8a23e6847 100644 --- a/src/app/(default)/editArea/[slug]/general/page.tsx +++ b/src/app/(default)/editArea/[slug]/general/page.tsx @@ -62,7 +62,7 @@ export default async function AreaEditPage ({ params }: DashboardPageProps): Pro - + diff --git a/src/components/maps/CoordinatePickerMap.tsx b/src/components/maps/CoordinatePickerMap.tsx new file mode 100644 index 000000000..35b00004a --- /dev/null +++ b/src/components/maps/CoordinatePickerMap.tsx @@ -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 = ({ + showFullscreenControl = true, initialCenter, onCoordinateConfirmed +}) => { + const [selectedCoord, setSelectedCoord] = useState({ lng: 0, lat: 0 }) + const [cursor, setCursor] = useState('default') + const [mapStyle, setMapStyle] = useState(MAP_STYLES.standard.style) + const [mapInstance, setMapInstance] = useState(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 ( +
+ { + setPopupOpen(false) + setCursor('move') + }} + onDragEnd={() => { + if (selectedCoord != null) { + setPopupOpen(true) + } + setCursor('default') + }} + onClick={onClick} + mapStyle={mapStyle} + cursor={cursor} + cooperativeGestures={showFullscreenControl} + > + + + + {showFullscreenControl && } + + + {(selectedCoord.lat !== 0 && selectedCoord.lng !== 0) && ( + <> + + + + setPopupOpen(false)} + open={popupOpen} + /> + + )} + +
+ ) +} + +export const LazyCoordinatePickerMap = dynamic(async () => await import('./CoordinatePickerMap').then( + module => module.CoordinatePickerMap), { + ssr: false +}) diff --git a/src/components/maps/CoordinatePickerPopup.tsx b/src/components/maps/CoordinatePickerPopup.tsx new file mode 100644 index 000000000..dd4555691 --- /dev/null +++ b/src/components/maps/CoordinatePickerPopup.tsx @@ -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 = ({ 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 ( + + + e.stopPropagation()} + > +
+

Coordinates: {latitude.toFixed(5)}, {longitude.toFixed(5)}

+
+ + +
+
+
+
+ ) +} diff --git a/src/components/maps/MapLayersSelector.tsx b/src/components/maps/MapLayersSelector.tsx index dbbd7c0c7..ba252951f 100644 --- a/src/components/maps/MapLayersSelector.tsx +++ b/src/components/maps/MapLayersSelector.tsx @@ -34,7 +34,7 @@ const MapLayersSelector: React.FC = ({ emit }) => { - +