Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
ec94750
device-dashboard initial setup
henry-tp Feb 19, 2026
5099549
render tags
henry-tp Feb 20, 2026
ed12408
abstract out tags filter
henry-tp Feb 20, 2026
8724a64
remove workspace layout
henry-tp Feb 20, 2026
2332da2
Revert "abstract out tags filter"
henry-tp Feb 20, 2026
a8a0813
revert console.log
henry-tp Feb 20, 2026
4e3a837
remove unused endpoint
henry-tp Feb 24, 2026
73a9e76
small improvement to reduce prop drilling
henry-tp Feb 24, 2026
e103ad9
add Category Seelctor
henry-tp Feb 24, 2026
2493f8a
use barrel file import
henry-tp Feb 24, 2026
9eff9ef
create summary dashboard entitlement
henry-tp Feb 24, 2026
f9af88a
flip incorrect boolean check
henry-tp Feb 24, 2026
ac7149d
hook up category state to API call
henry-tp Feb 24, 2026
99fd3ae
move FilterByCategory to own file
henry-tp Feb 24, 2026
cfc45a9
implement pagination
henry-tp Feb 24, 2026
3481fa1
hover background colors
henry-tp Feb 24, 2026
387ab7a
improve directory organization
henry-tp Feb 24, 2026
a51f699
use own file for custom hook
henry-tp Feb 25, 2026
730fd4e
remove fullName from most fields
henry-tp Feb 25, 2026
e6a068a
revert useRequireSummaryDashboard hook
henry-tp Feb 25, 2026
5ea318e
WEB-4454 initialize epic branch
henry-tp Feb 25, 2026
0f01803
Merge branch 'WEB-4454-epic' into device-dashboard
henry-tp Feb 25, 2026
d6b5512
Merge branch 'WEB-4390-install-rtk' into WEB-4454-epic
henry-tp Feb 25, 2026
a6cd491
Merge branch 'WEB-4454-epic' into WEB-4454-dashboard
henry-tp Feb 25, 2026
0934a8a
WEB-4454 move cells into own respective components
henry-tp Feb 25, 2026
b887415
WEB-4454 use redux for category filtering
henry-tp Feb 26, 2026
b2d5d09
WEB-4454 reset state on dismount
henry-tp Feb 26, 2026
e0b3767
WEB-4454 move deviceIssuesApi into own file
henry-tp Feb 26, 2026
a97f9c4
WEB-4454 add cloud-connected annotation
henry-tp Feb 26, 2026
ebfe23e
WEB-4454 rename SelectCategory
henry-tp Feb 27, 2026
b0ffbc0
WEB-4454 rename to PaginationController
henry-tp Feb 27, 2026
ebc755d
WEB-4454 fix PaginationController id
henry-tp Feb 28, 2026
9846689
WEB-4454 unify ClinicWorkspacePagination implementation
henry-tp Mar 2, 2026
55445fd
WEB-4454 use "Controller" suffix for concrete implementation name
henry-tp Mar 3, 2026
1256a81
WEB-4454 name for SegmentedControl
henry-tp Mar 4, 2026
21675d9
WEB-4454 rename category to STALE_DATA
henry-tp Mar 4, 2026
9e9be76
WEB-4454 reset offset on category change
henry-tp Mar 5, 2026
2bbb445
WEB-4454 vertically align segmented control items
henry-tp Mar 13, 2026
51d96fa
WEB4454 add tests for device issue dashboard
henry-tp Mar 16, 2026
7a21d24
WEB-4454 show tags only if entitled
henry-tp Mar 17, 2026
6d5f1a7
Merge branch 'develop' into WEB-4454-epic
henry-tp May 6, 2026
9a4d615
Merge branch 'WEB-4454-epic' into WEB-4454-dashboard
henry-tp May 6, 2026
29199fb
WEB-4454 fix routing issue
henry-tp May 26, 2026
e5aa695
WEB-4454 remove unneeded useEffect for tab navigation
henry-tp May 27, 2026
1a1df09
WEB-4454 set up entitlements for device-issues dashboard
henry-tp Jun 23, 2026
2f3fb21
Merge branch 'develop' into WEB-4454-dashboard
henry-tp Jun 23, 2026
c6a7bec
WEB-4454 convert category to deviceIssues param in API layer
henry-tp Jun 25, 2026
6241315
WEB-4454 fix query params based on BE discussion
henry-tp Jun 26, 2026
09bd9be
Merge branch 'feat/device-issues' into WEB-4454-dashboard
henry-tp Jun 26, 2026
f22e22f
WEB-4454 add field values for table to prevent key error
henry-tp Jun 29, 2026
06ce6ba
WEB-4454 create PatientCount indicator component
henry-tp Jun 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/* global jest, expect, describe, it, beforeAll, beforeEach, afterAll, afterEach */

