diff --git a/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js b/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js index 9557f5f23d..de9b75a3e1 100644 --- a/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js @@ -405,6 +405,25 @@ describe('ClinicPatients', () => { ); }, TEST_TIMEOUT_MS); + it('maps an applied summary period filter into the getPatientsForClinic query', async () => { + render( + + + + ); + + // Open the Summary Period filter dropdown, select 30 days, and apply + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + + expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith( + 'clinicID123', + { limit: 50, offset: 0, period: '30d', sortType: 'cgm', sort: '-lastData' }, + expect.any(Function), + ); + }, TEST_TIMEOUT_MS); + it('maps an applied site filter into the getPatientsForClinic query', async () => { render( diff --git a/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.test.js new file mode 100644 index 0000000000..13485afc8b --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.test.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; + +import FilterBySummaryPeriod from '@app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; + +const mockStore = configureStore([thunk]); + +describe('FilterBySummaryPeriod', () => { + let store; + let wrapper; + + const selectedClinicId = 'clinic123'; + + const setActiveSummaryPeriod = jest.fn(); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId } }, + }, + }); + + setActiveSummaryPeriod.mockClear(); + mockTrackMetric.mockClear(); + }); + + describe('handleChange', () => { + it('calls setActiveSummaryPeriod with the newly selected period', async () => { + wrapper = renderComponent({ activeSummaryPeriod: '14d' }); + + // Open the dropdown via the trigger's icon label (its text label is the dynamic + // "Summarizing ..." string that changes with the active period). + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + await screen.findByRole('radio', { name: /30 days/ }); + + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // The period value is passed straight through, not merged into an activeFilters object + expect(setActiveSummaryPeriod).toHaveBeenCalledTimes(1); + expect(setActiveSummaryPeriod).toHaveBeenCalledWith('30d'); + }); + }); + + describe('activeSummaryPeriod passthrough', () => { + it('reflects the active summary period in the trigger label and the pre-selected radio', async () => { + wrapper = renderComponent({ activeSummaryPeriod: '7d' }); + + // Trigger label reflects the active period + expect(screen.getByRole('button', { name: /Summarizing 7 days of data/ })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + + expect(screen.getByRole('radio', { name: /7 days/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /14 days/ })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: /30 days/ })).not.toBeChecked(); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js new file mode 100644 index 0000000000..77c1f329ba --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js @@ -0,0 +1,109 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; + +import SummaryPeriodFilterDropdown from '@app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown'; + +const mockStore = configureStore([thunk]); + +describe('SummaryPeriodFilterDropdown', () => { + let store; + let wrapper; + + const selectedClinicId = 'clinic123'; + + let onChange = jest.fn(); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId } }, + }, + }); + + onChange.mockClear(); + }); + + describe('filtering for summary period', () => { + it('applies the summary period based on the radio selected', async () => { + renderComponent({ activeSummaryPeriod: '14d' }); + + // Should have correct label + expect(screen.getByRole('button', { name: /Summarizing 14 days of data/ })).toBeInTheDocument(); + + // Dropdown closed initially + expect(screen.queryByTestId('summary-period-filter-dropdown')).not.toBeInTheDocument(); + + // Open the dropdown + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + expect(screen.getByRole('radio', { name: /24 hours/ })).toBeInTheDocument(); + + // The active period is pre-selected + expect(screen.getByRole('radio', { name: /14 days/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /30 days/ })).not.toBeChecked(); + + // Selecting a different period and applying sets the filter + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith('30d'); + + // Dropdown should automatically close + expect(screen.queryByTestId('summary-period-filter-dropdown')).not.toBeInTheDocument(); + }); + + it('disables the Apply button until a different period is selected', async () => { + renderComponent({ activeSummaryPeriod: '14d' }); + + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + + // Disabled while the pending selection matches the active period + expect(screen.getByRole('button', { name: /Apply/ })).toBeDisabled(); + + // Enabled once a different period is selected + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + expect(screen.getByRole('button', { name: /Apply/ })).toBeEnabled(); + + // Disabled again when re-selecting the active period + await userEvent.click(screen.getByRole('radio', { name: /14 days/ })); + expect(screen.getByRole('button', { name: /Apply/ })).toBeDisabled(); + }); + + it('cancels without applying and resets the pending selection', async () => { + renderComponent({ activeSummaryPeriod: '14d' }); + + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + + // Change the selection, then cancel + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + await userEvent.click(screen.getByRole('button', { name: /Cancel/ })); + + // No change is applied and the dropdown closes + expect(onChange).not.toHaveBeenCalled(); + expect(screen.queryByTestId('summary-period-filter-dropdown')).not.toBeInTheDocument(); + + // Re-opening shows the original active period still selected (pending was reset) + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + expect(screen.getByRole('radio', { name: /14 days/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /30 days/ })).not.toBeChecked(); + }); + }); +}); diff --git a/app/pages/clinicworkspace/ClinicPatients.js b/app/pages/clinicworkspace/ClinicPatients.js index fe1a26994a..f185a3e9e2 100644 --- a/app/pages/clinicworkspace/ClinicPatients.js +++ b/app/pages/clinicworkspace/ClinicPatients.js @@ -123,6 +123,7 @@ import { getGlycemicRangesPreset } from '../../core/glycemicRangesUtils'; import FilterByTags from './clinicPatientsFilters/FilterByTags'; import FilterBySites from './clinicPatientsFilters/FilterBySites'; import FilterByDataRecency from './clinicPatientsFilters/FilterByDataRecency'; +import FilterBySummaryPeriod from './clinicPatientsFilters/FilterBySummaryPeriod'; import ClinicPatientsPrintModal from './ClinicPatientsPrintModal'; import AppliedFiltersList, { getPatientQueryState } from './clinicPatientsFilters/AppliedFiltersList'; import useIsClinicAdmin from './useIsClinicAdmin'; @@ -696,13 +697,6 @@ export const ClinicPatients = (props) => { { value: '>=0.7', label: t('70% or more') }, ]; - const summaryPeriodOptions = [ - { value: '1d', label: t('24 hours') }, - { value: '7d', label: t('7 days') }, - { value: '14d', label: t('14 days') }, - { value: '30d', label: t('30 days') }, - ]; - const clinicSites = useMemo(() => keyBy(clinic?.sites, 'id'), [clinic?.sites]); const patientTags = useMemo(() => keyBy(clinic?.patientTags, 'id'), [clinic?.patientTags]); @@ -720,19 +714,8 @@ export const ClinicPatients = (props) => { const defaultSummaryPeriod = '14d'; const [activeSummaryPeriod, setActiveSummaryPeriod] = useLocalStorage('activePatientSummaryPeriod', defaultSummaryPeriod); - const [pendingSummaryPeriod, setPendingSummaryPeriod] = useState(activeSummaryPeriod); const previousSummaryPeriod = usePrevious(activeSummaryPeriod); - const summaryPeriodPopupFilterState = usePopupState({ - variant: 'popover', - popupId: 'summaryPeriodFilters', - }); - - const lastDataPopupFilterState = usePopupState({ - variant: 'popover', - popupId: 'lastDataFilters', - }); - const timeInRangePopupFilterState = usePopupState({ variant: 'popover', popupId: 'timeInRangeFilters', @@ -1962,99 +1945,19 @@ export const ClinicPatients = (props) => { )} {/* Flex Group 2b: Range select and Info/Visibility Icons */} - + {/* Range select */} {showSummaryData && ( - - - {t('Summarizing')} - - - { - if (!summaryPeriodPopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('Summary period filter open'), { clinicId: selectedClinicId }); - }} - > - - - - { - trackMetric(prefixPopHealthMetric('Summary period filter close'), { clinicId: selectedClinicId }); - }} - onClose={() => { - summaryPeriodPopupFilterState.close(); - setPendingSummaryPeriod(activeSummaryPeriod); - }} + sx={{ gap: 3, justifyContent: 'flex-end', alignItems: 'center', flexShrink: 0 }} > - - {t('Tidepool will generate health summaries for the selected number of days.')} - - setPendingSummaryPeriod(event.target.value)} - /> - - - - - - - + {showRpmReportUI && ( { + const handleChange = (summaryPeriod) => { + setActiveSummaryPeriod(summaryPeriod); + }; + + return ( + + ); +}; + +export default FilterBySummaryPeriod; diff --git a/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.js b/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.js new file mode 100644 index 0000000000..42e40c37d0 --- /dev/null +++ b/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.js @@ -0,0 +1,172 @@ +import React, { useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { trackMetric } from '../../../core/metricUtils'; +import { colors as vizColors } from '@tidepool/viz'; + +import { Box, Grid } from 'theme-ui'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; + +import noop from 'lodash/noop'; + +import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; + +import Button from '../../../components/elements/Button'; +import Popover from '../../../components/elements/Popover'; +import RadioGroup from '../../../components/elements/RadioGroup'; +import { Body0, Body1 } from '../../../components/elements/FontStyles'; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; + +const getSummaryPeriodSelectLabel = (t, activeSummaryPeriod) => { + switch (activeSummaryPeriod) { + case '1d': return t('Summarizing 24 hours of data'); + case '7d': return t('Summarizing 7 days of data'); + case '14d': return t('Summarizing 14 days of data'); + case '30d': return t('Summarizing 30 days of data'); + } + + return null; +}; + +const getSummaryPeriodOptions = (t) => ([ + { value: '1d', label: t('24 hours') }, + { value: '7d', label: t('7 days') }, + { value: '14d', label: t('14 days') }, + { value: '30d', label: t('30 days') }, +]); + +const DropdownContent = ({ + onClose, + onChange, + activeSummaryPeriod, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + + const [pendingSummaryPeriod, setPendingSummaryPeriod] = useState(activeSummaryPeriod); + + const handleChange = (summaryPeriod) => onChange(summaryPeriod); + + return ( + + + + {t('Summarizing Data')} + + + + {t('Tidepool will generate health summaries for the selected number of days.')} + + + + setPendingSummaryPeriod(event.target.value)} + /> + + + + + + + + + + ); +}; + +const SummaryPeriodFilterDropdown = ({ + onChange = noop, + activeSummaryPeriod = null, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + + const summaryPeriodPopupFilterState = usePopupState({ + variant: 'popover', + popupId: 'summaryPeriodFilters', + }); + + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + + const handleCloseDropdown = () => summaryPeriodPopupFilterState.close(); + + return ( + <> + { + if (!summaryPeriodPopupFilterState.isOpen) trackMetric('Clinic - Summary period filter open', { clinicId: selectedClinicId, pageName }); + }} + sx={{ flexShrink: 0 }} + > + + + + { + trackMetric('Clinic - Summary period filter close', { clinicId: selectedClinicId, pageName }); + }} + onClose={handleCloseDropdown} + > + { summaryPeriodPopupFilterState.isOpen && + + } + + + ); +}; + +export default SummaryPeriodFilterDropdown; diff --git a/test/unit/pages/ClinicPatients.test.js b/test/unit/pages/ClinicPatients.test.js index 26f04e2921..004d7b6f50 100644 --- a/test/unit/pages/ClinicPatients.test.js +++ b/test/unit/pages/ClinicPatients.test.js @@ -1763,140 +1763,35 @@ describe('ClinicPatients', () => { }); context('summary period filtering', () => { - let mockedLocalStorage; - - beforeEach(() => { - mockedLocalStorage = { - 'activePatientFilters/clinicianUserId123/clinicID123': { - timeInRange: [ - 'timeInAnyLowPercent', - 'timeInAnyHighPercent' - ], - patientTags: [], - meetsGlycemicTargets: false, - }, - activePatientSummaryPeriod: '14d', - }; + const emptyStatText = '--'; + const rowData = row => container.querySelectorAll('table tbody tr')[row].querySelectorAll('.MuiTableCell-root'); - mockUseLocalStorage.mockImplementation(key => { - defaults(mockedLocalStorage, { [key]: {} }) - return [ - mockedLocalStorage[key], - sinon.stub().callsFake(val => mockedLocalStorage[key] = val) - ]; + const mountWithSummaryPeriod = period => { + mockUseLocalStorage.mockImplementation((key, fallback = {}) => { + return [key === 'activePatientSummaryPeriod' ? period : fallback, sinon.stub()]; }); - mockUseClinicPatientsFilters.mockImplementation(() => ( - [ - { - timeInRange: ['timeInAnyLowPercent', 'timeInAnyHighPercent'], - patientTags: [], - meetsGlycemicTargets: false, - }, - sinon.stub(), - ] - )); - mountWrapper(store); - }); - - it('should allow filtering by summary period', () => { - const summaryPeriodFilterTrigger = container.querySelector('#summary-period-filter-trigger'); - expect(summaryPeriodFilterTrigger).to.exist; - - const popover = () => document.querySelector('#summaryPeriodFilters'); - expect(popover()).to.exist; - expect(popover().style.visibility).to.equal('hidden'); - - // Open filters popover - fireEvent.click(summaryPeriodFilterTrigger); - expect(popover().style.visibility).to.equal(''); - - // Ensure filter options present - const filterOptions = document.querySelectorAll('#summary-period-filters label'); - expect(filterOptions.length).to.equal(4); - expect(filterOptions[0].textContent).to.equal('24 hours'); - expect(filterOptions[0].querySelector('input').value).to.equal('1d'); - - expect(filterOptions[1].textContent).to.equal('7 days'); - expect(filterOptions[1].querySelector('input').value).to.equal('7d'); - - expect(filterOptions[2].textContent).to.equal('14 days'); - expect(filterOptions[2].querySelector('input').value).to.equal('14d'); - - expect(filterOptions[3].textContent).to.equal('30 days'); - expect(filterOptions[3].querySelector('input').value).to.equal('30d'); - - // Default should be 14 days - expect(filterOptions[2].querySelector('input').checked).to.be.true; - - // Set to 7 days - fireEvent.click(filterOptions[1].querySelector('input')); - - defaultProps.api.clinics.getPatientsForClinic.resetHistory(); - const applyButton = document.querySelector('#apply-summary-period-filter'); - fireEvent.click(applyButton); - - // Ensure resulting patient fetch is requesting the 7 day period for time in range filters - sinon.assert.calledWith(defaultProps.api.clinics.getPatientsForClinic, 'clinicID123', sinon.match({ - ...defaultFetchOptions, - sort: '-lastData', - period: '7d', - 'cgm.timeInAnyHighPercent': '>0.25', - 'cgm.timeInAnyLowPercent': '>0.04', - })); + }; - sinon.assert.calledWith(defaultProps.trackMetric, 'Clinic - Population Health - Summary period apply filter', sinon.match({ clinicId: 'clinicID123', summaryPeriod: '7d' })); + it('should show the GMI when the selected period is 14 days', () => { + mountWithSummaryPeriod('14d'); + expect(rowData(2)[4].textContent).to.contain('6.5 %'); }); - it('should not show the GMI if selected period is less than 14 days', () => { - const emptyStatText = '--'; - const summaryPeriodFilterTrigger = container.querySelector('#summary-period-filter-trigger'); - expect(summaryPeriodFilterTrigger).to.exist; - - const popover = () => document.querySelector('#summaryPeriodFilters'); - expect(popover()).to.exist; - expect(popover().style.visibility).to.equal('hidden'); - - const applyButton = () => document.querySelector('#apply-summary-period-filter'); - - // Open filters popover - fireEvent.click(summaryPeriodFilterTrigger); - expect(popover().style.visibility).to.equal(''); - - // Ensure filter options present - const filterOptions = () => document.querySelectorAll('#summary-period-filters label'); - - // Default should be 14 days - expect(filterOptions()[2].querySelector('input').checked).to.be.true; - - const dataRows = container.querySelectorAll('table tbody tr'); - expect(dataRows.length).to.equal(5); - - const rowData = row => container.querySelectorAll('table tbody tr')[row].querySelectorAll('.MuiTableCell-root'); - - expect(rowData(2)[4].textContent).to.contain('6.5 %'); // shows for 14 days - - // Open filters popover and set to 30 days - fireEvent.click(summaryPeriodFilterTrigger); - fireEvent.click(filterOptions()[3].querySelector('input')); - expect(filterOptions()[3].querySelector('input').checked).to.be.true; - fireEvent.click(applyButton()); - expect(rowData(2)[4].textContent).to.contain('7.5 %'); // shows for 30 days + it('should show the GMI when the selected period is 30 days', () => { + mountWithSummaryPeriod('30d'); + expect(rowData(2)[4].textContent).to.contain('7.5 %'); + }); - // Open filters popover and set to 7 days - fireEvent.click(summaryPeriodFilterTrigger); - fireEvent.click(filterOptions()[1].querySelector('input')); - expect(filterOptions()[1].querySelector('input').checked).to.be.true; - fireEvent.click(applyButton()); - expect(rowData(2)[4].textContent).to.contain(emptyStatText); // hidden for 7 days + it('should not show the GMI when the selected period is 7 days', () => { + mountWithSummaryPeriod('7d'); + expect(rowData(2)[4].textContent).to.contain(emptyStatText); + }); - // Open filters popover and set to 1 day - fireEvent.click(summaryPeriodFilterTrigger); - fireEvent.click(filterOptions()[0].querySelector('input')); - expect(filterOptions()[0].querySelector('input').checked).to.be.true; - fireEvent.click(applyButton()); - expect(rowData(2)[4].textContent).to.contain(emptyStatText); // hidden for 1 day + it('should not show the GMI when the selected period is 1 day', () => { + mountWithSummaryPeriod('1d'); + expect(rowData(2)[4].textContent).to.contain(emptyStatText); }); });