diff --git a/public/locales/en-US/tracks.json b/public/locales/en-US/tracks.json index e4f2eec12..57edff7aa 100644 --- a/public/locales/en-US/tracks.json +++ b/public/locales/en-US/tracks.json @@ -1,34 +1,18 @@ { "patrolTrackLegend": { - "multiplePatrolsTitle": "{{count}} patrols", - "singlePatrolTitle": "Patrol: {{patrolDisplayTitle}}", - "titleElement": { - "coveredSpan": " covered", - "icon": "Icon for {{title}}", - "lengthCovered": "{{length}} covered", - "removeButton": "remove" - } + "icon": "Icon for {{title}}", + "itemDescription": "{{length}} covered", + "trackLegendItemsName": "patrols" }, "subjectTrackLegend": { + "description": "{{pointCount}} points over {{trackTime}}", + "itemDescription_one": "{{count}} point", + "itemDescription_other": "{{count}} points", + "itemIcon": "Icon for {{title}}", + "trackLegendItemsName": "subjects" + }, + "trackLegend": { "clearTracksButton": "Clear Tracks", - "icon": "Icon for {{title}}", - "multipleSubjectTracksTitle": "{{count}} subjects", - "pointsOverTime": "{{pointCount}} points over {{trackTime}}", - "subjectTrackList": { - "closeButtonLabel": "Close the list of subjects", - "subjectTracksItem": { - "icon": "Icon for {{title}}", - "pointCount_one": "{{count}} point", - "pointCount_other": "{{count}} points", - "removeButton": "Remove", - "removeButtonLabel": "Remove {{title}}" - }, - "title": "Subjects" - }, - "subjectTracksListButtonLabel": { - "closed": "Open the list of subjects", - "open": "Close the list of subjects" - }, "timeOfDaySettings": { "chevronButtonLabel": { "closed": "Expand the time of day settings", @@ -55,6 +39,17 @@ "active": "Deactivate the time of day coloring", "inactive": "Activate the time of day coloring" }, + "tracksList": { + "closeButtonLabel": "Close the list of {{itemsName}}", + "tracksItem": { + "removeButton": "Remove", + "removeButtonLabel": "Remove {{title}}" + } + }, + "tracksListButtonLabel": { + "closed": "Open the list of {{itemsName}}", + "open": "Close the list of {{itemsName}}" + }, "trackSettings": { "closeButtonLabel": "Close the track settings", "customLengthErrorMessage": "Please enter a track length between {{min}} and {{max}}.", diff --git a/public/locales/es/tracks.json b/public/locales/es/tracks.json index f78fa41a0..11919afa7 100644 --- a/public/locales/es/tracks.json +++ b/public/locales/es/tracks.json @@ -1,34 +1,18 @@ { "patrolTrackLegend": { - "multiplePatrolsTitle": "{{count}} patrullas", - "singlePatrolTitle": "Patrulla: {{patrolDisplayTitle}}", - "titleElement": { - "coveredSpan": " cubiertas", - "icon": "Ícono para {{title}}", - "lengthCovered": "{{length}} cubiertos", - "removeButton": "remover" - } + "icon": "Ícono para {{title}}", + "itemDescription": "{{length}} cubiertos", + "trackLegendItemsName": "patrullas" }, "subjectTrackLegend": { + "description": "{{pointCount}} puntos en {{trackTime}}", + "itemDescription_one": "{{count}} punto", + "itemDescription_other": "{{count}} puntos", + "itemIcon": "Icono para {{title}}", + "trackLegendItemsName": "sujetos" + }, + "trackLegend": { "clearTracksButton": "Limpiar trayectorias", - "icon": "Icono para {{title}}", - "multipleSubjectTracksTitle": "{{count}} sujetos", - "pointsOverTime": "{{pointCount}} puntos en {{trackTime}}", - "subjectTrackList": { - "closeButtonLabel": "Cerrar la lista de sujetos", - "subjectTracksItem": { - "icon": "Icono para {{title}}", - "pointCount_one": "{{count}} punto", - "pointCount_other": "{{count}} puntos", - "removeButton": "Eliminar", - "removeButtonLabel": "Eliminar a {{title}}" - }, - "title": "Sujetos" - }, - "subjectTracksListButtonLabel": { - "closed": "Abrir la lista de sujetos", - "open": "Cerrar la lista de sujetos" - }, "timeOfDaySettings": { "chevronButtonLabel": { "closed": "Expandir la configuración de la hora del día", @@ -55,6 +39,17 @@ "active": "Desactivar la coloración por hora del día", "inactive": "Activar la coloración por hora del día" }, + "tracksList": { + "closeButtonLabel": "Cerrar la lista de {{itemsName}}", + "tracksItem": { + "removeButton": "Eliminar", + "removeButtonLabel": "Eliminar a {{title}}" + } + }, + "tracksListButtonLabel": { + "closed": "Abrir la lista de {{itemsName}}", + "open": "Cerrar la lista de {{itemsName}}" + }, "trackSettings": { "closeButtonLabel": "Cerrar la configuración de trayectorias", "customLengthErrorMessage": "Por favor, introduce una duración de trayectoria entre {{min}} y {{max}}.", diff --git a/public/locales/fr/tracks.json b/public/locales/fr/tracks.json index 2551b2d9f..20304b5cc 100644 --- a/public/locales/fr/tracks.json +++ b/public/locales/fr/tracks.json @@ -1,34 +1,18 @@ { "patrolTrackLegend": { - "multiplePatrolsTitle": "{{count}} patrouilles", - "singlePatrolTitle": "Patrouille: {{patrolDisplayTitle}}", - "titleElement": { - "coveredSpan": "parcourue", - "icon": "Icône pour {{title}}", - "lengthCovered": "{{length}} parcourue", - "removeButton": "retirer" - } + "icon": "Icône pour {{title}}", + "itemDescription": "{{length}} parcourue", + "trackLegendItemsName": "patrouilles" }, "subjectTrackLegend": { + "description": "{{pointCount}} points sur {{trackTime}}", + "itemDescription_one": "{{count}} point", + "itemDescription_other": "{{count}} points", + "itemIcon": "Icône pour {{title}}", + "trackLegendItemsName": "sujets" + }, + "trackLegend": { "clearTracksButton": "Effacer les trajectoires", - "icon": "Icône pour {{title}}", - "multipleSubjectTracksTitle": "{{count}} sujets", - "pointsOverTime": "{{pointCount}} points sur {{trackTime}}", - "subjectTrackList": { - "closeButtonLabel": "Fermer la liste des sujets", - "subjectTracksItem": { - "icon": "Icône pour {{title}}", - "pointCount_one": "{{count}} point", - "pointCount_other": "{{count}} points", - "removeButton": "Supprimer", - "removeButtonLabel": "Supprimer {{title}}" - }, - "title": "Sujets" - }, - "subjectTracksListButtonLabel": { - "closed": "Ouvrir la liste des sujets", - "open": "Fermer la liste des sujets" - }, "timeOfDaySettings": { "chevronButtonLabel": { "closed": "Développer les paramètres de l'heure de la journée", @@ -55,6 +39,17 @@ "active": "Désactiver la coloration par heure de la journée", "inactive": "Activer la coloration par heure de la journée" }, + "tracksList": { + "closeButtonLabel": "Fermer la liste des {{itemsName}}", + "tracksItem": { + "removeButton": "Supprimer", + "removeButtonLabel": "Supprimer {{title}}" + } + }, + "tracksListButtonLabel": { + "closed": "Ouvrir la liste des {{itemsName}}", + "open": "Fermer la liste des {{itemsName}}" + }, "trackSettings": { "closeButtonLabel": "Fermer les paramètres des trajectoires", "customLengthErrorMessage": "Veuillez entrer une durée de trajectoire entre {{min}} et {{max}}.", diff --git a/public/locales/ne-NP/tracks.json b/public/locales/ne-NP/tracks.json index 62a746e7e..e1964f90b 100644 --- a/public/locales/ne-NP/tracks.json +++ b/public/locales/ne-NP/tracks.json @@ -1,34 +1,18 @@ { "patrolTrackLegend": { - "multiplePatrolsTitle": "{{count}} गस्तीहरु", - "singlePatrolTitle": "गस्ती: {{patrolDisplayTitle}}", - "titleElement": { - "coveredSpan": "ढाकिएको", - "icon": "{{title}}को लागि आइकन", - "lengthCovered": "{{length}} ढाकिएको", - "removeButton": "हटाउनुहोस्" - } + "icon": "{{title}}को लागि आइकन", + "itemDescription": "{{length}} ढाकिएको", + "trackLegendItemsName": "गस्तीहरु" }, "subjectTrackLegend": { + "description": "{{trackTime}} मा {{pointCount}} पोइन्टहरू", + "itemDescription_one": "{{count}} पोइन्ट", + "itemDescription_other": "{{count}} पोइन्टहरू", + "itemIcon": "{{title}} को आइकन", + "trackLegendItemsName": "विषयहरू" + }, + "trackLegend": { "clearTracksButton": "ट्र्याकहरू खाली गर्नुहोस्", - "icon": "{{title}} को आइकन", - "multipleSubjectTracksTitle": "{{count}} विषयहरू", - "pointsOverTime": "{{trackTime}} मा {{pointCount}} पोइन्टहरू", - "subjectTrackList": { - "closeButtonLabel": "विषयहरूको सूची बन्द गर्नुहोस्", - "subjectTracksItem": { - "icon": "{{title}} को आइकन", - "pointCount_one": "{{count}} पोइन्ट", - "pointCount_other": "{{count}} पोइन्टहरू", - "removeButton": "हटाउनुहोस्", - "removeButtonLabel": "{{title}} हटाउनुहोस्" - }, - "title": "विषयहरू" - }, - "subjectTracksListButtonLabel": { - "closed": "विषयहरूको सूची खोल्नुहोस्", - "open": "विषयहरूको सूची बन्द गर्नुहोस्" - }, "timeOfDaySettings": { "chevronButtonLabel": { "closed": "दिनको समय सेटिङ विस्तार गर्नुहोस्", @@ -55,6 +39,17 @@ "active": "दिनको समय अनुसार रंग निष्क्रिय गर्नुहोस्", "inactive": "दिनको समय अनुसार रंग सक्रिय गर्नुहोस्" }, + "tracksList": { + "closeButtonLabel": "{{itemsName}} को सूची बन्द गर्नुहोस्", + "tracksItem": { + "removeButton": "हटाउनुहोस्", + "removeButtonLabel": "{{title}} हटाउनुहोस्" + } + }, + "tracksListButtonLabel": { + "closed": "{{itemsName}} को सूची खोल्नुहोस्", + "open": "{{itemsName}} को सूची बन्द गर्नुहोस्" + }, "trackSettings": { "closeButtonLabel": "ट्र्याक सेटिङहरू बन्द गर्नुहोस्", "customLengthErrorMessage": "कृपया {{min}} देखि {{max}} को बीचमा ट्र्याक लम्बाइ प्रविष्ट गर्नुहोस्।", diff --git a/public/locales/pt/tracks.json b/public/locales/pt/tracks.json index dab327a25..6131fd5f7 100644 --- a/public/locales/pt/tracks.json +++ b/public/locales/pt/tracks.json @@ -1,34 +1,18 @@ { "patrolTrackLegend": { - "multiplePatrolsTitle": "{{count}} patrulhas", - "singlePatrolTitle": "Patrulha: {{patrolDisplayTitle}}", - "titleElement": { - "coveredSpan": "Percorrido", - "icon": "Ícone para {{title}}", - "lengthCovered": "{{length}} percorridos", - "removeButton": "Remover" - } + "icon": "Ícone para {{title}}", + "itemDescription": "{{length}} percorridos", + "trackLegendItemsName": "patrulhas" }, "subjectTrackLegend": { + "description": "{{pointCount}} pontos ao longo de {{trackTime}}", + "itemDescription_one": "{{count}} ponto", + "itemDescription_other": "{{count}} pontos", + "itemIcon": "Ícone para {{title}}", + "trackLegendItemsName": "sujeitos" + }, + "trackLegend": { "clearTracksButton": "Limpar trajetórias", - "icon": "Ícone para {{title}}", - "multipleSubjectTracksTitle": "{{count}} sujeitos", - "pointsOverTime": "{{pointCount}} pontos ao longo de {{trackTime}}", - "subjectTrackList": { - "closeButtonLabel": "Fechar a lista de sujeitos", - "subjectTracksItem": { - "icon": "Ícone para {{title}}", - "pointCount_one": "{{count}} ponto", - "pointCount_other": "{{count}} pontos", - "removeButton": "Remover", - "removeButtonLabel": "Remover {{title}}" - }, - "title": "Sujeitos" - }, - "subjectTracksListButtonLabel": { - "closed": "Abrir a lista de sujeitos", - "open": "Fechar a lista de sujeitos" - }, "timeOfDaySettings": { "chevronButtonLabel": { "closed": "Expandir as configurações da hora do dia", @@ -55,6 +39,17 @@ "active": "Desativar a coloração por hora do dia", "inactive": "Ativar a coloração por hora do dia" }, + "tracksList": { + "closeButtonLabel": "Fechar a lista de {{itemsName}}", + "tracksItem": { + "removeButton": "Remover", + "removeButtonLabel": "Remover {{title}}" + } + }, + "tracksListButtonLabel": { + "closed": "Abrir a lista de {{itemsName}}", + "open": "Fechar a lista de {{itemsName}}" + }, "trackSettings": { "closeButtonLabel": "Fechar configurações de trajetórias", "customLengthErrorMessage": "Por favor, insira uma duração de trajetória entre {{min}} e {{max}}.", diff --git a/public/locales/sw/tracks.json b/public/locales/sw/tracks.json index fbd7d781d..7b6b9bf60 100644 --- a/public/locales/sw/tracks.json +++ b/public/locales/sw/tracks.json @@ -1,34 +1,18 @@ { "patrolTrackLegend": { - "multiplePatrolsTitle": "{{count}} doria", - "singlePatrolTitle": "Doria: {{patrolDisplayTitle}}", - "titleElement": { - "coveredSpan": " imefunikwa", - "icon": "Ikoni kwa {{title}}", - "lengthCovered": "{{length}} imefunikwa", - "removeButton": "ondoa" - } + "icon": "Ikoni kwa {{title}}", + "itemDescription": "{{length}} imefunikwa", + "trackLegendItemsName": "doria" }, "subjectTrackLegend": { + "description": "Pointi {{pointCount}} kwa muda wa {{trackTime}}", + "itemDescription_one": "Pointi {{count}}", + "itemDescription_other": "Pointi {{count}}", + "itemIcon": "Ikoni ya {{title}}", + "trackLegendItemsName": "wasanii" + }, + "trackLegend": { "clearTracksButton": "Futa nyimbo", - "icon": "Ikoni ya {{title}}", - "multipleSubjectTracksTitle": "Wasanii {{count}}", - "pointsOverTime": "Pointi {{pointCount}} kwa muda wa {{trackTime}}", - "subjectTrackList": { - "closeButtonLabel": "Funga orodha ya wasanii", - "subjectTracksItem": { - "icon": "Ikoni ya {{title}}", - "pointCount_one": "Pointi {{count}}", - "pointCount_other": "Pointi {{count}}", - "removeButton": "Ondoa", - "removeButtonLabel": "Ondoa {{title}}" - }, - "title": "Wasanii" - }, - "subjectTracksListButtonLabel": { - "closed": "Fungua orodha ya wasanii", - "open": "Funga orodha ya wasanii" - }, "timeOfDaySettings": { "chevronButtonLabel": { "closed": "Panua mipangilio ya wakati wa siku", @@ -55,6 +39,17 @@ "active": "Zima rangi kwa wakati wa siku", "inactive": "Amua rangi kwa wakati wa siku" }, + "tracksList": { + "closeButtonLabel": "Funga orodha ya {{itemsName}}", + "tracksItem": { + "removeButton": "Ondoa", + "removeButtonLabel": "Ondoa {{title}}" + } + }, + "tracksListButtonLabel": { + "closed": "Fungua orodha ya {{itemsName}}", + "open": "Funga orodha ya {{itemsName}}" + }, "trackSettings": { "closeButtonLabel": "Funga mipangilio ya nyimbo", "customLengthErrorMessage": "Tafadhali ingiza urefu wa wimbo kati ya {{min}} na {{max}}.", diff --git a/src/AddToPatrolModal/index.js b/src/AddToPatrolModal/index.js index 135d76b92..7ed25b77a 100644 --- a/src/AddToPatrolModal/index.js +++ b/src/AddToPatrolModal/index.js @@ -18,13 +18,19 @@ import { calcPatrolFilterForRequest } from '../utils/patrol-filter'; import LoadingOverlay from '../LoadingOverlay'; import PatrolListItem from '../PatrolListItem'; -import { INITIAL_PATROLS_STATE, PATROLS_API_URL, updatePatrolStore } from '../ducks/patrols'; +import { PATROLS_API_URL, updatePatrolStore } from '../ducks/patrols'; import { SocketContext } from '../withSocketConnection'; - import styles from './styles.module.scss'; +const INITIAL_PATROLS_STATE = { + count: null, + next: null, + previous: null, + results: [], +}; + const { Header, Title, Body, Footer } = Modal; const { get } = axios; diff --git a/src/Map/index.js b/src/Map/index.js index 32a41381f..6892cba83 100644 --- a/src/Map/index.js +++ b/src/Map/index.js @@ -369,12 +369,6 @@ const Map = ({ ); }, [dispatch]); - const onPatrolTrackLegendClose = useCallback(() => { - dispatch( - updatePatrolTrackState({ visible: [], pinned: [] }) - ); - }, [dispatch]); - const onRotationControlClick = useCallback(() => { map.easeTo({ bearing: 0, pitch: 0 }); }, [map]); @@ -651,7 +645,7 @@ const Map = ({ {subjectHeatmapAvailable && } {showReportHeatmap && } - {patrolTracksVisible && } + diff --git a/src/PatrolDetailView/PlanSection/index.js b/src/PatrolDetailView/PlanSection/index.js index 5fe88dbfa..c0b7e817d 100644 --- a/src/PatrolDetailView/PlanSection/index.js +++ b/src/PatrolDetailView/PlanSection/index.js @@ -25,7 +25,7 @@ import ReportedBySelect from '../../ReportedBySelect'; import TimePicker, { isValidTime } from '../../TimePicker'; import styles from './styles.module.scss'; -import { getPatrolLeadersWithLocation } from '../../selectors/patrols'; +import { selectPatrolLeadersWithLastPosition } from '../../selectors/patrols'; import { useTranslation } from 'react-i18next'; const shouldScheduleDate = (date, isAuto) => !isAuto && isFuture(date); @@ -48,7 +48,7 @@ const PlanSection = ({ const userPrefAutoStart = useSelector((state) => state.view.userPreferences.autoStartPatrols); const [isAutoEnd, setIsAutoEnd] = useState(isNewPatrol ? userPrefAutoEnd : !!actualEndTime); const [isAutoStart, setIsAutoStart] = useState(isNewPatrol ? userPrefAutoStart : !!actualStartTime); - const patrolLeaders = useSelector(getPatrolLeadersWithLocation); + const patrolLeaders = useSelector(selectPatrolLeadersWithLastPosition); const displayEndDate = displayEndTimeForPatrol(patrolForm); const displayStartDate = displayStartTimeForPatrol(patrolForm); const endDayIsSameAsStart = displayEndDate && displayStartDate?.toDateString() === displayEndDate?.toDateString(); diff --git a/src/PatrolDetailView/index.js b/src/PatrolDetailView/index.js index d5f58d6f8..aad0e59fe 100644 --- a/src/PatrolDetailView/index.js +++ b/src/PatrolDetailView/index.js @@ -9,7 +9,7 @@ import { ReactComponent as CalendarIcon } from '../common/images/icons/calendar. import { ReactComponent as HistoryIcon } from '../common/images/icons/history.svg'; import { addPatrolSegmentToEvent, getEventIdsForCollection, setOriginalTextToEventNotes } from '../utils/events'; -import { createPatrolDataSelector } from '../selectors/patrols'; +import { selectPatrolData } from '../selectors/patrols'; import { convertFileListToArray, filterDuplicateUploadFilenames } from '../utils/file'; import { actualEndTimeForPatrol, @@ -496,7 +496,7 @@ const PatrolDetailView = () => { const navigationPatrolId = isNewPatrol ? newPatrolTemporalId : patrolId; const memoryPatrolId = isNewPatrol ? temporalIdRef.current : patrolDataSelector?.patrol?.id; if (navigationPatrolId !== memoryPatrolId) { - setPatrolDataSelector(originalPatrol ? createPatrolDataSelector()(state, { patrol: originalPatrol }) : {}); + setPatrolDataSelector(originalPatrol ? selectPatrolData(state, originalPatrol) : {}); temporalIdRef.current = isNewPatrol ? newPatrolTemporalId : null; } } diff --git a/src/PatrolFilter/index.js b/src/PatrolFilter/index.js index 46f7014a8..93e9ad428 100644 --- a/src/PatrolFilter/index.js +++ b/src/PatrolFilter/index.js @@ -8,7 +8,6 @@ import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import { caseInsensitiveCompare } from '../utils/string'; -import { getPatrolList } from '../selectors/patrols'; import { INITIAL_FILTER_STATE, updatePatrolFilter } from '../ducks/patrol-filter'; import { resetGlobalDateRange } from '../ducks/global-date-range'; import { isFilterModified } from '../utils/patrol-filter'; @@ -34,7 +33,7 @@ const PatrolFilter = ({ className }) => { const containerRef = useRef(null); const { t } = useTranslation('filters', { keyPrefix: 'patrolFilters' }); const dispatch = useDispatch(); - const patrols = useSelector(getPatrolList); + const patrolStore = useSelector((state) => state.data.patrolStore); const patrolFilter = useSelector(state => state.data.patrolFilter); const [filterText, setFilterText] = useState(patrolFilter.filter.text); @@ -160,7 +159,7 @@ const PatrolFilter = ({ className }) => { className={styles.friendlyFilterString} dateRange={patrolFilter.filter.date_range} isFiltered={isFilterModified(patrolFilter)} - totalFeedCount={patrols?.results?.length ?? 0} + totalFeedCount={Object.keys(patrolStore).length ?? 0} /> { diff --git a/src/PatrolStartStopLayer/index.js b/src/PatrolStartStopLayer/index.js index ba921717e..49f3fab25 100644 --- a/src/PatrolStartStopLayer/index.js +++ b/src/PatrolStartStopLayer/index.js @@ -3,23 +3,23 @@ import React, { Fragment, memo } from 'react'; import { connect } from 'react-redux'; import { withMap } from '../EarthRangerMap'; -import { patrolsWithTrackShown } from '../selectors/patrols'; +import { selectPatrolsWithTracks } from '../selectors/patrols'; import StartStopLayer from './layer'; -const PatrolStartStopLayer = ({ patrols }) => { +const PatrolStartStopLayer = ({ patrolsWithTracks }) => { const onSymbolClick = () => {}; return - {patrols + {patrolsWithTracks .map((patrol, index) => )} ; }; const mapStateToProps = (state) => ({ - patrols: patrolsWithTrackShown(state), + patrolsWithTracks: selectPatrolsWithTracks(state), }); diff --git a/src/PatrolStartStopLayer/layer.js b/src/PatrolStartStopLayer/layer.js index 4890c12da..7e2b8c015 100644 --- a/src/PatrolStartStopLayer/layer.js +++ b/src/PatrolStartStopLayer/layer.js @@ -3,7 +3,7 @@ import { connect } from 'react-redux'; import { addMapImage } from '../utils/map'; import { calcImgIdFromUrlForMapImages } from '../utils/img'; -import { createPatrolDataSelector } from '../selectors/patrols'; +import { selectPatrolData } from '../selectors/patrols'; import { DEFAULT_SYMBOL_PAINT, LAYER_IDS } from '../constants'; import { withMap } from '../EarthRangerMap'; import { uuid } from '../utils/string'; @@ -100,10 +100,9 @@ const StartStopLayer = (props) => { }; const makeMapStateToProps = () => { - const getDataForPatrolFromProps = createPatrolDataSelector(); const mapStateToProps = (state, props) => { return { - patrolData: getDataForPatrolFromProps(state, props), + patrolData: selectPatrolData(state, props.patrol), }; }; return mapStateToProps; diff --git a/src/PatrolTrackLayer/index.js b/src/PatrolTrackLayer/index.js index f64373592..0168268d8 100644 --- a/src/PatrolTrackLayer/index.js +++ b/src/PatrolTrackLayer/index.js @@ -2,7 +2,7 @@ import React, { memo, useContext, useMemo } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { createPatrolDataSelector } from '../selectors/patrols'; +import { selectPatrolData } from '../selectors/patrols'; import { MapContext } from '../App'; import { trimTrackDataToTimeRange } from '../utils/tracks'; @@ -21,8 +21,7 @@ const PatrolTrackLayer = ({ onPointClick, patrol: patrolFromProps, trackTimeEnve const map = useContext(MapContext); const { patrol, trackData } = useSelector((state) => { - const getDataForPatrolFromProps = createPatrolDataSelector(); - return getDataForPatrolFromProps(state, { patrol: patrolFromProps }); + return selectPatrolData(state, patrolFromProps); }); const showTrackTimepoints = useSelector((state) => state.view.showTrackTimepoints); diff --git a/src/PatrolTrackLegend/index.js b/src/PatrolTrackLegend/index.js index 42b4ff053..2b322ea1f 100644 --- a/src/PatrolTrackLegend/index.js +++ b/src/PatrolTrackLegend/index.js @@ -1,110 +1,65 @@ -import React, { memo, useCallback } from 'react'; -import Button from 'react-bootstrap/Button'; +import React, { memo, useMemo } from 'react'; import { length } from '@turf/turf'; -import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; -import Popover from 'react-bootstrap/Popover'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as InfoIcon } from '../common/images/icons/information.svg'; - -import { displayTitleForPatrol, iconTypeForPatrol } from '../utils/patrols'; +import { displayTitleForPatrol, iconTypeForPatrol, patrolStateAllowsTrackDisplay } from '../utils/patrols'; +import { selectPatrolsWithTracksData } from '../selectors/patrols'; import { updatePatrolTrackState } from '../ducks/patrols'; -import { visibleTrackedPatrolData } from '../selectors/patrols'; import DasIcon from '../DasIcon'; -import MapLegend from '../MapLegend'; -import PatrolDistanceCovered from '../Patrols/DistanceCovered'; +import TrackLegend from '../TrackLegend'; import styles from './styles.module.scss'; -const TitleElement = ({ displayTitle, iconId, onRemovePatrolClick, patrolData }) => { - const { t } = useTranslation('tracks', { keyPrefix: 'patrolTrackLegend.titleElement' }); - - const convertPatrolTrackToDetailItem = useCallback(({ patrol, trackData, leader }) => { - const iconId = iconTypeForPatrol(patrol); - const title = displayTitleForPatrol(patrol, leader); - - return
  • - - -
    - {title} - - {t('lengthCovered', { length: `${trackData ? length(trackData.track).toFixed(2): 0.00}km` })} -
    - - -
  • ; - }, [onRemovePatrolClick, t]); - - return
    - {iconId && } - -
    -
    - {displayTitle} - - {patrolData.length > 1 && -
      - {patrolData.map(convertPatrolTrackToDetailItem)} -
    - } - placement="right" - rootClose - trigger="click" - > - -
    } -
    - - - - - {t('coveredSpan')} - -
    -
    ; -}; - -const PatrolTrackLegend = ({ onClose }) => { +const PatrolTrackLegend = () => { const dispatch = useDispatch(); const { t } = useTranslation('tracks', { keyPrefix: 'patrolTrackLegend' }); - const patrolData = useSelector(visibleTrackedPatrolData); - const trackState = useSelector((state) => state.view.patrolTrackState); - - const hasData = !!patrolData.length; - const isMulti = patrolData.length > 1; - - let displayTitle; - if (!hasData) { - displayTitle = null; - } else if (!isMulti) { - displayTitle = t('singlePatrolTitle', { - patrolDisplayTitle: displayTitleForPatrol(patrolData[0].patrol, patrolData[0].leader), - }); - } else { - displayTitle = t('multiplePatrolsTitle', { count: patrolData.length }); - } - - const iconId = !isMulti && hasData ? iconTypeForPatrol(patrolData[0].patrol) : null; - - return hasData ? dispatch(updatePatrolTrackState({ - pinned: trackState.pinned.filter((value) => value !== event.target.value), - visible: trackState.visible.filter((value) => value !== event.target.value), - }))} - patrolData={patrolData} - />} - /> : null; + const patrolTrackState = useSelector((state) => state.view.patrolTrackState); + const patrolsWithTrackData = useSelector(selectPatrolsWithTracksData); + + // Calculate the total tracks length to show a description in the legend like "3km". + const description = useMemo(() => { + const totalTracksLength = patrolsWithTrackData + .filter((patrolData) => !!patrolStateAllowsTrackDisplay(patrolData.patrol)) + .reduce((accumulator, patrolData) => { + const lineLength = patrolData.startStopGeometries?.lines ? length(patrolData.startStopGeometries.lines) : 0; + const trackLength = patrolData.trackData?.track ? length(patrolData.trackData.track) : 0; + + return accumulator + lineLength + trackLength; + }, 0); + + return `${totalTracksLength ? totalTracksLength.toFixed(2) : 0}km`; + }, [patrolsWithTrackData]); + + // Build the items array with the description, icon, id and title of each tracked patrol. + const items = useMemo(() => patrolsWithTrackData.map((patrolData) => { + const iconId = iconTypeForPatrol(patrolData.patrol); + const title = displayTitleForPatrol(patrolData.patrol, patrolData.leader); + + return { + description: t('itemDescription', { + length: `${patrolData.trackData ? length(patrolData.trackData.track).toFixed(2): 0.00}km`, + }), + icon: , + id: patrolData.patrol.id, + title, + }; + }), [patrolsWithTrackData, t]); + + return dispatch(updatePatrolTrackState({ visible: [], pinned: [] }))} + onRemoveItemTracks={(patrolId) => dispatch(updatePatrolTrackState({ + pinned: patrolTrackState.pinned.filter((pinnedPatrolTracksId) => pinnedPatrolTracksId !== patrolId), + visible: patrolTrackState.visible.filter((visiblePatrolTracksId) => visiblePatrolTracksId !== patrolId), + }))} + showTimeOfDaySettings={false} + showTrackSettings={false} + />; }; export default memo(PatrolTrackLegend); diff --git a/src/PatrolTrackLegend/styles.module.scss b/src/PatrolTrackLegend/styles.module.scss index 9ab4b1a03..c8200212d 100644 --- a/src/PatrolTrackLegend/styles.module.scss +++ b/src/PatrolTrackLegend/styles.module.scss @@ -1,74 +1,6 @@ -@import '../common/styles/buttons'; - -.icon { - max-height: 1.25rem; - margin-left: .25rem; - max-width: 1.25rem; -} - -.svgIcon { - @extend .icon; +.itemIcon { fill: gray; + height: 1.5rem; margin-right: 0.5rem; - max-height: 1.5rem; - max-width: unset; - width: 2rem; - position: relative; -} - -.titleWrapper { - align-items: flex-start; - display: flex; -} - -.innerTitleWrapper { - display: flex; - flex-flow: column; -} - -.infoButton { - @include unstyledButton; -} - -.infoIcon { - height: 1rem; - width: 1rem; -} - -.popover { - padding: 0.5rem; - max-width: 18rem; - min-width: 14rem; - ul { - list-style-type: none; - margin: 0; - max-height: 18rem; - overflow-y: auto; - padding: 0; - width: 100%; - } - li { - align-items: center; - display: flex; - justify-content: flex-start; - line-height: normal; - span, small { - display: block; - } - + li { - margin-top: 0.5rem; - } - } - img { - max-height: 1.5rem; - margin-left: .25rem; - max-width: 1.5rem; - } - button { - margin-left: auto; - } -} - -.listItemDetails { - padding-right: 0.5rem; + width: 1.5rem; } diff --git a/src/PatrolTracks/index.js b/src/PatrolTracks/index.js index afc591b3d..ed3690d02 100644 --- a/src/PatrolTracks/index.js +++ b/src/PatrolTracks/index.js @@ -1,16 +1,16 @@ import React, { memo } from 'react'; import { useSelector } from 'react-redux'; -import { patrolsWithTrackShown } from '../selectors/patrols'; +import { selectPatrolsWithTracks } from '../selectors/patrols'; import { selectTrackTimeEnvelope } from '../selectors/tracks'; import PatrolTrackLayer from '../PatrolTrackLayer'; const PatrolTracks = (props) => { - const patrols = useSelector(patrolsWithTrackShown); + const patrolsWithTracks = useSelector(selectPatrolsWithTracks); const trackTimeEnvelope = useSelector(selectTrackTimeEnvelope); - return patrols.map((patrol, index) => { const navigate= useNavigate(); - const patrols = useSelector(getPatrolList); - const sortedPatrols = useMemo(() => sortPatrolList(patrols.results), [patrols.results]); + const patrolStore = useSelector((state) => state.data.patrolStore); + const sortedPatrols = useMemo(() => sortPatrolList(Object.values(patrolStore)), [patrolStore]); const onItemClick = useCallback((id) => navigate(id), [navigate]); diff --git a/src/SubjectTrackLegend/SubjectTracksList/index.js b/src/SubjectTrackLegend/SubjectTracksList/index.js deleted file mode 100644 index ce14c4e44..000000000 --- a/src/SubjectTrackLegend/SubjectTracksList/index.js +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import { useSelector } from 'react-redux'; -import { useTranslation } from 'react-i18next'; - -import { ReactComponent as CrossIcon } from '../../common/images/icons/cross.svg'; - -import styles from './styles.module.scss'; - -const SubjectTracksItem = ({ onRemove, track }) => { - const { t } = useTranslation('tracks', { keyPrefix: 'subjectTrackLegend.subjectTrackList.subjectTracksItem' }); - - const subjectId = track.features[0].properties.id; - - const subject = useSelector((state) => state.data.subjectStore[subjectId]); - - const image = track.features[0].properties.image; - const pointCount = track.features[0].geometry?.coordinates.length || 0; - const title = track.features[0].properties.title; - - const subjectLastPositionImage = subject?.last_position?.properties?.image; - - return
  • -
    - {t('icon', - -

    {title}

    -
    - -
    -

    {t('pointCount', { count: pointCount })}

    - - -
    -
  • ; -}; - -const SubjectTracksList = ({ onClose, onRemoveSubjectTracks, subjectTracks }) => { - const { t } = useTranslation('tracks', { keyPrefix: 'subjectTrackLegend.subjectTrackList' }); - - return
    -
    -

    {t('title')}

    - - -
    - -
      - {subjectTracks.map((subjectTracks) => )} -
    -
    ; -}; - -export default SubjectTracksList; diff --git a/src/SubjectTrackLegend/SubjectTracksList/index.test.js b/src/SubjectTrackLegend/SubjectTracksList/index.test.js deleted file mode 100644 index c26494981..000000000 --- a/src/SubjectTrackLegend/SubjectTracksList/index.test.js +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react'; -import { Provider } from 'react-redux'; -import userEvent from '@testing-library/user-event'; - -import { render, screen } from '../../test-utils'; -import { mockStore } from '../../__test-helpers/MockStore'; - -import SubjectTracksList from '.'; - -describe('SubjectTrackLegend - SubjectTracksList', () => { - const onClose = jest.fn(); - const onRemoveSubjectTracks = jest.fn(); - - let store; - beforeEach(() => { - store = { - data: { - subjectStore: { - 1234: { - last_position: { - properties: { - image: 'https://root.dev.pamdas.org/static/elk-male.svg', - }, - }, - }, - 5678: { - last_position: { - properties: { - image: 'https://root.dev.pamdas.org/static/bison-male.svg', - }, - }, - }, - }, - }, - }; - }); - - const renderSubjectTracksList = (props, overrideStore) => render( - - - - ); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('closes the subject track list', () => { - renderSubjectTracksList(); - - expect(onClose).not.toHaveBeenCalled(); - - userEvent.click(screen.getByLabelText('Close the list of subjects')); - - expect(onClose).toHaveBeenCalledTimes(1); - }); - - test('lists all the subjects', () => { - renderSubjectTracksList(); - - expect(screen.getAllByRole('listitem')).toHaveLength(2); - }); - - test('shows the subject image', () => { - renderSubjectTracksList(); - - expect(screen.getByAltText('Icon for Ludwig')) - .toHaveAttribute('src', 'https://root.dev.pamdas.org/static/elk-male.svg'); - }); - - test('shows the subject title', () => { - renderSubjectTracksList(); - - expect(screen.getByText('Ludwig')).toBeVisible(); - }); - - test('shows the point count of the subject tracks', () => { - renderSubjectTracksList(); - - expect(screen.getAllByText('1 point')).toHaveLength(2); - }); - - test('removes a subject from the tracks list', () => { - renderSubjectTracksList(); - - expect(onRemoveSubjectTracks).not.toHaveBeenCalled(); - - userEvent.click(screen.getByLabelText('Remove Ludwig')); - - expect(onRemoveSubjectTracks).toHaveBeenCalledTimes(1); - expect(onRemoveSubjectTracks).toHaveBeenCalledWith('1234'); - }); -}); diff --git a/src/SubjectTrackLegend/index.js b/src/SubjectTrackLegend/index.js index 625f50c84..5aec4cf81 100644 --- a/src/SubjectTrackLegend/index.js +++ b/src/SubjectTrackLegend/index.js @@ -1,125 +1,67 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import Collapse from 'react-bootstrap/Collapse'; +import React, { memo, useMemo } from 'react'; import { formatDistance, formatDistanceToNow } from 'date-fns'; -import uniq from 'lodash/uniq'; import { useDispatch, useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import { ReactComponent as DayNightIcon } from '../common/images/icons/day-night.svg'; -import { ReactComponent as GearIcon } from '../common/images/icons/gear.svg'; -import { ReactComponent as TracksOffIcon } from '../common/images/icons/tracks_off.svg'; - -import { BOOTSTRAP_DEFAULTS, FEATURE_FLAG_LABELS } from '../constants'; import { getCurrentLocale } from '../utils/datetime'; import { MAP_INTERACTION_CATEGORY, trackEventFactory } from '../utils/analytics'; -import { selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod, selectTrackTimeEnvelope } from '../selectors/tracks'; -import { setIsTimeOfDayColoringActive } from '../ducks/tracks'; +import { + selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod, + selectTrackTimeEnvelope, +} from '../selectors/tracks'; import { updateTrackState } from '../ducks/map-ui'; -import { useFeatureFlag } from '../hooks'; -import DelayedUnmount from '../DelayedUnmount'; -import TimeOfDaySettings from './TimeOfDaySettings'; -import TrackSettings from './TrackSettings'; -import SubjectTracksList from './SubjectTracksList'; +import TrackLegend from '../TrackLegend'; import styles from './styles.module.scss'; -const MENUS = { - SUBJECT_TRACKS_LIST: 'SUBJECT_TRACKS_LIST', - TIME_OF_DAY_SETTINGS: 'TIME_OF_DAY_SETTINGS', - TRACK_SETTINGS: 'TRACK_SETTINGS', -}; - const mapInteractionTracker = trackEventFactory(MAP_INTERACTION_CATEGORY); -const SubjectTrackLegend = ({ subjectTracksCount }) => { +const SubjectTrackLegend = () => { const dispatch = useDispatch(); const { t } = useTranslation('tracks', { keyPrefix: 'subjectTrackLegend' }); - const timeOfDayTrackingEnabled = useFeatureFlag(FEATURE_FLAG_LABELS.TIME_OF_DAY_TRACKING); - - const isTimeOfDayColoringActive = useSelector((state) => state.view.trackSettings.isTimeOfDayColoringActive); const subjectStore = useSelector((state) => state.data.subjectStore); - const subjectTracksTrimmedToTrackTimeEnvelope = useSelector(selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod); const subjectTrackState = useSelector((state) => state.view.subjectTrackState); + const subjectTracksTrimmedToTrackTimeEnvelope = + useSelector(selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod); const trackTimeEnvelope = useSelector(selectTrackTimeEnvelope); - // This variable tracks if a menu is expanded, which one it is. There can be only one menu expanded at a time. - const [expandedMenu, setExpandedMenu] = useState(null); - // The component starts hidden so the slide in transition effect kicks. - const [show, setShow] = useState(false); - - const isSubjectTracksListExpanded = expandedMenu === MENUS.SUBJECT_TRACKS_LIST; - const isTimeOfDaySettingsExpanded = expandedMenu === MENUS.TIME_OF_DAY_SETTINGS; - const isTrackSettingsExpanded = expandedMenu === MENUS.TRACK_SETTINGS; - - const hasTracksToShow = !!subjectTracksTrimmedToTrackTimeEnvelope.length; - - let title = null; - let titleIconSrc = null; - if (subjectTracksCount && hasTracksToShow) { - if (subjectTracksCount === 1) { - // If there is a single subject being tracked, we use set its title and image in the legend. - title = subjectTracksTrimmedToTrackTimeEnvelope[0].track.features[0].properties.title; - - const tracksSubjectId = subjectTracksTrimmedToTrackTimeEnvelope[0].track.features[0].properties.id; - const tracksImage = subjectTracksTrimmedToTrackTimeEnvelope[0].track.features[0].properties.image; - const tracksSubjectLastPositionImage = subjectStore[tracksSubjectId]?.last_position?.properties?.image; - titleIconSrc = tracksSubjectLastPositionImage || tracksImage; - } else if (subjectTracksCount > 1) { - // If there are multiple subjects selected, we set a generic title and image. - title = t('multipleSubjectTracksTitle', { count: subjectTracksCount }); - } - } - - const trackTimeEnvelopeFormatted = useMemo( - () => trackTimeEnvelope.until + // Calculate the total points in the tracks to show a description in the legend like "3 points over 2 days". + const description = useMemo(() => { + const subjectTracksPointCount = subjectTracksTrimmedToTrackTimeEnvelope.reduce( + (accumulator, subjectTracks) => accumulator + subjectTracks.points.features.length, + 0 + ); + const trackTimeEnvelopeFormatted = trackTimeEnvelope.until ? formatDistance( new Date(trackTimeEnvelope.from), new Date(trackTimeEnvelope.until), { locale: getCurrentLocale() } ) - : formatDistanceToNow(new Date(trackTimeEnvelope.from), { locale: getCurrentLocale() }), - [trackTimeEnvelope.from, trackTimeEnvelope.until] - ); - - const subjectTrackPointCount = useMemo( - () => subjectTracksTrimmedToTrackTimeEnvelope.reduce( - (accumulator, subjectTracks) => accumulator + subjectTracks.points.features.length, - 0 - ), - [subjectTracksTrimmedToTrackTimeEnvelope] - ); + : formatDistanceToNow(new Date(trackTimeEnvelope.from), { locale: getCurrentLocale() }); - const onCollapseMenu = () => setExpandedMenu(null); - - const onExpandMenu = (menu) => { - if (!expandedMenu) { - // If no menu is currently expanded, we just expand the requested one. - setExpandedMenu(menu); - } else { - // If there is a menu expanded, we first collapse it and then expand the new one. - onCollapseMenu(); - setTimeout(() => setExpandedMenu(menu), BOOTSTRAP_DEFAULTS.COLLAPSE_TRANSITION_TIME); - } - }; + return t('description', { pointCount: subjectTracksPointCount, trackTime: trackTimeEnvelopeFormatted }); + }, [subjectTracksTrimmedToTrackTimeEnvelope, t, trackTimeEnvelope.from, trackTimeEnvelope.until]); - const onActivateTimeOfDayColoring = () => { - // When activating the time of day coloring, we also expand its menu. - dispatch(setIsTimeOfDayColoringActive(true)); - onExpandMenu(MENUS.TIME_OF_DAY_SETTINGS); - }; + // Build the items array with the description, icon, id and title of each tracked subject. + const items = useMemo(() => subjectTracksTrimmedToTrackTimeEnvelope.map((subjectTracks) => { + const [firstTrackFeature] = subjectTracks.track.features; - const onDectivateTimeOfDayColoring = useCallback(() => { - dispatch(setIsTimeOfDayColoringActive(false)); + const id = firstTrackFeature.properties.id; + const image = firstTrackFeature.properties.image; + const pointCount = firstTrackFeature.geometry?.coordinates.length || 0; + const title = firstTrackFeature.properties.title; - // When deactivating the time of day coloring, we collapse its menu if it was expanded. - if (expandedMenu === MENUS.TIME_OF_DAY_SETTINGS) { - onCollapseMenu(); - } - }, [dispatch, expandedMenu]); + const lastPositionImage = subjectStore[id]?.last_position?.properties?.image; - const onClickClearTracks = () => dispatch(updateTrackState({ visible: [], pinned: [] })); + return { + description: t('itemDescription', { count: pointCount }), + icon: {t('itemIcon',, + id, + title, + }; + }), [subjectStore, subjectTracksTrimmedToTrackTimeEnvelope, t]); const onRemoveSubjectTracks = (subjectId) => { dispatch(updateTrackState({ @@ -130,129 +72,13 @@ const SubjectTrackLegend = ({ subjectTracksCount }) => { mapInteractionTracker.track('Remove Subject Tracks Via Track Legend Popover'); }; - useEffect(() => { - // If there were multiple subjects, the user could have expanded the subject tracks list menu. If then the user - // removes all tracked subjects but one, we collapse it automatically. - if (subjectTracksCount === 1 && expandedMenu === MENUS.SUBJECT_TRACKS_LIST) { - onCollapseMenu(); - } - }, [dispatch, expandedMenu, subjectTracksCount]); - - useEffect(() => { - // If there are tracked subjects, show the legend. If not, hide it. The state variable is used so the transition - // effects kick. - if (!show && subjectTracksCount > 0) { - setShow(true); - } else if (show && subjectTracksCount === 0) { - setShow(false); - } - }, [show, subjectTracksCount]); - - return
    -
    -
    -
    - {titleIconSrc - ? {t('icon', - : } - - {subjectTracksCount > 1 - ? - :

    {title}

    } -
    - -
    - {timeOfDayTrackingEnabled && } - - -
    -
    - -
    -

    - {t('pointsOverTime', { pointCount: subjectTrackPointCount, trackTime: trackTimeEnvelopeFormatted })} -

    - - -
    -
    - - -
    - -
    -
    - - -
    - -
    -
    - - {timeOfDayTrackingEnabled && -
    - onExpandMenu(MENUS.TIME_OF_DAY_SETTINGS)} - /> -
    -
    } -
    ; -}; - -// Wrap the component with a delayed unmount so the slide out transition ends before unmounting. -const SubjectTrackLegendDelayedUnmount = () => { - const subjectTrackState = useSelector((state) => state.view.subjectTrackState); - - const subjectTracksCount = useMemo( - () => uniq([...subjectTrackState.visible, ...subjectTrackState.pinned]).length, - [subjectTrackState.pinned, subjectTrackState.visible] - ); - - // We unmount the component after the collapse transition ends when there are no more subject tracks. - return 0}> - - ; + return dispatch(updateTrackState({ visible: [], pinned: [] }))} + onRemoveItemTracks={onRemoveSubjectTracks} + />; }; -export default SubjectTrackLegendDelayedUnmount; +export default memo(SubjectTrackLegend); diff --git a/src/SubjectTrackLegend/index.test.js b/src/SubjectTrackLegend/index.test.js deleted file mode 100644 index 5cb4723cc..000000000 --- a/src/SubjectTrackLegend/index.test.js +++ /dev/null @@ -1,311 +0,0 @@ -import React from 'react'; -import { Provider } from 'react-redux'; -import userEvent from '@testing-library/user-event'; - -import { render, screen, within } from '../test-utils'; -import { mockStore } from '../__test-helpers/MockStore'; -import { setIsTimeOfDayColoringActive, TRACK_LENGTH_ORIGINS } from '../ducks/tracks'; -import { updateTrackState } from '../ducks/map-ui'; -import { useFeatureFlag } from '../hooks'; - -import SubjectTrackLegend from '.'; - -jest.mock('../ducks/tracks', () => ({ - ...jest.requireActual('../ducks/tracks'), - setIsTimeOfDayColoringActive: jest.fn(), -})); - -jest.mock('../ducks/map-ui', () => ({ - ...jest.requireActual('../ducks/map-ui'), - updateTrackState: jest.fn(), -})); - -jest.mock('../hooks', () => ({ - ...jest.requireActual('../hooks'), - useFeatureFlag: () => true, -})); - -describe('SubjectTrackLegend', () => { - const onClearTracks = jest.fn(); - - let setIsTimeOfDayColoringActiveMock, updateTrackStateMock, store; - beforeEach(() => { - setIsTimeOfDayColoringActiveMock = jest.fn(() => () => {}); - setIsTimeOfDayColoringActive.mockImplementation(setIsTimeOfDayColoringActiveMock); - updateTrackStateMock = jest.fn(() => () => {}); - updateTrackState.mockImplementation(updateTrackStateMock); - - store = { - data: { - eventFilter: { - filter: { - date_range: { - lower: '2020-01-01T06:00:00.000Z', - }, - }, - }, - patrolStore: {}, - subjectStore: { - 123: {}, - 456: {}, - }, - tracks: { - 123: { - points: { - features: [], - }, - track: { - features: [{ - properties: { - id: '123', - image: 'https://root.dev.pamdas.org/static/elk-male.svg', - title: 'Ludwig', - }, - }], - }, - }, - 456: { - points: { - features: [], - }, - track: { - features: [{ - properties: { - id: '456', - image: 'https://root.dev.pamdas.org/static/bison-male.svg', - title: 'Gabo', - }, - }], - }, - }, - }, - }, - view: { - patrolTrackState: { - pinned: [], - visible: [], - }, - subjectTrackState: { - pinned: [], - visible: [], - }, - timeSliderState: { - active: false, - virtualDate: '2020-06-01T06:00:00.000Z', - }, - trackSettings: { - isTimeOfDayColoringActive: false, - length: 21, - origin: TRACK_LENGTH_ORIGINS.CUSTOM_LENGTH, - }, - }, - }; - }); - - const renderSubjectTrackLegend = (props, overrideStore) => render( - - - - ); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - test('shows the subject track legend if there are subjects with visible or pinned tracks', () => { - store.view.subjectTrackState.visible = ['123']; - renderSubjectTrackLegend(); - - expect(screen.getByTestId('subjectTrackLegend')).toHaveClass('show'); - }); - - test('does not show the subject track legend if there are no subjects with visible or pinned tracks', () => { - renderSubjectTrackLegend(); - - expect(screen.queryByTestId('subjectTrackLegend')).toBeNull(); - }); - - test('shows the icon and title of the subject if there is only one subject being tracked', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123']; - renderSubjectTrackLegend(); - - const titleWrapper = screen.getByTestId('subjectTrackLegend-titleWrapper'); - - expect(within(titleWrapper).getByAltText('Icon for Ludwig')) - .toHaveAttribute('src', 'https://root.dev.pamdas.org/static/elk-male.svg'); - expect(titleWrapper).toHaveTextContent('Ludwig'); - }); - - test('shows the tracks icon and a button with the amount of subjects if there are multiple subjects being tracked', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123', '456']; - renderSubjectTrackLegend(); - - const titleWrapper = screen.getByTestId('subjectTrackLegend-titleWrapper'); - - expect(within(titleWrapper).getByText('tracks_off.svg')).toBeVisible(); - expect(titleWrapper).toHaveTextContent('2 subjects'); - }); - - test('opens and closes the subject tracks list when clicking the button in the title', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123', '456']; - renderSubjectTrackLegend(); - - const subjectTracksListButton = screen.getByLabelText('Open the list of subjects'); - - expect(subjectTracksListButton).toHaveAttribute('aria-expanded', 'false'); - - userEvent.click(subjectTracksListButton); - - expect(subjectTracksListButton).toHaveAttribute('aria-expanded', 'true'); - expect(subjectTracksListButton).toHaveAttribute('aria-label', 'Close the list of subjects'); - - userEvent.click(subjectTracksListButton); - - expect(subjectTracksListButton).toHaveAttribute('aria-expanded', 'false'); - expect(subjectTracksListButton).toHaveAttribute('aria-label', 'Open the list of subjects'); - }); - - test('closes the subject tracks list from the close button in the menu', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123', '456']; - renderSubjectTrackLegend(); - - const subjectTracksListButton = screen.getByLabelText('Open the list of subjects'); - userEvent.click(subjectTracksListButton); - - expect(subjectTracksListButton).toHaveAttribute('aria-expanded', 'true'); - expect(subjectTracksListButton).toHaveAttribute('aria-label', 'Close the list of subjects'); - - userEvent.click(screen.getAllByLabelText('Close the list of subjects')[1]); - - expect(subjectTracksListButton).toHaveAttribute('aria-expanded', 'false'); - expect(subjectTracksListButton).toHaveAttribute('aria-label', 'Open the list of subjects'); - }); - - test('removes the tracks of a subject from the subject tracks list', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123', '456']; - renderSubjectTrackLegend(); - - userEvent.click(screen.getByLabelText('Open the list of subjects')); - - expect(updateTrackState).not.toHaveBeenCalled(); - - userEvent.click(screen.getByLabelText('Remove Ludwig')); - - expect(updateTrackState).toHaveBeenCalledTimes(1); - expect(updateTrackState).toHaveBeenCalledWith({ pinned: [], visible: ['456'] }); - }); - - test('activates the time of day coloring when clicking the day night button', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123']; - renderSubjectTrackLegend(); - - const timeOfDaySettingsButton = screen.getByLabelText('Activate the time of day coloring'); - - expect(timeOfDaySettingsButton).toHaveAttribute('aria-expanded', 'false'); - expect(timeOfDaySettingsButton).not.toHaveClass('open'); - expect(setIsTimeOfDayColoringActive).not.toHaveBeenCalled(); - - userEvent.click(timeOfDaySettingsButton); - - expect(setIsTimeOfDayColoringActive).toHaveBeenCalledTimes(1); - expect(setIsTimeOfDayColoringActive).toHaveBeenCalledWith(true); - }); - - test('expands and collapses the time of day settings menu when clicking the chevron', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.trackSettings.isTimeOfDayColoringActive = true; - store.view.subjectTrackState.visible = ['123']; - renderSubjectTrackLegend(); - - const timeOfDaySettingsChevronButton = screen.getByLabelText('Expand the time of day settings'); - - expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-expanded', 'false'); - - userEvent.click(timeOfDaySettingsChevronButton); - - expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-expanded', 'true'); - expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-label', 'Collapse the time of day settings'); - - userEvent.click(timeOfDaySettingsChevronButton); - - expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-expanded', 'false'); - expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-label', 'Expand the time of day settings'); - }); - - test('deactivates the time of day coloring when clicking the day night button', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.trackSettings.isTimeOfDayColoringActive = true; - store.view.subjectTrackState.visible = ['123']; - renderSubjectTrackLegend(); - - const timeOfDaySettingsButton = screen.getByLabelText('Deactivate the time of day coloring'); - - expect(timeOfDaySettingsButton).toHaveAttribute('aria-expanded', 'true'); - expect(timeOfDaySettingsButton).toHaveClass('open'); - expect(setIsTimeOfDayColoringActive).not.toHaveBeenCalled(); - - userEvent.click(timeOfDaySettingsButton); - - expect(setIsTimeOfDayColoringActive).toHaveBeenCalledTimes(1); - expect(setIsTimeOfDayColoringActive).toHaveBeenCalledWith(false); - }); - - test('opens and closes the track settings when clicking the gear button', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123']; - renderSubjectTrackLegend(); - - const trackSettingsButton = screen.getByLabelText('Open the track settings'); - - expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'false'); - expect(trackSettingsButton).not.toHaveClass('open'); - - userEvent.click(trackSettingsButton); - - expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'true'); - expect(trackSettingsButton).toHaveAttribute('aria-label', 'Close the track settings'); - expect(trackSettingsButton).toHaveClass('open'); - - userEvent.click(trackSettingsButton); - - expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'false'); - expect(trackSettingsButton).toHaveAttribute('aria-label', 'Open the track settings'); - expect(trackSettingsButton).not.toHaveClass('open'); - }); - - test('closes the track settings from the close button in the menu', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123']; - renderSubjectTrackLegend(); - - const trackSettingsButton = screen.getByLabelText('Open the track settings'); - userEvent.click(trackSettingsButton); - - expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'true'); - expect(trackSettingsButton).toHaveAttribute('aria-label', 'Close the track settings'); - - userEvent.click(screen.getAllByLabelText('Close the track settings')[1]); - - expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'false'); - expect(trackSettingsButton).toHaveAttribute('aria-label', 'Open the track settings'); - }); - - test('clears the tracks when clicking the clear tracks button', () => { - store.view.trackSettings.origin = TRACK_LENGTH_ORIGINS.EVENT_FILTER; - store.view.subjectTrackState.visible = ['123']; - renderSubjectTrackLegend(); - - expect(updateTrackState).not.toHaveBeenCalled(); - - userEvent.click(screen.getByText('Clear Tracks')); - - expect(updateTrackState).toHaveBeenCalledTimes(1); - expect(updateTrackState).toHaveBeenCalledWith({ pinned: [], visible: [] }); - }); -}); diff --git a/src/SubjectTrackLegend/styles.module.scss b/src/SubjectTrackLegend/styles.module.scss index 04f5cb079..efeae6b67 100644 --- a/src/SubjectTrackLegend/styles.module.scss +++ b/src/SubjectTrackLegend/styles.module.scss @@ -1,135 +1,5 @@ -@use 'sass:color'; - -@use '../common/styles/vars/colors'; - -.subjectTrackLegendWrapper { - margin-bottom: 0.5rem; - transform: translateX(calc(100% + 1rem)); - transition: transform 0.3s ease-in-out; - width: 21.25rem; - - &.show { - transform: translateX(0); - } - - .subjectTrackLegend { - align-items: center; - background: white; - border-radius: 0.25rem; - box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25); - padding: 0.5rem; - - .row { - align-items: center; - display: flex; - justify-content: space-between; - - &:not(:last-child) { - margin-bottom: 0.25rem; - } - - .titleWrapper { - align-items: center; - display: flex; - margin-right: 1rem; - - .icon { - height: 1rem; - margin-right: 0.5rem; - min-width: 1.5rem; - } - - .tracksOffIcon { - height: 1.5rem; - margin-right: 0.25rem; - width: 1.5rem; - } - - .subjectTracksListButton { - background: none; - border: none; - color: colors.$light-blue; - padding: 0 0.25rem; - text-decoration: underline; - - &:focus-visible { - outline: 2px solid colors.$bright-blue; - } - - &:hover { - color: color.adjust(colors.$bright-blue, $lightness: -10%); - } - } - - .title { - color: black; - margin: 0; - max-width: 12.5rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - } - - .settingsButton { - background: none; - border: none; - border-radius: .1875rem; - color: colors.$secondary-medium-gray; - padding: 0.25rem 0.5rem; - - &:focus-visible { - color: colors.$bright-blue; - outline: 2px solid colors.$bright-blue; - } - - &:hover { - background: colors.$light-gray-background; - } - - &:not(:last-child) { - margin-right: 0.25rem - } - - &.open { - background: colors.$bright-blue; - color: white; - - &:hover { - background-color: color.adjust(colors.$bright-blue, $lightness: -10%); - } - } - - .icon { - height: 1.25rem; - width: 1.25rem; - } - } - - .pointsOverTime { - color: black; - font-size: 0.875rem; - margin: 0; - margin-right: 1rem; - } - - .clearTracksButton { - background-color: colors.$light-gray-background; - border: none; - border-radius: 0.25rem; - color: colors.$off-black; - font-size: 0.875rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - - &:focus-visible { - outline: 2px solid colors.$bright-blue; - } - - &:hover { - background-color: color.adjust(colors.$light-gray-background, $lightness: -5%); - } - } - } - } +.itemIcon { + height: 1rem; + margin-right: 0.5rem; + min-width: 1.5rem; } diff --git a/src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/index.js b/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.js similarity index 95% rename from src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/index.js rename to src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.js index 7712630a6..080fada57 100644 --- a/src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/index.js +++ b/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.js @@ -89,7 +89,7 @@ const compareTimeZoneParts = (timeZonePartsA, timeZonePartsB) => { const TimeZoneSelect = () => { const dispatch = useDispatch(); - const { i18n, t } = useTranslation('tracks', { keyPrefix: 'subjectTrackLegend.timeOfDaySettings.timeZoneSelect' }); + const { i18n, t } = useTranslation('tracks', { keyPrefix: 'trackLegend.timeOfDaySettings.timeZoneSelect' }); const timeOfDayTimeZone = useSelector((state) => state.view.trackSettings.timeOfDayTimeZone); @@ -143,6 +143,9 @@ const TimeZoneSelect = () => { Option: CustomOption, }} inputId="timeZoneSelect-input" + // The absolute position of this item was hidding it behind other map legeds, attaching the portal to the body + // fixes the issue. + menuPortalTarget={document.querySelector('body')} noOptionsMessage={() => t('noSelectOptionsMessage')} onChange={(newValue) => dispatch(setTimeOfDayTimeZone(newValue.value))} options={options} diff --git a/src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js b/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js similarity index 97% rename from src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js rename to src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js index a5a436b63..ca374eafc 100644 --- a/src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js +++ b/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/index.test.js @@ -13,7 +13,7 @@ jest.mock('../../../ducks/tracks', () => ({ setTimeOfDayTimeZone: jest.fn(), })); -describe('SubjectTrackLegend - TimeOfDaySettings - TimeZoneSelect', () => { +describe('TrackLegend - TimeOfDaySettings - TimeZoneSelect', () => { let setTimeOfDayTimeZoneMock, store; beforeEach(() => { setTimeOfDayTimeZoneMock = jest.fn(() => () => { }); diff --git a/src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/styles.module.scss b/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/styles.module.scss similarity index 50% rename from src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/styles.module.scss rename to src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/styles.module.scss index f45014f25..bde5857db 100644 --- a/src/SubjectTrackLegend/TimeOfDaySettings/TimeZoneSelect/styles.module.scss +++ b/src/TrackLegend/TimeOfDaySettings/TimeZoneSelect/styles.module.scss @@ -47,56 +47,56 @@ } } } + } +} + +.menuList { + overflow-x: hidden; - .menuList { - overflow-x: hidden; + .option { + align-items: center; + display: flex; + padding: 0.25rem 0.5rem; + padding-left: 1.75rem; + + &.focused { + background-color: colors.$semi-light-gray; } - .option { - align-items: center; - display: flex; - padding: 0.25rem 0.5rem; - padding-left: 1.75rem; + &.selected { + background-color: colors.$very-light-blue; + padding-left: 0.5rem; + } - &.focused { - background-color: colors.$semi-light-gray; - } + &:hover { + background-color: colors.$semi-light-gray; + } - &.selected { - background-color: colors.$very-light-blue; - padding-left: 0.5rem; - } + .checkLightIcon { + color: colors.$bright-blue; + margin-right: 0.5rem; + width: 0.75rem; + } - &:hover { - background-color: colors.$semi-light-gray; - } + .labelWrapper { + font-size: 0.75rem; + overflow: hidden; + text-transform: capitalize; - .checkLightIcon { - color: colors.$bright-blue; - margin-right: 0.5rem; - width: 0.75rem; + .label { + color: black; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } - .labelWrapper { - font-size: 0.75rem; + .description { + color: colors.$secondary-medium-gray; + margin: 0; overflow: hidden; - text-transform: capitalize; - - .label { - color: black; - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .description { - color: colors.$secondary-medium-gray; - margin: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } + text-overflow: ellipsis; + white-space: nowrap; } } } diff --git a/src/SubjectTrackLegend/TimeOfDaySettings/index.js b/src/TrackLegend/TimeOfDaySettings/index.js similarity index 97% rename from src/SubjectTrackLegend/TimeOfDaySettings/index.js rename to src/TrackLegend/TimeOfDaySettings/index.js index 76bd1c24a..2b87b18f4 100644 --- a/src/SubjectTrackLegend/TimeOfDaySettings/index.js +++ b/src/TrackLegend/TimeOfDaySettings/index.js @@ -63,7 +63,7 @@ const COLORED_TIME_ITEMS = [ ]; const TimeOfDaySettings = ({ isExpanded, onCollapseTimeOfDaySettings, onExpandTimeOfDaySettings }) => { - const { t } = useTranslation('tracks', { keyPrefix: 'subjectTrackLegend.timeOfDaySettings' }); + const { t } = useTranslation('tracks', { keyPrefix: 'trackLegend.timeOfDaySettings' }); return
    diff --git a/src/SubjectTrackLegend/TimeOfDaySettings/index.test.js b/src/TrackLegend/TimeOfDaySettings/index.test.js similarity index 97% rename from src/SubjectTrackLegend/TimeOfDaySettings/index.test.js rename to src/TrackLegend/TimeOfDaySettings/index.test.js index e23e9945e..67634d342 100644 --- a/src/SubjectTrackLegend/TimeOfDaySettings/index.test.js +++ b/src/TrackLegend/TimeOfDaySettings/index.test.js @@ -7,7 +7,7 @@ import { mockStore } from '../../__test-helpers/MockStore'; import TimeOfDaySettings from '.'; -describe('SubjectTrackLegend - TimeOfDaySettings', () => { +describe('TrackLegend - TimeOfDaySettings', () => { const onCollapseTimeOfDaySettings = jest.fn(); const onExpandTimeOfDaySettings = jest.fn(); diff --git a/src/SubjectTrackLegend/TimeOfDaySettings/styles.module.scss b/src/TrackLegend/TimeOfDaySettings/styles.module.scss similarity index 100% rename from src/SubjectTrackLegend/TimeOfDaySettings/styles.module.scss rename to src/TrackLegend/TimeOfDaySettings/styles.module.scss diff --git a/src/SubjectTrackLegend/TrackSettings/index.js b/src/TrackLegend/TrackSettings/index.js similarity index 97% rename from src/SubjectTrackLegend/TrackSettings/index.js rename to src/TrackLegend/TrackSettings/index.js index b527dd37f..00ad37251 100644 --- a/src/SubjectTrackLegend/TrackSettings/index.js +++ b/src/TrackLegend/TrackSettings/index.js @@ -20,7 +20,7 @@ const MIN_TRACK_LENGTH = 1; const TrackSettings = ({ onClose }) => { const dispatch = useDispatch(); - const { t } = useTranslation('tracks', { keyPrefix: 'subjectTrackLegend.trackSettings' }); + const { t } = useTranslation('tracks', { keyPrefix: 'trackLegend.trackSettings' }); const lowerEventFilterDateRange = useSelector((state) => state.data.eventFilter.filter.date_range.lower); const trackSettings = useSelector((state) => state.view.trackSettings); @@ -46,7 +46,7 @@ const TrackSettings = ({ onClose }) => { }, [dispatch, lengthFromEventFilterLowerRangeToToday, trackSettings.origin]); useEffect(() => { - // If the track length origin is set to a custom length, the track length follows the inputs while they ahve a + // If the track length origin is set to a custom length, the track length follows the inputs while they have a // valid value. if (trackSettings.origin === TRACK_LENGTH_ORIGINS.CUSTOM_LENGTH) { const isCustomLengthValid = (customLength >= MIN_TRACK_LENGTH) diff --git a/src/SubjectTrackLegend/TrackSettings/index.test.js b/src/TrackLegend/TrackSettings/index.test.js similarity index 98% rename from src/SubjectTrackLegend/TrackSettings/index.test.js rename to src/TrackLegend/TrackSettings/index.test.js index 943b3f5ed..5d7ddb5f2 100644 --- a/src/SubjectTrackLegend/TrackSettings/index.test.js +++ b/src/TrackLegend/TrackSettings/index.test.js @@ -15,7 +15,7 @@ jest.mock('../../ducks/tracks', () => ({ setTrackLengthOrigin: jest.fn(), })); -describe('SubjectTrackLegend - TrackSettings', () => { +describe('TrackLegend - TrackSettings', () => { const onClose = jest.fn(); let setTrackLengthMock, setTrackLengthOriginMock, store; diff --git a/src/SubjectTrackLegend/TrackSettings/styles.module.scss b/src/TrackLegend/TrackSettings/styles.module.scss similarity index 100% rename from src/SubjectTrackLegend/TrackSettings/styles.module.scss rename to src/TrackLegend/TrackSettings/styles.module.scss diff --git a/src/TrackLegend/TracksList/index.js b/src/TrackLegend/TracksList/index.js new file mode 100644 index 000000000..1f9403851 --- /dev/null +++ b/src/TrackLegend/TracksList/index.js @@ -0,0 +1,57 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import { ReactComponent as CrossIcon } from '../../common/images/icons/cross.svg'; + +import styles from './styles.module.scss'; + +const TracksItem = ({ item, onRemove }) => { + const { t } = useTranslation('tracks', { keyPrefix: 'trackLegend.tracksList.tracksItem' }); + + return
  • +
    + {item.icon} + +

    {item.title}

    +
    + +
    +

    {item.description}

    + + +
    +
  • ; +}; + +const TracksList = ({ items, itemsName, onClose, onRemoveItemTracks }) => { + const { t } = useTranslation('tracks', { keyPrefix: 'trackLegend.tracksList' }); + + return
    +
    +

    {itemsName}

    + + +
    + +
      + {items.map((item) => )} +
    +
    ; +}; + +export default TracksList; diff --git a/src/TrackLegend/TracksList/index.test.js b/src/TrackLegend/TracksList/index.test.js new file mode 100644 index 000000000..67bbf47a8 --- /dev/null +++ b/src/TrackLegend/TracksList/index.test.js @@ -0,0 +1,131 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from '../../test-utils'; + +import TracksList from '.'; + +describe('TrackLegend - TracksList', () => { + const onClose = jest.fn(); + const onRemoveItemTracks = jest.fn(); + + const renderTracksList = (props) => render(); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('closes the tracks list', () => { + renderTracksList(); + + expect(onClose).not.toHaveBeenCalled(); + + userEvent.click(screen.getByLabelText('Close the list of items')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test('lists all the items', () => { + renderTracksList({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + expect(screen.getAllByRole('listitem')).toHaveLength(2); + }); + + test('shows the item icon', () => { + renderTracksList({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + expect(screen.getByAltText('Item 1 icon')).toHaveAttribute('src', 'icon-1'); + expect(screen.getByAltText('Item 2 icon')).toHaveAttribute('src', 'icon-2'); + }); + + test('shows the item title', () => { + renderTracksList({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + expect(screen.getByText('Item 1 title')).toBeVisible(); + expect(screen.getByText('Item 2 title')).toBeVisible(); + }); + + test('shows the item description', () => { + renderTracksList({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + expect(screen.getByText('Item 1 description')).toBeVisible(); + expect(screen.getByText('Item 2 description')).toBeVisible(); + }); + + test('removes an item from the tracks list', () => { + renderTracksList({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + expect(onRemoveItemTracks).not.toHaveBeenCalled(); + + userEvent.click(screen.getByLabelText('Remove Item 2 title')); + + expect(onRemoveItemTracks).toHaveBeenCalledTimes(1); + expect(onRemoveItemTracks).toHaveBeenCalledWith('2'); + }); +}); diff --git a/src/SubjectTrackLegend/SubjectTracksList/styles.module.scss b/src/TrackLegend/TracksList/styles.module.scss similarity index 92% rename from src/SubjectTrackLegend/SubjectTracksList/styles.module.scss rename to src/TrackLegend/TracksList/styles.module.scss index 511f1d90b..1388ef103 100644 --- a/src/SubjectTrackLegend/SubjectTracksList/styles.module.scss +++ b/src/TrackLegend/TracksList/styles.module.scss @@ -2,7 +2,7 @@ @use '../../common/styles/vars/colors'; -.subjectTracksList { +.tracksList { backdrop-filter: blur(2px); background: rgb(white, 90%); border-radius: 0.25rem; @@ -19,6 +19,7 @@ .title { color: black; margin: 0; + text-transform: capitalize; } .closeButton { @@ -49,7 +50,7 @@ max-height: 12.5rem; overflow-y: scroll; - .subjectTracksItem { + .tracksItem { align-items: center; background: white; border-radius: 0.25rem; @@ -63,12 +64,6 @@ display: flex; margin-right: 0.25rem; - .icon { - height: 1rem; - margin-right: 0.5rem; - max-width: 1rem; - } - .title { color: black; font-size: 0.875rem; @@ -85,7 +80,7 @@ display: flex; margin-right: 0.25rem; - .pointCount { + .description { color: colors.$secondary-medium-gray; font-size: 0.75rem; margin: 0; diff --git a/src/TrackLegend/index.js b/src/TrackLegend/index.js new file mode 100644 index 000000000..e4e6838f4 --- /dev/null +++ b/src/TrackLegend/index.js @@ -0,0 +1,204 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import Collapse from 'react-bootstrap/Collapse'; +import { useDispatch, useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; + +import { ReactComponent as DayNightIcon } from '../common/images/icons/day-night.svg'; +import { ReactComponent as GearIcon } from '../common/images/icons/gear.svg'; +import { ReactComponent as TracksOffIcon } from '../common/images/icons/tracks_off.svg'; + +import { BOOTSTRAP_DEFAULTS, FEATURE_FLAG_LABELS } from '../constants'; +import { setIsTimeOfDayColoringActive } from '../ducks/tracks'; +import { useFeatureFlag } from '../hooks'; + +import DelayedUnmount from '../DelayedUnmount'; +import TimeOfDaySettings from './TimeOfDaySettings'; +import TrackSettings from './TrackSettings'; +import TracksList from './TracksList'; + +import styles from './styles.module.scss'; + +const MENUS = { + TIME_OF_DAY_SETTINGS: 'TIME_OF_DAY_SETTINGS', + TRACK_SETTINGS: 'TRACK_SETTINGS', + TRACKS_LIST: 'TRACKS_LIST', +}; + +const TrackLegend = ({ + description, + items, + itemsName, + onClickClearTracks, + onRemoveItemTracks, + showTimeOfDaySettings = true, + showTrackSettings = true, +}) => { + const dispatch = useDispatch(); + const { t } = useTranslation('tracks', { keyPrefix: 'trackLegend' }); + + const timeOfDayTrackingEnabled = useFeatureFlag(FEATURE_FLAG_LABELS.TIME_OF_DAY_TRACKING); + + const isTimeOfDayColoringActive = useSelector((state) => state.view.trackSettings.isTimeOfDayColoringActive); + + // This variable tracks if a menu is expanded, which one it is. There can be only one menu expanded at a time. + const [expandedMenu, setExpandedMenu] = useState(null); + // The component starts hidden so the slide in transition effect kicks. + const [show, setShow] = useState(false); + + const isTimeOfDaySettingsExpanded = expandedMenu === MENUS.TIME_OF_DAY_SETTINGS; + const isTrackSettingsExpanded = expandedMenu === MENUS.TRACK_SETTINGS; + const isTracksListExpanded = expandedMenu === MENUS.TRACKS_LIST; + + const onCollapseMenu = () => setExpandedMenu(null); + + const onExpandMenu = (menu) => { + if (!expandedMenu) { + // If no menu is currently expanded, we just expand the requested one. + setExpandedMenu(menu); + } else { + // If there is a menu expanded, we first collapse it and then expand the new one. + onCollapseMenu(); + setTimeout(() => setExpandedMenu(menu), BOOTSTRAP_DEFAULTS.COLLAPSE_TRANSITION_TIME); + } + }; + + const onActivateTimeOfDayColoring = () => { + // When activating the time of day coloring, we also expand its menu. + dispatch(setIsTimeOfDayColoringActive(true)); + onExpandMenu(MENUS.TIME_OF_DAY_SETTINGS); + }; + + const onDeactivateTimeOfDayColoring = useCallback(() => { + dispatch(setIsTimeOfDayColoringActive(false)); + + // When deactivating the time of day coloring, we collapse its menu if it was expanded. + if (isTimeOfDaySettingsExpanded) { + onCollapseMenu(); + } + }, [dispatch, isTimeOfDaySettingsExpanded]); + + useEffect(() => { + // If there were multiple tracked items, the user could have expanded the tracks list menu. If then the user + // removes all tracked items but one, we collapse it automatically. + if (items.length === 1 && isTracksListExpanded) { + onCollapseMenu(); + } + }, [dispatch, isTracksListExpanded, items.length]); + + useEffect(() => { + // If there are tracked items, show the legend. If not, hide it. The state variable is used so the transition + // effects kick. + if (!show && items.length > 0) { + setShow(true); + } else if (show && items.length === 0) { + setShow(false); + } + }, [items.length, show]); + + return
    +
    +
    +
    + {items.length === 1 + ? <> + {items[0].icon} + +

    {items[0].title}

    + + : <> + + + + } +
    + +
    + {timeOfDayTrackingEnabled && showTimeOfDaySettings && } + + {showTrackSettings && } +
    +
    + +
    +

    {description}

    + + +
    +
    + + +
    + +
    +
    + + {showTrackSettings && +
    + +
    +
    } + + {timeOfDayTrackingEnabled && showTimeOfDaySettings && +
    + onExpandMenu(MENUS.TIME_OF_DAY_SETTINGS)} + /> +
    +
    } +
    ; +}; + +// Wrap the component with a delayed unmount so the slide out transition ends before unmounting. +const TrackLegendDelayedUnmount = ({ items, ...otherProps }) => 0} + > + +; + +export default TrackLegendDelayedUnmount; diff --git a/src/TrackLegend/index.test.js b/src/TrackLegend/index.test.js new file mode 100644 index 000000000..b6a8412e0 --- /dev/null +++ b/src/TrackLegend/index.test.js @@ -0,0 +1,397 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import userEvent from '@testing-library/user-event'; + +import { render, screen, within } from '../test-utils'; +import { mockStore } from '../__test-helpers/MockStore'; +import { setIsTimeOfDayColoringActive, TRACK_LENGTH_ORIGINS } from '../ducks/tracks'; + +import TrackLegend from '.'; + +jest.mock('../ducks/tracks', () => ({ + ...jest.requireActual('../ducks/tracks'), + setIsTimeOfDayColoringActive: jest.fn(), +})); + +jest.mock('../hooks', () => ({ + ...jest.requireActual('../hooks'), + useFeatureFlag: () => true, +})); + +describe('TrackLegend', () => { + const onClickClearTracks = jest.fn(); + const onRemoveItemTracks = jest.fn(); + + let setIsTimeOfDayColoringActiveMock, store; + beforeEach(() => { + setIsTimeOfDayColoringActiveMock = jest.fn(() => () => {}); + setIsTimeOfDayColoringActive.mockImplementation(setIsTimeOfDayColoringActiveMock); + + store = { + data: { + eventFilter: { + filter: { + date_range: { + lower: '2020-01-01T06:00:00.000Z', + }, + }, + }, + }, + view: { + trackSettings: { + isTimeOfDayColoringActive: false, + length: 21, + origin: TRACK_LENGTH_ORIGINS.CUSTOM_LENGTH, + timeOfDayTimeZone: null, + }, + }, + }; + }); + + const renderTrackLegend = (props, overrideStore) => render( + + + + ); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('shows the track legend if there is at least one item', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + expect(screen.getByTestId('trackLegend')).toHaveClass('show'); + }); + + test('does not show the track legend if there are no items', () => { + renderTrackLegend(); + + expect(screen.queryByTestId('trackLegend')).toBeNull(); + }); + + test('shows the icon and title of the item if there is only one item', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + const titleWrapper = screen.getByTestId('trackLegend-titleWrapper'); + + expect(within(titleWrapper).getByAltText('Item icon')).toHaveAttribute('src', 'icon'); + expect(titleWrapper).toHaveTextContent('Item title'); + }); + + test('shows the tracks icon and a button with the amount of items if there are zero or multiple items', () => { + renderTrackLegend({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + const titleWrapper = screen.getByTestId('trackLegend-titleWrapper'); + + expect(within(titleWrapper).getByText('tracks_off.svg')).toBeVisible(); + expect(titleWrapper).toHaveTextContent('2 items'); + }); + + test('opens and closes the tracks list when clicking the button in the title', () => { + renderTrackLegend({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + const tracksListButton = screen.getByLabelText('Open the list of items'); + + expect(tracksListButton).toHaveAttribute('aria-expanded', 'false'); + + userEvent.click(tracksListButton); + + expect(tracksListButton).toHaveAttribute('aria-expanded', 'true'); + expect(tracksListButton).toHaveAttribute('aria-label', 'Close the list of items'); + + userEvent.click(tracksListButton); + + expect(tracksListButton).toHaveAttribute('aria-expanded', 'false'); + expect(tracksListButton).toHaveAttribute('aria-label', 'Open the list of items'); + }); + + test('closes the tracks list from the close button in the menu', () => { + renderTrackLegend({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + const tracksListButton = screen.getByLabelText('Open the list of items'); + userEvent.click(tracksListButton); + + expect(tracksListButton).toHaveAttribute('aria-expanded', 'true'); + expect(tracksListButton).toHaveAttribute('aria-label', 'Close the list of items'); + + userEvent.click(screen.getAllByLabelText('Close the list of items')[1]); + + expect(tracksListButton).toHaveAttribute('aria-expanded', 'false'); + expect(tracksListButton).toHaveAttribute('aria-label', 'Open the list of items'); + }); + + test('removes the tracks of an item from the tracks list', () => { + renderTrackLegend({ + items: [{ + description: 'Item 1 description', + icon: Item 1 icon, + id: '1', + title: 'Item 1 title', + }, { + description: 'Item 2 description', + icon: Item 2 icon, + id: '2', + title: 'Item 2 title', + }], + }); + + userEvent.click(screen.getByLabelText('Open the list of items')); + + expect(onRemoveItemTracks).not.toHaveBeenCalled(); + + userEvent.click(screen.getByLabelText('Remove Item 2 title')); + + expect(onRemoveItemTracks).toHaveBeenCalledTimes(1); + expect(onRemoveItemTracks).toHaveBeenCalledWith('2'); + }); + + test('doest not show the time of day settings button', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + showTimeOfDaySettings: false, + }); + + expect(screen.queryByLabelText('Activate the time of day coloring')).toBeNull(); + }); + + test('shows the time of day settings button', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + expect(screen.getByLabelText('Activate the time of day coloring')).toBeVisible(); + }); + + test('activates the time of day coloring when clicking the time of day settings button', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + const timeOfDaySettingsButton = screen.getByLabelText('Activate the time of day coloring'); + + expect(timeOfDaySettingsButton).toHaveAttribute('aria-expanded', 'false'); + expect(timeOfDaySettingsButton).not.toHaveClass('open'); + expect(setIsTimeOfDayColoringActive).not.toHaveBeenCalled(); + + userEvent.click(timeOfDaySettingsButton); + + expect(setIsTimeOfDayColoringActive).toHaveBeenCalledTimes(1); + expect(setIsTimeOfDayColoringActive).toHaveBeenCalledWith(true); + }); + + test('expands and collapses the time of day settings menu when clicking the chevron', () => { + store.view.trackSettings.isTimeOfDayColoringActive = true; + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + const timeOfDaySettingsChevronButton = screen.getByLabelText('Expand the time of day settings'); + + expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-expanded', 'false'); + + userEvent.click(timeOfDaySettingsChevronButton); + + expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-expanded', 'true'); + expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-label', 'Collapse the time of day settings'); + + userEvent.click(timeOfDaySettingsChevronButton); + + expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-expanded', 'false'); + expect(timeOfDaySettingsChevronButton).toHaveAttribute('aria-label', 'Expand the time of day settings'); + }); + + test('deactivates the time of day coloring when clicking the day night button', () => { + store.view.trackSettings.isTimeOfDayColoringActive = true; + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + const timeOfDaySettingsButton = screen.getByLabelText('Deactivate the time of day coloring'); + + expect(timeOfDaySettingsButton).toHaveAttribute('aria-expanded', 'true'); + expect(timeOfDaySettingsButton).toHaveClass('open'); + expect(setIsTimeOfDayColoringActive).not.toHaveBeenCalled(); + + userEvent.click(timeOfDaySettingsButton); + + expect(setIsTimeOfDayColoringActive).toHaveBeenCalledTimes(1); + expect(setIsTimeOfDayColoringActive).toHaveBeenCalledWith(false); + }); + + test('doest not show the track settings button', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + showTrackSettings: false, + }); + + expect(screen.queryByLabelText('Open the track settings')).toBeNull(); + }); + + test('shows the track settings button', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + expect(screen.getByLabelText('Open the track settings')).toBeVisible(); + }); + + test('opens and closes the track settings when clicking the gear button', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + const trackSettingsButton = screen.getByLabelText('Open the track settings'); + + expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'false'); + expect(trackSettingsButton).not.toHaveClass('open'); + + userEvent.click(trackSettingsButton); + + expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'true'); + expect(trackSettingsButton).toHaveAttribute('aria-label', 'Close the track settings'); + expect(trackSettingsButton).toHaveClass('open'); + + userEvent.click(trackSettingsButton); + + expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'false'); + expect(trackSettingsButton).toHaveAttribute('aria-label', 'Open the track settings'); + expect(trackSettingsButton).not.toHaveClass('open'); + }); + + test('closes the track settings from the close button in the menu', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + const trackSettingsButton = screen.getByLabelText('Open the track settings'); + userEvent.click(trackSettingsButton); + + expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'true'); + expect(trackSettingsButton).toHaveAttribute('aria-label', 'Close the track settings'); + + userEvent.click(screen.getAllByLabelText('Close the track settings')[1]); + + expect(trackSettingsButton).toHaveAttribute('aria-expanded', 'false'); + expect(trackSettingsButton).toHaveAttribute('aria-label', 'Open the track settings'); + }); + + test('clears the tracks when clicking the clear tracks button', () => { + renderTrackLegend({ + items: [{ + description: 'Item description', + icon: Item icon, + id: 'id', + title: 'Item title', + }], + }); + + expect(onClickClearTracks).not.toHaveBeenCalled(); + + userEvent.click(screen.getByText('Clear Tracks')); + + expect(onClickClearTracks).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/TrackLegend/styles.module.scss b/src/TrackLegend/styles.module.scss new file mode 100644 index 000000000..fed51d489 --- /dev/null +++ b/src/TrackLegend/styles.module.scss @@ -0,0 +1,129 @@ +@use 'sass:color'; + +@use '../common/styles/vars/colors'; + +.trackLegendWrapper { + margin-bottom: 0.5rem; + transform: translateX(calc(100% + 1rem)); + transition: transform 0.3s ease-in-out; + width: 21.25rem; + + &.show { + transform: translateX(0); + } + + .trackLegend { + align-items: center; + background: white; + border-radius: 0.25rem; + box-shadow: 0 4px 4px 0 rgba(0, 0, 0, 0.25); + padding: 0.5rem; + + .row { + align-items: center; + display: flex; + justify-content: space-between; + + &:not(:last-child) { + margin-bottom: 0.25rem; + } + + .titleWrapper { + align-items: center; + display: flex; + margin-right: 1rem; + + .tracksOffIcon { + height: 1.5rem; + margin-right: 0.25rem; + width: 1.5rem; + } + + .tracksListButton { + background: none; + border: none; + color: colors.$light-blue; + padding: 0 0.25rem; + text-decoration: underline; + + &:focus-visible { + outline: 2px solid colors.$bright-blue; + } + + &:hover { + color: color.adjust(colors.$bright-blue, $lightness: -10%); + } + } + + .title { + color: black; + margin: 0; + max-width: 12.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .settingsButton { + background: none; + border: none; + border-radius: .1875rem; + color: colors.$secondary-medium-gray; + padding: 0.25rem 0.5rem; + + &:focus-visible { + color: colors.$bright-blue; + outline: 2px solid colors.$bright-blue; + } + + &:hover { + background: colors.$light-gray-background; + } + + &:not(:last-child) { + margin-right: 0.25rem + } + + &.open { + background: colors.$bright-blue; + color: white; + + &:hover { + background-color: color.adjust(colors.$bright-blue, $lightness: -10%); + } + } + + .icon { + height: 1.25rem; + width: 1.25rem; + } + } + + .pointsOverTime { + color: black; + font-size: 0.875rem; + margin: 0; + margin-right: 1rem; + } + + .clearTracksButton { + background-color: colors.$light-gray-background; + border: none; + border-radius: 0.25rem; + color: colors.$off-black; + font-size: 0.875rem; + font-weight: 500; + padding: 0.25rem 0.5rem; + + &:focus-visible { + outline: 2px solid colors.$bright-blue; + } + + &:hover { + background-color: color.adjust(colors.$light-gray-background, $lightness: -5%); + } + } + } + } +} diff --git a/src/TracksLayer/index.js b/src/TracksLayer/index.js index 5710eb181..8ce613511 100644 --- a/src/TracksLayer/index.js +++ b/src/TracksLayer/index.js @@ -5,7 +5,7 @@ import { useSelector } from 'react-redux'; import { addMapImage } from '../utils/map'; import { MAP_LAYERS_CATEGORY, trackEventFactory } from '../utils/analytics'; import { MapContext } from '../App'; -import { visibleTrackDataWithPatrolAwareness } from '../selectors/patrols'; +import { selectSubjectTracksWithPatrolTrackShownFlag } from '../selectors/patrols'; import Arrow from '../common/images/icons/track-arrow.svg'; import TrackLayer from './track'; @@ -17,7 +17,7 @@ const mapLayerTracker = trackEventFactory(MAP_LAYERS_CATEGORY); const TracksLayer = ({ onPointClick, showTimepoints }) => { const map = useContext(MapContext); - const trackData = useSelector(visibleTrackDataWithPatrolAwareness); + const subjectTracksWithPatrolTrackShownFlag = useSelector(selectSubjectTracksWithPatrolTrackShownFlag); const onTimepointClick = useCallback((event) => { const layer = map.queryRenderedFeatures(event.point) @@ -33,13 +33,15 @@ const TracksLayer = ({ onPointClick, showTimepoints }) => { } }, [map]); - return trackData.length > 0 ? trackData.map((data) => ) : null; + return subjectTracksWithPatrolTrackShownFlag.length > 0 + ? subjectTracksWithPatrolTrackShownFlag.map((subjectTracks) => ) + : null; }; TracksLayer.defaultProps = { diff --git a/src/ducks/patrols.js b/src/ducks/patrols.js index a42ae897e..c6dd5e5e4 100644 --- a/src/ducks/patrols.js +++ b/src/ducks/patrols.js @@ -3,7 +3,6 @@ import merge from 'lodash/merge'; import { API_URL } from '../constants'; -import globallyResettableReducer from '../reducers/global-resettable'; import { calcPatrolFilterForRequest/* , validatePatrolAgainstCurrentPatrolFilter */ } from '../utils/patrol-filter'; @@ -262,45 +261,6 @@ export const uploadPatrolFile = (event_id, file, onUploadProgress = (event) => c }); }; -export const INITIAL_PATROLS_STATE = { - count: null, - next: null, - previous: null, - results: [], -}; - -const patrolsReducer = (state = INITIAL_PATROLS_STATE, action) => { - const { type, payload } = action; - - if (type === FETCH_PATROLS_SUCCESS) { - return { - ...payload, - results: payload.results.map(p => p.id), - }; - } - - - if (type === CREATE_PATROL_REALTIME) { - const match = state.results.includes(payload.id); - - if (!match) { - return { - ...state, - results: [payload.id, ...state.results], - }; - } - } - - if (type === REMOVE_PATROL_BY_ID) { - return { - ...state, - results: state.results.filter(id => id !== payload), - }; - } - - return state; -}; - // patrol store const INITIAL_STORE_STATE = {}; export const patrolStoreReducer = (state = INITIAL_STORE_STATE, { type, payload }) => { @@ -337,9 +297,6 @@ export const patrolStoreReducer = (state = INITIAL_STORE_STATE, { type, payload return state; }; -export default globallyResettableReducer(patrolsReducer, INITIAL_PATROLS_STATE); - - const INITIAL_PATROL_TRACKS_STATE = { pinned: [], visible: [], diff --git a/src/hooks/usePatrol/index.js b/src/hooks/usePatrol/index.js index 6bed47a93..7a50fff41 100644 --- a/src/hooks/usePatrol/index.js +++ b/src/hooks/usePatrol/index.js @@ -19,17 +19,14 @@ import { patrolStateDetailsStartTime, } from '../../utils/patrols'; -import { createPatrolDataSelector } from '../../selectors/patrols'; +import { selectPatrolData } from '../../selectors/patrols'; import { PATROL_API_STATES, PATROL_UI_STATES } from '../../constants'; import { updatePatrol } from '../../ducks/patrols'; -const usePatrol = (patrolFromProps) => { +const usePatrol = (patrol) => { const dispatch = useDispatch(); - const getDataForPatrolFromProps = useMemo(createPatrolDataSelector, []); - const patrolFromPropsObject = useMemo(() => ({ patrol: patrolFromProps }), [patrolFromProps]); - - const patrolData = useSelector((state) => getDataForPatrolFromProps(state, patrolFromPropsObject)); + const patrolData = useSelector((state) => selectPatrolData(state, patrol)); const patrolTrackState = useSelector(state => state?.view?.patrolTrackState); const trackState = useSelector(state => state?.view?.subjectTrackState); @@ -99,12 +96,12 @@ const usePatrol = (patrolFromProps) => { }, [patrolData.patrol]); const onPatrolChange = useCallback((value) => { - const merged = merge(patrolFromProps, value); + const merged = merge(patrol, value); const payload = { ...merged }; delete payload.updates; dispatch(updatePatrol(payload)); - }, [dispatch, patrolFromProps]); + }, [dispatch, patrol]); const restorePatrol = useCallback(() => { onPatrolChange({ state: PATROL_API_STATES.OPEN, patrol_segments: [{ time_range: { end_time: null } }] }); diff --git a/src/i18n.js b/src/i18n.js index 09746a0ca..a1e5cec01 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -50,12 +50,12 @@ i18n backendOptions: [{ expirationTime: 24 * 60 * 60 * 1000 * 7, versions: { - es: 'v1.9', - 'en-US': 'v1.9', - fr: 'v1.9', - 'ne-NP': 'v1.9', - pt: 'v1.9', - sw: 'v1.9' + es: 'v1.10', + 'en-US': 'v1.10', + fr: 'v1.10', + 'ne-NP': 'v1.10', + pt: 'v1.10', + sw: 'v1.10' } }] } diff --git a/src/reducers/index.js b/src/reducers/index.js index 8720caba1..1f1155e9c 100644 --- a/src/reducers/index.js +++ b/src/reducers/index.js @@ -9,7 +9,7 @@ import eventStoreReducer, { mapEventsReducer, eventFeedReducer, incidentFeedRedu import eventCategoriesReducer from '../ducks/event-categories'; import eventTypesReducer from '../ducks/event-types'; import observationsReducer from '../ducks/observations'; -import patrolsReducer, { patrolStoreReducer, patrolTracksReducer } from '../ducks/patrols'; +import { patrolStoreReducer, patrolTracksReducer } from '../ducks/patrols'; import patrolTypesReducer from '../ducks/patrol-types'; import patrolFilterReducer, { persistenceConfig as patrolFilterPersistenceConfig } from '../ducks/patrol-filter'; import mapsReducer, { homeMapReducer } from '../ducks/maps'; @@ -91,7 +91,6 @@ const rootReducer = combineReducers({ masterRequestCancelToken: masterRequestTokenReducer, recentEventDataReceived: recentEventDataReceivedReducer, observations: observationsReducer, - patrols: patrolsReducer, patrolTypes: patrolTypesReducer, reports: externalReportingReducer, subjectGroups: subjectGroupsReducer, diff --git a/src/selectors/patrols.js b/src/selectors/patrols.js deleted file mode 100644 index b17ffddfe..000000000 --- a/src/selectors/patrols.js +++ /dev/null @@ -1,155 +0,0 @@ -import uniq from 'lodash/uniq'; -import { isAfter } from 'date-fns'; -import { createSelector } from 'reselect'; - -import { getTimeSliderState } from './'; -import { getSubjectStore } from './subjects'; - -import { selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod } from './tracks'; -import { getLeaderForPatrol, extractPatrolPointsFromTrackData, drawLinesBetweenPatrolTrackAndPatrolPoints, patrolStateAllowsTrackDisplay } from '../utils/patrols'; -import { trackHasDataWithinTimeRange, trimTrackDataToTimeRange } from '../utils/tracks'; - -const selectTracks = (state) => state.data.tracks; - -export const getPatrolStore = ({ data: { patrolStore } }) => patrolStore; -const getPatrols = ({ data: { patrols } }) => patrols; -const getPatrolFromProps = (_state, { patrol }) => patrol; -export const getTrackForPatrolFromProps = ({ data: { tracks } }, { patrol }) => - !!patrol.patrol_segments - && !!patrol.patrol_segments.length - && !!patrol.patrol_segments[0].leader - && tracks[patrol.patrol_segments[0].leader.id]; -export const getLeaderForPatrolFromProps = (_store, { patrol }) => { - const [firstLeg] = patrol.patrol_segments; - const { leader } = firstLeg; - if (!leader) return null; - - return leader; -}; -const getPatrolTrackState = ({ view: { patrolTrackState } }) => uniq([...patrolTrackState.visible, ...patrolTrackState.pinned]); - - -export const getPatrolList = createSelector( - [getPatrolStore, getPatrols], - (store, patrols) => ({ - ...patrols, - results: patrols.results.map(id => store[id]).filter(item => !!item), - }) -); - -export const assemblePatrolDataForPatrol = (patrol, leader, trackData, timeSliderState) => { - const [firstLeg] = patrol.patrol_segments; - const timeRange = !!firstLeg && firstLeg.time_range; - const hasTrackDataWithinPatrolWindow = !!trackData && patrolStateAllowsTrackDisplay(patrol) && trackHasDataWithinTimeRange(trackData, timeRange.start_time, timeRange.end_time); - - const trimmed = !!hasTrackDataWithinPatrolWindow && trimTrackDataToTimeRange(trackData, timeRange.start_time, timeRange.end_time); - - const patrolData = { - patrol, leader, trackData: trimmed || null, - }; - - return { - ...patrolData, - startStopGeometries: patrolData.trackData ? generatePatrolStartStopData(patrolData, trackData, timeSliderState) : null, - }; -}; - -const generatePatrolStartStopData = (patrolData, rawTrack, timeSliderState) => { - const points = extractPatrolPointsFromTrackData(patrolData, rawTrack); - - const timeSliderActiveWithVirtualDate = (timeSliderState.active && timeSliderState.virtualDate); - - if (!points) return null; - - if (points.start_location - && points.start_location.properties.time) { - const startDate = new Date(points.start_location.properties.time); - if (timeSliderActiveWithVirtualDate && isAfter(startDate, new Date(timeSliderState.virtualDate))) { - delete points.start_location; - } - } - if (points.end_location - && points.end_location.properties.time) { - const endDate = new Date(points.end_location.properties.time); - - if (timeSliderActiveWithVirtualDate && isAfter(endDate, new Date(timeSliderState.virtualDate))) { - delete points.end_location; - } - } - - if (!points.start_location && !points.end_location) return null; - - const lines = drawLinesBetweenPatrolTrackAndPatrolPoints(points, patrolData.trackData); - - return { - points, - lines, - }; -}; - -export const createPatrolDataSelector = () => createSelector( - [getPatrolFromProps, getLeaderForPatrolFromProps, getTrackForPatrolFromProps, getTimeSliderState], - assemblePatrolDataForPatrol, -); - -export const patrolsWithTrackShown = createSelector( - [getPatrolTrackState, getPatrolStore], - (patrolTrackState, patrolStore) => patrolTrackState - .map(id => patrolStore[id]) - .filter(p => !!p) - .filter(patrolStateAllowsTrackDisplay) -); - - -export const visibleTrackedPatrolData = createSelector( - [(...args) => selectTracks(...args), patrolsWithTrackShown, getSubjectStore, (...args) => getTimeSliderState(...args)], - (tracks, patrols, subjectStore, timeSliderState) => { - - return patrols - .map((patrol) => { - const leader = getLeaderForPatrol(patrol, subjectStore); - const trackData = !!leader && tracks[leader.id]; - - return assemblePatrolDataForPatrol(patrol, leader, trackData, timeSliderState); - }); - } -); - -export const visibleTrackDataWithPatrolAwareness = createSelector( - [(...args) => selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod(...args), patrolsWithTrackShown], - (trackData, patrolsWithTrackShown) => trackData.map((t) => { - const trackSubjectId = t.track.features[0].properties.id; - const hasPatrolTrackMatch = patrolsWithTrackShown.some(p => - p.patrol_segments - && !!p.patrol_segments.length - && p.patrol_segments[0].leader - && p.patrol_segments[0].leader.id === trackSubjectId - ); - return { - ...t, - patrolTrackShown: hasPatrolTrackMatch, - }; - }), -); - -const getPatrolLeaderSchema = ({ data: { patrolLeaderSchema } }) => patrolLeaderSchema; - -const getPatrolLeaders = createSelector([getPatrolLeaderSchema], (patrolLeaderSchema) => - patrolLeaderSchema?.trackedbySchema?.properties?.leader?.enum_ext?.map(({ value }) => value) ?? null -); - -export const getPatrolLeadersWithLocation = createSelector( - [getPatrolLeaders, getSubjectStore], - (patrolLeaders, subjects) => !Array.isArray(patrolLeaders) ? null : patrolLeaders.map((patrolLeader) => { - const { id } = patrolLeader; - const subject = subjects[id]; - if (!patrolLeader.last_position && !patrolLeader.last_position_status && subject?.last_position && subject?.last_position_status){ - return { - ...patrolLeader, - last_position: subject.last_position, - last_position_status: subject.last_position_status - }; - } - return patrolLeader; - }) -); \ No newline at end of file diff --git a/src/selectors/patrols/index.js b/src/selectors/patrols/index.js new file mode 100644 index 000000000..00d998006 --- /dev/null +++ b/src/selectors/patrols/index.js @@ -0,0 +1,143 @@ +import { createSelector } from 'reselect'; +import { isAfter } from 'date-fns'; +import uniq from 'lodash/uniq'; + +import { + drawLinesBetweenPatrolTrackAndPatrolPoints, + extractPatrolPointsFromTrackData, + patrolStateAllowsTrackDisplay, +} from '../../utils/patrols'; +import { selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod } from '../tracks'; +import { trackHasDataWithinTimeRange, trimTrackDataToTimeRange } from '../../utils/tracks'; + +const buildPatrolData = (patrol, timeSliderState, tracks) => { + // Get the patrol leader from the first patrol segment and its tracks. + const patrolLeader = patrol.patrol_segments[0].leader || null; + const patrolLeaderTracks = tracks[patrolLeader?.id] || null; + + // Then calculate the tracks by trimming the patrol leader tracks to the patrol time range. + const timeRange = patrol.patrol_segments[0].time_range; + const patrolLeaderTracksTrimmedToPatrolTimeRange = !!patrolLeaderTracks + && patrolStateAllowsTrackDisplay(patrol) + && trackHasDataWithinTimeRange(patrolLeaderTracks, timeRange.start_time, timeRange.end_time) + && trimTrackDataToTimeRange(patrolLeaderTracks, timeRange.start_time, timeRange.end_time); + const patrolTrackData = patrolLeaderTracksTrimmedToPatrolTimeRange || null; + + // Create the patrol data object with what we have so far. + const patrolData = { leader: patrolLeader, patrol, trackData: patrolTrackData }; + + if (patrolData.trackData) { + // If the patrol has track data, we now calculate its start and stop geometries. First we extract the patrol + // points. + const patrolPoints = extractPatrolPointsFromTrackData(patrolData, patrolLeaderTracks); + + if (patrolPoints) { + const isTimeSliderActiveWithAVirtualDate = timeSliderState.active && timeSliderState.virtualDate; + if (isTimeSliderActiveWithAVirtualDate) { + // Adjust the patrol points to the time slider virtual date. + const timeSliderVirtualDate = new Date(timeSliderState.virtualDate); + + if (patrolPoints.start_location?.properties?.time) { + const patrolStartDate = new Date(patrolPoints.start_location.properties.time); + if (isAfter(patrolStartDate, timeSliderVirtualDate)) { + delete patrolPoints.start_location; + } + } + + if (patrolPoints.end_location?.properties?.time) { + const patrolEndDate = new Date(patrolPoints.end_location.properties.time); + if (isTimeSliderActiveWithAVirtualDate && isAfter(patrolEndDate, timeSliderVirtualDate)) { + delete patrolPoints.end_location; + } + } + } + + if (patrolPoints.start_location || patrolPoints.end_location) { + // If there are either a start or an end location, we calculate the lines and add the start and stop geometries + // to the patrol data object. + patrolData.startStopGeometries = { + points: patrolPoints, + lines: drawLinesBetweenPatrolTrackAndPatrolPoints(patrolPoints, patrolData.trackData), + }; + } + } + } + + return patrolData; +}; + +const selectPatrolLeaderSchema = (state) => state.data.patrolLeaderSchema; +const selectPatrolStore = (state) => state.data.patrolStore; +const selectPatrolTrackState = (state) => state.view.patrolTrackState; +const selectSubjectStore = (state) => state.data.subjectStore; +const selectTimeSliderState = (state) => state.view.timeSliderState; +const selectTracks = (state) => state.data.tracks; + +const selectPatrolLeaders = createSelector( + [selectPatrolLeaderSchema], + (patrolLeaderSchema) => patrolLeaderSchema?.trackedbySchema?.properties?.leader?.enum_ext?.map( + // Map the patrol leaders from the patrol leader schema. + (leader) => leader.value + ) || null +); + +const selectVisibleAndPinnedPatrolTracks = createSelector( + [selectPatrolTrackState], + (patrolTrackState) => uniq([...patrolTrackState.visible, ...patrolTrackState.pinned]) +); + +export const selectPatrolData = createSelector( + [selectTimeSliderState, selectTracks, (_, patrol) => patrol], + (timeSliderState, tracks, patrol) => buildPatrolData(patrol, timeSliderState, tracks) +); + +export const selectPatrolLeadersWithLastPosition = createSelector( + [selectPatrolLeaders, selectSubjectStore], + (patrolLeaders, subjectStore) => patrolLeaders ? patrolLeaders.map((patrolLeader) => { + // Map each patrol leader to its subject. + const patrolLeaderSubject = subjectStore[patrolLeader.id]; + if (!patrolLeader.last_position + && !patrolLeader.last_position_status + && patrolLeaderSubject?.last_position + && patrolLeaderSubject?.last_position_status) { + // If the patrol leader misses the last position properties, fill them from the subject object. + return { + ...patrolLeader, + last_position: patrolLeaderSubject.last_position, + last_position_status: patrolLeaderSubject.last_position_status, + }; + } + return patrolLeader; + }) : null +); + +export const selectPatrolsWithTracks = createSelector( + [selectPatrolStore, selectVisibleAndPinnedPatrolTracks], + (patrolStore, visibleAndPinnedPatrolTracks) => visibleAndPinnedPatrolTracks + // Map the ids of the patrols with visible and pinned tracks to their patrol object. + .map((patrolId) => patrolStore[patrolId]) + // Filter just the defined patrols that allow track display. + .filter((patrol) => !!patrol && patrolStateAllowsTrackDisplay(patrol)) +); + +export const selectPatrolsWithTracksData = createSelector( + [selectPatrolsWithTracks, selectTimeSliderState, selectTracks], + (patrolsWithTracks, timeSliderState, tracks) => patrolsWithTracks.map( + // Build the patrol data for each patrol with tracks. + (patrol) => buildPatrolData(patrol, timeSliderState, tracks) + ) +); + +export const selectSubjectTracksWithPatrolTrackShownFlag = createSelector( + [selectPatrolsWithTracks, selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod], + (patrolsWithTracks, subjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod) => + subjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod.map((subjectTracks) => { + const subjectId = subjectTracks.track.features[0].properties.id; + const isSubjectLeaderOfSomePatrol = patrolsWithTracks.some( + (patrol) => patrol.patrol_segments?.[0]?.leader?.id && patrol.patrol_segments[0].leader.id === subjectId + ); + + // Map each subject tracks and add the patrolTrackShown flag. + return { ...subjectTracks, patrolTrackShown: isSubjectLeaderOfSomePatrol }; + }), +); diff --git a/src/selectors/patrols/index.test.js b/src/selectors/patrols/index.test.js new file mode 100644 index 000000000..397bf7188 --- /dev/null +++ b/src/selectors/patrols/index.test.js @@ -0,0 +1,584 @@ +import { TRACK_LENGTH_ORIGINS } from '../../ducks/tracks'; + +import { + selectPatrolData, + selectPatrolLeadersWithLastPosition, + selectPatrolsWithTracks, + selectPatrolsWithTracksData, + selectSubjectTracksWithPatrolTrackShownFlag, +} from './'; + +jest.mock('../../store', () => ({})); + +describe('Selectors - Patrols', () => { + let state; + beforeEach(() => { + state = { + data: { + patrolLeaderSchema: {}, + patrolStore: {}, + subjectStore: {}, + tracks: {}, + }, + view: { + patrolTrackState: { + pinned: [], + visible: [], + }, + subjectTrackState: { + pinned: [], + visible: [], + }, + timeSliderState: { + active: false, + virtualDate: null, + }, + trackSettings: { + isTimeOfDayColoringActive: false, + length: 21, + origin: TRACK_LENGTH_ORIGINS.CUSTOM_LENGTH, + timeOfDayTimeZone: null, + }, + }, + }; + }); + + describe('selectPatrolData', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2020-01-10')); + }); + + test('builds the patrol data for the patrol specified', () => { + state.data.tracks = { + subject123: { + fetchedDateRange: { + since: '2020-01-01T00:00:00.000Z', + }, + points: { + features: [], + }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 0], + [0, 1], + [0, 2], + [0, 3], + [0, 4], + ], + }, + properties: { + coordinateProperties: { + times: [ + '2020-01-01T00:00:00.000Z', + '2020-01-03T00:00:00.000Z', + '2020-01-05T00:00:00.000Z', + '2020-01-07T00:00:00.000Z', + '2020-01-09T00:00:00.000Z', + ], + }, + }, + }, + ], + }, + }, + }; + const patrol = { + patrol_segments: [ + { + leader: { + id: 'subject123', + }, + time_range: { + end_time: '2020-01-15T00:00:00.000Z', + start_time: '2020-01-01T00:00:00.000Z', + }, + }, + ], + }; + expect(selectPatrolData(state, patrol)).toEqual({ + leader: { id: 'subject123' }, + patrol: { + patrol_segments: [ + { + leader: { id: 'subject123' }, + time_range: { + end_time: '2020-01-15T00:00:00.000Z', + start_time: '2020-01-01T00:00:00.000Z', + }, + }, + ], + }, + trackData: { + fetchedDateRange: { since: '2020-01-01T00:00:00.000Z' }, + indices: { from: 4, until: 1 }, + points: { features: [] }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 1], + [0, 2], + [0, 3], + [0, 4], + ], + }, + properties: { + coordinateProperties: { + times: [ + '2020-01-03T00:00:00.000Z', + '2020-01-05T00:00:00.000Z', + '2020-01-07T00:00:00.000Z', + '2020-01-09T00:00:00.000Z', + ], + }, + }, + }, + ], + }, + }, + }); + }); + }); + + describe('selectPatrolLeadersWithLastPosition', () => { + test('gets the patrol leaders and their last positions', () => { + state.data.patrolLeaderSchema = { + trackedbySchema: { + properties: { + leader: { + enum_ext: [ + { + value: { + id: 'subject123', + last_position: {}, + last_position_status: {}, + }, + }, + { + value: { + id: 'subject456', + }, + }, + ], + }, + }, + }, + }; + state.data.subjectStore = { + subject456: { + last_position: {}, + last_position_status: {}, + }, + }; + expect(selectPatrolLeadersWithLastPosition(state)).toEqual([ + { id: 'subject123', last_position: {}, last_position_status: {} }, + { id: 'subject456', last_position: {}, last_position_status: {} }, + ]); + }); + }); + + describe('selectPatrolsWithTracks', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2020-01-15')); + }); + + test('gets the patrols that have their tracks pinned or visible', () => { + state.view.patrolTrackState.pinned = ['patrol123']; + state.view.patrolTrackState.visible = ['patrol456']; + state.data.patrolStore = { + patrol123: { + patrol_segments: [ + { + time_range: { + end_time: '2020-01-10T00:00:00.000Z', + start_time: '2020-01-01T00:00:00.000Z', + }, + }, + ], + }, + patrol456: { + patrol_segments: [ + { + time_range: { + end_time: '2020-01-20T00:00:00.000Z', + start_time: '2020-01-10T00:00:00.000Z', + }, + }, + ], + }, + }; + expect(selectPatrolsWithTracks(state)).toEqual([ + { + patrol_segments: [ + { + time_range: { + end_time: '2020-01-20T00:00:00.000Z', + start_time: '2020-01-10T00:00:00.000Z', + }, + }, + ], + }, + { + patrol_segments: [ + { + time_range: { + end_time: '2020-01-10T00:00:00.000Z', + start_time: '2020-01-01T00:00:00.000Z', + }, + }, + ], + }, + ]); + }); + }); + + describe('selectPatrolsWithTracksData', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2020-01-15')); + }); + + test('gets the patrols that have their tracks pinned or visible', () => { + state.view.patrolTrackState.pinned = ['patrol123']; + state.view.patrolTrackState.visible = ['patrol456']; + state.data.patrolStore = { + patrol123: { + patrol_segments: [ + { + leader: { + id: 'subject123', + }, + time_range: { + end_time: '2020-01-10T00:00:00.000Z', + start_time: '2020-01-01T00:00:00.000Z', + }, + }, + ], + }, + patrol456: { + patrol_segments: [ + { + leader: { + id: 'subject456', + }, + time_range: { + end_time: '2020-01-20T00:00:00.000Z', + start_time: '2020-01-10T00:00:00.000Z', + }, + }, + ], + }, + }; + state.data.tracks = { + subject123: { + fetchedDateRange: { + since: '2020-01-01T00:00:00.000Z', + }, + points: { + features: [], + }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 0], + [0, 1], + [0, 2], + ], + }, + properties: { + coordinateProperties: { + times: [ + '2020-01-01T00:00:00.000Z', + '2020-01-03T00:00:00.000Z', + '2020-01-05T00:00:00.000Z', + ], + }, + }, + }, + ], + }, + }, + subject456: { + fetchedDateRange: { + since: '2020-01-01T00:00:00.000Z', + }, + points: { + features: [], + }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 0], + [1, 0], + [2, 0], + ], + }, + properties: { + coordinateProperties: { + times: [ + '2020-01-11T00:00:00.000Z', + '2020-01-13T00:00:00.000Z', + '2020-01-15T00:00:00.000Z', + ], + }, + }, + }, + ], + }, + }, + }; + expect(selectPatrolsWithTracksData(state)).toEqual([ + { + leader: { id: 'subject456' }, + patrol: { + patrol_segments: [ + { + leader: { id: 'subject456' }, + time_range: { + end_time: '2020-01-20T00:00:00.000Z', + start_time: '2020-01-10T00:00:00.000Z', + }, + }, + ], + }, + trackData: { + fetchedDateRange: { since: '2020-01-01T00:00:00.000Z' }, + indices: { from: 2, until: 1 }, + points: { features: [] }, + track: { + features: [ + { + geometry: { + coordinates: [ + [1, 0], + [2, 0], + ], + }, + properties: { + coordinateProperties: { + times: [ + '2020-01-13T00:00:00.000Z', + '2020-01-15T00:00:00.000Z', + ], + }, + }, + }, + ], + }, + }, + }, + { + leader: { id: 'subject123' }, + patrol: { + patrol_segments: [ + { + leader: { id: 'subject123' }, + time_range: { + end_time: '2020-01-10T00:00:00.000Z', + start_time: '2020-01-01T00:00:00.000Z', + }, + }, + ], + }, + trackData: { + fetchedDateRange: { since: '2020-01-01T00:00:00.000Z' }, + indices: { from: 2, until: 1 }, + points: { features: [] }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 1], + [0, 2], + ], + }, + properties: { + coordinateProperties: { + times: [ + '2020-01-03T00:00:00.000Z', + '2020-01-05T00:00:00.000Z', + ], + }, + }, + }, + ], + }, + }, + }, + ]); + }); + }); + + describe('selectSubjectTracksWithPatrolTrackShownFlag', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2020-01-15')); + }); + + test('gets the patrols that have their tracks pinned or visible', () => { + state.view.subjectTrackState.pinned = ['subject123']; + state.view.subjectTrackState.visible = ['subject456']; + state.view.patrolTrackState.pinned = ['patrol123']; + state.view.patrolTrackState.visible = ['patrol456']; + state.data.patrolStore = { + patrol123: { + patrol_segments: [ + { + leader: { + id: 'subject123', + }, + time_range: { + end_time: '2020-01-10T00:00:00.000Z', + start_time: '2020-01-01T00:00:00.000Z', + }, + }, + ], + }, + patrol456: { + patrol_segments: [ + { + leader: { + id: 'subject456', + }, + time_range: { + end_time: '2020-01-20T00:00:00.000Z', + start_time: '2020-01-10T00:00:00.000Z', + }, + }, + ], + }, + }; + state.data.tracks = { + subject123: { + fetchedDateRange: { + since: '2020-01-01T00:00:00.000Z', + }, + points: { + features: [], + }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 0], + [0, 1], + [0, 2], + ], + }, + properties: { + id: 'subject123', + coordinateProperties: { + times: [ + '2020-01-01T00:00:00.000Z', + '2020-01-03T00:00:00.000Z', + '2020-01-05T00:00:00.000Z', + ], + }, + }, + }, + ], + }, + }, + subject456: { + fetchedDateRange: { + since: '2020-01-01T00:00:00.000Z', + }, + points: { + features: [], + }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 0], + [1, 0], + [2, 0], + ], + }, + properties: { + id: 'subject456', + coordinateProperties: { + times: [ + '2020-01-11T00:00:00.000Z', + '2020-01-13T00:00:00.000Z', + '2020-01-15T00:00:00.000Z', + ], + }, + }, + }, + ], + }, + }, + }; + expect(selectSubjectTracksWithPatrolTrackShownFlag(state)).toEqual([ + { + fetchedDateRange: { since: '2020-01-01T00:00:00.000Z' }, + indices: { from: 2 }, + patrolTrackShown: true, + points: { features: [] }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 0], + [0, 1], + [0, 2], + ], + }, + properties: { + coordinateProperties: { + times: [ + '2020-01-01T00:00:00.000Z', + '2020-01-03T00:00:00.000Z', + '2020-01-05T00:00:00.000Z', + ], + }, + id: 'subject123', + }, + }, + ], + }, + }, + { + fetchedDateRange: { since: '2020-01-01T00:00:00.000Z' }, + indices: { from: 2 }, + patrolTrackShown: true, + points: { features: [] }, + track: { + features: [ + { + geometry: { + coordinates: [ + [0, 0], + [1, 0], + [2, 0], + ], + }, + properties: { + coordinateProperties: { + times: [ + '2020-01-11T00:00:00.000Z', + '2020-01-13T00:00:00.000Z', + '2020-01-15T00:00:00.000Z', + ], + }, + id: 'subject456', + }, + }, + ], + }, + }, + ]); + }); + }); +}); diff --git a/src/selectors/subjects.js b/src/selectors/subjects.js index 3e7634284..04f92800d 100644 --- a/src/selectors/subjects.js +++ b/src/selectors/subjects.js @@ -9,7 +9,7 @@ import { pinMapSubjectsToVirtualPosition, markSubjectFeaturesWithActivePatrols, const getMapSubjects = ({ data: { mapSubjects } }) => mapSubjects; const hiddenSubjectIDs = ({ data: { mapLayerFilter: { hiddenSubjectIDs } } }) => hiddenSubjectIDs; const subjectGroups = ({ data: { subjectGroups } }) => subjectGroups; -export const getSubjectStore = ({ data: { subjectStore } }) => subjectStore; +const getSubjectStore = ({ data: { subjectStore } }) => subjectStore; const showInactiveRadios = ({ view: { showInactiveRadios } }) => showInactiveRadios; const getSystemConfig = ({ view: { systemConfig } }) => systemConfig; const getUserPermissions = ({ data: { user, selectedUserProfile } }) => (selectedUserProfile.id ? selectedUserProfile : user).permissions || {}; diff --git a/src/selectors/tracks/index.js b/src/selectors/tracks/index.js index 7fa6151e0..3b5c61a06 100644 --- a/src/selectors/tracks/index.js +++ b/src/selectors/tracks/index.js @@ -8,8 +8,6 @@ import { getTimeOfDayPeriodBasedOnTime, trimTrackDataToTimeRange } from '../../u const selectEventFilter = (state) => state.data.eventFilter; const selectHeatmapSubjectIDs = (state) => state.view.heatmapSubjectIDs; -const selectPatrolStore = (state) => state.data.patrolStore; -const selectPatrolTrackState = (state) => state.view.patrolTrackState; const selectSubjectTrackState = (state) => state.view.subjectTrackState; const selectTimeSliderState = (state) => state.view.timeSliderState; const selectTrackSettings = (state) => state.view.trackSettings; @@ -64,27 +62,11 @@ export const selectHeatmapSubjectTracksTrimmedToTrackTimeEnvelope = createSelect ) ); -const selectPatrolTracksLeaderIds = createSelector( - [selectPatrolStore, selectPatrolTrackState], - (patrolStore, patrolTrackState) => [...patrolTrackState.visible, ...patrolTrackState.pinned] - // List the patrols that have visible or pinned tracks from the store. - .map((patrolId) => patrolStore[patrolId]) - // Filter the defined patrols. - .filter((patrol) => !!patrol) - // Get the leader of each patrol. - .map((patrol) => patrol.patrol_segments.length > 0 && patrol.patrol_segments[0].leader) - // Filter the defined leaders. - .filter((patrolLeader) => !!patrolLeader) - // Return the list of leader ids. - .map((patrolLeader) => patrolLeader.id) -); - const selectSubjectTracks = createSelector( - [selectPatrolTracksLeaderIds, selectSubjectTrackState, selectTracks], - (patrolTracksLeaderIds, subjectTrackState, tracks) => uniq([ + [selectSubjectTrackState, selectTracks], + (subjectTrackState, tracks) => uniq([ ...subjectTrackState.pinned, ...subjectTrackState.visible, - ...patrolTracksLeaderIds, ]) // Filter the defined subject ids. .filter((subjectId) => !!tracks[subjectId]) @@ -92,40 +74,33 @@ const selectSubjectTracks = createSelector( .map((subjectId) => tracks[subjectId]) ); - export const selectSubjectTracksTrimmedToTrackTimeEnvelopeWithTimeOfDayPeriod = createSelector( [selectSubjectTracks, selectTrackTimeEnvelope, selectTrackSettings], - (subjectTracks, trackTimeEnvelope, { timeOfDayTimeZone, isTimeOfDayColoringActive }) => subjectTracks.map( + (subjectTracks, trackTimeEnvelope, trackSettings) => subjectTracks.map( (subjectTrack) => { - const { - points: { - features, - ...otherPointsProps - }, - ...otherData - } = trimTrackDataToTimeRange( // Trim each subject tracks to the track time envelope. - subjectTrack, - trackTimeEnvelope.from, - trackTimeEnvelope.until - ); + // Trim each subject tracks to the track time envelope. + const trimmedTrackData = trimTrackDataToTimeRange(subjectTrack, trackTimeEnvelope.from, trackTimeEnvelope.until); - return { - ...otherData, - points: { - ...otherPointsProps, - features: features.map(({ properties, ...otherFeaturesProps }) => { - return { - ...otherFeaturesProps, - properties: { - ...properties, - timeOfDayPeriod: isTimeOfDayColoringActive - ? getTimeOfDayPeriodBasedOnTime(properties.time, timeOfDayTimeZone) - : undefined - } - }; - }) - } - }; + if (trackSettings.isTimeOfDayColoringActive) { + // If time of day coloring is active we add the time of day period to each point feature. + const pointFeaturesWithTimeOfDayPeriod = trimmedTrackData.points.features.map((feature) => ({ + ...feature, + properties: { + ...feature.properties, + timeOfDayPeriod: getTimeOfDayPeriodBasedOnTime(feature.properties.time, trackSettings.timeOfDayTimeZone), + }, + })); + + return { + ...trimmedTrackData, + points: { + ...trimmedTrackData.points, + features: pointFeaturesWithTimeOfDayPeriod, + } + }; + } + + return trimmedTrackData; } ) ); diff --git a/src/selectors/tracks/index.test.js b/src/selectors/tracks/index.test.js index 26446e7ca..b6186a758 100644 --- a/src/selectors/tracks/index.test.js +++ b/src/selectors/tracks/index.test.js @@ -19,15 +19,10 @@ describe('Selectors - Tracks', () => { }, }, }, - patrolStore: {}, tracks: {}, }, view: { heatmapSubjectIDs: [], - patrolTrackState: { - pinned: [], - visible: [], - }, subjectTrackState: { pinned: [], visible: [], @@ -36,8 +31,10 @@ describe('Selectors - Tracks', () => { active: false, }, trackSettings: { + isTimeOfDayColoringActive: false, length: 21, origin: TRACK_LENGTH_ORIGINS.CUSTOM_LENGTH, + timeOfDayTimeZone: null, }, }, }; @@ -133,7 +130,7 @@ describe('Selectors - Tracks', () => { jest.useFakeTimers().setSystemTime(new Date('2021-01-01')); }); - test('builds the subject tracks from the subjects with heatmap active trimmed to the time envelope', () => { + test('builds the subject tracks from the subjects with tracks active trimmed to the time envelope', () => { state.view.subjectTrackState.visible = ['123']; state.data.tracks = { 123: { diff --git a/src/utils/patrols.js b/src/utils/patrols.js index d470203c4..d6a2f6e53 100644 --- a/src/utils/patrols.js +++ b/src/utils/patrols.js @@ -228,15 +228,6 @@ export const actualEndTimeForPatrol = (patrol) => { : null; }; -export const getLeaderForPatrol = (patrol, subjectStore) => { - if (!patrol?.patrol_segments.length) return null; - const [firstLeg] = patrol.patrol_segments; - const { leader } = firstLeg; - if (!leader) return null; - - return subjectStore[leader.id] || leader; -}; - export const getPatrolsForLeaderId = (leaderId) => { const { data: { patrolStore } } = store.getState();