diff --git a/src/API/explorePage.js b/src/API/explorePage.js index 08c4094b..eaea6097 100644 --- a/src/API/explorePage.js +++ b/src/API/explorePage.js @@ -2,22 +2,28 @@ import utils from './utils'; import { api } from './baseUrlProxy'; const routes = { - async getDrugChemicalPairs() { + async getDrugChemicalPairs({ + pagination, + sort, + filters, + }) { let response; try { response = await api.post( '/api/explore/drug-disease', { pagination: { - offset: 0, - limit: 1000, + offset: pagination.pageIndex * pagination.pageSize, + limit: pagination.pageSize, }, + sort, + filters, }, ); } catch (error) { return utils.handleAxiosError(error); } - return response.data.rows; + return response.data; }, }; diff --git a/src/pages/explore/DebouncedFilterBox.jsx b/src/pages/explore/DebouncedFilterBox.jsx new file mode 100644 index 00000000..a5ff35fc --- /dev/null +++ b/src/pages/explore/DebouncedFilterBox.jsx @@ -0,0 +1,49 @@ +import { + IconButton, + InputLabel, + FormControl, + InputAdornment, + FilledInput, +} from '@material-ui/core'; +import { Clear } from '@material-ui/icons'; +import React from 'react'; + +function DebouncedFilterBox({ + value: initialValue, onChange, debounce = 500, ...props +}) { + const [value, setValue] = React.useState(initialValue); + + React.useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + React.useEffect(() => { + const timeout = setTimeout(() => { + onChange(value); + }, debounce); + + return () => clearTimeout(timeout); + }, [value]); + + return ( + + Filter + setValue(e.target.value)} + variant="filled" + margin="dense" + endAdornment={( + + setValue('')} size="small"> + + + + )} + {...props} + /> + + ); +} + +export default React.memo(DebouncedFilterBox); diff --git a/src/pages/explore/DrugDiseasePairs.jsx b/src/pages/explore/DrugDiseasePairs.jsx index 06cc4dbb..7855d496 100644 --- a/src/pages/explore/DrugDiseasePairs.jsx +++ b/src/pages/explore/DrugDiseasePairs.jsx @@ -1,13 +1,22 @@ -import { Button, makeStyles } from '@material-ui/core'; +import { Button, makeStyles, TablePagination } from '@material-ui/core'; import { ArrowRight } from '@material-ui/icons'; import React from 'react'; import { Grid, Row, Col, } from 'react-bootstrap'; import { useHistory, Link } from 'react-router-dom'; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from '@tanstack/react-table'; import QueryBuilderContext from '~/context/queryBuilder'; import useQueryBuilder from '../queryBuilder/useQueryBuilder'; import explorePage from '~/API/explorePage'; +import TablePaginationActions from './TableActions'; +import HeaderCell from './HeaderCell'; +import Loading from '~/components/loading/Loading'; const useStyles = makeStyles({ hover: { @@ -18,12 +27,29 @@ const useStyles = makeStyles({ visibility: 'visible', }, }, + table: { + fontSize: '1.6rem', + width: '100%', + '& td, & th': { + paddingLeft: 12, + }, + '& td:first-of-type, & th:first-of-type': { + paddingLeft: 0, + }, + }, }); const fetchPairs = explorePage.getDrugChemicalPairs; export default function DrugDiseasePairs() { - const [pairs, setPairs] = React.useState([]); + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 20, + }); + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState([]); + + const [data, setData] = React.useState([]); const [isLoading, setIsLoading] = React.useState(true); // eslint-disable-next-line no-unused-vars @@ -65,16 +91,74 @@ export default function DrugDiseasePairs() { const classes = useStyles(); + const columnHelper = createColumnHelper(); + const columns = React.useMemo(() => ([ + columnHelper.accessor('disease_name', { + header: 'Disease', + cell: (info) => ( + <> + {info.row.original.disease_name} + {info.row.original.disease_id} + + ), + }), + columnHelper.accessor('drug_name', { + header: 'Drug', + cell: (info) => ( + <> + {info.row.original.drug_name} + {info.row.original.drug_id} + + ), + }), + columnHelper.accessor('score', { + header: 'Score', + cell: (info) => (info.row.original.known ? ( + + {info.row.original.score.toFixed(6)}* + + ) : ( + info.row.original.score.toFixed(6) + )), + enableColumnFilter: false, + }), + columnHelper.display({ + id: 'startQueryButton', + cell: (props) => ( + + ), + }), + ]), []); + + const sortParam = React.useMemo(() => Object.fromEntries( + sorting.map(({ id, desc }) => [id, desc ? 'desc' : 'asc']), + ), [sorting]); + + const filterParam = React.useMemo(() => Object.fromEntries( + columnFilters.map(({ id, value }) => [id, value]), + ), [columnFilters]); + React.useEffect(() => { + setIsLoading(true); let ignore = false; (async () => { try { - const data = await fetchPairs(); - if (ignore) return; - setPairs(data); + setData(await fetchPairs({ + pagination, + sort: sortParam, + filters: filterParam, + })); setIsLoading(false); } catch (e) { setError(e.message); @@ -85,7 +169,29 @@ export default function DrugDiseasePairs() { return () => { ignore = true; }; - }, []); + }, [ + pagination, + JSON.stringify(sortParam), // TODO: better deep equal check + JSON.stringify(filterParam), + ]); + + const table = useReactTable({ + data: data.rows || [], + columns, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + enableMultiSort: false, + manualSorting: true, + manualFiltering: true, + rowCount: data.num_of_results, + state: { + pagination, + sorting, + columnFilters, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + }); return ( @@ -107,60 +213,79 @@ export default function DrugDiseasePairs() {
- {isLoading ? 'Loading...' : ( - +
+ {isLoading && ( +
+ +
+ )} + +
- - - - - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} - { - pairs.map((pair, i) => ( - - + {row.getVisibleCells().map((cell) => ( + - - - - - )) - } + ))} + + ))}
-

