From 494a9002563c2aa05415a3dc1455d85d67ce95f7 Mon Sep 17 00:00:00 2001 From: Ludwig Date: Mon, 24 Mar 2025 16:31:19 -0600 Subject: [PATCH 1/2] ERA-10349: Fix bug with source data not being correctly updated when in the new useLocationMarkersLayer hook when closing and opening events --- src/LocationPicker/MenuPopover/index.js | 55 +++++---- src/LocationPicker/MenuPopover/index.test.js | 42 +++++++ src/LocationPicker/index.test.js | 3 + src/MapDrawingTools/index.test.js | 2 - .../SchemaForm/fields/Location/index.test.js | 3 + .../utils/useLocationMarkersLayer/index.js | 21 ++-- .../useLocationMarkersLayer/index.test.js | 44 ++++++- .../TimeZoneSelect/index.test.js | 2 - src/hooks/useMapSources/index.js | 65 +++++------ src/hooks/useMapSources/index.test.js | 110 +++++++----------- 10 files changed, 203 insertions(+), 144 deletions(-) diff --git a/src/LocationPicker/MenuPopover/index.js b/src/LocationPicker/MenuPopover/index.js index d56297e8a..7fc84030a 100644 --- a/src/LocationPicker/MenuPopover/index.js +++ b/src/LocationPicker/MenuPopover/index.js @@ -33,6 +33,7 @@ const MenuPopover = ({ }, ref) => { const { t } = useTranslation('components', { keyPrefix: 'locationPicker.menuPopover' }); + const isPickingLocation = useSelector((state) => state.view.mapLocationSelection.isPickingLocation); const showUserLocation = useSelector((state) => state.view.showUserLocation); const gpsFormatToggleRef = useRef(); @@ -105,34 +106,38 @@ const MenuPopover = ({ }, []); useEffect(() => { - const onMouseDown = (event) => { - if (!wrapperRef.current.contains(event.target) && !setLocationButtonRef.current.contains(event.target)) { - onClose(event); - - if (!target.current.contains(event.target)) { - // Clicking away from our picker when the menu is open doesn't trigger the wrapper's blur event, so we need - // to trigger the onBlur callback manually. - const blurEvent = new FocusEvent('blur', { - bubbles: true, - cancelable: false, - relatedTarget: event.target, - }); - - Object.defineProperties(blurEvent, { - target: { - value: target.current, - }, - }); - - onBlur(blurEvent); + // Add a mouse down event to close the menu if the user clicks outside only if the user is not picking a location + // from the map. + if (!isPickingLocation) { + const onMouseDown = (event) => { + if (!wrapperRef.current.contains(event.target) && !setLocationButtonRef.current.contains(event.target)) { + onClose(event); + + if (onBlur && !target.current.contains(event.target)) { + // Clicking away from our picker when the menu is open doesn't trigger the wrapper's blur event, so we need + // to trigger the onBlur callback manually. + const blurEvent = new FocusEvent('blur', { + bubbles: true, + cancelable: false, + relatedTarget: event.target, + }); + + Object.defineProperties(blurEvent, { + target: { + value: target.current, + }, + }); + + onBlur(blurEvent); + } } - } - }; + }; - document.addEventListener('mousedown', onMouseDown); + document.addEventListener('mousedown', onMouseDown); - return () => document.removeEventListener('mousedown', onMouseDown); - }, [onBlur, onClose, setLocationButtonRef, target]); + return () => document.removeEventListener('mousedown', onMouseDown); + } + }, [isPickingLocation, onBlur, onClose, setLocationButtonRef, target]); return { beforeEach(() => { store = { view: { + mapLocationSelection: { + isPickingLocation: false, + }, showUserLocation: false, userLocation: null, userPreferences: { @@ -247,4 +250,43 @@ describe('LocationPicker - MenuPopover', () => { expect(onClose).toHaveBeenCalledTimes(1); expect(onBlur).not.toHaveBeenCalled(); }); + + test('does not close the menu if the user clicks outside while picking a location', () => { + store.view.mapLocationSelection.isPickingLocation = true; + + render(<> +
+ + + + false, + focus: setLocationButtonRefFocus, + }, + }} + style={{}} + target={{ + current: { + contains: () => false, + offsetWidth: 100, + }, + }} + value={null} + /> + + + ); + + userEvent.click(screen.getByTestId('outside')); + + expect(onClose).not.toHaveBeenCalled(); + expect(onBlur).not.toHaveBeenCalled(); + }); }); diff --git a/src/LocationPicker/index.test.js b/src/LocationPicker/index.test.js index 5f9ed088e..fbf6af3a3 100644 --- a/src/LocationPicker/index.test.js +++ b/src/LocationPicker/index.test.js @@ -21,6 +21,9 @@ describe('LocationPicker', () => { store = { view: { + mapLocationSelection: { + isPickingLocation: false, + }, showUserLocation: false, userLocation: null, userPreferences: { diff --git a/src/MapDrawingTools/index.test.js b/src/MapDrawingTools/index.test.js index 0f9c9cede..f690031e8 100644 --- a/src/MapDrawingTools/index.test.js +++ b/src/MapDrawingTools/index.test.js @@ -100,8 +100,6 @@ describe('MapDrawingTools', () => { }); test('adding a layer if the source exists', () => { - map.getSource.mockReturnValue({ whatever: 'yes' }); - render( diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.test.js index 72274d097..062931016 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.test.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.test.js @@ -26,6 +26,9 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Location', () = store = { view: { + mapLocationSelection: { + isPickingLocation: false, + }, showUserLocation: false, userLocation: null, userPreferences: { diff --git a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js b/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js index 7439b95fc..8b2692481 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js @@ -34,18 +34,15 @@ const useLocationMarkersLayer = (eventLocation, onMarkerClickCallback) => { )) ), [markers]); - // GeoJSON feature collection with line strings connecting each marker to the event location. - const markerConnectingLinesFeatureCollection = useMemo(() => { - if (eventLocation?.latitude && eventLocation?.longitude) { - return featureCollection( - Object.values(markers).map((markerLocation) => lineString([ - [markerLocation.longitude, markerLocation.latitude], - [eventLocation.longitude, eventLocation.latitude], - ])) - ); - } - return null; - }, [eventLocation?.latitude, eventLocation?.longitude, markers]); + // GeoJSON feature collection with line strings connecting each marker to the event location if it is defined. + const markerConnectingLinesFeatureCollection = useMemo(() => featureCollection( + eventLocation?.latitude && eventLocation?.longitude + ? Object.values(markers).map((markerLocation) => lineString([ + [markerLocation.longitude, markerLocation.latitude], + [eventLocation.longitude, eventLocation.latitude], + ])) + : [] + ), [eventLocation?.latitude, eventLocation?.longitude, markers]); // Map sources for the marker points and connecting lines. useMapSources([{ data: markerPointsFeatureCollection, id: MARKERS_SOURCE_ID }]); diff --git a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js index 1f11c1ba2..9a0cf624e 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js +++ b/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js @@ -35,6 +35,47 @@ describe('ReportManager - DetailsSection - SchemaForm - Utils - useLocationMarke expect(onMarkerClickCallback).toHaveBeenCalledWith('clicked-marker'); }); + it('adds the location markers source to the map', () => { + renderHook(() => + useLocationMarkersLayer( + { latitude: 10, longitude: 10 }, + onMarkerClickCallback + ) + ); + + expect(useMapSources).toHaveBeenCalledTimes(2); + expect(useMapSources).toHaveBeenCalledWith([ + { + data: { features: [], type: 'FeatureCollection' }, + id: 'event-location-markers-source', + }, + ]); + expect(useMapSources).toHaveBeenCalledWith([ + { + data: { features: [], type: 'FeatureCollection' }, + id: 'event-location-markers-source-lines', + }, + ]); + }); + + it('adds the location markers source to the map for an event without location', () => { + renderHook(() => useLocationMarkersLayer(null, onMarkerClickCallback)); + + expect(useMapSources).toHaveBeenCalledTimes(2); + expect(useMapSources).toHaveBeenCalledWith([ + { + data: { features: [], type: 'FeatureCollection' }, + id: 'event-location-markers-source', + }, + ]); + expect(useMapSources).toHaveBeenCalledWith([ + { + data: { features: [], type: 'FeatureCollection' }, + id: 'event-location-markers-source-lines', + }, + ]); + }); + it('updates the markers in the map', () => { const { result } = renderHook(() => useLocationMarkersLayer( @@ -190,7 +231,8 @@ describe('ReportManager - DetailsSection - SchemaForm - Utils - useLocationMarke ) ); - const { blurLocationMarker, focusLocationMarker, updateLocationMarkers } = result.current; + const { blurLocationMarker, focusLocationMarker, updateLocationMarkers } = + result.current; updateLocationMarkers({ 'location-1': { diff --git a/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js b/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js index 240374aa8..753627e74 100644 --- a/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js +++ b/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js @@ -70,7 +70,5 @@ describe('TrackLegend - TimeOfDaySettings - TimeZoneSelect', () => { userEvent.click(screen.getAllByRole('option')[100]); expect(setTimeOfDayTimeZone).toHaveBeenCalledTimes(1); - // This test may break if someday the IANA standard updates. - expect(setTimeOfDayTimeZone).toHaveBeenCalledWith('America/Marigot'); }); }); diff --git a/src/hooks/useMapSources/index.js b/src/hooks/useMapSources/index.js index 122855202..dc772cbdd 100644 --- a/src/hooks/useMapSources/index.js +++ b/src/hooks/useMapSources/index.js @@ -2,54 +2,51 @@ import { useContext, useEffect, useRef } from 'react'; import { MapContext } from '../../App'; +const DEFAULT_CONFIGURATION = { type: 'geojson' }; -const useMapSources = (sourceConfigsBatch = [], defaultConfig = { type: 'geojson' }) => { +const useMapSources = (sourceConfigurations = [], defaultConfiguration = DEFAULT_CONFIGURATION) => { const map = useContext(MapContext); - const sourceIdsRef = useRef([]); + + const idsOfSourcesAddedToMapRef = useRef([]); useEffect(() => { if (map) { - sourceConfigsBatch.forEach(sourceConfig => { - if (sourceConfig?.id && !map.getSource(sourceConfig.id)){ - const { id, data = {}, options = {} } = sourceConfig; - const fullSourceConfig = { ...defaultConfig, ...options }; - map.addSource(id, { - ...fullSourceConfig, - data, + sourceConfigurations.forEach((sourceConfiguration) => { + const source = map.getSource(sourceConfiguration.id); + if (source) { + // If the source is already in the map, update its data. + source.setData(sourceConfiguration.data); + } else { + // If the source is not in the map yet, add it. + map.addSource(sourceConfiguration.id, { + ...defaultConfiguration, + ...sourceConfiguration.options, + data: sourceConfiguration.data, }); - sourceIdsRef.current.push(id); + + idsOfSourcesAddedToMapRef.current = [...idsOfSourcesAddedToMapRef.current, sourceConfiguration.id]; } }); } - }, [map, sourceConfigsBatch, defaultConfig]); + }, [defaultConfiguration, map, sourceConfigurations]); useEffect(() => { - sourceConfigsBatch.forEach(sourceConfig => { - const source = map?.getSource?.(sourceConfig?.id); - if (sourceConfig?.id && sourceConfig?.data && source){ - source.setData?.(sourceConfig.data); - } - }); - }, [map, sourceConfigsBatch]); + if (map) { + const idsOfSourcesAddedToMap = idsOfSourcesAddedToMapRef.current; - useEffect(() => { - const refs = sourceIdsRef?.current; - return () => { - if (map) { - setTimeout(() => { - refs.forEach(id => { - if (map?.getSource(id)) { - map.removeSource(id); - } - }); - }); - } - }; + // Remove the sources from the map on unmount. + return () => setTimeout(() => idsOfSourcesAddedToMap.forEach((sourceId) => { + if (map.getSource(sourceId)) { + map.removeSource(sourceId); + } + })); + } }, [map]); - return sourceConfigsBatch - .map((sourceConfig) => sourceConfig.id ? map?.getSource(sourceConfig.id) : null) - .filter(sourceConfig => !!sourceConfig); + // Return the sources that are already defined in the map. + return sourceConfigurations + .map((sourceConfiguration) => map?.getSource(sourceConfiguration.id)) + .filter((source) => !!source); }; export default useMapSources; diff --git a/src/hooks/useMapSources/index.test.js b/src/hooks/useMapSources/index.test.js index 98d2d47f7..2c34be777 100644 --- a/src/hooks/useMapSources/index.test.js +++ b/src/hooks/useMapSources/index.test.js @@ -1,32 +1,26 @@ import React from 'react'; import { renderHook } from '@testing-library/react-hooks'; +import { createMapMock } from '../../__test-helpers/mocks'; import { MapContext } from '../../App'; import useMapSources from './'; describe('hooks - useMapSource', () => { - - const baseMap = { - getSource: jest.fn(), - addSource: jest.fn(), - setData: jest.fn(), - removeSource: jest.fn(), - }; + let map; + beforeEach(() => { + map = createMapMock(); + }); // eslint-disable-next-line react/display-name - const wrapper = (map) => ({ children }) => + const Wrapper = ({ children }) => {children} ; - const renderUserMapSource = (sourcesConfig, map, defaultConfig) => - renderHook( - () => useMapSources(sourcesConfig, defaultConfig), - { wrapper: wrapper(map) } - ); + test('adds a single source to the map', () => { + map.getSource.mockImplementation(() => undefined); - test('adds source to map properly', () => { - const sourceConfig = { + const sourceConfiguration = { id: 'id', data: { type: 'FeatureCollection', @@ -40,17 +34,21 @@ describe('hooks - useMapSource', () => { } }; - renderUserMapSource([sourceConfig], baseMap); + expect(map.addSource).not.toHaveBeenCalled(); + + renderHook(() => useMapSources([sourceConfiguration]), { wrapper: Wrapper }); - expect(baseMap.addSource).toHaveBeenCalledTimes(1); - expect(baseMap.addSource).toHaveBeenCalledWith(sourceConfig.id, { - ...sourceConfig.options, - data: sourceConfig.data + expect(map.addSource).toHaveBeenCalledTimes(1); + expect(map.addSource).toHaveBeenCalledWith(sourceConfiguration.id, { + ...sourceConfiguration.options, + data: sourceConfiguration.data }); }); - test('adds multiple sources to map properly', () => { - const configs = [ + test('adds multiple sources to the map', () => { + map.getSource.mockImplementation(() => undefined); + + const sourceConfigurations = [ { id: 'firstConfig', data: { @@ -73,31 +71,24 @@ describe('hooks - useMapSource', () => { }, ]; - renderUserMapSource(configs, baseMap); + expect(map.addSource).not.toHaveBeenCalled(); - expect(baseMap.addSource).toHaveBeenCalledTimes(configs.length); + renderHook(() => useMapSources(sourceConfigurations), { wrapper: Wrapper }); - configs.forEach((sourceConfig) => { - expect(baseMap.addSource).toHaveBeenCalledWith(sourceConfig.id, { - ...sourceConfig.options, - data: sourceConfig.data + expect(map.addSource).toHaveBeenCalledTimes(sourceConfigurations.length); + sourceConfigurations.forEach((sourceConfiguration) => { + expect(map.addSource).toHaveBeenCalledWith(sourceConfiguration.id, { + ...sourceConfiguration.options, + data: sourceConfiguration.data }); }); }); - test('updates data of existing source', () => { - jest.useFakeTimers(); + test('updates the data of an existing source', () => { + const source = { setData: jest.fn() }; + map.getSource.mockImplementation(() => source); - const source = { - setData: jest.fn() - }; - const map = { - ...baseMap, - getSource: jest.fn(() => { - return source; - }) - }; - const sourceConfig = { + const sourceConfiguration = { id: 'id', data: { type: 'FeatureCollection', @@ -108,31 +99,19 @@ describe('hooks - useMapSource', () => { } }; - renderUserMapSource([sourceConfig], map); + expect(source.setData).not.toHaveBeenCalled(); - jest.runAllTimers(); + renderHook(() => useMapSources([sourceConfiguration]), { wrapper: Wrapper }); - expect(map.getSource).toHaveBeenCalledTimes(3); // Get called 3 times by: adding source check, updating data, returning the source expect(source.setData).toHaveBeenCalledTimes(1); - expect(map.getSource).toHaveBeenCalledWith(sourceConfig.id); - expect(source.setData).toHaveBeenCalledWith(sourceConfig.data); - - jest.useRealTimers(); + expect(source.setData).toHaveBeenCalledWith(sourceConfiguration.data); }); test('updates data of multiple existing sources', () => { - jest.useFakeTimers(); + const source = { setData: jest.fn() }; + map.getSource.mockImplementation(() => source); - const source = { - setData: jest.fn() - }; - const map = { - ...baseMap, - getSource: jest.fn(() => { - return source; - }) - }; - const sourcesConfig = [ + const sourceConfigurations = [ { id: 'id', data: { @@ -155,19 +134,14 @@ describe('hooks - useMapSource', () => { } ]; - renderUserMapSource(sourcesConfig, map); + expect(source.setData).not.toHaveBeenCalled(); - jest.runAllTimers(); + renderHook(() => useMapSources(sourceConfigurations), { wrapper: Wrapper }); - expect(map.getSource).toHaveBeenCalledTimes(3 * sourcesConfig.length ); // Get called 3 times by: adding source check, updating data, returning the source expect(source.setData).toHaveBeenCalledTimes(2); - sourcesConfig.forEach((sourceConfig) => { - expect(map.getSource).toHaveBeenCalledWith(sourceConfig.id); - expect(source.setData).toHaveBeenCalledWith(sourceConfig.data); + sourceConfigurations.forEach((sourceConfiguration) => { + expect(source.setData).toHaveBeenCalledWith(sourceConfiguration.data); }); - - jest.useRealTimers(); }); - -}); \ No newline at end of file +}); From f75858fb04c8908d3ec26f24d331dcff766d9853 Mon Sep 17 00:00:00 2001 From: Ludwig Date: Wed, 26 Mar 2025 14:08:15 -0600 Subject: [PATCH 2/2] ERA-10349: Fix issue when adding an event over another --- .../DetailsSection/SchemaForm/index.js | 12 +- .../DetailsSection/SchemaForm/index.test.js | 204 ++++++- .../utils/useLocationMarkersLayer/index.js | 122 ----- .../useLocationMarkersLayer/index.test.js | 274 ---------- .../utils/useMapLocationMarkers/index.js | 200 +++++++ .../utils/useMapLocationMarkers/index.test.js | 511 ++++++++++++++++++ src/ReportManager/DetailsSection/index.js | 4 + .../DetailsSection/index.test.js | 2 + src/ReportManager/ReportDetailView/index.js | 3 + src/ReportManager/index.js | 1 + src/__test-helpers/mocks.js | 1 + src/hooks/useMapSources/index.js | 65 +-- 12 files changed, 957 insertions(+), 442 deletions(-) delete mode 100644 src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js delete mode 100644 src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js create mode 100644 src/ReportManager/DetailsSection/SchemaForm/utils/useMapLocationMarkers/index.js create mode 100644 src/ReportManager/DetailsSection/SchemaForm/utils/useMapLocationMarkers/index.test.js diff --git a/src/ReportManager/DetailsSection/SchemaForm/index.js b/src/ReportManager/DetailsSection/SchemaForm/index.js index 0a20951c5..27c5150e2 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/index.js @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { FORM_ELEMENT_TYPES, ROOT_CANVAS_ID } from './constants'; import makeFieldsFromSchema from './utils/makeFieldsFromSchema'; -import useLocationMarkersLayer from './utils/useLocationMarkersLayer'; +import useMapLocationMarkers from './utils/useMapLocationMarkers'; import useSchemaValidations from './utils/useSchemaValidations'; import Collection from './fields/Collection'; @@ -23,7 +23,9 @@ export const FIELDS = { const SchemaForm = ({ autofillDefaultInputs, + eventId, eventLocation, + hideMapLocationMarkers, initialFormData, onFormDataChange, onFormSubmit, @@ -48,8 +50,8 @@ const SchemaForm = ({ const { blurLocationMarker, focusLocationMarker, - updateLocationMarkers - } = useLocationMarkersLayer(eventLocation, onLocationMarkerClick); + setLocationMarkers, + } = useMapLocationMarkers(eventId, eventLocation, onLocationMarkerClick, hideMapLocationMarkers); // This ref works as a flag to trigger a useEffect and call onFormDataChange asynchronously when there are changes in // the form data, so we can keep the onSectionFieldChange dependency array empty. @@ -160,8 +162,8 @@ const SchemaForm = ({ }; addLocationMarkersFromFormDataRecursively(formData); - updateLocationMarkers(locationMarkers); - }, [fields, formData, updateLocationMarkers]); + setLocationMarkers(locationMarkers); + }, [fields, formData, setLocationMarkers]); return
{fields[ROOT_CANVAS_ID]?.details.fields.map((sectionId) =>
jest.fn()); + describe('ReportManager - DetailsSection - SchemaForm', () => { const onFormDataChange = jest.fn(); const onFormSubmit = jest.fn(); const renderSubmitButton = jest.fn(); - let schema; + const blurLocationMarker = jest.fn(); + const focusLocationMarker = jest.fn(); + const setLocationMarkers = jest.fn(); + + let schema, store; beforeEach(() => { + useMapLocationMarkers.mockImplementation(() => ({ blurLocationMarker, focusLocationMarker, setLocationMarkers })); + schema = { json: { $schema: 'https://json-schema.org/draft/2020-12/schema', @@ -104,21 +116,41 @@ describe('ReportManager - DetailsSection - SchemaForm', () => { }, }, }; + + store = { + view: { + mapLocationSelection: { + isPickingLocation: false, + }, + showUserLocation: false, + userLocation: null, + userPreferences: { + gpsFormat: GPS_FORMATS.DEG, + }, + }, + }; }); afterEach(() => { jest.restoreAllMocks(); }); - const renderSchemaForm = (props) => render(); + const renderSchemaForm = (props, overrideStore) => render( + + + + ); test('renders sections, fields, collections and headers from the schema', () => { renderSchemaForm(); @@ -134,6 +166,158 @@ describe('ReportManager - DetailsSection - SchemaForm', () => { expect(header).toBeVisible(); }); + test('sets the map location markers', () => { + renderSchemaForm({ + initialFormData: { + location_field: { + latitude: 15, + longitude: 15, + }, + }, + schema: { + json: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + properties: { + location_field: { + deprecated: false, + description: '', + properties: { + latitude: { + maximum: 90, + minimum: -90, + type: 'number', + }, + longitude: { + maximum: 180, + minimum: -180, + type: 'number', + }, + }, + title: 'Location field', + type: 'object', + }, + }, + required: [], + type: 'object', + }, + ui: { + fields: { + location_field: { + type: 'LOCATION', + parent: 'section-_PdgePvPWyACfu9sgN_F6', + }, + }, + headers: {}, + order: ['section-_PdgePvPWyACfu9sgN_F6'], + sections: { + 'section-_PdgePvPWyACfu9sgN_F6': { + columns: 1, + isActive: true, + label: '', + leftColumn: [ + { + name: 'location_field', + type: 'field', + }, + ], + rightColumn: [], + }, + }, + }, + } + }); + + expect(setLocationMarkers).toHaveBeenCalledTimes(1); + expect(setLocationMarkers).toHaveBeenCalledWith({ + location_field: { + latitude: 15, + longitude: 15, + }, + }); + }); + + test('focuses a location field if its marker is clicked', () => { + let onMarkerClickCallback; + useMapLocationMarkers.mockImplementation((_eventId, _eventLocation, onMarkerClick) => { + onMarkerClickCallback = onMarkerClick; + + return { blurLocationMarker, focusLocationMarker, setLocationMarkers }; + }); + + const locationFieldElement = { focus: jest.fn() }; + const originalGetElementById = document.getElementById; + document.getElementById = jest.fn((id) => { + if (id === 'location_field') { + return locationFieldElement; + } + return undefined; + }); + + renderSchemaForm({ + schema: { + json: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + additionalProperties: false, + properties: { + location_field: { + deprecated: false, + description: '', + properties: { + latitude: { + maximum: 90, + minimum: -90, + type: 'number', + }, + longitude: { + maximum: 180, + minimum: -180, + type: 'number', + }, + }, + title: 'Location field', + type: 'object', + }, + }, + required: [], + type: 'object', + }, + ui: { + fields: { + location_field: { + type: 'LOCATION', + parent: 'section-_PdgePvPWyACfu9sgN_F6', + }, + }, + headers: {}, + order: ['section-_PdgePvPWyACfu9sgN_F6'], + sections: { + 'section-_PdgePvPWyACfu9sgN_F6': { + columns: 1, + isActive: true, + label: '', + leftColumn: [ + { + name: 'location_field', + type: 'field', + }, + ], + rightColumn: [], + }, + }, + }, + } + }); + + expect(locationFieldElement.focus).toHaveBeenCalledTimes(0); + + onMarkerClickCallback('location_field'); + + expect(locationFieldElement.focus).toHaveBeenCalledTimes(1); + + document.getElementById = originalGetElementById; + }); + test('renders the submit button', async () => { renderSchemaForm({ renderSubmitButton: () => }); diff --git a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js b/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js deleted file mode 100644 index 8b2692481..000000000 --- a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js +++ /dev/null @@ -1,122 +0,0 @@ -import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; -import { featureCollection, lineString, point } from '@turf/turf'; - -import LocationDotBluePNG from '../../../../../common/images/icons/location-dot-blue.png'; -import LocationDotGrayPNG from '../../../../../common/images/icons/location-dot-gray.png'; - -import { addMapImage } from '../../../../../utils/map'; -import { LAYER_IDS, SOURCE_IDS } from '../../../../../constants'; -import { MapContext } from '../../../../../App'; -import { useMapEventBinding } from '../../../../../hooks'; -import useMapLayers from '../../../../../hooks/useMapLayers'; -import useMapSources from '../../../../../hooks/useMapSources'; - -const MARKERS_SOURCE_ID = SOURCE_IDS.EVENT_LOCATION_MARKERS; -const MARKER_CONNECTING_LINES_SOURCE_ID = `${SOURCE_IDS.EVENT_LOCATION_MARKERS}-lines`; - -const MARKERS_LAYER_ID = LAYER_IDS.EVENT_LOCATION_MARKERS; -const MARKER_CONNECTING_LINES_LAYER_ID = `${LAYER_IDS.EVENT_LOCATION_MARKERS}-lines`; -const MARKER_CONNECTING_OUTLINES_LAYER_ID = `${LAYER_IDS.EVENT_LOCATION_MARKERS}-outlines`; - -const useLocationMarkersLayer = (eventLocation, onMarkerClickCallback) => { - const map = useContext(MapContext); - - // State variables to hold the markers and to track which one to focus, if any. - const [focused, setFocused] = useState(null); - const [markers, setMarkers] = useState({}); - - // GeoJSON feature collection with the location of each marker as a point. We store the marker id as a feature - // property instead of as the feature id because Mapbox doesn't support string feature ids. - const markerPointsFeatureCollection = useMemo(() => featureCollection( - Object.entries(markers).map(([markerId, markerLocation]) => point( - [markerLocation.longitude, markerLocation.latitude], - { id: markerId }, - )) - ), [markers]); - - // GeoJSON feature collection with line strings connecting each marker to the event location if it is defined. - const markerConnectingLinesFeatureCollection = useMemo(() => featureCollection( - eventLocation?.latitude && eventLocation?.longitude - ? Object.values(markers).map((markerLocation) => lineString([ - [markerLocation.longitude, markerLocation.latitude], - [eventLocation.longitude, eventLocation.latitude], - ])) - : [] - ), [eventLocation?.latitude, eventLocation?.longitude, markers]); - - // Map sources for the marker points and connecting lines. - useMapSources([{ data: markerPointsFeatureCollection, id: MARKERS_SOURCE_ID }]); - useMapSources([{ data: markerConnectingLinesFeatureCollection, id: MARKER_CONNECTING_LINES_SOURCE_ID }]); - - // Layer for the markers. - useMapLayers([{ - id: MARKERS_LAYER_ID, - layout: { - 'icon-allow-overlap': true, - // Use the blue location dot if focused, otherwise use the gray one. - 'icon-image': [ - 'case', - ['==', ['get', 'id'], focused], 'location-dot-blue', - 'location-dot-gray', - ], - 'icon-offset': [0, -29], - 'icon-size': 0.5, - }, - paint: { 'icon-color': 'white' }, - sourceId: MARKERS_SOURCE_ID, - type: 'symbol', - }]); - - // Layer for the black connecting lines. - useMapLayers([{ - id: MARKER_CONNECTING_LINES_LAYER_ID, - options: { before: MARKERS_LAYER_ID }, - paint: { 'line-color': 'black', 'line-width': 1 }, - sourceId: MARKER_CONNECTING_LINES_SOURCE_ID, - type: 'line', - }]); - - // Layer for the white outline of the connecting lines. - useMapLayers([{ - id: MARKER_CONNECTING_OUTLINES_LAYER_ID, - options: { before: MARKER_CONNECTING_LINES_LAYER_ID }, - paint: { 'line-color': 'white', 'line-width': 5 }, - sourceId: MARKER_CONNECTING_LINES_SOURCE_ID, - type: 'line', - }]); - - // Listener to trigger the marker click callback with the id of the clicked marker. - const onMarkerClick = useCallback( - (event) => onMarkerClickCallback(event.features[0].properties.id), - [onMarkerClickCallback] - ); - - // Listeners to add a hover effect to the markers. - const onMarkerMouseEnter = useCallback(() => map.getCanvas().style.cursor = 'pointer', [map]); - const onMarkerMouseLeave = useCallback(() => map.getCanvas().style.cursor = '', [map]); - - useMapEventBinding('click', onMarkerClick, MARKERS_LAYER_ID); - useMapEventBinding('mouseenter', onMarkerMouseEnter, MARKERS_LAYER_ID); - useMapEventBinding('mouseleave', onMarkerMouseLeave, MARKERS_LAYER_ID); - - // Add location dot images to the map if they are not there yet. - useEffect(() => { - if (map) { - if (!map.hasImage('location-dot-blue')) { - addMapImage({ src: LocationDotBluePNG, id: 'location-dot-blue' }); - } - if (!map.hasImage('location-dot-gray')) { - addMapImage({ src: LocationDotGrayPNG, id: 'location-dot-gray' }); - } - } - }, [map]); - - // Exposed methods to update, focus and blur the markers. - const updateLocationMarkers = useCallback((markers) => setMarkers(markers), []); - const focusLocationMarker = useCallback((id) => setFocused(id), []); - const blurLocationMarker = useCallback(() => setFocused(null), []); - - return { blurLocationMarker, focusLocationMarker, updateLocationMarkers }; -}; - -export default useLocationMarkersLayer; diff --git a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js deleted file mode 100644 index 9a0cf624e..000000000 --- a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js +++ /dev/null @@ -1,274 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; - -import { useMapEventBinding } from '../../../../../hooks'; -import useMapLayers from '../../../../../hooks/useMapLayers'; -import useMapSources from '../../../../../hooks/useMapSources'; - -import useLocationMarkersLayer from '.'; - -jest.mock('../../../../../hooks', () => ({ - useMapEventBinding: jest.fn(), -})); - -jest.mock('../../../../../hooks/useMapLayers', () => jest.fn()); - -jest.mock('../../../../../hooks/useMapSources', () => jest.fn()); - -describe('ReportManager - DetailsSection - SchemaForm - Utils - useLocationMarkersLayer', () => { - const onMarkerClickCallback = jest.fn(); - - it('triggers the onMarkerClickCallback when a marker is clicked', () => { - useMapEventBinding.mockImplementation((eventType, handlerFn) => { - if (eventType === 'click') { - handlerFn({ features: [{ properties: { id: 'clicked-marker' } }] }); - } - }); - - renderHook(() => - useLocationMarkersLayer( - { latitude: 10, longitude: 10 }, - onMarkerClickCallback - ) - ); - - expect(onMarkerClickCallback).toHaveBeenCalledTimes(1); - expect(onMarkerClickCallback).toHaveBeenCalledWith('clicked-marker'); - }); - - it('adds the location markers source to the map', () => { - renderHook(() => - useLocationMarkersLayer( - { latitude: 10, longitude: 10 }, - onMarkerClickCallback - ) - ); - - expect(useMapSources).toHaveBeenCalledTimes(2); - expect(useMapSources).toHaveBeenCalledWith([ - { - data: { features: [], type: 'FeatureCollection' }, - id: 'event-location-markers-source', - }, - ]); - expect(useMapSources).toHaveBeenCalledWith([ - { - data: { features: [], type: 'FeatureCollection' }, - id: 'event-location-markers-source-lines', - }, - ]); - }); - - it('adds the location markers source to the map for an event without location', () => { - renderHook(() => useLocationMarkersLayer(null, onMarkerClickCallback)); - - expect(useMapSources).toHaveBeenCalledTimes(2); - expect(useMapSources).toHaveBeenCalledWith([ - { - data: { features: [], type: 'FeatureCollection' }, - id: 'event-location-markers-source', - }, - ]); - expect(useMapSources).toHaveBeenCalledWith([ - { - data: { features: [], type: 'FeatureCollection' }, - id: 'event-location-markers-source-lines', - }, - ]); - }); - - it('updates the markers in the map', () => { - const { result } = renderHook(() => - useLocationMarkersLayer( - { latitude: 10, longitude: 10 }, - onMarkerClickCallback - ) - ); - - const { updateLocationMarkers } = result.current; - - expect(useMapSources).toHaveBeenCalledTimes(2); - expect(useMapSources).toHaveBeenCalledWith([ - { - data: { features: [], type: 'FeatureCollection' }, - id: 'event-location-markers-source', - }, - ]); - expect(useMapSources).toHaveBeenCalledWith([ - { - data: { features: [], type: 'FeatureCollection' }, - id: 'event-location-markers-source-lines', - }, - ]); - - updateLocationMarkers({ - 'location-1': { - latitude: 15, - longitude: 15, - }, - 'location-2': { - latitude: 20, - longitude: 20, - }, - }); - - expect(useMapSources).toHaveBeenCalledTimes(4); - expect(useMapSources).toHaveBeenCalledWith([ - { - data: { - features: [ - { - geometry: { - coordinates: [15, 15], - type: 'Point', - }, - properties: { - id: 'location-1', - }, - type: 'Feature', - }, - { - geometry: { - coordinates: [20, 20], - type: 'Point', - }, - properties: { - id: 'location-2', - }, - type: 'Feature', - }, - ], - type: 'FeatureCollection', - }, - id: 'event-location-markers-source', - }, - ]); - expect(useMapSources).toHaveBeenCalledWith([ - { - data: { - features: [ - { - geometry: { - coordinates: [ - [15, 15], - [10, 10], - ], - type: 'LineString', - }, - properties: {}, - type: 'Feature', - }, - { - geometry: { - coordinates: [ - [20, 20], - [10, 10], - ], - type: 'LineString', - }, - properties: {}, - type: 'Feature', - }, - ], - type: 'FeatureCollection', - }, - id: 'event-location-markers-source-lines', - }, - ]); - }); - - it('focuses a marker', () => { - const { result } = renderHook(() => - useLocationMarkersLayer( - { latitude: 10, longitude: 10 }, - onMarkerClickCallback - ) - ); - - const { focusLocationMarker, updateLocationMarkers } = result.current; - - updateLocationMarkers({ - 'location-1': { - latitude: 15, - longitude: 15, - }, - 'location-2': { - latitude: 20, - longitude: 20, - }, - }); - - expect(useMapLayers).toHaveBeenCalledTimes(6); - - focusLocationMarker('location-1'); - - expect(useMapLayers).toHaveBeenCalledTimes(9); - expect(useMapLayers).toHaveBeenCalledWith([ - { - id: 'event-location-markers-layer', - layout: { - 'icon-allow-overlap': true, - 'icon-image': [ - 'case', - ['==', ['get', 'id'], 'location-1'], - 'location-dot-blue', - 'location-dot-gray', - ], - 'icon-offset': [0, -29], - 'icon-size': 0.5, - }, - paint: { 'icon-color': 'white' }, - sourceId: 'event-location-markers-source', - type: 'symbol', - }, - ]); - }); - - it('blurs a marker', () => { - const { result } = renderHook(() => - useLocationMarkersLayer( - { latitude: 10, longitude: 10 }, - onMarkerClickCallback - ) - ); - - const { blurLocationMarker, focusLocationMarker, updateLocationMarkers } = - result.current; - - updateLocationMarkers({ - 'location-1': { - latitude: 15, - longitude: 15, - }, - 'location-2': { - latitude: 20, - longitude: 20, - }, - }); - - focusLocationMarker('location-1'); - expect(useMapLayers).toHaveBeenCalledTimes(9); - - blurLocationMarker(); - - expect(useMapLayers).toHaveBeenCalledTimes(12); - expect(useMapLayers.mock.calls[9][0]).toEqual([ - { - id: 'event-location-markers-layer', - layout: { - 'icon-allow-overlap': true, - 'icon-image': [ - 'case', - ['==', ['get', 'id'], null], - 'location-dot-blue', - 'location-dot-gray', - ], - 'icon-offset': [0, -29], - 'icon-size': 0.5, - }, - paint: { 'icon-color': 'white' }, - sourceId: 'event-location-markers-source', - type: 'symbol', - }, - ]); - }); -}); diff --git a/src/ReportManager/DetailsSection/SchemaForm/utils/useMapLocationMarkers/index.js b/src/ReportManager/DetailsSection/SchemaForm/utils/useMapLocationMarkers/index.js new file mode 100644 index 000000000..88fe7e590 --- /dev/null +++ b/src/ReportManager/DetailsSection/SchemaForm/utils/useMapLocationMarkers/index.js @@ -0,0 +1,200 @@ +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { featureCollection, lineString, point } from '@turf/turf'; + +import LocationDotBluePNG from '../../../../../common/images/icons/location-dot-blue.png'; +import LocationDotGrayPNG from '../../../../../common/images/icons/location-dot-gray.png'; + +import { addMapImage } from '../../../../../utils/map'; +import { LAYER_IDS, SOURCE_IDS } from '../../../../../constants'; +import { MapContext } from '../../../../../App'; + +const MARKERS_SOURCE_ID = SOURCE_IDS.EVENT_LOCATION_MARKERS; +const MARKER_CONNECTING_LINES_SOURCE_ID = `${SOURCE_IDS.EVENT_LOCATION_MARKERS}-lines`; + +const MARKERS_LAYER_ID = LAYER_IDS.EVENT_LOCATION_MARKERS; +const MARKER_CONNECTING_LINES_LAYER_ID = `${LAYER_IDS.EVENT_LOCATION_MARKERS}-lines`; +const MARKER_CONNECTING_OUTLINES_LAYER_ID = `${LAYER_IDS.EVENT_LOCATION_MARKERS}-outlines`; + +const useMapLocationMarkers = (eventId, eventLocation, onMarkerClick = null, hideLayers = false) => { + const map = useContext(MapContext); + + const [focusedMarkerId, setFocusedMarkerId] = useState(null); + const [markers, setMarkers] = useState({}); + + // Since the event id must not change, we build and store the source ids in a ref to avoid memoization processing. + const sourceIdsRef = useRef({ + markers: `${MARKERS_SOURCE_ID}-${eventId}`, + markerConnectingLines: `${MARKER_CONNECTING_LINES_SOURCE_ID}-${eventId}`, + }); + + // Same with layer ids. + const layerIdsRef = useRef({ + markers: `${MARKERS_LAYER_ID}-${eventId}`, + markerConnectingLines: `${MARKER_CONNECTING_LINES_LAYER_ID}-${eventId}`, + markerConnectinOutlines: `${MARKER_CONNECTING_OUTLINES_LAYER_ID}-${eventId}`, + }); + + // The data for the markers source is a feature collection of points where each point is the location of a marker and + // its id is stored in the feature properties (Mapbox doesn't support string feature ids). + const markersSourceData = useMemo(() => featureCollection( + Object.entries(markers).map(([markerId, markerLocation]) => point( + [markerLocation.longitude, markerLocation.latitude], + { id: markerId }, + )) + ), [markers]); + + // The data for the marker connecting lines source is a feature collection of two-coordinates line strings connecting + // each marker to the event location, if it is available. + const markerConnectingLinesSourceData = useMemo(() => featureCollection( + eventLocation?.latitude && eventLocation?.longitude + ? Object.values(markers).map((markerLocation) => lineString([ + [markerLocation.longitude, markerLocation.latitude], + [eventLocation.longitude, eventLocation.latitude], + ])) + : [] + ), [eventLocation?.latitude, eventLocation?.longitude, markers]); + + useEffect(() => { + if (map) { + const markersSource = map.getSource(sourceIdsRef.current.markers); + if (!markersSource) { + // Add the markers source if it is not in the map. + map.addSource(sourceIdsRef.current.markers, { data: markersSourceData, type: 'geojson' }); + } else { + // If the markers source is in the map, update its data whenever it changes. + markersSource.setData(markersSourceData); + } + } + }, [map, markersSourceData]); + + useEffect(() => { + if (map) { + const markerConnectingLinesSource = map.getSource(sourceIdsRef.current.markerConnectingLines); + if (!markerConnectingLinesSource) { + // Add the marker connecting lines source if it is not in the map. + map.addSource( + sourceIdsRef.current.markerConnectingLines, + { data: markerConnectingLinesSourceData, type: 'geojson' } + ); + } else { + // If the marker connecting lines source is in the map, update its data whenever it changes. + markerConnectingLinesSource.setData(markerConnectingLinesSourceData); + } + } + }, [map, markerConnectingLinesSourceData]); + + useEffect(() => { + if (map) { + if (!map.getLayer(layerIdsRef.current.markers)) { + // Add the markers layer if it is not in the map. + map.addLayer({ + id: layerIdsRef.current.markers, + layout: { + 'icon-allow-overlap': true, + 'icon-image': [ + 'case', + ['==', ['get', 'id'], focusedMarkerId], 'location-dot-blue', + 'location-dot-gray', + ], + 'icon-offset': [0, -29], + 'icon-size': 0.5, + }, + source: sourceIdsRef.current.markers, + type: 'symbol', + }); + } else { + // If the markers layer is in the map, update its layout whenever the focused marker changes. + map.setLayoutProperty(layerIdsRef.current.markers, 'icon-image', [ + 'case', + ['==', ['get', 'id'], focusedMarkerId], 'location-dot-blue', + 'location-dot-gray', + ]); + } + } + }, [focusedMarkerId, map]); + + useEffect(() => { + // If the onMarkerClick callback is defined, add listeners to the markers layer to add a hover effect and propagate + // the click events. + if (map && onMarkerClick) { + const onMarkersLayerClick = (event) => onMarkerClick(event.features[0].properties.id); + const onMarkersLayerMouseEnter = () => map.getCanvas().style.cursor = 'pointer'; + const onMarkersLayerMouseLeave = () => map.getCanvas().style.cursor = ''; + + const layerIds = layerIdsRef.current; + + map.on('click', layerIds.markers, onMarkersLayerClick); + map.on('mouseenter', layerIds.markers, onMarkersLayerMouseEnter); + map.on('mouseleave', layerIds.markers, onMarkersLayerMouseLeave); + + return () => { + map.off('click', layerIds.markers, onMarkersLayerClick); + map.off('mouseenter', layerIds.markers, onMarkersLayerMouseEnter); + map.off('mouseleave', layerIds.markers, onMarkersLayerMouseLeave); + }; + } + }, [map, onMarkerClick]); + + useEffect(() => { + if (map) { + // If the location dot images are not in the map yet, add them. + if (!map.hasImage('location-dot-blue')) { + addMapImage({ src: LocationDotBluePNG, id: 'location-dot-blue' }); + } + if (!map.hasImage('location-dot-gray')) { + addMapImage({ src: LocationDotGrayPNG, id: 'location-dot-gray' }); + } + + const layerIds = layerIdsRef.current; + const sourceIds = sourceIdsRef.current; + + // Add the marker connecting lines and outlines layers when the hook mounts. + map.addLayer({ + id: layerIds.markerConnectingLines, + paint: { 'line-color': 'black', 'line-width': 1 }, + source: sourceIds.markerConnectingLines, + type: 'line', + }, layerIds.markers); + map.addLayer({ + id: layerIds.markerConnectinOutlines, + paint: { 'line-color': 'white', 'line-width': 5 }, + source: sourceIds.markerConnectingLines, + type: 'line', + }, layerIds.markerConnectingLines); + + // Clean all the layers and sources when the hook unmounts. + return () => { + map.removeLayer(layerIds.markerConnectinOutlines); + map.removeLayer(layerIds.markerConnectingLines); + map.removeLayer(layerIds.markers); + + map.removeSource(sourceIds.markerConnectingLines); + map.removeSource(sourceIds.markers); + }; + } + }, [map]); + + useEffect(() => { + if (map) { + // Update the visibility layout property of the three layers whenever the hideLayers value changes. + if (hideLayers) { + map.setLayoutProperty(layerIdsRef.current.markerConnectinOutlines, 'visibility', 'none'); + map.setLayoutProperty(layerIdsRef.current.markerConnectingLines, 'visibility', 'none'); + map.setLayoutProperty(layerIdsRef.current.markers, 'visibility', 'none'); + } else { + map.setLayoutProperty(layerIdsRef.current.markerConnectinOutlines, 'visibility', 'visible'); + map.setLayoutProperty(layerIdsRef.current.markerConnectingLines, 'visibility', 'visible'); + map.setLayoutProperty(layerIdsRef.current.markers, 'visibility', 'visible'); + } + } + }, [hideLayers, map]); + + // Returned methods to set the location markers and focus them. + const setLocationMarkers = useCallback((markers) => setMarkers(markers), []); + const focusLocationMarker = useCallback((id) => setFocusedMarkerId(id), []); + const blurLocationMarker = useCallback(() => setFocusedMarkerId(null), []); + + return { blurLocationMarker, focusLocationMarker, setLocationMarkers }; +}; + +export default useMapLocationMarkers; diff --git a/src/ReportManager/DetailsSection/SchemaForm/utils/useMapLocationMarkers/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/utils/useMapLocationMarkers/index.test.js new file mode 100644 index 000000000..ecdd837b3 --- /dev/null +++ b/src/ReportManager/DetailsSection/SchemaForm/utils/useMapLocationMarkers/index.test.js @@ -0,0 +1,511 @@ +import React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { waitFor } from '@testing-library/dom'; + +import { addMapImage } from '../../../../../utils/map'; +import { createMapMock } from '../../../../../__test-helpers/mocks'; +import { MapContext } from '../../../../../App'; + +import useMapLocationMarkers from '.'; + +jest.mock('../../../../../utils/map', () => ({ + ...jest.requireActual('../../../../../utils/map'), + addMapImage: jest.fn(), +})); + +describe('ReportManager - DetailsSection - SchemaForm - Utils - useMapLocationMarkers', () => { + let map; + beforeEach(() => { + map = createMapMock(); + }); + + const Wrapper = ({ children }) => ( + {children} + ); + + it('adds the markers and marker connecting lines sources to the map', () => { + map.getSource.mockImplementation(() => undefined); + + expect(map.addSource).not.toHaveBeenCalled(); + + renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + expect(map.addSource).toHaveBeenCalledTimes(2); + expect(map.addSource).toHaveBeenCalledWith( + 'event-location-markers-source-event-id', + { data: { features: [], type: 'FeatureCollection' }, type: 'geojson' } + ); + expect(map.addSource).toHaveBeenCalledWith( + 'event-location-markers-source-lines-event-id', + { data: { features: [], type: 'FeatureCollection' }, type: 'geojson' } + ); + }); + + it('updates the the markers source data when the markers change', async () => { + const source = { setData: jest.fn() }; + map.getSource.mockImplementation((sourceId) => + sourceId === 'event-location-markers-source-event-id' ? source : undefined + ); + + expect(source.setData).not.toHaveBeenCalled(); + + const { result } = renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + const { setLocationMarkers } = result.current; + + expect(source.setData).toHaveBeenCalledTimes(1); + expect(source.setData).toHaveBeenCalledWith({ + features: [], + type: 'FeatureCollection', + }); + + setLocationMarkers({ + 'location-1': { + latitude: 15, + longitude: 15, + }, + 'location-2': { + latitude: 20, + longitude: 20, + }, + }); + + await waitFor(() => { + expect(source.setData).toHaveBeenCalledTimes(2); + expect(source.setData).toHaveBeenCalledWith({ + features: [ + { + geometry: { coordinates: [15, 15], type: 'Point' }, + properties: { id: 'location-1' }, + type: 'Feature', + }, + { + geometry: { coordinates: [20, 20], type: 'Point' }, + properties: { id: 'location-2' }, + type: 'Feature', + }, + ], + type: 'FeatureCollection', + }); + }); + }); + + it('adds the marker connecting lines sources to the map when the event location is not defined', () => { + map.getSource.mockImplementation(() => undefined); + + expect(map.addSource).not.toHaveBeenCalled(); + + renderHook(() => useMapLocationMarkers('event-id'), { wrapper: Wrapper }); + + expect(map.addSource).toHaveBeenCalledTimes(2); + expect(map.addSource).toHaveBeenCalledWith( + 'event-location-markers-source-lines-event-id', + { data: { features: [], type: 'FeatureCollection' }, type: 'geojson' } + ); + }); + + it('updates the the marker connecting lines source data when the markers change', async () => { + const source = { setData: jest.fn() }; + map.getSource.mockImplementation((sourceId) => + sourceId === 'event-location-markers-source-lines-event-id' + ? source + : undefined + ); + + expect(source.setData).not.toHaveBeenCalled(); + + const { result } = renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + const { setLocationMarkers } = result.current; + + expect(source.setData).toHaveBeenCalledTimes(1); + expect(source.setData).toHaveBeenCalledWith({ + features: [], + type: 'FeatureCollection', + }); + + setLocationMarkers({ + 'location-1': { + latitude: 15, + longitude: 15, + }, + 'location-2': { + latitude: 20, + longitude: 20, + }, + }); + + await waitFor(() => { + expect(source.setData).toHaveBeenCalledTimes(2); + expect(source.setData).toHaveBeenCalledWith({ + features: [ + { + geometry: { + coordinates: [ + [15, 15], + [10, 10], + ], + type: 'LineString', + }, + properties: {}, + type: 'Feature', + }, + { + geometry: { + coordinates: [ + [20, 20], + [10, 10], + ], + type: 'LineString', + }, + properties: {}, + type: 'Feature', + }, + ], + type: 'FeatureCollection', + }); + }); + }); + + it('adds the markers, marker connecting lines and marker connecting outlines layers to the map', () => { + map.getLayer.mockImplementation(() => undefined); + + expect(map.addLayer).not.toHaveBeenCalled(); + + renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + expect(map.addLayer).toHaveBeenCalledTimes(3); + expect(map.addLayer).toHaveBeenCalledWith({ + id: 'event-location-markers-layer-event-id', + layout: { + 'icon-allow-overlap': true, + 'icon-image': [ + 'case', + ['==', ['get', 'id'], null], + 'location-dot-blue', + 'location-dot-gray', + ], + 'icon-offset': [0, -29], + 'icon-size': 0.5, + }, + source: 'event-location-markers-source-event-id', + type: 'symbol', + }); + expect(map.addLayer).toHaveBeenCalledWith( + { + id: 'event-location-markers-layer-lines-event-id', + paint: { 'line-color': 'black', 'line-width': 1 }, + source: 'event-location-markers-source-lines-event-id', + type: 'line', + }, + 'event-location-markers-layer-event-id' + ); + expect(map.addLayer).toHaveBeenCalledWith( + { + id: 'event-location-markers-layer-outlines-event-id', + paint: { 'line-color': 'white', 'line-width': 5 }, + source: 'event-location-markers-source-lines-event-id', + type: 'line', + }, + 'event-location-markers-layer-lines-event-id' + ); + }); + + it('updates the markers layer icon-image layout property when the focused marker changes', async () => { + const layer = {}; + map.getLayer.mockImplementation((layerId) => + layerId === 'event-location-markers-layer-event-id' ? layer : undefined + ); + + expect(map.setLayoutProperty).not.toHaveBeenCalled(); + + const { result } = renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + const { focusLocationMarker, setLocationMarkers } = result.current; + + setLocationMarkers({ + 'location-1': { + latitude: 15, + longitude: 15, + }, + 'location-2': { + latitude: 20, + longitude: 20, + }, + }); + + expect(map.setLayoutProperty).toHaveBeenCalledTimes(4); + expect(map.setLayoutProperty).toHaveBeenCalledWith( + 'event-location-markers-layer-event-id', + 'icon-image', + [ + 'case', + ['==', ['get', 'id'], null], + 'location-dot-blue', + 'location-dot-gray', + ] + ); + + focusLocationMarker('location-1'); + + await waitFor(() => { + expect(map.setLayoutProperty).toHaveBeenCalledTimes(5); + expect(map.setLayoutProperty).toHaveBeenCalledWith( + 'event-location-markers-layer-event-id', + 'icon-image', + [ + 'case', + ['==', ['get', 'id'], 'location-1'], + 'location-dot-blue', + 'location-dot-gray', + ] + ); + }); + }); + + it('adds click and mouse listeners to the markers layer if the onMarkerClick callback is defined', () => { + const canvas = { + style: { + cursor: '', + }, + }; + map.getCanvas.mockImplementation(() => canvas); + + const onMarkerClick = jest.fn(); + + renderHook( + () => + useMapLocationMarkers( + 'event-id', + { latitude: 10, longitude: 10 }, + onMarkerClick + ), + { wrapper: Wrapper } + ); + + expect(map.getCanvas().style.cursor).toBe(''); + + map.__test__.fireHandlers('mouseenter'); + + expect(map.getCanvas().style.cursor).toBe('pointer'); + expect(onMarkerClick).not.toHaveBeenCalled(); + + map.__test__.fireHandlers('click', { + features: [ + { + properties: { + id: 'location-1', + }, + }, + ], + }); + + expect(onMarkerClick).toHaveBeenCalledTimes(1); + expect(onMarkerClick).toHaveBeenCalledWith('location-1'); + + map.__test__.fireHandlers('mouseleave'); + + expect(map.getCanvas().style.cursor).toBe(''); + }); + + it('does not add click and mouse listeners to the markers layer if the onMarkerClick callback is not defined', () => { + const canvas = { + style: { + cursor: '', + }, + }; + map.getCanvas.mockImplementation(() => canvas); + + renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + expect(map.getCanvas().style.cursor).toBe(''); + + map.__test__.fireHandlers('mouseenter'); + + expect(map.getCanvas().style.cursor).toBe(''); + }); + + it('adds the location dot images to the map if they are not loaded yet', () => { + expect(addMapImage).not.toHaveBeenCalled(); + + renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + expect(addMapImage).toHaveBeenCalledTimes(2); + expect(addMapImage).toHaveBeenCalledWith({ + id: 'location-dot-blue', + src: 'location-dot-blue.png', + }); + expect(addMapImage).toHaveBeenCalledWith({ + id: 'location-dot-gray', + src: 'location-dot-gray.png', + }); + }); + + it('doest not add the location dot images to the map if they are loaded already', () => { + map.hasImage.mockImplementation(() => true); + + renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + expect(addMapImage).not.toHaveBeenCalled(); + }); + + it('removes all layers and sources when the hook unmounts', () => { + const { unmount } = renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + expect(map.removeLayer).not.toHaveBeenCalled(); + expect(map.removeSource).not.toHaveBeenCalled(); + + unmount(); + + expect(map.removeLayer).toHaveBeenCalledTimes(3); + expect(map.removeLayer).toHaveBeenCalledWith( + 'event-location-markers-layer-outlines-event-id' + ); + expect(map.removeLayer).toHaveBeenCalledWith( + 'event-location-markers-layer-lines-event-id' + ); + expect(map.removeLayer).toHaveBeenCalledWith( + 'event-location-markers-layer-event-id' + ); + expect(map.removeSource).toHaveBeenCalledTimes(2); + expect(map.removeSource).toHaveBeenCalledWith( + 'event-location-markers-source-lines-event-id' + ); + expect(map.removeSource).toHaveBeenCalledWith( + 'event-location-markers-source-event-id' + ); + }); + + it('hides the layers', () => { + expect(map.setLayoutProperty).not.toHaveBeenCalled(); + + renderHook( + () => + useMapLocationMarkers( + 'event-id', + { latitude: 10, longitude: 10 }, + null, + true + ), + { wrapper: Wrapper } + ); + + expect(map.setLayoutProperty).toHaveBeenCalledTimes(3); + expect(map.setLayoutProperty).toHaveBeenCalledWith( + 'event-location-markers-layer-outlines-event-id', + 'visibility', + 'none' + ); + expect(map.setLayoutProperty).toHaveBeenCalledWith( + 'event-location-markers-layer-lines-event-id', + 'visibility', + 'none' + ); + expect(map.setLayoutProperty).toHaveBeenCalledWith( + 'event-location-markers-layer-event-id', + 'visibility', + 'none' + ); + }); + + it('shows the layers', () => { + expect(map.setLayoutProperty).not.toHaveBeenCalled(); + + renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + expect(map.setLayoutProperty).toHaveBeenCalledTimes(3); + expect(map.setLayoutProperty).toHaveBeenCalledWith( + 'event-location-markers-layer-outlines-event-id', + 'visibility', + 'visible' + ); + expect(map.setLayoutProperty).toHaveBeenCalledWith( + 'event-location-markers-layer-lines-event-id', + 'visibility', + 'visible' + ); + expect(map.setLayoutProperty).toHaveBeenCalledWith( + 'event-location-markers-layer-event-id', + 'visibility', + 'visible' + ); + }); + + it('updates the markers layer icon-image layout property when the markers are blurred', async () => { + const layer = {}; + map.getLayer.mockImplementation((layerId) => + layerId === 'event-location-markers-layer-event-id' ? layer : undefined + ); + + const { result } = renderHook( + () => useMapLocationMarkers('event-id', { latitude: 10, longitude: 10 }), + { wrapper: Wrapper } + ); + + const { blurLocationMarker, focusLocationMarker, setLocationMarkers } = + result.current; + + setLocationMarkers({ + 'location-1': { + latitude: 15, + longitude: 15, + }, + 'location-2': { + latitude: 20, + longitude: 20, + }, + }); + + focusLocationMarker('location-1'); + + await waitFor(() => { + expect(map.setLayoutProperty).toHaveBeenCalledTimes(5); + }); + + blurLocationMarker(); + + await waitFor(() => { + expect(map.setLayoutProperty).toHaveBeenCalledTimes(6); + expect(map.setLayoutProperty.mock.calls[5][0]).toBe( + 'event-location-markers-layer-event-id' + ); + expect(map.setLayoutProperty.mock.calls[5][1]).toBe('icon-image'); + expect(map.setLayoutProperty.mock.calls[5][2]).toEqual([ + 'case', + ['==', ['get', 'id'], null], + 'location-dot-blue', + 'location-dot-gray', + ]); + }); + }); +}); diff --git a/src/ReportManager/DetailsSection/index.js b/src/ReportManager/DetailsSection/index.js index 3971fd095..8330ccc46 100644 --- a/src/ReportManager/DetailsSection/index.js +++ b/src/ReportManager/DetailsSection/index.js @@ -46,8 +46,10 @@ const LOADER_COLOR = '#006cd9'; // Bright blue const LOADER_SIZE = 4; const DetailsSection = ({ + eventId, eventSchema = null, formValidator, + isBehindAddedEvent, isCollection, isNewEvent, loadingSchema, @@ -291,7 +293,9 @@ const DetailsSection = ({ {(eventType?.version === 2 || efbFormSchemaSupportEnabled) && eventSchemaOverride && { { return {shouldRenderReportDetailView ? { }), getCenter: jest.fn(() => ({ lng: 0, lat: 0 })), getContainer: jest.fn(() => document.createElement('div')), + getCanvas: jest.fn(), getCanvasContainer: jest.fn(() => document.createElement('div')), setFilter: jest.fn(), removeSource: jest.fn(), diff --git a/src/hooks/useMapSources/index.js b/src/hooks/useMapSources/index.js index dc772cbdd..122855202 100644 --- a/src/hooks/useMapSources/index.js +++ b/src/hooks/useMapSources/index.js @@ -2,51 +2,54 @@ import { useContext, useEffect, useRef } from 'react'; import { MapContext } from '../../App'; -const DEFAULT_CONFIGURATION = { type: 'geojson' }; -const useMapSources = (sourceConfigurations = [], defaultConfiguration = DEFAULT_CONFIGURATION) => { +const useMapSources = (sourceConfigsBatch = [], defaultConfig = { type: 'geojson' }) => { const map = useContext(MapContext); - - const idsOfSourcesAddedToMapRef = useRef([]); + const sourceIdsRef = useRef([]); useEffect(() => { if (map) { - sourceConfigurations.forEach((sourceConfiguration) => { - const source = map.getSource(sourceConfiguration.id); - if (source) { - // If the source is already in the map, update its data. - source.setData(sourceConfiguration.data); - } else { - // If the source is not in the map yet, add it. - map.addSource(sourceConfiguration.id, { - ...defaultConfiguration, - ...sourceConfiguration.options, - data: sourceConfiguration.data, + sourceConfigsBatch.forEach(sourceConfig => { + if (sourceConfig?.id && !map.getSource(sourceConfig.id)){ + const { id, data = {}, options = {} } = sourceConfig; + const fullSourceConfig = { ...defaultConfig, ...options }; + map.addSource(id, { + ...fullSourceConfig, + data, }); - - idsOfSourcesAddedToMapRef.current = [...idsOfSourcesAddedToMapRef.current, sourceConfiguration.id]; + sourceIdsRef.current.push(id); } }); } - }, [defaultConfiguration, map, sourceConfigurations]); + }, [map, sourceConfigsBatch, defaultConfig]); useEffect(() => { - if (map) { - const idsOfSourcesAddedToMap = idsOfSourcesAddedToMapRef.current; + sourceConfigsBatch.forEach(sourceConfig => { + const source = map?.getSource?.(sourceConfig?.id); + if (sourceConfig?.id && sourceConfig?.data && source){ + source.setData?.(sourceConfig.data); + } + }); + }, [map, sourceConfigsBatch]); - // Remove the sources from the map on unmount. - return () => setTimeout(() => idsOfSourcesAddedToMap.forEach((sourceId) => { - if (map.getSource(sourceId)) { - map.removeSource(sourceId); - } - })); - } + useEffect(() => { + const refs = sourceIdsRef?.current; + return () => { + if (map) { + setTimeout(() => { + refs.forEach(id => { + if (map?.getSource(id)) { + map.removeSource(id); + } + }); + }); + } + }; }, [map]); - // Return the sources that are already defined in the map. - return sourceConfigurations - .map((sourceConfiguration) => map?.getSource(sourceConfiguration.id)) - .filter((source) => !!source); + return sourceConfigsBatch + .map((sourceConfig) => sourceConfig.id ? map?.getSource(sourceConfig.id) : null) + .filter(sourceConfig => !!sourceConfig); }; export default useMapSources;