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;