diff --git a/app/components/chart/basics.js b/app/components/chart/basics.js index 89b9b6e300..81685b483c 100644 --- a/app/components/chart/basics.js +++ b/app/components/chart/basics.js @@ -4,7 +4,7 @@ import _ from 'lodash'; import bows from 'bows'; import sundial from 'sundial'; import { withTranslation, Trans } from 'react-i18next'; -import { Flex } from 'theme-ui'; +import { Box, Flex } from 'theme-ui'; // tideline dependencies & plugins import tidelineBlip from 'tideline/plugins/blip'; @@ -82,73 +82,75 @@ class Basics extends Component { return (
-
-
-
-
+ +
+ + + {renderedContent} {!this.isMissingBasics() && ( - - - + )} -
-
-
-
-
- - - - - + + + + - -
-
-
-
+ + + + + +
- ); + ); }; renderChart = () => { diff --git a/app/components/chart/bgLog.js b/app/components/chart/bgLog.js index db211c2722..6624e40886 100644 --- a/app/components/chart/bgLog.js +++ b/app/components/chart/bgLog.js @@ -260,16 +260,16 @@ class BgLog extends Component { return (
- {this.isMissingSMBG() ? this.renderMissingSMBGHeader() : this.renderHeader()} -
-
-
+ + {this.isMissingSMBG() ? this.renderMissingSMBGHeader() : this.renderHeader()} + + + {renderedContent} - @@ -297,10 +301,9 @@ class BgLog extends Component { /> -
-
-
-
+ + + -
-
-
- + + + +
); }; diff --git a/app/components/chart/daily.js b/app/components/chart/daily.js index d4a1218057..c364de451a 100644 --- a/app/components/chart/daily.js +++ b/app/components/chart/daily.js @@ -23,7 +23,7 @@ import ReactDOM from 'react-dom'; import sundial from 'sundial'; import WindowSizeListener from 'react-window-size-listener'; import { withTranslation } from 'react-i18next'; -import { Flex } from 'theme-ui'; +import { Box, Flex } from 'theme-ui'; import Stats from './stats'; import BgSourceToggle from './bgSourceToggle'; @@ -288,41 +288,46 @@ class Daily extends Component { return (
-
-
-
-
- - {dataQueryComplete && this.renderChart()} - - - - -
-
-
-
+ + + -
-
-
- {this.state.hoveredBolus && } - {this.state.hoveredSMBG && } - {this.state.hoveredCBG && } - {this.state.hoveredCarb && } - {this.state.hoveredPumpSettingsOverride && } - + + + {this.state.hoveredBolus && } + {this.state.hoveredSMBG && } + {this.state.hoveredCBG && } + {this.state.hoveredCarb && } + {this.state.hoveredPumpSettingsOverride && } + +
); }; diff --git a/app/components/chart/header.js b/app/components/chart/header.js index f9d5673c85..f35a43ec71 100644 --- a/app/components/chart/header.js +++ b/app/components/chart/header.js @@ -8,6 +8,7 @@ import PrintRoundedIcon from '@material-ui/icons/PrintRounded'; import colorPalette from '../../themes/colorPalette'; import Icon from '../elements/Icon'; +import { Box } from 'theme-ui'; const Header = withTranslation()(class Header extends Component { static propTypes = { @@ -121,7 +122,7 @@ const Header = withTranslation()(class Header extends Component { }); return ( -
+
{t('Basics')} {t('Daily')} @@ -169,7 +170,7 @@ const Header = withTranslation()(class Header extends Component { {t('Print')}
-
+ ); }; @@ -194,11 +195,9 @@ const Header = withTranslation()(class Header extends Component { render = () => { return ( -
-
- {this.renderStandard()} -
-
+ + {this.renderStandard()} + ); }; diff --git a/app/components/chart/settings.js b/app/components/chart/settings.js index 3c8cf762da..f3d61408e9 100644 --- a/app/components/chart/settings.js +++ b/app/components/chart/settings.js @@ -3,10 +3,12 @@ import bows from 'bows'; import PropTypes from 'prop-types'; import React, { useState, useCallback, useEffect } from 'react'; import { Trans, withTranslation } from 'react-i18next'; -import { Flex, Box, Text } from 'theme-ui'; +import { Flex, Box, Text, Divider, Link } from 'theme-ui'; import moment from 'moment-timezone'; import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; import DateRangeRoundedIcon from '@material-ui/icons/DateRangeRounded'; +import AddRoundedIcon from '@material-ui/icons/AddRounded'; +import launchCustomProtocol from 'custom-protocol-detection'; import { bindPopover, @@ -28,7 +30,15 @@ import Button from '../elements/Button'; import Popover from '../elements/Popover'; import RadioGroup from '../../components/elements/RadioGroup'; import { usePrevious } from '../../core/hooks'; +import { clinicPatientFromAccountInfo } from '../../core/personutils'; import Icon from '../elements/Icon'; +import { useSelector } from 'react-redux'; +import DataConnections, { activeProviders } from '../../components/datasources/DataConnections'; +import DataConnectionsBanner from '../../components/elements/Card/Banners/DataConnections.png'; +import DataConnectionsModal from '../../components/datasources/DataConnectionsModal'; +import Card from '../elements/Card'; +import { Body1, MediumTitle } from '../elements/FontStyles'; +import Uploadlaunchoverlay from '../uploadlaunchoverlay'; const log = bows('Settings View'); @@ -60,6 +70,8 @@ const Settings = ({ onSwitchToBgLog, onClickPrint, patient, + clinicPatient, + isUserPatient, trackMetric, updateChartPrefs, uploadUrl, @@ -78,6 +90,11 @@ const Settings = ({ const [devices, setDevices] = useState([]); const [groupedData, setGroupedData] = useState([]); const previousSelectedDevice = usePrevious(selectedDevice); + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); + const isClinicContext = !!selectedClinicId; + const [showDataConnectionsModal, setShowDataConnectionsModal] = useState(false); + const [showUploadOverlay, setShowUploadOverlay] = useState(false); + const patientData = clinicPatient || clinicPatientFromAccountInfo(patient); const deviceSelectionPopupState = usePopupState({ variant: 'popover', @@ -259,6 +276,13 @@ const Settings = ({ onSwitchToBgLog(); }, [onSwitchToBgLog]); + const handleClickDataConnections = function(source) { + const properties = { patientID: currentPatientInViewId, source }; + if (selectedClinicId) properties.clinicId = selectedClinicId; + trackMetric('Clicked Settings Add Data Connections', properties); + setShowDataConnectionsModal(true); + }; + const toggleSettingsSection = useCallback((deviceKey, scheduleOrProfileKey) => { const prefs = _.cloneDeep(chartPrefs); @@ -273,6 +297,218 @@ const Settings = ({ updateChartPrefs(prefs, false); }, [chartPrefs, updateChartPrefs]); + const renderDeviceSettingsSelectionUI = () => ( + + { + if (!deviceSelectionPopupState.isOpen) + trackMetric(prefixSettingsMetric('Device selection open')); + }} + sx={{ flexShrink: 0 }} + > + + + + { + trackMetric(prefixSettingsMetric('Device selection close')); + }} + onClose={() => { + deviceSelectionPopupState.close(); + }} + > + + { + setPendingDevice(event.target.value || null); + }} + /> + + + + + + + + + + + — View settings from + + + { + if (!settingsSelectionPopupState.isOpen) + trackMetric( + prefixSettingsMetric('Settings selection open') + ); + }} + sx={{ flexShrink: 0, alignItems: 'center' }} + > + + + + { + trackMetric(prefixSettingsMetric('Settings selection close')); + }} + onClose={() => { + settingsSelectionPopupState.close(); + }} + > + + + + {t('Past therapy settings')} + + + + { + setPendingSettings(event.target.value || null); + }} + /> + + + + + + + + + + ); + const renderChart = () => { const pumpSettings = _.find(groupedData, { 0: selectedDevice })?.[1]; const selectedSettings = _.find(pumpSettings, { id: selectedSettingsId }); @@ -294,265 +530,161 @@ const Settings = ({ }; const renderMissingSettingsMessage = () => { - const handleClickUpload = () => { - trackMetric('Clicked Partial Data Upload, No Settings'); + const handleClickUpload = function(e) { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + + const properties = { patientID: currentPatientInViewId }; + if (selectedClinicId) properties.clinicId = selectedClinicId; + trackMetric('Clicked Partial Data Upload, No Settings', properties); + setShowUploadOverlay(true); + launchCustomProtocol('tidepoolupload://open'); }; return ( - -

The Device Settings view shows your basal rates, carb ratios, sensitivity factors and more, but it looks like you haven't uploaded pump data yet.

-

To see your Device Settings, + + This section shows basal rates, carb ratios, sensitivity factors, and more. To see Therapy Settings, upload your pump.

-

- If you just uploaded, try refreshing. -

+ onClick={handleClickUpload} + >upload data from a pump. If you just uploaded try refreshing. +
); }; - return ( -
-
{ + const cardProps = { + id: 'data-connections-card', + title: isUserPatient + ? t('Connect an Account') + : t('Connect a Device Account'), + subtitle: isUserPatient + ? t('Do you have a Dexcom, LibreView or twiist account? When you connect an account, data can flow into Tidepool without any extra effort.') + : t('Does your patient have a Dexcom, LibreView, or twiist account? Automatically sync data from these accounts with the patient\'s permission.'), + bannerImage: DataConnectionsBanner, + onClick: handleClickDataConnections.bind(null, 'card'), + variant: 'containers.cardHorizontal', + }; + + return ( + + ); + }; + + const renderDataConnectionsModal = () => { + const shownProviders = _.reject(activeProviders, providerName => _.find(patientData?.dataSources, { providerName })); + + return ( + setShowDataConnectionsModal(false)} /> -
-
-
- - { - if (!deviceSelectionPopupState.isOpen) - trackMetric(prefixSettingsMetric('Device selection open')); - }} - sx={{ flexShrink: 0 }} - > - - + ); + }; - { - trackMetric(prefixSettingsMetric('Device selection close')); - }} - onClose={() => { - deviceSelectionPopupState.close(); - }} - > - - { - setPendingDevice(event.target.value || null); - }} - /> - - - - - - - - - - - — View settings from - + const renderDataConnections = () => { + const shownProviders = _.map(patientData?.dataSources, 'providerName'); - { - if (!settingsSelectionPopupState.isOpen) - trackMetric( - prefixSettingsMetric('Settings selection open') - ); - }} - sx={{ flexShrink: 0, alignItems: 'center' }} - > - - + let showAddDevicesButton = false; + _.each(activeProviders, providerName => { + if (!_.find(patientData?.dataSources, { providerName })) showAddDevicesButton = true; + }); - { - trackMetric(prefixSettingsMetric('Settings selection close')); - }} - onClose={() => { - settingsSelectionPopupState.close(); - }} - > - - - - {t('Past therapy settings')} - - + return ( + + + {t('Devices')} + {showAddDevicesButton && ( + + )} + - { - setPendingSettings(event.target.value || null); - }} - /> - - - - - - - - - + + + ); + }; - {selectedSettingsId ? renderChart() : renderMissingSettingsMessage()} + const renderUploadOverlay = () => ( + setShowUploadOverlay(false)}/> + ); + + return ( +
+ +
+ + + + + + {(isClinicContext || isUserPatient) && ( + + {patientData?.dataSources?.length > 0 ? renderDataConnections() : renderDeviceConnectionCard()} + + )} + + {t('Therapy Settings')} + + {selectedSettingsId ? ( + <> + {renderDeviceSettingsSelectionUI()} + {renderChart()} + + ) : renderMissingSettingsMessage()} + - - -
-
-
+ + + + + {showDataConnectionsModal && renderDataConnectionsModal()} + {showUploadOverlay && renderUploadOverlay()} +
); }; diff --git a/app/components/chart/trends.js b/app/components/chart/trends.js index 24b3f0b1c6..c587f74f98 100644 --- a/app/components/chart/trends.js +++ b/app/components/chart/trends.js @@ -8,7 +8,7 @@ import React, { PureComponent } from 'react'; import sundial from 'sundial'; import WindowSizeListener from 'react-window-size-listener'; import { withTranslation } from 'react-i18next'; -import { Flex } from 'theme-ui'; +import { Box, Flex } from 'theme-ui'; import Header from './header'; import SubNav from './trendssubnav'; @@ -477,18 +477,26 @@ const Trends = withTranslation()(class Trends extends PureComponent { return (
- {this.renderHeader()} -
-
- {this.renderSubNav()} -
+ + {this.renderHeader()} + + + + + {this.renderSubNav()} + +
{dataQueryComplete && this.renderChart()}
- @@ -568,42 +576,41 @@ const Trends = withTranslation()(class Trends extends PureComponent { {dataQueryComplete && this.renderFocusedCbgDateTraceLabel()} {dataQueryComplete && this.renderFocusedSMBGPointLabel()} {dataQueryComplete && this.renderFocusedRangeLabels()} -
-
-
-
- - + + + + + + + - - - - -
-
-
- + + + +
); } diff --git a/app/components/clinic/ClinicWorkspaceHeader.js b/app/components/clinic/ClinicWorkspaceHeader.js index a355fc27e9..cd0486223c 100644 --- a/app/components/clinic/ClinicWorkspaceHeader.js +++ b/app/components/clinic/ClinicWorkspaceHeader.js @@ -71,7 +71,7 @@ export const ClinicWorkspaceHeader = (props) => { { const { patient, + shownProviders, trackMetric, ...themeProps } = props; @@ -521,9 +523,10 @@ export const DataConnections = (props) => { return ( <> - {map(activeProviders, (provider, i) => ( + {map(intersection(shownProviders, activeProviders), (provider, i) => ( { onClose, onBack, patient, + shownProviders, trackMetric, } = props; @@ -164,7 +165,7 @@ export const DataConnectionsModal = (props) => { )} - + @@ -213,6 +214,7 @@ DataConnectionsModal.propTypes = { onClose: PropTypes.func.isRequired, open: PropTypes.bool, patient: PropTypes.object.isRequired, + shownProviders: PropTypes.arrayOf(PropTypes.oneOf(activeProviders)), trackMetric: PropTypes.func.isRequired, }; diff --git a/app/components/elements/Card/Banners/DataConnections.png b/app/components/elements/Card/Banners/DataConnections.png new file mode 100644 index 0000000000..44468400d9 Binary files /dev/null and b/app/components/elements/Card/Banners/DataConnections.png differ diff --git a/app/components/elements/Card/Banners/Uploader.png b/app/components/elements/Card/Banners/Uploader.png new file mode 100644 index 0000000000..94f5f331a9 Binary files /dev/null and b/app/components/elements/Card/Banners/Uploader.png differ diff --git a/app/components/elements/Card/Card.js b/app/components/elements/Card/Card.js new file mode 100644 index 0000000000..b2a73030e4 --- /dev/null +++ b/app/components/elements/Card/Card.js @@ -0,0 +1,90 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, BoxProps, Flex, Image } from 'theme-ui'; +import noop from 'lodash/noop'; + +import { Body1, Title } from '../FontStyles'; + +export function Card(props) { + const { onClick, title, subtitle, bannerImage, children, variant, ...themeProps } = props; + + return ( + + {bannerImage && ( + + + + )} + + + {!!title && ( + + {title} + + )} + + {!!subtitle && ( + + {subtitle} + + )} + + {!!children && ( + + {children} + + )} + + + ); +} + +Card.propTypes = { + ...BoxProps, + bannerImage: PropTypes.elementType, + title: PropTypes.string, + subtitle: PropTypes.string, + onClick: PropTypes.func.isRequired, + variant: PropTypes.oneOf(['containers.card', 'containers.cardHorizontal']) +}; + +Card.defaultProps = { + variant: 'containers.card', + bannerImage: true, + onClick: noop, +}; + +export default Card; diff --git a/app/components/elements/Card/index.js b/app/components/elements/Card/index.js new file mode 100644 index 0000000000..b7d457fa25 --- /dev/null +++ b/app/components/elements/Card/index.js @@ -0,0 +1,3 @@ +import Card from './Card'; + +export default Card; diff --git a/app/components/navpatientheader/index.js b/app/components/navpatientheader/index.js index 7e7abbc0c0..4506f3e5ae 100644 --- a/app/components/navpatientheader/index.js +++ b/app/components/navpatientheader/index.js @@ -12,10 +12,11 @@ import ClinicianMenuOptions from './MenuOptions/Clinician'; import { isClinicianAccount } from '../../core/personutils'; import { getPermissions, getPatientListLink, getDemographicInfo } from './navPatientHeaderHelpers'; import UploadLaunchOverlay from '../../components/uploadlaunchoverlay'; +import { breakpoints } from '../../themes/baseTheme'; const HeaderContainer = ({ children }) => ( - ( ); -const NavPatientHeader = ({ +const NavPatientHeader = ({ patient, clinicPatient, - user, + user, permsOfLoggedInUser, trackMetric, - clinicFlowActive, - selectedClinicId, - query, + clinicFlowActive, + selectedClinicId, + query, }) => { const history = useHistory(); const [isUploadOverlayOpen, setIsUploadOverlayOpen] = useState(false); @@ -68,42 +69,50 @@ const NavPatientHeader = ({ trackMetric('Clicked Navbar Name'); history.push(`/patients/${patient.userid}/profile`); } - + const handleShare = () => { trackMetric('Clicked Navbar Share Data'); history.push(`/patients/${patient.userid}/share`); } return ( -
+ { isClinicianAccount(user) ? <> - + /> : <> - + /> } { isUploadOverlayOpen && - setIsUploadOverlayOpen(false)} /> + setIsUploadOverlayOpen(false)} /> } -
+ ); } -export default NavPatientHeader; \ No newline at end of file +export default NavPatientHeader; diff --git a/app/core/icons/libre_logo.svg b/app/core/icons/libre_logo.svg index b4aa8d7ab4..bd6b7d18d8 100644 --- a/app/core/icons/libre_logo.svg +++ b/app/core/icons/libre_logo.svg @@ -1,14 +1,19 @@ - - - + + + + + + + + + + + + + + + + + - - - - - - - - - diff --git a/app/core/personutils.js b/app/core/personutils.js index 2050148c49..322c091104 100644 --- a/app/core/personutils.js +++ b/app/core/personutils.js @@ -208,6 +208,7 @@ personUtils.clinicPatientFromAccountInfo = patient => ({ fullName: personUtils.patientFullName(patient), birthDate: _.get(patient, 'profile.patient.birthday'), mrn: _.get(patient, 'profile.patient.mrn'), + dataSources: patient.dataSources, }); personUtils.clinicPatientFromPatientInvite = invite => ({ diff --git a/app/pages/patientdata/patientdata.js b/app/pages/patientdata/patientdata.js index 5b25759dd1..f6c4f7bcb5 100644 --- a/app/pages/patientdata/patientdata.js +++ b/app/pages/patientdata/patientdata.js @@ -42,12 +42,9 @@ import Stats from '../../components/chart/stats'; import { bgLog as BgLog } from '../../components/chart'; import { settings as Settings } from '../../components/chart'; import UploadLaunchOverlay from '../../components/uploadlaunchoverlay'; -import baseTheme, { fontWeights, borders, radii } from '../../themes/baseTheme'; -import { Body1, Title } from '../../components/elements/FontStyles'; -import DexcomLogoIcon from '../../core/icons/DexcomLogo.svg'; +import baseTheme, { breakpoints, radii } from '../../themes/baseTheme'; import Messages from '../../components/messages'; -import UploaderButton from '../../components/uploaderbutton'; import ChartDateRangeModal from '../../components/ChartDateRangeModal'; import ChartDateModal from '../../components/ChartDateModal'; import PrintDateRangeModal from '../../components/PrintDateRangeModal'; @@ -55,11 +52,14 @@ import Button from '../../components/elements/Button'; import ToastContext from '../../providers/ToastProvider'; -import { Box, Flex } from 'theme-ui'; +import { Box, Flex, Link } from 'theme-ui'; import Checkbox from '../../components/elements/Checkbox'; import PopoverLabel from '../../components/elements/PopoverLabel'; -import { Paragraph2 } from '../../components/elements/FontStyles'; -import { DIABETES_DATA_TYPES } from '../../core/constants'; +import { Body2, Paragraph1, Paragraph2 } from '../../components/elements/FontStyles'; +import Card from '../../components/elements/Card'; +import UploaderBanner from '../../components/elements/Card/Banners/Uploader.png'; +import DataConnectionsBanner from '../../components/elements/Card/Banners/DataConnections.png'; +import DataConnectionsModal from '../../components/datasources/DataConnectionsModal'; const { Loader } = vizComponents; const { getLocalizedCeiling, getTimezoneFromTimePrefs } = vizUtils.datetime; @@ -241,135 +241,129 @@ export const PatientDataClass = createReactClass({ inTransition={false} atMostRecent={false} title={headerTitle} - ref="header" /> - ); + ref="header" + /> + ); }, renderInitialLoading: function() { - var header = this.renderEmptyHeader(); return ( -
- {header} -
-
-
-
-
-
+ + {this.renderEmptyHeader()} + + ); }, renderNoData: function() { - const { t } = this.props; - var content = t('{{patientName}} does not have any data yet.', {patientName: personUtils.patientFullName(this.props.patient)}); - var header = this.renderEmptyHeader('No Data Available'); - var uploadLaunchOverlay = this.state.showUploadOverlay ? this.renderUploadOverlay() : null; + const { t, currentPatientInViewId, isUserPatient, selectedClinicId } = this.props; + const uploadLaunchOverlay = this.state.showUploadOverlay ? this.renderUploadOverlay() : null; + const dataConnectionsModal = this.state.showDataConnectionsModal ? this.renderDataConnectionsModal() : null; - var self = this; - var handleClickUpload = function() { - self.props.trackMetric('Clicked No Data Upload'); - }; - var handleClickBlipNotes = function() { - self.props.trackMetric('Clicked No Data Get Blip Notes'); - }; - var handleClickDexcomConnect = function() { - self.props.trackMetric('Clicked No Data Connect Dexcom'); - self.props.history.push(`/patients/${self.props.currentPatientInViewId}/profile?dexcomConnect=patient-empty-data`); - }; - var handleClickLaunch = function(e) { + const self = this; + + const handleClickUpload = function(e) { if (e) { e.preventDefault(); e.stopPropagation(); } + + const properties = { patientID: currentPatientInViewId }; + if (selectedClinicId) properties.clinicId = selectedClinicId; + self.props.trackMetric('Clicked No Data Upload Card', properties); self.setState({showUploadOverlay: true}); launchCustomProtocol('tidepoolupload://open'); - } + }; + + const handleClickDataConnections = function() { + const properties = { patientID: currentPatientInViewId }; + if (selectedClinicId) properties.clinicId = selectedClinicId; + self.props.trackMetric('Clicked No Data Data Connections Card', properties); + self.setState({showDataConnectionsModal: true}); + }; + - if (this.props.isUserPatient) { - content = ( - - + {this.renderEmptyHeader(t('No Data Available'))} + + + + + {_.map(cards, card => )} + + + - To upload your data, install Tidepool Uploader - - - - - If you already have Tidepool Uploader, launch it here - - - + + Already uploaded? Click to reload. + + + + Need help? Email us at support@tidepool.org or visit our help page. + + + + -
- - -

- Already uploaded? Click to reload.
- Need help? Email us at support@tidepool.org or visit our help page. -

- - ); - } + {t('Refresh')} + + + + - return ( -
- {header} -
-
-
-
- {content} -
-
-
-
{uploadLaunchOverlay} -
+ {dataConnectionsModal} + ); }, renderUploadOverlay: function() { - return {this.setState({showUploadOverlay: false})}}/> + return {this.setState({ showUploadOverlay: false })}}/> + }, + + renderDataConnectionsModal: function() { + return this.setState({ showDataConnectionsModal: false })} + /> }, renderDatesDialog: function() { @@ -507,6 +501,8 @@ export const PatientDataClass = createReactClass({ currentPatientInViewId={this.props.currentPatientInViewId} data={this.props.data} patient={this.props.patient} + clinicPatient={this.props.clinicPatient} + isUserPatient={this.props.isUserPatient} onClickRefresh={this.handleClickRefresh} onClickNoDataRefresh={this.handleClickNoDataRefresh} onSwitchToBasics={this.handleSwitchToBasics} @@ -2250,6 +2246,7 @@ export function getFetchers(dispatchProps, ownProps, stateProps, api, options) { export function mapStateToProps(state, props) { let user = null; let patient = null; + let clinicPatient = null; let permissions = {}; let permsOfLoggedInUser = {}; @@ -2272,16 +2269,17 @@ export function mapStateToProps(state, props) { ); if (patient && state.blip.selectedClinicId) { + clinicPatient = _.get(state.blip, [ + 'clinics', + state.blip.selectedClinicId, + 'patients', + state.blip.currentPatientInViewId + ], null); + _.set( patient, 'profile.patient.mrn', - _.get(state.blip, [ - 'clinics', - state.blip.selectedClinicId, - 'patients', - state.blip.currentPatientInViewId, - 'mrn' - ]) + clinicPatient?.mrn ); } @@ -2315,6 +2313,7 @@ export function mapStateToProps(state, props) { user: user, isUserPatient: personUtils.isSame(user, patient), patient: { permissions, ...patient }, + clinicPatient, permsOfLoggedInUser: permsOfLoggedInUser, messageThread: state.blip.messageThread, fetchingPatient: state.blip.working.fetchingPatient.inProgress, diff --git a/app/pages/patientdata/patientdata.less b/app/pages/patientdata/patientdata.less index 4486ff06be..7e619a09ad 100644 --- a/app/pages/patientdata/patientdata.less +++ b/app/pages/patientdata/patientdata.less @@ -18,19 +18,6 @@ position: relative; } -// Header -// ==================================== - -.nav-patient-header { - margin: 0 auto 16px; - width: 100%; - max-width: @screen-lg-min; - - @media screen and (max-width: @screen-lg-min) { - padding: 0 20px; - } -} - // Subnav // ==================================== @@ -42,8 +29,6 @@ .patient-data-subnav-inner { color: @blue-primary; background-color: @blue-gray-dark; - border-radius: 3px 3px 0 0; - padding: 0; } @@ -82,7 +67,7 @@ padding: @patient-data-subnav-vertical-padding @patient-data-subnav-horizontal-padding; font-size: 14px; - + &.patient-data-icon { padding: 0; } @@ -153,48 +138,6 @@ float: right; } -// Content -// ==================================== - -.patient-data-content-outer { - display: block; - - @media (min-width: @patient-data-sidebar-bp) { - display: flex; - flex-wrap: nowrap; - } -} - -.patient-data-content-inner { - width: 100%; - background-color: #fff; - padding-top: 5px; - padding-bottom: 5px; -} - -.patient-data-content { - min-height: @patient-data-chart-height; - position: relative; -} - -// Sidebar -// ==================================== - -.patient-data-sidebar { - width: 100%; - background-color: #fff; - padding: @spacing-small; - - @media (min-width: @patient-data-sidebar-bp) { - flex-shrink: 0; - width: @patient-data-sidebar-width; - } -} - -.patient-data-sidebar-inner { - position: relative; -} - // Loader // ==================================== .patient-data > .loader { @@ -315,11 +258,6 @@ } } -.patient-no-data-help { - margin-top: 100px; - line-height: 25px; -} - // Footer // ==================================== diff --git a/app/themes/base/containers.js b/app/themes/base/containers.js index 267961fd96..45e8b63f00 100644 --- a/app/themes/base/containers.js +++ b/app/themes/base/containers.js @@ -1,4 +1,4 @@ -export default ({ borders, colors, radii, space }) => { +export default ({ borders, colors, radii, space, breakpoints }) => { const defaultStyles = { mx: [0, 'auto'], bg: colors.white, @@ -54,7 +54,83 @@ export default ({ borders, colors, radii, space }) => { mb: 0, }; + const card = { + ...defaultStyles, + ...fluid, + ...rounded, + borderLeft: borders.card, + borderRight: borders.card, + borderTop: borders.card, + borderBottom: borders.card, + bg: 'rgba(240, 245, 255, 1)', + mb: 0, + cursor: 'pointer', + + '.card-banner-image': { + height: ['90px', null, '120px'], + }, + + '.card-content': { + p: space[3], + }, + + '&:hover': { + bg: 'rgba(112, 143, 194, 0.1)', + }, + }; + + const cardHorizontal = { + ...card, + width: '100%', + display: 'flex', + flexWrap: ['wrap', null, 'nowrap'], + + '.card-banner-image': { + maxWidth: ['100%', null, '200px'], + // maxWidth: ['100%', '180px', '200px'], + }, + + '.card-content': { + p: space[3], + }, + }; + + const patientData = { + ...bordered, + mx: [0, 4, null, null, 'auto'], + width: ['auto', null, null, 'calc(100% - 48px)'], + maxWidth: breakpoints[3], + overflow: 'hidden', + }; + + const patientDataInner = { + display: 'flex', + px: 3, + py: 4, + bg: 'white', + minHeight: [0, 0, '50vh'], + flexDirection: 'row', + flexWrap: ['wrap', null, 'nowrap'], + gap: 4, + }; + + const patientDataContent = { + width: ['100%', null, 'auto'], + flexGrow: 1, + }; + + const patientDataSidebar = { + width: ['100%', null, '240px', '320px'], + flexShrink: 0, + }; + return { + card, + cardHorizontal, + patientData, + patientDataInner, + patientDataContent, + patientDataSidebar, fluid, fluidRounded: { ...fluid, diff --git a/app/themes/baseTheme.js b/app/themes/baseTheme.js index b18447f809..eb3499e655 100755 --- a/app/themes/baseTheme.js +++ b/app/themes/baseTheme.js @@ -120,6 +120,7 @@ export const borders = { modal: `1px solid ${colors.border.modal}`, divider: `2px solid ${colors.border.divider}`, dividerDark: `2px solid ${colors.border.dividerDark}`, + card: '1px solid rgba(225, 234, 249, 1)', }; export const fonts = { @@ -177,7 +178,7 @@ const variants = { tables: tables({ borders, colors, fonts, fontSizes, shadows, radii }), tags: tags({ colors, fonts, radii, fontWeights }), toasts: toasts({ borders, colors, radii, fontSizes, shadows }), - containers: containers({ borders, colors, radii, space }), + containers: containers({ borders, colors, radii, space, breakpoints }), }; const defaultText = { diff --git a/locales/en/translation.json b/locales/en/translation.json index 73cbc7aea3..f169537110 100644 --- a/locales/en/translation.json +++ b/locales/en/translation.json @@ -493,7 +493,7 @@ "html.peopletable-instructions": "Type a patient name in the search box or click <1>Show All to display all patients.", "html.peopletable-remove-patient-confirm": "<0><0>Are you sure you want to remove patient: <1>{{fullName}}<2> from your list?<1>You will no longer be able to see or comment on their data.", "html.peopletable-remove-patient-tag-confirm": "<0>Are you sure you want to remove the tag: <1><0>{{name}} from the clinic?<1>This tag will also be removed from any patients who have been tagged with it.", - "html.setting-no-uploaded-data": "<0>The Device Settings view shows your basal rates, carb ratios, sensitivity factors and more, but it looks like you haven't uploaded pump data yet.<1>To see your Device Settings, <1>upload your pump.<2>If you just uploaded, try <1>refreshing.", + "html.setting-no-uploaded-data": "", "html.signup-clinician": "If you are a Healthcare Provider and want to create an account, please <1>click here.", "html.signup-invited": "<0>You've been invited to Tidepool.<1>Sign up to view the invitation.", "html.signup-personal": " If you are a provider who lives with diabetes and wants to track and manage your personal diabetes data, please create a separate <1>personal account.", diff --git a/locales/es/translation.json b/locales/es/translation.json index ada954d04c..acbcbd75e2 100644 --- a/locales/es/translation.json +++ b/locales/es/translation.json @@ -493,7 +493,7 @@ "html.peopletable-instructions": "Escriba el nombre de un paciente en el cuadro de búsqueda o haga clic en <1> Mostrar todo para consultar todos los pacientes.", "html.peopletable-remove-patient-confirm": "<0><0>¿Está seguro de que desea eliminar a este paciente: <1>{{fullName}}<2>de su lista?<1>Ya no podrá ver ni comentar sus datos. ", "html.peopletable-remove-patient-tag-confirm": "<0>Are you sure you want to remove the tag: <1><0>{{name}} from the clinic?<1>This tag will also be removed from any patients who have been tagged with it.", - "html.setting-no-uploaded-data": "<0> La vista Configuración del dispositivo muestra las tasas basales, las proporciones de carbohidratos, los factores de sensibilidad y más, pero parece que aún no ha cargado los datos de la bomba. su bomba. <2> Si acaba de cargar, intente <1> actualizar . ", + "html.setting-no-uploaded-data": "", "html.signup-clinician": "Si usted quiere brindar atención médica y desea crear una cuenta, <1> haga clic aquí .", "html.signup-invited": "<0> Ha sido invitado a Tidepool. <1> Regístrese para ver la invitación. ", "html.signup-personal": "Si usted es un personal medico que tiene diabetes y desea rastrear y administrar sus datos personales sobre diabetes, cree una <1> cuenta personal separada.", diff --git a/locales/fr/translation.json b/locales/fr/translation.json index 07f52b1575..4d2fb18af5 100644 --- a/locales/fr/translation.json +++ b/locales/fr/translation.json @@ -493,7 +493,7 @@ "html.peopletable-instructions": "Saisissez le nom d'un patient dans le champ de recherche ou cliquez sur <1>Tout afficher pour afficher tous les patients.", "html.peopletable-remove-patient-confirm": "<0><0>Désirez-vous vraiment retirer les données du patient: <1>{{fullName}}<2> de votre liste?<1>Vous n'aurez plus consulter ou commenter ses données.", "html.peopletable-remove-patient-tag-confirm": "<0>Are you sure you want to remove the tag: <1><0>{{name}} from the clinic?<1>This tag will also be removed from any patients who have been tagged with it.", - "html.setting-no-uploaded-data": "<0>La vue Paramètres de l'appareil affiche votre taux de basale, les ratios de glucides, les facteurs de sensibilité, et plus, mais il semblerait que vous n'avez pas encore téléchargé les données de votre pompe.<1>Pour voir vos Paramètres de l'appareil, <1>téléchargez les données de votre pompe.<2>Si vous venez de faire le téléchargement, essayez de <1>rafraîchir la fenêtre.", + "html.setting-no-uploaded-data": "", "html.signup-clinician": "Si vous êtes un professionnel de la santé et voulez créer un compte, s'il vous plaît <1>cliquez ici.", "html.signup-invited": "<0>Une invitation à rejoindre Tidepool vous a été envoyé.<1>Enregistrez vous pour consulter l'invitation.", "html.signup-personal": " Si vous êtes un aidant naturel qui vit avec le diabète et que vous désirez enregistrer et gérer vos propres données de diabète, veuillez s'il vous plaît créer un <1>compte personnel séparé.", diff --git a/package.json b/package.json index 871790a983..a74b4a7196 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "node": "20.8.0" }, "packageManager": "yarn@3.6.4", - "version": "1.83.1-web-3179-dep-updates.2", + "version": "1.84.0-web-3274-device-settings-data-sources.1", "private": true, "scripts": { "test": "TZ=UTC NODE_ENV=test NODE_OPTIONS='--max-old-space-size=4096' yarn karma start", @@ -64,7 +64,7 @@ "@storybook/react": "7.5.0", "@storybook/react-webpack5": "7.5.0", "@testing-library/react-hooks": "8.0.1", - "@tidepool/viz": "1.45.0-web-3346-summary-accuracy.4", + "@tidepool/viz": "1.45.0-web-3274-device-settings-data-sources.3", "async": "2.6.4", "autoprefixer": "10.4.16", "babel-core": "7.0.0-bridge.0", @@ -180,7 +180,7 @@ "terser": "5.22.0", "terser-webpack-plugin": "5.3.9", "theme-ui": "0.16.1", - "tideline": "1.30.0", + "tideline": "1.31.0-web-3274-device-settings-data-sources.1", "tidepool-platform-client": "0.62.0-web-3272-patient-data-linking-after-creation.1", "tidepool-standard-action": "0.1.1", "ua-parser-js": "1.0.36", diff --git a/stories/Card.stories.js b/stories/Card.stories.js new file mode 100644 index 0000000000..640d43f416 --- /dev/null +++ b/stories/Card.stories.js @@ -0,0 +1,93 @@ +import React from 'react'; +import { ThemeProvider } from '@emotion/react'; +import { Box, Flex } from 'theme-ui'; +import map from 'lodash/map'; +import { action } from '@storybook/addon-actions'; + +import baseTheme from '../app/themes/baseTheme'; +import Card from '../app/components/elements/Card'; +import UploaderBanner from '../app/components/elements/Card/Banners/Uploader.png'; +import DataConnectionsBanner from '../app/components/elements/Card/Banners/DataConnections.png'; + +/* eslint-disable max-len */ + +const withTheme = (Story) => ( + + + + + +); + +export default { + title: 'Cards', + decorators: [withTheme], +}; + +export const Cards = { + render: () => { + const cards = [ + { + title: 'Connect a Device Account', + subtitle: 'Does your patient have a Dexcom, LibreView, or twiist account? Automatically sync data from these accounts with the patient\'s permission.', + bannerImage: DataConnectionsBanner, + onClick: action('Connect a Device'), + }, + { + title: 'Upload Data Directly with Tidepool Uploader', + subtitle: 'Tidepool Uploader supports over 85 devices. Download Tidepool Uploader to get started.', + bannerImage: UploaderBanner, + onClick: action('Get Uploader'), + }, + ]; + + return ( + + {map(cards, card => )} + + ); + }, + + name: 'Default', + + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/LdoOQCUyQKIS2d6fUhfFJx/Cloud-to-Cloud?node-id=2044-16648&t=uxeFnlP3CHzgkNRt-0', + }, + }, +}; + +export const HorizontalCards = { + render: () => { + const cards = [ + { + title: 'Connect a Device Account', + subtitle: 'Does your patient have a Dexcom, LibreView, or twiist account? Automatically sync data from these accounts with the patient\'s permission.', + bannerImage: DataConnectionsBanner, + onClick: action('Connect a Device'), + }, + { + title: 'Upload Data Directly with Tidepool Uploader', + subtitle: 'Tidepool Uploader supports over 85 devices. Download Tidepool Uploader to get started.', + bannerImage: UploaderBanner, + onClick: action('Get Uploader'), + }, + ]; + + return ( + + {map(cards, card => )} + + ); + }, + + name: 'Horizontal', + + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/design/LdoOQCUyQKIS2d6fUhfFJx/Cloud-to-Cloud?node-id=2044-16648&t=uxeFnlP3CHzgkNRt-0', + }, + }, +}; diff --git a/test/unit/components/chart/basics.test.js b/test/unit/components/chart/basics.test.js index f5370191ea..fcb6440875 100644 --- a/test/unit/components/chart/basics.test.js +++ b/test/unit/components/chart/basics.test.js @@ -126,6 +126,7 @@ describe('Basics', () => { }, } }); + const noDataMessage = wrapper.find('.patient-data-message').hostNodes(); const chart = wrapper.hostNodes('BasicsChart'); expect(noDataMessage.length).to.equal(0); diff --git a/test/unit/components/chart/settings.test.js b/test/unit/components/chart/settings.test.js index 20840fcec4..d41d562a68 100644 --- a/test/unit/components/chart/settings.test.js +++ b/test/unit/components/chart/settings.test.js @@ -1,8 +1,10 @@ /* global chai */ +/* global context */ /* global describe */ /* global sinon */ /* global it */ /* global before */ +/* global beforeEach */ /* global after */ /* global afterEach */ @@ -17,6 +19,9 @@ import configureStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import moment from 'moment-timezone'; +import { ToastProvider } from '../../../../app/providers/ToastProvider.js'; +import DataConnectionsModal from '../../../../app/components/datasources/DataConnectionsModal.js'; +import DataConnections, { activeProviders } from '../../../../app/components/datasources/DataConnections.js'; const expect = chai.expect; const mockStore = configureStore([thunk]); @@ -46,6 +51,21 @@ describe('Settings', () => { bgUnits: MGDL_UNITS, }; + const patient = { + emails: ['user@example.com'], + userid: 'userId123', + username: 'user@example.com', + profile: { + fullName: 'Example User', + clinic: { + role: 'clinic_manager', + }, + patient: { + foo: 'bar', + }, + }, + }; + const baseProps = { chartPrefs: { settings: { @@ -60,6 +80,16 @@ describe('Settings', () => { timezoneAware: false, timezoneName: 'US/Pacific', }, + data: { + combined: [ + { + type: 'pumpSettings', + normalTime: '2023-01-01T00:00:00Z', + source: 'source1', + id: 'id1', + }, + ], + }, }, printReady: false, trackMetric: sinon.stub(), @@ -71,6 +101,7 @@ describe('Settings', () => { onSwitchToDaily: sinon.stub(), onSwitchToSettings: sinon.stub(), onSwitchToBgLog: sinon.stub(), + patient, uploadUrl: '', }; @@ -125,20 +156,7 @@ describe('Settings', () => { const defaultState = { blip: { allUsersMap: { - userId123: { - emails: ['user@example.com'], - userid: 'userId123', - username: 'user@example.com', - profile: { - fullName: 'Example User', - clinic: { - role: 'clinic_manager', - }, - patient: { - foo: 'bar', - }, - }, - }, + userId123: patient, }, loggedInUserId: 'userId123', settings: { @@ -197,6 +215,7 @@ describe('Settings', () => { onSwitchToDaily: sinon.spy(), onSwitchToSettings: sinon.spy(), onSwitchToBgLog: sinon.spy(), + patient, trackMetric: sinon.spy(), uploadUrl: '', pdf: { @@ -204,7 +223,7 @@ describe('Settings', () => { }, }; const settingsElem = React.createElement(Settings, props); - const elem = mount(settingsElem); + const elem = mount({settingsElem}); expect(elem).to.be.ok; const x = elem.find('.patient-data-message'); expect(x).to.be.ok; @@ -225,6 +244,7 @@ describe('Settings', () => { onSwitchToDaily: sinon.spy(), onSwitchToSettings: sinon.spy(), onSwitchToBgLog: sinon.spy(), + patient, trackMetric: sinon.spy(), uploadUrl: '', pdf: { @@ -232,7 +252,7 @@ describe('Settings', () => { }, }; const settingsElem = React.createElement(Settings, props); - const elem = mount(settingsElem); + const elem = mount({settingsElem}); const refreshButton = elem.find('.btn-refresh').hostNodes(); expect(props.onClickRefresh.callCount).to.equal(0); @@ -251,10 +271,11 @@ describe('Settings', () => { }, }, onClickPrint: sinon.spy(), + patient, }); const settingsElem = React.createElement(Settings, props); - const elem = mount(settingsElem); + const elem = mount({settingsElem}); const printLink = elem.find('.printview-print-icon'); expect(printLink).to.be.ok; @@ -356,7 +377,7 @@ describe('Settings', () => { expect(wrapper.find('.pump-settings-container').length).to.equal(1); }); - it('disables device selection apply button when no sources are available', () => { + it('hides device settings selection UI when no sources are available', () => { mountWrapper({ data: { data: { @@ -366,11 +387,11 @@ describe('Settings', () => { }, }); wrapper.update(); - const deviceButton = wrapper.find('button#device-selection'); - expect(deviceButton.props().disabled).to.be.true; + const deviceSettingsSelection = wrapper.find('#device-settings-selection').hostNodes(); + expect(deviceSettingsSelection).to.have.lengthOf(0); }); - it('disables settings selection apply button when no settings are available', () => { + it('hides device settings selection UI when no settings are available', () => { mountWrapper({ data: { data: { @@ -380,8 +401,8 @@ describe('Settings', () => { }, }); wrapper.update(); - const settingsButton = wrapper.find('button#settings-selection'); - expect(settingsButton.props().disabled).to.be.true; + const deviceSettingsSelection = wrapper.find('#device-settings-selection').hostNodes(); + expect(deviceSettingsSelection).to.have.lengthOf(0); }); it('calls trackMetric when device selection popover is opened', () => { @@ -532,12 +553,14 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id1', deviceSerialNumber: '1234', }, { type: 'pumpSettings', normalTime: moment('2023-01-02T00:00:00Z').valueOf(), source: 'source2', + id: 'id2', deviceSerialNumber: '5678', }, ], @@ -562,6 +585,7 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id1', }, ], }, @@ -584,6 +608,7 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'Unspecified Data Source', + id: 'id1', deviceSerialNumber: '1234', }, ], @@ -604,12 +629,14 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'Unspecified Data Source', + id: 'id1', deviceSerialNumber: '1234', }, { type: 'pumpSettings', normalTime: moment('2023-01-02T00:00:00Z').valueOf(), source: 'source1', + id: 'id2', deviceSerialNumber: '1234', }, ], @@ -634,6 +661,7 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id1', }, ], }, @@ -659,11 +687,13 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id1', }, { type: 'pumpSettings', normalTime: moment('2023-01-02T00:00:00Z').valueOf(), source: 'source1', + id: 'id2', }, ], }, @@ -692,16 +722,19 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id1', }, { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'source2', + id: 'id2', }, { type: 'pumpSettings', normalTime: moment('2023-01-03T00:00:00Z').valueOf(), source: 'source2', + id: 'id3', }, ], }, @@ -730,11 +763,13 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T20:00:00Z').valueOf(), source: 'source1', + id: 'id1', }, { type: 'pumpSettings', normalTime: moment('2023-01-02T00:00:00Z').valueOf(), source: 'source1', + id: 'id2', }, ], }, @@ -763,16 +798,19 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id1', }, { type: 'pumpSettings', normalTime: moment('2023-02-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id2', }, { type: 'pumpSettings', normalTime: moment('2022-11-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id3', }, ], }, @@ -804,21 +842,25 @@ describe('Settings', () => { type: 'pumpSettings', normalTime: moment('2023-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id1', }, { type: 'pumpSettings', normalTime: moment('2024-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id2', }, { type: 'pumpSettings', normalTime: moment('2021-06-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id3', }, { type: 'pumpSettings', normalTime: moment('2018-01-01T00:00:00Z').valueOf(), source: 'source1', + id: 'id4', }, ], }, @@ -842,4 +884,307 @@ describe('Settings', () => { 'Jan 01, 2018 - Jun 01, 2021 : Active for >3 years' ); }); + + describe('data connections', () => { + let dataConnections; + let dataConnectionsAddButton; + let dataConnectionsCard; + let dataConnectionsModal; + let dataConnectionsWrapper; + let wrapper; + + const api = { + clinics: { + getPatientFromClinic: sinon.stub(), + } + }; + + const userPatient = { + userid: '40', + profile: { + fullName: 'Fooey McBar' + }, + }; + + const clinicPatient = { + id: '40', + fullName: 'Fooey McBar', + }; + + const defaultProps = { + currentPatientInViewId: '40', + trackMetric: sinon.stub(), + }; + + const defaultWorkingState = { + inProgress: false, + completed: null, + notification: null, + }; + + const defaultState = { + blip: { + working: { + updatingClinicPatient: defaultWorkingState, + sendingPatientDataProviderConnectRequest: defaultWorkingState, + }, + }, + }; + + const providerWrapper = store => props => { + const { children } = props; + + return ( + + + {children} + + + ); + }; + + beforeEach(() => { + dataConnections = () => wrapper.find('.data-connection').hostNodes(); + dataConnectionsAddButton = () => wrapper.find('#add-data-connections').hostNodes(); + dataConnectionsCard = () => wrapper.find('#data-connections-card'); + dataConnectionsModal = () => wrapper.find('Dialog#data-connections'); + dataConnectionsWrapper = () => wrapper.find('#data-connections').hostNodes(); + DataConnections.__Rewire__('api', api); + DataConnectionsModal.__Rewire__('api', api); + }); + + afterEach(() => { + DataConnections.__ResetDependency__('api'); + DataConnectionsModal.__ResetDependency__('api'); + }); + + context('clinician user', () => { + context('no active connections', () => { + it('should show the data connections card and open the data connections modal when clicked', () => { + const props = { + ...defaultProps, + patient: clinicPatient, + isUserPatient: false, + }; + + const state = { + blip: { + ...defaultState.blip, + selectedClinicId: 'clinic123', + } + }; + + const store = mockStore(state); + + wrapper = mount(, { wrappingComponent: providerWrapper(store) }); + + expect(dataConnectionsModal().length).to.equal(0); + expect(dataConnectionsCard().length).to.equal(1); + expect(dataConnectionsCard().text()).to.include('Connect a Device Account'); + const callCount = props.trackMetric.callCount; + dataConnectionsCard().simulate('click'); + + sinon.assert.callCount(props.trackMetric, callCount + 1); + sinon.assert.calledWith(props.trackMetric, 'Clicked Settings Add Data Connections', sinon.match({ source: 'card' })); + expect(dataConnectionsModal().length).to.equal(1); + }); + }); + + context('active connections to some providers', () => { + it('should show the data connections and open the data connections modal when Add button is clicked', () => { + const props = { + ...defaultProps, + patient: clinicPatient, + isUserPatient: false, + patient: { + userid: '40', + dataSources: [ + { providerName: activeProviders[0], state: 'connected' } + ], + }, + }; + + const state = { + blip: { + ...defaultState.blip, + selectedClinicId: 'clinic123', + } + }; + + const store = mockStore(state); + + wrapper = mount(, { wrappingComponent: providerWrapper(store) }); + + expect(dataConnectionsModal().length).to.equal(0); + expect(dataConnectionsCard().length).to.equal(0); + expect(dataConnectionsWrapper().length).to.equal(1); + expect(dataConnectionsWrapper().find(`#data-connection-${activeProviders[0]}`).hostNodes().length).to.equal(1); + const callCount = props.trackMetric.callCount; + + expect(dataConnectionsAddButton().length).to.equal(1); + dataConnectionsAddButton().simulate('click'); + + sinon.assert.callCount(props.trackMetric, callCount + 1); + sinon.assert.calledWith(props.trackMetric, 'Clicked Settings Add Data Connections', sinon.match({ source: 'button' })); + expect(dataConnectionsModal().length).to.equal(1); + + // Modal should only contain data providers that aren't already present for patient + expect(dataConnectionsModal().find(`#data-connection-${activeProviders[0]}`).hostNodes().length).to.equal(0); + expect(dataConnectionsModal().find(`#data-connection-${activeProviders[1]}`).hostNodes().length).to.equal(1); + }); + }); + + context('active connections to all providers', () => { + it('should show the data connections but not the "Add" button', () => { + const props = { + ...defaultProps, + isUserPatient: false, + patient: { + ...clinicPatient, + dataSources: _.map(activeProviders, providerName => ({ providerName, state: 'pending' })), + }, + }; + + const state = { + blip: { + ...defaultState.blip, + selectedClinicId: 'clinic123', + } + }; + + const store = mockStore(state); + + wrapper = mount(, { wrappingComponent: providerWrapper(store) }); + + + // No modal, card, or add button + expect(dataConnectionsModal().length).to.equal(0); + expect(dataConnectionsCard().length).to.equal(0); + expect(dataConnectionsAddButton().length).to.equal(0); + expect(dataConnectionsWrapper().length).to.equal(1); + + // Data connections shown for each provider + expect(dataConnections().length).to.equal(activeProviders.length); + + _.each(activeProviders, providerName => { + expect(dataConnections().find(`#data-connection-${providerName}`).hostNodes().length).to.equal(1); + }); + }); + }); + }); + + context('patient user', () => { + context('no active connections', () => { + it('should show the data connections card and open the data connections modal when clicked', () => { + const props = { + ...defaultProps, + patient: userPatient, + isUserPatient: true, + }; + + const store = mockStore(defaultState); + wrapper = mount(, { wrappingComponent: providerWrapper(store) }); + + expect(dataConnectionsModal().length).to.equal(0); + expect(dataConnectionsCard().length).to.equal(1); + expect(dataConnectionsCard().text()).to.include('Connect an Account'); + const callCount = props.trackMetric.callCount; + dataConnectionsCard().simulate('click'); + + sinon.assert.callCount(props.trackMetric, callCount + 1); + sinon.assert.calledWith(props.trackMetric, 'Clicked Settings Add Data Connections', sinon.match({ source: 'card' })); + expect(dataConnectionsModal().length).to.equal(1); + }); + }); + + context('active connections to some providers', () => { + it('should show the data connections and open the data connections modal when Add button is clicked', () => { + const props = { + ...defaultProps, + isUserPatient: true, + patient: { + ...userPatient, + dataSources: [ + { providerName: activeProviders[0], state: 'connected' } + ], + }, + }; + + const store = mockStore(defaultState); + wrapper = mount(, { wrappingComponent: providerWrapper(store) }); + + expect(dataConnectionsModal().length).to.equal(0); + expect(dataConnectionsCard().length).to.equal(0); + expect(dataConnectionsWrapper().length).to.equal(1); + expect(dataConnectionsWrapper().find(`#data-connection-${activeProviders[0]}`).hostNodes().length).to.equal(1); + const callCount = props.trackMetric.callCount; + + expect(dataConnectionsAddButton().length).to.equal(1); + dataConnectionsAddButton().simulate('click'); + + sinon.assert.callCount(props.trackMetric, callCount + 1); + sinon.assert.calledWith(props.trackMetric, 'Clicked Settings Add Data Connections', sinon.match({ source: 'button' })); + expect(dataConnectionsModal().length).to.equal(1); + + // Modal should only contain data providers that aren't already present for patient + expect(dataConnectionsModal().find(`#data-connection-${activeProviders[0]}`).hostNodes().length).to.equal(0); + expect(dataConnectionsModal().find(`#data-connection-${activeProviders[1]}`).hostNodes().length).to.equal(1); + }); + }); + + context('active connections to all providers', () => { + it('should show the data connections but not the "Add" button', () => { + const props = { + ...defaultProps, + isUserPatient: true, + patient: { + ...userPatient, + dataSources: _.map(activeProviders, providerName => ({ providerName, state: 'pending' })), + }, + }; + + const store = mockStore(defaultState); + wrapper = mount(, { wrappingComponent: providerWrapper(store) }); + + // No modal, card, or add button + expect(dataConnectionsModal().length).to.equal(0); + expect(dataConnectionsCard().length).to.equal(0); + expect(dataConnectionsAddButton().length).to.equal(0); + expect(dataConnectionsWrapper().length).to.equal(1); + + // Data connections shown for each provider + expect(dataConnections().length).to.equal(activeProviders.length); + + _.each(activeProviders, providerName => { + expect(dataConnections().find(`#data-connection-${providerName}`).hostNodes().length).to.equal(1); + }); + }); + }); + }); + + context('patient is not logged in user nor are they being viewed within a clinic context', () => { + context('no active connections', () => { + it('should not show the data connections card nor the data connections wrapper', () => { + const props = { + ...defaultProps, + patient: userPatient, + isUserPatient: false, + }; + + const state = { + blip: { + ...defaultState.blip, + selectedClinicId: null, + } + }; + + const store = mockStore(state); + wrapper = mount(, { wrappingComponent: providerWrapper(store) }); + + expect(dataConnectionsCard().length).to.equal(0); + expect(dataConnectionsWrapper().length).to.equal(0); + }); + }); + }); + }); }); diff --git a/test/unit/pages/patientdata.test.js b/test/unit/pages/patientdata.test.js index df71e4ff67..31482978fd 100644 --- a/test/unit/pages/patientdata.test.js +++ b/test/unit/pages/patientdata.test.js @@ -17,6 +17,9 @@ import { components as vizComponents } from '@tidepool/viz'; import i18next from '../../../app/core/language'; import createReactClass from 'create-react-class'; import { ThemeProvider } from '@emotion/react'; +import { Provider } from 'react-redux'; +import thunk from 'redux-thunk'; +import configureStore from 'redux-mock-store'; import baseTheme from '../../../app/themes/baseTheme'; @@ -24,6 +27,7 @@ const { Loader } = vizComponents; var assert = chai.assert; var expect = chai.expect; +const mockStore = configureStore([thunk]); const t = i18next.t.bind(i18next); @@ -31,6 +35,7 @@ const t = i18next.t.bind(i18next); // otherwise dependencies mocked will be bound to the wrong scope! import PD, { PatientData, PatientDataClass, getFetchers, mapStateToProps } from '../../../app/pages/patientdata/patientdata.js'; import { MGDL_UNITS } from '../../../app/core/constants'; +import { ToastProvider } from '../../../app/providers/ToastProvider.js'; describe('PatientData', function () { const defaultProps = { @@ -71,6 +76,7 @@ describe('PatientData', function () { }; before(() => { + PD.__Rewire__('launchCustomProtocol', _.noop); PD.__Rewire__('Basics', createReactClass({ render: function() { return (
); @@ -103,6 +109,7 @@ describe('PatientData', function () { }); after(() => { + PD.__ResetDependency__('launchCustomProtocol'); PD.__ResetDependency__('Basics'); PD.__ResetDependency__('Trends'); PD.__ResetDependency__('BgLog'); @@ -276,16 +283,18 @@ describe('PatientData', function () { }); }); - describe('no data message', () => { + describe('no data available', () => { + let dataConnectionsCard + let uploaderCard; let wrapper; - let noData; beforeEach(() => { - noData = () => wrapper.find('.patient-data-message-no-data'); + dataConnectionsCard = () => wrapper.find('#data-connections-card'); + uploaderCard = () => wrapper.find('#uploader-card'); }); describe('logged-in user is not current patient targeted for viewing', () => { - it('should render the no data message when no data is present and loading and processingData are false', function() { + it('should render the device connections and uploader cards when no data is present and loading and processingData are false', function() { var props = _.assign({}, defaultProps, { patient: { profile: { @@ -304,11 +313,14 @@ describe('PatientData', function () { } })); - expect(noData().length).to.equal(1); - expect(noData().text()).to.equal('Fooey McBar does not have any data yet.'); + expect(dataConnectionsCard().length).to.equal(1); + expect(dataConnectionsCard().text()).to.contain('Connect a Device Account'); + + expect(uploaderCard().length).to.equal(1); + expect(uploaderCard().text()).to.contain('Upload Data Directly with Tidepool Uploader'); }); - it('should render the no data message when no data is present for current patient', function() { + it('should render the device connections and uploader cards when no data is present for current patient', function() { var props = _.assign({}, defaultProps, { currentPatientInViewId: '40', patient: { @@ -335,13 +347,16 @@ describe('PatientData', function () { } })); - expect(noData().length).to.equal(1); - expect(noData().text()).to.equal('Fooey McBar does not have any data yet.'); + expect(dataConnectionsCard().length).to.equal(1); + expect(dataConnectionsCard().text()).to.contain('Connect a Device Account'); + + expect(uploaderCard().length).to.equal(1); + expect(uploaderCard().text()).to.contain('Upload Data Directly with Tidepool Uploader'); }); }); describe('logged-in user is viewing own data', () => { - it('should render the no data message when no data is present and loading and processingData are false', function() { + it('should render the device connections and uploader cards when no data is present and loading and processingData are false', function() { var props = { isUserPatient: true, fetchingPatient: false, @@ -359,10 +374,14 @@ describe('PatientData', function () { } })); - expect(noData().length).to.equal(1); + expect(dataConnectionsCard().length).to.equal(1); + expect(dataConnectionsCard().text()).to.contain('Connect an Account'); + + expect(uploaderCard().length).to.equal(1); + expect(uploaderCard().text()).to.contain('Upload Data Directly with Tidepool Uploader'); }); - it('should render the no data message when no data is present for current patient', function() { + it('should render the device connections and uploader cards when no data is present for current patient', function() { var props = { currentPatientInViewId: '40', isUserPatient: true, @@ -379,7 +398,7 @@ describe('PatientData', function () { pdf: {}, }; - wrapper = mount(); + wrapper = mount(, {}); wrapper.setProps(_.assign({}, props, { data: { @@ -387,11 +406,15 @@ describe('PatientData', function () { } })); - expect(noData().length).to.equal(1); + expect(dataConnectionsCard().length).to.equal(1); + expect(dataConnectionsCard().text()).to.contain('Connect an Account'); + + expect(uploaderCard().length).to.equal(1); + expect(uploaderCard().text()).to.contain('Upload Data Directly with Tidepool Uploader'); }); - it('should track click on main upload button', function() { - var props = { + it('should track click on Uploader card', function() { + const props = { currentPatientInViewId: '40', isUserPatient: true, patient: { @@ -418,19 +441,16 @@ describe('PatientData', function () { wrapper.update(); - expect(noData().length).to.equal(1); - - var links = wrapper.find('.patient-data-uploader-message a'); - var callCount = props.trackMetric.callCount; - - links.at(0).simulate('click'); + expect(uploaderCard().length).to.equal(1); + const callCount = props.trackMetric.callCount; + uploaderCard().simulate('click'); expect(props.trackMetric.callCount).to.equal(callCount + 1); - expect(props.trackMetric.calledWith('Clicked No Data Upload')).to.be.true; + expect(props.trackMetric.calledWith('Clicked No Data Upload Card')).to.be.true; }); - it('should track click on Dexcom Connect link', function() { - var props = { + it('should track click on Data Connections card', function() { + const props = { currentPatientInViewId: '40', isUserPatient: true, patient: { @@ -444,11 +464,43 @@ describe('PatientData', function () { removingData: { inProgress: false }, generatingPDF: { inProgress: false }, pdf: {}, - history: { push: sinon.stub() }, - trackMetric: sinon.stub() + trackMetric: sinon.stub(), + removeGeneratedPDFS: sinon.stub(), + dataWorkerRemoveDataSuccess: sinon.stub(), }; - wrapper = mount(); + const defaultWorkingState = { + inProgress: false, + completed: null, + notification: null, + }; + + const defaultState = { + blip: { + working: { + updatingClinicPatient: defaultWorkingState, + sendingPatientDataProviderConnectRequest: defaultWorkingState, + }, + }, + }; + + const store = mockStore(defaultState); + + function ProviderWrapper(props) { + const { children } = props; + + return ( + + + {children} + + + ); + } + + wrapper = mount(, { wrappingComponent: ProviderWrapper }); + + wrapper.update(); wrapper.setProps(_.assign({}, props, { data: { @@ -458,16 +510,12 @@ describe('PatientData', function () { wrapper.update(); - var link = wrapper.find('#dexcom-connect-link').hostNodes(); - var callCount = props.trackMetric.callCount; - - link.simulate('click'); - - expect(props.history.push.callCount).to.equal(1); - sinon.assert.calledWith(props.history.push, '/patients/40/profile?dexcomConnect=patient-empty-data'); + expect(dataConnectionsCard().length).to.equal(1); + const callCount = props.trackMetric.callCount; + dataConnectionsCard().simulate('click'); expect(props.trackMetric.callCount).to.equal(callCount + 1); - expect(props.trackMetric.calledWith('Clicked No Data Connect Dexcom')).to.be.true; + expect(props.trackMetric.calledWith('Clicked No Data Data Connections Card')).to.be.true; }); }); }); diff --git a/test/unit/utils/personutils.test.js b/test/unit/utils/personutils.test.js index 7b7b45e76c..92493783f5 100644 --- a/test/unit/utils/personutils.test.js +++ b/test/unit/utils/personutils.test.js @@ -623,6 +623,7 @@ describe('personutils', () => { username: 'someEmail', userid: 'someID', permissions: { foo: 'bar' }, + dataSources: 'data sources', profile: { fullName: 'Joe Jackson', emails: ['someEmail'], @@ -637,6 +638,7 @@ describe('personutils', () => { email: 'someEmail', fullName: 'Joe Jackson', birthDate: '1979-01-01', + dataSources: 'data sources', mrn: 'someMRN', id: 'someID', permissions: { foo: 'bar' }, diff --git a/yarn.lock b/yarn.lock index 7558f819e2..2346e6a5d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5278,9 +5278,9 @@ __metadata: languageName: node linkType: hard -"@tidepool/viz@npm:1.45.0-web-3346-summary-accuracy.4": - version: 1.45.0-web-3346-summary-accuracy.4 - resolution: "@tidepool/viz@npm:1.45.0-web-3346-summary-accuracy.4" +"@tidepool/viz@npm:1.45.0-web-3274-device-settings-data-sources.3": + version: 1.45.0-web-3274-device-settings-data-sources.3 + resolution: "@tidepool/viz@npm:1.45.0-web-3274-device-settings-data-sources.3" dependencies: bluebird: 3.7.2 bows: 1.7.2 @@ -5340,7 +5340,7 @@ __metadata: react-dom: 16.x react-redux: 8.x redux: 4.x - checksum: 18467d1d827507db3e579ab49f069ca6e26a3a2a81a5f4d6e95352aa397e17b8b112ea0c7df3d4043fc71df6eba17464bbcb44a3c257864a2334f27ff94713b9 + checksum: ed3bbe5fd1da450ff5e33c7d1b3ce4492868d96f8d233081b60dedd02da56ab85b9f9a634b7e4a06c3a6df7546e1a4bf9ec57bcf4a0e6c4bd2d06fe561d93e8a languageName: node linkType: hard @@ -7251,7 +7251,7 @@ __metadata: "@storybook/react": 7.5.0 "@storybook/react-webpack5": 7.5.0 "@testing-library/react-hooks": 8.0.1 - "@tidepool/viz": 1.45.0-web-3346-summary-accuracy.4 + "@tidepool/viz": 1.45.0-web-3274-device-settings-data-sources.3 async: 2.6.4 autoprefixer: 10.4.16 babel-core: 7.0.0-bridge.0 @@ -7374,7 +7374,7 @@ __metadata: terser: 5.22.0 terser-webpack-plugin: 5.3.9 theme-ui: 0.16.1 - tideline: 1.30.0 + tideline: 1.31.0-web-3274-device-settings-data-sources.1 tidepool-platform-client: 0.62.0-web-3272-patient-data-linking-after-creation.1 tidepool-standard-action: 0.1.1 ua-parser-js: 1.0.36 @@ -20026,9 +20026,9 @@ __metadata: languageName: node linkType: hard -"tideline@npm:1.30.0": - version: 1.30.0 - resolution: "tideline@npm:1.30.0" +"tideline@npm:1.31.0-web-3274-device-settings-data-sources.1": + version: 1.31.0-web-3274-device-settings-data-sources.1 + resolution: "tideline@npm:1.31.0-web-3274-device-settings-data-sources.1" dependencies: bows: 1.7.2 classnames: 2.3.2 @@ -20048,7 +20048,7 @@ __metadata: peerDependencies: babel-core: 6.x || 7.0.0-bridge.0 lodash: ^4.17.21 - checksum: faf7028567b20f5136df1d2f34c70be89929c89269a68664bfad16a5cdd5dee3c8287e49ab95a641fa772f1d806c9dddd40352c3fad07bf01565b029f01d5032 + checksum: 8c14db4706c8410459f06b22f48318e4f963b5f8d4ef49b167fe169ee126533e784ff7ded859b60322fe856d2cae5185baf2eded61d5d8b3543136a865eace14 languageName: node linkType: hard