Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrate Patients page to using a direct Neo4j query #167

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
297 changes: 297 additions & 0 deletions frontend/src/components/NewRecordsList.tsx
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new version of RecordsList.tsx that's adapted for the Patients page. Because of the new query method, there were several changes made to RecordsList.tsx for the Patients page, so I rewrote it in a new file to make the development process less hairy.

The alternative would have been updating RecordsList.tsx to work for both Patients page and the other pages (Requests & Cohorts), and the RecordsList component's props would end up quite long and confusing.

Meanwhile, you can still see a diff between these files here.

Below, I'm going to clarify only the logic that are new/different (not many), but happy to clarity existing codes as well if you'd like.

Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
import AutoSizer from "react-virtualized-auto-sizer";
import { Button, Container, Modal } from "react-bootstrap";
import { Dispatch, SetStateAction, useCallback, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { DownloadModal } from "./DownloadModal";
import {
buildSentenceCaseString,
buildTsvString,
} from "../utils/stringBuilders";
import { AgGridReact } from "ag-grid-react";
import { useState } from "react";
import styles from "./records.module.scss";
import "ag-grid-community/styles/ag-grid.css";
import "ag-grid-community/styles/ag-theme-alpine.css";
import "ag-grid-enterprise";
import { ColDef, IServerSideGetRowsParams } from "ag-grid-community";
import { DataName, useHookLazyGeneric } from "../shared/types";
import SamplesList, { SampleContext } from "./SamplesList";
import {
DashboardRecordFilter,
DashboardRecordSort,
PatientIdsTriplet,
QueryDashboardPatientsArgs,
} from "../generated/graphql";
import { defaultColDef } from "../shared/helpers";
import { ErrorMessage, Toolbar } from "../shared/tableElements";
import { AgGridReact as AgGridReactType } from "ag-grid-react/lib/agGridReact";
import { BreadCrumb } from "../shared/components/BreadCrumb";
import { Title } from "../shared/components/Title";
import { parseUserSearchVal } from "../utils/parseSearchQueries";

const CACHE_BLOCK_SIZE = 100; // number of rows to fetch at a time
const MAX_ROWS_EXPORT = 10000;

interface INewRecordsListProps {
columnDefs: ColDef[];
dataName: DataName;
enableInfiniteScroll?: boolean;
lazyRecordsQuery: typeof useHookLazyGeneric;
defaultSort: DashboardRecordSort;
userSearchVal: string;
setUserSearchVal: Dispatch<SetStateAction<string>>;
setCustomSearchStates?: Dispatch<SetStateAction<PatientIdsTriplet[]>>;
searchInterceptor?: (userSearchVal: string) => Promise<string[]>;
showDownloadModal: boolean;
setShowDownloadModal: Dispatch<SetStateAction<boolean>>;
handleDownload: (recordCount: number) => void;
samplesColDefs: ColDef[];
sampleContext?: SampleContext;
userEmail?: string | null;
setUserEmail?: Dispatch<SetStateAction<string | null>>;
customToolbarUI?: JSX.Element;
}

export default function NewRecordsList({
columnDefs,
dataName,
enableInfiniteScroll = true,
lazyRecordsQuery,
defaultSort,
userSearchVal,
setUserSearchVal,
setCustomSearchStates,
searchInterceptor,
showDownloadModal,
setShowDownloadModal,
handleDownload,
samplesColDefs,
sampleContext,
userEmail,
setUserEmail,
customToolbarUI,
}: INewRecordsListProps) {
const [showClosingWarning, setShowClosingWarning] = useState(false);
const [unsavedChanges, setUnsavedChanges] = useState(false);

const gridRef = useRef<AgGridReactType>(null);
const navigate = useNavigate();
const params = useParams();

const [, { error, data, fetchMore, refetch }] = lazyRecordsQuery({
variables: {
searchVals: [],
sort: defaultSort,
limit: CACHE_BLOCK_SIZE,
offset: 0,
},
});

const dataNameCapitalized = buildSentenceCaseString(dataName);
const recordsQueryName = `dashboard${dataNameCapitalized}`;
const recordCountQueryName = `dashboard${dataNameCapitalized.slice(
0,
-1
)}Count`;
const recordCount = data?.[recordCountQueryName]?.totalCount;

const getServerSideDatasource = useCallback(
({ searchVals }) => {
return {
getRows: async (params: IServerSideGetRowsParams) => {
let filter: DashboardRecordFilter | undefined;
const filterModel = params.request.filterModel;
if (filterModel && Object.keys(filterModel).length > 0) {
filter = {
field: Object.keys(filterModel)[0],
values: filterModel[Object.keys(filterModel)[0]].values,
};
} else {
filter = undefined; // all filter values are selected
}

const fetchInput = {
searchVals,
sort: params.request.sortModel[0] || defaultSort,
filter,
offset: params.request.startRow ?? 0,
limit: CACHE_BLOCK_SIZE,
} as QueryDashboardPatientsArgs; // TODO: apply TS union typing when implementing this for Requests and Cohorts

const thisFetch =
params.request.startRow === 0
? refetch(fetchInput)
: fetchMore({
variables: fetchInput,
});

return thisFetch
.then((result) => {
params.success({
rowData: result.data[recordsQueryName],
rowCount: result.data[recordCountQueryName].totalCount,
});
})
.catch((error) => {
console.error(error);
params.fail();
});
},
};
},
[defaultSort, refetch, fetchMore, recordsQueryName, recordCountQueryName]
);

async function refreshData(userSearchVal: string) {
const extraSearchVals = searchInterceptor
? await searchInterceptor(userSearchVal)
: [];
const searchVals = [
...parseUserSearchVal(userSearchVal),
...extraSearchVals,
];
Comment on lines +146 to +152
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

searchInterceptor function is a new component prop that runs some logic before the standard search logic kicks in. For the Patients page, this function returns additional values to be searched by: patient IDs corresponding to the MRNs that are entered in the search bar.

const newDatasource = getServerSideDatasource({ searchVals });
gridRef.current?.api.setServerSideDatasource(newDatasource); // triggers a refresh
}

if (error) return <ErrorMessage error={error} />;

const handleClose = () => {
if (unsavedChanges) {
setShowClosingWarning(true);
} else {
navigate(`/${dataName}`);
}
};

return (
<Container fluid>
<BreadCrumb currPageTitle={dataName} />
<Title text={dataName} />

{showDownloadModal && (
<DownloadModal
loader={async () => {
// Using fetchMore instead of refetch to avoid overriding the cached variables
const { data } = await fetchMore({
variables: {
offset: 0,
limit: MAX_ROWS_EXPORT,
},
});
return buildTsvString(
data[recordsQueryName],
columnDefs,
gridRef.current?.columnApi?.getAllGridColumns()
);
}}
onComplete={() => setShowDownloadModal(false)}
exportFileName={`${dataName}.tsv`}
/>
)}

{showClosingWarning && (
<Modal
show={true}
centered
onHide={() => setShowClosingWarning(false)}
className={styles.overlay}
>
<Modal.Header closeButton>
<Modal.Title id="contained-modal-title-vcenter">
Are you sure?
</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
You have unsaved changes. Are you sure you want to exit this view?
</p>
</Modal.Body>
<Modal.Footer>
<Button
className={"btn btn-secondary"}
onClick={() => setShowClosingWarning(false)}
>
Cancel
</Button>
<Button
className={"btn btn-danger"}
onClick={() => {
setShowClosingWarning(false);
setUnsavedChanges(false);
navigate(`/${dataName}`);
}}
>
Continue Exiting
</Button>
</Modal.Footer>
</Modal>
)}

{Object.keys(params).length !== 0 && (
<AutoSizer>
{({ height, width }) => (
<Modal show={true} dialogClassName="modal-90w" onHide={handleClose}>
<Modal.Header closeButton />
<Modal.Body>
<div className={styles.popupHeight}>
<SamplesList
columnDefs={samplesColDefs}
parentDataName={dataName}
sampleContext={sampleContext}
setUnsavedChanges={setUnsavedChanges}
userEmail={userEmail}
setUserEmail={setUserEmail}
/>
</div>
</Modal.Body>
</Modal>
)}
</AutoSizer>
)}

<Toolbar
dataName={dataName}
userSearchVal={userSearchVal}
setUserSearchVal={setUserSearchVal}
setCustomSearchStates={setCustomSearchStates}
onSearch={async (userSearchVal) => refreshData(userSearchVal)}
matchingResultsCount={`${
recordCount !== undefined ? recordCount.toLocaleString() : "Loading"
} matching ${dataName}`}
onDownload={() => handleDownload(recordCount)}
customUIRight={customToolbarUI}
/>

<AutoSizer>
{({ width }) => (
<div
className={`ag-theme-alpine ${styles.tableHeight}`}
style={{ width: width }}
>
<AgGridReact
ref={gridRef}
rowModelType="serverSide"
serverSideInfiniteScroll={enableInfiniteScroll}
cacheBlockSize={CACHE_BLOCK_SIZE}
columnDefs={columnDefs}
debug={false}
context={{
navigateFunction: navigate,
}}
defaultColDef={defaultColDef}
onGridReady={(params) => {
params.api.sizeColumnsToFit();
}}
onFirstDataRendered={(params) => {
params.columnApi.autoSizeAllColumns();
}}
enableRangeSelection={true}
onGridColumnsChanged={() => refreshData(userSearchVal)}
/>
</div>
)}
</AutoSizer>
</Container>
);
}
3 changes: 1 addition & 2 deletions frontend/src/components/RecordsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,8 @@ import {
} from "ag-grid-community";
import { DataName, useHookLazyGeneric } from "../shared/types";
import SamplesList, { SampleContext } from "./SamplesList";
import { SortDirection } from "../generated/graphql";
import { PatientIdsTriplet, SortDirection } from "../generated/graphql";
import { defaultColDef } from "../shared/helpers";
import { PatientIdsTriplet } from "../pages/patients/PatientsPage";
import {
ErrorMessage,
LoadingSpinner,
Expand Down
18 changes: 9 additions & 9 deletions frontend/src/components/SamplesList.tsx
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most code changes here are simple renames, either to make names more generic for reuse (like DashboardSampleSort to DashboardRecordSort) or follow better JS naming practices (like handleDownload to onDownload).

Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {
AgGridSortDirection,
DashboardSampleFilter,
DashboardSampleSort,
DashboardRecordFilter,
DashboardRecordSort,
QueryDashboardSamplesArgs,
useDashboardSamplesLazyQuery,
} from "../generated/graphql";
Expand Down Expand Up @@ -41,9 +41,9 @@ import { parseUserSearchVal } from "../utils/parseSearchQueries";

const POLLING_INTERVAL = 5000; // 5s
const CACHE_BLOCK_SIZE = 100; // number of rows to fetch at a time
const MAX_ROWS_EXPORT = 5000;
const MAX_ROWS_EXPORT = 10000;
const MAX_ROWS_EXPORT_EXCEED_ALERT =
"You can only download up to 5,000 rows of data at a time. Please refine your search and try again. If you need the full dataset, contact the SMILE team at [email protected].";
"You can only download up to 10,000 rows of data at a time. Please refine your search and try again. If you need the full dataset, contact the SMILE team at [email protected].";
Comment on lines +44 to +46
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pavitra requested for this change 1-2 weeks ago (context). I made this quick change to the code running in prod but haven't officially commit-ed it until now.

const COST_CENTER_VALIDATION_ALERT =
"Please update your Cost Center/Fund Number input as #####/##### (5 digits, a forward slash, then 5 digits). For example: 12345/12345.";

Expand All @@ -62,7 +62,7 @@ interface ISampleListProps {
customToolbarUI?: JSX.Element;
}

const DEFAULT_SORT: DashboardSampleSort = {
const DEFAULT_SORT: DashboardRecordSort = {
colId: "importDate",
sort: AgGridSortDirection.Desc,
};
Expand Down Expand Up @@ -91,7 +91,7 @@ export default function SamplesList({
useDashboardSamplesLazyQuery({
variables: {
searchVals: [],
sampleContext,
context: sampleContext,
sort: DEFAULT_SORT,
limit: CACHE_BLOCK_SIZE,
offset: 0,
Expand All @@ -106,7 +106,7 @@ export default function SamplesList({
({ userSearchVal, sampleContext }) => {
return {
getRows: async (params: IServerSideGetRowsParams) => {
let filter: DashboardSampleFilter | undefined;
let filter: DashboardRecordFilter | undefined;
const filterModel = params.request.filterModel;
if (filterModel && Object.keys(filterModel).length > 0) {
filter = {
Expand Down Expand Up @@ -353,11 +353,11 @@ export default function SamplesList({
dataName={"samples"}
userSearchVal={userSearchVal}
setUserSearchVal={setUserSearchVal}
refreshData={(userSearchVal) => refreshData(userSearchVal)}
onSearch={(userSearchVal) => refreshData(userSearchVal)}
matchingResultsCount={`${
sampleCount !== undefined ? sampleCount.toLocaleString() : "Loading"
} matching samples`}
handleDownload={() => {
onDownload={() => {
if (sampleCount && sampleCount > MAX_ROWS_EXPORT) {
setAlertContent(MAX_ROWS_EXPORT_EXCEED_ALERT);
} else {
Expand Down
Loading