diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.test.js index 78afacb932..39514ce732 100644 --- a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.test.js @@ -1,7 +1,8 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; import { http, HttpResponse } from 'msw'; import { setupServer } from 'msw/node'; import isEqual from 'lodash/isEqual'; @@ -11,7 +12,6 @@ import { setupStore } from '@tests/utils/setupStore'; import blipReducer from '@app/redux/reducers'; import { CATEGORY } from '@app/pages/clinicworkspace/TideDashboardV2/tideDashboardSlice'; import TideDashboardV2 from '@app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2'; -import { MemoryRouter } from 'react-router-dom'; // Pin the data recency window to a stable [lastDataFrom, lastDataTo] jest.mock('@app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints', () => ({ @@ -119,9 +119,18 @@ const anticipatedQueries = { 'cgm.timeInVeryHighPercent': '<0.045', 'cgm.timeCGMUsePercent': '>=0.695', }, + 'DEFAULT_WITH_FILTERS': { + offset: '0', + limit: '12', + period: '30d', + 'cgm.lastDataFrom': '2025-05-23T00:00:00.000Z', + 'cgm.lastDataTo': '2025-05-30T00:00:00.000Z', + tags: 'tag8', + sites: 'site9', + }, }; -const patientsForCategory = { +const datasets = { [DEFAULT]: [ { id: 'default-1', fullName: 'Default Patient 1', birthDate: '2001-01-01' }, { id: 'default-2', fullName: 'Default Patient 2', birthDate: '2002-02-02' }, @@ -154,22 +163,25 @@ const patientsForCategory = { { id: 'target-1', fullName: 'Meeting Targets Patient 1', birthDate: '2015-03-15' }, { id: 'target-2', fullName: 'Meeting Targets Patient 2', birthDate: '2016-04-16' }, ], + 'DEFAULT_WITH_FILTERS': [ + { id: 'filtered-3', fullName: 'Filtered Patient 3', birthDate: '2001-01-01' }, + ], }; const getCorrespondingDataForQuery = (searchParams) => { - let datasetName; + let matchedDatasetName; // Look over anticipated queries. If the query matches, return the dataset - for (let [category, anticipatedQuery] of entries(anticipatedQueries)) { + for (let [datasetName, anticipatedQuery] of entries(anticipatedQueries)) { if (isEqual(searchParams, anticipatedQuery)) { - datasetName = category; + matchedDatasetName = datasetName; break; } } - if (!datasetName) throw new Error('No data for this query found'); + if (!matchedDatasetName) throw new Error('No data for this query found'); - return patientsForCategory[datasetName]; + return datasets[matchedDatasetName]; }; const server = setupServer( @@ -263,4 +275,64 @@ describe('TideDashboardV2', () => { expect(screen.getByText('Meeting Targets Patient 2')).toBeInTheDocument(); expect(screen.queryByText('Low CGM Wear Patient 1')).not.toBeInTheDocument(); }, TEST_TIMEOUT_MS); + + it('fetches with filters', async () => { + store = setupStore({ + blip: { + selectedClinicId: 'clinic123', + clinics: { clinic123: { id: 'clinic123', patientTags: [{ id: 'tag8', name: 'Tag 8' }], sites: [{ id: 'site9', name: 'Site 9' }] } }, + tideDashboardFilters: { lastData: 7, patientTags: ['tag8'], clinicSites: ['site9'], summaryPeriod: '30d' }, + }, + }, { blip: blipReducer }); + + renderComponent(); + + expect(await screen.findByText('Filtered Patient 3')).toBeInTheDocument(); + }, TEST_TIMEOUT_MS); + + describe('empty content', () => { + beforeEach(() => { + let fetchCount = 0; + + server.use( + http.get('http://app.tidepool.test/v1/clinics/clinic123/patients', () => { + fetchCount += 1; + + return fetchCount <= 1 + // first fetch -> no patients + ? HttpResponse.json({ data: [], meta: { count: 0 } }) + // refetch -> 1 patient + : HttpResponse.json({ data: [{ id: 'default-1', fullName: 'Default Patient 1', birthDate: '2001-01-01' }], meta: { count: 1 } }); + } + ) + ); + }); + + it('shows Reset button when no patients match applied filters', async () => { + // A tag filter is applied before the dashboard mounts + store = setupStore({ + blip: { + selectedClinicId: 'clinic123', + clinics: { clinic123: { id: 'clinic123', patientTags: [{ id: 'tag1', name: 'Tag One' }] } }, + tideDashboardFilters: { lastData: 7, patientTags: ['tag1'], clinicSites: [], summaryPeriod: '14d' }, + }, + }, { blip: blipReducer }); + + renderComponent(); + + const emptyContent = await screen.findByTestId('tide-dashboard-empty-content'); + expect(within(emptyContent).getByText('There are no patients with the current filter(s)')).toBeInTheDocument(); + await userEvent.click(within(emptyContent).getByRole('button', { name: 'Reset All Filters' })); + + expect(await screen.findByText('Default Patient 1')).toBeInTheDocument(); + }); + + it('shows an empty message without a reset button when there are no patients and no filters applied', async () => { + renderComponent(); + + const emptyContent = await screen.findByTestId('tide-dashboard-empty-content'); + expect(within(emptyContent).getByText('There are no results to show')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Reset All Filters' })).not.toBeInTheDocument(); // no filters = no button + }); + }); }); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByDataRecency.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByDataRecency.test.js new file mode 100644 index 0000000000..957d47e337 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByDataRecency.test.js @@ -0,0 +1,64 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; + +import FilterByDataRecency from '@app/pages/clinicworkspace/TideDashboardV2/filters/FilterByDataRecency'; + +const mockStore = configureStore([thunk]); + +describe('FilterByDataRecency', () => { + let store; + + const renderComponent = () => render( + + + + + + ); + + it('dispatches the lastData filter and offset reset when data recency filter is applied', async () => { + store = mockStore({ + blip: { + selectedClinicId: 'clinic123', + tideDashboardFilters: { + lastData: 7, + patientTags: [], + clinicSites: [], + summaryPeriod: '14d', + }, + }, + }); + + renderComponent(); + + // Open the dropdown + expect(screen.queryByTestId('data-recency-filter-dropdown')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Data Recency/ })); + expect(screen.getByTestId('data-recency-filter-dropdown')).toBeInTheDocument(); + + expect(screen.queryByRole('radio', { name: /CGM/ })).not.toBeInTheDocument(); // not selectable in TIDE + expect(screen.queryByRole('radio', { name: /BGM/ })).not.toBeInTheDocument(); // not selectable in TIDE + + expect(screen.getByRole('radio', { name: /Today/ })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /Within 2 days/ })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /Within 7 days/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /Within 14 days/ })).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /Within 30 days/ })).not.toBeInTheDocument(); + + // Select an option + await userEvent.click(screen.getByRole('radio', { name: /Within 2 days/ })); + expect(store.getActions()).toStrictEqual([]); + + // Applying the filter dispatches the new filter value and resets the page offset + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboardFilters/setLastDataFilter', payload: 2 }, + { type: 'tideDashboard/setOffset', payload: 0 }, + ]); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySites.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySites.test.js new file mode 100644 index 0000000000..8ef018fac4 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySites.test.js @@ -0,0 +1,61 @@ +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 FilterBySites from '@app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySites'; + +const mockStore = configureStore([thunk]); + +describe('FilterBySites', () => { + let store; + + const renderComponent = () => render( + + + + + + ); + + it('dispatches the clinic sites filter and an offset reset when a site filter is applied', async () => { + store = mockStore({ + blip: { + selectedClinicId: 'clinic123', + clinics: { + clinic123: { + id: 'clinic123', + sites: [{ id: 'site1', name: 'Site Alpha' }, { id: 'site2', name: 'Site Bravo' }], + }, + }, + tideDashboardFilters: { + lastData: 7, + patientTags: [], + clinicSites: ['site1'], + summaryPeriod: '14d', + }, + }, + }); + + renderComponent(); + + // Open the dropdown + expect(screen.queryByTestId('site-filter-dropdown')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Clinic Sites/ })); + expect(screen.getByTestId('site-filter-dropdown')).toBeInTheDocument(); + + // Selecting a site + await userEvent.click(screen.getByRole('checkbox', { name: /Site Bravo/ })); + expect(store.getActions()).toStrictEqual([]); + + // Applying the filter dispatches the selected sites and resets the page offset + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboardFilters/setClinicSitesFilter', payload: ['site1', 'site2'] }, + { type: 'tideDashboard/setOffset', payload: 0 }, + ]); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySummaryPeriod.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySummaryPeriod.test.js new file mode 100644 index 0000000000..cd052c96d5 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySummaryPeriod.test.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; +import { MemoryRouter } from 'react-router-dom'; + +import FilterBySummaryPeriod from '@app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySummaryPeriod'; + +const mockStore = configureStore([thunk]); + +describe('FilterBySummaryPeriod', () => { + let store; + + const renderComponent = () => render( + + + + + + ); + + it('dispatches the summary period filter and an offset reset when a summary period is applied', async () => { + store = mockStore({ + blip: { + selectedClinicId: 'clinic123', + tideDashboardFilters: { + lastData: 7, + patientTags: [], + clinicSites: [], + summaryPeriod: '14d' }, + }, + }); + + renderComponent(); + + // Open the dropdown + expect(screen.queryByTestId('summary-period-filter-dropdown')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Summarizing 14 days of data/ })); + + expect(screen.getByTestId('summary-period-filter-dropdown')).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /24 hours/ })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /7 days/ })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /14 days/ })).toBeChecked(); + expect(screen.getByRole('radio', { name: /30 days/ })).toBeInTheDocument(); + + // Selecting a period + await userEvent.click(screen.getByRole('radio', { name: /30 days/ })); + expect(store.getActions()).toStrictEqual([]); + + // Applying the filter dispatches the new period and resets the page offset + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboardFilters/setSummaryPeriodFilter', payload: '30d' }, + { type: 'tideDashboard/setOffset', payload: 0 }, + ]); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByTags.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByTags.test.js new file mode 100644 index 0000000000..ac0e8ac3a7 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByTags.test.js @@ -0,0 +1,64 @@ +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 FilterByTags from '@app/pages/clinicworkspace/TideDashboardV2/filters/FilterByTags'; + +const mockStore = configureStore([thunk]); + +describe('FilterByTags', () => { + let store; + + const renderComponent = () => render( + + + + + + ); + + it('dispatches the patient tags filter and an offset reset when a tag filter is applied', async () => { + store = mockStore({ + blip: { + selectedClinicId: 'clinic123', + clinics: { + clinic123: { + id: 'clinic123', + patientTags: [ + { id: 'tag1', name: 'Week 1' }, + { id: 'tag2', name: 'Week 2' }, + ], + }, + }, + tideDashboardFilters: { + lastData: 7, + patientTags: [], + clinicSites: [], + summaryPeriod: '14d', + }, + }, + }); + + renderComponent(); + + // Open the dropdown + expect(screen.queryByTestId('tag-filter-dropdown')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Tags/ })); + expect(screen.getByTestId('tag-filter-dropdown')).toBeInTheDocument(); + + // Selecting a tag + await userEvent.click(screen.getByRole('checkbox', { name: /Week 2/ })); + expect(store.getActions()).toStrictEqual([]); + + // Applying the filter dispatches the selected tags and resets the page offset + await userEvent.click(screen.getByRole('button', { name: /Apply/ })); + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboardFilters/setPatientTagsFilter', payload: ['tag2'] }, + { type: 'tideDashboard/setOffset', payload: 0 }, + ]); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice.test.js new file mode 100644 index 0000000000..7595e45e82 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice.test.js @@ -0,0 +1,41 @@ +/* global describe */ +/* global it */ +/* global expect */ + +import reducer from '@app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice'; + +describe('tideDashboardFiltersSlice', () => { + const defaultFilters = { lastData: 7, patientTags: [], clinicSites: [], summaryPeriod: '14d' }; + const appliedFilters = { lastData: 30, patientTags: ['tag1'], clinicSites: ['site1'], summaryPeriod: '30d' }; + + describe('SELECT_CLINIC_SUCCESS', () => { + it('should hydrate the filters carried on the clinic selection', () => { + const persistedFilters = { lastData: 14, patientTags: ['tag2'], clinicSites: [], summaryPeriod: '7d' }; + + const state = reducer(appliedFilters, { + type: 'SELECT_CLINIC_SUCCESS', + payload: { clinicId: 'clinic123', tideDashboardFilters: persistedFilters }, + }); + + expect(state).toStrictEqual(persistedFilters); + }); + + it('should reset to the default filters when the clinic selection carries none', () => { + // Selecting a clinic with nothing persisted + let state = reducer(appliedFilters, { + type: 'SELECT_CLINIC_SUCCESS', + payload: { clinicId: 'clinic123', tideDashboardFilters: undefined }, + }); + + expect(state).toStrictEqual(defaultFilters); + + // Unsetting the clinic + state = reducer(appliedFilters, { + type: 'SELECT_CLINIC_SUCCESS', + payload: { clinicId: null }, + }); + + expect(state).toStrictEqual(defaultFilters); + }); + }); +}); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints.test.js index 72e287c354..1866faa269 100644 --- a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints.test.js @@ -9,12 +9,19 @@ import useDerivedDataRecencyEndpoints from '@app/pages/clinicworkspace/TideDashb const mockStore = configureStore([thunk]); describe('useDerivedDataRecencyEndpoints', () => { - let store; + const renderEndpointsHook = (tideDashboardFilters = {}) => { + const store = mockStore({ + blip: { + timePrefs: { timezoneAware: false, timezoneName: null }, + tideDashboardFilters: tideDashboardFilters, + }, + }); - const renderEndpointsHook = (filters) => renderHook( - () => useDerivedDataRecencyEndpoints(filters), - { wrapper: ({ children }) => {children} } - ).result.current; + return renderHook( + () => useDerivedDataRecencyEndpoints(), + { wrapper: ({ children }) => {children} } + ).result.current; + }; beforeEach(() => { // Fake only Date so "now" is pinned; the localized ceiling of now is 2025-05-30T00:00:00.000Z @@ -29,8 +36,6 @@ describe('useDerivedDataRecencyEndpoints', () => { 'setTimeout', 'clearTimeout', ], }); - - store = mockStore({ blip: { timePrefs: { timezoneAware: false, timezoneName: null } } }); }); afterEach(() => { @@ -71,11 +76,4 @@ describe('useDerivedDataRecencyEndpoints', () => { '2025-05-30T00:00:00.000Z', // lastDataTo ]); }); - - it('defaults to the 7-day tide dashboard window when called without filters', () => { - expect(renderEndpointsHook()).toStrictEqual([ - '2025-05-23T00:00:00.000Z', // lastDataFrom - '2025-05-30T00:00:00.000Z', // lastDataTo - ]); - }); }); diff --git a/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/usePruneInvalidFilters.test.js b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/usePruneInvalidFilters.test.js new file mode 100644 index 0000000000..eaf20f6f0b --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/TideDashboardV2/usePruneInvalidFilters.test.js @@ -0,0 +1,106 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { renderHook } from '@testing-library/react-hooks/dom'; +import { thunk } from 'redux-thunk'; +import configureStore from 'redux-mock-store'; + +import usePruneInvalidFilters from '@app/pages/clinicworkspace/TideDashboardV2/usePruneInvalidFilters'; +import { SPECIAL_FILTER_STATES } from '@app/pages/clinicworkspace/useClinicPatientsFilters'; + +const mockStore = configureStore([thunk]); + +describe('usePruneInvalidFilters', () => { + let store; + + const clinic = { + id: 'clinic123', + patientTags: [ + { id: 'tag1', name: 'Week 1' }, + { id: 'tag2', name: 'Week 2' }, + // tag3 is deleted + ], + sites: [ + { id: 'site1', name: 'Site Alpha' }, + { id: 'site2', name: 'Site Bravo' }, + // site3 is deleted + ], + }; + + const renderPruneHook = ({ filterOverrides = {}, clinics = { clinic123: clinic } } = {}) => { + store = mockStore({ + blip: { + selectedClinicId: 'clinic123', + clinics, + tideDashboardFilters: { + lastData: 7, + patientTags: [], + clinicSites: [], + summaryPeriod: '14d', + ...filterOverrides, + }, + }, + }); + + renderHook(() => usePruneInvalidFilters(), { + wrapper: ({ children }) => {children}, + }); + }; + + it('prunes filtered tag ids that no longer exist in the clinic', () => { + renderPruneHook({ filterOverrides: { patientTags: ['tag1', 'tag3'] } }); + + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboardFilters/setPatientTagsFilter', payload: ['tag1'] }, + ]); + }); + + it('prunes filtered site ids that no longer exist in the clinic', () => { + renderPruneHook({ filterOverrides: { clinicSites: ['site3', 'site2'] } }); + + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboardFilters/setClinicSitesFilter', payload: ['site2'] }, + ]); + }); + + it('prunes invalid tags and sites in the same mount', () => { + renderPruneHook({ + filterOverrides: { + patientTags: ['tag3', 'tag1'], + clinicSites: ['site3', 'site2'], + }, + }); + + expect(store.getActions()).toStrictEqual([ + { type: 'tideDashboardFilters/setPatientTagsFilter', payload: ['tag1'] }, + { type: 'tideDashboardFilters/setClinicSitesFilter', payload: ['site2'] }, + ]); + }); + + it('dispatches nothing when all filtered tags and sites still exist in the clinic', () => { + renderPruneHook({ + filterOverrides: { + patientTags: ['tag1', 'tag2'], + clinicSites: ['site1', 'site2'], + }, + }); + + expect(store.getActions()).toStrictEqual([]); + }); + + it('dispatches nothing when no tag or site filters are set', () => { + renderPruneHook(); + + expect(store.getActions()).toStrictEqual([]); + }); + + it('preserves the special zero-tags and zero-sites filter states', () => { + renderPruneHook({ + filterOverrides: { + patientTags: SPECIAL_FILTER_STATES.ZERO_TAGS, + clinicSites: SPECIAL_FILTER_STATES.ZERO_SITES, + }, + }); + + expect(store.getActions()).toStrictEqual([]); + }); +}); diff --git a/test/unit/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.test.js b/__tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.test.js similarity index 100% rename from test/unit/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.test.js rename to __tests__/unit/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.test.js diff --git a/__tests__/unit/app/pages/clinicworkspace/components/ActiveFiltersTray.test.js b/__tests__/unit/app/pages/clinicworkspace/components/filters/ActiveFiltersTray.test.js similarity index 75% rename from __tests__/unit/app/pages/clinicworkspace/components/ActiveFiltersTray.test.js rename to __tests__/unit/app/pages/clinicworkspace/components/filters/ActiveFiltersTray.test.js index f0e378517f..7abd548b98 100644 --- a/__tests__/unit/app/pages/clinicworkspace/components/ActiveFiltersTray.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/components/filters/ActiveFiltersTray.test.js @@ -8,18 +8,17 @@ import { ThemeProvider } from 'theme-ui'; import '@app/core/language'; import theme from '@app/themes/baseTheme'; -import ActiveFiltersTray from '@app/pages/clinicworkspace/components/ActiveFiltersTray'; -import { defaultFilterState, SPECIAL_FILTER_STATES } from '@app/pages/clinicworkspace/useClinicPatientsFilters'; +import ActiveFiltersTray from '@app/pages/clinicworkspace/components/filters/ActiveFiltersTray'; +import { defaultFilterState } from '@app/pages/clinicworkspace/useClinicPatientsFilters'; const mockStore = configureStore([thunk]); -const buildState = ({ fetchedPatientCount = 5 } = {}) => ({ +const buildState = () => ({ blip: { selectedClinicId: 'clinic123', clinics: { 'clinic123': { id: 'clinic123', - fetchedPatientCount, patientTags: [ { id: 'tag1', name: 'Tag One' }, { id: 'tag2', name: 'Tag Two' }, @@ -35,37 +34,42 @@ const buildState = ({ fetchedPatientCount = 5 } = {}) => ({ const renderTray = ({ filters = defaultFilterState, + patientCount = 0, hasSearchActive = false, onRemoveFilter = jest.fn(), state = buildState(), } = {}) => { const store = mockStore(state); - const utils = render( + const ui = (props = {}) => ( ); - return { ...utils, onRemoveFilter }; + const utils = render(ui()); + + return { ...utils, ui, onRemoveFilter }; }; describe('ActiveFiltersTray', () => { describe('patient count header', () => { - it('renders the fetched patient count', () => { - renderTray({ state: buildState({ fetchedPatientCount: 5 }) }); + it('renders the patient count', () => { + renderTray({ patientCount: 5 }); expect(screen.getByText('Showing 5 patients')).toBeInTheDocument(); }); it('notes the count reflects the search when a search is active', () => { - renderTray({ hasSearchActive: true, state: buildState({ fetchedPatientCount: 5 }) }); + renderTray({ hasSearchActive: true, patientCount: 5 }); expect(screen.getByText('Showing 5 patients that match your search')).toBeInTheDocument(); }); @@ -110,6 +114,22 @@ describe('ActiveFiltersTray', () => { }); }); + describe('required filters', () => { + it('renders a required filter without its remove icon', () => { + const { ui, rerender } = renderTray({ + filters: { ...defaultFilterState, lastData: 14, lastDataType: 'cgm' }, + }); + + // Removable by default: the chip renders with its remove icon + expect(screen.getByLabelText('Remove CGM data within 14 days filter')).toBeInTheDocument(); + + // Marking the filter required keeps the chip but drops the remove icon + rerender(ui({ requiredFilters: { lastData: true } })); + expect(screen.getByText('CGM data within 14 days')).toBeInTheDocument(); + expect(screen.queryByLabelText('Remove CGM data within 14 days filter')).not.toBeInTheDocument(); + }); + }); + describe('removing a chip', () => { it('fires onRemoveFilter with the chip type and value when its remove icon is clicked', async () => { const { onRemoveFilter } = renderTray({ diff --git a/test/unit/pages/clinicworkspace/components/CGMUseFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/filters/CGMUseFilterDropdown.test.js similarity index 99% rename from test/unit/pages/clinicworkspace/components/CGMUseFilterDropdown.test.js rename to __tests__/unit/app/pages/clinicworkspace/components/filters/CGMUseFilterDropdown.test.js index dd66c5a5ed..cf43033293 100644 --- a/test/unit/pages/clinicworkspace/components/CGMUseFilterDropdown.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/components/filters/CGMUseFilterDropdown.test.js @@ -6,7 +6,7 @@ import configureStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; import { MemoryRouter } from 'react-router-dom'; -import CGMUseFilterDropdown from '@app/pages/clinicworkspace/components/CGMUseFilterDropdown'; +import CGMUseFilterDropdown from '@app/pages/clinicworkspace/components/filters/CGMUseFilterDropdown'; import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; const mockStore = configureStore([thunk]); diff --git a/__tests__/unit/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/filters/DataRecencyFilterDropdown.test.js similarity index 99% rename from __tests__/unit/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.test.js rename to __tests__/unit/app/pages/clinicworkspace/components/filters/DataRecencyFilterDropdown.test.js index fa1aae7869..350e40d9ca 100644 --- a/__tests__/unit/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/components/filters/DataRecencyFilterDropdown.test.js @@ -6,7 +6,7 @@ import configureStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; import { MemoryRouter } from 'react-router-dom'; -import DataRecencyFilterDropdown from '@app/pages/clinicworkspace/components/DataRecencyFilterDropdown'; +import DataRecencyFilterDropdown from '@app/pages/clinicworkspace/components/filters/DataRecencyFilterDropdown'; import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; const mockStore = configureStore([thunk]); diff --git a/__tests__/unit/app/pages/clinicworkspace/components/SiteFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/filters/SiteFilterDropdown.test.js similarity index 99% rename from __tests__/unit/app/pages/clinicworkspace/components/SiteFilterDropdown.test.js rename to __tests__/unit/app/pages/clinicworkspace/components/filters/SiteFilterDropdown.test.js index a43191333b..4e651f367e 100644 --- a/__tests__/unit/app/pages/clinicworkspace/components/SiteFilterDropdown.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/components/filters/SiteFilterDropdown.test.js @@ -6,7 +6,7 @@ import configureStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; import { MemoryRouter } from 'react-router-dom'; -import SiteFilterDropdown from '@app/pages/clinicworkspace/components/SiteFilterDropdown'; +import SiteFilterDropdown from '@app/pages/clinicworkspace/components/filters/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'; diff --git a/__tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/filters/SummaryPeriodFilterDropdown.test.js similarity index 98% rename from __tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js rename to __tests__/unit/app/pages/clinicworkspace/components/filters/SummaryPeriodFilterDropdown.test.js index 9c6946fa7b..a1ffcae930 100644 --- a/__tests__/unit/app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/components/filters/SummaryPeriodFilterDropdown.test.js @@ -6,7 +6,7 @@ import configureStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; import { MemoryRouter } from 'react-router-dom'; -import SummaryPeriodFilterDropdown from '@app/pages/clinicworkspace/components/SummaryPeriodFilterDropdown'; +import SummaryPeriodFilterDropdown from '@app/pages/clinicworkspace/components/filters/SummaryPeriodFilterDropdown'; import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; const mockStore = configureStore([thunk]); diff --git a/__tests__/unit/app/pages/clinicworkspace/components/TagFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/filters/TagFilterDropdown.test.js similarity index 99% rename from __tests__/unit/app/pages/clinicworkspace/components/TagFilterDropdown.test.js rename to __tests__/unit/app/pages/clinicworkspace/components/filters/TagFilterDropdown.test.js index 604e4c6224..3faf8ae63f 100644 --- a/__tests__/unit/app/pages/clinicworkspace/components/TagFilterDropdown.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/components/filters/TagFilterDropdown.test.js @@ -6,7 +6,7 @@ import configureStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; import { MemoryRouter } from 'react-router-dom'; -import TagFilterDropdown from '@app/pages/clinicworkspace/components/TagFilterDropdown'; +import TagFilterDropdown from '@app/pages/clinicworkspace/components/filters/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'; diff --git a/__tests__/unit/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.test.js b/__tests__/unit/app/pages/clinicworkspace/components/filters/TimeInRangeFilterDropdown.test.js similarity index 99% rename from __tests__/unit/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.test.js rename to __tests__/unit/app/pages/clinicworkspace/components/filters/TimeInRangeFilterDropdown.test.js index 1c673bfa43..79ded58058 100644 --- a/__tests__/unit/app/pages/clinicworkspace/components/TimeInRangeFilterDropdown.test.js +++ b/__tests__/unit/app/pages/clinicworkspace/components/filters/TimeInRangeFilterDropdown.test.js @@ -7,7 +7,7 @@ import { thunk } from 'redux-thunk'; import { MemoryRouter } from 'react-router-dom'; import { useFlags } from 'launchdarkly-react-client-sdk'; -import TimeInRangeFilterDropdown from '@app/pages/clinicworkspace/components/TimeInRangeFilterDropdown'; +import TimeInRangeFilterDropdown from '@app/pages/clinicworkspace/components/filters/TimeInRangeFilterDropdown'; import { MMOLL_UNITS } from '@app/core/constants'; import { trackMetric as mockTrackMetric } from '../../../../../app/core/metricUtils'; diff --git a/__tests__/unit/app/pages/clinicworkspace/useClinicMetricsPageName.test.js b/__tests__/unit/app/pages/clinicworkspace/useClinicMetricsPageName.test.js new file mode 100644 index 0000000000..45c9b7b564 --- /dev/null +++ b/__tests__/unit/app/pages/clinicworkspace/useClinicMetricsPageName.test.js @@ -0,0 +1,24 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { renderHook } from '@testing-library/react-hooks/dom'; + +import useClinicMetricsPageName from '@app/pages/clinicworkspace/useClinicMetricsPageName'; + +describe('useClinicMetricsPageName', () => { + const renderPageNameHook = (route) => renderHook( + () => useClinicMetricsPageName(), + { wrapper: ({ children }) => {children} } + ).result.current; + + it('returns Population Health arg', () => { + expect(renderPageNameHook('/clinic-workspace')).toBe('Population Health'); + }); + + it('returns Population Health arg when backing out from patient data view', () => { + expect(renderPageNameHook('/clinic-workspace/patients')).toBe('Population Health'); + }); + + it('returns TIDE Dashboard arg', () => { + expect(renderPageNameHook('/clinic-workspace/tide-dashboard')).toBe('TIDE Dashboard'); + }); +}); diff --git a/__tests__/unit/app/redux/actions/async.test.js b/__tests__/unit/app/redux/actions/async.test.js index d0159488a7..4c30ed7a71 100644 --- a/__tests__/unit/app/redux/actions/async.test.js +++ b/__tests__/unit/app/redux/actions/async.test.js @@ -2,28 +2,18 @@ /* global describe */ /* global it */ /* global expect */ -/* global beforeEach */ -/* global before */ /* global afterEach */ -/* global after */ -/* global context */ import configureStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; import trackingMiddleware from '../../../../../app/redux/utils/trackingMiddleware'; -import moment from 'moment'; -import _ from 'lodash'; - -import isTSA from 'tidepool-standard-action'; import initialState from '../../../../../app/redux/reducers/initialState'; import * as ErrorMessages from '../../../../../app/redux/constants/errorMessages'; -import * as UserMessages from '../../../../../app/redux/constants/usrMessages'; // need to require() async in order to rewire utils inside const async = require('../../../../../app/redux/actions/async'); -const sync = require('../../../../../app/redux/actions/sync'); describe('Actions', () => { const trackMetric = jest.fn(); @@ -48,9 +38,6 @@ describe('Actions', () => { { type: 'CREATE_CLINIC_SITE_REQUEST' }, { type: 'CREATE_CLINIC_SITE_SUCCESS', payload: { clinicId, site: newSite } }, ]; - _.each(expectedActions, (action) => { - expect(isTSA(action)).toBe(true); - }); let store = mockStore({ blip: initialState }); store.dispatch(async.createClinicSite(api, clinicId, { name: 'Site Alpha' })); @@ -76,9 +63,7 @@ describe('Actions', () => { { type: 'CREATE_CLINIC_SITE_REQUEST' }, { type: 'CREATE_CLINIC_SITE_FAILURE', error: err, meta: { apiError: { status: 500, body: 'Error!' } } }, ]; - _.each(expectedActions, (action) => { - expect(isTSA(action)).toBe(true); - }); + let store = mockStore({ blip: initialState }); store.dispatch(async.createClinicSite(api, clinicId, { name: 'Site Charlie' })); @@ -87,5 +72,173 @@ describe('Actions', () => { expect(api.clinics.createClinicSite).toHaveBeenCalledTimes(1); }); }); + + describe('selectClinic', () => { + const clinicId = 'clinic123'; + + const expectedUIDetails = { + entitlements: { + patientTags: false, + clinicSites: false, + prescriptions: false, + rpmReport: false, + summaryDashboard: false, + tideDashboard: false, + }, + patientLimitEnforced: false, + planName: 'internationalBase', + ui: { + display: { + patientCount: true, + patientLimit: false, + planName: false, + workspacePlan: false, + workspaceLimitDescription: false, + workspaceLimitFeedback: false, + workspaceLimitResolutionLink: false, + }, + text: { + planDisplayName: 'Base', + limitDescription: undefined, + limitFeedback: undefined, + limitResolutionLink: undefined, + }, + warnings: { + limitApproaching: false, + limitReached: false, + }, + }, + }; + + afterEach(() => { + localStorage.clear(); + }); + + it('should trigger SELECT_CLINIC_SUCCESS, FETCH_CLINIC_PATIENT_COUNTS_SUCCESS, and FETCH_CLINIC_PATIENT_COUNT_SETTINGS_SUCCESS for a successful request', () => { + const countResults = { plan: 33 }; + const settingsResults = { bar: 'baz' }; + + localStorage.setItem('tideDashboardFilters/user456/clinic123', JSON.stringify({ lastData: 7, patientTags: ['load1'] })); // this one should load + localStorage.setItem('tideDashboardFilters/wrongUser/clinic123', JSON.stringify({ lastData: 1 })); + localStorage.setItem('tideDashboardFilters/user456/wrongClinic', JSON.stringify({ lastData: 30 })); + + let api = { + clinics: { + getClinicPatientCount: jest.fn().mockImplementation((_arg1, cb) => cb(null, countResults)), + getClinicPatientCountSettings: jest.fn().mockImplementation((_arg1, cb) => cb(null, settingsResults)), + }, + }; + + let expectedActions = [ + { type: 'SELECT_CLINIC_SUCCESS', payload: { clinicId, tideDashboardFilters: { lastData: 7, patientTags: ['load1'] } } }, // filters load from localState + { type: 'FETCH_CLINIC_PATIENT_COUNTS_REQUEST' }, + { type: 'FETCH_CLINIC_PATIENT_COUNT_SETTINGS_REQUEST' }, + { type: 'FETCH_CLINIC_PATIENT_COUNTS_SUCCESS', payload: { clinicId, patientCounts: countResults } }, + { type: 'FETCH_CLINIC_PATIENT_COUNT_SETTINGS_SUCCESS', payload: { clinicId, patientCountSettings: settingsResults } }, + { type: 'SET_CLINIC_UI_DETAILS', payload: { clinicId, uiDetails: expectedUIDetails } }, + ]; + + let store = mockStore({ blip: { + ...initialState, + loggedInUserId: 'user456', + clinics: { + [clinicId]: { + patientCounts: undefined, + patientCountSettings: undefined, + }, + }, + } }); + + store.dispatch(async.selectClinic(api, clinicId)); + + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + expect(api.clinics.getClinicPatientCount).toHaveBeenCalledWith(clinicId, expect.any(Function)); + expect(api.clinics.getClinicPatientCount).toHaveBeenCalledTimes(1); + expect(api.clinics.getClinicPatientCountSettings).toHaveBeenCalledWith(clinicId, expect.any(Function)); + expect(api.clinics.getClinicPatientCountSettings).toHaveBeenCalledTimes(1); + }); + + it('should trigger SELECT_CLINIC_SUCCESS, but not FETCH_CLINIC_PATIENT_COUNTS_REQUEST or FETCH_CLINIC_PATIENT_COUNT_SETTINGS_REQUEST for a successful request if data available in clinic state', () => { + let api = { + clinics: { + getClinicPatientCount: jest.fn(), + getClinicPatientCountSettings: jest.fn(), + }, + }; + + let expectedActions = [ + { type: 'SELECT_CLINIC_SUCCESS', payload: { clinicId, tideDashboardFilters: undefined } }, + { type: 'SET_CLINIC_UI_DETAILS', payload: { clinicId, uiDetails: expectedUIDetails } }, + ]; + + let store = mockStore({ blip: { + ...initialState, + clinics: { + [clinicId]: { + patientCounts: { plan: 33 }, + patientCountSettings: { foo: 'bar' }, + }, + }, + } }); + + store.dispatch(async.selectClinic(api, clinicId)); + + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + expect(api.clinics.getClinicPatientCount).not.toHaveBeenCalled(); + expect(api.clinics.getClinicPatientCountSettings).not.toHaveBeenCalled(); + }); + + it('should trigger FETCH_CLINIC_PATIENT_COUNTS_FAILURE and FETCH_CLINIC_PATIENT_COUNT_SETTINGS_FAILURE and it should call error once for a failed request', () => { + let api = { + clinics: { + getClinicPatientCount: jest.fn().mockImplementation((_arg1, cb) => cb({ status: 500, body: 'Count Error!' }, null)), + getClinicPatientCountSettings: jest.fn().mockImplementation((_arg1, cb) => cb({ status: 500, body: 'Settings Error!' }, null)), + }, + }; + + let countErr = new Error(ErrorMessages.ERR_FETCHING_CLINIC_PATIENT_COUNTS); + countErr.status = 500; + + let settingsErr = new Error(ErrorMessages.ERR_FETCHING_CLINIC_PATIENT_COUNT_SETTINGS); + settingsErr.status = 500; + + let expectedActions = [ + { type: 'SELECT_CLINIC_SUCCESS', payload: { clinicId, tideDashboardFilters: undefined } }, + { type: 'FETCH_CLINIC_PATIENT_COUNTS_REQUEST' }, + { type: 'FETCH_CLINIC_PATIENT_COUNT_SETTINGS_REQUEST' }, + { + type: 'FETCH_CLINIC_PATIENT_COUNTS_FAILURE', + error: countErr, + meta: { apiError: { status: 500, body: 'Count Error!' } }, + }, + { + type: 'FETCH_CLINIC_PATIENT_COUNT_SETTINGS_FAILURE', + error: settingsErr, + meta: { apiError: { status: 500, body: 'Settings Error!' } }, + }, + ]; + + let store = mockStore({ blip: { + ...initialState, + clinics: { + [clinicId]: { + patientCounts: undefined, + patientCountSettings: undefined, + }, + }, + } }); + + store.dispatch(async.selectClinic(api, clinicId)); + + const actions = store.getActions(); + expect(actions).toStrictEqual(expectedActions); + expect(api.clinics.getClinicPatientCount).toHaveBeenCalledWith(clinicId, expect.any(Function)); + expect(api.clinics.getClinicPatientCount).toHaveBeenCalledTimes(1); + expect(api.clinics.getClinicPatientCountSettings).toHaveBeenCalledWith(clinicId, expect.any(Function)); + expect(api.clinics.getClinicPatientCountSettings).toHaveBeenCalledTimes(1); + }); + }); }); }); diff --git a/__tests__/unit/app/redux/store/configureStore.dev.test.js b/__tests__/unit/app/redux/store/configureStore.dev.test.js new file mode 100644 index 0000000000..b3f282fcaf --- /dev/null +++ b/__tests__/unit/app/redux/store/configureStore.dev.test.js @@ -0,0 +1,49 @@ +/* global jest */ +/* global describe */ +/* global it */ +/* global expect */ +/* global beforeEach */ +/* global afterEach */ + +import configureStore from '@app/redux/store/configureStore.dev'; +import { setTideDashboardFilters } from '@app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice'; + +describe('configureStore.dev', () => { + const api = { + metrics: { track: jest.fn() }, + errors: { log: jest.fn() }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + localStorage.clear(); + }); + + it('should persist select items to localStorage, scoped to the logged-in user and selected clinic', () => { + // Log in and select a clinic + const store = configureStore(api); + store.dispatch({ type: 'FETCH_USER_SUCCESS', payload: { user: { userid: 'user456' } } }); + store.dispatch({ type: 'SELECT_CLINIC_SUCCESS', payload: { clinicId: 'clinic123' } }); + store.dispatch(setTideDashboardFilters({ patientTags: ['tag1'], summaryPeriod: '7d' })); + + jest.advanceTimersByTime(1200); // subscriber is throttled to 1 second + + expect(JSON.parse(localStorage.getItem('blipState'))).toStrictEqual({ selectedClinicId: 'clinic123' }); + expect(JSON.parse(localStorage.getItem('tideDashboardFilters/user456/clinic123'))).toStrictEqual({ patientTags: ['tag1'], summaryPeriod: '7d' }); + }); + + it('should not persist items no clinic is selected', () => { + const store = configureStore(api); + store.dispatch({ type: 'FETCH_USER_SUCCESS', payload: { user: { userid: 'user456' } } }); + + jest.advanceTimersByTime(1200); // subscriber is throttled to 1 second + + expect(JSON.parse(localStorage.getItem('blipState'))).toStrictEqual({ selectedClinicId: null }); + expect(JSON.parse(localStorage.getItem('tideDashboardFilters/user456/clinic123'))).toStrictEqual(null); + }); +}); diff --git a/__tests__/unit/app/redux/store/configureStore.prod.test.js b/__tests__/unit/app/redux/store/configureStore.prod.test.js new file mode 100644 index 0000000000..7fbc0adac2 --- /dev/null +++ b/__tests__/unit/app/redux/store/configureStore.prod.test.js @@ -0,0 +1,49 @@ +/* global jest */ +/* global describe */ +/* global it */ +/* global expect */ +/* global beforeEach */ +/* global afterEach */ + +import configureStore from '@app/redux/store/configureStore.prod'; +import { setTideDashboardFilters } from '@app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice'; + +describe('configureStore.prod', () => { + const api = { + metrics: { track: jest.fn() }, + errors: { log: jest.fn() }, + }; + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + localStorage.clear(); + }); + + it('should persist select items to localStorage, scoped to the logged-in user and selected clinic', () => { + // Log in and select a clinic + const store = configureStore(api); + store.dispatch({ type: 'FETCH_USER_SUCCESS', payload: { user: { userid: 'user456' } } }); + store.dispatch({ type: 'SELECT_CLINIC_SUCCESS', payload: { clinicId: 'clinic123' } }); + store.dispatch(setTideDashboardFilters({ patientTags: ['tag1'], summaryPeriod: '7d' })); + + jest.advanceTimersByTime(1200); // subscriber is throttled to 1 second + + expect(JSON.parse(localStorage.getItem('blipState'))).toStrictEqual({ selectedClinicId: 'clinic123' }); + expect(JSON.parse(localStorage.getItem('tideDashboardFilters/user456/clinic123'))).toStrictEqual({ patientTags: ['tag1'], summaryPeriod: '7d' }); + }); + + it('should not persist items no clinic is selected', () => { + const store = configureStore(api); + store.dispatch({ type: 'FETCH_USER_SUCCESS', payload: { user: { userid: 'user456' } } }); + + jest.advanceTimersByTime(1200); // subscriber is throttled to 1 second + + expect(JSON.parse(localStorage.getItem('blipState'))).toStrictEqual({ selectedClinicId: null }); + expect(JSON.parse(localStorage.getItem('tideDashboardFilters/user456/clinic123'))).toStrictEqual(null); + }); +}); diff --git a/app/pages/clinicworkspace/TideDashboardV2/EmptyContentNode.js b/app/pages/clinicworkspace/TideDashboardV2/EmptyContentNode.js index 33c346efad..a29a9332bf 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/EmptyContentNode.js +++ b/app/pages/clinicworkspace/TideDashboardV2/EmptyContentNode.js @@ -1,15 +1,40 @@ import React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; import { useTranslation } from 'react-i18next'; import colorPalette from '../../../themes/colorPalette'; import { Flex, Text } from 'theme-ui'; +import { resetTideDashboardFilters } from './tideDashboardFiltersSlice'; +import { setOffset } from './tideDashboardSlice'; +import ClearFilterButtons, { PATIENT_QUERY_STATE } from '../components/ClearFilterButtons'; +import noop from 'lodash/noop'; +import without from 'lodash/without'; const EmptyContentNode = () => { const { t } = useTranslation(); + const dispatch = useDispatch(); - const emptyContentCopy = t('There are no results to show'); + const { patientTags, clinicSites } = useSelector(state => state.blip.tideDashboardFilters); + + const activeFiltersCount = without([ + patientTags?.length, + clinicSites?.length, + ], null, 0, undefined).length; + + const hasActiveFilters = activeFiltersCount > 0; + + const handleResetFilters = () => { + dispatch(resetTideDashboardFilters()); + dispatch(setOffset(0)); + }; + + const emptyContentCopy = hasActiveFilters + ? t('There are no patients with the current filter(s)') + : t('There are no results to show'); + + const patientQueryState = hasActiveFilters ? PATIENT_QUERY_STATE.FILTER_ONLY : PATIENT_QUERY_STATE.NONE; return ( - { {emptyContentCopy} + + { hasActiveFilters && + + } ); }; diff --git a/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js b/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js index 445203ca88..1d431c0e5d 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js +++ b/app/pages/clinicworkspace/TideDashboardV2/TideDashboardV2.js @@ -1,23 +1,36 @@ import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import Table from '../../../components/elements/Table'; -import { Flex } from 'theme-ui'; +import { Flex, Text, Box } from 'theme-ui'; -import FilterByCategory from './FilterByCategory'; +import FilterByCategory from './filters/FilterByCategory'; +import FilterByTags from './filters/FilterByTags'; +import FilterBySites from './filters/FilterBySites'; +import FilterByDataRecency from './filters/FilterByDataRecency'; +import FilterBySummaryPeriod from './filters/FilterBySummaryPeriod'; +import AppliedFiltersList from './filters/AppliedFiltersList'; import TableCategoryHeader from './TableCategoryHeader'; import PaginationController from './PaginationController'; import useTideDashboardPatients from './useTideDashboardPatients'; +import usePruneInvalidFilters from './usePruneInvalidFilters'; import useTableColumns from './useTableColumns'; import EmptyContentNode from './EmptyContentNode'; import { Redirect, useLocation } from 'react-router-dom'; import useAuthorizationGate from './useAuthorizationGate'; +const Gap = () => ; + const tableContainerProps = { sx: { containerType: 'inline-size' } }; const TideDashboardV2 = () => { const { search } = useLocation(); + const { t } = useTranslation(); + + usePruneInvalidFilters(); + const category = useSelector(state => state.blip.tideDashboard.category); const { isAuthorized, isUnauthorized } = useAuthorizationGate(); @@ -41,11 +54,21 @@ const TideDashboardV2 = () => { return ( <> + + {t('Filter By')} + + + + + + + + { + const hasFiltersActive = clinicSites?.length > 0 || patientTags?.length > 0; + + if (hasFiltersActive) return PATIENT_QUERY_STATE.FILTER_ONLY; + + return PATIENT_QUERY_STATE.NONE; +}; + +const AppliedFiltersList = ({ patientCount = 0 }) => { + const dispatch = useDispatch(); + + const { lastData, clinicSites, patientTags } = useSelector(state => state.blip.tideDashboardFilters); + + // Last Data is a required filter to be applied + const requiredFilters = { lastData: true }; + const activeFilters = { lastDataType: 'cgm', lastData, clinicSites, patientTags }; + + const handleResetFilters = () => { + dispatch(setPatientTagsFilter([])); + dispatch(setClinicSitesFilter([])); + dispatch(setOffset(0)); + }; + + const handleRemoveFilter = (filterKey, value) => { + switch (filterKey) { + case 'patientTags': { + const updatedTags = without(patientTags, value); + dispatch(setPatientTagsFilter(updatedTags)); + dispatch(setOffset(0)); + break; + } + + case 'clinicSites': { + const updatedSites = without(clinicSites, value); + dispatch(setClinicSitesFilter(updatedSites)); + dispatch(setOffset(0)); + break; + } + } + }; + + const patientQueryState = getPatientQueryState(patientTags, clinicSites); + + return ( + + + + } + /> + ); +}; + +AppliedFiltersList.propTypes = { + patientCount: PropTypes.number, +}; + +export default AppliedFiltersList; diff --git a/app/pages/clinicworkspace/TideDashboardV2/FilterByCategory.js b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByCategory.js similarity index 93% rename from app/pages/clinicworkspace/TideDashboardV2/FilterByCategory.js rename to app/pages/clinicworkspace/TideDashboardV2/filters/FilterByCategory.js index d1f68697d4..bb8b8e0850 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/FilterByCategory.js +++ b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByCategory.js @@ -1,8 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useSelector, useDispatch } from 'react-redux'; -import { SegmentedControl, Segment } from '../components/SegmentedControl'; -import { setCategory, setOffset, CATEGORY } from './tideDashboardSlice'; +import { SegmentedControl, Segment } from '../../components/SegmentedControl'; +import { setCategory, setOffset, CATEGORY } from '../tideDashboardSlice'; import { colors as vizColors } from '@tidepool/viz'; import { Box } from 'theme-ui'; diff --git a/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByDataRecency.js b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByDataRecency.js new file mode 100644 index 0000000000..ac72ffde5b --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByDataRecency.js @@ -0,0 +1,33 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setOffset } from '../tideDashboardSlice'; +import { setLastDataFilter } from '../tideDashboardFiltersSlice'; + +import { lastDataFilterOptions } from '../../../../core/clinicUtils'; + +import DataRecencyFilterDropdown from '../../components/filters/DataRecencyFilterDropdown'; + +const FilterByDataRecency = () => { + const dispatch = useDispatch(); + const { lastData } = useSelector(state => state.blip.tideDashboardFilters); + + const handleChange = ({ lastData }) => { + dispatch(setLastDataFilter(lastData)); + dispatch(setOffset(0)); + }; + + const customLastDataFilterOptions = lastDataFilterOptions.filter(opt => [1, 2, 7, 14].includes(opt.value)); + + return ( + + ); +}; + +export default FilterByDataRecency; diff --git a/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySites.js b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySites.js new file mode 100644 index 0000000000..1d33c9f33f --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySites.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setOffset } from '../tideDashboardSlice'; +import { setClinicSitesFilter } from '../tideDashboardFiltersSlice'; +import SiteFilterDropdown from '../../components/filters/SiteFilterDropdown'; + +const FilterBySites = () => { + const dispatch = useDispatch(); + const { clinicSites } = useSelector(state => state.blip.tideDashboardFilters); + + const handleChange = (clinicSites) => { + dispatch(setClinicSitesFilter(clinicSites)); + dispatch(setOffset(0)); + }; + + return ; +}; + +export default FilterBySites; diff --git a/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySummaryPeriod.js b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySummaryPeriod.js new file mode 100644 index 0000000000..9fb359cabd --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterBySummaryPeriod.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import SummaryPeriodFilterDropdown from '../../components/filters/SummaryPeriodFilterDropdown'; +import { setSummaryPeriodFilter } from '../tideDashboardFiltersSlice'; +import { setOffset } from '../tideDashboardSlice'; + +const FilterBySummaryPeriod = () => { + const dispatch = useDispatch(); + const { summaryPeriod } = useSelector(state => state.blip.tideDashboardFilters); + + const handleChange = (summaryPeriod) => { + dispatch(setSummaryPeriodFilter(summaryPeriod)); + dispatch(setOffset(0)); + }; + + return ( + + ); +}; + +export default FilterBySummaryPeriod; diff --git a/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByTags.js b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByTags.js new file mode 100644 index 0000000000..ce9c9c7321 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/filters/FilterByTags.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setOffset } from '../tideDashboardSlice'; +import { setPatientTagsFilter } from '../tideDashboardFiltersSlice'; +import TagFilterDropdown from '../../components/filters/TagFilterDropdown'; + +const FilterByTags = () => { + const dispatch = useDispatch(); + const { patientTags } = useSelector(state => state.blip.tideDashboardFilters); + + const handleChange = (tags) => { + dispatch(setPatientTagsFilter(tags)); + dispatch(setOffset(0)); + }; + + return ; +}; + +export default FilterByTags; diff --git a/app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice.js b/app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice.js new file mode 100644 index 0000000000..e24dae61e2 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/tideDashboardFiltersSlice.js @@ -0,0 +1,46 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { SELECT_CLINIC_SUCCESS } from '../../../redux/constants/actionTypes'; + +const getInitialState = () => ({ + lastData: 7, + patientTags: [], + clinicSites: [], + summaryPeriod: '14d', +}); + +const tideDashboardFiltersSlice = createSlice({ + name: 'tideDashboardFilters', + initialState: getInitialState(), + reducers: { + setTideDashboardFilters: (_state, action) => action.payload || getInitialState(), + setLastDataFilter: (state, action) => { + state.lastData = action.payload; + }, + setPatientTagsFilter: (state, action) => { + state.patientTags = action.payload; + }, + setClinicSitesFilter: (state, action) => { + state.clinicSites = action.payload; + }, + setSummaryPeriodFilter: (state, action) => { + state.summaryPeriod = action.payload; + }, + resetTideDashboardFilters: () => getInitialState(), + }, + extraReducers: (builder) => { + builder.addCase(SELECT_CLINIC_SUCCESS, (_state, action) => { + return action.payload?.tideDashboardFilters || getInitialState(); + }); + }, +}); + +export const { + setTideDashboardFilters, + setLastDataFilter, + setPatientTagsFilter, + setClinicSitesFilter, + setSummaryPeriodFilter, + resetTideDashboardFilters, +} = tideDashboardFiltersSlice.actions; + +export default tideDashboardFiltersSlice.reducer; diff --git a/app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints.js b/app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints.js index 10693ffede..edb7c24656 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints.js +++ b/app/pages/clinicworkspace/TideDashboardV2/useDerivedDataRecencyEndpoints.js @@ -4,18 +4,8 @@ import { useSelector } from 'react-redux'; import { utils as vizUtils } from '@tidepool/viz'; const { getLocalizedCeiling } = vizUtils.datetime; -// TEMPORARY, will be set in redux -const tideDashboardFilters = { - lastData: 7, - patientTags: [], - clinicSites: [], - summaryPeriod: '14d', -}; - -const useDerivedDataRecencyEndpoints = ( - filters = tideDashboardFilters // TEMPORARY, will be set in redux -) => { - const lastData = filters.lastData; +const useDerivedDataRecencyEndpoints = () => { + const lastData = useSelector(state => state.blip.tideDashboardFilters.lastData); const timePrefs = useSelector((state) => state.blip.timePrefs); const lastDataTo = getLocalizedCeiling(new Date().toISOString(), timePrefs).toISOString(); diff --git a/app/pages/clinicworkspace/TideDashboardV2/usePruneInvalidFilters.js b/app/pages/clinicworkspace/TideDashboardV2/usePruneInvalidFilters.js new file mode 100644 index 0000000000..1713447831 --- /dev/null +++ b/app/pages/clinicworkspace/TideDashboardV2/usePruneInvalidFilters.js @@ -0,0 +1,43 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { setPatientTagsFilter, setClinicSitesFilter } from './tideDashboardFiltersSlice'; +import { SPECIAL_FILTER_STATES } from '../useClinicPatientsFilters'; +import keyBy from 'lodash/keyBy'; +import isEqual from 'lodash/isEqual'; + +const usePruneInvalidFilters = () => { + const dispatch = useDispatch(); + const selectedClinicId = useSelector(state => state.blip.selectedClinicId); + const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); + const { patientTags, clinicSites } = useSelector(state => state.blip.tideDashboardFilters); + + const clinicId = clinic?.id; + + useEffect(() => { + if (!patientTags?.length || !clinicId) return; + + if (isEqual(patientTags, SPECIAL_FILTER_STATES.ZERO_TAGS)) return; + + const availableTags = keyBy(clinic.patientTags || [], 'id'); + const prunedTags = patientTags.filter(tagId => !!availableTags[tagId]); + + if (prunedTags.length < patientTags.length) { + dispatch(setPatientTagsFilter(prunedTags)); + } + }, [clinicId]); + + useEffect(() => { + if (!clinicSites?.length || !clinicId) return; + + if (isEqual(clinicSites, SPECIAL_FILTER_STATES.ZERO_SITES)) return; + + const availableSites = keyBy(clinic.sites || [], 'id'); + const prunedSites = clinicSites.filter(siteId => !!availableSites[siteId]); + + if (prunedSites.length < clinicSites.length) { + dispatch(setClinicSitesFilter(prunedSites)); + } + }, [clinicId]); +}; + +export default usePruneInvalidFilters; diff --git a/app/pages/clinicworkspace/TideDashboardV2/useTideDashboardPatients.js b/app/pages/clinicworkspace/TideDashboardV2/useTideDashboardPatients.js index 63a10a9adb..c6454a6526 100644 --- a/app/pages/clinicworkspace/TideDashboardV2/useTideDashboardPatients.js +++ b/app/pages/clinicworkspace/TideDashboardV2/useTideDashboardPatients.js @@ -4,21 +4,13 @@ import useDerivedDataRecencyEndpoints from './useDerivedDataRecencyEndpoints'; const LIMIT = 12; -// TEMPORARY, will be set in redux -const tideDashboardFilters = { - lastData: 7, - patientTags: [], - clinicSites: [], - summaryPeriod: '14d', -}; - const useTideDashboardPatients = () => { const selectedClinicId = useSelector(state => state.blip.selectedClinicId); const category = useSelector(state => state.blip.tideDashboard.category); const offset = useSelector(state => state.blip.tideDashboard.offset); - const patientTags = tideDashboardFilters.patientTags; - const clinicSites = tideDashboardFilters.clinicSites; - const summaryPeriod = tideDashboardFilters.summaryPeriod; + const patientTags = useSelector(state => state.blip.tideDashboardFilters.patientTags); + const clinicSites = useSelector(state => state.blip.tideDashboardFilters.clinicSites); + const summaryPeriod = useSelector(state => state.blip.tideDashboardFilters.summaryPeriod); const [lastDataFrom, lastDataTo] = useDerivedDataRecencyEndpoints(); return useGetTideDashboardPatientsQuery( diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.js b/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.js index 3bcb865899..545e016888 100644 --- a/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.js +++ b/app/pages/clinicworkspace/clinicPatientsFilters/AppliedFiltersList.js @@ -3,7 +3,7 @@ import { useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import without from 'lodash/without'; -import ActiveFiltersTray from '../components/ActiveFiltersTray'; +import ActiveFiltersTray from '../components/filters/ActiveFiltersTray'; import ClearFilterButtons, { PATIENT_QUERY_STATE } from '../components/ClearFilterButtons'; import { defaultFilterState } from '../useClinicPatientsFilters'; import { Box } from 'theme-ui'; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.js index 1c6f76bc25..71d7ef6dce 100644 --- a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.js +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByCGMUse.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import noop from 'lodash/noop'; -import CGMUseFilterDropdown from '../components/CGMUseFilterDropdown'; +import CGMUseFilterDropdown from '../components/filters/CGMUseFilterDropdown'; const FilterByCGMUse = ({ activeFilters = {}, diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js index b48b4036bf..8e3d62161e 100644 --- a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByDataRecency.js @@ -6,7 +6,7 @@ import reject from 'lodash/reject'; import { lastDataFilterOptions } from '../../../core/clinicUtils'; -import DataRecencyFilterDropdown from '../components/DataRecencyFilterDropdown'; +import DataRecencyFilterDropdown from '../components/filters/DataRecencyFilterDropdown'; const FilterByDataRecency = ({ activeFilters = {}, diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js index c0990ce846..bec3afbda5 100644 --- a/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySites.js @@ -5,7 +5,7 @@ import * as actions from '../../../redux/actions'; import { trackMetric } from '../../../core/metricUtils'; import noop from 'lodash/noop'; -import SiteFilterDropdown from '../components/SiteFilterDropdown'; +import SiteFilterDropdown from '../components/filters/SiteFilterDropdown'; import useIsClinicAdmin from '../useIsClinicAdmin'; import useClinicMetricsPageName from '../useClinicMetricsPageName'; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.js index 9e759560a5..e98b5f67d3 100644 --- a/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.js +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterBySummaryPeriod.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import noop from 'lodash/noop'; -import SummaryPeriodFilterDropdown from '../components/SummaryPeriodFilterDropdown'; +import SummaryPeriodFilterDropdown from '../components/filters/SummaryPeriodFilterDropdown'; import { summaryPeriodOptions } from '../../../core/clinicUtils'; const FilterBySummaryPeriod = ({ diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js index 997d1ddf0c..6b0a48c090 100644 --- a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTags.js @@ -5,7 +5,7 @@ import * as actions from '../../../redux/actions'; import { trackMetric } from '../../../core/metricUtils'; import noop from 'lodash/noop'; -import TagFilterDropdown from '../components/TagFilterDropdown'; +import TagFilterDropdown from '../components/filters/TagFilterDropdown'; import useIsClinicAdmin from '../useIsClinicAdmin'; import useClinicMetricsPageName from '../useClinicMetricsPageName'; diff --git a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.js b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.js index 087dfabf00..36ca835686 100644 --- a/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.js +++ b/app/pages/clinicworkspace/clinicPatientsFilters/FilterByTimeInRange.js @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import noop from 'lodash/noop'; -import TimeInRangeFilterDropdown from '../components/TimeInRangeFilterDropdown'; +import TimeInRangeFilterDropdown from '../components/filters/TimeInRangeFilterDropdown'; const FilterByTimeInRange = ({ activeFilters = {}, diff --git a/app/pages/clinicworkspace/components/ActiveFiltersTray.js b/app/pages/clinicworkspace/components/filters/ActiveFiltersTray.js similarity index 86% rename from app/pages/clinicworkspace/components/ActiveFiltersTray.js rename to app/pages/clinicworkspace/components/filters/ActiveFiltersTray.js index d8e6596fa3..20ae9bde08 100644 --- a/app/pages/clinicworkspace/components/ActiveFiltersTray.js +++ b/app/pages/clinicworkspace/components/filters/ActiveFiltersTray.js @@ -1,24 +1,24 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; -import { useTranslation, withTranslation } from 'react-i18next'; -import { Flex, Text, Box } from 'theme-ui'; +import { useTranslation } from 'react-i18next'; +import { Flex, Text } from 'theme-ui'; import { colors as vizColors } from '@tidepool/viz'; import CloseRoundedIcon from '@material-ui/icons/CloseRounded'; import LocationOnOutlinedIcon from '@material-ui/icons/LocationOnOutlined'; -import TagIcon from '../../../core/icons/tagIcon.svg'; +import TagIcon from '../../../../core/icons/tagIcon.svg'; import find from 'lodash/find'; import isEqual from 'lodash/isEqual'; import noop from 'lodash/noop'; -import Icon from '../../../components/elements/Icon'; -import utils from '../../../core/utils'; -import { transitions } from '../../../themes/baseTheme'; -import { SPECIAL_FILTER_STATES } from '../useClinicPatientsFilters'; +import Icon from '../../../../components/elements/Icon'; +import utils from '../../../../core/utils'; +import { transitions } from '../../../../themes/baseTheme'; +import { SPECIAL_FILTER_STATES } from '../../useClinicPatientsFilters'; -const usePrimaryChips = (activeFilters) => { +const usePrimaryChips = (activeFilters, requiredFilters) => { const { t } = useTranslation(); const { lastData, lastDataType, timeCGMUsePercent, timeInRange = [] } = activeFilters; @@ -47,6 +47,7 @@ const usePrimaryChips = (activeFilters) => { type: 'lastData', value: `${lastDataType}-${lastData}`, label: getLastDataChipLabel(lastDataType, lastData), + required: requiredFilters?.['lastData'] || false, }), // CGM Wear Time Filter @@ -111,8 +112,9 @@ const useSiteChips = (clinicSites = []) => { .toSorted((a, b) => utils.compareLabels(a.label, b.label)); }; -const Chip = ({ label, onRemove }) => { +const Chip = ({ label, onRemove, required = false }) => { const { t } = useTranslation(); + const canRemove = !required; return ( { fontWeight: 'normal', cursor: 'default', ml: 1, - '&:hover': { + '&:hover': canRemove ? { color: vizColors.blue80, fontWeight: 'medium', - }, + } : {}, '.remove-filter-icon': { fontSize: '14px', padding: '2px', @@ -150,16 +152,18 @@ const Chip = ({ label, onRemove }) => { }, }} > - + {label} - + { canRemove && + + } ); }; @@ -175,6 +179,7 @@ const ChipGroup = ({ prefix, chips, onRemove }) => { onRemove(chip)} /> ))} @@ -183,23 +188,22 @@ const ChipGroup = ({ prefix, chips, onRemove }) => { }; const ActiveFiltersTray = ({ + patientCount = 0, filters = {}, + requiredFilters = {}, hasSearchActive = false, onRemoveFilter = noop, rightContent = null, }) => { const { t } = useTranslation(); - const selectedClinicId = useSelector(state => state.blip.selectedClinicId); - const clinic = useSelector(state => state.blip.clinics?.[selectedClinicId]); - - const primaryChips = usePrimaryChips(filters); + const primaryChips = usePrimaryChips(filters, requiredFilters); const tagChips = useTagChips(filters.patientTags); const siteChips = useSiteChips(filters.clinicSites); - const count = clinic?.fetchedPatientCount || 0; - const handleRemoveChip = chip => onRemoveFilter(chip.type, chip.value); + const count = patientCount; + return ( [ { value: '<0.7', label: t('Less than 70%') }, diff --git a/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js b/app/pages/clinicworkspace/components/filters/DataRecencyFilterDropdown.js similarity index 71% rename from app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js rename to app/pages/clinicworkspace/components/filters/DataRecencyFilterDropdown.js index a1c306ee61..3d0d85d566 100644 --- a/app/pages/clinicworkspace/components/DataRecencyFilterDropdown.js +++ b/app/pages/clinicworkspace/components/filters/DataRecencyFilterDropdown.js @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { useTranslation } from 'react-i18next'; -import { trackMetric } from '../../../core/metricUtils'; +import { trackMetric } from '../../../../core/metricUtils'; import { colors as vizColors } from '@tidepool/viz'; import { Box, Grid } from 'theme-ui'; @@ -11,13 +11,15 @@ import noop from 'lodash/noop'; import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; -import Button from '../../../components/elements/Button'; -import Popover from '../../../components/elements/Popover'; -import RadioGroup from '../../../components/elements/RadioGroup'; -import { lastDataFilterOptions } from '../../../core/clinicUtils'; -import useClinicMetricsPageName from '../useClinicMetricsPageName'; +import Button from '../../../../components/elements/Button'; +import Popover from '../../../../components/elements/Popover'; +import RadioGroup from '../../../../components/elements/RadioGroup'; +import { lastDataFilterOptions } from '../../../../core/clinicUtils'; +import useClinicMetricsPageName from '../../useClinicMetricsPageName'; const DropdownContent = ({ + canSelectLastDataType, + canClearSelection, onClose, onChange, lastData, @@ -40,25 +42,29 @@ const DropdownContent = ({ return ( - - - {t('Device Type')} - - - - - { - setPending({ ...pending, lastDataType: event.target.value || null }); - }} - /> - + {canSelectLastDataType && + <> + + + {t('Device Type')} + + + + + { + setPending({ ...pending, lastDataType: event.target.value || null }); + }} + /> + + + } {t('Data Recency')} @@ -82,17 +88,20 @@ const DropdownContent = ({