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;