diff --git a/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js b/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js index 4b01fab9f4..6387975d8f 100644 --- a/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js @@ -382,146 +382,139 @@ describe('ClinicPatients', () => { describe('filtering for patients', () => { afterEach(() => { - // Clear any persisted filter state between tests localStorage.clear(); }); - it('should allow filtering by sites', async () => { + it('maps an applied tag filter into the getPatientsForClinic query', async () => { render( ); - // Open the Sites filter dropdown and filter for 2 sites - await userEvent.click(screen.getByRole('button', { name: /Sites/ })); - - const site1checkbox = screen.getByTestId('clinic-site-filter-option-checkbox-site-1-id'); - const site2checkbox = screen.getByTestId('clinic-site-filter-option-checkbox-site-2-id'); - - expect(site1checkbox).not.toBeChecked(); - expect(site2checkbox).not.toBeChecked(); + // Open the Tags filter dropdown, select 2 tags, and apply + await userEvent.click(screen.getByRole('button', { name: /Tags/ })); + await userEvent.click(screen.getByTestId('tag-filter-option-checkbox-tag1')); + await userEvent.click(screen.getByTestId('tag-filter-option-checkbox-tag3')); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); - await userEvent.click(site1checkbox); - await userEvent.click(site2checkbox); + expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith( + 'clinicID123', + { tags: ['tag1', 'tag3'], limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData' }, + expect.any(Function), + ); + }, TEST_TIMEOUT_MS); - expect(site1checkbox).toBeChecked(); - expect(site2checkbox).toBeChecked(); + it('maps an applied summary period filter into the getPatientsForClinic query', async () => { + render( + + + + ); - // Click Apply + // Open the Summary Period filter dropdown, select 30 days, and apply + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); await userEvent.click(screen.getByRole('button', { name: /Apply/ })); - expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenCalledWith( + expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith( 'clinicID123', - { sites: ['site-1-id', 'site-2-id'], limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData' }, + { limit: 50, offset: 0, period: '30d', sortType: 'cgm', sort: '-lastData' }, expect.any(Function), ); - - expect(defaultProps.trackMetric).toHaveBeenCalledWith( - 'Clinic - Population Health - Clinic sites filter apply', - { clinicId: 'clinicID123' }, - ); }, TEST_TIMEOUT_MS); - it('should allow filtering by for patients with zero sites', async () => { + it('maps an applied site filter into the getPatientsForClinic query', async () => { render( ); - // Open the Sites filter dropdown and filter for 2 sites + // Open the Sites filter dropdown, select 2 sites, and apply await userEvent.click(screen.getByRole('button', { name: /Sites/ })); - - const site1checkbox = screen.getByTestId('clinic-site-filter-option-checkbox-site-1-id'); - const site2checkbox = screen.getByTestId('clinic-site-filter-option-checkbox-site-2-id'); - await userEvent.click(site1checkbox); - await userEvent.click(site2checkbox); - expect(site1checkbox).toBeChecked(); - expect(site2checkbox).toBeChecked(); - - // Click the checkbox to filter for pwds with zero sites. Others should uncheck. - const zeroSiteCheckbox = screen.getByTestId('clinic-site-filter-option-checkbox-PWDS_WITH_ZERO_SITES'); - await userEvent.click(zeroSiteCheckbox); - expect(site1checkbox).not.toBeChecked(); - expect(site2checkbox).not.toBeChecked(); - - // Click Apply. A query of `['_']` should be made for sites. + await userEvent.click(screen.getByTestId('clinic-site-filter-option-checkbox-site-1-id')); + await userEvent.click(screen.getByTestId('clinic-site-filter-option-checkbox-site-2-id')); await userEvent.click(screen.getByRole('button', { name: /Apply/ })); - expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenCalledWith( + expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith( 'clinicID123', - { sites: ['_'], limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData' }, + { sites: ['site-1-id', 'site-2-id'], limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData' }, expect.any(Function), ); }, TEST_TIMEOUT_MS); - it('should allow filtering by tags', async () => { + it('maps an applied data recency filter into the getPatientsForClinic query', async () => { render( ); - // Open the Tags filter dropdown and filter for 2 sites - await userEvent.click(screen.getByRole('button', { name: /Tags/ })); - - const tag1checkbox = screen.getByTestId('tag-filter-option-checkbox-tag1'); - const tag3checkbox = screen.getByTestId('tag-filter-option-checkbox-tag3'); - - expect(tag1checkbox).not.toBeChecked(); - expect(tag3checkbox).not.toBeChecked(); - - await userEvent.click(tag1checkbox); - await userEvent.click(tag3checkbox); - - expect(tag1checkbox).toBeChecked(); - expect(tag3checkbox).toBeChecked(); - - // Click Apply + // 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', - { tags: ['tag1', 'tag3'], limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData' }, + 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), ); - - expect(defaultProps.trackMetric).toHaveBeenCalledWith( - 'Clinic - Population Health - Patient tag filter apply', - { clinicId: 'clinicID123' }, - ); }, TEST_TIMEOUT_MS); - it('should allow filtering by for patients with zero tags', async () => { + it('maps an applied time in range filter into the getPatientsForClinic query', async () => { render( ); - // Open the Tags filter dropdown and filter for 2 tags - await userEvent.click(screen.getByRole('button', { name: /Tags/ })); + // Open the % Time in Range filter dropdown, select a range, and apply. + await userEvent.click(screen.getByRole('button', { name: /Filter by Time in Range/ })); + await userEvent.click(screen.getByRole('checkbox', { name: /Not meeting TIR/ })); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); - const tag1checkbox = screen.getByTestId('tag-filter-option-checkbox-tag1'); - const tag2checkbox = screen.getByTestId('tag-filter-option-checkbox-tag2'); - await userEvent.click(tag1checkbox); - await userEvent.click(tag2checkbox); - expect(tag1checkbox).toBeChecked(); - expect(tag2checkbox).toBeChecked(); + // Selecting ranges scopes the query to standard target ranges and maps each + // selected range into a `cgm.` comparator threshold (fraction of time). + expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith( + 'clinicID123', + expect.objectContaining({ + omitNonStandardRanges: true, + 'cgm.timeInTargetPercent': '<=0.7', + limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData', + }), + expect.any(Function), + ); + }, TEST_TIMEOUT_MS); - // Click the checkbox to filter for pwds with zero tags. Others should uncheck. - const zeroTagCheckbox = screen.getByTestId('tag-filter-option-checkbox-PWDS_WITH_ZERO_TAGS'); - await userEvent.click(zeroTagCheckbox); - expect(tag1checkbox).not.toBeChecked(); - expect(tag2checkbox).not.toBeChecked(); + it('maps an applied cgm use filter into the getPatientsForClinic query', async () => { + render( + + + + ); - // Click Apply. A query of `['_']` should be made for sites. + // Open the % CGM Use filter dropdown, select a range, and apply. + await userEvent.click(screen.getByRole('button', { name: /CGM Use/ })); + await userEvent.click(screen.getByRole('radio', { name: /Less than 70%/ })); await userEvent.click(screen.getByRole('button', { name: /Apply/ })); - expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenCalledWith( + expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith( 'clinicID123', - { tags: ['_'], limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData' }, + expect.objectContaining({ + 'cgm.timeCGMUsePercent': '<0.7', + limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData', + }), expect.any(Function), ); }, TEST_TIMEOUT_MS); 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..99562ba4e1 --- /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 = Not in Range 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/clinicPatientsFilters/FilterByCGMUse.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.test.js new file mode 100644 index 0000000000..f0a5326f9d --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.test.js @@ -0,0 +1,73 @@ +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 FilterByCGMUse from '@app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse'; + +const mockStore = configureStore([thunk]); + +describe('FilterByCGMUse', () => { + let store; + + 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 cgm use merged into the existing activeFilters', async () => { + renderComponent({ activeFilters: { timeCGMUsePercent: null, patientTags: ['tag1'] } }); + + await userEvent.click(screen.getByRole('button', { name: /CGM Use/ })); + await screen.findByTestId('cgm-use-filter-dropdown'); + + await userEvent.click(screen.getByRole('radio', { name: /Less than 70%/ })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(setActiveFilters).toHaveBeenCalledTimes(1); + expect(setActiveFilters).toHaveBeenCalledWith({ + timeCGMUsePercent: '<0.7', + patientTags: ['tag1'], + }); + }); + }); + + describe('activeFilters passthrough', () => { + it('reflects the active cgm use in the pre-selected radio', async () => { + renderComponent({ activeFilters: { timeCGMUsePercent: '>=0.7' } }); + + await userEvent.click(screen.getByRole('button', { name: /CGM Use/ })); + await screen.findByTestId('cgm-use-filter-dropdown'); + + expect(screen.getByRole('radio', { name: /70% or more/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /Less than 70%/ })).not.toBeChecked(); + }); + }); +}); 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/clinicPatientsFilters/FilterBySites.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.test.js new file mode 100644 index 0000000000..f7fdd3575c --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.test.js @@ -0,0 +1,120 @@ +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 FilterBySites from '@app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; +import useIsClinicAdmin from '@app/pages/clinicworkspace/useIsClinicAdmin'; + +jest.mock('@app/pages/clinicworkspace/useIsClinicAdmin'); + +jest.mock('@app/redux/actions', () => ({ + async: { fetchClinicSites: jest.fn().mockReturnValue({ type: 'FETCH_CLINIC_SITES' }) }, +})); + +const mockStore = configureStore([thunk]); + +describe('FilterBySites', () => { + let store; + let wrapper; + + const api = { some: 'api' }; + const selectedClinicId = 'clinic123'; + + const clinicSiteDefs = [ + { id: 'site1', name: 'Site One' }, + { id: 'site2', name: 'Site Two' }, + ]; + + const setActiveFilters = jest.fn(); + const setShowClinicSitesDialog = jest.fn(); + + useIsClinicAdmin.mockReturnValue(true); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId, sites: clinicSiteDefs } }, + }, + }); + + setActiveFilters.mockClear(); + setShowClinicSitesDialog.mockClear(); + }); + + describe('handleChange', () => { + it('calls setActiveFilters with the applied sites merged into the existing activeFilters', async () => { + wrapper = renderComponent({ activeFilters: { clinicSites: [], patientTags: ['tag1'] } }); + + await userEvent.click(screen.getByRole('button', { name: /^Clinic Sites/ })); + await screen.findByTestId('clinic-site-filter-option-checkbox-site1'); + + await userEvent.click(screen.getByTestId('clinic-site-filter-option-checkbox-site1')); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(setActiveFilters).toHaveBeenCalledTimes(1); + expect(setActiveFilters).toHaveBeenCalledWith({ + clinicSites: ['site1'], + patientTags: ['tag1'], + }); + }); + }); + + describe('onClickEditSites visibility', () => { + it('offers the edit-sites control to clinic admins only', async () => { + // Hidden if not admin + useIsClinicAdmin.mockReturnValue(false); + const { rerender } = renderComponent(); + + expect(screen.queryByRole('button', { name: 'Edit Sites' })).not.toBeInTheDocument(); + + // Visible if not admin + useIsClinicAdmin.mockReturnValue(true); + rerender(ui()); + + await userEvent.click(screen.getByRole('button', { name: /^Clinic Sites/ })); + await screen.findByTestId('clinic-site-filter-option-checkbox-site1'); + + const editButton = screen.getByRole('button', { name: 'Edit Sites' }); + expect(editButton).toBeInTheDocument(); + + await userEvent.click(editButton); + + expect(actions.async.fetchClinicSites).toHaveBeenCalledWith(api, selectedClinicId); + expect(setShowClinicSitesDialog).toHaveBeenCalledWith(true); + expect(mockTrackMetric).toHaveBeenCalledWith( + 'Clinic - Edit clinic sites open', + expect.objectContaining({ clinicId: selectedClinicId, source: 'Filter menu', pageName: 'Population Health' }) + ); + }); + }); + + describe('clinicSites passthrough', () => { + it('reflects the active clinic sites in the filter count', () => { + wrapper = renderComponent({ activeFilters: { clinicSites: ['site1', 'site2'] } }); + + expect(screen.getByLabelText('clinic site count')).toHaveTextContent('2'); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.test.js new file mode 100644 index 0000000000..d5955de405 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.test.js @@ -0,0 +1,78 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; + +import FilterBySummaryPeriod from '@app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod'; + +const mockStore = configureStore([thunk]); + +describe('FilterBySummaryPeriod', () => { + let store; + let wrapper; + + const selectedClinicId = 'clinic123'; + + const setActiveSummaryPeriod = jest.fn(); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId } }, + }, + }); + + setActiveSummaryPeriod.mockClear(); + }); + + describe('handleChange', () => { + it('calls setActiveSummaryPeriod with the newly selected period', async () => { + wrapper = renderComponent({ activeSummaryPeriod: '14d' }); + + // Open the dropdown via the trigger's icon label (its text label is the dynamic + // "Summarizing ..." string that changes with the active period). + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + await screen.findByRole('radio', { name: /30 days/ }); + + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + // The period value is passed straight through, not merged into an activeFilters object + expect(setActiveSummaryPeriod).toHaveBeenCalledTimes(1); + expect(setActiveSummaryPeriod).toHaveBeenCalledWith('30d'); + }); + }); + + describe('activeSummaryPeriod passthrough', () => { + it('reflects the active summary period in the trigger label and the pre-selected radio', async () => { + wrapper = renderComponent({ activeSummaryPeriod: '7d' }); + + // Trigger label reflects the active period + expect(screen.getByRole('button', { name: /Summarizing 7 days of data/ })).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + + expect(screen.getByRole('radio', { name: /24 hours/ })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: /7 days/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /14 days/ })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: /30 days/ })).not.toBeChecked(); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.test.js new file mode 100644 index 0000000000..3a8d5bb5f9 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.test.js @@ -0,0 +1,122 @@ +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 FilterByTags from '@app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; +import useIsClinicAdmin from '@app/pages/clinicworkspace/useIsClinicAdmin'; + +jest.mock('@app/pages/clinicworkspace/useIsClinicAdmin'); + +jest.mock('@app/redux/actions', () => ({ + async: { + fetchClinicPatientTags: jest.fn().mockReturnValue({ type: 'FETCH_CLINIC_PATIENT_TAGS' }), + }, +})); + +const mockStore = configureStore([thunk]); + +describe('FilterByTags', () => { + let store; + let wrapper; + + const api = { some: 'api' }; + const selectedClinicId = 'clinic123'; + + const patientTagDefs = [ + { id: 'tag1', name: 'Tag One' }, + { id: 'tag2', name: 'Tag Two' }, + ]; + + const setActiveFilters = jest.fn(); + const setShowClinicPatientTagsDialog = jest.fn(); + + useIsClinicAdmin.mockReturnValue(true); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId, patientTags: patientTagDefs } }, + }, + }); + + setActiveFilters.mockClear(); + setShowClinicPatientTagsDialog.mockClear(); + }); + + describe('handleChange', () => { + it('calls setActiveFilters with the applied tags merged into the existing activeFilters', async () => { + wrapper = renderComponent({ activeFilters: { patientTags: [], clinicSites: ['siteX'] } }); + + await userEvent.click(screen.getByRole('button', { name: /^Tags/ })); + await screen.findByTestId('tag-filter-option-checkbox-tag1'); + + await userEvent.click(screen.getByTestId('tag-filter-option-checkbox-tag1')); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(setActiveFilters).toHaveBeenCalledTimes(1); + expect(setActiveFilters).toHaveBeenCalledWith({ + patientTags: ['tag1'], + clinicSites: ['siteX'], + }); + }); + }); + + describe('onClickEditTags visibility', () => { + it('offers the edit-tags control to clinic admins only', async () => { + // Hidden if not admin + useIsClinicAdmin.mockReturnValue(false); + const { rerender } = renderComponent(); + + expect(screen.queryByRole('button', { name: 'Edit Tags' })).not.toBeInTheDocument(); + + // Visible if not admin + useIsClinicAdmin.mockReturnValue(true); + rerender(ui()); + + await userEvent.click(screen.getByRole('button', { name: /^Tags/ })); + await screen.findByTestId('tag-filter-option-checkbox-tag1'); + + const editButton = screen.getByRole('button', { name: 'Edit Tags' }); + expect(editButton).toBeInTheDocument(); + + await userEvent.click(editButton); + + expect(actions.async.fetchClinicPatientTags).toHaveBeenCalledWith(api, selectedClinicId); + expect(setShowClinicPatientTagsDialog).toHaveBeenCalledWith(true); + expect(mockTrackMetric).toHaveBeenCalledWith( + 'Clinic - Edit clinic tags open', + expect.objectContaining({ clinicId: selectedClinicId, source: 'Filter menu', pageName: 'Population Health' }) + ); + }); + }); + + describe('patientTags passthrough', () => { + it('reflects the active patient tags in the filter count', () => { + wrapper = renderComponent({ activeFilters: { patientTags: ['tag1', 'tag2'] } }); + + expect(screen.getByLabelText('filter count')).toHaveTextContent('2'); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.test.js new file mode 100644 index 0000000000..687c08e28e --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.test.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; + +import { useFlags } from 'launchdarkly-react-client-sdk'; +import FilterByTimeInRange from '@app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange'; + +jest.mock('launchdarkly-react-client-sdk'); + +const mockStore = configureStore([thunk]); + +describe('FilterByTimeInRange', () => { + let store; + + const selectedClinicId = 'clinic123'; + + const setActiveFilters = jest.fn(); + + useFlags.mockReturnValue({ showExtremeHigh: false }); + + 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 time in range filters merged into the existing activeFilters', async () => { + renderComponent({ activeFilters: { timeInRange: [], patientTags: ['tag1'] } }); + + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + await screen.findByRole('checkbox', { name: /Very High/ }); + + await userEvent.click(screen.getByRole('checkbox', { name: /Very High/ })); + await userEvent.click(screen.getByRole('checkbox', { name: /Very Low/ })); + await userEvent.click(screen.getByRole('button', { name: 'Apply' })); + + expect(setActiveFilters).toHaveBeenCalledTimes(1); + expect(setActiveFilters).toHaveBeenCalledWith({ + timeInRange: ['timeInVeryHighPercent', 'timeInVeryLowPercent'], + patientTags: ['tag1'], + }); + }); + }); + + describe('timeInRange passthrough', () => { + it('pre-selects the checkboxes matching the active filters', async () => { + renderComponent({ activeFilters: { timeInRange: ['timeInVeryLowPercent'] } }); + + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + await screen.findByRole('checkbox', { name: /Very Low/ }); + + expect(screen.getByRole('checkbox', { name: /Very Low/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Very High/ })).not.toBeChecked(); + }); + }); +}); 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..f0e378517f --- /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 = Not in Range')).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/__tests__/unit/app/pages/clinicworkspace/components/CGMUseFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/CGMUseFilterDropdown.test.js new file mode 100644 index 0000000000..dd66c5a5ed --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/CGMUseFilterDropdown.test.js @@ -0,0 +1,109 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; + +import CGMUseFilterDropdown from '@app/pages/clinicworkspace/components/CGMUseFilterDropdown'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; + +const mockStore = configureStore([thunk]); + +describe('CGMUseFilterDropdown', () => { + let store; + + const selectedClinicId = 'clinic123'; + + let onChange = jest.fn(); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId } }, + }, + }); + + onChange.mockClear(); + mockTrackMetric.mockClear(); + }); + + describe('filtering for cgm use', () => { + it('applies the cgm use filter based on the radio selected', async () => { + renderComponent({ timeCGMUsePercent: null }); + + // Dropdown closed initially + expect(screen.queryByTestId('cgm-use-filter-dropdown')).not.toBeInTheDocument(); + + // Open the dropdown + await userEvent.click(screen.getByRole('button', { name: /CGM Use/ })); + expect(screen.getByTestId('cgm-use-filter-dropdown')).toBeInTheDocument(); + + // Nothing selected initially + expect(screen.getByRole('radio', { name: /Less than 70%/ })).not.toBeChecked(); + expect(screen.getByRole('radio', { name: /70% or more/ })).not.toBeChecked(); + + // Selecting an option and applying sets the filter + await userEvent.click(screen.getByRole('radio', { name: /Less than 70%/ })); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith('<0.7'); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - CGM use apply filter', { + clinicId: 'clinic123', + filter: '<0.7', + pageName: 'Population Health', + }); + + // Dropdown should automatically close + expect(screen.queryByTestId('cgm-use-filter-dropdown')).not.toBeInTheDocument(); + }); + + it('disables the Apply button until an option is selected', async () => { + renderComponent({ timeCGMUsePercent: null }); + + await userEvent.click(screen.getByRole('button', { name: /CGM Use/ })); + + // Disabled with nothing selected + expect(screen.getByRole('button', { name: /Apply/ })).toBeDisabled(); + + // Enabled once an option is selected + await userEvent.click(screen.getByRole('radio', { name: /70% or more/ })); + expect(screen.getByRole('button', { name: /Apply/ })).toBeEnabled(); + }); + + it('pre-selects the radio matching the active filter', async () => { + renderComponent({ timeCGMUsePercent: '>=0.7' }); + + await userEvent.click(screen.getByRole('button', { name: /CGM Use/ })); + + expect(screen.getByRole('radio', { name: /70% or more/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /Less than 70%/ })).not.toBeChecked(); + }); + + it('clears the filter', async () => { + renderComponent({ timeCGMUsePercent: '<0.7' }); + + await userEvent.click(screen.getByRole('button', { name: /CGM Use/ })); + expect(screen.getByRole('radio', { name: /Less than 70%/ })).toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: /Clear/ })); + expect(onChange).toHaveBeenCalledWith(null); + expect(screen.queryByTestId('cgm-use-filter-dropdown')).not.toBeInTheDocument(); + }); + }); +}); 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..fa1aae7869 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.test.js @@ -0,0 +1,123 @@ +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 DataRecencyFilterDropdown from '@app/pages/clinicworkspace/components/DataRecencyFilterDropdown'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; + +const mockStore = configureStore([thunk]); + +describe('DataRecencyFilterDropdown', () => { + let store; + + 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/__tests__/unit/app/pages/clinicworkspace/components/SiteFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/SiteFilterDropdown.test.js new file mode 100644 index 0000000000..a43191333b --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/SiteFilterDropdown.test.js @@ -0,0 +1,157 @@ +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 SiteFilterDropdown from '@app/pages/clinicworkspace/components/SiteFilterDropdown'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; +import useIsClinicAdmin from '@app/pages/clinicworkspace/useIsClinicAdmin'; +import { SPECIAL_FILTER_STATES } from '@app/pages/clinicworkspace/useClinicPatientsFilters'; + +jest.mock('@app/pages/clinicworkspace/useIsClinicAdmin'); + +const mockStore = configureStore([thunk]); + +describe('SiteFilterDropdown', () => { + let store; + + const selectedClinicId = 'clinic123'; + + const clinicSiteDefs = [ + { id: 'site1', name: 'North Site' }, + { id: 'site2', name: 'South Site' }, + { id: 'site3', name: 'East Site' }, + { id: 'site4', name: 'Downtown Clinic' }, + ]; + + let onChange = jest.fn(); + let onClickEditSites = jest.fn(); + let clinicSites = []; + + useIsClinicAdmin.mockReturnValue(true); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId, sites: clinicSiteDefs } }, + }, + }); + + onChange.mockClear(); + onClickEditSites.mockClear(); + mockTrackMetric.mockClear(); + }); + + describe('filtering for sites', () => { + it('applies sites based on checkboxes selected', async () => { + renderComponent({ clinicSites: ['site1', 'site3'] }); + + // Dropdown closed initially + expect(screen.queryByTestId('site-filter-dropdown')).not.toBeInTheDocument(); + + // Open the dropdown + await userEvent.click(screen.getByRole('button', { name: /Clinic Sites/ })); + expect(screen.getByTestId('site-filter-dropdown')).toBeInTheDocument(); + + expect(screen.getByRole('checkbox', { name: /North Site/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /South Site/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /East Site/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Downtown Clinic/ })).not.toBeChecked(); + + // Typing into the box should search down the options + await userEvent.click(screen.getByRole('textbox')); + await userEvent.paste(' site'); + expect(screen.getByRole('checkbox', { name: /North Site/ })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /South Site/ })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /East Site/ })).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: /Downtown Clinic/ })).not.toBeInTheDocument(); + + // Applying a checkbox filter sets the filter + await userEvent.click(screen.getByRole('checkbox', { name: /North Site/ })); // unselect + await userEvent.click(screen.getByRole('checkbox', { name: /South Site/ })); // select + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith(['site3', 'site2']); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Clinic sites filter apply', { clinicId: 'clinic123', pageName: 'Population Health' }) + + // Dropdown should automatically close + expect(screen.queryByTestId('site-filter-dropdown')).not.toBeInTheDocument(); + }); + + it('applies a special state for patients without sites', async () => { + renderComponent({ clinicSites: ['site1', 'site3'] }); + + await userEvent.click(screen.getByRole('button', { name: /Clinic Sites/ })); + expect(screen.getByRole('checkbox', { name: /North Site/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /South Site/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /East Site/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Downtown Clinic/ })).not.toBeChecked(); + + // Clicking the Patients without any sites checkbox should uncheck all + await userEvent.click(screen.getByRole('checkbox', { name: /Patients without any sites/ })); + expect(screen.getByRole('checkbox', { name: /North Site/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /South Site/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /East Site/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Downtown Clinic/ })).not.toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith(SPECIAL_FILTER_STATES.ZERO_SITES); + }); + + it('clears the filter', async () => { + renderComponent({ clinicSites: ['site1', 'site3'] }); + + await userEvent.click(screen.getByRole('button', { name: /Clinic Sites/ })); + expect(screen.getByRole('checkbox', { name: /North Site/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /South Site/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /East Site/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Downtown Clinic/ })).not.toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: /Clear/ })); + expect(onChange).toHaveBeenCalledWith([]); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Clinic sites filter clear', { clinicId: 'clinic123', pageName: 'Population Health' }) + expect(screen.queryByTestId('site-filter-dropdown')).not.toBeInTheDocument(); + }); + }); + + describe('edit sites', () => { + it('conditionally renders a button to edit sites', async () => { + // Should be hidden if no passed callback fn + useIsClinicAdmin.mockReturnValue(true); + const { rerender } = renderComponent({ onClickEditSites: null }); + await userEvent.click(screen.getByRole('button', { name: /Clinic Sites/ })); + + expect(screen.queryByLabelText(/Edit Sites/)).not.toBeInTheDocument(); + + // Should be hidden if not Clinic Admin + useIsClinicAdmin.mockReturnValue(false); + rerender(ui()); + expect(screen.queryByLabelText(/Edit Sites/)).not.toBeInTheDocument(); + + // Visible if Clinic Admin and passed callback fn + useIsClinicAdmin.mockReturnValue(true); + rerender(ui()); + + await userEvent.click(screen.getByLabelText(/Edit Sites/)); + expect(onClickEditSites).toHaveBeenCalled(); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js new file mode 100644 index 0000000000..9c6946fa7b --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js @@ -0,0 +1,113 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; + +import SummaryPeriodFilterDropdown from '@app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; + +const mockStore = configureStore([thunk]); + +describe('SummaryPeriodFilterDropdown', () => { + let store; + + const selectedClinicId = 'clinic123'; + + let onChange = jest.fn(); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId } }, + }, + }); + + onChange.mockClear(); + mockTrackMetric.mockClear(); + }); + + describe('filtering for summary period', () => { + it('applies the summary period based on the radio selected', async () => { + renderComponent({ activeSummaryPeriod: '14d' }); + + // Should have correct label + expect(screen.getByRole('button', { name: /Summarizing 14 days of data/ })).toBeInTheDocument(); + + // Dropdown closed initially + expect(screen.queryByTestId('summary-period-filter-dropdown')).not.toBeInTheDocument(); + + // Open the dropdown + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + expect(screen.getByRole('radio', { name: /24 hours/ })).toBeInTheDocument(); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Summary period filter open', { clinicId: 'clinic123', pageName: 'Population Health' }); + + // The active period is pre-selected + expect(screen.getByRole('radio', { name: /14 days/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /30 days/ })).not.toBeChecked(); + + // Selecting a different period and applying sets the filter + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith('30d'); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Summary period apply filter', { clinicId: 'clinic123', summaryPeriod: '30d', pageName: 'Population Health' }); + + // Dropdown should automatically close + expect(screen.queryByTestId('summary-period-filter-dropdown')).not.toBeInTheDocument(); + }); + + it('disables the Apply button until a different period is selected', async () => { + renderComponent({ activeSummaryPeriod: '14d' }); + + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + + // Disabled while the pending selection matches the active period + expect(screen.getByRole('button', { name: /Apply/ })).toBeDisabled(); + + // Enabled once a different period is selected + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + expect(screen.getByRole('button', { name: /Apply/ })).toBeEnabled(); + + // Disabled again when re-selecting the active period + await userEvent.click(screen.getByRole('radio', { name: /14 days/ })); + expect(screen.getByRole('button', { name: /Apply/ })).toBeDisabled(); + }); + + it('cancels without applying and resets the pending selection', async () => { + renderComponent({ activeSummaryPeriod: '14d' }); + + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + + // Change the selection, then cancel + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + await userEvent.click(screen.getByRole('button', { name: /Cancel/ })); + + // No change is applied and the dropdown closes + expect(onChange).not.toHaveBeenCalled(); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Summary period filter cancel', { clinicId: 'clinic123', pageName: 'Population Health' }); + expect(screen.queryByTestId('summary-period-filter-dropdown')).not.toBeInTheDocument(); + + // Re-opening shows the original active period still selected (pending was reset) + await userEvent.click(screen.getByRole('button', { name: /Filter by summary period duration/ })); + expect(screen.getByRole('radio', { name: /14 days/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /30 days/ })).not.toBeChecked(); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/components/TagFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/TagFilterDropdown.test.js new file mode 100644 index 0000000000..604e4c6224 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/TagFilterDropdown.test.js @@ -0,0 +1,157 @@ +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 TagFilterDropdown from '@app/pages/clinicworkspace/components/TagFilterDropdown'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; +import useIsClinicAdmin from '@app/pages/clinicworkspace/useIsClinicAdmin'; +import { SPECIAL_FILTER_STATES } from '@app/pages/clinicworkspace/useClinicPatientsFilters'; + +jest.mock('@app/pages/clinicworkspace/useIsClinicAdmin'); + +const mockStore = configureStore([thunk]); + +describe('TagFilterDropdown', () => { + let store; + + const selectedClinicId = 'clinic123'; + + const patientTagDefs = [ + { id: 'tag1', name: 'Week 1' }, + { id: 'tag2', name: 'Week 2' }, + { id: 'tag3', name: 'Week 3' }, + { id: 'tag4', name: 'Pregnancy' }, + ]; + + let onChange = jest.fn(); + let onClickEditTags = jest.fn(); + let patientTags = []; + + useIsClinicAdmin.mockReturnValue(true); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId, patientTags: patientTagDefs } }, + }, + }); + + onChange.mockClear(); + onClickEditTags.mockClear(); + mockTrackMetric.mockClear(); + }); + + describe('filtering for tags', () => { + it('applies tags based on checkboxes selected', async () => { + renderComponent({ patientTags: ['tag1', 'tag3'] }); + + // Dropdown closed initially + expect(screen.queryByTestId('tag-filter-dropdown')).not.toBeInTheDocument(); + + // Open the dropdown + await userEvent.click(screen.getByRole('button', { name: /Tags/ })); + expect(screen.getByTestId('tag-filter-dropdown')).toBeInTheDocument(); + + expect(screen.getByRole('checkbox', { name: /Week 1/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Week 2/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Week 3/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Pregnancy/ })).not.toBeChecked(); + + // Typing into the box should search down the options + await userEvent.click(screen.getByRole('textbox')); + await userEvent.paste(' wee'); + expect(screen.getByRole('checkbox', { name: /Week 1/ })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /Week 2/ })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /Week 3/ })).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', { name: /Pregnancy/ })).not.toBeInTheDocument(); + + // Applying a checkbox filter sets the filter + await userEvent.click(screen.getByRole('checkbox', { name: /Week 1/ })); // unselect + await userEvent.click(screen.getByRole('checkbox', { name: /Week 2/ })); // select + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith(['tag3', 'tag2']); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Patient tag filter apply', { clinicId: 'clinic123', pageName: 'Population Health' }) + + // Dropdown should automatically close + expect(screen.queryByTestId('tag-filter-dropdown')).not.toBeInTheDocument(); + }); + + it('applies a special state for patients without tags', async () => { + renderComponent({ patientTags: ['tag1', 'tag3'] }); + + await userEvent.click(screen.getByRole('button', { name: /Tags/ })); + expect(screen.getByRole('checkbox', { name: /Week 1/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Week 2/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Week 3/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Pregnancy/ })).not.toBeChecked(); + + // Clicking the Patients without any tags checkbox should uncheck all + await userEvent.click(screen.getByRole('checkbox', { name: /Patients without any tags/ })); + expect(screen.getByRole('checkbox', { name: /Week 1/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Week 2/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Week 3/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Pregnancy/ })).not.toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith(SPECIAL_FILTER_STATES.ZERO_TAGS); + }); + + it('clears the filter', async () => { + renderComponent({ patientTags: ['tag1', 'tag3'] }); + + await userEvent.click(screen.getByRole('button', { name: /Tags/ })); + expect(screen.getByRole('checkbox', { name: /Week 1/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Week 2/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Week 3/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Pregnancy/ })).not.toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: /Clear/ })); + expect(onChange).toHaveBeenCalledWith([]); + expect(mockTrackMetric).toHaveBeenCalledWith('Clinic - Patient tag filter clear', { clinicId: 'clinic123', pageName: 'Population Health' }); + expect(screen.queryByTestId('tag-filter-dropdown')).not.toBeInTheDocument(); + }); + }); + + describe('edit tags', () => { + it('conditionally renders a button to edit tags', async () => { + // Should be hidden if no passed callback fn + useIsClinicAdmin.mockReturnValue(true); + const { rerender } = renderComponent({ onClickEditTags: null }); + await userEvent.click(screen.getByRole('button', { name: /Tags/ })); + + expect(screen.queryByLabelText(/Edit Tags/)).not.toBeInTheDocument(); + + // Should be hidden if not Clinic Admin + useIsClinicAdmin.mockReturnValue(false); + rerender(ui()); + expect(screen.queryByLabelText(/Edit Tags/)).not.toBeInTheDocument(); + + // Visible if Clinic Admin and passed callback fn + useIsClinicAdmin.mockReturnValue(true); + rerender(ui()); + + await userEvent.click(screen.getByLabelText(/Edit Tags/)); + expect(onClickEditTags).toHaveBeenCalled(); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.test.js new file mode 100644 index 0000000000..1c673bfa43 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.test.js @@ -0,0 +1,170 @@ +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 { useFlags } from 'launchdarkly-react-client-sdk'; +import TimeInRangeFilterDropdown from '@app/pages/clinicworkspace/components/TimeInRangeFilterDropdown'; +import { MMOLL_UNITS } from '@app/core/constants'; +import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; + +jest.mock('launchdarkly-react-client-sdk'); + +const mockStore = configureStore([thunk]); + +describe('TimeInRangeFilterDropdown', () => { + let store; + + const selectedClinicId = 'clinic123'; + + let onChange = jest.fn(); + + const ui = (props = {}) => ( + + + + + + ); + + const renderComponent = (props = {}) => render(ui(props)); + + beforeEach(() => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId } }, + }, + }); + + useFlags.mockReturnValue({ showExtremeHigh: false }); + onChange.mockClear(); + mockTrackMetric.mockClear(); + }); + + describe('filtering for time in range', () => { + it('applies the time in range filters based on checkboxes selected', async () => { + renderComponent({ timeInRange: [] }); + + // Empty due to no TIR filters applied + expect(screen.queryByLabelText('filter count')).not.toBeInTheDocument(); + + // Dropdown closed initially + expect(screen.queryByTestId('time-in-range-filter-dropdown')).not.toBeInTheDocument(); + + // Open the dropdown + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + expect(screen.getByRole('checkbox', { name: /Very High/ })).toBeInTheDocument(); + + // Nothing selected initially + expect(screen.getByRole('checkbox', { name: /Very High/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /^High/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Not meeting TIR/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /^Low/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Very Low/ })).not.toBeChecked(); + + // Selecting ranges and applying sets the filter + await userEvent.click(screen.getByRole('checkbox', { name: /Very High/ })); + await userEvent.click(screen.getByRole('checkbox', { name: /Very Low/ })); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith(['timeInVeryHighPercent', 'timeInVeryLowPercent']); + + // Dropdown should automatically close + expect(screen.queryByTestId('time-in-range-filter-dropdown')).not.toBeInTheDocument(); + }); + + it('clears the filter', async () => { + renderComponent({ timeInRange: ['timeInVeryHighPercent', 'timeInVeryLowPercent'] }); + + expect(screen.getByLabelText('filter count')).toHaveTextContent('2'); + + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + expect(screen.getByRole('checkbox', { name: /Very High/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /^High/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Not meeting TIR/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /^Low/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Very Low/ })).toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: /Clear/ })); + expect(onChange).toHaveBeenCalledWith([]); + expect(screen.queryByRole('checkbox', { name: /Very High/ })).not.toBeInTheDocument(); + }); + + it('shows the highest range option only when the showExtremeHigh flag is set', async () => { + // Hidden when the flag is off + const { rerender } = renderComponent(); + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + expect(screen.queryByRole('checkbox', { name: /Extremely High/ })).not.toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /Very High/ })).toBeInTheDocument(); + + // Visible when the flag is on + useFlags.mockReturnValue({ showExtremeHigh: true }); + rerender(ui()); + expect(screen.getByRole('checkbox', { name: /Extremely High/ })).toBeInTheDocument(); + }); + + it('removes a range when its checkbox is unchecked', async () => { + renderComponent({ timeInRange: ['timeInVeryHighPercent', 'timeInVeryLowPercent'] }); + + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + expect(screen.getByRole('checkbox', { name: /Very High/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Very Low/ })).toBeChecked(); + + // Unchecking a selected range removes it from the applied filter + await userEvent.click(screen.getByRole('checkbox', { name: /Very High/ })); + expect(screen.getByRole('checkbox', { name: /Very High/ })).not.toBeChecked(); + + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith(['timeInVeryLowPercent']); + }); + + it('applies the "Not meeting TIR" (target) range', async () => { + renderComponent({ timeInRange: [] }); + + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + + // This is the only option using a "Less than" threshold (the rest use "Greater than") + expect(screen.getByText(/Less than 70%/)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('checkbox', { name: /Not meeting TIR/ })); + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(onChange).toHaveBeenCalledWith(['timeInTargetPercent']); + }); + + it('renders the range definitions in mg/dL units', async () => { + renderComponent({ timeInRange: [] }); + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + + expect(screen.getByText('Greater than 1% Time <54 mg/dL')).toBeInTheDocument(); // Very Low + expect(screen.getByText('Greater than 4% Time <70 mg/dL')).toBeInTheDocument(); // Low + expect(screen.getByText('Less than 70% Time between 70-180 mg/dL')).toBeInTheDocument(); // Not meeting TIR + expect(screen.getByText('Greater than 25% Time >180 mg/dL')).toBeInTheDocument(); // High + expect(screen.getByText('Greater than 5% Time >250 mg/dL')).toBeInTheDocument(); // Very High + }); + + it('renders the range definitions in mmol/L units', async () => { + store = mockStore({ + blip: { + selectedClinicId, + clinics: { [selectedClinicId]: { id: selectedClinicId, preferredBgUnits: MMOLL_UNITS } }, + }, + }); + + renderComponent({ timeInRange: [] }); + await userEvent.click(screen.getByRole('button', { name: /Time in Range/ })); + + expect(screen.getByText('Greater than 1% Time <3.0 mmol/L')).toBeInTheDocument(); // Very Low + expect(screen.getByText('Greater than 4% Time <3.9 mmol/L')).toBeInTheDocument(); // Low + expect(screen.getByText('Less than 70% Time between 3.9-10.0 mmol/L')).toBeInTheDocument(); // Not meeting TIR + expect(screen.getByText('Greater than 25% Time >10.0 mmol/L')).toBeInTheDocument(); // High + expect(screen.getByText('Greater than 5% Time >13.9 mmol/L')).toBeInTheDocument(); // Very High + }); + }); +}); diff --git a/app/core/clinicUtils.js b/app/core/clinicUtils.js index ee925e273b..fddfcc4f68 100644 --- a/app/core/clinicUtils.js +++ b/app/core/clinicUtils.js @@ -87,6 +87,17 @@ export const summaryPeriodOptions = [ { value: '30d', label: t('30 days') }, ]; +export const timeInRangeFilterThresholds = { + timeInVeryLowPercent: { value: 1, comparator: '>' }, + timeInLowPercent: { value: 4, comparator: '>' }, + timeInAnyLowPercent: { value: 4, comparator: '>' }, + timeInTargetPercent: { value: 70, comparator: '<' }, + timeInHighPercent: { value: 25, comparator: '>' }, + timeInAnyHighPercent: { value: 25, comparator: '>' }, + timeInVeryHighPercent: { value: 5, comparator: '>' }, + timeInExtremeHighPercent: { value: 1, comparator: '>' }, +}; + export const timezoneOptions = map( timezoneNames, name => ({ value: name, label: name }) diff --git a/app/core/icons/tagIcon.svg b/app/core/icons/tagIcon.svg new file mode 100644 index 0000000000..eb0439f030 --- /dev/null +++ b/app/core/icons/tagIcon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/pages/clinicadmin/clinicadmin.js b/app/pages/clinicadmin/clinicadmin.js index f9da0080eb..f0e1fbc9a3 100644 --- a/app/pages/clinicadmin/clinicadmin.js +++ b/app/pages/clinicadmin/clinicadmin.js @@ -5,7 +5,6 @@ import { useTranslation, Trans } from 'react-i18next'; import { push } from 'connected-react-router'; import compact from 'lodash/compact'; import filter from 'lodash/filter'; -import find from 'lodash/find'; import get from 'lodash/get' import has from 'lodash/has'; import includes from 'lodash/includes'; @@ -25,7 +24,6 @@ import { useFormik } from 'formik'; import { useFlags } from 'launchdarkly-react-client-sdk'; import { - Title, MediumTitle, Body1, } from '../../components/elements/FontStyles'; @@ -63,6 +61,7 @@ import { import config from '../../config'; import Icon from '../../components/elements/Icon'; import utils from '../../core/utils'; +import useIsClinicAdmin from '../clinicworkspace/useIsClinicAdmin'; const clinicTypesLabels = mapValues(keyBy(clinicTypes, 'value'), 'label'); @@ -93,7 +92,6 @@ export const ClinicAdmin = (props) => { const pendingSentClinicianInvites = useSelector((state) => state.blip.pendingSentClinicianInvites); const timePrefs = useSelector((state) => state.blip.timePrefs); const [clinicianArray, setClinicianArray] = useState([]); - const [userRolesInClinic, setUserRolesInClinic] = useState([]); const [sortOptions, setSortOptions] = useState({ orderBy: 'fullName', order: 'asc' }); const sortedClinicianArray = useMemo(() => { @@ -127,7 +125,7 @@ export const ClinicAdmin = (props) => { validationSchema, }); - const isClinicAdmin = () => includes(userRolesInClinic, 'CLINIC_ADMIN'); + const isClinicAdmin = useIsClinicAdmin(); const isOnlyClinicAdmin = () => filter(clinicianArray, { isAdmin: true, inviteId: undefined }).length === 1; useEffect(() => { @@ -316,7 +314,6 @@ export const ClinicAdmin = (props) => { }, [clinic?.clinicians]); useEffect(() => { - setUserRolesInClinic(get(find(clinicianArray, { userId: loggedInUserId }), 'roles', [])); setPageCount(Math.ceil(clinicianArray.length / rowsPerPage)); }, [clinicianArray]); @@ -659,7 +656,7 @@ export const ClinicAdmin = (props) => { render: renderRole, }); - if (isClinicAdmin()) { + if (isClinicAdmin) { columns.push({ title: t('Security'), field: 'mfaEnabled', @@ -670,7 +667,7 @@ export const ClinicAdmin = (props) => { }); } - if (((isClinicAdmin()))) { + if (isClinicAdmin) { columns.push( { title: '', @@ -724,7 +721,7 @@ export const ClinicAdmin = (props) => { - {isClinicAdmin() && ( + {isClinicAdmin && ( - - - { - 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 }); - }} - /> - - - - - - - - - - { - if (!clinicSitesPopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('clinic sites filter open'), { clinicId: selectedClinicId }); - }} - sx={{ flexShrink: 0 }} - > - - - - {/* Clinic Sites Filter */} - { - trackMetric(prefixPopHealthMetric('Clinic sites filter close'), { clinicId: selectedClinicId }); - }} - onClose={() => { - clinicSitesPopupFilterState.close(); - setPendingFilters(activeFilters); - }} - > - - - - - {t('Sites')} - - { sortedSiteFilterOptions.length > 0 && - - {t('Any patient with one or more of the sites you select below will be shown.')} - - } - - - { // Render a list of checkboxes - sortedSiteFilterOptions.map(({ id, label }) => { - const { clinicSites } = pendingFilters; - const isChecked = clinicSites?.includes(id); - - return ( - - - {label} - - } - checked={isChecked} - onChange={() => { - if (isFilteringForZeroSites) { - setPendingFilters({ ...pendingFilters, clinicSites: [id] }); - } else if (isChecked) { - setPendingFilters({ ...pendingFilters, clinicSites: without(clinicSites, id) }); - } else { - setPendingFilters({ ...pendingFilters, clinicSites: [...clinicSites, id] }); - } - }} - /> - - ); - }) - } - - { // Display an option to filter for patients with zero sites - sortedSiteFilterOptions.length > 0 && - - - {t('Patients without any sites')} - } - checked={isFilteringForZeroSites} - onChange={() => { - if (isFilteringForZeroSites) { - setPendingFilters({ ...pendingFilters, clinicSites: [] }); - } else { - setPendingFilters({ ...pendingFilters, clinicSites: SPECIAL_FILTER_STATES.ZERO_SITES }); - } - }} - /> - - } - - { // If no sites exist, display a message - sortedSiteFilterOptions.length <= 0 && - - - {t('Create and assign sites to patient accounts to segment your patient population by location.')} - - { !isClinicAdmin && - - - Sites can only be created by your Workspace Admins. Not sure who the admins are? Check the Clinic Members list in your  - Workspace Settings. - - - } - - } - - - - { sortedSiteFilterOptions.length > 0 && - - - - - - } - - {isClinicAdmin && - - - - - } - - - {/* Tags Filter */} - { - if (!patientTagsPopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('patient tags filter open'), { clinicId: selectedClinicId }); - }} - sx={{ flexShrink: 0 }} - > - - - - { - trackMetric(prefixPopHealthMetric('Patient tag filter close'), { clinicId: selectedClinicId }); - }} - onClose={() => { - patientTagsPopupFilterState.close(); - setPendingFilters(activeFilters); - }} - > - - - - - {t('Tags')} - - { sortedTagFilterOptions.length > 0 && - - {t('Only patients with ALL of the tags you select below will be shown.')} - - } - - - { // Render a list of checkboxes - sortedTagFilterOptions.map(({ id, label }) => { - const { patientTags } = pendingFilters; - const isChecked = patientTags?.includes(id); - - return ( - - {label}} - checked={isChecked} - onChange={() => { - if (isFilteringForZeroTags) { - setPendingFilters({ ...pendingFilters, patientTags: [id] }); - } else if (isChecked) { - setPendingFilters({ ...pendingFilters, patientTags: without(patientTags, id) }); - } else { - setPendingFilters({ ...pendingFilters, patientTags: [...patientTags, id] }); - } - }} - /> - - ); - }) - } - - { // Display an option to filter for patients with zero tags - sortedTagFilterOptions.length > 0 && - - - {t('Patients without any tags')} - } - checked={isFilteringForZeroTags} - onChange={() => { - if (isFilteringForZeroTags) { - setPendingFilters({ ...pendingFilters, patientTags: [] }); - } else { - setPendingFilters({ ...pendingFilters, patientTags: SPECIAL_FILTER_STATES.ZERO_TAGS }); - } - }} - /> - - } - - { // If no tags exist, display a message - sortedTagFilterOptions.length <= 0 && - - - {t('Tags help you segment your patient population based on criteria you define, such as clinician, type of diabetes, or care groups.')} - - { !isClinicAdmin && - - - Tags can only be created by your Workspace Admins. Not sure who the admins are? Check the Clinic Members list in your  - Workspace Settings. - - - } - - } - - - - { sortedTagFilterOptions.length > 0 && - - - - - - } - - {isClinicAdmin && - - - - - } - - - { - if (!timeInRangePopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('Time in range filter open'), { clinicId: selectedClinicId }); - }} - > - - - - { - trackMetric(prefixPopHealthMetric('Time in range filter close'), { clinicId: selectedClinicId }); - }} - onClose={() => { - timeInRangePopupFilterState.close(); - setPendingFilters(activeFilters); - }} - > - - - - {t('% Time in Range')} - - - {t('Only patients using the standard target range will be included.')} - - - - {map(getTimeInRangeFilterOptions(showExtremeHigh, t), ({ value, title, rangeName, threshold, prefix }) => { - const {prefix: bgPrefix, suffix, value:glucoseTargetValue} = bgLabels[rangeName]; - - return ( - - { - setPendingFilters(event.target.checked - ? { ...pendingFilters, timeInRange: [...pendingFilters.timeInRange, value] } - : { ...pendingFilters, timeInRange: without(pendingFilters.timeInRange, value) } - ); - }} - /> - - - - - - - - {title} - - - - {prefix}{' '} - - {threshold} - - % {t('Time')}{' '} - {bgPrefix && `${t(bgPrefix)} `} - - {glucoseTargetValue} - {' '} - {suffix} - - - - - ); - })} - - - - - - - - - - + - { - if (!cgmUsePopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('CGM Use filter open'), { clinicId: selectedClinicId }); - }} - sx={{ flexShrink: 0 }} - > - - + - { - trackMetric(prefixPopHealthMetric('CGM Use filter close'), { clinicId: selectedClinicId }); - }} - onClose={() => { - cgmUsePopupFilterState.close(); - setPendingFilters(activeFilters); - }} - > - - - - {t('% CGM Use')} - - - - { - setPendingFilters({ ...pendingFilters, timeCGMUsePercent: event.target.value || null }); - }} - /> - + - - + - - - + - - {activeFiltersCount > 0 && ( - - )} )} {/* Flex Group 2b: Range select and Info/Visibility Icons */} - + {/* Range select */} {showSummaryData && ( - - - {t('Summarizing')} - - { - if (!summaryPeriodPopupFilterState.isOpen) trackMetric(prefixPopHealthMetric('Summary period filter open'), { clinicId: selectedClinicId }); - }} - > - - - - { - trackMetric(prefixPopHealthMetric('Summary period filter close'), { clinicId: selectedClinicId }); - }} - onClose={() => { - summaryPeriodPopupFilterState.close(); - setPendingSummaryPeriod(activeSummaryPeriod); - }} - > - - {t('Tidepool will generate health summaries for the selected number of days.')} - - setPendingSummaryPeriod(event.target.value)} - /> - - - - + - - - - - {showRpmReportUI && ( - - - - )} - - )} + + + )} + + )} {/* 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={ - + @@ -4306,6 +3163,7 @@ export const ClinicPatients = (props) => { ); }, [ + activeFilters, clinic?.fetchedPatientCount, columns, data, @@ -4314,6 +3172,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..3bcb865899 --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.js @@ -0,0 +1,134 @@ +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 ( + + + + } + /> + ); +}; + +AppliedFiltersList.propTypes = { + activeFilters: PropTypes.shape({ + lastData: PropTypes.number, + lastDataType: PropTypes.oneOf(['bgm', 'cgm']), + timeCGMUsePercent: PropTypes.oneOf(['<0.7', '>=0.7']), + timeInRange: PropTypes.arrayOf(PropTypes.string), + meetsGlycemicTargets: PropTypes.bool, + patientTags: PropTypes.arrayOf(PropTypes.string), + clinicSites: PropTypes.arrayOf(PropTypes.string), + }).isRequired, + setActiveFilters: PropTypes.func.isRequired, + onClearSearch: PropTypes.func.isRequired, + onResetFilters: PropTypes.func.isRequired, +}; + +export default AppliedFiltersList; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.js new file mode 100644 index 0000000000..1c6f76bc25 --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import noop from 'lodash/noop'; + +import CGMUseFilterDropdown from '../components/CGMUseFilterDropdown'; + +const FilterByCGMUse = ({ + activeFilters = {}, + setActiveFilters = noop, +}) => { + const handleChange = (timeCGMUsePercent) => { + setActiveFilters({ ...activeFilters, timeCGMUsePercent }); + }; + + const { timeCGMUsePercent } = activeFilters; + + return ( + + ); +}; + +FilterByCGMUse.propTypes = { + activeFilters: PropTypes.shape({ + timeCGMUsePercent: PropTypes.string, + }), + setActiveFilters: PropTypes.func, +}; + +export default FilterByCGMUse; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js new file mode 100644 index 0000000000..b48b4036bf --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +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 ( + + ); +}; + +FilterByDataRecency.propTypes = { + activeFilters: PropTypes.shape({ + lastData: PropTypes.number, + lastDataType: PropTypes.oneOf(['bgm', 'cgm']), + }), + setActiveFilters: PropTypes.func, +}; + +export default FilterByDataRecency; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js new file mode 100644 index 0000000000..c0990ce846 --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import * as actions from '../../../redux/actions'; +import { trackMetric } from '../../../core/metricUtils'; +import noop from 'lodash/noop'; + +import SiteFilterDropdown from '../components/SiteFilterDropdown'; +import useIsClinicAdmin from '../useIsClinicAdmin'; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; + +const FilterBySites = ({ + api, + activeFilters = {}, + setActiveFilters = noop, + setShowClinicSitesDialog = noop, +}) => { + const dispatch = useDispatch(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const isClinicAdmin = useIsClinicAdmin(); + const pageName = useClinicMetricsPageName(); + + const handleChange = (clinicSites) => { + setActiveFilters({ ...activeFilters, clinicSites }); + }; + + const clinicSites = activeFilters?.clinicSites; + + const handleClickEditSites = () => { + trackMetric('Clinic - Edit clinic sites open', { clinicId: selectedClinicId, source: 'Filter menu', pageName }); + dispatch(actions.async.fetchClinicSites(api, selectedClinicId)); // current data in clinic object may be stale + setShowClinicSitesDialog(true); + }; + + return ( + + ); +}; + +FilterBySites.propTypes = { + api: PropTypes.object.isRequired, + activeFilters: PropTypes.shape({ + clinicSites: PropTypes.arrayOf(PropTypes.string), + }), + setActiveFilters: PropTypes.func, + setShowClinicSitesDialog: PropTypes.func, +}; + +export default FilterBySites; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.js new file mode 100644 index 0000000000..9e759560a5 --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.js @@ -0,0 +1,29 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import noop from 'lodash/noop'; + +import SummaryPeriodFilterDropdown from '../components/SummaryPeriodFilterDropdown'; +import { summaryPeriodOptions } from '../../../core/clinicUtils'; + +const FilterBySummaryPeriod = ({ + activeSummaryPeriod, + setActiveSummaryPeriod = noop, +}) => { + const handleChange = (summaryPeriod) => { + setActiveSummaryPeriod(summaryPeriod); + }; + + return ( + + ); +}; + +FilterBySummaryPeriod.propTypes = { + activeSummaryPeriod: PropTypes.oneOf(summaryPeriodOptions.map(opt => opt.value)).isRequired, + setActiveSummaryPeriod: PropTypes.func, +}; + +export default FilterBySummaryPeriod; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js new file mode 100644 index 0000000000..997d1ddf0c --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import * as actions from '../../../redux/actions'; +import { trackMetric } from '../../../core/metricUtils'; +import noop from 'lodash/noop'; + +import TagFilterDropdown from '../components/TagFilterDropdown'; +import useIsClinicAdmin from '../useIsClinicAdmin'; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; + +const FilterByTags = ({ + api, + activeFilters = {}, + setActiveFilters = noop, + setShowClinicPatientTagsDialog = noop, +}) => { + const dispatch = useDispatch(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const isClinicAdmin = useIsClinicAdmin(); + const pageName = useClinicMetricsPageName(); + + const handleChange = (patientTags) => { + setActiveFilters({ ...activeFilters, patientTags }); + }; + + const patientTags = activeFilters?.patientTags; + + const handleClickEditTags = () => { + trackMetric('Clinic - Edit clinic tags open', { clinicId: selectedClinicId, source: 'Filter menu', pageName }); + dispatch(actions.async.fetchClinicPatientTags(api, selectedClinicId)); // current data in clinic object may be stale + setShowClinicPatientTagsDialog(true); + }; + + return ( + + ); +}; + +FilterByTags.propTypes = { + api: PropTypes.object.isRequired, + activeFilters: PropTypes.shape({ + patientTags: PropTypes.arrayOf(PropTypes.string), + }), + setActiveFilters: PropTypes.func, + setShowClinicPatientTagsDialog: PropTypes.func, +}; + +export default FilterByTags; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.js new file mode 100644 index 0000000000..087dfabf00 --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.js @@ -0,0 +1,32 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import noop from 'lodash/noop'; + +import TimeInRangeFilterDropdown from '../components/TimeInRangeFilterDropdown'; + +const FilterByTimeInRange = ({ + activeFilters = {}, + setActiveFilters = noop, +}) => { + const handleChange = (timeInRange) => { + setActiveFilters({ ...activeFilters, timeInRange }); + }; + + const { timeInRange } = activeFilters; + + return ( + + ); +}; + +FilterByTimeInRange.propTypes = { + activeFilters: PropTypes.shape({ + timeInRange: PropTypes.arrayOf(PropTypes.string), + }), + setActiveFilters: PropTypes.func, +}; + +export default FilterByTimeInRange; diff --git a/app/pages/clinicworkspace/components/ActiveFiltersTray.js b/app/pages/clinicworkspace/components/ActiveFiltersTray.js new file mode 100644 index 0000000000..d8e6596fa3 --- /dev/null +++ b/app/pages/clinicworkspace/components/ActiveFiltersTray.js @@ -0,0 +1,283 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +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 getLastDataChipLabel = (lastDataType, lastData) => ({ + bgm: t('BGM data within {{ count }} days', { count: lastData }), + cgm: t('CGM data within {{ count }} days', { count: lastData }), + }[lastDataType]); + + const getTimeCGMUsePercentChipLabel = (timeCGMUsePercent) => ({ + '<0.7': t('< 70% CGM use'), + '>=0.7': t('≥ 70% CGM use'), + }[timeCGMUsePercent]); + + const getTimeInRangeChipLabel = (rangeKey) => ({ + timeInExtremeHighPercent: t('%TIR = Extremely High'), + timeInVeryHighPercent: t('%TIR = Very High'), + timeInAnyHighPercent: t('%TIR = High'), + timeInTargetPercent: t('%TIR = Not in Range'), + timeInAnyLowPercent: t('%TIR = Low'), + timeInVeryLowPercent: t('%TIR = Very Low'), + }[rangeKey]); + + return [ + // Data Recency Filter + (lastData && lastDataType && { + type: 'lastData', + value: `${lastDataType}-${lastData}`, + label: getLastDataChipLabel(lastDataType, lastData), + }), + + // CGM Wear Time Filter + (timeCGMUsePercent && { + type: 'timeCGMUsePercent', + value: timeCGMUsePercent, + label: getTimeCGMUsePercentChipLabel(timeCGMUsePercent), + }), + + // Time In Range Filters + ...timeInRange.map(rangeKey => ({ + type: 'timeInRange', + value: rangeKey, + label: getTimeInRangeChipLabel(rangeKey), + })), + ].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 = ({ label, onRemove }) => { + const { t } = useTranslation(); + + return ( + + + {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} + + )} + + ); +}; + +ActiveFiltersTray.propTypes = { + filters: PropTypes.shape({ + lastData: PropTypes.number, + lastDataType: PropTypes.oneOf(['bgm', 'cgm']), + timeCGMUsePercent: PropTypes.oneOf(['<0.7', '>=0.7']), + timeInRange: PropTypes.arrayOf(PropTypes.string), + patientTags: PropTypes.arrayOf(PropTypes.string), + clinicSites: PropTypes.arrayOf(PropTypes.string), + }), + hasSearchActive: PropTypes.bool, + onRemoveFilter: PropTypes.func, + rightContent: PropTypes.node, +}; + +export default ActiveFiltersTray; diff --git a/app/pages/clinicworkspace/components/CGMUseFilterDropdown.js b/app/pages/clinicworkspace/components/CGMUseFilterDropdown.js new file mode 100644 index 0000000000..9ac8cc3d5a --- /dev/null +++ b/app/pages/clinicworkspace/components/CGMUseFilterDropdown.js @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { trackMetric } from '../../../core/metricUtils'; +import { colors as vizColors } from '@tidepool/viz'; + +import { Box, Grid } from 'theme-ui'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; +import noop from 'lodash/noop'; + +import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; + +import Button from '../../../components/elements/Button'; +import Popover from '../../../components/elements/Popover'; +import RadioGroup from '../../../components/elements/RadioGroup'; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; + +const getCgmUseFilterOptions = (t) => [ + { value: '<0.7', label: t('Less than 70%') }, + { value: '>=0.7', label: t('70% or more') }, +]; + +const DropdownContent = ({ + onClose, + onChange, + timeCGMUsePercent, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + + const [pendingTimeCGMUsePercent, setPendingTimeCGMUsePercent] = useState(timeCGMUsePercent); + + const cgmUseFilterOptions = getCgmUseFilterOptions(t); + + const handleChange = timeCGMUsePercent => onChange(timeCGMUsePercent); + + return ( + + + + {t('% CGM Use')} + + + + setPendingTimeCGMUsePercent(event.target.value || null)} + /> + + + + + + + + + + ); +}; + +const CGMUseFilterDropdown = ({ + onChange = noop, + timeCGMUsePercent = null, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + + const cgmUsePopupFilterState = usePopupState({ + variant: 'popover', + popupId: 'cgmUseFilters', + }); + + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + + const handleCloseDropdown = () => cgmUsePopupFilterState.close(); + + return ( + <> + { + if (!cgmUsePopupFilterState.isOpen) trackMetric('Clinic - CGM Use filter open', { clinicId: selectedClinicId, pageName }); + }} + sx={{ flexShrink: 0 }} + > + + + + { + trackMetric('Clinic - CGM Use filter close', { clinicId: selectedClinicId, pageName }); + }} + onClose={handleCloseDropdown} + > + { cgmUsePopupFilterState.isOpen && + + } + + + ); +}; + +CGMUseFilterDropdown.propTypes = { + onChange: PropTypes.func, + timeCGMUsePercent: PropTypes.oneOf(getCgmUseFilterOptions(label => label).map(opt => opt.value)), +}; + +export default CGMUseFilterDropdown; diff --git a/app/pages/clinicworkspace/components/ClearFilterButtons.js b/app/pages/clinicworkspace/components/ClearFilterButtons.js new file mode 100644 index 0000000000..10f09ee681 --- /dev/null +++ b/app/pages/clinicworkspace/components/ClearFilterButtons.js @@ -0,0 +1,67 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Trans, 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 + + Reset All Filters + {' '}or{' '} + Clear Search + + ; + + case NONE: + default: + return null; + } +}; + +ClearFilterButtons.propTypes = { + patientQueryState: PropTypes.oneOf(Object.values(PATIENT_QUERY_STATE)).isRequired, + onClearSearch: PropTypes.func.isRequired, + onResetFilters: PropTypes.func.isRequired, +}; + +export default ClearFilterButtons; diff --git a/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js b/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js new file mode 100644 index 0000000000..a1c306ee61 --- /dev/null +++ b/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js @@ -0,0 +1,200 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { trackMetric } from '../../../core/metricUtils'; +import { colors as vizColors } from '@tidepool/viz'; + +import { Box, Grid } from 'theme-ui'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; +import noop from 'lodash/noop'; + +import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; + +import Button from '../../../components/elements/Button'; +import Popover from '../../../components/elements/Popover'; +import RadioGroup from '../../../components/elements/RadioGroup'; +import { 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 && + + } + + + ); +}; + +DataRecencyFilterDropdown.propTypes = { + onChange: PropTypes.func, + lastData: PropTypes.number, + lastDataType: PropTypes.oneOf(['bgm', 'cgm']), + filterOptions: PropTypes.arrayOf(PropTypes.shape({ + value: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + })), +}; + +export default DataRecencyFilterDropdown; diff --git a/app/pages/clinicworkspace/components/SiteFilterDropdown.js b/app/pages/clinicworkspace/components/SiteFilterDropdown.js new file mode 100644 index 0000000000..8e9c8ccf70 --- /dev/null +++ b/app/pages/clinicworkspace/components/SiteFilterDropdown.js @@ -0,0 +1,320 @@ +import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { Trans, useTranslation } from 'react-i18next'; + +import { Box, Flex, Grid, Text } from 'theme-ui'; +import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; +import CloseRoundedIcon from '@material-ui/icons/CloseRounded'; +import SearchIcon from '@material-ui/icons/Search'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; +import { colors as vizColors } from '@tidepool/viz'; +import utils from '../../../core/utils'; +import { trackMetric } from '../../../core/metricUtils'; + +import without from 'lodash/without'; +import map from 'lodash/map'; +import noop from 'lodash/noop'; +import isEqual from 'lodash/isEqual'; +import isEmpty from 'lodash/isEmpty'; + +import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; + +import Button from '../../../components/elements/Button'; +import Icon from '../../../components/elements/Icon'; +import Pill from '../../../components/elements/Pill'; +import Popover from '../../../components/elements/Popover'; +import Checkbox from '../../../components/elements/Checkbox'; + +import { borders } from '../../../themes/baseTheme'; + +import { SPECIAL_FILTER_STATES } from '../useClinicPatientsFilters'; +import useIsClinicAdmin from '../useIsClinicAdmin'; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; +import TextInput from '../../../components/elements/TextInput'; +import styled from '@emotion/styled'; + +const EditSitesAction = ({ onClick = noop }) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +const NoClinicSites = () => { + const isClinicAdmin = useIsClinicAdmin(); + const { t } = useTranslation(); + + return ( + + + {t('You don\'t have any Clinic Sites listed for your workspace. Add Clinic Sites to organize and filter patients by care location.')} + + { !isClinicAdmin && + + + Only admins can add new Clinic Sites associated with this workspace. If you don't have admin access, contact a Workspace Admin to add clinic sites. + + + } + + ); +}; + +const DropdownContent = ({ + onClose, + onChange, + clinicSites, + onClickEditSites, +}) => { + const { t } = useTranslation(); + const isClinicAdmin = useIsClinicAdmin(); + const pageName = useClinicMetricsPageName(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + + const [pendingSites, setPendingSites] = useState(clinicSites); + const [searchText, setSearchText] = useState(''); + + const isFilteringForZeroSites = isEqual(pendingSites, SPECIAL_FILTER_STATES.ZERO_SITES); + + const sortedSiteFilterOptions = useMemo(() => { + return map(clinic?.sites, ({ id, name }) => ({ id, label: name })) + .toSorted((a, b) => utils.compareLabels(a.label, b.label)); + }, [clinic?.sites]); + + const shownSiteFilterOptions = useMemo(() => { + const trimmedSearchText = searchText.trim().toLowerCase(); + if (!trimmedSearchText) return sortedSiteFilterOptions; + + return sortedSiteFilterOptions.filter(({ label }) => label?.toLowerCase()?.includes(trimmedSearchText)); + }, [sortedSiteFilterOptions, searchText]); + + const handleChange = (clinicSites) => onChange(clinicSites); + + const isChecked = id => pendingSites?.includes(id); + + const canEditSites = !!onClickEditSites && isClinicAdmin; + + const hasNoSearchResults = !!searchText.trim() && shownSiteFilterOptions.length <= 0; + + return ( + + + + {t('Clinic Sites')} + {t('Any patient with one or more of the clinic sites you select below will be shown.')} + + + {canEditSites && } + + + + + { sortedSiteFilterOptions.length > 0 && + setSearchText('')} + onChange={evt => setSearchText(evt.target.value)} + value={searchText} + variant="ultraCondensed" + sx={{ margin: 2, width: 'unset' }} + /> + } + + + { // Render a list of checkboxes + shownSiteFilterOptions.map(({ id, label }) => ( + + + {label} + + } + checked={isChecked(id)} + onChange={() => { + if (isFilteringForZeroSites) { + setPendingSites([id]); + } else if (isChecked(id)) { + setPendingSites(pendingSites => without(pendingSites, id)); + } else { + setPendingSites(pendingSites => [...pendingSites, id]); + } + }} + /> + + )) + } + + + { hasNoSearchResults && + + {t('No sites found that match your search. Check your spelling or try a different search.')} + + } + + { // Display an option to filter for patients with zero sites + sortedSiteFilterOptions.length > 0 && + + + {t('Patients without any sites')} + } + checked={isFilteringForZeroSites} + onChange={() => { + if (isFilteringForZeroSites) { + setPendingSites([]); + } else { + setPendingSites(SPECIAL_FILTER_STATES.ZERO_SITES); + } + }} + /> + + } + + { // If no sites exist, display a message + sortedSiteFilterOptions.length <= 0 && + } + + + { sortedSiteFilterOptions.length > 0 && + + + + + + } + + ); +}; + +const SiteFilterPopover = styled(Popover)` + .MuiPopover-paper { + max-height: 540px; + overflow: clip; + } +`; + +const SiteFilterDropdown = ({ + onChange = noop, + clinicSites = [], + onClickEditSites = null, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + + const clinicSitesPopupFilterState = usePopupState({ + variant: 'popover', + popupId: 'clinicSitesFilters', + }); + + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + + const handleCloseDropdown = () => clinicSitesPopupFilterState.close(); + + return ( + <> + { + if (!clinicSitesPopupFilterState.isOpen) trackMetric('Clinic - clinic sites filter open', { clinicId: selectedClinicId, pageName }); + }} + sx={{ flexShrink: 0 }} + > + + + + { + trackMetric('Clinic - Clinic sites filter close', { clinicId: selectedClinicId, pageName }); + }} + onClose={handleCloseDropdown} + > + { clinicSitesPopupFilterState.isOpen && + + } + + + ); +}; + +SiteFilterDropdown.propTypes = { + onChange: PropTypes.func, + clinicSites: PropTypes.arrayOf(PropTypes.string), + onClickEditSites: PropTypes.func, +}; + +export default SiteFilterDropdown; diff --git a/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.js b/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.js new file mode 100644 index 0000000000..06c1c3c668 --- /dev/null +++ b/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.js @@ -0,0 +1,168 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { trackMetric } from '../../../core/metricUtils'; +import { colors as vizColors } from '@tidepool/viz'; + +import { Box, Grid } from 'theme-ui'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; + +import noop from 'lodash/noop'; + +import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; + +import Button from '../../../components/elements/Button'; +import Popover from '../../../components/elements/Popover'; +import RadioGroup from '../../../components/elements/RadioGroup'; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; +import { summaryPeriodOptions } from '../../../core/clinicUtils'; + +const getSummaryPeriodSelectLabel = (t, activeSummaryPeriod) => { + switch (activeSummaryPeriod) { + case '1d': return t('Summarizing 24 hours of data'); + case '7d': return t('Summarizing 7 days of data'); + case '14d': return t('Summarizing 14 days of data'); + case '30d': return t('Summarizing 30 days of data'); + } + + return null; +}; + +const DropdownContent = ({ + onClose, + onChange, + activeSummaryPeriod, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + + const [pendingSummaryPeriod, setPendingSummaryPeriod] = useState(activeSummaryPeriod); + + const handleChange = (summaryPeriod) => onChange(summaryPeriod); + + return ( + + + + {t('Summarizing Data')} + {t('Tidepool will generate health summaries for the selected number of days.')} + + + + setPendingSummaryPeriod(event.target.value)} + /> + + + + + + + + + + ); +}; + +const SummaryPeriodFilterDropdown = ({ + onChange = noop, + activeSummaryPeriod, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + + const summaryPeriodPopupFilterState = usePopupState({ + variant: 'popover', + popupId: 'summaryPeriodFilters', + }); + + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + + const handleCloseDropdown = () => summaryPeriodPopupFilterState.close(); + + return ( + <> + { + if (!summaryPeriodPopupFilterState.isOpen) trackMetric('Clinic - Summary period filter open', { clinicId: selectedClinicId, pageName }); + }} + sx={{ flexShrink: 0 }} + > + + + + { + trackMetric('Clinic - Summary period filter close', { clinicId: selectedClinicId, pageName }); + }} + onClose={handleCloseDropdown} + > + { summaryPeriodPopupFilterState.isOpen && + + } + + + ); +}; + +SummaryPeriodFilterDropdown.propTypes = { + onChange: PropTypes.func, + activeSummaryPeriod: PropTypes.oneOf(summaryPeriodOptions.map(opt => opt.value)).isRequired, +}; + +export default SummaryPeriodFilterDropdown; diff --git a/app/pages/clinicworkspace/components/TagFilterDropdown.js b/app/pages/clinicworkspace/components/TagFilterDropdown.js new file mode 100644 index 0000000000..741a8a5493 --- /dev/null +++ b/app/pages/clinicworkspace/components/TagFilterDropdown.js @@ -0,0 +1,323 @@ +import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { Trans, useTranslation } from 'react-i18next'; + +import { Box, Flex, Grid, Text } from 'theme-ui'; +import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; +import CloseRoundedIcon from '@material-ui/icons/CloseRounded'; +import SearchIcon from '@material-ui/icons/Search'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; +import { colors as vizColors } from '@tidepool/viz'; +import utils from '../../../core/utils'; +import { trackMetric } from '../../../core/metricUtils'; + +import without from 'lodash/without'; +import map from 'lodash/map'; +import noop from 'lodash/noop'; +import isEqual from 'lodash/isEqual'; +import isEmpty from 'lodash/isEmpty'; + +import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; + +import Button from '../../../components/elements/Button'; +import Icon from '../../../components/elements/Icon'; +import Pill from '../../../components/elements/Pill'; +import Popover from '../../../components/elements/Popover'; +import Checkbox from '../../../components/elements/Checkbox'; + +import { borders } from '../../../themes/baseTheme'; + +import { SPECIAL_FILTER_STATES } from '../useClinicPatientsFilters'; +import useIsClinicAdmin from '../useIsClinicAdmin'; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; +import TextInput from '../../../components/elements/TextInput'; +import styled from '@emotion/styled'; + +const EditTagsAction = ({ onClick = noop }) => { + const { t } = useTranslation(); + + return ( + + ); +}; + +const NoClinicTags = () => { + const isClinicAdmin = useIsClinicAdmin(); + const { t } = useTranslation(); + + return ( + + + {t('You don\'t have any tags yet.')} + + + {t('Tags help you organize and find patients using categories that matter to your clinic, such as clinician, diabetes type, or care group.')} + + { !isClinicAdmin && + + + Tags can only be created by Workspace Admins. If you don't have admin access, contact a Workspace Admin. + + + } + + ); +}; + +const DropdownContent = ({ + onClose, + onChange, + patientTags, + onClickEditTags, +}) => { + const { t } = useTranslation(); + const isClinicAdmin = useIsClinicAdmin(); + const pageName = useClinicMetricsPageName(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + + const [pendingTags, setPendingTags] = useState(patientTags); + const [searchText, setSearchText] = useState(''); + + const isFilteringForZeroTags = isEqual(pendingTags, SPECIAL_FILTER_STATES.ZERO_TAGS); + + const sortedTagFilterOptions = useMemo(() => { + return map(clinic?.patientTags, ({ id, name }) => ({ id, label: name })) + .toSorted((a, b) => utils.compareLabels(a.label, b.label)); + }, [clinic?.patientTags]); + + const shownTagFilterOptions = useMemo(() => { + const trimmedSearchText = searchText.trim().toLowerCase(); + if (!trimmedSearchText) return sortedTagFilterOptions; + + return sortedTagFilterOptions.filter(({ label }) => label?.toLowerCase()?.includes(trimmedSearchText)); + }, [sortedTagFilterOptions, searchText]); + + const handleChange = (patientTags) => onChange(patientTags); + + const isChecked = id => pendingTags?.includes(id); + + const canEditTags = !!onClickEditTags && isClinicAdmin; + + const hasNoSearchResults = !!searchText.trim() && shownTagFilterOptions.length <= 0; + + return ( + + + + {t('Tags')} + {t('Only patients with ALL of the tags you select below will be shown.')} + + + {canEditTags && } + + + + + { sortedTagFilterOptions.length > 0 && + setSearchText('')} + onChange={evt => setSearchText(evt.target.value)} + value={searchText} + variant="ultraCondensed" + sx={{ margin: 2, width: 'unset' }} + /> + } + + + { // Render a list of checkboxes + shownTagFilterOptions.map(({ id, label }) => ( + + + {label} + + } + checked={isChecked(id)} + onChange={() => { + if (isFilteringForZeroTags) { + setPendingTags([id]); + } else if (isChecked(id)) { + setPendingTags(pendingTags => without(pendingTags, id)); + } else { + setPendingTags(pendingTags => [...pendingTags, id]); + } + }} + /> + + )) + } + + + { hasNoSearchResults && + + {t('No tags found that match your search. Check your spelling or try a different search.')} + + } + + { // Display an option to filter for patients with zero tags + sortedTagFilterOptions.length > 0 && + + + {t('Patients without any tags')} + } + checked={isFilteringForZeroTags} + onChange={() => { + if (isFilteringForZeroTags) { + setPendingTags([]); + } else { + setPendingTags(SPECIAL_FILTER_STATES.ZERO_TAGS); + } + }} + /> + + } + + { // If no tags exist, display a message + sortedTagFilterOptions.length <= 0 && + } + + + { sortedTagFilterOptions.length > 0 && + + + + + + } + + ); +}; + +const TagFilterPopover = styled(Popover)` + .MuiPopover-paper { + max-height: 540px; + overflow: clip; + } +`; + +const TagFilterDropdown = ({ + onChange = noop, + patientTags = [], + onClickEditTags = null, +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + + const patientTagsPopupFilterState = usePopupState({ + variant: 'popover', + popupId: 'patientTagFilters', + }); + + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + + const handleCloseDropdown = () => patientTagsPopupFilterState.close(); + + return ( + <> + { + if (!patientTagsPopupFilterState.isOpen) trackMetric('Clinic - patient tags filter open', { clinicId: selectedClinicId, pageName }); + }} + sx={{ flexShrink: 0 }} + > + + + + { + trackMetric('Clinic - Patient tag filter close', { clinicId: selectedClinicId, pageName }); + }} + onClose={handleCloseDropdown} + > + { patientTagsPopupFilterState.isOpen && + + } + + + ); +}; + +TagFilterDropdown.propTypes = { + onChange: PropTypes.func, + patientTags: PropTypes.arrayOf(PropTypes.string), + onClickEditTags: PropTypes.func, +}; + +export default TagFilterDropdown; diff --git a/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.js b/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.js new file mode 100644 index 0000000000..08ce8c0a0d --- /dev/null +++ b/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.js @@ -0,0 +1,326 @@ +import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import { useSelector } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import { useFlags } from 'launchdarkly-react-client-sdk'; +import { trackMetric } from '../../../core/metricUtils'; +import { colors as vizColors, utils as vizUtils } from '@tidepool/viz'; + +import { Box, Flex, Grid, Text } from 'theme-ui'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; + +import map from 'lodash/map'; +import includes from 'lodash/includes'; +import without from 'lodash/without'; +import noop from 'lodash/noop'; + +import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; + +import Button from '../../../components/elements/Button'; +import Pill from '../../../components/elements/Pill'; +import Popover from '../../../components/elements/Popover'; +import Checkbox from '../../../components/elements/Checkbox'; + +import { colors } from '../../../themes/baseTheme'; +import { MGDL_UNITS } from '../../../core/constants'; + +const { reshapeBgClassesToBgBounds, generateBgRangeLabels } = vizUtils.bg; +import useClinicMetricsPageName from '../useClinicMetricsPageName'; +import { timeInRangeFilterThresholds } from '../../../core/clinicUtils'; + +const getRangeDefinition = (t, { comparator, threshold, bgRange, isBounded }) => { + if (isBounded) { + return comparator === '<' + ? t('Less than {{threshold}}% Time between {{bgRange}}', { threshold, bgRange }) + : t('Greater than {{threshold}}% Time between {{bgRange}}', { threshold, bgRange }); + } + + return comparator === '<' + ? t('Less than {{threshold}}% Time {{bgRange}}', { threshold, bgRange }) + : t('Greater than {{threshold}}% Time {{bgRange}}', { threshold, bgRange }); +}; + +const getTimeInRangeFilterOptions = (showExtremeHigh = false, t) => [ + (showExtremeHigh && { + title: t('Extremely High'), + value: 'timeInExtremeHighPercent', + threshold: timeInRangeFilterThresholds.timeInExtremeHighPercent.value, + rangeName: 'extremeHigh', + }), + { + title: t('Very High'), + value: 'timeInVeryHighPercent', + threshold: timeInRangeFilterThresholds.timeInVeryHighPercent.value, + rangeName: 'veryHigh', + }, + { + title: t('High'), + value: 'timeInAnyHighPercent', + threshold: timeInRangeFilterThresholds.timeInAnyHighPercent.value, + rangeName: 'anyHigh', + }, + { + title: t('Not meeting TIR'), + value: 'timeInTargetPercent', + threshold: timeInRangeFilterThresholds.timeInTargetPercent.value, + rangeName: 'target', + }, + { + title: t('Low'), + value: 'timeInAnyLowPercent', + threshold: timeInRangeFilterThresholds.timeInAnyLowPercent.value, + rangeName: 'anyLow', + }, + { + title: t('Very Low'), + value: 'timeInVeryLowPercent', + threshold: timeInRangeFilterThresholds.timeInVeryLowPercent.value, + rangeName: 'veryLow', + }, +].filter(Boolean) + .reverse(); + +const DropdownContent = ({ + onClose = noop, + onChange = noop, + timeInRange = [], +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + const { showExtremeHigh } = useFlags(); + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const clinicBgUnits = useSelector((state) => state.blip.clinics?.[selectedClinicId]?.preferredBgUnits) || MGDL_UNITS; + + const [pendingTimeInRange, setPendingTimeInRange] = useState(timeInRange); + + const bgLabels = useMemo( + () => generateBgRangeLabels( + { + bgUnits: clinicBgUnits, + bgBounds: reshapeBgClassesToBgBounds({ bgUnits: clinicBgUnits }), + }, + { segmented: true } + ), + [clinicBgUnits] + ); + + const handleChange = (timeInRange) => onChange(timeInRange); + + const filterOptions = getTimeInRangeFilterOptions(showExtremeHigh, t); + + return ( + + + {t('% Time in Range')} + {t('Only patients using the standard target range will be included.')} + + + + {map(filterOptions, ({ value, title, rangeName, threshold }, i) => { + const { prefix: bgPrefix, suffix, value: bgValue } = bgLabels[rangeName]; + const { comparator } = timeInRangeFilterThresholds[value]; + + const definition = getRangeDefinition(t, { + comparator, + threshold, + bgRange: `${bgValue} ${suffix}`, + isBounded: !!bgPrefix, + }); + + return ( + + { + setPendingTimeInRange(event.target.checked + ? [...pendingTimeInRange, value] + : without(pendingTimeInRange, value) + ); + }} + /> + + + + + + + + {title} + + + + {definition} + + + + + ); + })} + + + + + + + + + ); +}; + +const TimeInRangeFilterDropdown = ({ + onChange = noop, + timeInRange = [], +}) => { + const { t } = useTranslation(); + const pageName = useClinicMetricsPageName(); + + const timeInRangePopupFilterState = usePopupState({ + variant: 'popover', + popupId: 'timeInRangeFilters', + }); + + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + + const handleCloseDropdown = () => timeInRangePopupFilterState.close(); + + return ( + <> + { + if (!timeInRangePopupFilterState.isOpen) trackMetric('Clinic - Time in range filter open', { clinicId: selectedClinicId, pageName }); + }} + sx={{ flexShrink: 0 }} + > + + + + { + trackMetric('Clinic - Time in range filter close', { clinicId: selectedClinicId, pageName }); + }} + onClose={handleCloseDropdown} + > + { timeInRangePopupFilterState.isOpen && + + } + + + ); +}; + +TimeInRangeFilterDropdown.propTypes = { + onChange: PropTypes.func, + timeInRange: PropTypes.arrayOf(PropTypes.string), +}; + +export default TimeInRangeFilterDropdown; diff --git a/app/pages/clinicworkspace/useClinicMetricsPageName.js b/app/pages/clinicworkspace/useClinicMetricsPageName.js new file mode 100644 index 0000000000..43202e1340 --- /dev/null +++ b/app/pages/clinicworkspace/useClinicMetricsPageName.js @@ -0,0 +1,12 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; + +const useClinicMetricsPageName = () => { + const { pathname } = useLocation(); + + if (pathname.startsWith('/clinic-workspace')) return 'Population Health'; + + return 'Unknown'; +}; + +export default useClinicMetricsPageName; 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/app/pages/clinicworkspace/useIsClinicAdmin.js b/app/pages/clinicworkspace/useIsClinicAdmin.js new file mode 100644 index 0000000000..d614177fcf --- /dev/null +++ b/app/pages/clinicworkspace/useIsClinicAdmin.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { useSelector } from 'react-redux'; +import get from 'lodash/get'; +import includes from 'lodash/includes'; + +const useIsClinicAdmin = () => { + const selectedClinicId = useSelector((state) => state.blip.selectedClinicId); + const loggedInUserId = useSelector((state) => state.blip.loggedInUserId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + + const isClinicAdmin = includes(get(clinic, ['clinicians', loggedInUserId, 'roles'], []), 'CLINIC_ADMIN'); + + return isClinicAdmin; +}; + +export default useIsClinicAdmin; 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..b3960176e3 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 @@ -1639,331 +1625,36 @@ 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; - - const popover = () => document.querySelector('#cgmUseFilters'); - expect(popover()).to.exist; - expect(popover().style.visibility).to.equal('hidden'); - - // Open filters popover - fireEvent.click(cgmUseFilterTrigger); - expect(popover().style.visibility).to.equal(''); - - // Ensure filter options present - const cgmUseFilterOptions = document.querySelectorAll('#cgm-use label'); - expect(cgmUseFilterOptions.length).to.equal(2); - expect(cgmUseFilterOptions[0].textContent).to.equal('Less than 70%'); - expect(cgmUseFilterOptions[0].querySelector('input').value).to.equal('<0.7'); - - expect(cgmUseFilterOptions[1].textContent).to.equal('70% or more'); - expect(cgmUseFilterOptions[1].querySelector('input').value).to.equal('>=0.7'); - - // Apply button disabled until selection made - const applyButton = () => document.querySelector('#apply-cgm-use-filter'); - expect(applyButton().disabled).to.be.true; - - fireEvent.click(cgmUseFilterOptions[0].querySelector('input')); - expect(applyButton().disabled).to.be.false; - - fireEvent.click(applyButton()); - sinon.assert.calledWith(defaultProps.trackMetric, 'Clinic - Population Health - CGM use apply filter', sinon.match({ clinicId: 'clinicID123', filter: '<0.7' })); - }); - - it('should allow filtering by bg range targets that DO NOT meet selected criteria', async () => { - // Set up stateful filter mock to allow DOM verification after applying filters - let currentFilters = { timeInRange: [], patientTags: [], meetsGlycemicTargets: true }; - const applyFiltersMock = (newFilters) => { - currentFilters = typeof newFilters === 'function' ? newFilters(currentFilters) : newFilters; - mockUseClinicPatientsFilters.mockImplementation(() => [currentFilters, applyFiltersMock]); - }; - mockUseClinicPatientsFilters.mockImplementation(() => [currentFilters, applyFiltersMock]); - mountWrapper(store); - defaultProps.trackMetric.resetHistory(); - - const timeInRangeFilterTrigger = container.querySelector('#time-in-range-filter-trigger'); - expect(timeInRangeFilterTrigger).to.exist; - expect(timeInRangeFilterTrigger.textContent).to.equal('% Time in Range'); - - const timeInRangeFilterCount = () => container.querySelector('#time-in-range-filter-count'); - expect(timeInRangeFilterCount()).to.be.null; - - const popover = () => document.querySelector('#timeInRangeFilters'); - expect(popover()).to.exist; - expect(popover().style.visibility).to.equal('hidden'); - - // Open filters popover - fireEvent.click(timeInRangeFilterTrigger); - expect(popover().style.visibility).to.equal(''); - - // Ensure filter options present and in default unchecked state - const veryLowFilter = () => document.querySelector('#time-in-range-filter-veryLow'); - expect(veryLowFilter()).to.exist; - expect(veryLowFilter().textContent).to.contain('Greater than 1% Time'); - expect(veryLowFilter().textContent).to.contain('<54 mg/dL'); - expect(veryLowFilter().querySelector('input').checked).to.be.false; - - const lowFilter = () => document.querySelector('#time-in-range-filter-anyLow'); - expect(lowFilter()).to.exist; - expect(lowFilter().textContent).to.contain('Greater than 4% Time'); - expect(lowFilter().textContent).to.contain('<70 mg/dL'); - expect(lowFilter().querySelector('input').checked).to.be.false; - - const targetFilter = () => document.querySelector('#time-in-range-filter-target'); - expect(targetFilter()).to.exist; - expect(targetFilter().textContent).to.contain('Less than 70% Time'); - expect(targetFilter().textContent).to.contain('between 70-180 mg/dL'); - expect(targetFilter().querySelector('input').checked).to.be.false; - - const highFilter = () => document.querySelector('#time-in-range-filter-anyHigh'); - expect(highFilter()).to.exist; - expect(highFilter().textContent).to.contain('Greater than 25% Time'); - expect(highFilter().textContent).to.contain('>180 mg/dL'); - expect(highFilter().querySelector('input').checked).to.be.false; - - const veryHighFilter = () => document.querySelector('#time-in-range-filter-veryHigh'); - expect(veryHighFilter()).to.exist; - expect(veryHighFilter().textContent).to.contain('Greater than 5% Time'); - expect(veryHighFilter().textContent).to.contain('>250 mg/dL'); - expect(veryHighFilter().querySelector('input').checked).to.be.false; - - // Select all filter ranges - fireEvent.click(veryLowFilter().querySelector('input')); - expect(veryLowFilter().querySelector('input').checked).to.be.true; - - fireEvent.click(lowFilter().querySelector('input')); - expect(lowFilter().querySelector('input').checked).to.be.true; - - fireEvent.click(targetFilter().querySelector('input')); - expect(targetFilter().querySelector('input').checked).to.be.true; - - fireEvent.click(highFilter().querySelector('input')); - expect(highFilter().querySelector('input').checked).to.be.true; - - fireEvent.click(veryHighFilter().querySelector('input')); - expect(veryHighFilter().querySelector('input').checked).to.be.true; - - // Submit the form - defaultProps.api.clinics.getPatientsForClinic.resetHistory(); - const applyButton = document.querySelector('#timeInRangeFilterConfirm'); - fireEvent.click(applyButton); - - sinon.assert.calledWith(defaultProps.api.clinics.getPatientsForClinic, 'clinicID123', sinon.match({ - ...defaultFetchOptions, - sort: '-lastData', - 'cgm.timeInAnyHighPercent': '>=0.25', - 'cgm.timeInAnyLowPercent': '>=0.04', - 'cgm.timeInTargetPercent': '<=0.7', - 'cgm.timeInVeryHighPercent': '>=0.05', - 'cgm.timeInVeryLowPercent': '>=0.01', - omitNonStandardRanges: true, - })); - - sinon.assert.calledWith(defaultProps.trackMetric, 'Clinic - Population Health - Time in range apply filter', sinon.match({ - clinicId: 'clinicID123', - hyper: true, - hypo: true, - inRange: true, - meetsCriteria: true, - severeHyper: true, - severeHypo: true - })); - - await waitFor(() => { - expect(timeInRangeFilterCount()).to.exist; - expect(timeInRangeFilterCount().textContent).to.equal('5'); - }); - }); - context('summary period filtering', () => { - let mockedLocalStorage; - - beforeEach(() => { - mockedLocalStorage = { - 'activePatientFilters/clinicianUserId123/clinicID123': { - timeInRange: [ - 'timeInAnyLowPercent', - 'timeInAnyHighPercent' - ], - patientTags: [], - meetsGlycemicTargets: false, - }, - activePatientSummaryPeriod: '14d', - }; + const emptyStatText = '--'; + const rowData = row => container.querySelectorAll('table tbody tr')[row].querySelectorAll('.MuiTableCell-root'); - mockUseLocalStorage.mockImplementation(key => { - defaults(mockedLocalStorage, { [key]: {} }) - return [ - mockedLocalStorage[key], - sinon.stub().callsFake(val => mockedLocalStorage[key] = val) - ]; + const mountWithSummaryPeriod = period => { + mockUseLocalStorage.mockImplementation((key, fallback = {}) => { + return [key === 'activePatientSummaryPeriod' ? period : fallback, sinon.stub()]; }); - mockUseClinicPatientsFilters.mockImplementation(() => ( - [ - { - timeInRange: ['timeInAnyLowPercent', 'timeInAnyHighPercent'], - patientTags: [], - meetsGlycemicTargets: false, - }, - sinon.stub(), - ] - )); - mountWrapper(store); - }); + }; - it('should show the Filter Reset Bar', () => { - const filterResetBar = container.querySelector('.filter-reset-bar'); - expect(filterResetBar).to.exist; + it('should show the GMI when the selected period is 14 days', () => { + mountWithSummaryPeriod('14d'); + expect(rowData(2)[4].textContent).to.contain('6.5 %'); }); - it('should allow filtering by summary period', () => { - const summaryPeriodFilterTrigger = container.querySelector('#summary-period-filter-trigger'); - expect(summaryPeriodFilterTrigger).to.exist; - - const popover = () => document.querySelector('#summaryPeriodFilters'); - expect(popover()).to.exist; - expect(popover().style.visibility).to.equal('hidden'); - - // Open filters popover - fireEvent.click(summaryPeriodFilterTrigger); - expect(popover().style.visibility).to.equal(''); - - // Ensure filter options present - const filterOptions = document.querySelectorAll('#summary-period-filters label'); - expect(filterOptions.length).to.equal(4); - expect(filterOptions[0].textContent).to.equal('24 hours'); - expect(filterOptions[0].querySelector('input').value).to.equal('1d'); - - expect(filterOptions[1].textContent).to.equal('7 days'); - expect(filterOptions[1].querySelector('input').value).to.equal('7d'); - - expect(filterOptions[2].textContent).to.equal('14 days'); - expect(filterOptions[2].querySelector('input').value).to.equal('14d'); - - expect(filterOptions[3].textContent).to.equal('30 days'); - expect(filterOptions[3].querySelector('input').value).to.equal('30d'); - - // Default should be 14 days - expect(filterOptions[2].querySelector('input').checked).to.be.true; - - // Set to 7 days - fireEvent.click(filterOptions[1].querySelector('input')); - - defaultProps.api.clinics.getPatientsForClinic.resetHistory(); - const applyButton = document.querySelector('#apply-summary-period-filter'); - fireEvent.click(applyButton); - - // Ensure resulting patient fetch is requesting the 7 day period for time in range filters - sinon.assert.calledWith(defaultProps.api.clinics.getPatientsForClinic, 'clinicID123', sinon.match({ - ...defaultFetchOptions, - sort: '-lastData', - period: '7d', - 'cgm.timeInAnyHighPercent': '>0.25', - 'cgm.timeInAnyLowPercent': '>0.04', - })); - - sinon.assert.calledWith(defaultProps.trackMetric, 'Clinic - Population Health - Summary period apply filter', sinon.match({ clinicId: 'clinicID123', summaryPeriod: '7d' })); + it('should show the GMI when the selected period is 30 days', () => { + mountWithSummaryPeriod('30d'); + expect(rowData(2)[4].textContent).to.contain('7.5 %'); }); - it('should not show the GMI if selected period is less than 14 days', () => { - const emptyStatText = '--'; - const summaryPeriodFilterTrigger = container.querySelector('#summary-period-filter-trigger'); - expect(summaryPeriodFilterTrigger).to.exist; - - const popover = () => document.querySelector('#summaryPeriodFilters'); - expect(popover()).to.exist; - expect(popover().style.visibility).to.equal('hidden'); - - const applyButton = () => document.querySelector('#apply-summary-period-filter'); - - // Open filters popover - fireEvent.click(summaryPeriodFilterTrigger); - expect(popover().style.visibility).to.equal(''); - - // Ensure filter options present - const filterOptions = () => document.querySelectorAll('#summary-period-filters label'); - - // Default should be 14 days - expect(filterOptions()[2].querySelector('input').checked).to.be.true; - - const dataRows = container.querySelectorAll('table tbody tr'); - expect(dataRows.length).to.equal(5); - - const rowData = row => container.querySelectorAll('table tbody tr')[row].querySelectorAll('.MuiTableCell-root'); - - expect(rowData(2)[4].textContent).to.contain('6.5 %'); // shows for 14 days - - // Open filters popover and set to 30 days - fireEvent.click(summaryPeriodFilterTrigger); - fireEvent.click(filterOptions()[3].querySelector('input')); - expect(filterOptions()[3].querySelector('input').checked).to.be.true; - fireEvent.click(applyButton()); - expect(rowData(2)[4].textContent).to.contain('7.5 %'); // shows for 30 days - - // Open filters popover and set to 7 days - fireEvent.click(summaryPeriodFilterTrigger); - fireEvent.click(filterOptions()[1].querySelector('input')); - expect(filterOptions()[1].querySelector('input').checked).to.be.true; - fireEvent.click(applyButton()); - expect(rowData(2)[4].textContent).to.contain(emptyStatText); // hidden for 7 days + it('should not show the GMI when the selected period is 7 days', () => { + mountWithSummaryPeriod('7d'); + expect(rowData(2)[4].textContent).to.contain(emptyStatText); + }); - // Open filters popover and set to 1 day - fireEvent.click(summaryPeriodFilterTrigger); - fireEvent.click(filterOptions()[0].querySelector('input')); - expect(filterOptions()[0].querySelector('input').checked).to.be.true; - fireEvent.click(applyButton()); - expect(rowData(2)[4].textContent).to.contain(emptyStatText); // hidden for 1 day + it('should not show the GMI when the selected period is 1 day', () => { + mountWithSummaryPeriod('1d'); + expect(rowData(2)[4].textContent).to.contain(emptyStatText); }); }); @@ -2010,11 +1701,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'); @@ -2039,37 +1725,6 @@ describe('ClinicPatients', () => { expect(tag3Filter.checked).to.be.false; }); - it('should set the time in range filters on load based on the stored filters', () => { - const timeInRangeFilterTrigger = container.querySelector('#time-in-range-filter-trigger'); - - // Should show 2 active time in range filters - const timeInRangeFilterCount = () => container.querySelector('#time-in-range-filter-count'); - expect(timeInRangeFilterCount()).to.exist; - expect(timeInRangeFilterCount().textContent).to.equal('2'); - - // Open time in range filters dialog - fireEvent.click(timeInRangeFilterTrigger); - - const popover = () => document.querySelector('#timeInRangeFilters'); - expect(popover()).to.exist; - - // Ensure filter options in pre-set state - const veryLowFilter = () => document.querySelector('#time-in-range-filter-veryLow'); - expect(veryLowFilter().querySelector('input').checked).to.be.false; - - const lowFilter = () => document.querySelector('#time-in-range-filter-anyLow'); - expect(lowFilter().querySelector('input').checked).to.be.true; - - const targetFilter = () => document.querySelector('#time-in-range-filter-target'); - expect(targetFilter().querySelector('input').checked).to.be.false; - - const highFilter = () => document.querySelector('#time-in-range-filter-anyHigh'); - expect(highFilter().querySelector('input').checked).to.be.true; - - const veryHighFilter = () => document.querySelector('#time-in-range-filter-veryHigh'); - expect(veryHighFilter().querySelector('input').checked).to.be.false; - }); - it('should fetch the initial patient based on the stored filters', () => { sinon.assert.calledWith(defaultProps.api.clinics.getPatientsForClinic, 'clinicID123', sinon.match({ ...defaultFetchOptions, @@ -2162,273 +1817,8 @@ describe('ClinicPatients', () => { expect(rowData(2)[8].textContent).to.contain('11.5'); expect(rowData(3)[8].textContent).to.contain('12.5'); }); - - it('should show the bg range filters in mmol/L units', () => { - const timeInRangeFilterTrigger = container.querySelector('#time-in-range-filter-trigger'); - - const popover = () => document.querySelector('#timeInRangeFilters'); - - // Open filters popover - fireEvent.click(timeInRangeFilterTrigger); - - // Ensure filter options present and in default unchecked state - const veryLowFilter = () => document.querySelector('#time-in-range-filter-veryLow'); - expect(veryLowFilter()).to.exist; - expect(veryLowFilter().textContent).to.contain('Greater than 1% Time'); - expect(veryLowFilter().textContent).to.contain('<3.0 mmol/L'); - expect(veryLowFilter().querySelector('input').checked).to.be.false; - - const lowFilter = () => document.querySelector('#time-in-range-filter-anyLow'); - expect(lowFilter()).to.exist; - expect(lowFilter().textContent).to.contain('Greater than 4% Time'); - expect(lowFilter().textContent).to.contain('<3.9 mmol/L'); - expect(lowFilter().querySelector('input').checked).to.be.false; - - const targetFilter = () => document.querySelector('#time-in-range-filter-target'); - expect(targetFilter()).to.exist; - expect(targetFilter().textContent).to.contain('Less than 70% Time'); - expect(targetFilter().textContent).to.contain('between 3.9-10.0 mmol/L'); - expect(targetFilter().querySelector('input').checked).to.be.false; - - const highFilter = () => document.querySelector('#time-in-range-filter-anyHigh'); - expect(highFilter()).to.exist; - expect(highFilter().textContent).to.contain('Greater than 25% Time'); - expect(highFilter().textContent).to.contain('>10.0 mmol/L'); - expect(highFilter().querySelector('input').checked).to.be.false; - - const veryHighFilter = () => document.querySelector('#time-in-range-filter-veryHigh'); - expect(veryHighFilter()).to.exist; - expect(veryHighFilter().textContent).to.contain('Greater than 5% Time'); - expect(veryHighFilter().textContent).to.contain('>13.9 mmol/L'); - expect(veryHighFilter().querySelector('input').checked).to.be.false; - }); }); - 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; - - const timeInRangeFilterCount = () => container.querySelector('#time-in-range-filter-count'); - expect(timeInRangeFilterCount()).to.be.null; - - // Reset Filters button only shows when filters are active - const resetAllFiltersButton = () => container.querySelector('#reset-all-active-filters'); - expect(resetAllFiltersButton()).to.be.null; - - // Open time in range popover - 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; - - // Close popover without applying filter - defaultProps.api.clinics.getPatientsForClinic.resetHistory(); - expect(document.querySelector('#timeInRangeFilters')).to.exist; - const closeButton = document.querySelector('#timeInRangeFilters button[aria-label="close dialog"]'); - fireEvent.click(closeButton); - - // Re-open popover - fireEvent.click(timeInRangeFilterTrigger); - - // Verify that options are not still checked - expect(veryLowFilter().querySelector('input').checked).to.be.false; - expect(lowFilter().querySelector('input').checked).to.be.false; - expect(highFilter().querySelector('input').checked).to.be.false; - - // Total filter count and time in range filter count should be unset - expect(filterCount()).to.be.null; - expect(timeInRangeFilterCount()).to.be.null; - expect(resetAllFiltersButton()).to.be.null; - }, 30000); - it('should send an upload reminder to a fully claimed patient account', async () => { const dataRows = container.querySelectorAll('table tbody tr'); expect(dataRows.length).to.equal(5);