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;