import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import PropTypes from 'prop-types'; import { withTranslation } from 'react-i18next'; import { useDispatch, useSelector } from 'react-redux'; import compact from 'lodash/compact'; import debounce from 'lodash/debounce'; import get from 'lodash/get'; import includes from 'lodash/includes'; import isEmpty from 'lodash/isEmpty'; import keyBy from 'lodash/keyBy'; import map from 'lodash/map'; import omitBy from 'lodash/omitBy'; import pick from 'lodash/pick'; import reject from 'lodash/reject'; import { useFormik } from 'formik'; import InputMask from 'react-input-mask'; import { Box, BoxProps, Flex } from 'theme-ui'; import { colors as vizColors } from '@tidepool/viz'; import InfoRoundedIcon from '@material-ui/icons/InfoRounded'; import * as actions from '../../../redux/actions'; import TextInput from '../../../components/elements/TextInput'; import { getCommonFormikFieldProps } from '../../../core/forms'; import { useInitialFocusedInput, usePrevious } from '../../../core/hooks'; import { dateRegex, patientSchema as validationSchema } from '../../../core/clinicUtils'; import { accountInfoFromClinicPatient } from '../../../core/personutils'; import { Body0 } from '../../../components/elements/FontStyles'; import { MediumTitle } from '../../../components/elements/FontStyles'; import SelectDiabetesType from './SelectDiabetesType'; import SelectGlycemicRanges from './SelectGlycemicRanges'; import SelectTags from './SelectTags'; import SelectSites from './SelectSites'; import { DEFAULT_GLYCEMIC_RANGES } from '../../../core/glycemicRangesUtils'; import Icon from '../../elements/Icon'; export function getFormValues(source, clinicPatientTags, clinicSites) { return { birthDate: source?.birthDate || '', email: source?.email || '', fullName: source?.fullName || '', mrn: source?.mrn || '', tags: reject(source?.tags || [], tagId => !clinicPatientTags?.[tagId]), dataSources: source?.dataSources || [], sites: source?.sites?.filter(site => !!clinicSites[site.id]) || [], diagnosisType: source?.diagnosisType || '', glycemicRanges: source?.glycemicRanges || DEFAULT_GLYCEMIC_RANGES, }; } export function emptyValuesFilter(value, key) { // We want to allow sending an empty `tags` and `sites` arrays. Otherwise, strip empty fields from payload. return !includes(['tags', 'sites', 'diagnosisType'], key) && isEmpty(value); } export const PatientForm = (props) => { const { t, action, api, invite, onFormChange, patient, trackMetric, searchDebounceMs, initialFocusedInput = 'fullName', disabledFields = {}, ...boxProps } = props; const dispatch = useDispatch(); const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); const mrnSettings = useMemo(() => clinic?.mrnSettings ?? {}, [clinic?.mrnSettings]); const clinicMRNsForPatientFormValidation = useSelector(state => state.blip.clinicMRNsForPatientFormValidation); // Patient's pre-existing MRN will already exist in clinic, so it needs to not trip the validator const existingMRNs = useMemo(() => ( clinicMRNsForPatientFormValidation?.filter(mrn => mrn !== patient?.mrn) || [] ), [clinicMRNsForPatientFormValidation, patient?.mrn]); const dateInputFormat = 'MM/DD/YYYY'; const dateMaskFormat = dateInputFormat.replace(/[A-Z]/g, '9'); const [initialValues, setInitialValues] = useState({}); const hasSummaryDashboard = clinic?.entitlements?.summaryDashboard; const showTags = clinic?.entitlements?.patientTags && !!clinic?.patientTags?.length; const showSites = clinic?.entitlements?.clinicSites && !!clinic?.sites?.length; const showDiabetesType = !!selectedClinicId; // hide in private workspace const showGlycemicRanges = !!selectedClinicId; // hide in private workspace const clinicPatientTags = useMemo(() => keyBy(clinic?.patientTags, 'id'), [clinic?.patientTags]); const clinicSites = useMemo(() => keyBy(clinic?.sites, 'id'), [clinic?.sites]); const showEmail = action !== 'acceptInvite'; const { fetchingClinicMRNsForPatientFormValidation } = useSelector((state) => state.blip.working); const [patientFetchOptions, setPatientFetchOptions] = useState({}); const loggedInUserId = useSelector((state) => state.blip.loggedInUserId); const previousFetchingClinicMRNsForPatientFormValidation = usePrevious(fetchingClinicMRNsForPatientFormValidation); const previousFetchOptions = usePrevious(patientFetchOptions); const initialFocusedInputRef = useInitialFocusedInput(); const tagSectionRef = useRef(null); const siteSectionRef = useRef(null); const diagnosisTypeSectionRef = useRef(null); const targetRangePresetSectionRef = useRef(null); const schema = useMemo(() => validationSchema({ mrnSettings, existingMRNs }), [mrnSettings, existingMRNs]); const formikContext = useFormik({ initialValues: getFormValues(patient, clinicPatientTags, clinicSites), onSubmit: (values) => { const context = selectedClinicId ? 'clinic' : 'vca'; const actionMap = { edit: { clinic: { handler: 'updateClinicPatient', args: () => [selectedClinicId, patient.id, omitBy({ ...patient, ...getFormValues(values, clinicPatientTags, clinicSites) }, emptyValuesFilter)], }, vca: { handler: 'updatePatient', args: () => [accountInfoFromClinicPatient(omitBy({ ...patient, ...getFormValues(values, clinicPatientTags, clinicSites) }, emptyValuesFilter))], }, }, create: { clinic: { handler: 'createClinicCustodialAccount', args: () => [selectedClinicId, omitBy(getFormValues(values, clinicPatientTags, clinicSites), emptyValuesFilter)], }, vca: { handler: 'createVCACustodialAccount', args: () => [accountInfoFromClinicPatient(omitBy(values, emptyValuesFilter)).profile], }, }, acceptInvite: { clinic: { handler: 'acceptPatientInvitation', args: () => [selectedClinicId, invite.key, invite.creatorId, omitBy( pick(getFormValues(values, clinicPatientTags, clinicSites), ['mrn', 'birthDate', 'fullName', 'tags', 'sites', 'diagnosisType', 'glycemicRanges']), emptyValuesFilter )], }, } } if (!initialValues.email && values.email) { trackMetric(`${selectedClinicId ? 'Clinic' : 'Clinician'} - add patient email saved`); } const handlerArgs = actionMap[action][context].args(); dispatch(actions.async[actionMap[action][context].handler](api, ...handlerArgs)); }, validationSchema: schema, }); const { setFieldValue, setValues, values, } = formikContext; // Fetchers useEffect(() => { if ( loggedInUserId && clinic?.id && !fetchingClinicMRNsForPatientFormValidation.inProgress && !isEmpty(patientFetchOptions) && !(patientFetchOptions === previousFetchOptions) ) { const fetchOptions = { ...patientFetchOptions }; if (isEmpty(fetchOptions.search)) { delete fetchOptions.search; } dispatch( actions.async.fetchClinicMRNsForPatientFormValidation(api, clinic.id, fetchOptions) ); } }, [ api, clinic, dispatch, fetchingClinicMRNsForPatientFormValidation, loggedInUserId, patientFetchOptions, previousFetchOptions ]); // revalidate form on patient fetch complete useEffect(() => { if ( previousFetchingClinicMRNsForPatientFormValidation?.inProgress && !fetchingClinicMRNsForPatientFormValidation.inProgress ) { formikContext.validateForm(); } }, [ fetchingClinicMRNsForPatientFormValidation.inProgress, formikContext, previousFetchingClinicMRNsForPatientFormValidation?.inProgress, ]); useEffect(() => { // set form field values and store initial patient values on patient load const patientValues = getFormValues(patient, clinicPatientTags, clinicSites); setValues(patientValues); setInitialValues(patientValues); }, [patient, clinicPatientTags]); useEffect(() => { onFormChange(formikContext); }, [values, clinicPatientTags]); // Pull the patient on load to ensure the most recent dexcom connection state is made available useEffect(() => { if ((action === 'edit') && selectedClinicId && patient?.id) fetchPatientDetails(); }, []); function fetchPatientDetails() { dispatch(actions.async.fetchPatientFromClinic(api, selectedClinicId, patient.id)); } const debounceSearch = useCallback( debounce((search) => { setPatientFetchOptions({ ...patientFetchOptions, offset: 0, search, }); }, searchDebounceMs), [patientFetchOptions] ); function handleSearchChange(event) { debounceSearch(event.target.value); } function handleScrollToRef(ref) { // Wait for height modal to expand via CSS, then scroll down to enhance dropdown visibility setTimeout(() => ref?.current?.scrollIntoView(), 50); } const isEmailFieldDisabled = disabledFields.email || (patient?.id && !patient?.permissions?.custodian); return ( {t('Patient Details')} { formikContext.setFieldValue('birthDate', e.target.value.replace(dateRegex, '$3-$1-$2'), e.target.value.length === 10); }} onBlur={e => { formikContext.setFieldTouched('birthDate'); formikContext.setFieldValue('birthDate', e.target.value.replace(dateRegex, '$3-$1-$2')); }} disabled={disabledFields.birthDate} > { handleSearchChange(e); formikContext.setFieldValue('mrn', e.target.value.toUpperCase()); }} onBlur={(e) => { formikContext.setFieldTouched('mrn'); formikContext.setFieldValue('mrn', e.target.value.toUpperCase()); }} disabled={disabledFields.mrn} /> {showEmail && ( <> { !isEmailFieldDisabled && {t('Adding the patient\'s email lets them upload data at home or connect other diabetes device accounts to share data with you on an ongoing basis.')} } )} {showDiabetesType && ( setFieldValue('diagnosisType', diagnosisType)} onMenuOpen={() => handleScrollToRef(diagnosisTypeSectionRef)} /> )} {showGlycemicRanges && ( setFieldValue('glycemicRanges', glycemicRanges)} onMenuOpen={() => handleScrollToRef(targetRangePresetSectionRef)} /> {hasSummaryDashboard ? t('Target ranges follow ADA guidelines. Setting an alternate range will be used when viewing patient data, but will not be available in the dashboard view.') : t('Target ranges follow ADA guidelines and will be used when viewing patient data.') } )} {showTags && ( {t('Tags')} setFieldValue('tags', tagIds)} onMenuOpen={() => handleScrollToRef(tagSectionRef)} /> )} {showSites && ( {t('Sites')} setFieldValue('sites', sites)} onMenuOpen={() => handleScrollToRef(siteSectionRef)} /> )} ); }; PatientForm.propTypes = { ...BoxProps, api: PropTypes.object.isRequired, invite: PropTypes.object, onFormChange: PropTypes.func.isRequired, patient: PropTypes.object, t: PropTypes.func.isRequired, trackMetric: PropTypes.func.isRequired, searchDebounceMs: PropTypes.number.isRequired, initialFocusedInput: PropTypes.string, action: PropTypes.oneOf(['create', 'edit', 'acceptInvite']).isRequired, disabledFields: PropTypes.shape({ fullName: PropTypes.bool, birthDate: PropTypes.bool, mrn: PropTypes.bool, email: PropTypes.bool, }), }; PatientForm.defaultProps = { searchDebounceMs: 1000, }; export default withTranslation()(PatientForm);