diff --git a/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js b/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js index 2f495b8929..9557f5f23d 100644 --- a/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js @@ -424,6 +424,34 @@ describe('ClinicPatients', () => { expect.any(Function), ); }, TEST_TIMEOUT_MS); + + it('maps an applied data recency filter into the getPatientsForClinic query', async () => { + render( + + + + ); + + // Open the Data Recency filter dropdown, pick a device type and window, and apply. + // Match the trigger via its icon label ("Data Recency" alone also matches the + // sortable column header of the same name). + await userEvent.click(screen.getByRole('button', { name: /Filter by last upload/ })); + await userEvent.click(screen.getByRole('radio', { name: /CGM/ })); + await userEvent.click(screen.getByRole('radio', { name: /Within 14 days/ })); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + + // The from/to date bounds are derived from the current date, so assert their + // presence and 14-day span rather than exact ISO timestamps. + expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith( + 'clinicID123', + expect.objectContaining({ + 'cgm.lastDataFrom': expect.any(String), + 'cgm.lastDataTo': expect.any(String), + limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData', + }), + expect.any(Function), + ); + }, TEST_TIMEOUT_MS); }); describe('managing sites', () => { diff --git a/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.test.js new file mode 100644 index 0000000000..2d3c9210e7 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.test.js @@ -0,0 +1,84 @@ +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 FilterByDataRecency from '@app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency'; + +const mockStore = configureStore([thunk]); + +describe('FilterByDataRecency', () => { + let store; + let wrapper; + + const selectedClinicId = 'clinic123'; + + const setActiveFilters = jest.fn(); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId } }, + }, + }); + + setActiveFilters.mockClear(); + }); + + describe('handleChange', () => { + it('calls setActiveFilters with the applied data recency merged into the existing activeFilters', async () => { + wrapper = renderComponent({ activeFilters: { lastData: null, lastDataType: null, patientTags: ['tag1'] } }); + + await userEvent.click(screen.getByRole('button', { name: /^Data Recency/ })); + await screen.findByTestId('data-recency-filter-dropdown'); + + // Correct options exist + expect(screen.getByRole('radio', { name: /Today/ })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /Within 2 days/ })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /Within 14 days/ })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /Within 30 days/ })).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /Within 7 days/ })).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('radio', { name: /CGM/ })); + await userEvent.click(screen.getByRole('radio', { name: /Within 14 days/ })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(setActiveFilters).toHaveBeenCalledTimes(1); + expect(setActiveFilters).toHaveBeenCalledWith({ + lastData: 14, + lastDataType: 'cgm', + patientTags: ['tag1'], + }); + }); + }); + + describe('activeFilters passthrough', () => { + it('reflects the active data recency in the pre-selected radios', async () => { + wrapper = renderComponent({ activeFilters: { lastData: 30, lastDataType: 'bgm' } }); + + await userEvent.click(screen.getByRole('button', { name: /^Data Recency/ })); + await screen.findByTestId('data-recency-filter-dropdown'); + + expect(screen.getByRole('radio', { name: /BGM/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /CGM/ })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: /Within 30 days/ })).toBeChecked(); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.test.js new file mode 100644 index 0000000000..a0566fb0e1 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.test.js @@ -0,0 +1,125 @@ +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 * as actions from '@app/redux/actions'; +import DataRecencyFilterDropdown from '@app/pages/clinicworkspace/components/DataRecencyFilterDropdown'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; + +const mockStore = configureStore([thunk]); + +describe('DataRecencyFilterDropdown', () => { + let store; + let wrapper; + + const selectedClinicId = 'clinic123'; + + const filterOptions = [ + { value: 1, label: 'Today' }, + { value: 2, label: 'Within 2 days' }, + { value: 14, label: 'Within 14 days' }, + { value: 30, label: 'Within 30 days' }, + ]; + + let onChange = jest.fn(); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId } }, + }, + }); + + onChange.mockClear(); + mockTrackMetric.mockClear(); + }); + + describe('filtering for data recency', () => { + it('applies the device type and data recency based on radios selected', async () => { + renderComponent(); + + // Dropdown closed initially + expect(screen.queryByTestId('data-recency-filter-dropdown')).not.toBeInTheDocument(); + + // Open the dropdown + await userEvent.click(screen.getByRole('button', { name: /Data Recency/ })); + expect(screen.getByTestId('data-recency-filter-dropdown')).toBeInTheDocument(); + + // Nothing selected initially + expect(screen.getByRole('radio', { name: /CGM/ })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: /BGM/ })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: /Within 14 days/ })).not.toBeChecked(); + + // Select a device type and a data recency window + await userEvent.click(screen.getByRole('radio', { name: /CGM/ })); + await userEvent.click(screen.getByRole('radio', { name: /Within 14 days/ })); + + // Applying the filter sets it + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith({ lastData: 14, lastDataType: 'cgm' }); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Last upload apply filter', { clinicId: 'clinic123', dateRange: '14 days', type: 'cgm', pageName: 'Population Health' }); + + // Dropdown should automatically close + expect(screen.queryByTestId('data-recency-filter-dropdown')).not.toBeInTheDocument(); + }); + + it('disables the Apply button until both a device type and data recency are selected', async () => { + renderComponent(); + + await userEvent.click(screen.getByRole('button', { name: /Data Recency/ })); + + // Disabled with nothing selected + expect(screen.getByRole('button', { name: /Apply/ })).toBeDisabled(); + + // Still disabled with only a device type selected + await userEvent.click(screen.getByRole('radio', { name: /BGM/ })); + expect(screen.getByRole('button', { name: /Apply/ })).toBeDisabled(); + + // Enabled once a data recency window is also selected + await userEvent.click(screen.getByRole('radio', { name: /Within 2 days/ })); + expect(screen.getByRole('button', { name: /Apply/ })).toBeEnabled(); + }); + + it('pre-selects the radios matching the active filters', async () => { + renderComponent({ lastData: 30, lastDataType: 'cgm' }); + + await userEvent.click(screen.getByRole('button', { name: /Data Recency/ })); + + expect(screen.getByRole('radio', { name: /CGM/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /BGM/ })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: /Within 30 days/ })).toBeChecked(); + }); + + it('clears the filter', async () => { + renderComponent({ lastData: 14, lastDataType: 'cgm' }); + + await userEvent.click(screen.getByRole('button', { name: /Data Recency/ })); + expect(screen.getByRole('radio', { name: /CGM/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /Within 14 days/ })).toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: /Clear/ })); + expect(onChange).toHaveBeenCalledWith({ lastData: null, lastDataType: null }); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Last upload clear filter', { clinicId: 'clinic123', pageName: 'Population Health' }); + expect(screen.queryByTestId('data-recency-filter-dropdown')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/app/pages/clinicworkspace/ClinicPatients.js b/app/pages/clinicworkspace/ClinicPatients.js index 741c112ae5..fe1a26994a 100644 --- a/app/pages/clinicworkspace/ClinicPatients.js +++ b/app/pages/clinicworkspace/ClinicPatients.js @@ -122,6 +122,7 @@ import noop from 'lodash/noop'; import { getGlycemicRangesPreset } from '../../core/glycemicRangesUtils'; import FilterByTags from './clinicPatientsFilters/FilterByTags'; import FilterBySites from './clinicPatientsFilters/FilterBySites'; +import FilterByDataRecency from './clinicPatientsFilters/FilterByDataRecency'; import ClinicPatientsPrintModal from './ClinicPatientsPrintModal'; import AppliedFiltersList, { getPatientQueryState } from './clinicPatientsFilters/AppliedFiltersList'; import useIsClinicAdmin from './useIsClinicAdmin'; @@ -695,13 +696,6 @@ export const ClinicPatients = (props) => { { value: '>=0.7', label: t('70% or more') }, ]; - const lastDataTypeFilterOptions = [ - { value: 'cgm', label: t('CGM') }, - { value: 'bgm', label: t('BGM') }, - ]; - - const customLastDataFilterOptions = reject(lastDataFilterOptions, { value: 7 }); - const summaryPeriodOptions = [ { value: '1d', label: t('24 hours') }, { value: '7d', label: t('7 days') }, @@ -1677,124 +1671,6 @@ export const ClinicPatients = (props) => { - - { - if (!lastDataPopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('Last data filter open'), { clinicId: selectedClinicId }); - }} - sx={{ flexShrink: 0 }} - > - - - - { - trackMetric(prefixPopHealthMetric('Last upload filter close'), { clinicId: selectedClinicId }); - }} - onClose={() => { - lastDataPopupFilterState.close(); - setPendingFilters(activeFilters); - }} - > - - - - {t('Device Type')} - - - - { - setPendingFilters({ ...pendingFilters, lastDataType: event.target.value || null }); - }} - /> - - - {t('Data Recency')} - {t('Tidepool will only show patients who have data within the selected number of days.')} - - - { - setPendingFilters({ ...pendingFilters, lastData: parseInt(event.target.value) || null }); - }} - /> - - - - - - - - - { setShowClinicSitesDialog={setShowClinicSitesDialog} /> + + { if (!timeInRangePopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('Time in range filter open'), { clinicId: selectedClinicId }); diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js new file mode 100644 index 0000000000..bb9e18f9ab --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js @@ -0,0 +1,32 @@ +import React from 'react'; + +import noop from 'lodash/noop'; +import reject from 'lodash/reject'; + +import { lastDataFilterOptions } from '../../../core/clinicUtils'; + +import DataRecencyFilterDropdown from '../components/DataRecencyFilterDropdown'; + +const FilterByDataRecency = ({ + activeFilters = {}, + setActiveFilters = noop, +}) => { + const handleChange = ({ lastData, lastDataType }) => { + setActiveFilters({ ...activeFilters, lastData, lastDataType }); + }; + + const { lastData, lastDataType } = activeFilters; + + const customLastDataFilterOptions = reject(lastDataFilterOptions, { value: 7 }); + + return ( + + ); +}; + +export default FilterByDataRecency; diff --git a/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js b/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js new file mode 100644 index 0000000000..0c6e706de7 --- /dev/null +++ b/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js @@ -0,0 +1,195 @@ +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, Text } from 'theme-ui'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; + +import reject from 'lodash/reject'; +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 } from '../../../components/elements/FontStyles'; + +import { borders } from '../../../themes/baseTheme'; +import { DialogContent, DialogActions } from '../../../components/elements/Dialog'; +import { lastDataFilterOptions } from '../../../core/clinicUtils'; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; + +const DropdownContent = ({ + onClose, + onChange, + lastData, + lastDataType, + filterOptions, +}) => { + const { t } = useTranslation(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const pageName = useClinicMetricsPageName(); + + const [pending, setPending] = useState({ lastData, lastDataType }); + + const lastDataTypeFilterOptions = [ + { value: 'cgm', label: t('CGM') }, + { value: 'bgm', label: t('BGM') }, + ]; + + const handleChange = (filters) => onChange(filters); + + return ( + + + + + {t('Device Type')} + + + + + { + setPending({ ...pending, lastDataType: event.target.value || null }); + }} + /> + + + + {t('Data Recency')} + {t('Tidepool will only show patients who have data within the selected number of days.')} + + + + { + setPending({ ...pending, lastData: parseInt(event.target.value) || null }); + }} + /> + + + + + + + + + + ); +}; + +const DataRecencyFilterDropdown = ({ + onChange = noop, + lastData = null, + lastDataType = null, + filterOptions = lastDataFilterOptions, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + + const lastDataPopupFilterState = usePopupState({ + variant: 'popover', + popupId: 'lastDataFilters', + }); + + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + + const handleCloseDropdown = () => { + lastDataPopupFilterState.close(); + }; + + return ( + <> + { + if (!lastDataPopupFilterState.isOpen) trackMetric('Clinic - Last data filter open', { clinicId: selectedClinicId, pageName }); + }} + sx={{ flexShrink: 0 }} + > + + + + { + trackMetric('Clinic - Last upload filter close', { clinicId: selectedClinicId, pageName }); + }} + onClose={handleCloseDropdown} + > + { lastDataPopupFilterState.isOpen && + + } + + + ); +}; + +export default DataRecencyFilterDropdown; diff --git a/test/unit/pages/ClinicPatients.test.js b/test/unit/pages/ClinicPatients.test.js index 5103fa6e46..26f04e2921 100644 --- a/test/unit/pages/ClinicPatients.test.js +++ b/test/unit/pages/ClinicPatients.test.js @@ -1625,54 +1625,6 @@ describe('ClinicPatients', () => { expect(timeAgoMessage).to.equal('Last updated less than an hour ago'); }); - it('should allow filtering by last upload', () => { - const lastDataFilterTrigger = container.querySelector('#last-data-filter-trigger'); - expect(lastDataFilterTrigger).to.exist; - - const popover = () => document.querySelector('#lastDataFilters'); - expect(popover()).to.exist; - expect(popover().style.visibility).to.equal('hidden'); - - // Open filters popover - fireEvent.click(lastDataFilterTrigger); - expect(popover().style.visibility).to.equal(''); - - // Ensure filter options present - const typeFilterOptions = document.querySelectorAll('#last-upload-type label'); - expect(typeFilterOptions.length).to.equal(2); - expect(typeFilterOptions[0].textContent).to.equal('CGM'); - expect(typeFilterOptions[0].querySelector('input').value).to.equal('cgm'); - - expect(typeFilterOptions[1].textContent).to.equal('BGM'); - expect(typeFilterOptions[1].querySelector('input').value).to.equal('bgm'); - - // Ensure period filter options present - const periodFilterOptions = document.querySelectorAll('#last-upload-filters label'); - expect(periodFilterOptions.length).to.equal(4); - expect(periodFilterOptions[0].textContent).to.equal('Today'); - expect(periodFilterOptions[0].querySelector('input').value).to.equal('1'); - - expect(periodFilterOptions[1].textContent).to.equal('Within 2 days'); - expect(periodFilterOptions[1].querySelector('input').value).to.equal('2'); - - expect(periodFilterOptions[2].textContent).to.equal('Within 14 days'); - expect(periodFilterOptions[2].querySelector('input').value).to.equal('14'); - - expect(periodFilterOptions[3].textContent).to.equal('Within 30 days'); - expect(periodFilterOptions[3].querySelector('input').value).to.equal('30'); - - // Apply button disabled until selection made - const applyButton = () => document.querySelector('#apply-last-upload-filter'); - expect(applyButton().disabled).to.be.true; - - fireEvent.click(typeFilterOptions[1].querySelector('input')); - fireEvent.click(periodFilterOptions[3].querySelector('input')); - expect(applyButton().disabled).to.be.false; - - fireEvent.click(applyButton()); - sinon.assert.calledWith(defaultProps.trackMetric, 'Clinic - Population Health - Last upload apply filter', sinon.match({ clinicId: 'clinicID123', dateRange: '30 days', type: 'bgm'})); - }); - it('should allow filtering by cgm use', () => { const cgmUseFilterTrigger = container.querySelector('#cgm-use-filter-trigger'); expect(cgmUseFilterTrigger).to.exist;