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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ 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:

,
+ 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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ id: '1',
+ title: 'Item 1 title',
+ }, {
+ description: 'Item 2 description',
+ 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:

,
+ 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:

,
+ 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:

,
+ 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:

,
+ 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:

,
+ 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:

,
+ 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:

,
+ 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:

,
+ 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:

,
+ 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:

,
+ 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();