Disease

- -
-

Drug

- -
-

Score

-
+ {header.isPlaceholder ? null : ( + + )} +
- {pair.disease_name} - {pair.disease_id} + {table.getRowModel().rows.map((row) => ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} - {pair.drug_name} - {pair.drug_id} - - { - pair.known ? ( - - {pair.score.toFixed(6)}* - - ) : ( - pair.score.toFixed(6) - ) - } -
- )} + + {data.num_of_results > 0 && ( + `${from}-${to} of ${ + count !== -1 ? count.toLocaleString() : `more than ${to}` + }`} + onChangePage={(_, page) => { + setPagination((prev) => ({ ...prev, pageIndex: page })); + }} + onChangeRowsPerPage={(e) => { + const pageSize = e.target.value ? Number(e.target.value) : 10; + setPagination(({ pageIndex: 0, pageSize })); + }} + ActionsComponent={TablePaginationActions} + /> + )} + + {data.num_of_results === 0 && ( +
+ No results found, please try a different filter. +
+ )} +
diff --git a/src/pages/explore/HeaderCell.jsx b/src/pages/explore/HeaderCell.jsx new file mode 100644 index 00000000..6a3fa7c3 --- /dev/null +++ b/src/pages/explore/HeaderCell.jsx @@ -0,0 +1,91 @@ +import { + Badge, Collapse, + IconButton, Tooltip, +} from '@material-ui/core'; +import { + ArrowDownward, + ArrowUpward, + FilterList, + SwapVert, +} from '@material-ui/icons'; +import { flexRender } from '@tanstack/react-table'; +import React from 'react'; +import DebouncedFilterBox from './DebouncedFilterBox'; + +/** + * @param {{ header: import('@tanstack/react-table').Header }} props + */ +export default function HeaderCell({ header }) { + const [isFilterOpen, setIsFilterOpen] = React.useState( + header.column.getIsFiltered(), + ); + + return ( +
+ {/* Main header */} +
+

+ {flexRender(header.column.columnDef.header, header.getContext())} +

+ + {/* Icon group */} + {(header.column.getCanFilter() || header.column.getCanSort()) && ( +
+ {header.column.getCanSort() && ( + + + {{ + asc: , + desc: , + }[header.column.getIsSorted()] || } + + + )} + + {header.column.getCanFilter() && ( + + + { + setIsFilterOpen(!isFilterOpen); + }} + > + + + + + )} +
+ )} +
+ + {/* Hidden filter section */} + {header.column.getCanFilter() && ( + + header.column.setFilterValue(value)} + /> + + )} +
+ ); +} diff --git a/src/pages/explore/TableActions.jsx b/src/pages/explore/TableActions.jsx new file mode 100644 index 00000000..84554040 --- /dev/null +++ b/src/pages/explore/TableActions.jsx @@ -0,0 +1,71 @@ +import { Box, IconButton, useTheme } from '@material-ui/core'; +import { + LastPage, FirstPage, KeyboardArrowRight, KeyboardArrowLeft, +} from '@material-ui/icons'; +import React from 'react'; + +const TablePaginationActions = (props) => { + const { + count, page, rowsPerPage, onChangePage, + } = props; + const theme = useTheme(); + + const handleFirstPageButtonClick = (event) => { + onChangePage(event, 0); + }; + + const handleBackButtonClick = (event) => { + onChangePage(event, page - 1); + }; + + const handleNextButtonClick = (event) => { + onChangePage(event, page + 1); + }; + + const handleLastPageButtonClick = (event) => { + onChangePage(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); + }; + + return ( + + + {theme.direction === 'rtl' ? : } + + + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="next page" + > + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="last page" + > + {theme.direction === 'rtl' ? : } + + + ); +}; + +export default TablePaginationActions;