import React from 'react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { combineReducers } from 'redux';
import { http, HttpResponse, delay } from 'msw';
import { setupServer } from 'msw/node';

import { setupStore } from '@tests/utils/setupStore';
import deviceIssuesReducer from '@app/pages/clinicworkspace/DeviceIssues/deviceIssuesSlice';
import DeviceIssues from '@app/pages/clinicworkspace/DeviceIssues/DeviceIssues';

const CLINIC_ID = 'clinic123';
const API_URL = `http://app.tidepool.test/v1/clinics/${CLINIC_ID}/patients`;

const clinicPatientTags = [
{ id: 'tag1', name: 'Diabetes' },
{ id: 'tag2', name: 'Hypertension' },
];

const mockPatients = {
data: [
{ fullName: 'Jane Doe', birthDate: '1990-01-15', mrn: 'MRN001', tags: ['tag1', 'tag2'] },
{ fullName: 'John Smith', birthDate: '1985-06-20', mrn: 'MRN002', tags: ['tag1'] },
],
meta: { count: 30 },
};

const blipReducer = combineReducers({
selectedClinicId: (state = CLINIC_ID) => state,
clinics: (state = { [CLINIC_ID]: { patientTags: clinicPatientTags } }) => state,
deviceIssues: deviceIssuesReducer,
});

const server = setupServer();

describe('DeviceIssues', () => {
let store;
let capturedRequests;

beforeAll(() => server.listen());

beforeEach(() => {
capturedRequests = [];
store = setupStore({}, { blip: blipReducer });

server.use(
http.get(API_URL, async ({ request }) => {
const url = new URL(request.url);
capturedRequests.push(Object.fromEntries(url.searchParams));
await delay(50);
return HttpResponse.json(mockPatients);
})
);
});

afterEach(() => {
server.resetHandlers();
});

afterAll(() => server.close());

const renderComponent = () =>
render(
<Provider store={store}>
<DeviceIssues />
</Provider>
);

it('makes the correct HTTP call with default args on mount', async () => {
renderComponent();

await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});

expect(capturedRequests).toHaveLength(1);
expect(capturedRequests[0]).toEqual({
offset: '0',
category: 'DEFAULT',
limit: '12',
});
});

it('changes the HTTP call category arg when clicking a different category', async () => {
renderComponent();

await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});

await userEvent.click(screen.getByText('Stale Data'));

await waitFor(() => {
expect(capturedRequests.length).toBeGreaterThanOrEqual(2);
});

const lastRequest = capturedRequests[capturedRequests.length - 1];
expect(lastRequest.category).toBe('STALE_DATA');
expect(lastRequest.offset).toBe('0');
});

it('changes the HTTP call offset arg when clicking a pagination button', async () => {
renderComponent();

await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});

// meta.count=30 with limit=12 gives 3 pages; click page 2
await userEvent.click(screen.getByText('2'));

await waitFor(() => {
expect(capturedRequests.length).toBeGreaterThanOrEqual(2);
});

const lastRequest = capturedRequests[capturedRequests.length - 1];
expect(lastRequest.offset).toBe('12');
expect(lastRequest.limit).toBe('12');
});

describe('table cells', () => {
it('displays patient detail demographics', async () => {
renderComponent();

await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});

const cell0 = within(screen.getByTestId('deviceIssuesPatientsTable-row-0-fullName'));

// Patient 1 - first row
expect(cell0.getByText('Jane Doe')).toBeInTheDocument();
expect(cell0.getByText(/DOB:/)).toBeInTheDocument();
expect(cell0.getByText(/1990-01-15/)).toBeInTheDocument();
expect(cell0.getByText(/MRN001/)).toBeInTheDocument();

const cell1 = within(screen.getByTestId('deviceIssuesPatientsTable-row-1-fullName'));

// Patient 2 - second row
expect(cell1.getByText('John Smith')).toBeInTheDocument();
expect(cell1.getByText(/DOB:/)).toBeInTheDocument();
expect(cell1.getByText(/1985-06-20/)).toBeInTheDocument();
expect(cell1.getByText(/MRN002/)).toBeInTheDocument();
});

it('displays the per-patient tag list', async () => {
renderComponent();

await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});

const cell0 = within(screen.getByTestId('deviceIssuesPatientsTable-row-0-tags'));

// Patient 1 has both tags
expect(cell0.getByText('Diabetes')).toBeInTheDocument();
expect(cell0.getByText('Hypertension')).toBeInTheDocument();

const cell1 = within(screen.getByTestId('deviceIssuesPatientsTable-row-1-tags'));

