diff --git a/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.test.js
new file mode 100644
index 0000000000..3f21c2b4e9
--- /dev/null
+++ b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.test.js
@@ -0,0 +1,162 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import configureStore from 'redux-mock-store';
+import { thunk } from 'redux-thunk';
+import { Provider } from 'react-redux';
+import { ThemeProvider } from 'theme-ui';
+
+import theme from '@app/themes/baseTheme';
+import AppliedFiltersList from '@app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList';
+import { defaultFilterState, SPECIAL_FILTER_STATES } from '@app/pages/clinicworkspace/useClinicPatientsFilters';
+
+const mockStore = configureStore([thunk]);
+
+const FULLY_ACTIVE_FILTERS = {
+ ...defaultFilterState,
+ lastData: 14,
+ lastDataType: 'cgm',
+ timeInRange: ['timeInTargetPercent', 'timeInVeryLowPercent'],
+ patientTags: ['tag1', 'tag2'],
+ clinicSites: ['site1', 'site2'],
+};
+
+const buildState = ({
+ fetchedPatientCount = 5,
+ patientListSearchTextInput = '',
+} = {}) => ({
+ blip: {
+ selectedClinicId: 'clinic123',
+ clinics: {
+ 'clinic123': {
+ id: 'clinic123',
+ fetchedPatientCount,
+ patientTags: [
+ { id: 'tag1', name: 'Tag One' },
+ { id: 'tag2', name: 'Tag Two' },
+ ],
+ sites: [
+ { id: 'site1', name: 'Site Alpha' },
+ { id: 'site2', name: 'Site Bravo' },
+ ],
+ },
+ },
+ patientListFilters: { patientListSearchTextInput },
+ },
+});
+
+const renderList = ({
+ activeFilters = defaultFilterState,
+ setActiveFilters = jest.fn(),
+ onClearSearch = jest.fn(),
+ onResetFilters = jest.fn(),
+ state = buildState(),
+} = {}) => {
+ const store = mockStore(state);
+
+ const utils = render(
+
+
+
+
+
+ );
+
+ return { ...utils, setActiveFilters, onClearSearch, onResetFilters };
+};
+
+describe('AppliedFiltersList', () => {
+ describe('clear/reset controls', () => {
+ it('shows a "Reset Filters" control that fires onResetFilters when only filters are active', async () => {
+ const { onResetFilters, onClearSearch } = renderList({
+ activeFilters: { ...defaultFilterState, timeInRange: ['timeInTargetPercent'] },
+ });
+
+ await userEvent.click(screen.getByRole('button', { name: 'Reset All Filters' }));
+
+ expect(onResetFilters).toHaveBeenCalledTimes(1);
+ expect(onClearSearch).not.toHaveBeenCalled();
+ });
+
+ it('shows a "Clear Search" control that fires onClearSearch when only a search is active', async () => {
+ const { onClearSearch, onResetFilters } = renderList({
+ activeFilters: defaultFilterState,
+ state: buildState({ patientListSearchTextInput: 'john' }),
+ });
+
+ await userEvent.click(screen.getByRole('button', { name: 'Clear Search' }));
+
+ expect(onClearSearch).toHaveBeenCalledTimes(1);
+ expect(onResetFilters).not.toHaveBeenCalled();
+ });
+
+ it('shows both controls, each wired to its own callback, when a filter and a search are both active', async () => {
+ const { onClearSearch, onResetFilters } = renderList({
+ activeFilters: { ...defaultFilterState, timeInRange: ['timeInTargetPercent'] },
+ state: buildState({ patientListSearchTextInput: 'john' }),
+ });
+
+ await userEvent.click(screen.getByRole('button', { name: 'Reset All Filters' }));
+ await userEvent.click(screen.getByRole('button', { name: 'Clear Search' }));
+
+ expect(onResetFilters).toHaveBeenCalledTimes(1);
+ expect(onClearSearch).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('removing filters fires setActiveFilters correctly', () => {
+ it('resets lastData and lastDataType to their defaults when the data-recency chip is removed', async () => {
+ const { setActiveFilters } = renderList({ activeFilters: FULLY_ACTIVE_FILTERS });
+
+ await userEvent.click(screen.getByLabelText('Remove CGM data within 14 days filter'));
+
+ expect(setActiveFilters).toHaveBeenCalledTimes(1);
+ expect(setActiveFilters).toHaveBeenCalledWith({
+ ...FULLY_ACTIVE_FILTERS,
+ lastData: defaultFilterState.lastData,
+ lastDataType: defaultFilterState.lastDataType,
+ });
+ });
+
+ it('removes only the clicked time-in-range value, preserving the others', async () => {
+ const { setActiveFilters } = renderList({ activeFilters: FULLY_ACTIVE_FILTERS });
+
+ await userEvent.click(screen.getByLabelText('Remove %TIR = Meeting Targets filter'));
+
+ expect(setActiveFilters).toHaveBeenCalledTimes(1);
+ expect(setActiveFilters).toHaveBeenCalledWith({
+ ...FULLY_ACTIVE_FILTERS,
+ timeInRange: ['timeInVeryLowPercent'],
+ });
+ });
+
+ it('removes only the clicked patient tag, preserving the others', async () => {
+ const { setActiveFilters } = renderList({ activeFilters: FULLY_ACTIVE_FILTERS });
+
+ await userEvent.click(screen.getByLabelText('Remove Tag One filter'));
+
+ expect(setActiveFilters).toHaveBeenCalledTimes(1);
+ expect(setActiveFilters).toHaveBeenCalledWith({
+ ...FULLY_ACTIVE_FILTERS,
+ patientTags: ['tag2'],
+ });
+ });
+
+ it('removes only the clicked clinic site, preserving the others', async () => {
+ const { setActiveFilters } = renderList({ activeFilters: FULLY_ACTIVE_FILTERS });
+
+ await userEvent.click(screen.getByLabelText('Remove Site Alpha filter'));
+
+ expect(setActiveFilters).toHaveBeenCalledTimes(1);
+ expect(setActiveFilters).toHaveBeenCalledWith({
+ ...FULLY_ACTIVE_FILTERS,
+ clinicSites: ['site2'],
+ });
+ });
+ });
+});
diff --git a/__tests__/unit/app/pages/clinicworkspace/components/ActiveFiltersTray.test.js b/__tests__/unit/app/pages/clinicworkspace/components/ActiveFiltersTray.test.js
new file mode 100644
index 0000000000..7764c4a963
--- /dev/null
+++ b/__tests__/unit/app/pages/clinicworkspace/components/ActiveFiltersTray.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 configureStore from 'redux-mock-store';
+import { thunk } from 'redux-thunk';
+import { Provider } from 'react-redux';
+import { ThemeProvider } from 'theme-ui';
+import '@app/core/language';
+
+import theme from '@app/themes/baseTheme';
+import ActiveFiltersTray from '@app/pages/clinicworkspace/components/ActiveFiltersTray';
+import { defaultFilterState, SPECIAL_FILTER_STATES } from '@app/pages/clinicworkspace/useClinicPatientsFilters';
+
+const mockStore = configureStore([thunk]);
+
+const buildState = ({ fetchedPatientCount = 5 } = {}) => ({
+ blip: {
+ selectedClinicId: 'clinic123',
+ clinics: {
+ 'clinic123': {
+ id: 'clinic123',
+ fetchedPatientCount,
+ patientTags: [
+ { id: 'tag1', name: 'Tag One' },
+ { id: 'tag2', name: 'Tag Two' },
+ ],
+ sites: [
+ { id: 'site1', name: 'Site Alpha' },
+ { id: 'site2', name: 'Site Bravo' },
+ ],
+ },
+ },
+ },
+});
+
+const renderTray = ({
+ filters = defaultFilterState,
+ hasSearchActive = false,
+ onRemoveFilter = jest.fn(),
+ state = buildState(),
+} = {}) => {
+ const store = mockStore(state);
+
+ const utils = render(
+
+
+
+
+
+ );
+
+ return { ...utils, onRemoveFilter };
+};
+
+describe('ActiveFiltersTray', () => {
+ describe('patient count header', () => {
+ it('renders the fetched patient count', () => {
+ renderTray({ state: buildState({ fetchedPatientCount: 5 }) });
+
+ expect(screen.getByText('Showing 5 patients')).toBeInTheDocument();
+ });
+
+ it('notes the count reflects the search when a search is active', () => {
+ renderTray({ hasSearchActive: true, state: buildState({ fetchedPatientCount: 5 }) });
+
+ expect(screen.getByText('Showing 5 patients that match your search')).toBeInTheDocument();
+ });
+ });
+
+ describe('primary filter chips', () => {
+ it('renders a time-in-range filter under the "with" prefix using its expected label', () => {
+ renderTray({ filters: { ...defaultFilterState, timeInRange: ['timeInTargetPercent'] } });
+
+ expect(screen.getByText('with')).toBeInTheDocument();
+ expect(screen.getByText('%TIR = Meeting Targets')).toBeInTheDocument();
+ });
+
+ it('renders a data-recency filter with its expected label', () => {
+ renderTray({ filters: { ...defaultFilterState, lastData: 14, lastDataType: 'cgm' } });
+
+ expect(screen.getByText('CGM data within 14 days')).toBeInTheDocument();
+ });
+
+ it('renders a CGM-use filter with its expected label', () => {
+ renderTray({ filters: { ...defaultFilterState, timeCGMUsePercent: '>=0.7' } });
+
+ expect(screen.getByText('≥ 70% CGM use')).toBeInTheDocument();
+ });
+ });
+
+ describe('tag chips', () => {
+ it('renders a "tagged" prefix and the tag name for an applied patient tag', () => {
+ renderTray({ filters: { ...defaultFilterState, patientTags: ['tag1'] } });
+
+ expect(screen.getByText('tagged')).toBeInTheDocument();
+ expect(screen.getByText('Tag One')).toBeInTheDocument();
+ });
+ });
+
+ describe('site chips', () => {
+ it('renders a "visiting" prefix and the site name for an applied clinic site', () => {
+ renderTray({ filters: { ...defaultFilterState, clinicSites: ['site1'] } });
+
+ expect(screen.getByText('visiting')).toBeInTheDocument();
+ expect(screen.getByText('Site Alpha')).toBeInTheDocument();
+ });
+ });
+
+ describe('removing a chip', () => {
+ it('fires onRemoveFilter with the chip type and value when its remove icon is clicked', async () => {
+ const { onRemoveFilter } = renderTray({
+ filters: { ...defaultFilterState, clinicSites: ['site1'] },
+ });
+
+ await userEvent.click(screen.getByLabelText('Remove Site Alpha filter'));
+
+ expect(onRemoveFilter).toHaveBeenCalledTimes(1);
+ expect(onRemoveFilter).toHaveBeenCalledWith('clinicSites', 'site1');
+ });
+ });
+});
diff --git a/app/core/icons/tagIcon.svg b/app/core/icons/tagIcon.svg
new file mode 100644
index 0000000000..5fdf830e3f
--- /dev/null
+++ b/app/core/icons/tagIcon.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/app/pages/clinicworkspace/ClinicPatients.js b/app/pages/clinicworkspace/ClinicPatients.js
index 8c61da0d11..971b0841fc 100644
--- a/app/pages/clinicworkspace/ClinicPatients.js
+++ b/app/pages/clinicworkspace/ClinicPatients.js
@@ -49,7 +49,7 @@ import { scroller } from 'react-scroll';
import { Formik, Form } from 'formik';
import { useFlags, useLDClient } from 'launchdarkly-react-client-sdk';
import { Link as RouterLink } from 'react-router-dom';
-import useClinicPatientsFilters, { defaultFilterState } from './useClinicPatientsFilters';
+import useClinicPatientsFilters, { defaultFilterState, SPECIAL_FILTER_STATES } from './useClinicPatientsFilters';
import {
bindPopover,
@@ -87,6 +87,7 @@ import SendEmailIcon from '../../core/icons/SendEmailIcon.svg';
import TabularReportIcon from '../../core/icons/TabularReportIcon.svg';
import utils from '../../core/utils';
import LimitReached from './images/LimitReached.svg';
+import ClearFilterButtons, { PATIENT_QUERY_STATE } from './components/ClearFilterButtons';
import {
Dialog,
@@ -120,6 +121,7 @@ import colorPalette from '../../themes/colorPalette';
import noop from 'lodash/noop';
import { getGlycemicRangesPreset } from '../../core/glycemicRangesUtils';
import ClinicPatientsPrintModal from './ClinicPatientsPrintModal';
+import AppliedFiltersList, { getPatientQueryState } from './clinicPatientsFilters/AppliedFiltersList';
const { Loader } = vizComponents;
const { reshapeBgClassesToBgBounds, generateBgRangeLabels, formatBgValue } = vizUtils.bg;
@@ -207,55 +209,9 @@ const printPatientData = (patient, setSelectedPatient, selectedClinicId, trackMe
setShowPrintDataModal(true);
};
-const ClearButton = styled.button`
- background: none;
- color: ${vizColors.indigo30};
- border: none;
- padding: 0;
- font: inherit;
- cursor: pointer;
- text-underline-offset: 4px;
- text-decoration: underline;
-`;
-
-export const PATIENT_LIST_QUERY_STATE = {
- FILTER_AND_SEARCH: 'FILTER_AND_SEARCH',
- FILTER_ONLY: 'FILTER_ONLY',
- SEARCH_ONLY: 'SEARCH_ONLY',
- NONE: 'NONE',
-};
-
-export const getPatientListQueryState = (
- activeFilters = {},
- patientListSearchTextInput = '',
-) => {
- const { lastData, lastDataType, timeCGMUsePercent, timeInRange, clinicSites, patientTags } = activeFilters;
-
- const hasFiltersActive = (
- lastData ||
- lastDataType ||
- timeCGMUsePercent ||
- timeInRange?.length > 0 ||
- clinicSites?.length > 0 ||
- patientTags?.length > 0
- );
-
- const hasSearchActive = !!patientListSearchTextInput;
-
- if (hasFiltersActive && hasSearchActive) {
- return PATIENT_LIST_QUERY_STATE.FILTER_AND_SEARCH;
- } else if (hasFiltersActive) {
- return PATIENT_LIST_QUERY_STATE.FILTER_ONLY;
- } else if (hasSearchActive) {
- return PATIENT_LIST_QUERY_STATE.SEARCH_ONLY;
- }
-
- return PATIENT_LIST_QUERY_STATE.NONE;
-};
-
-const EmptyContentNode = ({ patientListQueryState, children }) => {
+const EmptyContentNode = ({ patientQueryState, children }) => {
const { t } = useTranslation();
- const { FILTER_AND_SEARCH, FILTER_ONLY, SEARCH_ONLY, NONE } = PATIENT_LIST_QUERY_STATE;
+ const { FILTER_AND_SEARCH, FILTER_ONLY, SEARCH_ONLY, NONE } = PATIENT_QUERY_STATE;
const emptyContentCopyDefs = {
[FILTER_AND_SEARCH]: t('There are no patient accounts with the current filter(s) that match your search'),
@@ -264,11 +220,11 @@ const EmptyContentNode = ({ patientListQueryState, children }) => {
[NONE]: t('There are no results to show'),
};
- const emptyContentCopy = emptyContentCopyDefs[patientListQueryState] || emptyContentCopyDefs[NONE];
+ const emptyContentCopy = emptyContentCopyDefs[patientQueryState] || emptyContentCopyDefs[NONE];
return (
{
);
};
-const ClearFilterButtons = withTranslation()(({ t, patientListQueryState, onClearSearch, onResetFilters }) => {
- const { FILTER_AND_SEARCH, FILTER_ONLY, SEARCH_ONLY, NONE } = PATIENT_LIST_QUERY_STATE;
-
- switch(patientListQueryState) {
- case SEARCH_ONLY:
- return
-
- {t('Clear Search')}
-
- ;
-
- case FILTER_ONLY:
- return
-
- {t('Reset Filters')}
-
- ;
-
- case FILTER_AND_SEARCH:
- return
-
- {t('Reset Filters')}
-
- <>{' '}{t('or')}{' '}>
-
- {t('Clear Search')}
-
- ;
-
- case NONE:
- default:
- return null;
- }
-});
-
-const FilterResetBar = withTranslation()(({ t, rightSideContent, patientListQueryState }) => {
- const selectedClinicId = useSelector((state) => state.blip.selectedClinicId);
- const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]);
- const count = clinic?.fetchedPatientCount || 0;
-
- const { FILTER_AND_SEARCH, FILTER_ONLY, SEARCH_ONLY, NONE } = PATIENT_LIST_QUERY_STATE;
-
- if (patientListQueryState === PATIENT_LIST_QUERY_STATE.NONE) return null; // hide when no search or filters applied
-
- const fetchedPatientCountCopyDefs = {
- [FILTER_AND_SEARCH]: t('Showing {{ count }} patient accounts with the current filter(s) that match your search', { count }),
- [FILTER_ONLY]: t('Showing {{ count }} patient accounts with the current filter(s)', { count }),
- [SEARCH_ONLY]: t('Showing {{ count }} patient accounts that match your search', { count }),
- [NONE]: t('There are no results to show'),
- };
-
- const fetchedPatientCountCopy = fetchedPatientCountCopyDefs[patientListQueryState];
-
- return (
-
- {fetchedPatientCountCopy}
- {rightSideContent}
-
- );
-});
-
const MoreMenu = ({
patient,
isClinicAdmin,
@@ -694,15 +580,6 @@ const PatientTags = ({
);
};
-// If we HTTP GET `/patients` without a sites/tags query arg, we receive a list of PwDs with zero
-// or many sites/tags. We need to pass an explicit argument to request PwDs with exactly zero
-// sites/tags. By setting the filter to `['_']`, the query path is set to `/patients?sites=_` or
-// `/patients?tags=_`, which the backend understands as a request for PwDs with zero sites/tags
-export const SPECIAL_FILTER_STATES = {
- ZERO_SITES: ['_'],
- ZERO_TAGS: ['_'],
-};
-
export const ClinicPatients = (props) => {
useScrollToTop();
const { t, api, trackMetric, searchDebounceMs } = props;
@@ -1648,14 +1525,6 @@ export const ClinicPatients = (props) => {
}, [api, dispatch, selectedClinicId, selectedPatient?.id, trackMetric]);
const renderHeader = () => {
- const activeFiltersCount = without([
- activeFilters.timeCGMUsePercent,
- activeFilters.lastData,
- activeFilters.clinicSites?.length,
- activeFilters.timeInRange?.length,
- activeFilters.patientTags?.length,
- ], null, 0, undefined).length;
-
const sortedSiteFilterOptions = clinicSitesFilterOptions?.toSorted((a, b) => utils.compareLabels(a.label, b.label)) || [];
const sortedTagFilterOptions = patientTagsFilterOptions?.toSorted((a, b) => utils.compareLabels(a.label, b.label)) || [];
@@ -1813,35 +1682,18 @@ export const ClinicPatients = (props) => {
pl={[0, 0, 2]}
py={1}
sx={{
- color: activeFiltersCount > 0 ? 'purpleMedium' : 'grays.4',
+ color: 'grays.4',
alignItems: 'center',
gap: 1,
borderLeft: ['none', null, borders.divider],
flexShrink: 0
}}
>
- {activeFiltersCount > 0 ? (
-
- ) : (
-
- )}
{t('Filter By')}
+
{
if (!lastDataPopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('Last data filter open'), { clinicId: selectedClinicId });
@@ -1857,12 +1709,7 @@ export const ClinicPatients = (props) => {
iconLabel="Filter by last upload"
sx={{ fontSize: 0, lineHeight: 1.3 }}
>
- {activeFilters.lastData
- ? activeFilters.lastData === 1
- ? t('Data within 1 day')
- : t('Data within') + find(customLastDataFilterOptions, { value: activeFilters.lastData })?.label.replace('Within', '')
- : t('Data Recency')
- }
+ {t('Data Recency')}
@@ -1980,7 +1827,7 @@ export const ClinicPatients = (props) => {
sx={{ fontSize: 0, lineHeight: 1.3 }}
>
- {t('Sites')}
+ {t('Clinic Sites')}
{!!activeFilters.clinicSites?.length && (
{
- {t('Sites')}
+ {t('Clinic Sites')}
{ sortedSiteFilterOptions.length > 0 &&
@@ -2600,18 +2447,6 @@ export const ClinicPatients = (props) => {
-
- {activeFiltersCount > 0 && (
-
- )}
)}
@@ -2722,12 +2557,12 @@ export const ClinicPatients = (props) => {
id="open-rpm-report-config"
variant="tertiary"
onClick={handleConfigureRpmReport}
- fontSize={0}
lineHeight={1.3}
px={2}
py={1}
iconSrc={TabularReportIcon}
iconPosition="left"
+ sx={{ fontSize: 0 }}
>
{t('RPM Report')}
@@ -2737,7 +2572,7 @@ export const ClinicPatients = (props) => {
)}
{/* Info/Visibility Icons */}
-
+
{showSummaryData && isPatientListVisible && (
<>
{
const page = Math.ceil(patientFetchOptions.offset / patientFetchOptions.limit) + 1;
const sort = patientFetchOptions.sort || defaultPatientFetchOptions.sort;
- const patientListQueryState = getPatientListQueryState(activeFilters, patientListSearchTextInput);
-
- // Show the Filter Reset Bar only if data exists and any filters/search are applied
- const showFilterResetBar = (data?.length > 0) && patientListQueryState !== PATIENT_LIST_QUERY_STATE.NONE;
+ const patientQueryState = getPatientQueryState(activeFilters, patientListSearchTextInput);
return (
- { showFilterResetBar &&
-
- }
- />
- }
+
{
orderBy={sort?.substring(1)}
onClickRow={handleClickPatient}
emptyContentNode={
-
+
@@ -4348,6 +4174,7 @@ export const ClinicPatients = (props) => {
);
}, [
+ activeFilters,
clinic?.fetchedPatientCount,
columns,
data,
@@ -4356,6 +4183,7 @@ export const ClinicPatients = (props) => {
handleSortChange,
loading,
patientFetchOptions,
+ setActiveFilters,
showSummaryData,
tableStyle,
]);
diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.js b/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.js
new file mode 100644
index 0000000000..327a3c43d9
--- /dev/null
+++ b/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.js
@@ -0,0 +1,119 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import PropTypes from 'prop-types';
+import without from 'lodash/without';
+
+import ActiveFiltersTray from '../components/ActiveFiltersTray';
+import ClearFilterButtons, { PATIENT_QUERY_STATE } from '../components/ClearFilterButtons';
+import { defaultFilterState } from '../useClinicPatientsFilters';
+import { Box } from 'theme-ui';
+
+export const getPatientQueryState = (
+ activeFilters = {},
+ patientListSearchTextInput = '',
+) => {
+ const { lastData, lastDataType, timeCGMUsePercent, timeInRange, clinicSites, patientTags } = activeFilters;
+
+ const hasFiltersActive = (
+ lastData ||
+ lastDataType ||
+ timeCGMUsePercent ||
+ timeInRange?.length > 0 ||
+ clinicSites?.length > 0 ||
+ patientTags?.length > 0
+ );
+
+ const hasSearchActive = !!patientListSearchTextInput;
+
+ if (hasFiltersActive && hasSearchActive) {
+ return PATIENT_QUERY_STATE.FILTER_AND_SEARCH;
+ } else if (hasFiltersActive) {
+ return PATIENT_QUERY_STATE.FILTER_ONLY;
+ } else if (hasSearchActive) {
+ return PATIENT_QUERY_STATE.SEARCH_ONLY;
+ }
+
+ return PATIENT_QUERY_STATE.NONE;
+};
+
+const AppliedFiltersList = ({ activeFilters, setActiveFilters, onClearSearch, onResetFilters }) => {
+ const selectedClinicId = useSelector(state => state.blip.selectedClinicId);
+ const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]);
+ const { patientListSearchTextInput } = useSelector(state => state.blip.patientListFilters);
+
+ const handleRemoveFilter = (filterKey, value) => {
+ switch (filterKey) {
+ case 'lastData':
+ setActiveFilters({
+ ...activeFilters,
+ lastData: defaultFilterState.lastData,
+ lastDataType: defaultFilterState.lastDataType,
+ });
+ break;
+
+ case 'timeInRange':
+ setActiveFilters({
+ ...activeFilters,
+ timeInRange: without(activeFilters.timeInRange, value),
+ });
+ break;
+
+ case 'patientTags':
+ setActiveFilters({
+ ...activeFilters,
+ patientTags: without(activeFilters.patientTags, value),
+ });
+ break;
+
+ case 'clinicSites':
+ setActiveFilters({
+ ...activeFilters,
+ clinicSites: without(activeFilters.clinicSites, value),
+ });
+ break;
+
+ case 'timeCGMUsePercent':
+ setActiveFilters({
+ ...activeFilters,
+ timeCGMUsePercent: defaultFilterState.timeCGMUsePercent,
+ });
+ break;
+ }
+ };
+
+ const hasSearchActive = !!patientListSearchTextInput;
+
+ const hasActiveFilters = !!(
+ activeFilters.lastData ||
+ activeFilters.lastDataType ||
+ activeFilters.timeCGMUsePercent ||
+ activeFilters.timeInRange?.length > 0 ||
+ activeFilters.patientTags?.length > 0 ||
+ activeFilters.clinicSites?.length > 0
+ );
+
+ const isRendered = hasActiveFilters || hasSearchActive;
+
+ if (!isRendered) return null;
+
+ const patientQueryState = getPatientQueryState(activeFilters, patientListSearchTextInput);
+
+ return (
+
+
+
+ }
+ />
+ );
+};
+
+export default AppliedFiltersList;
diff --git a/app/pages/clinicworkspace/components/ActiveFiltersTray.js b/app/pages/clinicworkspace/components/ActiveFiltersTray.js
new file mode 100644
index 0000000000..7068c9ccf3
--- /dev/null
+++ b/app/pages/clinicworkspace/components/ActiveFiltersTray.js
@@ -0,0 +1,259 @@
+import React from 'react';
+import { useSelector } from 'react-redux';
+import { useTranslation, withTranslation } from 'react-i18next';
+import { Flex, Text, Box } from 'theme-ui';
+import { colors as vizColors } from '@tidepool/viz';
+
+import CloseRoundedIcon from '@material-ui/icons/CloseRounded';
+import LocationOnOutlinedIcon from '@material-ui/icons/LocationOnOutlined';
+import TagIcon from '../../../core/icons/tagIcon.svg';
+
+import find from 'lodash/find';
+import isEqual from 'lodash/isEqual';
+import noop from 'lodash/noop';
+
+import Icon from '../../../components/elements/Icon';
+import utils from '../../../core/utils';
+import { transitions } from '../../../themes/baseTheme';
+import { SPECIAL_FILTER_STATES } from '../useClinicPatientsFilters';
+
+const usePrimaryChips = (activeFilters) => {
+ const { t } = useTranslation();
+ const { lastData, lastDataType, timeCGMUsePercent, timeInRange = [] } = activeFilters;
+
+ const getLastDataChipChipLabel = (lastDataType, lastData, t) => ({
+ bgm: t('BGM data within {{ count }} days', { count: lastData }),
+ cgm: t('CGM data within {{ count }} days', { count: lastData }),
+ }[lastDataType]);
+
+ const getTimeCGMUsePercentChipLabel = (timeCGMUsePercent, t) => ({
+ '<0.7': t('< 70% CGM use'),
+ '>=0.7': t('≥ 70% CGM use'),
+ }[timeCGMUsePercent]);
+
+ const getTimeInRangeChipLabel = (rangeKey, t) => ({
+ timeInExtremeHighPercent: t('%TIR = Highest'),
+ timeInVeryHighPercent: t('%TIR = Very High'),
+ timeInAnyHighPercent: t('%TIR = High'),
+ timeInTargetPercent: t('%TIR = Meeting Targets'),
+ timeInAnyLowPercent: t('%TIR = Low'),
+ timeInVeryLowPercent: t('%TIR = Very Low'),
+ }[rangeKey]);
+
+ return [
+ // Data Recency Filter
+ (lastData && lastDataType && {
+ type: 'lastData',
+ value: `${lastDataType}-${lastData}`,
+ label: getLastDataChipChipLabel(lastDataType, lastData, t),
+ }),
+
+ // CGM Wear Time Filter
+ (timeCGMUsePercent && {
+ type: 'timeCGMUsePercent',
+ value: timeCGMUsePercent,
+ label: getTimeCGMUsePercentChipLabel(timeCGMUsePercent, t),
+ }),
+
+ // Time In Range Filters
+ ...timeInRange.map(rangeKey => ({
+ type: 'timeInRange',
+ value: rangeKey,
+ label: getTimeInRangeChipLabel(rangeKey, t),
+ })),
+ ].filter(Boolean);
+};
+
+const useTagChips = (patientTags = []) => {
+ const { t } = useTranslation();
+ const selectedClinicId = useSelector(state => state.blip.selectedClinicId);
+ const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]);
+
+ if (isEqual(patientTags, SPECIAL_FILTER_STATES.ZERO_TAGS)) {
+ return [{
+ type: 'patientTags',
+ value: SPECIAL_FILTER_STATES.ZERO_TAGS[0],
+ label: t('No tags'),
+ }];
+ }
+
+ return patientTags
+ .map(id => ({
+ type: 'patientTags',
+ value: id,
+ label: find(clinic?.patientTags, { id })?.name,
+ }))
+ .filter(chip => chip.label)
+ .toSorted((a, b) => utils.compareLabels(a.label, b.label));
+};
+
+const useSiteChips = (clinicSites = []) => {
+ const { t } = useTranslation();
+ const selectedClinicId = useSelector(state => state.blip.selectedClinicId);
+ const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]);
+
+ if (isEqual(clinicSites, SPECIAL_FILTER_STATES.ZERO_SITES)) {
+ return [{
+ type: 'clinicSites',
+ value: SPECIAL_FILTER_STATES.ZERO_SITES[0],
+ label: t('No clinic sites'),
+ }];
+ }
+
+ return clinicSites
+ .map(id => ({
+ type: 'clinicSites',
+ value: id,
+ label: find(clinic?.sites, { id })?.name,
+ }))
+ .filter(chip => chip.label)
+ .toSorted((a, b) => utils.compareLabels(a.label, b.label));
+};
+
+const Chip = withTranslation()(({ t, label, onRemove }) => (
+
+ {label}
+
+
+));
+
+const ChipGroup = ({ prefix, chips, onRemove }) => {
+ if (!chips?.length) return null;
+
+ return (
+
+ {prefix}
+
+ {chips.map(chip => (
+ onRemove(chip)}
+ />
+ ))}
+
+ );
+};
+
+const ActiveFiltersTray = ({
+ filters = {},
+ hasSearchActive = false,
+ onRemoveFilter = noop,
+ rightContent = null,
+}) => {
+ const { t } = useTranslation();
+ const selectedClinicId = useSelector(state => state.blip.selectedClinicId);
+ const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]);
+
+ const primaryChips = usePrimaryChips(filters);
+ const tagChips = useTagChips(filters.patientTags);
+ const siteChips = useSiteChips(filters.clinicSites);
+
+ const count = clinic?.fetchedPatientCount || 0;
+
+ const handleRemoveChip = chip => onRemoveFilter(chip.type, chip.value);
+
+ return (
+
+
+
+ { hasSearchActive
+ ? t('Showing {{ count }} patients that match your search', { count })
+ : t('Showing {{ count }} patients', { count })
+ }
+
+
+ {t('with')}}
+ />
+
+
+
+ {t('tagged')}
+ }
+ />
+
+
+
+ {t('visiting')}
+ }
+ />
+
+
+ {rightContent && (
+
+ {rightContent}
+
+ )}
+
+ );
+};
+
+export default ActiveFiltersTray;
diff --git a/app/pages/clinicworkspace/components/ClearFilterButtons.js b/app/pages/clinicworkspace/components/ClearFilterButtons.js
new file mode 100644
index 0000000000..bdfaacf400
--- /dev/null
+++ b/app/pages/clinicworkspace/components/ClearFilterButtons.js
@@ -0,0 +1,62 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Box } from 'theme-ui';
+import styled from '@emotion/styled';
+import { colors as vizColors } from '@tidepool/viz';
+
+export const PATIENT_QUERY_STATE = {
+ FILTER_AND_SEARCH: 'FILTER_AND_SEARCH',
+ FILTER_ONLY: 'FILTER_ONLY',
+ SEARCH_ONLY: 'SEARCH_ONLY',
+ NONE: 'NONE',
+};
+
+const ClearButton = styled.button`
+ background: none;
+ color: ${vizColors.indigo30};
+ border: none;
+ padding: 0;
+ font: inherit;
+ cursor: pointer;
+ text-underline-offset: 4px;
+ text-decoration: underline;
+`;
+
+const ClearFilterButtons = ({ patientQueryState, onClearSearch, onResetFilters }) => {
+ const { t } = useTranslation();
+
+ const { FILTER_AND_SEARCH, FILTER_ONLY, SEARCH_ONLY, NONE } = PATIENT_QUERY_STATE;
+
+ switch(patientQueryState) {
+ case SEARCH_ONLY:
+ return
+
+ {t('Clear Search')}
+
+ ;
+
+ case FILTER_ONLY:
+ return
+
+ {t('Reset All Filters')}
+
+ ;
+
+ case FILTER_AND_SEARCH:
+ return
+
+ {t('Reset All Filters')}
+
+ <>{' '}{t('or')}{' '}>
+
+ {t('Clear Search')}
+
+ ;
+
+ case NONE:
+ default:
+ return null;
+ }
+};
+
+export default ClearFilterButtons;
diff --git a/app/pages/clinicworkspace/useClinicPatientsFilters.js b/app/pages/clinicworkspace/useClinicPatientsFilters.js
index 594e0d5f3b..180a342383 100644
--- a/app/pages/clinicworkspace/useClinicPatientsFilters.js
+++ b/app/pages/clinicworkspace/useClinicPatientsFilters.js
@@ -11,6 +11,15 @@ export const defaultFilterState = {
clinicSites: [],
};
+// If we HTTP GET `/patients` without a sites/tags query arg, we receive a list of PwDs with zero
+// or many sites/tags. We need to pass an explicit argument to request PwDs with exactly zero
+// sites/tags. By setting the filter to `['_']`, the query path is set to `/patients?sites=_` or
+// `/patients?tags=_`, which the backend understands as a request for PwDs with zero sites/tags
+export const SPECIAL_FILTER_STATES = {
+ ZERO_SITES: ['_'],
+ ZERO_TAGS: ['_'],
+};
+
const useClinicPatientsFilters = () => {
const selectedClinicId = useSelector((state) => state.blip.selectedClinicId);
const loggedInUserId = useSelector((state) => state.blip.loggedInUserId);
diff --git a/locales/en/translation.json b/locales/en/translation.json
index 94b36c33d6..4970e6d4bd 100644
--- a/locales/en/translation.json
+++ b/locales/en/translation.json
@@ -204,6 +204,8 @@
"BG readings": "",
"BG Target": "",
"BGM": "",
+ "BGM data within {{ count }} days_one": "BGM data within {{ count }} day",
+ "BGM data within {{ count }} days_other": "BGM data within {{ count }} days",
"Birthdate": "",
"Birthdate not known": "",
"birthday": "",
@@ -235,6 +237,8 @@
"Carbs": "",
"Carbs (g)": "",
"CGM": "",
+ "CGM data within {{ count }} days_one": "CGM data within {{ count }} day",
+ "CGM data within {{ count }} days_other": "CGM data within {{ count }} days",
"CGM data will be synced from Dexcom": "",
"CGM Use <{{minCgmPercent}}%": "",
"CGM Use <{{minCgmHours}} hours": "",
@@ -862,6 +866,10 @@
"Showing {{ count }} patient accounts with the current filter(s)_other": "Showing {{ count }} patient accounts with the current filter(s)",
"Showing {{ count }} patient accounts that match your search_one": "Showing {{ count }} patient account that matches your search",
"Showing {{ count }} patient accounts that match your search_other": "Showing {{ count }} patient accounts that match your search",
+ "Showing {{ count }} patients that match your search_one": "Showing {{ count }} patient that matches your search",
+ "Showing {{ count }} patients that match your search_other": "Showing {{ count }} patients that match your search",
+ "Showing {{ count }} patients_one": "Showing {{ count }} patient",
+ "Showing {{ count }} patients_other": "Showing {{ count }} patients",
"If you remove it, {{ count }} patient accounts will no longer be associated with this site._one": "If you remove it, {{ count }} patient account will no longer be associated with this site.",
"If you remove it, {{ count }} patient accounts will no longer be associated with this site._other": "If you remove it, {{ count }} patient accounts will no longer be associated with this site.",
"If you remove it, {{ count }} patient accounts will no longer be associated with this tag._one": "If you remove it, {{ count }} patient account will no longer be associated with this tag.",
diff --git a/test/unit/pages/ClinicPatients.test.js b/test/unit/pages/ClinicPatients.test.js
index 84899dc057..5103fa6e46 100644
--- a/test/unit/pages/ClinicPatients.test.js
+++ b/test/unit/pages/ClinicPatients.test.js
@@ -664,13 +664,6 @@ describe('ClinicPatients', () => {
expect(container.querySelector('.table-empty-text').textContent).includes('There are no results to show');
});
- describe('Filter Reset Bar', () => {
- it('should hide the Filter Reset Bar', () => {
- const filterResetBar = container.querySelector('.filter-reset-bar');
- expect(filterResetBar).to.be.null;
- });
- });
-
it('should open a modal for adding a new patient', async () => {
const addButton = container.querySelector('button#add-patient');
expect(addButton.textContent).to.equal('Add New Patient');
@@ -953,13 +946,6 @@ describe('ClinicPatients', () => {
mountWrapper(store);
});
- describe('Filter Reset Bar', () => {
- it('should hide the Filter Reset Bar', () => {
- const filterResetBar = container.querySelector('.filter-reset-bar');
- expect(filterResetBar).to.be.null;
- });
- });
-
describe('when Reset Filters button is clicked', function () {
it('should show the No Results text', () => {
expect(container.querySelectorAll('.MuiTableRow-root').length).to.equal(1); // only header
@@ -1862,11 +1848,6 @@ describe('ClinicPatients', () => {
mountWrapper(store);
});
- it('should show the Filter Reset Bar', () => {
- const filterResetBar = container.querySelector('.filter-reset-bar');
- expect(filterResetBar).to.exist;
- });
-
it('should allow filtering by summary period', () => {
const summaryPeriodFilterTrigger = container.querySelector('#summary-period-filter-trigger');
expect(summaryPeriodFilterTrigger).to.exist;
@@ -2010,11 +1991,6 @@ describe('ClinicPatients', () => {
defaultProps.trackMetric.resetHistory();
});
- it('should set the last upload filter on load based on the stored filters', () => {
- const lastDataFilterTrigger = container.querySelector('#last-data-filter-trigger');
- expect(lastDataFilterTrigger.textContent).to.equal('Data within 14 days');
- });
-
it('should set the patient tag filters on load based on the stored filters', () => {
const patientTagsFilterCount = container.querySelector('#patient-tags-filter-count');
expect(patientTagsFilterCount.textContent).to.equal('1');
@@ -2204,181 +2180,6 @@ describe('ClinicPatients', () => {
});
});
- it('should track how many filters are active', async () => {
- // Set up stateful filter mock to allow DOM verification after applying filters
- let currentFilters = { timeInRange: [], patientTags: [], meetsGlycemicTargets: false };
- const applyFiltersMock = (newFilters) => {
- currentFilters = typeof newFilters === 'function' ? newFilters(currentFilters) : newFilters;
- mockUseClinicPatientsFilters.mockImplementation(() => [currentFilters, applyFiltersMock]);
- };
- mockUseClinicPatientsFilters.mockImplementation(() => [currentFilters, applyFiltersMock]);
- mountWrapper(store);
- defaultProps.trackMetric.resetHistory();
-
- const filterCount = () => container.querySelector('#filter-count');
- expect(filterCount()).to.be.null;
-
- const timeInRangeFilterCount = () => container.querySelector('#time-in-range-filter-count');
- expect(timeInRangeFilterCount()).to.be.null;
-
- // Set lastData filter
- const lastDataFilterTrigger = container.querySelector('#last-data-filter-trigger');
- expect(lastDataFilterTrigger).to.exist;
-
- fireEvent.click(lastDataFilterTrigger);
-
- const typeFilterOptions = document.querySelectorAll('#last-upload-type label');
- expect(typeFilterOptions.length).to.equal(2);
-
- const periodFilterOptions = document.querySelectorAll('#last-upload-filters label');
- expect(periodFilterOptions.length).to.equal(4);
-
- fireEvent.click(typeFilterOptions[0].querySelector('input'));
- fireEvent.click(periodFilterOptions[3].querySelector('input'));
- fireEvent.click(document.querySelector('#apply-last-upload-filter'));
-
- // Filter count should be 1 after natural re-render from popover close
- await waitFor(() => {
- expect(filterCount()).to.exist;
- });
- expect(filterCount().textContent).to.equal('1');
-
- // Set time in range filter
- const timeInRangeFilterTrigger = container.querySelector('#time-in-range-filter-trigger');
- expect(timeInRangeFilterTrigger).to.exist;
-
- fireEvent.click(timeInRangeFilterTrigger);
-
- // Select 3 filter ranges
- const veryLowFilter = () => document.querySelector('#time-in-range-filter-veryLow');
- fireEvent.click(veryLowFilter().querySelector('input'));
- expect(veryLowFilter().querySelector('input').checked).to.be.true;
-
- const lowFilter = () => document.querySelector('#time-in-range-filter-anyLow');
- fireEvent.click(lowFilter().querySelector('input'));
- expect(lowFilter().querySelector('input').checked).to.be.true;
-
- const highFilter = () => document.querySelector('#time-in-range-filter-anyHigh');
- fireEvent.click(highFilter().querySelector('input'));
- expect(highFilter().querySelector('input').checked).to.be.true;
-
- // Submit the form
- defaultProps.api.clinics.getPatientsForClinic.resetHistory();
- fireEvent.click(document.querySelector('#timeInRangeFilterConfirm'));
-
- // Filter count should be 2 after natural re-render from popover close
- await waitFor(() => {
- expect(filterCount()?.textContent).to.equal('2');
- expect(timeInRangeFilterCount()).to.exist;
- expect(timeInRangeFilterCount().textContent).to.equal('3');
- });
-
- // Unset last upload filter
- fireEvent.click(lastDataFilterTrigger);
- fireEvent.click(document.querySelector('#clear-last-upload-filter'));
-
- // Filter count should be 1
- await waitFor(() => {
- expect(filterCount()?.textContent).to.equal('1');
- expect(timeInRangeFilterCount()).to.exist;
- expect(timeInRangeFilterCount().textContent).to.equal('3');
- });
-
- // Unset time in range filter
- fireEvent.click(timeInRangeFilterTrigger);
- fireEvent.click(document.querySelector('#timeInRangeFilterClear'));
-
- // Total filter count and time in range filter count should be unset
- await waitFor(() => {
- expect(filterCount()).to.be.null;
- });
- expect(timeInRangeFilterCount()).to.be.null;
- }, 30000);
-
- it('should reset all active filters at once', async () => {
- // Set up stateful filter mock to allow DOM verification after applying filters
- let currentFilters = { timeInRange: [], patientTags: [], meetsGlycemicTargets: false };
- const applyFiltersMock = (newFilters) => {
- currentFilters = typeof newFilters === 'function' ? newFilters(currentFilters) : newFilters;
- mockUseClinicPatientsFilters.mockImplementation(() => [currentFilters, applyFiltersMock]);
- };
- mockUseClinicPatientsFilters.mockImplementation(() => [currentFilters, applyFiltersMock]);
- mountWrapper(store);
- defaultProps.trackMetric.resetHistory();
-
- const filterCount = () => container.querySelector('#filter-count');
- expect(filterCount()).to.be.null;
-
- const timeInRangeFilterCount = () => container.querySelector('#time-in-range-filter-count');
- expect(timeInRangeFilterCount()).to.be.null;
-
- const resetAllFiltersButton = () => container.querySelector('#reset-all-active-filters');
- expect(resetAllFiltersButton()).to.be.null;
-
- // Set lastData filter
- const lastDataFilterTrigger = container.querySelector('#last-data-filter-trigger');
- expect(lastDataFilterTrigger).to.exist;
-
- fireEvent.click(lastDataFilterTrigger);
-
- const typeFilterOptions = document.querySelectorAll('#last-upload-type label');
- expect(typeFilterOptions.length).to.equal(2);
-
- const periodFilterOptions = document.querySelectorAll('#last-upload-filters label');
- expect(periodFilterOptions.length).to.equal(4);
-
- fireEvent.click(typeFilterOptions[0].querySelector('input'));
- fireEvent.click(periodFilterOptions[3].querySelector('input'));
- fireEvent.click(document.querySelector('#apply-last-upload-filter'));
-
- // Filter count should be 1 after natural re-render from popover close
- await waitFor(() => {
- expect(filterCount()).to.exist;
- });
- expect(filterCount().textContent).to.equal('1');
- expect(resetAllFiltersButton()).to.exist;
-
- // Set time in range filter
- const timeInRangeFilterTrigger = container.querySelector('#time-in-range-filter-trigger');
- expect(timeInRangeFilterTrigger).to.exist;
-
- fireEvent.click(timeInRangeFilterTrigger);
-
- // Select 3 filter ranges
- const veryLowFilter = () => document.querySelector('#time-in-range-filter-veryLow');
- fireEvent.click(veryLowFilter().querySelector('input'));
- expect(veryLowFilter().querySelector('input').checked).to.be.true;
-
- const lowFilter = () => document.querySelector('#time-in-range-filter-anyLow');
- fireEvent.click(lowFilter().querySelector('input'));
- expect(lowFilter().querySelector('input').checked).to.be.true;
-
- const highFilter = () => document.querySelector('#time-in-range-filter-anyHigh');
- fireEvent.click(highFilter().querySelector('input'));
- expect(highFilter().querySelector('input').checked).to.be.true;
-
- // Submit the form
- defaultProps.api.clinics.getPatientsForClinic.resetHistory();
- fireEvent.click(document.querySelector('#timeInRangeFilterConfirm'));
-
- // Filter count should be 2 after natural re-render from popover close
- await waitFor(() => {
- expect(filterCount()?.textContent).to.equal('2');
- expect(timeInRangeFilterCount()).to.exist;
- expect(timeInRangeFilterCount().textContent).to.equal('3');
- });
- expect(resetAllFiltersButton()).to.exist;
-
- fireEvent.click(resetAllFiltersButton());
-
- // Total filter count and time in range filter count should be unset
- await waitFor(() => {
- expect(filterCount()).to.be.null;
- });
- expect(timeInRangeFilterCount()).to.be.null;
- expect(resetAllFiltersButton()).to.be.null;
- }, 25000);
-
it('should clear pending filter edits when time in range filter dialog closed', () => {
const filterCount = () => container.querySelector('#filter-count');
expect(filterCount()).to.be.null;