From 336c108708da24f96cc8405c1e94f279461dbb78 Mon Sep 17 00:00:00 2001 From: Ludwig Date: Wed, 19 Mar 2025 16:05:39 -0600 Subject: [PATCH 1/4] ERA-1237: Add location markers to the map when viewing a form with location fields --- src/CursorGpsDisplay/MenuPopover/index.js | 8 +- src/LocationPicker/index.js | 1 + src/LocationPicker/styles.module.scss | 2 +- .../SortableList/Item/FormModal/index.js | 3 + .../Collection/SortableList/Item/index.js | 10 +- .../SortableList/Item/styles.module.scss | 2 +- .../fields/Collection/SortableList/index.js | 2 + .../SchemaForm/fields/Collection/index.js | 7 + .../SchemaForm/fields/Location/index.js | 11 +- .../SchemaForm/fields/Section/index.js | 17 ++- .../DetailsSection/SchemaForm/index.js | 66 ++++++++- .../utils/useLocationMarkersLayer/index.js | 125 ++++++++++++++++++ src/ReportManager/DetailsSection/index.js | 1 + src/common/images/icons/location-dot-blue.png | Bin 0 -> 721 bytes src/common/images/icons/location-dot-gray.png | Bin 0 -> 669 bytes src/constants/index.js | 50 +++---- 16 files changed, 269 insertions(+), 36 deletions(-) create mode 100644 src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.js create mode 100644 src/common/images/icons/location-dot-blue.png create mode 100644 src/common/images/icons/location-dot-gray.png diff --git a/src/CursorGpsDisplay/MenuPopover/index.js b/src/CursorGpsDisplay/MenuPopover/index.js index 5195e8da2..47265525b 100644 --- a/src/CursorGpsDisplay/MenuPopover/index.js +++ b/src/CursorGpsDisplay/MenuPopover/index.js @@ -59,6 +59,12 @@ const MenuPopover = ({ buttonRef, className, onClose, ...otherProps }, ref) => { } }; + const onGpsInputButtonClick = (event) => { + event.stopPropagation(); + + onJumpToCoordinates(); + }; + useEffect(() => { // Select the GPS input on mount so user can type away or navigate. gpsInputRef.current.select(); @@ -115,7 +121,7 @@ const MenuPopover = ({ buttonRef, className, onClose, ...otherProps }, ref) => { aria-label={t('gpsInputButtonLabel')} className={styles.gpsInputButton} disabled={!gpsInputValue} - onClick={() => onJumpToCoordinates()} + onClick={onGpsInputButtonClick} ref={gpsInputButtonRef} title={t('gpsInputButtonLabel')} type="button" diff --git a/src/LocationPicker/index.js b/src/LocationPicker/index.js index caf099766..7693c06c8 100644 --- a/src/LocationPicker/index.js +++ b/src/LocationPicker/index.js @@ -71,6 +71,7 @@ const LocationPicker = ({ className={`${styles.input} ${readOnly ? styles.readOnly : ''}`} disabled={disabled} id={id} + onFocus={() => setLocationButtonRef.current.focus()} placeholder={placeholder || t('defaultPlaceholder')} readOnly required={required} diff --git a/src/LocationPicker/styles.module.scss b/src/LocationPicker/styles.module.scss index d424d4bf4..b9c0a2744 100644 --- a/src/LocationPicker/styles.module.scss +++ b/src/LocationPicker/styles.module.scss @@ -50,7 +50,7 @@ background-color: colors.$disabled-field-gray; } - &:focus-visible:not(:disabled) { + &:focus:not(:disabled) { border: 2px solid colors.$bright-blue; } diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormModal/index.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormModal/index.js index af21e6c8d..ca216f7f5 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormModal/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormModal/index.js @@ -12,6 +12,7 @@ const FormModal = ({ breadcrumbs, columns, errors, + focusLocationMarker, formData, isOpen, itemName, @@ -74,6 +75,7 @@ const FormModal = ({ formData[fieldId], onFieldChange, errors?.[fieldId], + focusLocationMarker, [...breadcrumbs, { display: title, id: fieldId }] ))} @@ -87,6 +89,7 @@ const FormModal = ({ formData[fieldId], onFieldChange, errors?.[fieldId], + focusLocationMarker, [...breadcrumbs, { display: title, id: fieldId }] ))} } diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js index 44a9b4356..d19e74db7 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js @@ -21,6 +21,7 @@ const Item = ({ collectionDetails, errors, fields, + focusLocationMarker, formData, id, isDragging = false, @@ -118,7 +119,13 @@ const Item = ({ + (isDragging ? ` ${styles.isDragging}` : '') + (isDragOverlay ? ` ${styles.dragOverlay}` : '') + (hasError ? ` ${styles.error}` : ''); - return
  • + return
  • (markerId) => + focusLocationMarker(`${id}.${itemIndex}.${markerId}`); + return
    !!value[index]) diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.js index bd25e1f34..39448a752 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.js @@ -1,13 +1,14 @@ -import React, { memo } from 'react'; +import React, { memo, useEffect } from 'react'; import LocationPicker from '../../../../../LocationPicker'; import styles from './styles.module.scss'; const Location = ({ - autofillDefaultInput: _autofillDefaultInput, + blurLocationMarker, details, error, + focusLocationMarker, id, onFieldChange, value = null, @@ -16,6 +17,10 @@ const Location = ({ const hasDescription = !!details.description && !hasError; const label = details.isRequired ? `${details.label} *` : details.label; + // When closing a collection item form modal, the location fields get unmounted without triggering the blur event, so + // we need to blur the location markers manually. + useEffect(() => () => blurLocationMarker(), [blurLocationMarker]); + return
    @@ -27,7 +32,9 @@ const Location = ({ 'aria-invalid': hasError, 'aria-required': details.isRequired, }} + onBlur={() => blurLocationMarker()} onChange={(newLocation) => onFieldChange(id, newLocation || undefined)} + onFocus={() => focusLocationMarker(id)} value={value} /> diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Section/index.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Section/index.js index 0f5ff762f..fc0a3366e 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Section/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Section/index.js @@ -4,7 +4,16 @@ import styles from './styles.module.scss'; // Sections are just visual elements that are not present in the form data structure. Thus, fields contained by // sections are in the root objects of form data and field errors. -const Section = ({ details, fieldErrors, formData, id, onFieldChange, onFieldErrorsChange, renderField }) => { +const Section = ({ + details, + fieldErrors, + focusLocationMarker, + formData, + id, + onFieldChange, + onFieldErrorsChange, + renderField, +}) => { const onColumnFieldChange = (fieldId, value, error) => { onFieldChange(fieldId, value); onFieldErrorsChange({ ...fieldErrors, [fieldId]: error }); @@ -25,7 +34,8 @@ const Section = ({ details, fieldErrors, formData, id, onFieldChange, onFieldErr fieldId, formData[fieldId], onColumnFieldChange, - fieldErrors[fieldId] + fieldErrors[fieldId], + focusLocationMarker ))}
    @@ -37,7 +47,8 @@ const Section = ({ details, fieldErrors, formData, id, onFieldChange, onFieldErr fieldId, formData[fieldId], onColumnFieldChange, - fieldErrors[fieldId] + fieldErrors[fieldId], + focusLocationMarker ))}
    }
    diff --git a/src/ReportManager/DetailsSection/SchemaForm/index.js b/src/ReportManager/DetailsSection/SchemaForm/index.js index 5bd674c88..9aec00d45 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/index.js @@ -2,6 +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 useSchemaValidations from './utils/useSchemaValidations'; import Collection from './fields/Collection'; @@ -16,13 +17,13 @@ import Text from './fields/Text'; export const FIELDS = { [FORM_ELEMENT_TYPES.CHOICE_LIST]: ChoiceList, [FORM_ELEMENT_TYPES.DATE_TIME]: DateTime, - [FORM_ELEMENT_TYPES.LOCATION]: Location, [FORM_ELEMENT_TYPES.NUMERIC]: Numeric, [FORM_ELEMENT_TYPES.TEXT]: Text, }; const SchemaForm = ({ autofillDefaultInputs, + eventLocation, initialFormData, onFormDataChange, onFormSubmit, @@ -31,6 +32,27 @@ const SchemaForm = ({ }) => { const runValidations = useSchemaValidations(schema); + const onLocationMarkerClick = useCallback((markerId) => { + const locationField = document.getElementById(markerId); + if (locationField) { + // If the location field that corresponds to the clicked marker is contained directly by a section, its element + // will be focusable. + locationField.focus(); + } else { + // If the location field is nested in a collection, we try to calculate the id of the collection item that + // contains it to focus it. + const markerIdPathParts = markerId.split('.'); + const collectionItemId = `${markerIdPathParts[0]}.${markerIdPathParts[1]}`; + document.getElementById(collectionItemId)?.focus(); + } + }, []); + + const { + blurLocationMarker, + focusLocationMarker, + updateLocationMarkers + } = useLocationMarkersLayer(eventLocation, onLocationMarkerClick); + // 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. const shouldSendFormDataChangeRef = useRef(false); @@ -65,8 +87,9 @@ const SchemaForm = ({ }; // This method is designed to render fields inside sections and collections. In order to support recursion we let the - // parents handle the propagation of values, change callbacks, errors, breadcrumbs (only for collections), etc... - const renderField = (id, value, onChange, error, breadcrumbs = []) => { + // parents handle the propagation of values, change callbacks, errors, focusing of location markers and breadcrumbs + // (only for collections). + const renderField = (id, value, onChange, error, focusLocationMarker, breadcrumbs = []) => { switch (fields[id].type) { case FORM_ELEMENT_TYPES.HEADER: return
    ; @@ -77,6 +100,7 @@ const SchemaForm = ({ details={fields[id].details} error={error} fields={fields} + focusLocationMarker={focusLocationMarker} id={id} key={id} onFieldChange={onChange} @@ -84,6 +108,18 @@ const SchemaForm = ({ value={value} />; + case FORM_ELEMENT_TYPES.LOCATION: + return ; + default: const Field = FIELDS[fields[id].type]; return { + // Update the location markers whenever there is a change. + const locationMarkers = {}; + const addLocationMarkersFromFormDataRecursively = (formData, idPrefix = '') => { + // Iterate the fields. + Object.entries(formData).forEach(([fieldId, fieldValue]) => { + if (fields[fieldId]?.type === FORM_ELEMENT_TYPES.LOCATION && fieldValue) { + // If the field is a location with a value, add it to the location markers. + locationMarkers[`${idPrefix}${fieldId}`] = fieldValue; + } else if (fields[fieldId]?.type === FORM_ELEMENT_TYPES.COLLECTION) { + // If the field is a collection, add the location markers for each collection item. + fieldValue.forEach((itemFormData, index) => addLocationMarkersFromFormDataRecursively( + itemFormData, + `${idPrefix}${fieldId}.${index}.` + )); + } + }); + }; + addLocationMarkersFromFormDataRecursively(formData); + + updateLocationMarkers(locationMarkers); + }, [fields, formData, updateLocationMarkers]); + return
    {fields[ROOT_CANVAS_ID]?.details.fields.map((sectionId) =>
    { + 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. + 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]); + + // 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/index.js b/src/ReportManager/DetailsSection/index.js index 61d85b0e1..3971fd095 100644 --- a/src/ReportManager/DetailsSection/index.js +++ b/src/ReportManager/DetailsSection/index.js @@ -291,6 +291,7 @@ const DetailsSection = ({ {(eventType?.version === 2 || efbFormSchemaSupportEnabled) && eventSchemaOverride && K~#7Fos~~$ z6G0TlUrJGkP+}oavCshzLPZu)Hx?(w@u!|ylPT2;SCr7d0J*H6tQ1uL0gS|4)=rs3-g0Lhx$4kBO4qzMd zvMPL(Uy=E&S&H~CLRTbyyZOv0kFzJLn1hl@XPYZPG0aRo2tmuKDhH59>+ zKn~kcIb0=}m-Qt6PBv{p_rtNeI$%Kd6>0q2vS9*Yr*|*lS0aa-nU9aw#xgbS5YaW8Yx`Gt-b$##@O=d z!580n`5m9T7M)f2PL@CX3V;BIcl}vmBK9FRPwRgIkI^4O94sF$00000NkvXXu0mjf DyM;vK literal 0 HcmV?d00001 diff --git a/src/common/images/icons/location-dot-gray.png b/src/common/images/icons/location-dot-gray.png new file mode 100644 index 0000000000000000000000000000000000000000..58f20a5f11066144c5935f65aacc7fbd7853b0ab GIT binary patch literal 669 zcmV;O0%HA%P)=62*|%?IcGiwq5XW%=4;R*eeTDJ+2z!9d9LL$&);SiUUwHVd)v6Fh zksJ;OIiJsx&*#PSJgHPFQY;o{@adYQj-locNWAfQEO<&5TCJ9}+ih-e1H#`nvd76} z;tq#Hxdf$BNk*d)RsJW;r9?hLx|oRA@AuN{_2hIqg;@VBAxmF^Ylsv!n~hATQ>Mq~ z_ZL`WKA$IvHc_wFrCO~f$)^(L()2@Wq}gmXAgW8UAB)A}6%j*Vq7_(0RFst~z`Vztx%#A1q!TGn;b1U$HJ(=9C0T*&Iv83OSjBR=Y@H+7kvm?4EPs+cnLC+b zn=t3o^-m;wU}AN!LbmD>tf-zAs$_8#Sy-#pUPz}itELljj6^gYEELw>vrm(D!Wjvn z=BMr8suZY(*P*r4cb$x<_!&o{kQJT{K@bR{x&sk!nt#2C58hRMPF@PC0?`hyp&HAn z7Z8R>Qn<4&lZiy4S~FkYO@{{D>FdTS(`-=6KHHdz+S9 zvWQSU9);PvWXu7G?riFqbcP7@Z5!E?5dqX4%{EI_^|Ss?nc!b)xm?CbbYf71q4-%{ z(N}{%8K_Cf*6TINZseoDVh=wIMf5121;c{5m-zk#EQB3#41Vs700000NkvXXu0mjf D`6n(g literal 0 HcmV?d00001 diff --git a/src/constants/index.js b/src/constants/index.js index bfbcdea83..4a408e706 100644 --- a/src/constants/index.js +++ b/src/constants/index.js @@ -78,48 +78,50 @@ export const BREAKPOINTS = { }; export const LAYER_IDS = { - EVENT_GEOMETRY_LAYER: 'event-geometry-layer', - TOPMOST_STYLE_LAYER: 'feature-separation-layer', - SKY_LAYER: 'sky', + ANALYZER_LINES_CRITICAL: 'analyzer-line-critical', + ANALYZER_LINES_WARNING: 'analyzer-line-warning', + ANALYZER_POLYS_CRITICAL: 'analyzer-polygon-critical', + ANALYZER_POLYS_WARNING: 'analyzer-polygon-warning', CLUSTER_BUFFER_POLYGON_LAYER_ID: 'cluster-buffer-polygon-layer', + CLUSTERED_STATIC_SENSORS_LAYER: 'clustered_static_sensors_layer', CLUSTERS_LAYER_ID: 'clusters-layer', - FEATURE_FILLS: 'feature-fills', - FEATURE_SYMBOLS: 'feature-symbols', - FEATURE_LINES: 'feature-lines', EVENT_CLUSTER_COUNT_SYMBOLS: 'event_cluster_count', + EVENT_GEOMETRY_LAYER: 'event-geometry-layer', + EVENT_LOCATION_MARKERS: 'event-location-markers-layer', EVENT_SYMBOLS: 'event_symbols', - SUBJECT_SYMBOLS: 'subject-symbol-layer', - STATIC_SENSOR: 'static_sensor', - CLUSTERED_STATIC_SENSORS_LAYER: 'clustered_static_sensors_layer', - UNCLUSTERED_STATIC_SENSORS_LAYER: 'unclustered_static_sensors_layer', - SECOND_STATIC_SENSOR_PREFIX: 'icons-layer-', - PATROL_SYMBOLS: 'patrol_symbols', - TRACKS_LINES: 'track-layer', - TRACK_TIMEPOINTS_SYMBOLS: 'track-layer-timepoints', + FEATURE_FILLS: 'feature-fills', + FEATURE_LINES: 'feature-lines', + FEATURE_SYMBOLS: 'feature-symbols', HEATMAP_LAYER: 'heatmap', - ANALYZER_POLYS_WARNING: 'analyzer-polygon-warning', - ANALYZER_POLYS_CRITICAL: 'analyzer-polygon-critical', - ANALYZER_LINES_WARNING: 'analyzer-line-warning', - ANALYZER_LINES_CRITICAL: 'analyzer-line-critical', ISOCHRONE_LAYER: 'isochrone', MOUSE_MARKER_LAYER: 'mouse-marker-layer', + PATROL_SYMBOLS: 'patrol_symbols', + SECOND_STATIC_SENSOR_PREFIX: 'icons-layer-', + SKY_LAYER: 'sky', + STATIC_SENSOR: 'static_sensor', + SUBJECT_SYMBOLS: 'subject-symbol-layer', + TOPMOST_STYLE_LAYER: 'feature-separation-layer', + TRACK_TIMEPOINTS_SYMBOLS: 'track-layer-timepoints', + TRACKS_LINES: 'track-layer', + UNCLUSTERED_STATIC_SENSORS_LAYER: 'unclustered_static_sensors_layer', }; export const SOURCE_IDS = { - ANALYZER_POLYS_WARNING_SOURCE: 'analyzer-polygon-warning-source', - ANALYZER_POLYS_CRITICAL_SOURCE: 'analyzer-polygon-critical-source', - ANALYZER_LINES_WARNING_SOURCE: 'analyzer-line-warning-source', ANALYZER_LINES_CRITICAL_SOURCE: 'analyzer-line-critical-source', - SUBJECT_SYMBOLS: 'subject-symbol-source', + ANALYZER_LINES_WARNING_SOURCE: 'analyzer-line-warning-source', + ANALYZER_POLYS_CRITICAL_SOURCE: 'analyzer-polygon-critical-source', + ANALYZER_POLYS_WARNING_SOURCE: 'analyzer-polygon-warning-source', CLUSTER_BUFFER_POLYGON_SOURCE_ID: 'cluster-buffer-polygon-source', CLUSTERS_SOURCE_ID: 'clusters-source', + CURRENT_USER_LOCATION_SOURCE: 'current-user-location-source', EVENT_GEOMETRY: 'event-geometry-source', - UNCLUSTERED_EVENTS_SOURCE: 'events-data-unclustered', + EVENT_LOCATION_MARKERS: 'event-location-markers-source', MAP_FEATURES_LINES_SOURCE: 'feature-line-source', MAP_FEATURES_POLYGONS_SOURCE: 'feature-polygon-source', MAP_FEATURES_SYMBOLS_SOURCE: 'feature-symbol-source', MOUSE_MARKER_SOURCE: 'mouse-marker-source', - CURRENT_USER_LOCATION_SOURCE: 'current-user-location-source', + SUBJECT_SYMBOLS: 'subject-symbol-source', + UNCLUSTERED_EVENTS_SOURCE: 'events-data-unclustered', }; export const DEFAULT_SHOW_NAMES_IN_MAP_CONFIG = { From b5f4c55032a13696f5f1ca7e2de8c88635e9b05f Mon Sep 17 00:00:00 2001 From: Ludwig Date: Thu, 20 Mar 2025 09:23:36 -0600 Subject: [PATCH 2/4] ERA-11237: Small fix for markers of location fields inside collection items --- .../fields/Collection/SortableList/Item/index.js | 5 ++++- .../SchemaForm/fields/Collection/SortableList/index.js | 1 + src/ReportManager/DetailsSection/SchemaForm/index.js | 10 ++++------ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js index d19e74db7..6cb6a001d 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js @@ -24,6 +24,7 @@ const Item = ({ focusLocationMarker, formData, id, + index, isDragging = false, isDragOverlay = false, isFormModalOpen = false, @@ -122,7 +123,9 @@ const Item = ({ return
  • diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/index.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/index.js index 263dcfec3..1c256ed0b 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/index.js @@ -148,6 +148,7 @@ const SortableList = ({ focusLocationMarker={focusLocationMarker(index)} formData={item.formData} id={item.id} + index={index} isFormModalOpen={item.isFormModalOpen} isFormPreviewOpen={item.isFormPreviewOpen} key={item.id} diff --git a/src/ReportManager/DetailsSection/SchemaForm/index.js b/src/ReportManager/DetailsSection/SchemaForm/index.js index 9aec00d45..fe8e88731 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/index.js @@ -35,12 +35,10 @@ const SchemaForm = ({ const onLocationMarkerClick = useCallback((markerId) => { const locationField = document.getElementById(markerId); if (locationField) { - // If the location field that corresponds to the clicked marker is contained directly by a section, its element - // will be focusable. locationField.focus(); } else { - // If the location field is nested in a collection, we try to calculate the id of the collection item that - // contains it to focus it. + // If the location field of the clicked marker is not defined, it will be contained by a collection item, so we + // try to calculate the collection item id to focus it. const markerIdPathParts = markerId.split('.'); const collectionItemId = `${markerIdPathParts[0]}.${markerIdPathParts[1]}`; document.getElementById(collectionItemId)?.focus(); @@ -146,13 +144,13 @@ const SchemaForm = ({ // Update the location markers whenever there is a change. const locationMarkers = {}; const addLocationMarkersFromFormDataRecursively = (formData, idPrefix = '') => { - // Iterate the fields. Object.entries(formData).forEach(([fieldId, fieldValue]) => { if (fields[fieldId]?.type === FORM_ELEMENT_TYPES.LOCATION && fieldValue) { // If the field is a location with a value, add it to the location markers. locationMarkers[`${idPrefix}${fieldId}`] = fieldValue; } else if (fields[fieldId]?.type === FORM_ELEMENT_TYPES.COLLECTION) { - // If the field is a collection, add the location markers for each collection item. + // If the field is a collection, add the location markers for each of its items recursively with a prefix to + // differentiate the same fields in different collection items. fieldValue.forEach((itemFormData, index) => addLocationMarkersFromFormDataRecursively( itemFormData, `${idPrefix}${fieldId}.${index}.` From dfea7142e124d7370eeea300544a73d7c23e0851 Mon Sep 17 00:00:00 2001 From: Ludwig Date: Thu, 20 Mar 2025 13:52:11 -0600 Subject: [PATCH 3/4] ERA-11237: Unit tests --- src/LocationPicker/index.test.js | 8 + .../SortableList/Item/FormModal/index.test.js | 4 + .../SortableList/Item/index.test.js | 10 + .../fields/Collection/index.test.js | 16 ++ .../SchemaForm/fields/Location/index.test.js | 54 ++++ .../SchemaForm/fields/Section/index.test.js | 3 + .../DetailsSection/SchemaForm/index.js | 1 - .../useLocationMarkersLayer/index.test.js | 232 ++++++++++++++++++ 8 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js diff --git a/src/LocationPicker/index.test.js b/src/LocationPicker/index.test.js index f86e20384..37a5fd318 100644 --- a/src/LocationPicker/index.test.js +++ b/src/LocationPicker/index.test.js @@ -163,6 +163,14 @@ describe('LocationPicker', () => { expect(screen.getByLabelText('Location')).toBeRequired(); }); + test('forwards the focusing of the input to the set location button', () => { + renderLocationPicker(); + + fireEvent.focus(screen.getByLabelText('Location')); + + expect(screen.getByLabelText('Open the location picker menu to set a value')).toHaveFocus(); + }); + test('shows a display value in the input', () => { renderLocationPicker({ value: { diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormModal/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormModal/index.test.js index d7432202d..04bf30155 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormModal/index.test.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormModal/index.test.js @@ -8,6 +8,7 @@ import { mockStore } from '../../../../../../../../__test-helpers/MockStore'; import FormModal from './'; describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - SortableList - Item - FormModal', () => { + const focusLocationMarker = jest.fn(); const onCancel = jest.fn(); const onDeleteItem = jest.fn(); const onDone = jest.fn(); @@ -31,6 +32,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So breadcrumbs={[{ id: '1', display: 'Item 1' }, { id: '2', display: 'Item 2' }]} columns={1} errors={{}} + focusLocationMarker={focusLocationMarker} formData={{ 'field-1': 'Value 1', 'field-2': 'Value 2' }} isOpen itemName="Item" @@ -115,6 +117,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So 'Value 1', onFieldChange, undefined, + focusLocationMarker, [{ id: '1', display: 'Item 1' }, { id: '2', display: 'Item 2' }, { id: 'field-1', display: 'Item 3' }] ); expect(renderField).toHaveBeenCalledWith( @@ -122,6 +125,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So 'Value 2', onFieldChange, undefined, + focusLocationMarker, [{ id: '1', display: 'Item 1' }, { id: '2', display: 'Item 2' }, { id: 'field-2', display: 'Item 3' }] ); }); diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.test.js index c321c8258..2c224ecd8 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.test.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.test.js @@ -10,6 +10,7 @@ import { mockStore } from '../../../../../../../__test-helpers/MockStore'; import Item from './'; describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - SortableList - Item', () => { + const focusLocationMarker = jest.fn(); const onChange = jest.fn(); const onDelete = jest.fn(); const renderField = jest.fn(); @@ -24,6 +25,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So itemName: 'Collection 1', leftColumn: ['field-1', 'field-2'], rightColumn: [], + value: 'collection-1', }; store = { @@ -58,8 +60,10 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So type: FORM_ELEMENT_TYPES.TEXT, }, }} + focusLocationMarker={focusLocationMarker} formData={{ 'field-1': 'Value 1', 'field-2': 'Value 2' }} id={1} + index={0} isDragging={false} isDragOverlay={false} isFormModalOpen={false} @@ -136,6 +140,12 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So expect(setIsFormPreviewOpen).toHaveBeenCalledTimes(1); }); + test('assigns an id to the item based on its position in the form data', () => { + renderItem(); + + expect(screen.getByTestId('schema-form-collection-item')).toHaveAttribute('id', 'collection-1.0'); + }); + test('opens the form preview when the user clicks the title', () => { renderItem(); diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/index.test.js index bae4d0d14..79631a5ef 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/index.test.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/index.test.js @@ -10,6 +10,7 @@ import { mockStore } from '../../../../../__test-helpers/MockStore'; import Collection from './'; describe('ReportManager - DetailsSection - SchemaForm - fields - Collection', () => { + const focusLocationMarker = jest.fn(); const onFieldChange = jest.fn(); const renderField = jest.fn(); @@ -61,6 +62,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection', () type: FORM_ELEMENT_TYPES.TEXT, }, }} + focusLocationMarker={focusLocationMarker} id="collection-1" onFieldChange={onFieldChange} renderField={renderField} @@ -155,6 +157,20 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection', () expect(screen.queryByTestId('schema-form-collection-list-empty-state')).toBeNull(); }); + test('focuses a location marker prefixed with the collection value and the item index', () => { + renderField.mockImplementation((_id, _value, _onChange, _error, focusLocationMarker) => { + focusLocationMarker('location-1'); + + return null; + }); + renderCollectionField({ value: [{}] }); + + userEvent.click(screen.getByLabelText('Edit Item 1')); + + expect(focusLocationMarker).toHaveBeenCalled(); + expect(focusLocationMarker).toHaveBeenCalledWith('collection-1.0.location-1'); + }); + test('opens and closes the form preview of an item', () => { renderCollectionField({ value: [{}] }); diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.test.js index b4d8547a9..72274d097 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.test.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Location/index.test.js @@ -8,7 +8,11 @@ import { mockStore } from '../../../../../__test-helpers/MockStore'; import Location from './'; +jest.mock('../../../../../hooks/useJumpToLocation', () => () => () => {}); + describe('ReportManager - DetailsSection - SchemaForm - fields - Location', () => { + const blurLocationMarker = jest.fn(); + const focusLocationMarker = jest.fn(); const onFieldChange = jest.fn(); let details, store; @@ -34,8 +38,10 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Location', () = const renderLocationField = (props, overrideStore) => render( { + renderLocationField({ + value: { + latitude: 10, + longitude: 10, + }, + }); + + expect(focusLocationMarker).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByLabelText('Jump to location')); + + expect(focusLocationMarker).toHaveBeenCalledTimes(1); + expect(focusLocationMarker).toHaveBeenCalledWith('location-1'); + }); + test('updates the form data when the user does changes to the input', async () => { details.defaultInput = ''; renderLocationField(); @@ -122,4 +144,36 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Location', () = expect(onFieldChange).toHaveBeenCalledTimes(2); expect(onFieldChange).toHaveBeenCalledWith('location-1', { latitude: 10, longitude: 10 }); }); + + test('blurs the location marker when the user blurs the location picker', async () => { + renderLocationField({ + value: { + latitude: 10, + longitude: 10, + }, + }); + + await userEvent.click(screen.getByLabelText('Jump to location')); + + expect(blurLocationMarker).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByText('Location 1 Description')); + + expect(blurLocationMarker).toHaveBeenCalledTimes(1); + }); + + test('blurs the location marker when component unmounts', async () => { + const { unmount } = renderLocationField({ + value: { + latitude: 10, + longitude: 10, + }, + }); + + expect(blurLocationMarker).not.toHaveBeenCalled(); + + unmount(); + + expect(blurLocationMarker).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Section/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Section/index.test.js index 56cea844e..ce8919555 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Section/index.test.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Section/index.test.js @@ -6,6 +6,7 @@ import { render, screen } from '../../../../../test-utils'; import Section from './'; describe('ReportManager - DetailsSection - SchemaForm - fields - Section', () => { + const focusLocationMarker = jest.fn(); const onFieldChange = jest.fn(); const onFieldErrorsChange = jest.fn(); const renderField = jest.fn(); @@ -23,6 +24,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Section', () => const renderSectionField = (props) => render(
    expect(renderField.mock.calls[0][0]).toBe('text-1'); expect(renderField.mock.calls[0][1]).toBe('Value 1'); expect(renderField.mock.calls[0][3]).toBe(undefined); + expect(renderField.mock.calls[0][4]).toBe(focusLocationMarker); }); test('applies changes in values and errors from the children', () => { diff --git a/src/ReportManager/DetailsSection/SchemaForm/index.js b/src/ReportManager/DetailsSection/SchemaForm/index.js index fe8e88731..cd0218afa 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/index.js @@ -77,7 +77,6 @@ const SchemaForm = ({ // collection). const idOfFirstErroneousField = Object.keys(fieldErrors)[0]; const elementWithError = document.getElementById(idOfFirstErroneousField); - elementWithError?.scrollIntoView?.(); elementWithError?.focus(); } else { onFormSubmit(); diff --git a/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js new file mode 100644 index 000000000..1f11c1ba2 --- /dev/null +++ b/src/ReportManager/DetailsSection/SchemaForm/utils/useLocationMarkersLayer/index.test.js @@ -0,0 +1,232 @@ +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('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', + }, + ]); + }); +}); From 09a98c945a0765b9369c671d26d8294ff8b3ab88 Mon Sep 17 00:00:00 2001 From: Ludwig Date: Fri, 21 Mar 2025 13:03:17 -0600 Subject: [PATCH 4/4] ERA-11237: Add jump to location button in collection summary and increase zoom level when jumping to location markers --- public/locales/en-US/reports.json | 3 +- public/locales/es/reports.json | 3 +- public/locales/fr/reports.json | 3 +- public/locales/ne-NP/reports.json | 5 +- public/locales/pt/reports.json | 3 +- public/locales/sw/reports.json | 3 +- src/LocationPicker/MenuPopover/index.js | 29 +++- src/LocationPicker/MenuPopover/index.test.js | 48 +++++- src/LocationPicker/index.js | 4 +- src/LocationPicker/index.test.js | 19 ++- .../DetailsSection/SchemaForm/constants.js | 2 + .../SortableList/Item/FormPreview/index.js | 51 ++++-- .../Item/FormPreview/index.test.js | 155 +++++++++++++++++- .../Item/FormPreview/styles.module.scss | 56 +++++-- .../Collection/SortableList/Item/index.js | 9 +- .../SortableList/Item/index.test.js | 9 + .../fields/Collection/SortableList/index.js | 2 + .../SchemaForm/fields/Collection/index.js | 2 + .../fields/Collection/index.test.js | 3 + .../SchemaForm/fields/Location/index.js | 2 + .../DetailsSection/SchemaForm/index.js | 1 + src/i18n.js | 12 +- 22 files changed, 377 insertions(+), 47 deletions(-) diff --git a/public/locales/en-US/reports.json b/public/locales/en-US/reports.json index 5c22a9a26..076fc90ef 100644 --- a/public/locales/en-US/reports.json +++ b/public/locales/en-US/reports.json @@ -173,7 +173,8 @@ "doneButton": "Done" }, "formPreview": { - "collectionHumanizedValue": "{{collectionLength}} items" + "collectionHumanizedValue": "{{collectionLength}} items", + "jumpToLocationButtonLabel": "Jump to {{field}} location" }, "chevronButtonLabel": { "closed": "Open the {{itemTitle}} form preview", diff --git a/public/locales/es/reports.json b/public/locales/es/reports.json index dc6e95303..a2d10e250 100644 --- a/public/locales/es/reports.json +++ b/public/locales/es/reports.json @@ -162,7 +162,8 @@ "doneButton": "Hecho" }, "formPreview": { - "collectionHumanizedValue": "{{collectionLength}} elementos" + "collectionHumanizedValue": "{{collectionLength}} elementos", + "jumpToLocationButtonLabel": "Ir a la ubicación de {{field}}" }, "chevronButtonLabel": { "closed": "Abrir la vista previa del formulario {{itemTitle}}", diff --git a/public/locales/fr/reports.json b/public/locales/fr/reports.json index ee4d4f3a6..d47a3c224 100644 --- a/public/locales/fr/reports.json +++ b/public/locales/fr/reports.json @@ -162,7 +162,8 @@ "doneButton": "Terminé" }, "formPreview": { - "collectionHumanizedValue": "{{collectionLength}} éléments" + "collectionHumanizedValue": "{{collectionLength}} éléments", + "jumpToLocationButtonLabel": "Aller à l'emplacement de {{field}}" }, "chevronButtonLabel": { "closed": "Ouvrir l'aperçu du formulaire {{itemTitle}}", diff --git a/public/locales/ne-NP/reports.json b/public/locales/ne-NP/reports.json index 466e23ec2..5300589b4 100644 --- a/public/locales/ne-NP/reports.json +++ b/public/locales/ne-NP/reports.json @@ -170,8 +170,9 @@ "doneButton": "सम्पन्न" }, "formPreview": { - "collectionHumanizedValue": "{{collectionLength}} वस्तुहरू" - }, + "collectionHumanizedValue": "{{collectionLength}} वस्तुहरू", + "jumpToLocationButtonLabel": "{{field}} स्थानमा जानुहोस्" + }, "chevronButtonLabel": { "closed": "{{itemTitle}} फारमको पूर्वावलोकन खोल्नुहोस्", "open": "{{itemTitle}} फारमको पूर्वावलोकन बन्द गर्नुहोस्" diff --git a/public/locales/pt/reports.json b/public/locales/pt/reports.json index 067c7a3cb..5a549223d 100644 --- a/public/locales/pt/reports.json +++ b/public/locales/pt/reports.json @@ -162,7 +162,8 @@ "doneButton": "Concluído" }, "formPreview": { - "collectionHumanizedValue": "{{collectionLength}} itens" + "collectionHumanizedValue": "{{collectionLength}} itens", + "jumpToLocationButtonLabel": "Ir para a localização de {{field}}" }, "chevronButtonLabel": { "closed": "Abrir a visualização do formulário {{itemTitle}}", diff --git a/public/locales/sw/reports.json b/public/locales/sw/reports.json index 9cbd32ebe..ecff28ff2 100644 --- a/public/locales/sw/reports.json +++ b/public/locales/sw/reports.json @@ -173,7 +173,8 @@ "doneButton": "Imekamilika" }, "formPreview": { - "collectionHumanizedValue": "{{collectionLength}} vitu" + "collectionHumanizedValue": "{{collectionLength}} vitu", + "jumpToLocationButtonLabel": "Ruka kwenye eneo la {{field}}" }, "chevronButtonLabel": { "closed": "Fungua muhtasari wa fomu ya {{itemTitle}}", diff --git a/src/LocationPicker/MenuPopover/index.js b/src/LocationPicker/MenuPopover/index.js index 29dbac309..d56297e8a 100644 --- a/src/LocationPicker/MenuPopover/index.js +++ b/src/LocationPicker/MenuPopover/index.js @@ -22,6 +22,7 @@ const eventReportTracker = trackEventFactory(EVENT_REPORT_CATEGORY); const MenuPopover = ({ className, id, + onBlur, onChange, onClose, setLocationButtonRef, @@ -104,14 +105,34 @@ const MenuPopover = ({ }, []); useEffect(() => { - const onMouseDown = (event) => !wrapperRef.current.contains(event.target) - && !setLocationButtonRef.current.contains(event.target) - && onClose(); + 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); + } + } + }; document.addEventListener('mousedown', onMouseDown); return () => document.removeEventListener('mousedown', onMouseDown); - }, [onClose, setLocationButtonRef]); + }, [onBlur, onClose, setLocationButtonRef, target]); return ({ })); describe('LocationPicker - MenuPopover', () => { + const onBlur = jest.fn(); const onChange = jest.fn(); const onClose = jest.fn(); const setLocationButtonRefFocus = jest.fn(); @@ -48,6 +49,7 @@ describe('LocationPicker - MenuPopover', () => { { style={{}} target={{ current: { + contains: () => false, offsetWidth: 100, }, }} @@ -166,7 +169,47 @@ describe('LocationPicker - MenuPopover', () => { expect(setLocationButtonRefFocus).toHaveBeenCalledTimes(1); }); - test('closes the menu if the user clicks outside', () => { + test('closes the menu if the user clicks outside and triggers the blur callback if the click was outside of the picker', () => { + render(<> +
    + + + + false, + focus: setLocationButtonRefFocus, + }, + }} + style={{}} + target={{ + current: { + contains: () => false, + offsetWidth: 100, + }, + }} + value={null} + /> + + + ); + + expect(onClose).not.toHaveBeenCalled(); + expect(onBlur).not.toHaveBeenCalled(); + + userEvent.click(screen.getByTestId('outside')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + + test('closes the menu if the user clicks outside but does not trigger the blur callback if the click was inside the picker', () => { render(<>
    @@ -175,6 +218,7 @@ describe('LocationPicker - MenuPopover', () => { { style={{}} target={{ current: { + contains: () => true, offsetWidth: 100, }, }} @@ -200,5 +245,6 @@ describe('LocationPicker - MenuPopover', () => { userEvent.click(screen.getByTestId('outside')); expect(onClose).toHaveBeenCalledTimes(1); + expect(onBlur).not.toHaveBeenCalled(); }); }); diff --git a/src/LocationPicker/index.js b/src/LocationPicker/index.js index 7693c06c8..52fcda2ec 100644 --- a/src/LocationPicker/index.js +++ b/src/LocationPicker/index.js @@ -18,6 +18,7 @@ const LocationPicker = ({ disabled = false, id, inputProps = {}, + jumpToLocationButtonZoom = undefined, name = '', onBlur = null, onChange, @@ -98,7 +99,7 @@ const LocationPicker = ({ aria-label={t('jumpToLocationButtonLabel')} className={styles.jumpToLocationButton} disabled={!value || disabled} - onClick={() => jumpToLocation([value.longitude, value.latitude])} + onClick={() => jumpToLocation([value.longitude, value.latitude], jumpToLocationButtonZoom)} title={t('jumpToLocationButtonLabel')} type="button" > @@ -117,6 +118,7 @@ const LocationPicker = ({ setIsMenuPopoverOpen(false)} setLocationButtonRef={setLocationButtonRef} target={innerRef} diff --git a/src/LocationPicker/index.test.js b/src/LocationPicker/index.test.js index 37a5fd318..5f9ed088e 100644 --- a/src/LocationPicker/index.test.js +++ b/src/LocationPicker/index.test.js @@ -229,7 +229,24 @@ describe('LocationPicker', () => { userEvent.click(screen.getByLabelText('Jump to location')); expect(jumpToLocationMock).toHaveBeenCalledTimes(1); - expect(jumpToLocationMock).toHaveBeenCalledWith([10, 15]); + expect(jumpToLocationMock).toHaveBeenCalledWith([10, 15], undefined); + }); + + test('jumps to the location with a custom zoom', () => { + renderLocationPicker({ + jumpToLocationButtonZoom: 20, + value: { + latitude: 15, + longitude: 10, + }, + }); + + expect(jumpToLocationMock).not.toHaveBeenCalled(); + + userEvent.click(screen.getByLabelText('Jump to location')); + + expect(jumpToLocationMock).toHaveBeenCalledTimes(1); + expect(jumpToLocationMock).toHaveBeenCalledWith([10, 15], 20); }); test('opens the menu popover', () => { diff --git a/src/ReportManager/DetailsSection/SchemaForm/constants.js b/src/ReportManager/DetailsSection/SchemaForm/constants.js index 58885bf63..03eb6c1c6 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/constants.js +++ b/src/ReportManager/DetailsSection/SchemaForm/constants.js @@ -32,6 +32,8 @@ export const HEADER_ELEMENT_SIZES = { SMALL: 'SMALL', }; +export const JUMP_TO_LOCATION_BUTTON_ZOOM = 20; + export const ROOT_CANVAS_ID = 'root'; export const TEXT_ELEMENT_INPUT_TYPES = { diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/index.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/index.js index 0c75925c3..f02a0941d 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/index.js @@ -2,11 +2,24 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; +import { ReactComponent as MarkerFeedIcon } from '../../../../../../../../common/images/icons/marker-feed.svg'; + +import { FORM_ELEMENT_TYPES, JUMP_TO_LOCATION_BUTTON_ZOOM } from '../../../../../constants'; import { getHumanizedValue } from '../utils'; +import useJumpToLocation from '../../../../../../../../hooks/useJumpToLocation'; import styles from './styles.module.scss'; -const FormPreview = ({ errors, fieldIds, fields, formData, isDragOverlay }) => { +const FormPreview = ({ + blurLocationMarker, + errors, + fieldIds, + fields, + focusLocationMarker, + formData, + isDragOverlay, +}) => { + const jumpToLocation = useJumpToLocation(); const { t, i18n } = useTranslation('reports', { keyPrefix: 'reportManager.detailsSection.schemaForm.fields.collection.sortableList.item.formPreview', }); @@ -19,15 +32,33 @@ const FormPreview = ({ errors, fieldIds, fields, formData, isDragOverlay }) => { className={`${styles.formPreview} ${isDragOverlay ? styles.dragOverlay : ''} ${hasError ? styles.error : ''}`} data-testid="schema-form-collection-item-form-preview" > - {fieldIds.map((fieldId) =>
    -

    - {fields[fieldId].details.label} -

    - -

    - {getHumanizedValue(fields[fieldId], formData[fieldId], '-', i18n.language, gpsFormat, t)} -

    -
    )} + {fieldIds.map((fieldId) =>
  • +
    +

    + {fields[fieldId].details.label} +

    + +

    + {getHumanizedValue(fields[fieldId], formData[fieldId], '-', i18n.language, gpsFormat, t)} +

    +
    + + {fields[fieldId].type === FORM_ELEMENT_TYPES.LOCATION && formData[fieldId] && } +
  • )} ; }; diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/index.test.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/index.test.js index aab069520..19654183e 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/index.test.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/index.test.js @@ -1,16 +1,26 @@ import React from 'react'; import { Provider } from 'react-redux'; +import userEvent from '@testing-library/user-event'; -import { render, screen } from '../../../../../../../../test-utils'; +import { fireEvent, render, screen } from '../../../../../../../../test-utils'; import { FORM_ELEMENT_TYPES } from '../../../../../constants'; import { GPS_FORMATS } from '../../../../../../../../utils/location'; import { mockStore } from '../../../../../../../../__test-helpers/MockStore'; +import useJumpToLocation from '../../../../../../../../hooks/useJumpToLocation'; import FormPreview from './'; +jest.mock('../../../../../../../../hooks/useJumpToLocation', () => jest.fn()); + describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - SortableList - Item - FormPreview', () => { - let store; + const blurLocationMarker = jest.fn(); + const focusLocationMarker = jest.fn(); + + let jumpToLocationMock, store; beforeEach(() => { + jumpToLocationMock = jest.fn(); + useJumpToLocation.mockImplementation(() => jumpToLocationMock); + store = { view: { userPreferences: { @@ -23,6 +33,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So const renderFormPreview = (props, overrideStore) => render( { + renderFormPreview({ + fieldIds: ['field-1', 'field-2'], + fields: { + 'field-1': { + details: { + label: 'Field 1', + }, + type: FORM_ELEMENT_TYPES.TEXT, + }, + 'field-2': { + details: { + label: 'Field 2', + }, + type: FORM_ELEMENT_TYPES.LOCATION, + }, + }, + formData: { 'field-1': 'Value 1', 'field-2': { latitude: 10, longitude: 10 } }, + }); + + expect(screen.getByLabelText('Jump to Field 2 location')).toBeVisible(); + }); + + test('does not show a jump to location button if fields are not of type location', () => { + renderFormPreview(); + + expect(screen.queryByLabelText('Jump to Field 2 location')).toBeNull(); + }); + + test('does not show a jump to location button for location fields without values', () => { + renderFormPreview({ + fieldIds: ['field-1', 'field-2'], + fields: { + 'field-1': { + details: { + label: 'Field 1', + }, + type: FORM_ELEMENT_TYPES.TEXT, + }, + 'field-2': { + details: { + label: 'Field 2', + }, + type: FORM_ELEMENT_TYPES.LOCATION, + }, + }, + formData: { 'field-1': 'Value 1' }, + }); + + expect(screen.queryByLabelText('Jump to Field 2 location')).toBeNull(); + }); + + test('jumps to the location of a location field when clicking the button and focuses its marker', () => { + renderFormPreview({ + fieldIds: ['field-1', 'field-2'], + fields: { + 'field-1': { + details: { + label: 'Field 1', + }, + type: FORM_ELEMENT_TYPES.TEXT, + }, + 'field-2': { + details: { + label: 'Field 2', + }, + type: FORM_ELEMENT_TYPES.LOCATION, + }, + }, + formData: { 'field-1': 'Value 1', 'field-2': { latitude: 10, longitude: 10 } }, + }); + + expect(jumpToLocationMock).not.toHaveBeenCalled(); + expect(focusLocationMarker).not.toHaveBeenCalled(); + + userEvent.click(screen.getByLabelText('Jump to Field 2 location')); + + expect(jumpToLocationMock).toHaveBeenCalledTimes(1); + expect(jumpToLocationMock).toHaveBeenCalledWith([10, 10], 20); + expect(focusLocationMarker).toHaveBeenCalledTimes(1); + expect(focusLocationMarker).toHaveBeenCalledWith('field-2'); + }); + + test('does neither jump to the location of a location field when clicking the button nor focuses its marker if its a drag overlay', () => { + renderFormPreview({ + fieldIds: ['field-1', 'field-2'], + fields: { + 'field-1': { + details: { + label: 'Field 1', + }, + type: FORM_ELEMENT_TYPES.TEXT, + }, + 'field-2': { + details: { + label: 'Field 2', + }, + type: FORM_ELEMENT_TYPES.LOCATION, + }, + }, + formData: { 'field-1': 'Value 1', 'field-2': { latitude: 10, longitude: 10 } }, + isDragOverlay: true, + }); + + userEvent.click(screen.getByLabelText('Jump to Field 2 location')); + + expect(jumpToLocationMock).not.toHaveBeenCalled(); + expect(focusLocationMarker).not.toHaveBeenCalled(); + }); + + test('blurs the location marker when the jump to location button is blurred', () => { + renderFormPreview({ + fieldIds: ['field-1', 'field-2'], + fields: { + 'field-1': { + details: { + label: 'Field 1', + }, + type: FORM_ELEMENT_TYPES.TEXT, + }, + 'field-2': { + details: { + label: 'Field 2', + }, + type: FORM_ELEMENT_TYPES.LOCATION, + }, + }, + formData: { 'field-1': 'Value 1', 'field-2': { latitude: 10, longitude: 10 } }, + }); + + userEvent.click(screen.getByLabelText('Jump to Field 2 location')); + + expect(blurLocationMarker).not.toHaveBeenCalled(); + + fireEvent.blur(screen.getByLabelText('Jump to Field 2 location')); + + expect(blurLocationMarker).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/styles.module.scss b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/styles.module.scss index 12ed59814..62d213b07 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/styles.module.scss +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/FormPreview/styles.module.scss @@ -17,22 +17,54 @@ border-color: colors.$bright-red; } - .summaryLabel { - font-size: 0.875rem; - color: colors.$secondary-medium-gray; - margin: 0; + .fieldSummary { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 0.5rem; - &.error { - color: colors.$bright-red; + .label { + font-size: 0.875rem; + color: colors.$secondary-medium-gray; + margin: 0; + + &.error { + color: colors.$bright-red; + } } - } + + .value { + color: black; + margin: 0; + + &.error { + color: colors.$bright-red; + } + } + + .jumpToLocationButton { + background: none; + border: none; + border-radius: 0.1875rem; + color: colors.$secondary-medium-gray; + outline: none; + + &:hover { + background: colors.$light-gray-background; + } + + &:focus-visible { + border: 2px solid colors.$bright-blue; + color: colors.$bright-blue; + } - .summaryValue { - color: black; - margin-bottom: 0.5rem; + &.dragOverlay { + cursor: grabbing; - &.error { - color: colors.$bright-red; + &:hover { + background: unset; + } + } } } } diff --git a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js index 6cb6a001d..bc02641e8 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/fields/Collection/SortableList/Item/index.js @@ -17,14 +17,15 @@ import FormPreview from './FormPreview'; import styles from './styles.module.scss'; const Item = ({ + blurLocationMarker = null, breadcrumbs = null, collectionDetails, errors, fields, - focusLocationMarker, + focusLocationMarker = null, formData, id, - index, + index = null, isDragging = false, isDragOverlay = false, isFormModalOpen = false, @@ -125,7 +126,7 @@ const Item = ({ data-testid="schema-form-collection-item" // We use the index and not the item id because the id is internal for having a constant default title, while the // index corresponds directly to the position of the item in the form data object. - id={`${collectionDetails.value}.${index}`} + id={index !== null ? `${collectionDetails.value}.${index}` : undefined} ref={ref} {...otherProps} > @@ -187,7 +188,9 @@ const Item = ({
    { + const blurLocationMarker = jest.fn(); const focusLocationMarker = jest.fn(); const onChange = jest.fn(); const onDelete = jest.fn(); @@ -43,6 +44,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So const renderItem = (props, overrideStore) => render( { + renderItem({ index: undefined }); + + expect(screen.getByTestId('schema-form-collection-item')).not.toHaveAttribute('id'); + }); + test('opens the form preview when the user clicks the title', () => { renderItem(); @@ -283,6 +291,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection - So rerender( { }; const SortableList = ({ + blurLocationMarker, breadcrumbs, collectionDetails, fields, @@ -141,6 +142,7 @@ const SortableList = ({ > item.id)} strategy={verticalListSortingStrategy}> {items.map((item, index) => : { + const blurLocationMarker = jest.fn(); const focusLocationMarker = jest.fn(); const onFieldChange = jest.fn(); const renderField = jest.fn(); @@ -45,6 +46,7 @@ describe('ReportManager - DetailsSection - SchemaForm - fields - Collection', () const renderCollectionField = (props, overrideStore) => render( blurLocationMarker()} onChange={(newLocation) => onFieldChange(id, newLocation || undefined)} onFocus={() => focusLocationMarker(id)} diff --git a/src/ReportManager/DetailsSection/SchemaForm/index.js b/src/ReportManager/DetailsSection/SchemaForm/index.js index cd0218afa..0a20951c5 100644 --- a/src/ReportManager/DetailsSection/SchemaForm/index.js +++ b/src/ReportManager/DetailsSection/SchemaForm/index.js @@ -93,6 +93,7 @@ const SchemaForm = ({ case FORM_ELEMENT_TYPES.COLLECTION: return