diff --git a/src/GpsFormatToggle/index.js b/src/GpsFormatToggle/index.js index 9935174b9..0f116626f 100644 --- a/src/GpsFormatToggle/index.js +++ b/src/GpsFormatToggle/index.js @@ -23,8 +23,7 @@ const GpsFormatToggle = (props) => { }); }; - const gpsString = showGpsString && calcGpsDisplayString(lat, lng, currentFormat); - const displayGpsString = gpsString || null; + const gpsString = showGpsString ? calcGpsDisplayString(lat, lng, currentFormat) : null; return (
@@ -34,9 +33,9 @@ const GpsFormatToggle = (props) => { onClick={() => onGpsFormatClick(gpsFormat)}>{gpsFormat} )} - {displayGpsString &&
- {displayGpsString} - {showCopyControl && } + {gpsString &&
+ {gpsString} + {showCopyControl && }
}
diff --git a/src/HeatmapToggleButton/index.js b/src/HeatmapToggleButton/index.js index 0b044fecf..42032b3f0 100644 --- a/src/HeatmapToggleButton/index.js +++ b/src/HeatmapToggleButton/index.js @@ -2,18 +2,20 @@ import React, { memo } from 'react'; import noop from 'lodash/noop'; import PropTypes from 'prop-types'; import styles from './styles.module.scss'; -import LoadingOverlay from '../LoadingOverlay'; + +import SubjectControlButton from '../SubjectControls/button'; const HeatmapToggleButton = (props) => { - const { className: externalClass, heatmapVisible, heatmapPartiallyVisible, onButtonClick, showLabel, loading } = props; - const className = heatmapVisible ? 'visible' : heatmapPartiallyVisible ? 'partial' : ''; - const labelText = className ? 'Heatmap on' : 'Heatmap off'; - - return
- {loading && } - - {showLabel && {labelText}} -
; + const { className: externalClass, heatmapVisible, heatmapPartiallyVisible, onButtonClick, ...rest } = props; + + const stateClassName = heatmapVisible ? 'visible' : heatmapPartiallyVisible ? 'partial' : ''; + + const containerClassName = `${styles.container} ${stateClassName}`; + const buttonClassName = `${styles.button} ${styles[stateClassName]} ${externalClass || ''}`; + + const labelText = stateClassName ? 'Heatmap on' : 'Heatmap off'; + + return ; }; export default memo(HeatmapToggleButton); diff --git a/src/HeatmapToggleButton/styles.module.scss b/src/HeatmapToggleButton/styles.module.scss index 0613adcb9..c2ee9177b 100644 --- a/src/HeatmapToggleButton/styles.module.scss +++ b/src/HeatmapToggleButton/styles.module.scss @@ -1,92 +1,15 @@ @import '../common/styles/buttons'; - -$background_image_url: '../common/images/icons/'; - -@mixin button($image: 'heatmap') { - background: url('#{$background-image_url+$image}.svg'); - background-size: cover; - border: none; - display: block; - filter: saturate(50%) grayscale(100%); - height: $square-button-dimension; - outline: none; - width: $square-button-dimension; - - &:focus { - outline: none; - } -} - -.button { - @include button; - &.visible { - filter: unset; - } -} +@import '../SubjectControls/mixins'; .container { - align-items: center; - display: flex; - flex-flow: row; - position: relative; - - &.hasLabel { - background-color: rgba($secondary-light-gray, .25); - border: 0.06rem solid $secondary-medium-light-gray; - border-radius: .12rem; - max-width: 5.75rem; - max-height: 1.85rem; - border-radius: 0.2rem; - cursor: pointer; - - [class*=spinner] { - position: relative; - top: -16%; + .button { + @include buttonBackground('heatmap'); + filter: saturate(50%) grayscale(100%); + &.visible { + filter: unset; } - - button { - min-height: 1.80rem; - height: 1.80rem; - min-width: 1.80rem; - width: 1.80rem; - } - - span { - font-size: 0.68rem; - line-height: normal; - padding: .1rem .7rem; - margin: 0 -.2rem; - } - - &[class*=visible] { - border: 0.06px solid $bright-blue; - background-color: rgba($bright-blue, 0.1); - } - - &[class*=pinned] { - border: 0.06px solid $green; - background-color: rgba($green, 0.1); + &.partial { + filter: opacity(50%); } } - -} - -.visible { - @include button; } - -.partial { - filter: opacity(50%); -} - -div.loadingOverlay { - background: none; - z-index: 10; - [class*=spinner] { - height: 1rem; - width: 1rem; - &::after { - background: none; - } - } -} \ No newline at end of file diff --git a/src/SideBar/styles.module.scss b/src/SideBar/styles.module.scss index 5d8d1bfa1..7070c5ac1 100644 --- a/src/SideBar/styles.module.scss +++ b/src/SideBar/styles.module.scss @@ -445,7 +445,9 @@ } .controls { - div > button, button:not(:last-child) { + > div:not(:last-child) { + height: 2.25rem; + width: 2.25rem; margin-right: 0.5rem; border-radius: 0.25rem; } diff --git a/src/StaticSensorsLayer/index.js b/src/StaticSensorsLayer/index.js index 7b61654b7..fd06480d7 100644 --- a/src/StaticSensorsLayer/index.js +++ b/src/StaticSensorsLayer/index.js @@ -1,20 +1,20 @@ import React, { useContext, memo, useCallback, useEffect, useState } from 'react'; import PropTypes from 'prop-types'; -import { Provider, connect } from 'react-redux'; -import ReactDOM from 'react-dom'; +import { connect } from 'react-redux'; import mapboxgl from 'mapbox-gl'; import set from 'lodash/set'; import isEmpty from 'lodash/isEmpty'; -import store from '../store'; import { MapContext } from '../App'; import { DEVELOPMENT_FEATURE_FLAGS, LAYER_IDS, SUBJECT_FEATURE_CONTENT_TYPE } from '../constants'; import { addFeatureCollectionImagesToMap } from '../utils/map'; import { getSubjectDefaultDeviceProperty } from '../utils/subjects'; + +import { showPopup } from '../ducks/popup'; + import { BACKGROUND_LAYER, LABELS_LAYER } from './layerStyles'; import LayerBackground from '../common/images/sprites/layer-background-sprite.png'; -import SubjectPopup from '../SubjectPopup'; const { ENABLE_NEW_CLUSTERING } = DEVELOPMENT_FEATURE_FLAGS; @@ -38,7 +38,7 @@ const IMAGE_DATA = { const popup = new mapboxgl.Popup({ offset: [0, 0], anchor: 'bottom', closeButton: false }); -const StaticSensorsLayer = ({ staticSensors = {}, isTimeSliderActive, showMapNames, simplifyMapDataOnZoom: { enabled: isDataInMapSimplified } }) => { +const StaticSensorsLayer = ({ staticSensors = {}, isTimeSliderActive, showMapNames, simplifyMapDataOnZoom: { enabled: isDataInMapSimplified }, showPopup }) => { const map = useContext(MapContext); const showMapStaticSubjectsNames = showMapNames[STATIC_SENSOR]?.enabled ?? false; const [clickedLayerID, setClickedLayerID] = useState(''); @@ -94,31 +94,32 @@ const StaticSensorsLayer = ({ staticSensors = {}, isTimeSliderActive, showMapNam } }, [map]); - const createPopup = useCallback((layer) => { - const { geometry } = layer; - - const elementContainer = document.createElement('div'); - ReactDOM.render( - - , elementContainer); + const showPopupForStationarySubject = useCallback((layer) => { + const { geometry, properties } = layer; - popup.setLngLat(geometry.coordinates) - .setDOMContent(elementContainer) - .addTo(map); - - popup.on('close', () => { + const handleMapClick = () => { setClickedLayerID(''); setLayerVisibility(layer.layer.id); - }); - }, [map, setLayerVisibility]); + }; + + showPopup('subject', { geometry, properties, coordinates: geometry.coordinates }); + setTimeout(() => map.once('click', handleMapClick)); + + }, [map, setLayerVisibility, showPopup]); const onLayerClick = useCallback((event) => { + if (event?.originalEvent?.cancelBubble) return; + + event?.preventDefault(); + event?.originalEvent?.stopPropagation(); + const clickedLayer = getStaticSensorLayer(event); + const clickedLayerID = clickedLayer.layer.id; setClickedLayerID(clickedLayerID.replace(PREFIX_ID, '')); - createPopup(clickedLayer); + showPopupForStationarySubject(clickedLayer); setLayerVisibility(clickedLayerID, false); - }, [getStaticSensorLayer, setClickedLayerID, createPopup, setLayerVisibility]); + }, [getStaticSensorLayer, showPopupForStationarySubject, setClickedLayerID, setLayerVisibility]); const createLayer = useCallback((layerID, sourceId, layout, paint) => { if (!map.getLayer(layerID)) { @@ -198,7 +199,7 @@ const StaticSensorsLayer = ({ staticSensors = {}, isTimeSliderActive, showMapNam const mapStatetoProps = ({ view: { showMapNames, simplifyMapDataOnZoom } }) => ({ showMapNames, simplifyMapDataOnZoom }); -export default connect(mapStatetoProps, null)(memo(StaticSensorsLayer)); +export default connect(mapStatetoProps, { showPopup })(memo(StaticSensorsLayer)); StaticSensorsLayer.propTypes = { staticSensors: PropTypes.object.isRequired, diff --git a/src/SubjectControls/_mixins.scss b/src/SubjectControls/_mixins.scss new file mode 100644 index 000000000..4dd43a643 --- /dev/null +++ b/src/SubjectControls/_mixins.scss @@ -0,0 +1,37 @@ +@import '../common/styles/buttons'; +@import '../common/styles/vars/colors'; + + +@mixin buttonBackground($image: 'tracks_off') { + $background_image_url: '../common/images/icons/'; + background-image: url('#{$background_image_url+$image}.svg'); +} + +@mixin control { + align-items: center; + background-size: cover; + cursor: pointer; + display: flex; + flex-flow: row; + line-height: normal; + position: relative; + + .button { + @include buttonBackground(); + background-size: cover; + background-color: $subject-control-btn-bg; + border: none; + display: block; + height: $square-button-dimension; + outline: none; + width: $square-button-dimension; + + &:focus { + outline: none; + } + + &.visible { + filter: unset; + } + } +} \ No newline at end of file diff --git a/src/SubjectControls/button.js b/src/SubjectControls/button.js new file mode 100644 index 000000000..163fbb6da --- /dev/null +++ b/src/SubjectControls/button.js @@ -0,0 +1,35 @@ +import React, { forwardRef, memo } from 'react'; +import noop from 'lodash/noop'; +import PropTypes from 'prop-types'; +import LoadingOverlay from '../LoadingOverlay'; +import styles from './styles.module.scss'; + +const SubjectControlButton = (props, ref) => { + const { buttonClassName = '', containerClassName = '', disabled = false, labelText, onClick, showLabel, loading, ...rest } = props; + + return
+ {loading && } + + {showLabel && labelText && {labelText}} +
; +}; + +const memoizedSubjectControlButton = memo(forwardRef(SubjectControlButton)); + +export default memoizedSubjectControlButton; + +memoizedSubjectControlButton.defaultProps = { + onClick: noop, + showLabel: true, + loading: false, +}; + +memoizedSubjectControlButton.propTypes = { + buttonClassName: PropTypes.string, + containerClassName: PropTypes.string, + disabled: PropTypes.bool, + labelText: PropTypes.string, + onClick: PropTypes.func, + showLabel: PropTypes.bool, + loading: PropTypes.bool, +}; \ No newline at end of file diff --git a/src/SubjectControls/index.js b/src/SubjectControls/index.js index ab2c51408..1a7b87e36 100644 --- a/src/SubjectControls/index.js +++ b/src/SubjectControls/index.js @@ -1,8 +1,9 @@ -import React, { memo, useState } from 'react'; +import React, { lazy, memo, useCallback, useState } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { usePermissions } from '../hooks'; +import { addModal } from '../ducks/modals'; import { PERMISSION_KEYS, PERMISSIONS } from '../constants'; @@ -11,24 +12,32 @@ import { addHeatmapSubjects, removeHeatmapSubjects, toggleTrackState } from '../ import TrackToggleButton from '../TrackToggleButton'; import HeatmapToggleButton from '../HeatmapToggleButton'; import SubjectMessagesPopover from '../SubjectMessagesPopover'; +import SubjectHistoryButton from '../SubjectHistoryButton'; import LocationJumpButton from '../LocationJumpButton'; import { trackEventFactory, MAP_LAYERS_CATEGORY } from '../utils/analytics'; +import { subjectIsStatic } from '../utils/subjects'; + + import { getSubjectControlState } from './selectors'; import { fetchTracksIfNecessary } from '../utils/tracks'; import styles from './styles.module.scss'; +const SubjectHistoricalDataModal = lazy(() => import('../SubjectHistoricalDataModal')); + const mapLayerTracker = trackEventFactory(MAP_LAYERS_CATEGORY); const SubjectControls = (props) => { const { subject, + addModal, children, showHeatmapButton, showTrackButton, showJumpButton, showMessageButton, + showHistoryButton, showTitles, showLabels, className, @@ -39,6 +48,7 @@ const SubjectControls = (props) => { tracksLoaded, tracksVisible, tracksPinned, + map: _map, ...rest } = props; const [ loadingHeatmap, setHeatmapLoadingState ] = useState(false); @@ -49,8 +59,11 @@ const SubjectControls = (props) => { const isMessageable = !!canViewMessages && !!showMessageButton && !!subject?.messaging?.length; + const hasAdditionalDeviceProps = !!subject?.device_status_properties?.length; const canShowTrack = canShowTrackForSubject(subject); + const canShowHistoryButton = showHistoryButton && (subjectIsStatic(subject) ? !!hasAdditionalDeviceProps : true); + const fetchSubjectTracks = () => { if (tracksLoaded) return new Promise(resolve => resolve()); return fetchTracksIfNecessary([id]); @@ -89,15 +102,15 @@ const SubjectControls = (props) => { } }; + const onHistoricalDataClick = useCallback(() => { + addModal({ content: SubjectHistoricalDataModal, subjectId: subject.id, subjectIsStatic: subjectIsStatic(subject), title: `Historical Data: ${subject.name}` }); + }, [addModal, subject]); + if (!showHeatmapButton && !showTrackButton && !showJumpButton) return null; return
- {isMessageable && } {showTrackButton && canShowTrack && { heatmapVisible={subjectIsInHeatmap} />} + {isMessageable && } + + {canShowHistoryButton && } + {showJumpButton && coordinates && getSubjectControlState(state, props); -export default connect(mapStateToProps, { toggleTrackState, addHeatmapSubjects, removeHeatmapSubjects })(memo(SubjectControls)); +export default connect(mapStateToProps, { addModal, toggleTrackState, addHeatmapSubjects, removeHeatmapSubjects })(memo(SubjectControls)); diff --git a/src/SubjectControls/index.test.js b/src/SubjectControls/index.test.js new file mode 100644 index 000000000..368ef7e0a --- /dev/null +++ b/src/SubjectControls/index.test.js @@ -0,0 +1,85 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { subjectIsStatic } from '../utils/subjects'; + +import mockSubjectData from '../__test-helpers/fixtures/subjects'; + +import { Provider } from 'react-redux'; +import { mockStore } from '../__test-helpers/MockStore'; +import NavigationWrapper from '../__test-helpers/navigationWrapper'; + +import { addModal } from '../ducks/modals'; + +import SubjectControls from './'; + +jest.mock('../ducks/modals', () => { + const real = jest.requireActual('../ducks/modals'); + return { + ...real, + addModal: jest.fn(), + }; +}); + +describe('SubjectControls', () => { + let store, addModalMock; + const [subject] = mockSubjectData; + const buttonTestId = `history-button-${subject.id}`; + + beforeEach(() => { + addModalMock = jest.fn(() => () => {}); + addModal.mockImplementation(addModalMock); + store = mockStore({ + data: { + tracks: {}, + }, + view: { + subjectTrackState: { pinned: [], visible: [] }, + heatmapSubjectIDs: [], + }, + }); + }); + + test('rendering without crashing', () => { + render( + + + + + ); + }); + + describe('the histrical data button', () => { + + + test('showing the historical data button', async () => { + render( + + + + + + ); + + await screen.findByTestId(buttonTestId); + + }); + + test('showing the history modal on button click', async () => { + + render( + + + + + + ); + + const button = await screen.findByTestId(buttonTestId); + userEvent.click(button); + + expect(addModal).toHaveBeenCalledWith(expect.objectContaining({ subjectId: subject.id, subjectIsStatic: subjectIsStatic(subject), title: `Historical Data: ${subject.name}` })); + }); + }); +}); \ No newline at end of file diff --git a/src/SubjectControls/styles.module.scss b/src/SubjectControls/styles.module.scss index e4412cd39..e977c79ab 100644 --- a/src/SubjectControls/styles.module.scss +++ b/src/SubjectControls/styles.module.scss @@ -1,8 +1,11 @@ -@import '../common/styles/layout'; + +@import '../common/styles/vars/colors'; +@import './mixins'; .controls { + align-items: center; display: flex; - justify-content: space-around; + justify-content: flex-start; &.noTitles { span { display: none; @@ -10,12 +13,43 @@ } } -.messagingButton { - border: none; - padding: 0rem; +.container { + position: relative; + @include control; +} + +.loadingOverlay { + z-index: 1000; +} + + +.hasLabel { + background-color: rgba($secondary-light-gray, .25); + border: 0.06rem solid $secondary-medium-light-gray; + border-radius: 0.12rem; + max-width: 5.75rem; + max-height: 1.85rem; + border-radius: 0.2rem; + overflow: hidden; + cursor: pointer; - svg { - height: 2.2rem; - width: 2.2rem; + [class*=spinner] { + position: relative; + top: -16%; } + + .button { + min-height: 1.80rem; + height: 1.80rem; + min-width: 1.80rem; + width: 1.80rem; + } + + span { + font-size: 0.65rem; + line-height: normal; + padding: 0.1rem 0.3rem; + margin: 0 -.2rem; + } + } \ No newline at end of file diff --git a/src/SubjectHistoricalDataModal/index.js b/src/SubjectHistoricalDataModal/index.js index 516a70d7c..49548a749 100644 --- a/src/SubjectHistoricalDataModal/index.js +++ b/src/SubjectHistoricalDataModal/index.js @@ -10,6 +10,9 @@ import startCase from 'lodash/startCase'; import { fetchObservationsForSubject } from '../ducks/observations'; import { removeModal } from '../ducks/modals'; + +import { calcGpsDisplayString } from '../utils/location'; + import LoadingOverlay from '../LoadingOverlay'; import DateTime from '../DateTime'; @@ -26,7 +29,7 @@ export const getObservationUniqProperties = (observations) => { return uniqPropertiesByLabel.map(property => property.label); }; -const SubjectHistoricalDataModal = ({ title, subjectId, fetchObservationsForSubject }) => { +const SubjectHistoricalDataModal = ({ gpsFormat, title, subjectId, subjectIsStatic, fetchObservationsForSubject }) => { const [loading, setLoadState] = useState(true); const [subjectObservations, setSubjectObservations] = useState([]); const [observationsCount, setObservationsCount] = useState(1); @@ -44,10 +47,6 @@ const SubjectHistoricalDataModal = ({ title, subjectId, fetchObservationsForSubj }); }, [fetchObservationsForSubject, subjectId]); - useEffect(() => { - if (activePage === 1) fetchObservations(); - }, [activePage, fetchObservations]); - useEffect(() => { fetchObservations(activePage); }, [activePage, fetchObservations]); @@ -75,15 +74,20 @@ const SubjectHistoricalDataModal = ({ title, subjectId, fetchObservationsForSubj Date {observationProperties.map(property => {startCase(property)})} + {!subjectIsStatic && Location} - {subjectObservations.map(({ id, recorded_at, device_status_properties }) => - + {subjectObservations.map(({ id, recorded_at, location, device_status_properties }) => { + const locationString = !subjectIsStatic && calcGpsDisplayString(location.latitude, location.longitude, gpsFormat); + + return {observationProperties.map(property => {getMatchedProperty(property, device_status_properties)})} - - )} + {!!locationString && {locationString} + } + ; + })} {observationsCount > ITEMS_PER_PAGE && ({ gpsFormat }); -export default connect(null, { fetchObservationsForSubject, removeModal })(memo(SubjectHistoricalDataModal)); \ No newline at end of file +export default connect(mapStateToProps, { fetchObservationsForSubject, removeModal })(memo(SubjectHistoricalDataModal)); \ No newline at end of file diff --git a/src/SubjectHistoricalDataModal/index.test.js b/src/SubjectHistoricalDataModal/index.test.js index 40f05f313..38d79b4e8 100644 --- a/src/SubjectHistoricalDataModal/index.test.js +++ b/src/SubjectHistoricalDataModal/index.test.js @@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event'; import { fetchObservationsForSubject } from '../ducks/observations'; import { mockStore } from '../__test-helpers/MockStore'; +import { GPS_FORMATS } from '../utils/location'; import mockedObservationsData from '../__test-helpers/fixtures/observations'; import SubjectHistoricalDataModal, { ITEMS_PER_PAGE, getObservationUniqProperties } from './'; @@ -16,7 +17,7 @@ jest.mock('../ducks/observations', () => ({ fetchObservationsForSubject: jest.fn(), })); -const store = mockStore({ data: {}, view: {} }); +const store = mockStore({ data: {}, view: { userPreferences: { gpsFormat: GPS_FORMATS.DEG } } }); describe('SubjectHistoricalDataModal', () => { let fetchObservationsForSubjectMock; @@ -56,7 +57,8 @@ describe('SubjectHistoricalDataModal', () => { expect(tableHeaders[2].childNodes[0]).toHaveTextContent('Temperature'); expect(tableCells[2].childNodes[0]).toHaveTextContent('1000 c'); - expect(tableHeaders[3]).toBe(undefined); + expect(tableHeaders[3].childNodes[0]).toHaveTextContent('Location'); + expect(tableCells[3].childNodes[0]).toHaveTextContent('20.701133°, -103.572941°'); }); }); describe('pagination', () => { diff --git a/src/SubjectHistoryButton/PatrolAwareTrackToggleButton.js b/src/SubjectHistoryButton/PatrolAwareTrackToggleButton.js new file mode 100644 index 000000000..ef1176f16 --- /dev/null +++ b/src/SubjectHistoryButton/PatrolAwareTrackToggleButton.js @@ -0,0 +1,69 @@ +import React, { memo, useCallback, useMemo } from 'react'; +import { connect } from 'react-redux'; +import isEqual from 'react-fast-compare'; + +import { togglePatrolTrackState } from '../ducks/patrols'; +import { toggleTrackState } from '../ducks/map-ui'; + +import { trackEventFactory, PATROL_LIST_ITEM_CATEGORY } from '../utils/analytics'; + +import TrackToggleButton from './'; + +const patrolListItemTracker = trackEventFactory(PATROL_LIST_ITEM_CATEGORY); + +const PatrolAwareTrackToggleButton = ({ buttonRef, dispatch: _dispatch, patrolData, patrolTrackState, subjectTrackState, togglePatrolTrackState, toggleTrackState, ...rest }) => { + const { patrol, leader } = patrolData; + + const patrolTrackPinned = useMemo(() => patrolTrackState.pinned.includes(patrol.id), [patrol.id, patrolTrackState.pinned]); + const patrolTrackVisible = useMemo(() => !patrolTrackPinned && patrolTrackState.visible.includes(patrol.id), [patrol.id, patrolTrackPinned, patrolTrackState.visible]); + const patrolTrackHidden = useMemo(() => !patrolTrackPinned && !patrolTrackVisible, [patrolTrackPinned, patrolTrackVisible]); + + const subjectTrackPinned = useMemo(() => !!leader && subjectTrackState.pinned.includes(leader.id), [leader, subjectTrackState.pinned]); + const subjectTrackVisible = useMemo(() => !!leader && !subjectTrackPinned && subjectTrackState.visible.includes(leader.id), [leader, subjectTrackPinned, subjectTrackState.visible]); + const subjectTrackHidden = useMemo(() => !subjectTrackPinned && !subjectTrackVisible, [subjectTrackPinned, subjectTrackVisible]); + // trackVisible={patrolTrackVisible} trackPinned={patrolTrackPinned} onClick={onTrackButtonClick} + + const patrolToggleStates = useMemo(() => [patrolTrackPinned, patrolTrackVisible, patrolTrackHidden], [patrolTrackHidden, patrolTrackPinned, patrolTrackVisible]); + const subjectToggleStates = useMemo(() => [subjectTrackPinned, subjectTrackVisible, subjectTrackHidden], [subjectTrackHidden, subjectTrackPinned, subjectTrackVisible]); + + const onTrackButtonClick = useCallback(() => { + const nextPatrolTrackStateIfToggled = patrolTrackPinned + ? 'hidden' + : patrolTrackHidden + ? 'visible' + : 'pinned'; + + if (!leader) return; + const actionToTrack = `Toggle patrol track state to ${nextPatrolTrackStateIfToggled} from patrol card popover`; + + if (isEqual(patrolToggleStates, subjectToggleStates)) { + toggleTrackState(leader.id); + togglePatrolTrackState(patrol.id); + patrolListItemTracker.track(actionToTrack); + return; + } + if (!patrolTrackHidden && subjectTrackHidden) { + togglePatrolTrackState(patrol.id); + patrolListItemTracker.track(actionToTrack); + return; + } + if (subjectTrackPinned && patrolTrackVisible) { + togglePatrolTrackState(patrol.id); + patrolListItemTracker.track(actionToTrack); + } + if (patrolTrackPinned && subjectTrackVisible) { + toggleTrackState(leader.id); + } + if (patrolTrackHidden && !subjectTrackHidden) { + toggleTrackState(leader.id); + return; + } + }, [leader, patrol.id, patrolToggleStates, patrolTrackHidden, patrolTrackPinned, patrolTrackVisible, subjectToggleStates, subjectTrackHidden, subjectTrackPinned, subjectTrackVisible, togglePatrolTrackState, toggleTrackState]); + + return ; +}; + + +const mapStateToProps = ({ view: { patrolTrackState, subjectTrackState } }) => ({ patrolTrackState, subjectTrackState }); + +export default connect(mapStateToProps, { togglePatrolTrackState, toggleTrackState })(memo(PatrolAwareTrackToggleButton)); \ No newline at end of file diff --git a/src/SubjectHistoryButton/index.js b/src/SubjectHistoryButton/index.js new file mode 100644 index 000000000..ff9aefaf0 --- /dev/null +++ b/src/SubjectHistoryButton/index.js @@ -0,0 +1,26 @@ +import React, { forwardRef, memo } from 'react'; +import noop from 'lodash/noop'; +import PropTypes from 'prop-types'; +import styles from './styles.module.scss'; + +import SubjectControlButton from '../SubjectControls/button'; + +const SubjectHistoryButton = (props, ref) => { + + return ; +}; + +export default memo(forwardRef(SubjectHistoryButton)); + +SubjectHistoryButton.defaultProps = { + onClick: noop, + showLabel: true, +}; + +SubjectHistoryButton.propTypes = { + trackVisible: PropTypes.bool.isRequired, + trackPinned: PropTypes.bool.isRequired, + onClick: PropTypes.func, + showLabel: PropTypes.bool, + loading: PropTypes.bool, +}; \ No newline at end of file diff --git a/src/SubjectHistoryButton/index.test.js b/src/SubjectHistoryButton/index.test.js new file mode 100644 index 000000000..83efb4792 --- /dev/null +++ b/src/SubjectHistoryButton/index.test.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { render, screen, userEvent, waitFor } from '@testing-library/react'; + +import SubjectHistoryButton from './'; + +const onClick = jest.fn(); + +test('rendering without crashing', () => { + render(); +}); + +test('call onClick', () => { + render(); + waitFor(async () => { + const toggleButton = await screen.getByRole('button'); + userEvent.click(toggleButton); + expect(onClick).toHaveBeenCalled(); + }); +}); + +describe('setting label visibility', () => { + test('showing the label showLabel is true', () => { + render(); + expect(screen.getByText('Historical Data')).toBeTruthy(); + }); + + + test('not showing any label if showLabel is false', () => { + render(); + expect(() => screen.getByText('Historical Data')).toThrow(); + }); +}); \ No newline at end of file diff --git a/src/SubjectHistoryButton/styles.module.scss b/src/SubjectHistoryButton/styles.module.scss new file mode 100644 index 000000000..b22539f3b --- /dev/null +++ b/src/SubjectHistoryButton/styles.module.scss @@ -0,0 +1,47 @@ +@import '../common/styles/buttons'; +@import '../common/styles/vars/colors'; +@import '../SubjectControls/mixins'; + + +.container { + display: flex; + align-items: center; + flex-flow: row; + position: relative; + .button { + @include buttonBackground('historical-data'); + pointer-events: none; + background-size: 90%; + background-repeat: no-repeat; + background-position: center; + } + + &.hasLabel { + background-color: $subject-control-btn-bg; + border: 0.06px solid $secondary-medium-light-gray; + max-width: 5.75rem; + max-height: 1.85rem; + border-radius: 0.25rem; + cursor: pointer; + + [class*=spinner] { + position: relative; + top: -16%; + } + + button { + min-height: 1.80rem; + height: 1.80rem; + min-width: 1.80rem; + width: 1.80rem; + } + + span { + font-size: 0.65rem; + line-height: normal; + padding: .1rem .7rem; + margin: 0 -.2rem; + } + } + +} diff --git a/src/SubjectMessagesPopover/index.js b/src/SubjectMessagesPopover/index.js index 9c0ed4aa1..a86cf9880 100644 --- a/src/SubjectMessagesPopover/index.js +++ b/src/SubjectMessagesPopover/index.js @@ -1,6 +1,7 @@ import React, { memo, useMemo } from 'react'; import Popover from 'react-bootstrap/Popover'; import OverlayTrigger from 'react-bootstrap/OverlayTrigger'; +import SubjectControlButton from '../SubjectControls/button'; import { usePermissions } from '../hooks'; import { PERMISSION_KEYS, PERMISSIONS } from '../constants'; @@ -14,10 +15,12 @@ import { ReactComponent as ChatIcon } from '../common/images/icons/chat-icon.svg import styles from './styles.module.scss'; const SubjectMessagesPopover = (props) => { - const { className = '', subject } = props; + const { className = '', subject, ...rest } = props; const hasMessagingWritePermissions = usePermissions(PERMISSION_KEYS.MESSAGING, PERMISSIONS.CREATE); + const buttonClassName = `${className} ${styles.button}`; + const params = useMemo(() => { return { subject_id: subject?.id }; }, [subject]); @@ -29,15 +32,13 @@ const SubjectMessagesPopover = (props) => {
{subject.name}
- + {!!hasMessagingWritePermissions && } ; return - + ; }; diff --git a/src/SubjectMessagesPopover/styles.module.scss b/src/SubjectMessagesPopover/styles.module.scss index 3e0b15bf7..2ebcd0ca4 100644 --- a/src/SubjectMessagesPopover/styles.module.scss +++ b/src/SubjectMessagesPopover/styles.module.scss @@ -1,4 +1,4 @@ -@import '../common/styles/vars/colors'; +@import '../SubjectControls/mixins'; .popover { height: 22rem; @@ -8,22 +8,10 @@ [class*=popover-body] { height: 15.5rem; } +} - h6 { - svg { - width: 2.5rem; - height: 2.5rem; - margin: -0.625rem; - margin-right: -0.312rem; - fill: $dark-gray; - - rect { - fill: transparent; - } - - path { - fill: $dark-gray; - } - } +.container { + .button { + @include buttonBackground('chat-icon'); } -} \ No newline at end of file +} diff --git a/src/SubjectPopup/index.js b/src/SubjectPopup/index.js index a528932f4..142f93b1a 100644 --- a/src/SubjectPopup/index.js +++ b/src/SubjectPopup/index.js @@ -1,4 +1,4 @@ -import React, { lazy, memo, Fragment, useCallback, useMemo, useState } from 'react'; +import React, { memo, Fragment, useCallback, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import format from 'date-fns/format'; @@ -11,7 +11,6 @@ import TrackLength from '../TrackLength'; import SubjectControls from '../SubjectControls'; import AddReport from '../AddReport'; -import { addModal } from '../ducks/modals'; import { showPopup } from '../ducks/popup'; import { DEVELOPMENT_FEATURE_FLAGS } from '../constants'; @@ -23,11 +22,9 @@ import styles from './styles.module.scss'; const { ENABLE_UFA_NAVIGATION_UI } = DEVELOPMENT_FEATURE_FLAGS; -const SubjectHistoricalDataModal = lazy(() => import('../SubjectHistoricalDataModal')); - const STORAGE_KEY = 'showSubjectDetailsByDefault'; -const SubjectPopup = ({ data, popoverPlacement, timeSliderState, addModal, showPopup }) => { +const SubjectPopup = ({ data, popoverPlacement, timeSliderState, showPopup }) => { const { geometry, properties } = data; const { active: isTimeSliderActive } = timeSliderState; @@ -39,8 +36,10 @@ const SubjectPopup = ({ data, popoverPlacement, timeSliderState, addModal, showP const { tracks_available } = properties; const coordProps = typeof properties.coordinateProperties === 'string' ? JSON.parse(properties.coordinateProperties) : properties.coordinateProperties; + const isStatic = subjectIsStatic(data); + const hasAdditionalDeviceProps = !!device_status_properties?.length; - const additionalPropsShouldBeToggleable = hasAdditionalDeviceProps && device_status_properties.length > 2 && !subjectIsStatic(data); + const additionalPropsShouldBeToggleable = hasAdditionalDeviceProps && device_status_properties.length > 2 && !isStatic; const [additionalPropsToggledOn, toggleAdditionalPropsVisibility] = useState(window.localStorage.getItem(STORAGE_KEY) === 'true' ? true : false); const showAdditionalProps = hasAdditionalDeviceProps && @@ -61,10 +60,6 @@ const SubjectPopup = ({ data, popoverPlacement, timeSliderState, addModal, showP showPopup('subject-messages', { geometry, properties: properties, coordinates: geometry.coordinates }); }, [geometry, properties, showPopup]); - const onHistoricalDataClick = useCallback(() => { - addModal({ title: 'Historical Data', content: SubjectHistoricalDataModal, subjectId: properties.id }); - }, [addModal, properties]); - const locationObject = { longitude: geometry.coordinates[0], latitude: geometry.coordinates[1], @@ -78,7 +73,7 @@ const SubjectPopup = ({ data, popoverPlacement, timeSliderState, addModal, showP
{properties.default_status_value && <> {properties.image && {`Subject} - {!isTimeSliderActive ? properties.default_status_value : 'No historical data'} + {properties.default_status_value} }
{properties.name}
@@ -110,34 +105,27 @@ const SubjectPopup = ({ data, popoverPlacement, timeSliderState, addModal, showP
} {tracks_available && } - {hasAdditionalDeviceProps && showAdditionalProps &&