-
Notifications
You must be signed in to change notification settings - Fork 8
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
base: master
Are you sure you want to change the base?
Changes from 7 commits
599fcc4
a5f05fd
2cef6bf
52213a6
253c2bb
15742b8
f6b5f9b
ec75498
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
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> | ||
); | ||
} |
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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"; | ||
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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."; | ||
|
||
|
@@ -62,7 +62,7 @@ interface ISampleListProps { | |
customToolbarUI?: JSX.Element; | ||
} | ||
|
||
const DEFAULT_SORT: DashboardSampleSort = { | ||
const DEFAULT_SORT: DashboardRecordSort = { | ||
colId: "importDate", | ||
sort: AgGridSortDirection.Desc, | ||
}; | ||
|
@@ -91,7 +91,7 @@ export default function SamplesList({ | |
useDashboardSamplesLazyQuery({ | ||
variables: { | ||
searchVals: [], | ||
sampleContext, | ||
context: sampleContext, | ||
sort: DEFAULT_SORT, | ||
limit: CACHE_BLOCK_SIZE, | ||
offset: 0, | ||
|
@@ -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 = { | ||
|
@@ -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 { | ||
|
There was a problem hiding this comment.
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 toRecordsList.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.