diff --git a/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js b/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js index 4b01fab9f4..2f495b8929 100644 --- a/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/ClinicPatients.test.js @@ -382,103 +382,20 @@ 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(); - - await userEvent.click(site1checkbox); - await userEvent.click(site2checkbox); - - expect(site1checkbox).toBeChecked(); - expect(site2checkbox).toBeChecked(); - - // Click Apply - await userEvent.click(screen.getByRole('button', { name: /Apply/ })); - - expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenCalledWith( - 'clinicID123', - { sites: ['site-1-id', 'site-2-id'], limit: 50, offset: 0, period: '14d', 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 () => { - 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'); - 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.getByRole('button', { name: /Apply/ })); - - expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenCalledWith( - 'clinicID123', - { sites: ['_'], limit: 50, offset: 0, period: '14d', sortType: 'cgm', sort: '-lastData' }, - expect.any(Function), - ); - }, TEST_TIMEOUT_MS); - - it('should allow filtering by tags', async () => { - render( - - - - ); - - // Open the Tags filter dropdown and filter for 2 sites + // Open the Tags filter dropdown, select 2 tags, and apply 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 + 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/ })); expect(defaultProps.api.clinics.getPatientsForClinic).toHaveBeenLastCalledWith( @@ -486,42 +403,24 @@ describe('ClinicPatients', () => { { tags: ['tag1', 'tag3'], 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 site 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/ })); - - 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(); - - // 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(); - - // Click Apply. A query of `['_']` should be made for sites. + // Open the Sites filter dropdown, select 2 sites, and apply + await userEvent.click(screen.getByRole('button', { name: /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', - { tags: ['_'], 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); 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/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/components/SiteFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/SiteFilterDropdown.test.js new file mode 100644 index 0000000000..94ceceb97b --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/SiteFilterDropdown.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 { 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 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; + let wrapper; + + 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' }, + ]; + + const setActiveFilters = jest.fn(); + const setShowClinicPatientTagsDialog = jest.fn(); + + 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 site 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/TagFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/TagFilterDropdown.test.js new file mode 100644 index 0000000000..3597e00582 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/components/TagFilterDropdown.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 { 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 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; + let wrapper; + + const selectedClinicId = 'clinic123'; + + const patientTagDefs = [ + { id: 'tag1', name: 'Week 1' }, + { id: 'tag2', name: 'Week 2' }, + { id: 'tag3', name: 'Week 3' }, + { id: 'tag4', name: 'Pregnancy' }, + ]; + + const setActiveFilters = jest.fn(); + const setShowClinicPatientTagsDialog = jest.fn(); + + 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/app/core/icons/tagIcon.svg b/app/core/icons/tagIcon.svg index 5fdf830e3f..eb0439f030 100644 --- a/app/core/icons/tagIcon.svg +++ b/app/core/icons/tagIcon.svg @@ -1,3 +1 @@ - - - \ No newline at end of file + \ No newline at end of file diff --git a/app/core/metricUtils.js b/app/core/metricUtils.js index 6780c9a019..cae3681cde 100644 --- a/app/core/metricUtils.js +++ b/app/core/metricUtils.js @@ -1,3 +1,4 @@ import appContext from '../bootstrap'; +import { useLocation } from 'react-router-dom'; export const trackMetric = (...args) => appContext.trackMetric(...args); diff --git a/app/pages/clinicworkspace/ClinicPatients.js b/app/pages/clinicworkspace/ClinicPatients.js index 818cc37417..741c112ae5 100644 --- a/app/pages/clinicworkspace/ClinicPatients.js +++ b/app/pages/clinicworkspace/ClinicPatients.js @@ -120,8 +120,11 @@ import Banner from '../../components/elements/Banner'; import colorPalette from '../../themes/colorPalette'; import noop from 'lodash/noop'; import { getGlycemicRangesPreset } from '../../core/glycemicRangesUtils'; +import FilterByTags from './clinicPatientsFilters/FilterByTags'; +import FilterBySites from './clinicPatientsFilters/FilterBySites'; import ClinicPatientsPrintModal from './ClinicPatientsPrintModal'; import AppliedFiltersList, { getPatientQueryState } from './clinicPatientsFilters/AppliedFiltersList'; +import useIsClinicAdmin from './useIsClinicAdmin'; const { Loader } = vizComponents; const { reshapeBgClassesToBgBounds, generateBgRangeLabels, formatBgValue } = vizUtils.bg; @@ -593,7 +596,7 @@ export const ClinicPatients = (props) => { const mrnSettings = useMemo(() => clinic?.mrnSettings ?? {}, [clinic?.mrnSettings]); const timePrefs = useSelector((state) => state.blip.timePrefs); const rpmReportPatients = useSelector(state => state.blip.rpmReportPatients); - const isClinicAdmin = includes(get(clinic, ['clinicians', loggedInUserId, 'roles'], []), 'CLINIC_ADMIN'); + const isClinicAdmin = useIsClinicAdmin(); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteClinicSiteDialog, setShowDeleteClinicSiteDialog] = useState(false); const [showUpdateClinicSiteDialog, setShowUpdateClinicSiteDialog] = useState(false); @@ -736,16 +739,6 @@ export const ClinicPatients = (props) => { popupId: 'lastDataFilters', }); - const clinicSitesPopupFilterState = usePopupState({ - variant: 'popover', - popupId: 'clinicSitesFilters', - }); - - const patientTagsPopupFilterState = usePopupState({ - variant: 'popover', - popupId: 'patientTagFilters', - }); - const timeInRangePopupFilterState = usePopupState({ variant: 'popover', popupId: 'timeInRangeFilters', @@ -1525,9 +1518,6 @@ export const ClinicPatients = (props) => { }, [api, dispatch, selectedClinicId, selectedPatient?.id, trackMetric]); const renderHeader = () => { - const sortedSiteFilterOptions = clinicSitesFilterOptions?.toSorted((a, b) => utils.compareLabels(a.label, b.label)) || []; - const sortedTagFilterOptions = patientTagsFilterOptions?.toSorted((a, b) => utils.compareLabels(a.label, b.label)) || []; - const VisibilityIcon = isPatientListVisible ? VisibilityOffOutlinedIcon : VisibilityOutlinedIcon; const hoursAgo = Math.floor(patientFetchMinutesAgo / 60); let timeAgoUnits = hoursAgo < 2 ? t('hour') : t('hours'); @@ -1535,12 +1525,6 @@ export const ClinicPatients = (props) => { if (hoursAgo >= 24) timeAgo = t('over 24'); const timeAgoMessage = t('Last updated {{timeAgo}} {{timeAgoUnits}} ago', { timeAgo, timeAgoUnits }); - // Filtering for patients "zero sites/tags" is different than not filtering. If we don't pass any filters - // to backend, we receive a list of PwDs with zero or many sites/tags. We need to explicitly filter for - // PwDs with exactly zero sites/tags. - const isFilteringForZeroSites = isEqual(pendingFilters?.clinicSites, SPECIAL_FILTER_STATES.ZERO_SITES); - const isFilteringForZeroTags = isEqual(pendingFilters?.patientTags, SPECIAL_FILTER_STATES.ZERO_TAGS); - return ( <> @@ -1811,373 +1795,19 @@ export const ClinicPatients = (props) => { - { - 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('Clinic 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 && - - - - - } - + { diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js new file mode 100644 index 0000000000..f62c6479d4 --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import * as actions from '../../../redux/actions'; +import { trackMetric } from '../../../core/metricUtils'; +import get from 'lodash/get'; +import includes from 'lodash/includes'; +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 ( + + ); +}; + +export default FilterBySites; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js new file mode 100644 index 0000000000..23f705134e --- /dev/null +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import * as actions from '../../../redux/actions'; +import { trackMetric } from '../../../core/metricUtils'; +import get from 'lodash/get'; +import includes from 'lodash/includes'; +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 ( + + ); +}; + +export default FilterByTags; diff --git a/app/pages/clinicworkspace/components/SiteFilterDropdown.js b/app/pages/clinicworkspace/components/SiteFilterDropdown.js new file mode 100644 index 0000000000..11fd7edb40 --- /dev/null +++ b/app/pages/clinicworkspace/components/SiteFilterDropdown.js @@ -0,0 +1,301 @@ +import React, { useMemo, useState } from 'react'; +import { useSelector, useDispatch } 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 InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import CloseRoundedIcon from '@material-ui/icons/CloseRounded'; +import SearchIcon from '@material-ui/icons/Search'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; +import { components as vizComponents, utils as vizUtils, colors as vizColors } from '@tidepool/viz'; +import { useFlags, useLDClient } from 'launchdarkly-react-client-sdk'; +import { Link as RouterLink } from 'react-router-dom'; +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, colors } from '../../../themes/baseTheme'; +import { DialogContent, DialogActions } from '../../../components/elements/Dialog'; + +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 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; + + return ( + + + + {t('Clinic Sites')} + + + {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]); + } + }} + /> + + )) + } + + + { // 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 && + + + {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 or update your permissions from  + Workspace Settings. + + + } + + } + + + { 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 && + + } + + + ); +}; + +export default SiteFilterDropdown; diff --git a/app/pages/clinicworkspace/components/TagFilterDropdown.js b/app/pages/clinicworkspace/components/TagFilterDropdown.js new file mode 100644 index 0000000000..ba5d98e06d --- /dev/null +++ b/app/pages/clinicworkspace/components/TagFilterDropdown.js @@ -0,0 +1,304 @@ +import React, { useMemo, useState } from 'react'; +import { useSelector, useDispatch } 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 InfoOutlinedIcon from '@material-ui/icons/InfoOutlined'; +import CloseRoundedIcon from '@material-ui/icons/CloseRounded'; +import SearchIcon from '@material-ui/icons/Search'; +import KeyboardArrowDownRoundedIcon from '@material-ui/icons/KeyboardArrowDownRounded'; +import { components as vizComponents, utils as vizUtils, colors as vizColors } from '@tidepool/viz'; +import { useFlags, useLDClient } from 'launchdarkly-react-client-sdk'; +import { Link as RouterLink } from 'react-router-dom'; +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, colors } from '../../../themes/baseTheme'; +import { DialogContent, DialogActions } from '../../../components/elements/Dialog'; + +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 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; + + return ( + + + + {t('Tags')} + + + {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]); + } + }} + /> + + )) + } + + + { // 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 && + + + {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 to create tags or update your permissions from  + Workspace Settings. + + + } + + } + + + { 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 && + + } + + + ); +}; + +export default TagFilterDropdown; diff --git a/app/pages/clinicworkspace/useClinicMetricsPageName.js b/app/pages/clinicworkspace/useClinicMetricsPageName.js new file mode 100644 index 0000000000..9ff36de14e --- /dev/null +++ b/app/pages/clinicworkspace/useClinicMetricsPageName.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { useLocation } from 'react-router-dom'; + +const useClinicMetricsPageName = () => { + const { pathname } = useLocation(); + + switch (pathname) { + case '/clinic-workspace': + case '/clinic-workspace/patients': + return 'Population Health'; + + default: + return 'Unknown'; + }; +}; + +export default useClinicMetricsPageName; 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;