Skip to content

ERA-11239: track styles: patrol tracks (2) #1251

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Mar 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions public/locales/en-US/tracks.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"patrolTrackLegend": {
"icon": "Icon for {{title}}",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two updates in the translations file:

  • Simplify the item description since it was taking too much space unnecessarily
  • Add the "Patrol: " prefix to the item title
    image

"itemDescription": "{{length}} covered",
"icon": "Icon for {{patrolTitle}}",
"itemTitle": "Patrol: {{patrolTitle}}",
"trackLegendItemsName": "patrols"
},
"subjectTrackLegend": {
Expand Down
2 changes: 1 addition & 1 deletion public/locales/es/tracks.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"patrolTrackLegend": {
"icon": "Ícono para {{title}}",
"itemDescription": "{{length}} cubiertos",
"itemTitle": "Patrulla: {{patrolTitle}}",
"trackLegendItemsName": "patrullas"
},
"subjectTrackLegend": {
Expand Down
2 changes: 1 addition & 1 deletion public/locales/fr/tracks.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"patrolTrackLegend": {
"icon": "Icône pour {{title}}",
"itemDescription": "{{length}} parcourue",
"itemTitle": "Patrouille : {{patrolTitle}}",
"trackLegendItemsName": "patrouilles"
},
"subjectTrackLegend": {
Expand Down
2 changes: 1 addition & 1 deletion public/locales/ne-NP/tracks.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"patrolTrackLegend": {
"icon": "{{title}}को लागि आइकन",
"itemDescription": "{{length}} ढाकिएको",
"itemTitle": "गस्ती: {{patrolTitle}}",
"trackLegendItemsName": "गस्तीहरु"
},
"subjectTrackLegend": {
Expand Down
2 changes: 1 addition & 1 deletion public/locales/pt/tracks.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"patrolTrackLegend": {
"icon": "Ícone para {{title}}",
"itemDescription": "{{length}} percorridos",
"itemTitle": "Patrulha: {{patrolTitle}}",
"trackLegendItemsName": "patrulhas"
},
"subjectTrackLegend": {
Expand Down
2 changes: 1 addition & 1 deletion public/locales/sw/tracks.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"patrolTrackLegend": {
"icon": "Ikoni kwa {{title}}",
"itemDescription": "{{length}} imefunikwa",
"itemTitle": "Doria: {{patrolTitle}}",
"trackLegendItemsName": "doria"
},
"subjectTrackLegend": {
Expand Down
1 change: 1 addition & 0 deletions src/MapLegend/styles.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
flex-wrap: wrap;
height: var(--legend-height);
line-height: normal;
margin-bottom: 0.5rem;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add gap between the heatmap legend and the patrol track legend:
image

padding: 0.5rem;
position: relative;

Expand Down
5 changes: 3 additions & 2 deletions src/PatrolFilter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useTranslation } from 'react-i18next';
import { caseInsensitiveCompare } from '../utils/string';
import { INITIAL_FILTER_STATE, updatePatrolFilter } from '../ducks/patrol-filter';
import { resetGlobalDateRange } from '../ducks/global-date-range';
import { selectPatrolsFeedMappedFromStore } from '../selectors/patrols';
import { isFilterModified } from '../utils/patrol-filter';
import { trackEventFactory, PATROL_FILTER_CATEGORY } from '../utils/analytics';

Expand All @@ -33,7 +34,7 @@ const PatrolFilter = ({ className }) => {
const containerRef = useRef(null);
const { t } = useTranslation('filters', { keyPrefix: 'patrolFilters' });
const dispatch = useDispatch();
const patrolStore = useSelector((state) => state.data.patrolStore);
const patrolsFeedMappedFromStore = useSelector(selectPatrolsFeedMappedFromStore);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the new selector that gets the response of the latest patrol feed request with the filtering params, and maps them to their patrol store objects, filtering the ones that are not defined, to calculate the total feed count:
image

const patrolFilter = useSelector(state => state.data.patrolFilter);

const [filterText, setFilterText] = useState(patrolFilter.filter.text);
Expand Down Expand Up @@ -159,7 +160,7 @@ const PatrolFilter = ({ className }) => {
className={styles.friendlyFilterString}
dateRange={patrolFilter.filter.date_range}
isFiltered={isFilterModified(patrolFilter)}
totalFeedCount={Object.keys(patrolStore).length ?? 0}
totalFeedCount={patrolsFeedMappedFromStore.length}
/>

{
Expand Down
4 changes: 1 addition & 3 deletions src/PatrolFilter/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ describe('PatrolFilter', () => {
},
status: INITIAL_FILTER_STATE.status,
},
patrols: {
results: [],
},
patrolsFeed: [],
subjectStore: {},
},
};
Expand Down
15 changes: 9 additions & 6 deletions src/PatrolTrackLegend/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,18 @@ const PatrolTrackLegend = () => {
// 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);
const patrolTitle = displayTitleForPatrol(patrolData.patrol, patrolData.leader);

return {
description: t('itemDescription', {
length: `${patrolData.trackData ? length(patrolData.trackData.track).toFixed(2): 0.00}km`,
}),
icon: <DasIcon className={styles.itemIcon} iconId={iconId} title={t('icon', { title })} type="events" />,
description: `${patrolData.trackData ? length(patrolData.trackData.track).toFixed(2): 0.00}km`,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the new translation for the item title and remove the unnecessary translation for the description, leave just the distance.

icon: <DasIcon
className={styles.itemIcon}
iconId={iconId}
title={t('icon', { patrolTitle })}
type="events"
/>,
id: patrolData.patrol.id,
title,
title: t('itemTitle', { patrolTitle }),
};
}), [patrolsWithTrackData, t]);

Expand Down
14 changes: 8 additions & 6 deletions src/SideBar/PatrolsFeedTab/index.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,24 @@
import React, { memo, useCallback, useMemo } from 'react';
import React, { memo, useMemo } from 'react';
import { useSelector } from 'react-redux';

import useNavigate from '../../hooks/useNavigate';
import { selectPatrolsFeedMappedFromStore } from '../../selectors/patrols';
import { sortPatrolList } from '../../utils/patrols';

import PatrolFilter from '../../PatrolFilter';
import PatrolList from '../../PatrolList';
import { sortPatrolList } from '../../utils/patrols';

const PatrolsFeedTab = ({ loadingPatrolsFeed }) => {
const navigate= useNavigate();
const patrolStore = useSelector((state) => state.data.patrolStore);
const sortedPatrols = useMemo(() => sortPatrolList(Object.values(patrolStore)), [patrolStore]);

const onItemClick = useCallback((id) => navigate(id), [navigate]);
const patrolsFeedMappedFromStore = useSelector(selectPatrolsFeedMappedFromStore);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, use the new selector to get the array of patrol objects that should be rendered in the patrol feed. These consider the latest response from the patrols feed request using the configured filters:
image


const sortedPatrols = useMemo(() => sortPatrolList(patrolsFeedMappedFromStore), [patrolsFeedMappedFromStore]);

return <>
<PatrolFilter />
<PatrolList loading={loadingPatrolsFeed} onItemClick={onItemClick} patrols={sortedPatrols} />

<PatrolList loading={loadingPatrolsFeed} onItemClick={(id) => navigate(id)} patrols={sortedPatrols} />
</>;
};

Expand Down
2 changes: 1 addition & 1 deletion src/SideBar/PatrolsFeedTab/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const patrolFilter = { filter: {
let store = patrolDefaultStoreData;
store.data.patrolFilter = patrolFilter;
store.data.patrolStore = { [activePatrol.id]: activePatrol };
store.data.patrols.results = [activePatrol.id];
store.data.patrolsFeed = [activePatrol.id];

describe('PatrolsFeedTab', () => {
let navigate, useNavigateMock;
Expand Down
17 changes: 7 additions & 10 deletions src/SideBar/useFetchPatrolsFeed/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import cloneDeep from 'lodash/cloneDeep';
import { useDispatch, useSelector } from 'react-redux';

import { fetchPatrols } from '../../ducks/patrols';
import { fetchPatrolsFeed } from '../../ducks/patrols';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only logical change in this file is rename the old fetchPatrols to fetchPatrolsFeed (more explicit) but I did a bit of refactoring to clean an unnecessay useCallback.


const useFetchPatrolsFeed = () => {
const dispatch = useDispatch();
Expand All @@ -20,18 +20,15 @@ const useFetchPatrolsFeed = () => {
return filterParams;
}, [patrolFilter]);

const fetchAndLoadPatrolData = useCallback(() => {
patrolFetchRef.current = dispatch(fetchPatrols());
useEffect(() => {
setLoadingPatrolsFeed(true);

patrolFetchRef.current = dispatch(fetchPatrolsFeed());

patrolFetchRef.current.request.finally(() => {
setLoadingPatrolsFeed(false);
patrolFetchRef.current = null;
});
}, [dispatch]);

useEffect(() => {
setLoadingPatrolsFeed(true);
fetchAndLoadPatrolData();

return () => {
const priorRequestCancelToken = patrolFetchRef?.current?.cancelToken;
Expand All @@ -40,7 +37,7 @@ const useFetchPatrolsFeed = () => {
priorRequestCancelToken.cancel();
}
};
}, [fetchAndLoadPatrolData, patrolFilterParams]);
}, [dispatch, patrolFilterParams]);

return { loadingPatrolsFeed };
};
Expand Down
2 changes: 1 addition & 1 deletion src/SideBar/useFetchPatrolsFeed/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('useFetchPatrolsFeed', () => {

await waitFor(() => {
expect(actions).toHaveLength(2);
expect(actions[0].type).toBe('FETCH_PATROLS_SUCCESS');
expect(actions[0].type).toBe('FETCH_PATROLS_FEED_SUCCESS');
expect(actions[1].type).toBe('UPDATE_PATROL_STORE');
});
});
Expand Down
4 changes: 1 addition & 3 deletions src/__test-helpers/fixtures/patrols.js
Original file line number Diff line number Diff line change
Expand Up @@ -1672,9 +1672,7 @@ export const patrolDefaultStoreData = {
},
patrolStore: {},
subjectStore: {},
patrols: {
results: [],
},
patrolsFeed: [],
patrolTypes: [{
display: 'Dog Patrol',
icon_id: 'dog-patrol-icon',
Expand Down
91 changes: 49 additions & 42 deletions src/ducks/patrols.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import axios, { CancelToken, isCancel } from 'axios';
import axios, { CancelToken } from 'axios';
import merge from 'lodash/merge';

import { API_URL } from '../constants';

import { calcPatrolFilterForRequest/* ,
validatePatrolAgainstCurrentPatrolFilter */ } from '../utils/patrol-filter';
import { calcPatrolFilterForRequest } from '../utils/patrol-filter';
import globallyResettableReducer from '../reducers/global-resettable';

export const PATROLS_API_URL = `${API_URL}activity/patrols/`;

const FETCH_PATROLS_SUCCESS = 'FETCH_PATROLS_SUCCESS';
const UPDATE_PATROL_STORE = 'UPDATE_PATROL_STORE';
const FETCH_PATROLS_ERROR = 'FETCH_PATROLS_ERROR';

const CREATE_PATROL_SUCCESS = 'CREATE_PATROL_SUCCESS';
// const CREATE_PATROL_ERROR = 'CREATE_PATROL_ERROR';

const UPDATE_PATROL_ERROR = 'UPDATE_PATROL_ERROR';

Expand All @@ -23,7 +20,6 @@ const UPLOAD_PATROL_FILES_START = 'UPLOAD_PATROL_FILES_START';
const UPLOAD_PATROL_FILES_SUCCESS = 'UPLOAD_PATROL_FILES_SUCCESS';
const UPLOAD_PATROL_FILES_ERROR = 'UPLOAD_PATROL_FILES_ERROR';


const CLEAR_PATROL_DATA = 'CLEAR_PATROL_DATA';

const REMOVE_PATROL_BY_ID = 'REMOVE_PATROL_BY_ID';
Expand Down Expand Up @@ -74,14 +70,6 @@ export const updatePatrolStore = (patrols) => ({
payload: patrols,
});

export const fetchPatrolsSuccess = (patrols) => (dispatch) => {
dispatch({
type: FETCH_PATROLS_SUCCESS,
payload: patrols,
});
dispatch(updatePatrolStore(patrols));
};

export const socketDeletePatrol = (payload) => (dispatch) => {
const { patrol_id, matches_current_filter } = payload;
if (matches_current_filter) {
Expand All @@ -106,33 +94,6 @@ export const fetchPatrol = id => dispatch => axios.get(`${PATROLS_API_URL}${id}`
throw error;
});

export const fetchPatrols = () => (dispatch) => {
let cancelToken = CancelToken.source();

const patrolFilterParamString = calcPatrolFilterForRequest({ params: { page_size: 200 } });

const request = axios.get(`${PATROLS_API_URL}?${patrolFilterParamString}`, {
cancelToken: cancelToken.token,
})
.then(({ data: { data: patrols } }) => {
dispatch(fetchPatrolsSuccess(patrols));
return patrols;

})
.catch((error) => {
console.warn('error fetching patrols', error);
dispatch({
type: FETCH_PATROLS_ERROR,
payload: error,
});
if (!isCancel(error)) {
return new Error(error);
}
});

return { request, cancelToken };
};

export const createPatrol = (patrol) => (dispatch) => {

return axios.post(PATROLS_API_URL, patrol)
Expand Down Expand Up @@ -313,3 +274,49 @@ export const patrolTracksReducer = (state = INITIAL_PATROL_TRACKS_STATE, { type,

return state;
};

// Actions
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename and refactor the fetchPatrols method, now called fetchPatrolsFeed, and add back the reducer I removed (original cause of the feed issue) but in a cleaner way. Not storing the whole Axios response, but just the mapped ids of the patrols.

const FETCH_PATROLS_FEED_SUCCESS = 'FETCH_PATROLS_FEED_SUCCESS';

// Action creators
export const fetchPatrolsFeed = () => (dispatch) => {
const cancelToken = CancelToken.source();

const request = axios.get(
`${PATROLS_API_URL}?${calcPatrolFilterForRequest({ params: { page_size: 200 } })}`,
{ cancelToken: cancelToken.token }
)
.then((response) => {
dispatch({ payload: response.data.data.results.map((patrol) => patrol.id), type: FETCH_PATROLS_FEED_SUCCESS });
dispatch(updatePatrolStore(response.data.data));
})
.catch((error) => {
dispatch({ payload: error, type: FETCH_PATROLS_ERROR });

console.warn('error fetching patrols', error);
});

return { cancelToken, request };
};

// Reducer
export const INITIAL_PATROLS_FEED_STATE = [];

export const patrolsFeedReducer = globallyResettableReducer((state, action) => {
switch (action.type) {
case FETCH_PATROLS_FEED_SUCCESS:
return action.payload;

case CREATE_PATROL_REALTIME:
if (!state.includes(action.payload.id)) {
return [action.payload.id, ...state];
}
return state;

case REMOVE_PATROL_BY_ID:
return state.filter((patrolId) => patrolId !== action.payload);

default:
return state;
}
}, INITIAL_PATROLS_FEED_STATE);
3 changes: 2 additions & 1 deletion src/reducers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 { patrolStoreReducer, patrolTracksReducer } from '../ducks/patrols';
import { patrolsFeedReducer, 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';
Expand Down Expand Up @@ -68,6 +68,7 @@ const rootReducer = combineReducers({
data: combineReducers({
baseLayers: baseLayersReducer,
eventStore: eventStoreReducer,
patrolsFeed: patrolsFeedReducer,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add back the clean version of the patrols reducer, now called patrolsFeed which is more explicit. Remember that the patrolStore reducer will have many patrols, but they don't correspond to the applied filters. This reducer contains the latest response of the patrols feed request, storing an array of the ids of the patrols that should be rendered in the feed. Those ids need to be resolved using the patrolStore.

patrolStore: patrolStoreReducer,
feedEvents: eventFeedReducer,
feedIncidents: incidentFeedReducer,
Expand Down
7 changes: 7 additions & 0 deletions src/selectors/patrols/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const buildPatrolData = (patrol, timeSliderState, tracks) => {
return patrolData;
};

const selectPatrolsFeed = (state) => state.data.patrolsFeed;
const selectPatrolLeaderSchema = (state) => state.data.patrolLeaderSchema;
const selectPatrolStore = (state) => state.data.patrolStore;
const selectPatrolTrackState = (state) => state.view.patrolTrackState;
Expand Down Expand Up @@ -111,6 +112,12 @@ export const selectPatrolLeadersWithLastPosition = createSelector(
}) : null
);

export const selectPatrolsFeedMappedFromStore = createSelector(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new selector that maps the patrols feed stored in the new reducer to their patrol objects in the patrol store.

[selectPatrolsFeed, selectPatrolStore],
// Map each patrol id from the feed to its patrol object and filter just the defined ones.
(patrolsFeed, patrolStore) => patrolsFeed.map((patrolId) => patrolStore[patrolId]).filter((patrol) => !!patrol),
);

export const selectPatrolsWithTracks = createSelector(
[selectPatrolStore, selectVisibleAndPinnedPatrolTracks],
(patrolStore, visibleAndPinnedPatrolTracks) => visibleAndPinnedPatrolTracks
Expand Down