From e3c09eda30f73587edc693ebc889fa260d042411 Mon Sep 17 00:00:00 2001 From: henry-tp Date: Mon, 17 Aug 2026 17:01:37 -0700 Subject: [PATCH 01/13] WEB-4460 initialize base branch From d4ad0e426b93ce85c1484277cbf713b64138a3cf Mon Sep 17 00:00:00 2001 From: henry-tp Date: Mon, 17 Aug 2026 16:13:34 -0700 Subject: [PATCH 02/13] WEB-4460 update modals --- app/components/datasources/DataConnectionsModal.js | 6 ++++-- app/components/navpatientheader/EditPatientDialog.js | 12 +++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/components/datasources/DataConnectionsModal.js b/app/components/datasources/DataConnectionsModal.js index 4ca0616075..2fd5f83168 100644 --- a/app/components/datasources/DataConnectionsModal.js +++ b/app/components/datasources/DataConnectionsModal.js @@ -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(); @@ -51,18 +52,18 @@ const PATIENT_FORM_SEARCH_DEBOUNCE_MS = 600; const EditPatientDialog = ({ api, - trackMetric, + clinicPatient, isOpen, onClose = noop, + onEditSuccess = 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 clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); const isSmartOnFhir = useSelector(selectIsSmartOnFhirMode); + const patientId = clinicPatient?.id; const mrnSettings = useMemo(() => clinic?.mrnSettings ?? {}, [clinic?.mrnSettings]); const existingMRNs = useSelector(state => state.blip.clinicMRNsForPatientFormValidation)?.filter(mrn => mrn !== clinicPatient?.mrn) || []; @@ -87,7 +88,8 @@ const EditPatientDialog = ({ onClose(); if (shouldClearDataRef.current) { - dispatch(actions.worker.dataWorkerRemoveDataRequest(null, currentPatientInViewId)); + onEditSuccess(); + dispatch(actions.worker.dataWorkerRemoveDataRequest(null, patientId)); shouldClearDataRef.current = false; } }; @@ -112,7 +114,7 @@ const EditPatientDialog = ({ setPatientFormContext({ ...formikContext }); }; - if (!currentPatientInViewId || !selectedClinicId) return null; + if (!patientId || !selectedClinicId) return null; return ( Date: Mon, 17 Aug 2026 16:14:07 -0700 Subject: [PATCH 03/13] WEB-4460 add modal controllers to dashboard --- app/components/navpatientheader/index.js | 2 +- .../DataConnectionsModalController.js | 25 +++++++++++++ .../EditPatientDialogController.js | 37 +++++++++++++++++++ .../TideDashboardV2/TideDashboardV2.js | 8 +++- .../TideDashboardV2/tideDashboardSlice.js | 31 ++++++++++++++++ .../TideDashboardV2/useTableColumns.js | 3 +- 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 app/pages/clinicworkspace/TideDashboardV2/DataConnectionsModalController.js create mode 100644 app/pages/clinicworkspace/TideDashboardV2/EditPatientDialogController.js diff --git a/app/components/navpatientheader/index.js b/app/components/navpatientheader/index.js index 2bdc86e8f7..68d24c6a76 100644 --- a/app/components/navpatientheader/index.js +++ b/app/components/navpatientheader/index.js @@ -100,7 +100,7 @@ const NavPatientHeader = ({ api, trackMetric, patient, clinicPatient, user, perm setIsEditPatientModalOpen(false)} /> diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataConnectionsModalController.js b/app/pages/clinicworkspace/TideDashboardV2/DataConnectionsModalController.js new file mode 100644 index 0000000000..b2fbde81f3 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/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/EditPatientDialogController.js b/app/pages/clinicworkspace/TideDashboardV2/EditPatientDialogController.js new file mode 100644 index 0000000000..95d6c4e11a --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/EditPatientDialogController.js @@ -0,0 +1,37 @@ +import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { closeModals } from './tideDashboardSlice'; +import { RTKQueryApi } from '../../../redux/api/baseApi'; +import EditPatientDialog from '../../../components/navpatientheader/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(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..1fb3ac2bd7 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 './EditPatientDialogController'; +import DataConnectionsModalController from './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/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 }); From fa3d5410c183a3f9adcdf92a3704594070c5537d Mon Sep 17 00:00:00 2001 From: henry-tp Date: Tue, 25 Aug 2026 16:30:05 -0700 Subject: [PATCH 04/13] WEB-4460 re-add in more menu --- .../clinicworkspace/TideDashboardV2/Cells.js | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/app/pages/clinicworkspace/TideDashboardV2/Cells.js b/app/pages/clinicworkspace/TideDashboardV2/Cells.js index cdb4ecd9c1..adb7c4949d 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,50 @@ 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 ; From 679276d8587195b127f5cd95ea19221805e434a8 Mon Sep 17 00:00:00 2001 From: henry-tp Date: Thu, 27 Aug 2026 14:05:51 -0700 Subject: [PATCH 05/13] WEB-4460 fix EditPatientDialog tests --- .../EditPatientDialog.test.js | 73 +++++++++---------- 1 file changed, 35 insertions(+), 38 deletions(-) diff --git a/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js b/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js index 98a8b868e6..bcda79ea6d 100644 --- a/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js +++ b/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js @@ -24,22 +24,17 @@ 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,7 +43,7 @@ const initialState = { }, }; -const renderEditPatientDialog = (storeState = initialState) => { +const renderEditPatientDialog = (storeState = initialState, clinicPatient = baseClinicPatient) => { const reducer = (state = storeState, action) => state; const store = createStore(reducer, applyMiddleware(thunk)); @@ -58,7 +53,7 @@ const renderEditPatientDialog = (storeState = initialState) => { @@ -129,11 +124,13 @@ describe('EditPatientDialog', () => { const store = mockStore(justCompletedUpdateState); const onClose = jest.fn(); + const testApi = { clinics: { getPatientFromClinic: jest.fn() } }; + render( - + @@ -173,12 +170,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,11 +178,11 @@ describe('EditPatientDialog', () => { const api = { clinics: { getPatientFromClinic: jest.fn(), updateClinicPatient: jest.fn() } }; const onClose = jest.fn(); - const ui = (store) => ( + const ui = (store, clinicPatient) => ( - + @@ -204,58 +196,63 @@ 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 initialState = makeState({ chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE }); + + const { rerender } = render(ui(mockStore(initialState), 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); From a140b33ab3f13ad486ddb34b6f9452aecd392e4b Mon Sep 17 00:00:00 2001 From: henry-tp Date: Thu, 27 Aug 2026 14:15:22 -0700 Subject: [PATCH 06/13] WEB-4460 fix breaking tests --- .../unit/app/components/navpatientheader/index.test.js | 8 ++++---- .../components/navpatientheader/EditPatientDialog.test.js | 6 +++--- app/components/navpatientheader/index.js | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) 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/components/navpatientheader/EditPatientDialog.test.js b/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js index bcda79ea6d..c60c63283f 100644 --- a/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js +++ b/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js @@ -43,7 +43,7 @@ const initialState = { }, }; -const renderEditPatientDialog = (storeState = initialState, clinicPatient = baseClinicPatient) => { +const renderEditPatientDialog = (storeState = initialState, clinicPatient) => { const reducer = (state = storeState, action) => state; const store = createStore(reducer, applyMiddleware(thunk)); @@ -80,7 +80,7 @@ describe('EditPatientDialog', () => { }, }; - renderEditPatientDialog(smartOnFhirState); + renderEditPatientDialog(smartOnFhirState, baseClinicPatient); expect(screen.getByRole('textbox', { name: /Full Name/i })).toBeDisabled(); expect(screen.getByRole('textbox', { name: /Birthdate/i })).toBeDisabled(); @@ -96,7 +96,7 @@ describe('EditPatientDialog', () => { }); it('sets read-only fields enabled when smartCorrelationId is absent', () => { - renderEditPatientDialog(initialState); + renderEditPatientDialog(initialState, baseClinicPatient); expect(screen.getByRole('textbox', { name: /Full Name/i })).not.toBeDisabled(); expect(screen.getByRole('textbox', { name: /Birthdate/i })).not.toBeDisabled(); diff --git a/app/components/navpatientheader/index.js b/app/components/navpatientheader/index.js index 68d24c6a76..58b6ff0b11 100644 --- a/app/components/navpatientheader/index.js +++ b/app/components/navpatientheader/index.js @@ -18,7 +18,7 @@ import utils from '../../core/utils'; const HeaderContainer = ({ children }) => ( - Date: Thu, 27 Aug 2026 14:30:34 -0700 Subject: [PATCH 07/13] WEB-4460 add tests for more menu --- .../TideDashboardV2/Cells.test.js | 30 +++++++++++++++++++ .../clinicworkspace/TideDashboardV2/Cells.js | 1 + 2 files changed, 31 insertions(+) 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/app/pages/clinicworkspace/TideDashboardV2/Cells.js b/app/pages/clinicworkspace/TideDashboardV2/Cells.js index adb7c4949d..15d60cd352 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/Cells.js +++ b/app/pages/clinicworkspace/TideDashboardV2/Cells.js @@ -268,6 +268,7 @@ export const MoreMenuCell = ({ patient }) => { return ( Date: Mon, 31 Aug 2026 14:30:18 -0700 Subject: [PATCH 08/13] WEB-4460 move modals into directory --- .../clinicworkspace/TideDashboardV2/TideDashboardV2.js | 4 ++-- .../{ => modals}/DataConnectionsModalController.js | 4 ++-- .../{ => modals}/EditPatientDialogController.js | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) rename app/pages/clinicworkspace/TideDashboardV2/{ => modals}/DataConnectionsModalController.js (81%) rename app/pages/clinicworkspace/TideDashboardV2/{ => modals}/EditPatientDialogController.js (77%) diff --git a/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js b/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js index 1fb3ac2bd7..ac4d4cac99 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js +++ b/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js @@ -21,8 +21,8 @@ import EmptyContentNode from './EmptyContentNode'; import { Redirect, useLocation } from 'react-router-dom'; import useAuthorizationGate from './useAuthorizationGate'; -import EditPatientDialogController from './EditPatientDialogController'; -import DataConnectionsModalController from './DataConnectionsModalController'; +import EditPatientDialogController from './modals/EditPatientDialogController'; +import DataConnectionsModalController from './modals/DataConnectionsModalController'; const Gap = () => ; diff --git a/app/pages/clinicworkspace/TideDashboardV2/DataConnectionsModalController.js b/app/pages/clinicworkspace/TideDashboardV2/modals/DataConnectionsModalController.js similarity index 81% rename from app/pages/clinicworkspace/TideDashboardV2/DataConnectionsModalController.js rename to app/pages/clinicworkspace/TideDashboardV2/modals/DataConnectionsModalController.js index b2fbde81f3..3acf2bf96f 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/DataConnectionsModalController.js +++ b/app/pages/clinicworkspace/TideDashboardV2/modals/DataConnectionsModalController.js @@ -1,7 +1,7 @@ import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import DataConnectionsModal from '../../../components/datasources/DataConnectionsModal'; -import { closeModals } from './tideDashboardSlice'; +import DataConnectionsModal from '../../../../components/datasources/DataConnectionsModal'; +import { closeModals } from '../tideDashboardSlice'; const DataConnectionsModalController = ({ patients }) => { const dispatch = useDispatch(); diff --git a/app/pages/clinicworkspace/TideDashboardV2/EditPatientDialogController.js b/app/pages/clinicworkspace/TideDashboardV2/modals/EditPatientDialogController.js similarity index 77% rename from app/pages/clinicworkspace/TideDashboardV2/EditPatientDialogController.js rename to app/pages/clinicworkspace/TideDashboardV2/modals/EditPatientDialogController.js index 95d6c4e11a..c2e3edcc49 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/EditPatientDialogController.js +++ b/app/pages/clinicworkspace/TideDashboardV2/modals/EditPatientDialogController.js @@ -1,9 +1,9 @@ import React from 'react'; import { useSelector, useDispatch } from 'react-redux'; -import { closeModals } from './tideDashboardSlice'; -import { RTKQueryApi } from '../../../redux/api/baseApi'; -import EditPatientDialog from '../../../components/navpatientheader/EditPatientDialog'; -import { tagTypes } from './tideDashboardApi'; +import { closeModals } from '../tideDashboardSlice'; +import { RTKQueryApi } from '../../../../redux/api/baseApi'; +import EditPatientDialog from '../../../../components/navpatientheader/EditPatientDialog'; +import { tagTypes } from '../tideDashboardApi'; const { TIDE_DASHBOARD_PATIENTS } = tagTypes; From fb579759fc2f92e3e3040fa9aae3fe8559720378 Mon Sep 17 00:00:00 2001 From: henry-tp Date: Tue, 1 Sep 2026 17:41:30 -0700 Subject: [PATCH 09/13] WEB-4460 abstract Edit Patient Dialog --- ...js => EditPatientDialogController.test.js} | 64 ++++++------ .../EditPatientDialog.js | 99 +++++++------------ .../EditPatientDialogController.js | 84 ++++++++++++++++ app/components/navpatientheader/index.js | 4 +- .../modals/EditPatientDialogController.js | 7 +- 5 files changed, 157 insertions(+), 101 deletions(-) rename __tests__/unit/components/navpatientheader/{EditPatientDialog.test.js => EditPatientDialogController.test.js} (86%) rename app/components/{navpatientheader => clinic}/EditPatientDialog.js (53%) create mode 100644 app/components/navpatientheader/EditPatientDialogController.js diff --git a/__tests__/unit/components/navpatientheader/EditPatientDialog.test.js b/__tests__/unit/components/navpatientheader/EditPatientDialogController.test.js similarity index 86% rename from __tests__/unit/components/navpatientheader/EditPatientDialog.test.js rename to __tests__/unit/components/navpatientheader/EditPatientDialogController.test.js index c60c63283f..33427a0fb6 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'; @@ -34,6 +35,7 @@ const baseClinicPatient = { const initialState = { blip: { selectedClinicId: 'clinic123', + currentPatientInViewId: 'patient123', clinics: { clinic123: { id: 'clinic123', mrnSettings: { required: false } } }, working: { fetchingClinicMRNsForPatientFormValidation: { inProgress: false, completed: false, notification: null }, @@ -43,27 +45,29 @@ const initialState = { }, }; -const renderEditPatientDialog = (storeState = initialState, clinicPatient) => { +const renderEditPatientDialogController = (storeState = initialState, clinicPatient) => { const reducer = (state = storeState, action) => state; const store = createStore(reducer, applyMiddleware(thunk)); return render( - - - - - + + + + + + + ); }; -describe('EditPatientDialog', () => { +describe('EditPatientDialogController', () => { it('locks identity fields and leaves the save button enabled when smartCorrelationId is present', () => { const smartOnFhirState = { blip: { @@ -80,7 +84,7 @@ describe('EditPatientDialog', () => { }, }; - renderEditPatientDialog(smartOnFhirState, baseClinicPatient); + renderEditPatientDialogController(smartOnFhirState, baseClinicPatient); expect(screen.getByRole('textbox', { name: /Full Name/i })).toBeDisabled(); expect(screen.getByRole('textbox', { name: /Birthdate/i })).toBeDisabled(); @@ -96,7 +100,7 @@ describe('EditPatientDialog', () => { }); it('sets read-only fields enabled when smartCorrelationId is absent', () => { - renderEditPatientDialog(initialState, baseClinicPatient); + renderEditPatientDialogController(initialState, baseClinicPatient); expect(screen.getByRole('textbox', { name: /Full Name/i })).not.toBeDisabled(); expect(screen.getByRole('textbox', { name: /Birthdate/i })).not.toBeDisabled(); @@ -128,11 +132,13 @@ describe('EditPatientDialog', () => { render( - - - - - + + + + + + + ); @@ -161,7 +167,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: { @@ -180,11 +186,13 @@ describe('EditPatientDialog', () => { const ui = (store, clinicPatient) => ( - - - - - + + + + + + + ); @@ -201,9 +209,7 @@ describe('EditPatientDialog', () => { 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({ chartDataSize: 100, updatingClinicPatient: IDLE_UPDATE }); - - const { rerender } = render(ui(mockStore(initialState), originalClinicPatient)); + 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' })); diff --git a/app/components/navpatientheader/EditPatientDialog.js b/app/components/clinic/EditPatientDialog.js similarity index 53% rename from app/components/navpatientheader/EditPatientDialog.js rename to app/components/clinic/EditPatientDialog.js index 46d48d1f3f..5636797847 100644 --- a/app/components/navpatientheader/EditPatientDialog.js +++ b/app/components/clinic/EditPatientDialog.js @@ -1,46 +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]); @@ -48,27 +40,22 @@ const useUpdatingClinicPatientWorkingState = ({ onUpdateSuccess = noop }) => { return updatingClinicPatient; }; -const PATIENT_FORM_SEARCH_DEBOUNCE_MS = 600; - const EditPatientDialog = ({ api, clinicPatient, - isOpen, + isOpen = false, onClose = noop, + onEditConfirm = noop, onEditSuccess = noop, + onEditFailure = noop, }) => { const { t } = useTranslation(); - const dispatch = useDispatch(); - - const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const pageName = useClinicMetricsPageName(); + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); const isSmartOnFhir = useSelector(selectIsSmartOnFhirMode); - const patientId = clinicPatient?.id; - - 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( @@ -76,46 +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) { - onEditSuccess(); - dispatch(actions.worker.dataWorkerRemoveDataRequest(null, patientId)); - 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 (!patientId || !selectedClinicId) return null; - return ( diff --git a/app/components/navpatientheader/EditPatientDialogController.js b/app/components/navpatientheader/EditPatientDialogController.js new file mode 100644 index 0000000000..3383c5cd2a --- /dev/null +++ b/app/components/navpatientheader/EditPatientDialogController.js @@ -0,0 +1,84 @@ +import React, { useRef } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import noop from 'lodash/noop'; +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import { DEFAULT_GLYCEMIC_RANGES } from '../../core/glycemicRangesUtils'; +import { useToasts } from '../../providers/ToastProvider'; +import * as actions from '../../redux/actions'; +import EditPatientDialog from '../clinic/EditPatientDialog'; + +const EditPatientDialogController = ({ + api, + clinicPatient, + isOpen, + onClose = noop, +}) => { + 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 onEditSuccess = () => { + // 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 onEditFailure = () => { + setToast({ + message: get(notification, 'message'), + variant: 'danger', + }); + }; + + const handleEditPatientConfirm = (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 58b6ff0b11..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'; @@ -98,7 +98,7 @@ const NavPatientHeader = ({ api, trackMetric, patient, clinicPatient, user, perm setIsUploadOverlayOpen(false)} /> } - { const clinicPatient = patients.find(patient => patient.id === editPatientDialog.patientId); - const handleCloseModal = () => { - dispatch(closeModals()); - }; + const handleCloseModal = () => dispatch(closeModals()); const handleEditSuccess = () => { + dispatch(closeModals()); dispatch(RTKQueryApi.util.invalidateTags([TIDE_DASHBOARD_PATIENTS])); }; From cac17a2ab826668a4d91877ea91a24d1b2e9b50c Mon Sep 17 00:00:00 2001 From: henry-tp Date: Tue, 1 Sep 2026 17:53:32 -0700 Subject: [PATCH 10/13] WEB-4460 add new tests for abstract modal --- .../clinic/EditPatientDialog.test.js | 138 ++++++++++++++++++ .../EditPatientDialogController.test.js | 64 -------- app/components/clinic/EditPatientDialog.js | 2 +- 3 files changed, 139 insertions(+), 65 deletions(-) create mode 100644 __tests__/unit/components/clinic/EditPatientDialog.test.js 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/EditPatientDialogController.test.js b/__tests__/unit/components/navpatientheader/EditPatientDialogController.test.js index 33427a0fb6..c20888b652 100644 --- a/__tests__/unit/components/navpatientheader/EditPatientDialogController.test.js +++ b/__tests__/unit/components/navpatientheader/EditPatientDialogController.test.js @@ -45,71 +45,7 @@ const initialState = { }, }; -const renderEditPatientDialogController = (storeState = initialState, clinicPatient) => { - const reducer = (state = storeState, action) => state; - const store = createStore(reducer, applyMiddleware(thunk)); - - return render( - - - - - - - - - - ); -}; - describe('EditPatientDialogController', () => { - 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' }, - }, - }, - }, - }, - }; - - renderEditPatientDialogController(smartOnFhirState, baseClinicPatient); - - 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', () => { - renderEditPatientDialogController(initialState, baseClinicPatient); - - 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(); - }); - // 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. diff --git a/app/components/clinic/EditPatientDialog.js b/app/components/clinic/EditPatientDialog.js index 5636797847..ba99bcd2eb 100644 --- a/app/components/clinic/EditPatientDialog.js +++ b/app/components/clinic/EditPatientDialog.js @@ -91,7 +91,7 @@ const EditPatientDialog = ({ onClose={onClose} > { - trackMetric('Clinic - Edit patient close', { clinicId: selectedClinicId }); + trackMetric('Clinic - Edit patient close', { clinicId: selectedClinicId, pageName }); onClose(); }}> {t('Edit Patient Details')} From 0e77b953904ecccbab00bfe2731ae2cc0a8b086c Mon Sep 17 00:00:00 2001 From: henry-tp Date: Tue, 1 Sep 2026 18:00:43 -0700 Subject: [PATCH 11/13] WEB-4460 use convention for handler names --- .../navpatientheader/EditPatientDialogController.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/components/navpatientheader/EditPatientDialogController.js b/app/components/navpatientheader/EditPatientDialogController.js index 3383c5cd2a..922f34b980 100644 --- a/app/components/navpatientheader/EditPatientDialogController.js +++ b/app/components/navpatientheader/EditPatientDialogController.js @@ -29,7 +29,7 @@ const EditPatientDialogController = ({ // Captured at submit time: whether this edit needs the chart data reprocessed. const shouldClearDataRef = useRef(false); - const onEditSuccess = () => { + 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. @@ -48,14 +48,14 @@ const EditPatientDialogController = ({ } }; - const onEditFailure = () => { + const handleEditFailure = () => { setToast({ message: get(notification, 'message'), variant: 'danger', }); }; - const handleEditPatientConfirm = (formContext) => { + 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); @@ -74,9 +74,9 @@ const EditPatientDialogController = ({ clinicPatient={clinicPatient} isOpen={isOpen} onClose={onClose} - onEditConfirm={handleEditPatientConfirm} - onEditSuccess={onEditSuccess} - onEditFailure={onEditFailure} + onEditConfirm={handleEditConfirm} + onEditSuccess={handleEditSuccess} + onEditFailure={handleEditFailure} /> ); }; From c81fb27836caf91f75479e8ffc0f794792b67038 Mon Sep 17 00:00:00 2001 From: henry-tp Date: Tue, 1 Sep 2026 18:10:15 -0700 Subject: [PATCH 12/13] WEB-4460 fix imports --- .../DataIssues/EditPatientDialogController.js | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/pages/clinicworkspace/TideDashboardV2/DataIssues/EditPatientDialogController.js 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; From 333bf68d231d1c1abdffe037ed2a31a08578f5a8 Mon Sep 17 00:00:00 2001 From: henry-tp Date: Thu, 3 Sep 2026 17:50:20 -0700 Subject: [PATCH 13/13] WEB-4460 address automated review feedback --- app/components/datasources/DataConnectionsModal.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/datasources/DataConnectionsModal.js b/app/components/datasources/DataConnectionsModal.js index 2fd5f83168..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();