diff --git a/__tests__/unit/app/components/navpatientheader/index.test.js b/__tests__/unit/app/components/navpatientheader/index.test.js index e07501c918..1ec023440c 100644 --- a/__tests__/unit/app/components/navpatientheader/index.test.js +++ b/__tests__/unit/app/components/navpatientheader/index.test.js @@ -7,7 +7,7 @@ import React from 'react'; import { Provider } from 'react-redux'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import _ from 'lodash'; import configureStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; @@ -39,7 +39,7 @@ jest.mock('@app/providers/ToastProvider', () => ({ describe('NavPatientHeader', () => { const trackMetric = jest.fn(); - const api = {}; + const api = { clinics: { getPatientFromClinic: jest.fn() } }; const patientProps = { userid: '1234', @@ -191,7 +191,7 @@ describe('NavPatientHeader', () => { expect(screen.getByText(/Naoya Inoue/)).toBeInTheDocument(); expect(screen.getByText(/999999/)).toBeInTheDocument(); expect(screen.getByText(/1965-01-01/)).toBeInTheDocument(); - expect(screen.getByText(/Pre-diabetes/)).toBeInTheDocument(); + expect(within(screen.getByTestId('nav-patient-header')).getByText(/Pre-diabetes/)).toBeInTheDocument(); // should NOT show demographic info from the 'patient' object expect(screen.queryByText(/Vasyl Lomachenko/)).not.toBeInTheDocument(); @@ -228,7 +228,7 @@ describe('NavPatientHeader', () => { expect(screen.getByText(/Naoya Inoue/)).toBeInTheDocument(); expect(screen.getByText(/999999/)).toBeInTheDocument(); expect(screen.getByText(/1965-01-01/)).toBeInTheDocument(); - expect(screen.getByText(/Pre-diabetes/)).toBeInTheDocument(); + expect(within(screen.getByTestId('nav-patient-header')).getByText(/Pre-diabetes/)).toBeInTheDocument(); // should NOT show demographic info from the 'patient' object expect(screen.queryByText(/Vasyl Lomachenko/)).not.toBeInTheDocument(); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/Cells.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/Cells.test.js index 4dc5f527b4..cc73f19249 100644 --- a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/Cells.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/Cells.test.js @@ -267,4 +267,34 @@ describe('Cells', () => { expect(screen.queryByText('Very Low')).not.toBeInTheDocument(); }); }); + + describe('MoreMenuCell', () => { + it('dispatches the actions to open the Edit Patient dialog for the patient', async () => { + renderComponent(); + + expect(screen.queryByRole('button', { name: /Edit Patient Details/ })).not.toBeInTheDocument(); + await userEvent.click(screen.getByTestId('action-menu-patient-1-icon')); + + await userEvent.click(screen.getByRole('button', { name: /Edit Patient Details/ })); + + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboard/setEditPatientDialogIsOpen', payload: true }, + { type: 'tideDashboard/setEditPatientDialogPatientId', payload: 'patient-1' }, + ]); + }); + + it('dispatches the actions to open the Data Connections modal for the patient', async () => { + renderComponent(); + + expect(screen.queryByRole('button', { name: /Bring Data into Tidepool/ })).not.toBeInTheDocument(); + await userEvent.click(screen.getByTestId('action-menu-patient-1-icon')); + + await userEvent.click(screen.getByRole('button', { name: /Bring Data into Tidepool/ })); + + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboard/setDataConnectionsModalIsOpen', payload: true }, + { type: 'tideDashboard/setDataConnectionsModalPatientId', payload: 'patient-1' }, + ]); + }); + }); }); diff --git a/__tests__/unit/components/clinic/EditPatientDialog.test.js b/__tests__/unit/components/clinic/EditPatientDialog.test.js new file mode 100644 index 0000000000..248e7a06df --- /dev/null +++ b/__tests__/unit/components/clinic/EditPatientDialog.test.js @@ -0,0 +1,138 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { ThemeProvider } from 'theme-ui'; +import theme from '@app/themes/baseTheme'; +import EditPatientDialog from '@app/components/clinic/EditPatientDialog'; +import { usePrevious } from '@app/core/hooks'; + +const mockStore = configureStore([thunk]); + +jest.mock('@app/core/hooks', () => ({ + ...jest.requireActual('@app/core/hooks'), + useIsFirstRender: jest.fn(() => false), + usePrevious: jest.fn(), +})); + +const clinicPatient = { + id: 'patient123', + fullName: 'John Doe', + birthDate: '2000-01-01', + permissions: { custodian: {} }, +}; + +const IDLE_UPDATE = { inProgress: false, completed: null, notification: null }; +const COMPLETED_UPDATE = { inProgress: false, completed: true, notification: null }; +const FAILED_UPDATE = { inProgress: false, completed: false, notification: { message: 'Something went wrong' } }; + +const makeState = ({ smartCorrelationId, updatingClinicPatient = IDLE_UPDATE } = {}) => ({ + blip: { + selectedClinicId: 'clinic123', + smartCorrelationId, + clinics: { clinic123: { id: 'clinic123', mrnSettings: { required: false } } }, + working: { + fetchingClinicMRNsForPatientFormValidation: { inProgress: false, completed: false, notification: null }, + updatingClinicPatient, + }, + clinicMRNsForPatientFormValidation: [], + }, +}); + +const api = { clinics: { getPatientFromClinic: jest.fn(), updateClinicPatient: jest.fn() } }; +const onClose = jest.fn(); +const onEditConfirm = jest.fn(); +const onEditSuccess = jest.fn(); +const onEditFailure = jest.fn(); + +const ui = (store) => ( + + + + + + + +); + +describe('EditPatientDialog', () => { + beforeEach(() => { + jest.clearAllMocks(); + usePrevious.mockReturnValue(true); + }); + + it('renders the patient form populated from clinicPatient, locking identity fields only in smart-on-fhir mode', () => { + const { rerender } = render(ui(mockStore(makeState()))); + + // The form is prefilled from clinicPatient ('2000-01-01' renders in the display format, 01/01/2000) + expect(screen.getByRole('textbox', { name: /Full Name/i })).toHaveValue('John Doe'); + expect(screen.getByRole('textbox', { name: /Birthdate/i })).toHaveValue('01/01/2000'); + + // Outside smart-on-fhir mode every field is editable + expect(screen.getByRole('textbox', { name: /Full Name/i })).toBeEnabled(); + expect(screen.getByRole('textbox', { name: /Birthdate/i })).toBeEnabled(); + expect(screen.getByRole('textbox', { name: /MRN/i })).toBeEnabled(); + expect(screen.getByRole('textbox', { name: /Email/i })).toBeEnabled(); + + // In smart-on-fhir mode the EHR-sourced identity fields lock + rerender(ui(mockStore(makeState({ smartCorrelationId: 'some-correlation-id' })))); + expect(screen.getByRole('textbox', { name: /Full Name/i })).toBeDisabled(); + expect(screen.getByRole('textbox', { name: /Birthdate/i })).toBeDisabled(); + expect(screen.getByRole('textbox', { name: /MRN/i })).toBeDisabled(); + expect(screen.getByRole('textbox', { name: /Email/i })).toBeDisabled(); + + // Clinical fields stay editable in smart-on-fhir mode + expect(screen.getByLabelText(/Diabetes Type/i)).toBeEnabled(); + expect(screen.getByLabelText('Target Range')).toBeEnabled(); + }); + + it('notifies the parent through onEditConfirm and submits the form when Save Changes is clicked', async () => { + render(ui(mockStore(makeState()))); + + await userEvent.click(screen.getByRole('button', { name: 'Save Changes' })); + + // The parent is handed the live form context before the submit fires + expect(onEditConfirm).toHaveBeenCalledWith( + expect.objectContaining({ values: expect.objectContaining({ fullName: 'John Doe' }) }) + ); + + // The form submit runs through to the patient update endpoint + await waitFor(() => { + expect(api.clinics.updateClinicPatient).toHaveBeenCalledWith( + 'clinic123', // clinicId + 'patient123', // patientId + expect.objectContaining({ fullName: 'John Doe' }), // updated patient + expect.any(Function), // node-style callback + ); + }); + }); + + it('reports the update result: onEditSuccess when it completes and onEditFailure when it fails', () => { + const { rerender } = render(ui(mockStore(makeState({ updatingClinicPatient: IDLE_UPDATE })))); + + // Nothing is reported while no update has resolved + expect(onEditSuccess).not.toHaveBeenCalled(); + expect(onEditFailure).not.toHaveBeenCalled(); + + // The in-flight update completes successfully + rerender(ui(mockStore(makeState({ updatingClinicPatient: COMPLETED_UPDATE })))); + expect(onEditSuccess).toHaveBeenCalledTimes(1); + expect(onEditFailure).not.toHaveBeenCalled(); + + // A later update fails + rerender(ui(mockStore(makeState({ updatingClinicPatient: FAILED_UPDATE })))); + expect(onEditFailure).toHaveBeenCalledTimes(1); + expect(onEditSuccess).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js b/__tests__/unit/components/navpatientheader/EditPatientDialogController.test.js similarity index 56% rename from __tests__/unit/components/navpatientheader/EditPatientDialog.test.js rename to __tests__/unit/components/navpatientheader/EditPatientDialogController.test.js index 98a8b868e6..c20888b652 100644 --- a/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js +++ b/__tests__/unit/components/navpatientheader/EditPatientDialogController.test.js @@ -2,13 +2,14 @@ import React from 'react'; import { createStore, applyMiddleware } from 'redux'; import { thunk } from 'redux-thunk'; import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import configureStore from 'redux-mock-store'; import { ThemeProvider } from 'theme-ui'; import { utils as vizUtils } from '@tidepool/viz'; import theme from '@app/themes/baseTheme'; -import EditPatientDialog from '@app/components/navpatientheader/EditPatientDialog'; +import EditPatientDialogController from '@app/components/navpatientheader/EditPatientDialogController'; import { ToastProvider } from '@app/providers/ToastProvider'; import { buildGlycemicRangesFromPreset } from '@app/core/glycemicRangesUtils'; import { GLYCEMIC_RANGE_OPTS } from '@app/components/clinic/PatientForm/SelectGlycemicRanges'; @@ -24,22 +25,18 @@ jest.mock('@app/core/hooks', () => ({ usePrevious: jest.fn(), })); +const baseClinicPatient = { + id: 'patient123', + fullName: 'John Doe', + birthDate: '2000-01-01', + permissions: { custodian: {} }, +}; + const initialState = { blip: { selectedClinicId: 'clinic123', currentPatientInViewId: 'patient123', - clinics: { - clinic123: { - id: 'clinic123', - mrnSettings: { required: false }, - }, - }, - allUsersMap: { - patient123: { - id: 'patient123', - profile: { fullName: 'John Doe' }, - }, - }, + clinics: { clinic123: { id: 'clinic123', mrnSettings: { required: false } } }, working: { fetchingClinicMRNsForPatientFormValidation: { inProgress: false, completed: false, notification: null }, updatingClinicPatient: { inProgress: false, completed: false, notification: null }, @@ -48,69 +45,7 @@ const initialState = { }, }; -const renderEditPatientDialog = (storeState = initialState) => { - const reducer = (state = storeState, action) => state; - const store = createStore(reducer, applyMiddleware(thunk)); - - return render( - - - - - - - - ); -}; - -describe('EditPatientDialog', () => { - it('locks identity fields and leaves the save button enabled when smartCorrelationId is present', () => { - const smartOnFhirState = { - blip: { - ...initialState.blip, - smartCorrelationId: 'some-correlation-id', - clinics: { - clinic123: { - ...initialState.blip.clinics.clinic123, - patients: { - patient123: { id: 'patient123', fullName: 'John Doe', birthDate: '2000-01-01' }, - }, - }, - }, - }, - }; - - renderEditPatientDialog(smartOnFhirState); - - expect(screen.getByRole('textbox', { name: /Full Name/i })).toBeDisabled(); - expect(screen.getByRole('textbox', { name: /Birthdate/i })).toBeDisabled(); - expect(screen.getByRole('textbox', { name: /MRN/i })).toBeDisabled(); - expect(screen.getByRole('textbox', { name: /Email/i })).toBeDisabled(); - // Clinical fields stay editable in smart-on-fhir mode; only EHR-sourced identity fields lock. - expect(screen.getByLabelText(/Diabetes Type/i)).not.toBeDisabled(); - expect(screen.getByLabelText('Target Range')).not.toBeDisabled(); - - const saveButton = screen.getByRole('button', { name: 'Save Changes' }); - expect(saveButton).toBeInTheDocument(); - expect(saveButton).toBeEnabled(); - }); - - it('sets read-only fields enabled when smartCorrelationId is absent', () => { - renderEditPatientDialog(initialState); - - expect(screen.getByRole('textbox', { name: /Full Name/i })).not.toBeDisabled(); - expect(screen.getByRole('textbox', { name: /Birthdate/i })).not.toBeDisabled(); - expect(screen.getByRole('textbox', { name: /MRN/i })).not.toBeDisabled(); - expect(screen.getByRole('textbox', { name: /Email/i })).not.toBeDisabled(); - expect(screen.getByLabelText(/Diabetes Type/i)).not.toBeDisabled(); - expect(screen.getByLabelText('Target Range')).not.toBeDisabled(); - }); - +describe('EditPatientDialogController', () => { // The dialog is permanently mounted and subscribes to the global updatingClinicPatient, so it must // only clear the data worker for updates it initiated (isOpen), not foreign ones (e.g. adding a data // source) — clearing on a foreign update would strand the patient-data view on the loader. @@ -129,13 +64,17 @@ describe('EditPatientDialog', () => { const store = mockStore(justCompletedUpdateState); const onClose = jest.fn(); + const testApi = { clinics: { getPatientFromClinic: jest.fn() } }; + render( - - - - - + + + + + + + ); @@ -164,7 +103,7 @@ describe('EditPatientDialog', () => { const IDLE_UPDATE = { inProgress: false, completed: null, notification: null }; const COMPLETED_UPDATE = { inProgress: false, completed: true, notification: null }; - const makeState = ({ savedRange, chartDataSize, updatingClinicPatient }) => ({ + const makeState = ({ chartDataSize, updatingClinicPatient }) => ({ blip: { ...initialState.blip, working: { @@ -173,12 +112,7 @@ describe('EditPatientDialog', () => { }, data: { metaData: { size: chartDataSize } }, clinics: { - clinic123: { - ...initialState.blip.clinics.clinic123, - patients: { - patient123: { id: 'patient123', fullName: 'John Doe', birthDate: '2000-01-01', glycemicRanges: savedRange }, - }, - }, + clinic123: { ...initialState.blip.clinics.clinic123 }, }, }, }); @@ -186,13 +120,15 @@ describe('EditPatientDialog', () => { const api = { clinics: { getPatientFromClinic: jest.fn(), updateClinicPatient: jest.fn() } }; const onClose = jest.fn(); - const ui = (store) => ( + const ui = (store, clinicPatient) => ( - - - - - + + + + + + + ); @@ -204,58 +140,61 @@ describe('EditPatientDialog', () => { window.HTMLElement.prototype.scrollIntoView = jest.fn(); }); + const originalClinicPatient = { ...baseClinicPatient, glycemicRanges: RANGE_A }; + const uneditedClinicPatient = { ...baseClinicPatient, glycemicRanges: RANGE_A }; + const modifiedClinicPatient = { ...baseClinicPatient, glycemicRanges: RANGE_B }; + it('does NOT clear the data worker for a demographic-only edit, even when chart data exists', async () => { - const initialState = makeState({ savedRange: RANGE_A, chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE }); - const { rerender } = render(ui(mockStore(initialState))); + const { rerender } = render(ui(mockStore(makeState({ chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE })), originalClinicPatient)); await userEvent.type(screen.getByRole('textbox', { name: /Full Name/i }), ' Jr'); await userEvent.click(screen.getByRole('button', { name: 'Save Changes' })); - const completedStore = mockStore(makeState({ savedRange: RANGE_A, chartDataSize: 100, updatingClinicPatient: COMPLETED_UPDATE })); - rerender(ui(completedStore)); + const completedStore = mockStore(makeState({ chartDataSize: 100, updatingClinicPatient: COMPLETED_UPDATE })); + rerender(ui(completedStore, uneditedClinicPatient)); expect(onClose).toHaveBeenCalled(); expect(cleared(completedStore)).toBe(false); }); it('DOES clear the data worker when Target Range changed from the saved value and the patient has chart data', async () => { - const { rerender } = render(ui(mockStore(makeState({ savedRange: RANGE_A, chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE })))); + const { rerender } = render(ui(mockStore(makeState({ chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE })), originalClinicPatient)); await userEvent.click(screen.getByLabelText('Target Range')); await userEvent.click(screen.getByText(RANGE_B_LABEL)); await userEvent.click(screen.getByRole('button', { name: 'Save Changes' })); - const completedStore = mockStore(makeState({ savedRange: RANGE_A, chartDataSize: 100, updatingClinicPatient: COMPLETED_UPDATE })); - rerender(ui(completedStore)); + const completedStore = mockStore(makeState({ chartDataSize: 100, updatingClinicPatient: COMPLETED_UPDATE })); + rerender(ui(completedStore, modifiedClinicPatient)); expect(onClose).toHaveBeenCalled(); expect(cleared(completedStore)).toBe(true); }); it('clears on a SUBSEQUENT Target Range change — compares against the saved value, not the frozen original', async () => { - const { rerender } = render(ui(mockStore(makeState({ savedRange: RANGE_A, chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE })))); - rerender(ui(mockStore(makeState({ savedRange: RANGE_B, chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE })))); + const { rerender } = render(ui(mockStore(makeState({ chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE })), originalClinicPatient)); + rerender(ui(mockStore(makeState({ chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE })), modifiedClinicPatient)); await userEvent.click(screen.getByLabelText('Target Range')); await userEvent.click(screen.getByText(RANGE_A_LABEL)); await userEvent.click(screen.getByRole('button', { name: 'Save Changes' })); - const completedStore = mockStore(makeState({ savedRange: RANGE_B, chartDataSize: 100, updatingClinicPatient: COMPLETED_UPDATE })); - rerender(ui(completedStore)); + const completedStore = mockStore(makeState({ chartDataSize: 100, updatingClinicPatient: COMPLETED_UPDATE })); + rerender(ui(completedStore, modifiedClinicPatient)); expect(onClose).toHaveBeenCalled(); expect(cleared(completedStore)).toBe(true); }); it('does NOT clear the data worker on a Target Range change for a no-data patient (would strand the loader)', async () => { - const { rerender } = render(ui(mockStore(makeState({ savedRange: RANGE_A, chartDataSize: 0, updatingClinicPatient: IDLE_UPDATE })))); + const { rerender } = render(ui(mockStore(makeState({ chartDataSize: 0, updatingClinicPatient: IDLE_UPDATE })), originalClinicPatient)); await userEvent.click(screen.getByLabelText('Target Range')); await userEvent.click(screen.getByText(RANGE_B_LABEL)); await userEvent.click(screen.getByRole('button', { name: 'Save Changes' })); - const completedStore = mockStore(makeState({ savedRange: RANGE_A, chartDataSize: 0, updatingClinicPatient: COMPLETED_UPDATE })); - rerender(ui(completedStore)); + const completedStore = mockStore(makeState({ chartDataSize: 0, updatingClinicPatient: COMPLETED_UPDATE })); + rerender(ui(completedStore, uneditedClinicPatient)); expect(onClose).toHaveBeenCalled(); expect(cleared(completedStore)).toBe(false); diff --git a/app/components/navpatientheader/EditPatientDialog.js b/app/components/clinic/EditPatientDialog.js similarity index 50% rename from app/components/navpatientheader/EditPatientDialog.js rename to app/components/clinic/EditPatientDialog.js index ab6a360d0b..ba99bcd2eb 100644 --- a/app/components/navpatientheader/EditPatientDialog.js +++ b/app/components/clinic/EditPatientDialog.js @@ -1,45 +1,38 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import React, { useState, useEffect, useMemo } from 'react'; +import { useSelector } from 'react-redux'; import { useIsFirstRender, usePrevious } from '../../core/hooks'; import { useTranslation } from 'react-i18next'; -import { selectClinicPatient, selectIsSmartOnFhirMode } from '../../core/selectors'; +import { selectIsSmartOnFhirMode } from '../../core/selectors'; import { Dialog, DialogActions, DialogContent, DialogTitle } from '../elements/Dialog'; import { MediumTitle } from '../elements/FontStyles'; import Button from '../elements/Button'; import PatientForm from '../clinic/PatientForm'; import noop from 'lodash/noop'; import keys from 'lodash/keys'; -import get from 'lodash/get'; -import isEqual from 'lodash/isEqual'; import { fieldsAreValid } from '../../core/forms'; import { patientSchema as validationSchema } from '../../core/clinicUtils'; -import { DEFAULT_GLYCEMIC_RANGES } from '../../core/glycemicRangesUtils'; -import { useToasts } from '../../providers/ToastProvider'; -import * as actions from '../../redux/actions'; +import { trackMetric } from '../../core/metricUtils'; +import useClinicMetricsPageName from '../../pages/clinicworkspace/useClinicMetricsPageName'; -const useUpdatingClinicPatientWorkingState = ({ onUpdateSuccess = noop }) => { - const { t } = useTranslation(); +const PATIENT_FORM_SEARCH_DEBOUNCE_MS = 600; + +const useUpdatingClinicPatientWorkingState = ({ + onEditSuccess = noop, + onEditFailure = noop, +}) => { const updatingClinicPatient = useSelector((state) => state.blip.working.updatingClinicPatient); - const { set: setToast } = useToasts(); - const { inProgress, completed, notification } = updatingClinicPatient; + const { inProgress, completed } = updatingClinicPatient; const prevInProgress = usePrevious(inProgress); const isFirstRender = useIsFirstRender(); useEffect(() => { if (!isFirstRender && !inProgress && prevInProgress !== false) { if (completed) { - onUpdateSuccess(); - setToast({ - message: t('You have successfully updated a patient.'), - variant: 'success', - }); + onEditSuccess(); } if (completed === false) { - setToast({ - message: get(notification, 'message'), - variant: 'danger', - }); + onEditFailure(); } } }, [isFirstRender, inProgress, prevInProgress, completed]); @@ -47,27 +40,22 @@ const useUpdatingClinicPatientWorkingState = ({ onUpdateSuccess = noop }) => { return updatingClinicPatient; }; -const PATIENT_FORM_SEARCH_DEBOUNCE_MS = 600; - const EditPatientDialog = ({ api, - trackMetric, - isOpen, + clinicPatient, + isOpen = false, onClose = noop, + onEditConfirm = noop, + onEditSuccess = noop, + onEditFailure = noop, }) => { const { t } = useTranslation(); - const dispatch = useDispatch(); - - const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); - const currentPatientInViewId = useSelector(state => state.blip.currentPatientInViewId); - const clinicPatient = useSelector(state => selectClinicPatient(state)); + const pageName = useClinicMetricsPageName(); + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); const isSmartOnFhir = useSelector(selectIsSmartOnFhirMode); - const mrnSettings = useMemo(() => clinic?.mrnSettings ?? {}, [clinic?.mrnSettings]); - const existingMRNs = useSelector(state => state.blip.clinicMRNsForPatientFormValidation)?.filter(mrn => mrn !== clinicPatient?.mrn) || []; - - const hasChartData = useSelector(state => (state.blip.data?.metaData?.size || 0) > 0); + const [patientFormContext, setPatientFormContext] = useState(); // In smart-on-fhir mode, identity fields are sourced from the EHR and locked. const disabledFields = useMemo( @@ -75,45 +63,26 @@ const EditPatientDialog = ({ [isSmartOnFhir] ); - // Captured at submit time: whether this edit needs the chart data reprocessed. - const shouldClearDataRef = useRef(false); + const mrnSettings = useMemo(() => clinic?.mrnSettings ?? {}, [clinic?.mrnSettings]); + const existingMRNs = useSelector(state => state.blip.clinicMRNsForPatientFormValidation)?.filter(mrn => mrn !== clinicPatient?.mrn) || []; - const onUpdateSuccess = () => { - // updatingClinicPatient is global working state, so this fires for any clinic-patient update while - // the header is mounted. Only react to updates this dialog drove; a foreign update (e.g. adding a - // data source) would otherwise clear the data worker cache and strand the data view on the loader. - if (!isOpen) return; + const updatingClinicPatient = useUpdatingClinicPatientWorkingState({ onEditSuccess, onEditFailure }); - onClose(); + const disabled = !patientFormContext || !fieldsAreValid( + keys(patientFormContext?.values), + validationSchema({ mrnSettings, existingMRNs }), patientFormContext?.values + ); - if (shouldClearDataRef.current) { - dispatch(actions.worker.dataWorkerRemoveDataRequest(null, currentPatientInViewId)); - shouldClearDataRef.current = false; - } + const handlePatientFormChange = (formikContext) => { + setPatientFormContext({ ...formikContext }); }; - const updatingClinicPatient = useUpdatingClinicPatientWorkingState({ onUpdateSuccess }); - - const [patientFormContext, setPatientFormContext] = useState(); + const handleSubmit = () => { + onEditConfirm(patientFormContext); // notify parent - const handleEditPatientConfirm = () => { - // Clear the data worker (forcing a reprocess) only when Target Range (glycemicRanges, the only - // data-affecting field here) changed AND there is chart data to reprocess. Compare against the - // patient's saved range, not the form's initialValues (frozen at mount, blind to prior saves); - // fall back to the default range so a patient without one — for whom the form injects the default — - // doesn't read as a change. - const savedRange = clinicPatient?.glycemicRanges || DEFAULT_GLYCEMIC_RANGES; - const targetRangeChanged = !isEqual(patientFormContext?.values?.glycemicRanges, savedRange); - shouldClearDataRef.current = targetRangeChanged && hasChartData; patientFormContext?.handleSubmit(); }; - const handlePatientFormChange = (formikContext) => { - setPatientFormContext({ ...formikContext }); - }; - - if (!currentPatientInViewId || !selectedClinicId) return null; - return ( { - trackMetric('Clinic - Edit patient close', { clinicId: selectedClinicId }); + trackMetric('Clinic - Edit patient close', { clinicId: selectedClinicId, pageName }); onClose(); }}> {t('Edit Patient Details')} @@ -142,7 +111,7 @@ const EditPatientDialog = ({ diff --git a/app/components/datasources/DataConnectionsModal.js b/app/components/datasources/DataConnectionsModal.js index 4ca0616075..e3b3227e69 100644 --- a/app/components/datasources/DataConnectionsModal.js +++ b/app/components/datasources/DataConnectionsModal.js @@ -28,6 +28,7 @@ import i18next from '../../core/language'; import { URL_TIDEPOOL_EXTERNAL_DATA_CONNECTIONS, URL_UPLOADER_DOWNLOAD_PAGE } from '../../core/constants'; import PatientEmailModal from './PatientEmailModal'; import { DesktopOnly } from '../mediaqueries'; +import { trackMetric } from '../../core/metricUtils'; const t = i18next.t.bind(i18next); @@ -38,7 +39,6 @@ export const DataConnectionsModal = (props) => { onBack, patient, shownProviders, - trackMetric, } = props; const history = useHistory(); @@ -60,8 +60,8 @@ export const DataConnectionsModal = (props) => { const dispatch = useDispatch(); const fetchPatientDetails = useCallback(() => { - dispatch(actions.async.fetchPatientFromClinic(api, selectedClinicId, patient.id)); - }, [dispatch, patient.id, selectedClinicId]) + dispatch(actions.async.fetchPatientFromClinic(api, selectedClinicId, patient?.id)); + }, [dispatch, patient?.id, selectedClinicId]); // Pull the patient on load to ensure the most recent dexcom connection state is made available useEffect(() => { @@ -136,6 +136,8 @@ export const DataConnectionsModal = (props) => { ? t('Learn more.') : t('Learn more here.'); + if (!patient) return null; + return ( <> { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const { set: setToast } = useToasts(); + + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const currentPatientInViewId = useSelector(state => state.blip.currentPatientInViewId); + const updatingClinicPatient = useSelector((state) => state.blip.working.updatingClinicPatient); + const { notification } = updatingClinicPatient; + + const hasChartData = useSelector(state => (state.blip.data?.metaData?.size || 0) > 0); + + // Captured at submit time: whether this edit needs the chart data reprocessed. + const shouldClearDataRef = useRef(false); + + const handleEditSuccess = () => { + // updatingClinicPatient is global working state, so this fires for any clinic-patient update while + // the header is mounted. Only react to updates this dialog drove; a foreign update (e.g. adding a + // data source) would otherwise clear the data worker cache and strand the data view on the loader. + setToast({ + message: t('You have successfully updated a patient.'), + variant: 'success', + }); + + if (!isOpen) return; + + onClose(); + + if (shouldClearDataRef.current) { + dispatch(actions.worker.dataWorkerRemoveDataRequest(null, currentPatientInViewId)); + shouldClearDataRef.current = false; + } + }; + + const handleEditFailure = () => { + setToast({ + message: get(notification, 'message'), + variant: 'danger', + }); + }; + + const handleEditConfirm = (formContext) => { + // Clear the data worker (forcing a reprocess) only when Target Range (glycemicRanges, the only + // data-affecting field here) changed AND there is chart data to reprocess. Compare against the + // patient's saved range, not the form's initialValues (frozen at mount, blind to prior saves); + // fall back to the default range so a patient without one — for whom the form injects the default — + // doesn't read as a change. + const savedRange = clinicPatient?.glycemicRanges || DEFAULT_GLYCEMIC_RANGES; + const targetRangeChanged = !isEqual(formContext?.values?.glycemicRanges, savedRange); + shouldClearDataRef.current = targetRangeChanged && hasChartData; + }; + + if (!currentPatientInViewId || !selectedClinicId) return null; + + return ( + + ); +}; + +export default EditPatientDialogController; diff --git a/app/components/navpatientheader/index.js b/app/components/navpatientheader/index.js index 2bdc86e8f7..1a3496e610 100644 --- a/app/components/navpatientheader/index.js +++ b/app/components/navpatientheader/index.js @@ -9,7 +9,7 @@ import DemographicInfo from './DemographicInfo'; import PatientMenuOptions from './MenuOptions/Patient'; import ClinicianMenuOptions from './MenuOptions/Clinician'; import UploadLaunchOverlay from '../../components/uploadlaunchoverlay'; -import EditPatientDialog from './EditPatientDialog'; +import EditPatientDialogController from './EditPatientDialogController'; import { isClinicianAccount } from '../../core/personutils'; import { breakpoints } from '../../themes/baseTheme'; @@ -18,7 +18,7 @@ import utils from '../../core/utils'; const HeaderContainer = ({ children }) => ( - setIsUploadOverlayOpen(false)} /> } - setIsEditPatientModalOpen(false)} /> diff --git a/app/pages/clinicworkspace/TideDashboardV2/Cells.js b/app/pages/clinicworkspace/TideDashboardV2/Cells.js index cdb4ecd9c1..15d60cd352 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/Cells.js +++ b/app/pages/clinicworkspace/TideDashboardV2/Cells.js @@ -1,5 +1,5 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; import { useTranslation, withTranslation } from 'react-i18next'; import { Box, Flex, Text } from 'theme-ui'; import { colors as vizColors } from '@tidepool/viz'; @@ -13,6 +13,16 @@ import utils from '../../../core/utils'; import { CATEGORY } from './tideDashboardSlice'; import isUndefined from 'lodash/isUndefined'; +import PopoverMenu from '../../../components/elements/PopoverMenu'; +import EditIcon from '@material-ui/icons/EditRounded'; +import DataInIcon from '../../../core/icons/DataInIcon.svg'; + +import { + setEditPatientDialogIsOpen, + setEditPatientDialogPatientId, + setDataConnectionsModalIsOpen, + setDataConnectionsModalPatientId, +} from './tideDashboardSlice'; import { tideDashboardExclusionQuery } from './tideDashboardApi'; import { useFlags } from 'launchdarkly-react-client-sdk'; @@ -241,7 +251,51 @@ export const MoreMenuHeader = () => { return ; }; -export const MoreMenuCell = () => <>; // TEMPORARY +export const MoreMenuCell = ({ patient }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const handleOpenEditPatientDialog = () => { + dispatch(setEditPatientDialogIsOpen(true)); + dispatch(setEditPatientDialogPatientId(patient.id)); + }; + + const handleOpenDataConnectionsModal = () => { + dispatch(setDataConnectionsModalIsOpen(true)); + dispatch(setDataConnectionsModalPatientId(patient.id)); + }; + + return ( + { + _popupState.close(); + handleOpenEditPatientDialog(); + }, + text: t('Edit Patient Details'), + }, { + iconSrc: DataInIcon, + iconLabel: t('Bring Data into Tidepool'), + iconPosition: 'left', + id: `edit-data-connections-${patient?.id}`, + variant: 'actionListItem', + onClick: (_popupState) => { + _popupState.close(); + handleOpenDataConnectionsModal(); + }, + text: t('Bring Data into Tidepool'), + }]} + sx={{ position: 'relative', left: '-2px' }} + /> + ); +}; export const PatientLastReviewedCell = ({ patient }) => { return ; diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataIssues/EditPatientDialogController.js b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/EditPatientDialogController.js new file mode 100644 index 0000000000..75d2b5c648 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/DataIssues/EditPatientDialogController.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { useDispatch } from 'react-redux'; +import { RTKQueryApi } from '../../../../redux/api/baseApi'; +import EditPatientDialog from '../../../../components/clinic/EditPatientDialog'; +import { tagTypes } from '../tideDashboardApi'; + +const { TIDE_DASHBOARD_PATIENTS } = tagTypes; + +const EditPatientDialogController = ({ api, isOpen, patient, onClose }) => { + const dispatch = useDispatch(); + + const handleEditSuccess = () => { + dispatch(RTKQueryApi.util.invalidateTags([TIDE_DASHBOARD_PATIENTS])); + }; + + return ( + + ); +}; + +export default EditPatientDialogController; diff --git a/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js b/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js index 1d431c0e5d..ac4d4cac99 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js +++ b/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js @@ -21,11 +21,14 @@ import EmptyContentNode from './EmptyContentNode'; import { Redirect, useLocation } from 'react-router-dom'; import useAuthorizationGate from './useAuthorizationGate'; +import EditPatientDialogController from './modals/EditPatientDialogController'; +import DataConnectionsModalController from './modals/DataConnectionsModalController'; + const Gap = () => ; const tableContainerProps = { sx: { containerType: 'inline-size' } }; -const TideDashboardV2 = () => { +const TideDashboardV2 = ({ api }) => { const { search } = useLocation(); const { t } = useTranslation(); @@ -80,6 +83,9 @@ const TideDashboardV2 = () => { /> + + + ); }; diff --git a/app/pages/clinicworkspace/TideDashboardV2/modals/DataConnectionsModalController.js b/app/pages/clinicworkspace/TideDashboardV2/modals/DataConnectionsModalController.js new file mode 100644 index 0000000000..3acf2bf96f --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/modals/DataConnectionsModalController.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import DataConnectionsModal from '../../../../components/datasources/DataConnectionsModal'; +import { closeModals } from '../tideDashboardSlice'; + +const DataConnectionsModalController = ({ patients }) => { + const dispatch = useDispatch(); + + const dataConnectionsModal = useSelector(state => state.blip.tideDashboard.dataConnectionsModal); + const { patientId, isOpen } = dataConnectionsModal; + + const patient = patients.find(patient => patient.id === patientId); + + const handleClose = () => dispatch(closeModals()); + + return ( + + ); +}; + +export default DataConnectionsModalController; diff --git a/app/pages/clinicworkspace/TideDashboardV2/modals/EditPatientDialogController.js b/app/pages/clinicworkspace/TideDashboardV2/modals/EditPatientDialogController.js new file mode 100644 index 0000000000..4a8a82272e --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/modals/EditPatientDialogController.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { closeModals } from '../tideDashboardSlice'; +import { RTKQueryApi } from '../../../../redux/api/baseApi'; +import EditPatientDialog from '../../../../components/clinic/EditPatientDialog'; +import { tagTypes } from '../tideDashboardApi'; + +const { TIDE_DASHBOARD_PATIENTS } = tagTypes; + +const EditPatientDialogController = ({ api, patients }) => { + const dispatch = useDispatch(); + const editPatientDialog = useSelector(state => state.blip.tideDashboard.editPatientDialog); + + const clinicPatient = patients.find(patient => patient.id === editPatientDialog.patientId); + + const handleCloseModal = () => dispatch(closeModals()); + + const handleEditSuccess = () => { + dispatch(closeModals()); + dispatch(RTKQueryApi.util.invalidateTags([TIDE_DASHBOARD_PATIENTS])); + }; + + return ( + <> + + + ); +}; + +export default EditPatientDialogController; diff --git a/app/pages/clinicworkspace/TideDashboardV2/tideDashboardSlice.js b/app/pages/clinicworkspace/TideDashboardV2/tideDashboardSlice.js index 866e6d361d..17369bb817 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/tideDashboardSlice.js +++ b/app/pages/clinicworkspace/TideDashboardV2/tideDashboardSlice.js @@ -14,6 +14,14 @@ export const CATEGORY = { const getInitialState = () => ({ category: CATEGORY.DEFAULT, offset: 0, + editPatientDialog: { + patientId: null, + isOpen: false, + }, + dataConnectionsModal: { + patientId: null, + isOpen: false, + }, }); const tideDashboardSlice = createSlice({ @@ -26,6 +34,24 @@ const tideDashboardSlice = createSlice({ setOffset: (state, action) => { state.offset = action.payload; }, + setEditPatientDialogPatientId: (state, action) => { + state.editPatientDialog.patientId = action.payload; + }, + setEditPatientDialogIsOpen: (state, action) => { + state.editPatientDialog.isOpen = action.payload; + }, + setDataConnectionsModalPatientId: (state, action) => { + state.dataConnectionsModal.patientId = action.payload; + }, + setDataConnectionsModalIsOpen: (state, action) => { + state.dataConnectionsModal.isOpen = action.payload; + }, + closeModals: (state) => { + const { editPatientDialog, dataConnectionsModal } = getInitialState(); + + state.editPatientDialog = editPatientDialog; + state.dataConnectionsModal = dataConnectionsModal; + }, resetTideDashboardState: () => getInitialState(), }, }); @@ -33,6 +59,11 @@ const tideDashboardSlice = createSlice({ export const { setCategory, setOffset, + setEditPatientDialogPatientId, + setEditPatientDialogIsOpen, + setDataConnectionsModalPatientId, + setDataConnectionsModalIsOpen, + closeModals, resetTideDashboardState, } = tideDashboardSlice.actions; diff --git a/app/pages/clinicworkspace/TideDashboardV2/useTableColumns.js b/app/pages/clinicworkspace/TideDashboardV2/useTableColumns.js index 049f214d27..60b3e850a6 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/useTableColumns.js +++ b/app/pages/clinicworkspace/TideDashboardV2/useTableColumns.js @@ -24,6 +24,7 @@ import { FlagCell, MoreMenuHeader, PatientLastReviewedCell, + MoreMenuCell, } from './Cells'; import TagListCell from '../components/TagListCell'; @@ -120,7 +121,7 @@ const buildColumnTypes = (t, category, thresholds) => ({ field: 'moreMenu', align: 'center', titleComponent: () => , - render: patient => null, // TODO: Implement + render: patient => , }, // More });