Skip to content

Commit

Permalink
Merge pull request #1485 from tidepool-org/WEB-3272-patient-data-link…
Browse files Browse the repository at this point in the history
…ing-after-creation

[WEB-3272,WEB-3273] Clinician-initiated patient data connection management
  • Loading branch information
clintonium-119 authored Jan 23, 2025
2 parents f941f78 + ce9ebe0 commit fc24c6b
Show file tree
Hide file tree
Showing 67 changed files with 4,273 additions and 1,082 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,5 @@ locales/*/*_old.json
!.yarn/releases
!.yarn/sdks
!.yarn/versions

.codegpt
272 changes: 14 additions & 258 deletions app/components/clinic/PatientForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,47 +20,34 @@ import InputMask from 'react-input-mask';
import CloseRoundedIcon from '@material-ui/icons/CloseRounded';
import CheckCircleRoundedIcon from '@material-ui/icons/CheckCircleRounded';
import ErrorOutlineRoundedIcon from '@material-ui/icons/ErrorOutlineRounded';
import { Box, Flex, Text, BoxProps } from 'theme-ui';
import { Box, Text, BoxProps } from 'theme-ui';
import moment from 'moment';

import * as actions from '../../redux/actions';
import Checkbox from '../../components/elements/Checkbox';
import TextInput from '../../components/elements/TextInput';
import Button from '../../components/elements/Button';
import { TagList } from '../../components/elements/Tag';
import ResendDexcomConnectRequestDialog from './ResendDexcomConnectRequestDialog';
import { useToasts } from '../../providers/ToastProvider';
import { getCommonFormikFieldProps } from '../../core/forms';
import { useInitialFocusedInput, useIsFirstRender, usePrevious } from '../../core/hooks';
import { dateRegex, patientSchema as validationSchema } from '../../core/clinicUtils';
import { accountInfoFromClinicPatient } from '../../core/personutils';
import { Body0 } from '../../components/elements/FontStyles';
import { borders, colors } from '../../themes/baseTheme';
import Icon from '../elements/Icon';
import DexcomLogoIcon from '../../core/icons/DexcomLogo.svg';

function getFormValues(source, clinicPatientTags, disableDexcom) {
const hasDexcomDataSource = !!find(source?.dataSources, { providerName: 'dexcom' });
const connectDexcom = (hasDexcomDataSource || (!disableDexcom && source?.connectDexcom)) || false;
const addDexcomDataSource = connectDexcom && !hasDexcomDataSource;

export function getFormValues(source, clinicPatientTags) {
return {
birthDate: source?.birthDate || '',
email: source?.email || '',
fullName: source?.fullName || '',
mrn: source?.mrn || '',
tags: reject(source?.tags || [], tagId => !clinicPatientTags?.[tagId]),
connectDexcom,
dataSources: addDexcomDataSource ? [
...source?.dataSources || [],
{ providerName: 'dexcom', state: 'pending' },
] : source?.dataSources || [],
dataSources: source?.dataSources || [],
};
}

function emptyValuesFilter(value, key) {
export function emptyValuesFilter(value, key) {
// We want to allow sending an empty `tags` array. Otherwise, strip empty fields from payload.
return !includes(['tags', 'connectDexcom'], key) && isEmpty(value);
return !includes(['tags'], key) && isEmpty(value);
}

export const PatientForm = (props) => {
Expand Down Expand Up @@ -94,12 +81,8 @@ export const PatientForm = (props) => {
const clinicPatientTags = useMemo(() => keyBy(clinic?.patientTags, 'id'), [clinic?.patientTags]);
const dexcomDataSource = find(patient?.dataSources, { providerName: 'dexcom' });
const dexcomAuthInviteExpired = dexcomDataSource?.expirationTime < moment.utc().toISOString();
const showConnectDexcom = action !== 'acceptInvite' && !!selectedClinicId && !dexcomDataSource;
const showEmail = action !== 'acceptInvite';
const [disableConnectDexcom, setDisableConnectDexcom] = useState(false);
const showDexcomConnectState = !!selectedClinicId && !!dexcomDataSource?.state;
const [showResendDexcomConnectRequest, setShowResendDexcomConnectRequest] = useState(false);
const { sendingPatientDexcomConnectRequest, fetchingPatientsForClinic } = useSelector((state) => state.blip.working);
const { fetchingPatientsForClinic } = useSelector((state) => state.blip.working);
const [patientFetchOptions, setPatientFetchOptions] = useState({});
const loggedInUserId = useSelector((state) => state.blip.loggedInUserId);
const previousFetchingPatientsForClinic = usePrevious(fetchingPatientsForClinic);
Expand Down Expand Up @@ -156,14 +139,15 @@ export const PatientForm = (props) => {

const formikContext = useFormik({
initialValues: getFormValues(patient, clinicPatientTags),
initialStatus: { showDataConnectionsModalNext: false },
onSubmit: (values, formikHelpers) => {
const context = selectedClinicId ? 'clinic' : 'vca';

const actionMap = {
edit: {
clinic: {
handler: 'updateClinicPatient',
args: () => [selectedClinicId, patient.id, omitBy({ ...patient, ...getFormValues(values, clinicPatientTags, disableConnectDexcom) }, emptyValuesFilter)],
args: () => [selectedClinicId, patient.id, omitBy({ ...patient, ...getFormValues(values, clinicPatientTags) }, emptyValuesFilter)],
},
vca: {
handler: 'updatePatient',
Expand All @@ -173,7 +157,7 @@ export const PatientForm = (props) => {
create: {
clinic: {
handler: 'createClinicCustodialAccount',
args: () => [selectedClinicId, omitBy(getFormValues(values, clinicPatientTags, disableConnectDexcom), emptyValuesFilter)],
args: () => [selectedClinicId, omitBy(getFormValues(values, clinicPatientTags), emptyValuesFilter)],
},
vca: {
handler: 'createVCACustodialAccount',
Expand All @@ -184,7 +168,7 @@ export const PatientForm = (props) => {
clinic: {
handler: 'acceptPatientInvitation',
args: () => [selectedClinicId, invite.key, invite.creatorId, omitBy(
pick(getFormValues(values, clinicPatientTags, disableConnectDexcom), ['mrn', 'birthDate', 'fullName', 'tags']),
pick(getFormValues(values, clinicPatientTags), ['mrn', 'birthDate', 'fullName', 'tags']),
emptyValuesFilter
)],
},
Expand All @@ -195,57 +179,24 @@ export const PatientForm = (props) => {
trackMetric(`${selectedClinicId ? 'Clinic' : 'Clinician'} - add patient email saved`);
}

const emailUpdated = initialValues.email && values.email && (initialValues.email !== values.email);

if (context === 'clinic' && values.connectDexcom && (!patient?.lastRequestedDexcomConnectTime || emailUpdated)) {
const reason = emailUpdated ? 'email updated' : 'initial connection request';
const handlerArgs = actionMap[action][context].args();

trackMetric('Clinic - Request dexcom connection for patient', {
clinicId: selectedClinicId,
reason,
action,
});

formikHelpers.setStatus('sendingDexcomConnectRequest');
if (context === 'clinic' && action === 'create' && clinic?.country === 'US') {
formikHelpers.setStatus({ showDataConnectionsModalNext: true, newPatient: handlerArgs[1] });
}

dispatch(actions.async[actionMap[action][context].handler](api, ...actionMap[action][context].args()));
dispatch(actions.async[actionMap[action][context].handler](api, ...handlerArgs));
},
validationSchema: validationSchema({mrnSettings, existingMRNs}),
});

const {
errors,
setFieldValue,
setValues,
status,
values,
} = formikContext;

function handleAsyncResult(workingState, successMessage) {
const { inProgress, completed, notification } = workingState;

if (!isFirstRender && !inProgress) {
if (completed) {
// Close the resend email modal and refetch patient details to update the connection status
setShowResendDexcomConnectRequest(false);
fetchPatientDetails();

setToast({
message: successMessage,
variant: 'success',
});
}

if (completed === false) {
setToast({
message: get(notification, 'message'),
variant: 'danger',
});
}
}
}

// Fetchers
useEffect(() => {
if (
Expand Down Expand Up @@ -298,51 +249,15 @@ export const PatientForm = (props) => {
onFormChange(formikContext);
}, [values, clinicPatientTags, status]);

useEffect(() => {
if (includes(['create', 'edit'], action)) {
const hasValidEmail = !isEmpty(values.email) && !errors.email;
setDisableConnectDexcom(!hasValidEmail);

if (values.connectDexcom && !hasValidEmail) {
setFieldValue('connectDexcom', false);
}
}
}, [values.email, errors.email, action]);

// Pull the patient on load to ensure the most recent dexcom connection state is made available
useEffect(() => {
if ((action === 'edit') && selectedClinicId && patient?.id) fetchPatientDetails();
}, []);

useEffect(() => {
handleAsyncResult(sendingPatientDexcomConnectRequest, t('Dexcom connection request to {{email}} has been resent.', {
email: patient?.email,
}));
}, [sendingPatientDexcomConnectRequest]);

function handleResendDexcomConnectEmail() {
trackMetric('Clinic - Resend Dexcom connect email', { clinicId: selectedClinicId, dexcomConnectState, source: 'patientForm' })
setShowResendDexcomConnectRequest(true);
}

function handleResendDexcomConnectEmailConfirm() {
trackMetric('Clinic - Resend Dexcom connect email confirm', { clinicId: selectedClinicId, source: 'patientForm' });
formikContext.setStatus('resendingDexcomConnectRequest');
dispatch(actions.async.sendPatientDexcomConnectRequest(api, selectedClinicId, patient.id));
}

function fetchPatientDetails() {
dispatch(actions.async.fetchPatientFromClinic(api, selectedClinicId, patient.id));
}

function renderRegionalNote() {
return (
<Body0 sx={{ fontWeight: 'medium', color: colors.mediumGrey, lineHeight: '1.5 !important', fontStyle: 'italic'}}>
{t('For US Dexcom Users Only')}
</Body0>
);
}

const debounceSearch = useCallback(
debounce((search) => {
setPatientFetchOptions({
Expand Down Expand Up @@ -486,165 +401,6 @@ export const PatientForm = (props) => {
)}
</Box>
)}

{showConnectDexcom && (
<Box
id="connectDexcomWrapper"
mt={3}
pt={3}
sx={{
borderTop: borders.default,
}}
>
<Checkbox
{...getCommonFormikFieldProps('connectDexcom', formikContext, 'checked')}
disabled={disableConnectDexcom}
label={(
<Flex sx={{ alignItems: 'center' }}>
<Text mr={1} mt={1} sx={{ display: 'block', fontSize: 1 }}>
{t('Connect with')}
</Text>

<Icon
variant="static"
iconSrc={DexcomLogoIcon}
sx={{
img: {
filter: disableConnectDexcom ? 'saturate(0%) brightness(130%)' : 'none',
},
}}
label="Dexcom"
/>
</Flex>
)}
/>

<Body0 mt={1} sx={{ fontWeight: 'medium' }}>
{t('If this box is checked, patient will receive an email to authorize sharing Dexcom data with Tidepool.')}
</Body0>

{renderRegionalNote()}
</Box>
)}

{showDexcomConnectState && (
<Box
id="connectDexcomStatusWrapper"
mt={3}
pt={3}
sx={{
color: dexcomConnectStateUI[dexcomConnectState].color,
borderTop: borders.default,
}}
>
<Flex sx={{ alignItems: 'center' }}>
<Icon
variant="static"
icon={dexcomConnectStateUI[dexcomConnectState].icon}
label={`${dexcomConnectStateUI[dexcomConnectState].label} Dexcom`}
sx={dexcomConnectStateUI[dexcomConnectState].iconStyles}
/>

<Text mx={1} mt={1} sx={{ fontSize: 0 }}>
{dexcomConnectStateUI[dexcomConnectState].label}
</Text>

<Icon
variant="static"
iconSrc={DexcomLogoIcon}
label="Dexcom"
/>
</Flex>

{dexcomConnectState === 'pending' && (
<Body0 mt={2} sx={{ fontWeight: 'medium', color: colors.mediumGrey, display: 'inline-block', lineHeight: '0.5 !important'}}>
{t('Patient has received an email to authorize Dexcom data sharing with Tidepool but they have not taken any action yet.')}

<Button
id="resendDexcomConnectRequestTrigger"
variant="textPrimary"
onClick={handleResendDexcomConnectEmail}
sx={{ fontSize: 0, display: 'inline-block !important'}}
>
{t('Resend email')}
</Button>
</Body0>
)}

{dexcomConnectState === 'pendingReconnect' && (
<Body0 mt={2} sx={{ fontWeight: 'medium', color: colors.mediumGrey, display: 'inline-block', lineHeight: '0.5 !important'}}>
{t('Patient has received an email to reconnect their Dexcom data with Tidepool but they have not taken any action yet.')}

<Button
id="resendDexcomConnectRequestTrigger"
variant="textPrimary"
onClick={handleResendDexcomConnectEmail}
sx={{ fontSize: 0, display: 'inline-block !important'}}
>
{t('Resend email')}
</Button>
</Body0>
)}

{dexcomConnectState === 'pendingExpired' && (
<Body0 mt={2} sx={{ fontWeight: 'medium', color: colors.mediumGrey, display: 'inline-block', lineHeight: '0.5 !important'}}>
{t('Patient invitation to authorize Dexcom data sharing with Tidepool has expired. Would you like to send a new connection request?')}

<Button
id="resendDexcomConnectRequestTrigger"
variant="textPrimary"
onClick={handleResendDexcomConnectEmail}
sx={{ fontSize: 0, display: 'inline-block !important'}}
>
{t('Resend email')}
</Button>
</Body0>
)}

{dexcomConnectState === 'disconnected' && (
<Body0 mt={2} sx={{ fontWeight: 'medium', color: colors.mediumGrey, display: 'inline-block', lineHeight: '0.5 !important'}}>
{t('Patient has disconnected their Dexcom data sharing authorization with Tidepool. Would you like to send a new connection request?')}

<Button
id="resendDexcomConnectRequestTrigger"
variant="textPrimary"
onClick={handleResendDexcomConnectEmail}
sx={{ fontSize: 0, display: 'inline-block !important'}}
>
{t('Send email')}
</Button>
</Body0>
)}

{dexcomConnectState === 'error' && (
<Body0 mt={2} sx={{ fontWeight: 'medium', color: colors.mediumGrey, display: 'inline-block', lineHeight: '0.5 !important'}}>
{t('Patient\'s previous Dexcom authorization is no longer valid. Would you like to send a new connection request?')}

<Button
id="resendDexcomConnectRequestTrigger"
variant="textPrimary"
onClick={handleResendDexcomConnectEmail}
sx={{ fontSize: 0, display: 'inline-block !important'}}
>
{t('Send email')}
</Button>
</Body0>
)}

{dexcomConnectStateUI[dexcomConnectState].showRegionalNote && renderRegionalNote()}

<ResendDexcomConnectRequestDialog
api={api}
onClose={() => setShowResendDexcomConnectRequest(false)}
onConfirm={handleResendDexcomConnectEmailConfirm}
open={showResendDexcomConnectRequest}
patient={patient}
t={t}
trackMetric={trackMetric}
/>
</Box>
)}

</Box>
);
};
Expand Down
Loading

0 comments on commit fc24c6b

Please sign in to comment.