// Patient 2 has only Diabetes
expect(cell1.getByText('Diabetes')).toBeInTheDocument();
expect(cell1.queryByText('Hypertension')).not.toBeInTheDocument();
});
});
});
3 changes: 2 additions & 1 deletion __tests__/utils/setupStore.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { configureStore } from '@reduxjs/toolkit';
import { RTKQueryApi } from '@app/redux/api/baseApi';

export const setupStore = (preloadedState = {}) => {
export const setupStore = (preloadedState = {}, additionalReducers = {}) => {
return configureStore({
reducer: {
[RTKQueryApi.reducerPath]: RTKQueryApi.reducer,
...additionalReducers,
},
middleware: (getDefaultMiddleware) => ([
...getDefaultMiddleware(),
Expand Down
2 changes: 2 additions & 0 deletions app/components/elements/Table.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,15 @@ export const Table = React.memo(props => {
<TableRow
id={`${id}-row-${rowIndex}`}
key={`${id}-row-${rowIndex}`}
data-testid={`${id}-row-${rowIndex}`}
hover={rowHover}
onClick={e => handleRowClick(e, d)}
>
{map(columns, (col, index) => (
<TableCell
id={`${id}-row-${rowIndex}-${col.field}`}
key={`${id}-row-${rowIndex}-${col.field}`}
data-testid={`${id}-row-${rowIndex}-${col.field}`}
component={index === 0 ? 'th' : 'td'}
scope={index === 0 ? 'row' : undefined}
align={col.align || (index === 0 ? 'left' : 'right')}
Expand Down
14 changes: 7 additions & 7 deletions app/core/clinicUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,31 +185,31 @@ export const clinicTierDetails = (clinic = {}) => {
},
tier0201: {
planName: 'essential',
entitlements: { ...entitlements, patientTags: true, clinicSites: true, summaryDashboard: true },
entitlements: { ...entitlements, deviceIssues: true, patientTags: true, clinicSites: true, summaryDashboard: true },
},
tier0202: {
planName: 'professional',
entitlements: { ...entitlements, patientTags: true, clinicSites: true, summaryDashboard: true },
entitlements: { ...entitlements, deviceIssues: true, patientTags: true, clinicSites: true, summaryDashboard: true },
},
tier0300: {
planName: 'professional',
entitlements: { ...entitlements, patientTags: true, clinicSites: true, summaryDashboard: true },
entitlements: { ...entitlements, deviceIssues: true, patientTags: true, clinicSites: true, summaryDashboard: true },
},
tier0301: {
planName: 'professional',
entitlements: { ...entitlements, rpmReport: true, patientTags: true, clinicSites: true, summaryDashboard: true, tideDashboard: true },
entitlements: { ...entitlements, rpmReport: true, deviceIssues: true, patientTags: true, clinicSites: true, summaryDashboard: true, tideDashboard: true },
},
tier0302: {
planName: 'professional',
entitlements: { ...entitlements, rpmReport: true, patientTags: true, clinicSites: true, summaryDashboard: true },
entitlements: { ...entitlements, rpmReport: true, deviceIssues: true, patientTags: true, clinicSites: true, summaryDashboard: true },
},
tier0303: {
planName: 'professional',
entitlements: { ...entitlements, rpmReport: true, patientTags: true, clinicSites: true, summaryDashboard: true, tideDashboard: true },
entitlements: { ...entitlements, rpmReport: true, deviceIssues: true, patientTags: true, clinicSites: true, summaryDashboard: true, tideDashboard: true },
},
tier0400: {
planName: 'enterprise',
entitlements: { ...entitlements, rpmReport: true, patientTags: true, clinicSites: true, summaryDashboard: true, tideDashboard: true },
entitlements: { ...entitlements, rpmReport: true, deviceIssues: true, patientTags: true, clinicSites: true, summaryDashboard: true, tideDashboard: true },
},
};

Expand Down
31 changes: 12 additions & 19 deletions app/pages/clinicworkspace/ClinicPatients.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import Button from '../../components/elements/Button';
import Icon from '../../components/elements/Icon';
import Table from '../../components/elements/Table';
import { TagList } from '../../components/elements/Tag';
import Pagination from '../../components/elements/Pagination';
import PaginationControls from './components/PaginationControls';
import TextInput from '../../components/elements/TextInput';
import BgSummaryCell from '../../components/clinic/BgSummaryCell';
import PatientForm from '../../components/clinic/PatientForm';
Expand Down Expand Up @@ -1563,11 +1563,8 @@ export const ClinicPatients = (props) => {
debounceSearch('');
}

const handlePageChange = useCallback((event, page) => {
setPatientFetchOptions({
...patientFetchOptions,
offset: (page - 1) * patientFetchOptions.limit,
});
const handleOffsetChange = useCallback(offset => {
setPatientFetchOptions({ ...patientFetchOptions, offset: offset });
}, [patientFetchOptions]);

function handleResetFilters() {
Expand Down Expand Up @@ -4223,7 +4220,6 @@ export const ClinicPatients = (props) => {

const renderPeopleTable = useCallback(() => {
const pageCount = Math.ceil(clinic?.fetchedPatientCount / patientFetchOptions.limit);
const page = Math.ceil(patientFetchOptions.offset / patientFetchOptions.limit) + 1;
const sort = patientFetchOptions.sort || defaultPatientFetchOptions.sort;

const patientListQueryState = getPatientListQueryState(activeFilters, patientListSearchTextInput);
Expand Down Expand Up @@ -4271,17 +4267,14 @@ export const ClinicPatients = (props) => {
/>

{pageCount > 1 && (
<Pagination
px="5%"
sx={{ width: '100%', position: 'absolute', bottom: '-50px' }}
id="clinic-patients-pagination"
count={pageCount}
disabled={pageCount < 2}
onChange={handlePageChange}
page={page}
showFirstButton={false}
showLastButton={false}
/>
<Box sx={{ width: '100%', position: 'absolute', bottom: '-50px' }}>
<PaginationControls
total={clinic?.fetchedPatientCount}
limit={patientFetchOptions.limit}
offset={patientFetchOptions.offset}
onOffsetChange={handleOffsetChange}
/>
</Box>
)}
</Box>
);
Expand All @@ -4290,7 +4283,7 @@ export const ClinicPatients = (props) => {
columns,
data,
defaultPatientFetchOptions.sort,
handlePageChange,
handleOffsetChange,
handleSortChange,
loading,
patientFetchOptions,
Expand Down
93 changes: 93 additions & 0 deletions app/pages/clinicworkspace/DeviceIssues/DeviceIssues.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React, { useEffect, useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation, Trans } from 'react-i18next';
import { colors as vizColors } from '@tidepool/viz';
import Table from '../../../components/elements/Table';
import { Box, Flex, Grid, Text } from 'theme-ui';

import FilterByCategory from './FilterByCategory';
import PaginationControls from '../components/PaginationControls';

import { setOffset, resetDeviceIssuesState } from './deviceIssuesSlice';
import { useGetDeviceIssuesPatientsQuery } from './deviceIssuesApi';
import useTableColumns from './useTableColumns';
import PatientCount from '../components/PatientCount';

const LIMIT = 12;

const DeviceIssues = () => {
const { t } = useTranslation();
const dispatch = useDispatch();

const selectedClinicId = useSelector(state => state.blip.selectedClinicId);
const category = useSelector(state => state.blip.deviceIssues.category);
const offset = useSelector(state => state.blip.deviceIssues.offset);

const columns = useTableColumns();

const { data } = useGetDeviceIssuesPatientsQuery(
{ clinicId: selectedClinicId, offset, category, limit: LIMIT },
{ skip: !selectedClinicId }
);

// reset state on dismount
useEffect(() => {
return () => dispatch(resetDeviceIssuesState());
}, []);

const handleChangeOffset = (newOffset) => dispatch(setOffset(newOffset));

if (!data) return null;

const tableData = data?.data || [];

const total = data?.meta?.count || 0;

return (
<>
<Flex mb={2}>
<Trans>
<Text sx={{ fontSize: 0, color: vizColors.blueGray50, fontStyle: 'italic' }}>
Only patients with active device issues or delayed data from a <Text sx={{ fontWeight: 'bold' }}>cloud-connected device</Text> will be displayed.
</Text>
</Trans>
</Flex>

<Flex mb={3} sx={{ justifyContent: 'center' }}>
<FilterByCategory />
</Flex>

<Table
id="deviceIssuesPatientsTable"
variant="condensed"
label="deviceIssuesPatientsTable"
columns={columns}
data={tableData}
// sx={tableStyle}
// onSort={handleSortChange}
// order={sort?.substring(0, 1) === '+' ? 'asc' : 'desc'}
// orderBy={sort?.substring(1)}
// onClickRow={handleClickPatient}
// emptyContentNode={}
/>

<Grid sx={{ gridTemplateColumns: '1fr 2fr 1fr' }}>
<Flex sx={{ alignItems: 'flex-end', padding: '0 0 24px 12px' }}>
<PatientCount offset={offset} limit={LIMIT} total={total} />
</Flex>
<Flex pb={4} sx={{ maxWidth: '640px', justifyContent: 'center', margin: '0 auto' }}>
<PaginationControls
limit={LIMIT}
total={total}
offset={offset}
onOffsetChange={handleChangeOffset}
/>
</Flex>
<Box></Box>
</Grid>
</>
);
};

export default DeviceIssues;
Loading