diff --git a/src/components/CollapsibleTable.jsx b/src/components/CollapsibleTable.jsx deleted file mode 100644 index 179d6478e..000000000 --- a/src/components/CollapsibleTable.jsx +++ /dev/null @@ -1,409 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import _ from 'lodash'; -import Box from '@mui/material/Box'; -import CircularProgress from '@mui/material/CircularProgress'; -import Collapse from '@mui/material/Collapse'; -import IconButton from '@mui/material/IconButton'; -import { styled } from '@mui/material/styles'; -import Table from '@mui/material/Table'; -import TableBody from '@mui/material/TableBody'; -import TableCell, { tableCellClasses } from '@mui/material/TableCell'; -import TableContainer from '@mui/material/TableContainer'; -import TableHead from '@mui/material/TableHead'; -import TableRow, { tableRowClasses } from '@mui/material/TableRow'; -import Paper from '@mui/material/Paper'; -import Popover from '@mui/material/Popover'; -import Typography from '@mui/material/Typography'; -import ArticleOutlinedIcon from '@mui/icons-material/ArticleOutlined'; -import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; -import { isEmpty } from 'lodash'; -import { Checkbox } from '@mui/material'; - -/* -The data should follow the following format: - -const table = { - headers: [ - { - value: 'Header 1', - }, - { - value: () => 'Header 2', - }, - { - value: 'Header 3', - }, - ], - rows: [ - { - id: 'Row 1', - data: [ - { - value: 'Row 1, Cell 1', - truncate: true, - }, - { - value: 'Row 1, Cell 2', - truncate: false, - }, - { - value: 'Row 1, Cell 3', - truncate: false, - } - ], - subtable: { - headers: [ - { - value: 'Sub header 1', - }, - { - value: 'Sub header 2', - }, - ], - rows: [ - { - id: 'Sub Row 1', - data: [ - { - value: 'Sub Row 1, Cell 1', - }, - { - value: 'Sub Row 1, Cell 2', - }, - ], - }, - { - id: 'Sub Row 2', - data: [ - { - value: 'Sub Row 2, Cell 1', - }, - { - value: 'Sub Row 2, Cell 2', - }, - ], - }, - ], - } - }, - { - id: 'Row 2', - data: [ - { - value: 'Row 2, Cell 1', - }, - { - value: 'Row 2, Cell 2', - }, - { - value: 'Row 2, Cell 3', - } - ], - }, - ], -}; -*/ - -const SubtableRow = styled(TableRow)(() => ({ - [`&.${tableRowClasses.root}`]: { - border: '1px solid rgba(224, 224, 224, 1)' - } -})); - -const StyledTableCell = styled(TableCell)(() => ({ - paddingTop: '0px', - paddingBottom: '0px', - height: '57px', - [`&.${tableCellClasses.head}`]: { - color: '#333F52', - fontFamily: 'Montserrat', - fontSize: '14px', - fontWeight: 'bold', - lineHeight: '16px', - backgroundColor: '#e2e8f4', - textTransform: 'uppercase', - }, - [`&.${tableCellClasses.body}`]: { - fontSize: '14px', - lineHeight: '24px', - fontFamily: 'Montserrat', - fontWeight: 400, - color: '#333F52', - letterSpacing: 0, - } -})); - -const HeaderCell = styled(StyledTableCell)(() => ({ - [`&.${tableCellClasses.root}`]: { - height: '40px' - } -})); - -const TruncatedTableCell = styled(StyledTableCell)(() => ({ - [`&.${tableCellClasses.root}`]: { - padding: '8px', - maxWidth: '20ch', - whiteSpace: 'nowrap', - overflow: 'hidden', - textOverflow: 'ellipsis', - transition: 'max-width 0.5s', - '&:hover': { - whiteSpace: 'normal', - overflow: 'normal', - wordBreak: 'break-all' - }, - }, -})); - -const renderValue = (data) => { - if (_.isEmpty(data)) { - return null; - } - if (_.isFunction(data.value)) { - return data.value(); - } - return data.value; -}; - -const CollapsibleRow = (props) => { - const { row, row: { subtable: { rows: subrows } }, selected, selectHandler, expandHandler, collapseHandler } = props; - - const [open, setOpen] = useState(false); - const [toggling, setToggling] = useState(false); - - const isSelected = (id) => selected.indexOf(id) !== -1; - const allSelected = subrows.every((row) => isSelected(row.id)); - const someSelected = subrows.some((row) => isSelected(row.id)); - - const openHandler = async (event, data) => { - if (_.isFunction(expandHandler)) { - await expandHandler(event, data); - } - setOpen(true); - }; - - const closeHandler = async (event, data) => { - if (_.isFunction(collapseHandler)) { - await collapseHandler(event, data); - } - setOpen(false); - }; - - const toggleHandler = async (event, data) => { - setToggling(true); - if (open) { - await closeHandler(event, data); - } else { - await openHandler(event, data); - } - setToggling(false); - }; - - return ( - - {/* main table row */} - - - selectHandler(event, row, 'row')} - checked={allSelected} - indeterminate={someSelected && !allSelected} - /> - - - toggleHandler(event, row)} - disabled={toggling} - > - {toggling && } - {!toggling ? (open ? : ) : null} - - - {row.data.map((cell, i) => { - return ; - })} - - {/* subtable */} - - - - - - {/* subtable header */} - - - - {row.subtable.headers.map((header, i) => ( - {renderValue(header)} - ))} - - - {/* subtable rows */} - - {subrows.map((subRow, j) => ( - - - selectHandler(event, subRow, 'subrow')} - checked={isSelected(subRow.id)} - /> - - {subRow.data.map((cell, k) => { - return ; - })} - - ))} - -
-
-
-
-
-
- ); -}; - -export const CollapsibleTable = (props) => { - const { data, summary, selected, selectHandler, expandHandler } = props; - - const [allSelected, setAllSelected] = useState(false); - const [someSelected, setSomeSelected] = useState(false); - - useEffect(() => { - const isSelected = (id) => selected.indexOf(id) !== -1; - if (!isEmpty(data) && !isEmpty(data.rows)) { - setAllSelected(data.rows.every((row) => { - return row.subtable.rows.every((subRow) => isSelected(subRow.id)); - })); - setSomeSelected(data.rows.some((row) => { - return row.subtable.rows.some((subRow) => isSelected(subRow.id)); - })); - } - }, [data, selected]); - - return !isEmpty(data) && ( - - - {/* main table header */} - - - - selectHandler(event, data, 'all')} - checked={allSelected} - indeterminate={someSelected && !allSelected} - /> - - - {data.headers.map((header, i) => ( - {renderValue(header)} - ))} - - - {/* main table rows */} - - {data.rows.map((row) => ( - - ))} - -
-
- ); -}; - -export default CollapsibleTable; - -const TableCellRenderer = ({ cell }) => { - - const [anchorEl, setAnchorEl] = React.useState(null); - - const handlePopoverOpen = (event) => { - setAnchorEl(event.currentTarget); - }; - - const handlePopoverClose = () => { - setAnchorEl(null); - }; - - const popoverOpen = Boolean(anchorEl); - - if (cell?.hideUnderIcon) { - return ( - - - - - {renderValue(cell)} - - - - ); - } - - if (cell?.truncate && cell?.increaseWidth) { - return - {renderValue(cell)} - ; - } - - if (cell?.truncate) { - return - {renderValue(cell)} - ; - } - - if (cell?.increaseWidth) { - return - {renderValue(cell)} - ; - } - - // Default case: - return - {renderValue(cell)} - ; -}; - -const SubtableCellRenderer = ({ cell }) => { - - if (cell?.increaseWidth) { - return - {renderValue(cell)} - ; - } - - // Default case: - return - {renderValue(cell)} - ; - -}; diff --git a/src/components/SimpleTable.jsx b/src/components/SimpleTable.jsx index 989bfba08..069735599 100644 --- a/src/components/SimpleTable.jsx +++ b/src/components/SimpleTable.jsx @@ -104,7 +104,7 @@ const DataRows = ({rowData, baseStyle, columnHeaders, rowWrapper = ({renderedRow let output; //columnHeaders determine width of the columns, //therefore extract width from columnHeader and apply to cell style - const columnWidthStyle = columnHeaders[cellIndex].cellStyle; + const columnWidthStyle = { width: columnHeaders[cellIndex].cellStyle.width }; const appliedStyle = Object.assign({}, style, columnWidthStyle); //assume component is in hyperscript format //wrap component in dive with columnWidth applied diff --git a/src/components/Tooltips.tsx b/src/components/Tooltips.tsx new file mode 100644 index 000000000..392526fba --- /dev/null +++ b/src/components/Tooltips.tsx @@ -0,0 +1,36 @@ +import ReactTooltip from 'react-tooltip'; +import * as React from 'react'; +import {useEffect, useRef, useState} from 'react'; + +// Copied from ReactTooltip Types +type Place = 'top' | 'right' | 'bottom' | 'left'; + +interface TooltipProps { + tooltipText: string; + children: React.ReactNode | React.ReactNode[]; + id: string; + place: Place +} +export const OverflowTooltip = (props: TooltipProps) => { + const ref = useRef(null); + const [isOverflown, setIsOverflown] = useState(false); + useEffect(() => { + const element = ref.current!; + setIsOverflown(element.offsetWidth < element.scrollWidth); + }, []); + + return <> +
{props.children}
+
{props.tooltipText}
+ ; +}; diff --git a/src/components/data_search/DatasetSearchTable.jsx b/src/components/data_search/DatasetSearchTable.jsx index a3d911fd4..e57d18a78 100644 --- a/src/components/data_search/DatasetSearchTable.jsx +++ b/src/components/data_search/DatasetSearchTable.jsx @@ -1,66 +1,67 @@ +import Tab from '@mui/material/Tab'; +import Tabs from '@mui/material/Tabs'; import * as React from 'react'; -import _ from 'lodash'; -import { Box, Button, Link } from '@mui/material'; +import { Box, Button } from '@mui/material'; import { useEffect, useState, useRef } from 'react'; -import { groupBy, isEmpty, concat, compact, map } from 'lodash'; -import CollapsibleTable from '../CollapsibleTable'; +import { isEmpty } from 'lodash'; +import { TerraDataRepo } from '../../libs/ajax/TerraDataRepo'; +import { DatasetSearchTableDisplay } from './DatasetSearchTableDisplay'; +import { datasetSearchTableTabs } from './DatasetSearchTableConstants'; import TableHeaderSection from '../TableHeaderSection'; -import DatasetExportButton from './DatasetExportButton'; import { DataSet } from '../../libs/ajax/DataSet'; import { DAR } from '../../libs/ajax/DAR'; -import eventList from '../../libs/events'; -import { Config } from '../../libs/config'; import DatasetFilterList from './DatasetFilterList'; -import { Metrics } from '../../libs/ajax/Metrics'; import { Notifications } from '../../libs/utils'; import { Styles } from '../../libs/theme'; -import { TerraDataRepo } from '../../libs/ajax/TerraDataRepo'; -import isEqual from 'lodash/isEqual'; -import TranslatedDulModal from '../modals/TranslatedDulModal'; - +import * as _ from 'lodash'; + +const styles = { + subTab: { + padding: '0 25px', + fontSize: '15px', + textTransform: 'none', + fontFamily: 'Montserrat, sans-serif', + color: '#00609f', + }, + subTabActive: { + fontWeight: 'bold', + borderBottom: `5px solid #00609f` + }, +}; -const studyTableHeader = [ - 'Study Name', - 'Description', - 'Datasets', - 'Participants', - 'Phenotype', - 'Species', - 'PI Name', - 'Data Custodian', -]; -const datasetTableHeader = [ - 'DUOS ID', - 'Dataset Name', - 'Data Use', - 'Data Types', - 'Participants', - 'Access Type', - 'Data Location', - 'Export to Terra', -]; const defaultFilters = { accessManagement: [], dataUse: [], -} +}; export const DatasetSearchTable = (props) => { const { datasets, history, icon, title } = props; + const [exportableDatasets, setExportableDatasets] = useState(); const [filters, setFilters] = useState(defaultFilters); const [filtered, setFiltered] = useState([]); - const [tableData, setTableData] = useState({}); const [selected, setSelected] = useState([]); - const [exportableDatasets, setExportableDatasets] = useState({}); // datasetId -> snapshot - const [tdrApiUrl, setTdrApiUrl] = useState(''); - const [showTranslatedDULModal, setShowTranslatedDULModal] = useState(false); - const [dataUse, setDataUse] = useState(); + const [selectedTable, setSelectedTable] = useState(datasetSearchTableTabs.study); const searchRef = useRef(''); const isFiltered = (filter, category) => (filters[category]).indexOf(filter) > -1; const numSelectedFilters = (filters) => Object.values(filters).reduce((sum, array) => sum + array.length, 0); + const getExportableDatasets = async (datasets) => { + // Note the dataset identifier is in each sub-table row. + const datasetIdentifiers = datasets.map((row) => row.datasetIdentifier); + const snapshots = await TerraDataRepo.listSnapshotsByDatasetIds(datasetIdentifiers); + if (snapshots.filteredTotal > 0) { + const datasetIdToSnapshot = _.chain(snapshots.items) + // Ignore any snapshots that a user does not have export (steward or reader) to + .filter((snapshot) => _.intersection(snapshots.roleMap[snapshot.id], ['steward', 'reader']).length > 0) + .groupBy('duosId') + .value(); + setExportableDatasets(datasetIdToSnapshot); + } + }; + const assembleFullQuery = (searchTerm, filters) => { const queryChunks = [ { @@ -171,7 +172,7 @@ export const DatasetSearchTable = (props) => { const search = async () => { try { await DataSet.searchDatasetIndex(fullQuery).then((filteredDatasets) => { - var newFiltered = datasets.filter(value => filteredDatasets.some(item => isEqual(item, value))); + var newFiltered = datasets.filter(value => filteredDatasets.some(item => _.isEqual(item, value))); setFiltered(newFiltered); }); } catch (error) { @@ -180,74 +181,8 @@ export const DatasetSearchTable = (props) => { }; search(); }; - - - - const selectHandler = async (event, data, selector) => { - let idsToModify = []; - if (selector === 'all') { - data.rows.forEach((row) => { - row.subtable.rows.forEach((subRow) => { - idsToModify.push(subRow.id); - }); - }); - } else if (selector === 'row') { - const rowIds = data.subtable.rows.map(row => row.id); - const isRowSelected = rowIds.every(id => selected.includes(id)); - isRowSelected ? - await Metrics.captureEvent(eventList.dataLibrary, {'action': 'study-unselected'}) : - await Metrics.captureEvent(eventList.dataLibrary, {'action': 'study-selected'}); - data.subtable.rows.forEach((row) => { - idsToModify.push(row.id); - }); - } else if (selector === 'subrow') { - selected.includes(data.id) ? - await Metrics.captureEvent(eventList.dataLibrary, {'action': 'dataset-unselected'}) : - await Metrics.captureEvent(eventList.dataLibrary,{'action': 'dataset-selected'}); - idsToModify.push(data.id); - } - - let newSelected = []; - const allSelected = idsToModify.every((id) => selected.includes(id)); - if (allSelected) { - newSelected = selected.filter((id) => !idsToModify.includes(id)); - } else { - newSelected = selected.concat(idsToModify); - } - - setSelected(newSelected); - }; - - const getExportableDatasets = async (event, data) => { - setTdrApiUrl(await Config.getTdrApiUrl()); - // Note the dataset identifier is in each sub-table row. - const datasetIdentifiers = data.subtable.rows.map((row) => row.datasetIdentifier); - const snapshots = await TerraDataRepo.listSnapshotsByDatasetIds(datasetIdentifiers); - if (snapshots.filteredTotal > 0) { - const datasetIdToSnapshot = _.chain(snapshots.items) - // Ignore any snapshots that a user does not have export (steward or reader) to - .filter((snapshot) => _.intersection(snapshots.roleMap[snapshot.id], ['steward', 'reader']).length > 0) - .groupBy('duosId') - .value(); - setExportableDatasets(datasetIdToSnapshot); - } - }; - - const expandHandler = async (event, data) => { - try { - getExportableDatasets(event, data); - } catch { - Notifications.showError({ text: 'Unable to retrieve exportable datasets from Terra' }); - } - }; - - const collapseHandler = () => { - setExportableDatasets({}); - }; - const applyForAccess = async () => { - const draftDatasets = selected.map((id) => parseInt(id.replace('dataset-', ''))); - const darDraft = await DAR.postDarDraft({ datasetId: draftDatasets }); + const darDraft = await DAR.postDarDraft({ datasetId: selected }); history.push(`/dar_application/${darDraft.referenceId}`); }; @@ -256,146 +191,12 @@ export const DatasetSearchTable = (props) => { filterHandler(null, datasets, '', ''); }; - const openTranslatedDUL = (dataUse) => { - const mergedPrimaryAndSecondary = concat(dataUse.primary, dataUse.secondary); - setDataUse(mergedPrimaryAndSecondary); - setShowTranslatedDULModal(true); - }; - useEffect(() => { if (isEmpty(filtered)) { return; } - - const studies = groupBy(filtered, 'study.studyId'); - const table = { - id: 'study-table', - headers: studyTableHeader.map((header) => ({ value: header })), - rows: Object.values(studies).map((entry, index) => { - const sum = entry.reduce((acc, dataset) => { - return acc + dataset.participantCount; - }, 0); - return { - id: 'study-' + entry[0].study.studyId, - data: [ - { - value: entry[0].study.studyName, - truncate: true, - increaseWidth: true, - }, - { - value: entry[0].study.description, - hideUnderIcon: true, - }, - { - value: entry.length, - truncate: true, - }, - { - value: isNaN(sum) ? undefined : sum, - truncate: true, - }, - { - value: entry[0].study.phenotype, - truncate: true, - }, - { - value: entry[0].study.species, - truncate: true, - }, - { - value: entry[0].study.piName, - truncate: true, - }, - { - value: entry[0].study.dataCustodianEmail?.join(', '), - truncate: true, - }, - ], - subtable: { - headers: datasetTableHeader.map((header) => ({ value: header })), - rows: entry.map((dataset) => { - return { - id: 'dataset-' + dataset.datasetId, - datasetIdentifier: dataset.datasetIdentifier, - data: [ - { - value: {dataset.datasetIdentifier}, - increaseWidth: true, - }, - { - value: dataset.datasetName, - truncate: true, - }, - { - value: () => { - const dataUse = dataset.dataUse; - const primaryDataUse = map(dataUse?.primary, 'code').join(', '); - const secondaryDataUse = map(dataUse?.secondary, 'code').join(', '); - const mergedDataUse = compact(concat(primaryDataUse, secondaryDataUse)).join(', '); - return openTranslatedDUL(dataUse)} - >{mergedDataUse}; - } - }, - { - value: dataset.dataTypes, - }, - { - value: dataset.participantCount, - }, - { - value: () => { - let accessType; - if (dataset.accessManagement === 'external') { - accessType = dataset.url ? External to DUOS : 'External to DUOS'; - } else if (dataset.accessManagement === 'open') { - accessType = dataset.url ? Open Access : 'Open Access'; - } else { - accessType = dataset.dac?.dacEmail ? {dataset.dac?.dacName} : dataset.dac?.dacName; - } - return accessType; - } - }, - { - value: () => { - const exportableSnapshots = exportableDatasets[dataset.datasetIdentifier] || []; - if (exportableSnapshots.length === 0) { - return dataset.dataLocation; - } - return exportableSnapshots.map((snapshot, i) => - - {snapshot.name}{'\n'} - ); - } - }, - { - value: () => { - const exportableSnapshots = exportableDatasets[dataset.datasetIdentifier] || []; - return exportableSnapshots - .map((snapshot, i) => - ); - } - }, - ], - }; - }), - } - }; - }), - }; - - setTableData(table); - }, [filtered, exportableDatasets, tdrApiUrl]); + getExportableDatasets(filtered); + }, [filtered]); useEffect(() => { setFiltered(datasets); @@ -432,6 +233,24 @@ export const DatasetSearchTable = (props) => { + + + {Object.values(datasetSearchTableTabs).map((tab) => setSelectedTable(tab)} + component={Button} + />)} + + @@ -446,25 +265,8 @@ export const DatasetSearchTable = (props) => {

No datasets registered for this library.

); - } else if (isEmpty(filtered)) { - return ( - -

There are no datasets that fit these criteria.

-
- ); } else { - return ( - - ); + return ; } })()}
@@ -478,14 +280,6 @@ export const DatasetSearchTable = (props) => { } - { - showTranslatedDULModal && - setShowTranslatedDULModal(false)} - /> - } ); }; diff --git a/src/components/data_search/DatasetSearchTableConstants.tsx b/src/components/data_search/DatasetSearchTableConstants.tsx new file mode 100644 index 000000000..822f20780 --- /dev/null +++ b/src/components/data_search/DatasetSearchTableConstants.tsx @@ -0,0 +1,428 @@ +import {DatasetTerm} from 'src/types/model'; +import _, {groupBy} from 'lodash'; +import {Checkbox, Link} from '@mui/material'; +import * as React from 'react'; +import {OverflowTooltip} from '../Tooltips'; +import {SnapshotSummaryModel} from 'src/types/tdrModel'; +import DatasetExportButton from './DatasetExportButton'; + +export interface DatasetSearchTableTab { + key: string; + label: string; + makeHeaders: (datasets: DatasetTerm[], selected: number[], onSelect: (datasetIds: number[]) => void, exportableDatasets: { [duosId: string]: SnapshotSummaryModel[] }) => HeaderData[]; + makeRows: (datasets: DatasetTerm[], headers: HeaderData[]) => CellData[][]; +} + +interface DatasetSearchTableTabs { + study: DatasetSearchTableTab; + dataset: DatasetSearchTableTab; +} + + + +const makeHeaderStyle = (width: string | number): React.CSSProperties => ({ + width, +}); + +const makeRowStyle = (width: string | number): React.CSSProperties => ({ + width, + textOverflow: 'ellipsis', + textWrap: 'nowrap', + overflow: 'hidden', + paddingRight: 5 +}); + +const trimNewlineCharacters = (str: string): string => str?.replace( /[\r\n]+/gm, ''); + +interface CellData{ + data: string | React.ReactElement; + value: string | React.ReactElement; + id: string; + style?: React.CSSProperties; + label: string; +} + +interface HeaderData{ + label: string | React.ReactElement; + sortable: boolean; + cellStyle: React.CSSProperties; + cellDataFn: (datasets: T) => CellData; +} + +// eslint-disable-next-line no-unused-vars +export const makeStudyTableHeaders = (datasets: DatasetTerm[], selected: number[], onSelect: (datasetIds: number[]) => void, _exportableDatasets: { [duosId: string]: SnapshotSummaryModel[] }): HeaderData[] => { + interface StudyCellWidths{ + selected: number; + studyName: string; + description: string; + participants: string; + phenotype: string; + species: string; + piName: string; + dataCustodians: string; + } + const studyCellWidths: StudyCellWidths = { + selected: 50, + // subtract the selected column width + studyName: 'calc(25% - 50px)', + description: '20%', + participants: '10%', + phenotype: '15%', + species: '10%', + piName: '10%', + dataCustodians: '20%' + }; + const datasetIds = datasets.map(dataset => dataset.datasetId); + return [ + { + label: 0 && selected.length < datasetIds.length} + onClick={() => onSelect(datasetIds.length === selected.length ? [] : datasetIds)}/>, + sortable: false, + cellStyle: makeHeaderStyle(studyCellWidths.selected), + cellDataFn: datasets => { + const studyDatasetIds = datasets.map(dataset => dataset.datasetId); + const numberSelected = _.intersection(studyDatasetIds, selected).length; + const fullySelected = numberSelected === studyDatasetIds.length; + const indeterminate = numberSelected > 0 && numberSelected < studyDatasetIds.length; + return { + data: onSelect(fullySelected ? _.without(selected, ...studyDatasetIds) : indeterminate ? _.xor(_.without(selected, ...studyDatasetIds), studyDatasetIds) : [...selected, ...studyDatasetIds])}/>, + value: fullySelected ? 'Selected' : indeterminate ? 'Partially Selected' : 'Not Selected', + id: `${datasets[0].study.studyId}-is-selected`, + style: makeRowStyle(studyCellWidths.selected), + label: `Study ${datasets[0].study.studyId} is ${fullySelected ? '' : indeterminate ? 'partially ' : 'not '} selected` + }; + } + }, + { + label: 'Study Name', + sortable: true, + cellStyle: makeHeaderStyle(studyCellWidths.studyName), + cellDataFn: (datasets) => ({ + data: + {trimNewlineCharacters(datasets[0].study.studyName)} + , + value: datasets[0].study.studyName, + id: `${datasets[0].study.studyId}-study-name`, + style: makeRowStyle(studyCellWidths.selected), + label: datasets[0].study.studyName + }) + }, + { + label: 'Description', + sortable: true, + cellStyle: makeHeaderStyle(studyCellWidths.description), + cellDataFn: (datasets) => ({ + data: + {trimNewlineCharacters(datasets[0].study.description)} + , + value: datasets[0].study.description, + id: `${datasets[0].study.studyId}-study-description`, + style: makeRowStyle(studyCellWidths.description), + label: datasets[0].study.description + }) + }, + { + label: 'Participants', + sortable: true, + cellStyle: makeHeaderStyle(studyCellWidths.participants), + cellDataFn: (datasets) => { + const participantCount = datasets.map(dataset => dataset.participantCount).reduce((partialSum, participants) => partialSum + participants, 0); + return { + data: isNaN(participantCount) ? '' : participantCount.toString(), + value: participantCount.toString(), + id: `${datasets[0].study.studyId}-participant-count`, + style: makeRowStyle(studyCellWidths.participants), + label: `Participant Count for study ${datasets[0].study.studyId}: ${participantCount}` + }; + } + }, + { + label: 'Phenotype', + sortable: true, + cellStyle: makeHeaderStyle(studyCellWidths.phenotype), + cellDataFn: (datasets) => ({ + data: + {trimNewlineCharacters(datasets[0].study.phenotype)} + , + value: datasets[0].study.phenotype, + id: `${datasets[0].study.studyId}-phenotype`, + style: makeRowStyle(studyCellWidths.phenotype), + label: `Phenotype for study ${datasets[0].study.studyId}: ${datasets[0].study.phenotype}` + }) + }, + { + label: 'Species', + sortable: true, + cellStyle: makeHeaderStyle(studyCellWidths.species), + cellDataFn: (datasets) => ({ + data: + {trimNewlineCharacters(datasets[0].study.species)} + , + style: makeRowStyle(studyCellWidths.species), + value: datasets[0].study.species, + id: `${datasets[0].study.studyId}-species`, + label: `Species for study ${datasets[0].study.studyId}: ${datasets[0].study.species}` + }) + }, + { + label: 'PI Name', + sortable: true, + cellStyle: makeHeaderStyle(studyCellWidths.piName), + cellDataFn: (datasets) => ({ + data: + {trimNewlineCharacters(datasets[0].study.piName)} + , + value: datasets[0].study.piName, + id: `${datasets[0].study.studyId}-pi-name`, + style: makeRowStyle(studyCellWidths.piName), + label: `PI for study ${datasets[0].study.studyId}: ${datasets[0].study.piName}` + }) + }, + { + label: 'Data Custodian', + sortable: true, + cellStyle: makeHeaderStyle(studyCellWidths.dataCustodians), + cellDataFn: (datasets) => { + const custodians = datasets[0].study.dataCustodianEmail.join(', '); + return { + data: + {custodians} + , + value: custodians, + id: `${datasets[0].study.studyId}-custodians`, + style: makeRowStyle(studyCellWidths.dataCustodians), + label: `Data Custodians for study ${datasets[0].study.studyId}: ${custodians}` + }; + } + }, + ]; +}; + +export const makeStudyTableRowData = (datasets: DatasetTerm[], headers: HeaderData[]) => { + const studies = groupBy(datasets, 'study.studyId'); + return Object.values(studies).map((datasets: DatasetTerm[]) => headers.map(header => header.cellDataFn(datasets))); +}; + +export const makeDatasetTableHeader = (datasets: DatasetTerm[], selected: number[], onSelect: (datasetIds: number[]) => void, exportableDatasets: { [duosId: string]: SnapshotSummaryModel[] }): HeaderData[] => { + interface CellWidths{ + selected: number; + datasetName: string; + studyName: string; + duosId: string; + accessType: string; + dataType: string; + donorSize: string; + dataLocation: string; + dac: string; + exportToTerra: number; + } + const cellWidths: CellWidths = { + selected: 50, + // subtract the selected column width + datasetName: 'calc(25% - 150px)', + studyName: '10%', + duosId: '10%', + accessType: '10%', + dataType: '15%', + donorSize: '7%', + dataLocation: '13%', + dac: '10%', + exportToTerra: 100, + }; + const datasetIds = datasets.map(dataset => dataset.datasetId); + return [ + { + label: 0 && selected.length < datasets.length} + onClick={() => onSelect(datasetIds.length === selected.length ? [] : datasetIds)}/>, + sortable: false, + cellStyle: makeHeaderStyle(cellWidths.selected), + cellDataFn: (dataset: DatasetTerm) => { + const isSelected = selected.includes(dataset.datasetId); + return { + data: onSelect(_.xor([dataset.datasetId], selected))}/>, + value: isSelected ? 'Selected' : 'Not Selected', + id: `${dataset.datasetId}-is-selected`, + style: makeRowStyle(cellWidths.selected), + label: `Dataset ${dataset.datasetId} is ${isSelected ? '' : 'not '} selected` + }; + } + }, + { + label: 'Dataset Name', + sortable: true, + cellStyle: makeHeaderStyle(cellWidths.datasetName), + cellDataFn: (dataset: DatasetTerm) => ({ + data: + {trimNewlineCharacters(dataset.datasetName)} + , + value: dataset.datasetName, + id: `${dataset.datasetId}-dataset-name`, + style: makeRowStyle(cellWidths.datasetName), + label: dataset.datasetName + }) + }, + { + label: 'Study Name', + sortable: true, + cellStyle: makeHeaderStyle(cellWidths.studyName), + cellDataFn: (dataset: DatasetTerm) => ({ + data: + {trimNewlineCharacters(dataset.study.studyName)} + , + value: dataset.study.studyName, + id: `${dataset.datasetId}-study-name`, + style: makeRowStyle(cellWidths.studyName), + label: dataset.study.studyName, + }) + }, + { + label: 'DUOS Id', + sortable: true, + cellStyle: makeHeaderStyle(cellWidths.duosId), + cellDataFn: (dataset: DatasetTerm) => ({ + data: {dataset.datasetIdentifier}, + value: dataset.datasetIdentifier, + id: `${dataset.datasetId}-study-description`, + style: makeRowStyle(cellWidths.duosId), + label: dataset.datasetIdentifier + }) + }, + { + label: 'Access Type', + sortable: true, + cellStyle: makeHeaderStyle(cellWidths.accessType), + cellDataFn: (dataset: DatasetTerm) => { + let accessType; + if (dataset.accessManagement === 'external') { + accessType = dataset.url ? + External to DUOS : 'External to DUOS'; + } else if (dataset.accessManagement === 'open') { + accessType = dataset.url ? Open Access : 'Open Access'; + } else { + accessType = dataset.dac?.dacEmail ? {dataset.dac?.dacName} : dataset.dac?.dacName; + } + return { + data: accessType, + value: dataset.accessManagement, + id: `${dataset.datasetId}-participant-count`, + style: makeRowStyle(cellWidths.accessType), + label: `Access Type for dataset ${dataset.datasetId}: ${dataset.accessManagement}` + }; + } + }, + { + label: 'Data Type', + sortable: true, + cellStyle: makeHeaderStyle(cellWidths.dataType), + cellDataFn: (dataset: DatasetTerm) => ({ + data: + {dataset.study.dataTypes?.join(', ')} + , + value: dataset.study.dataTypes?.join(', '), + id: `${dataset.datasetId}-data-types`, + style: makeRowStyle(cellWidths.dataType), + label: `Data Types for dataset ${dataset.datasetId}: ${dataset.study.dataTypes?.join(', ')}` + }) + }, + { + label: 'Donor Size', + sortable: true, + cellStyle: makeHeaderStyle(cellWidths.donorSize), + cellDataFn: (dataset: DatasetTerm) => { + const donorSize = isNaN(dataset.participantCount) ? '' : dataset.participantCount.toString(); + return { + data: donorSize, + style: makeRowStyle(cellWidths.donorSize), + value: donorSize, + id: `${dataset.datasetId}-participant-count`, + label: `Participant Count for dataset ${dataset.datasetId}: ${donorSize}` + }; + } + }, + { + label: 'Data Location', + sortable: true, + cellStyle: makeHeaderStyle(cellWidths.dataLocation), + cellDataFn: (dataset: DatasetTerm) => { + let dataLocation; + if (dataset.dataLocation === 'TDR Location') { + dataLocation = dataset.url ? + Terra Data Repo : 'Terra Data Repo'; + } else if (dataset.dataLocation === 'Terra Workspace') { + dataLocation = dataset.url ? + Terra Workspace : 'Terra Data Repo'; + } else if (dataset.dataLocation === 'Not Determined') { + dataLocation = 'Not Determined'; + } else { + dataLocation = dataset.url ? + External to DUOS : 'External Location'; + } + return { + data: dataLocation, + value: dataset.accessManagement, + id: `${dataset.datasetId}-participant-count`, + style: makeRowStyle(cellWidths.accessType), + label: `Access Type for dataset ${dataset.datasetId}: ${dataset.dataLocation}` + }; + } + }, + { + label: 'DAC', + sortable: true, + cellStyle: makeHeaderStyle(cellWidths.dac), + cellDataFn: (dataset: DatasetTerm) => ({ + data: + {dataset.dac?.dacName} + , + value: dataset.dac?.dacName, + id: `${dataset.datasetId}-dac`, + style: makeRowStyle(cellWidths.dac), + label: `DAC for dataset ${dataset.datasetId}: ${dataset.dac?.dacName}` + }) + }, + { + label: 'Export to Terra', + sortable: false, + cellStyle: makeHeaderStyle(cellWidths.exportToTerra), + cellDataFn: (dataset: DatasetTerm) => { + const exportableSnapshots = exportableDatasets[dataset.datasetIdentifier] || []; + return { + data: <>{exportableSnapshots + .map((snapshot, i) => + )}, + value: 'Export to Workspace', + id: `${dataset.datasetId}-export-to-terra`, + style: makeRowStyle(cellWidths.exportToTerra), + label: `Export to Terra for dataset ${dataset.datasetId}` + }; + } + } + ]; +}; + +export const makeDatasetTableRows = (datasets: DatasetTerm[], headers: HeaderData[]): CellData[][] => datasets.map(dataset => headers.map(header => header.cellDataFn(dataset))); + + +export const datasetSearchTableTabs: DatasetSearchTableTabs = { + study: { + key: 'study-table-tab', + label: 'View By Studies', + makeHeaders: makeStudyTableHeaders, + makeRows: makeStudyTableRowData, + }, + dataset: { + key: 'datasets-table-tab', + label: 'View By Datasets', + makeHeaders: makeDatasetTableHeader, + makeRows: makeDatasetTableRows, + } +}; diff --git a/src/components/data_search/DatasetSearchTableDisplay.tsx b/src/components/data_search/DatasetSearchTableDisplay.tsx new file mode 100644 index 000000000..7a420768c --- /dev/null +++ b/src/components/data_search/DatasetSearchTableDisplay.tsx @@ -0,0 +1,74 @@ +import * as React from 'react'; +import {Box} from '@mui/material'; +import {isEmpty} from 'lodash'; +import {DatasetTerm} from 'src/types/model'; +import SimpleTable from '../SimpleTable'; +import {Styles} from '../../libs/theme'; +import { + DatasetSearchTableTab, +} from './DatasetSearchTableConstants'; +import {SnapshotSummaryModel} from "src/types/tdrModel"; + +const styles = { + baseStyle: { + fontFamily: 'Montserrat', + fontSize: '1.4rem', + fontWeight: 400, + display: 'flex', + padding: '1rem 2%', + justifyContent: 'space-between', + alignItems: 'center', + whiteSpace: 'pre-wrap', + backgroundColor: 'white', + border: '1px solid #DEDEDE', + borderRadius: '4px', + textOverflow: 'ellipsis', + height: '4rem', + marginTop: 5, + }, + columnStyle: Object.assign({}, Styles.TABLE.HEADER_ROW, { + justifyContent: 'space-between', + fontFamily: 'Montserrat', + fontSize: '1.2rem', + fontWeight: 'bold', + letterSpacing: '0.2px', + backgroundColor: '#E2E8F4', + border: 'none', + textTransform: 'uppercase', + lineHeight: '16px', + }), + containerOverride: {} +}; + + +interface DatasetSearchTableDisplayProps{ + onSelect: (newSelectedIds: number[]) => void; + filteredData: DatasetTerm[]; + selected: number[]; + exportableDatasets: { [duosId: string]: SnapshotSummaryModel[] } + tab: DatasetSearchTableTab +} + +export const DatasetSearchTableDisplay = (props: DatasetSearchTableDisplayProps) => { + const { onSelect, exportableDatasets, filteredData, selected, tab } = props; + const headers = tab.makeHeaders(filteredData, selected, onSelect, exportableDatasets); + const rowData = tab.makeRows(filteredData, headers); + + return isEmpty(filteredData) ? ( + +

There are no datasets that fit these criteria.

+
+ ) + : ( + + ); +}; diff --git a/src/components/modals/TranslatedDulModal.jsx b/src/components/modals/TranslatedDulModal.jsx deleted file mode 100644 index 3927fc591..000000000 --- a/src/components/modals/TranslatedDulModal.jsx +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { - Dialog, DialogTitle, DialogContent, DialogActions, Button, IconButton -} from '@mui/material'; -import { Close as CloseIcon } from '@mui/icons-material'; -import { useState, useEffect } from 'react'; -import isEmpty from 'lodash/fp/isEmpty'; - -const listStyle = { - listStyle: 'none', - padding: '0', - fontSize:'1rem' -}; - -//NOTE: li partial can be used in components that only need the list -async function GenerateUseRestrictionStatements(dataUse) { - if (!dataUse || isEmpty(dataUse)) { - return ( -
  • - None -
  • - ); - } - - return dataUse.map((restriction) => { - return ( -
  • - {restriction.code}: - {restriction.description} -
  • - ); - }); -} - -export default function TranslatedDulModal(props) { - const { showModal, onCloseRequest, dataUse } = props; - const [translatedDulList, setTranslatedDulList] = useState([]); - - const closeHandler = () => { - onCloseRequest(); - }; - - useEffect(() => { - const getTranslatedDulList = async () => { - const dulList = await GenerateUseRestrictionStatements(dataUse || []); - setTranslatedDulList(dulList); - }; - - getTranslatedDulList(); - }, [dataUse]); - - return ( - - - Data Use Terms - theme.palette.grey[500], - }} - > - - - - -
      - {translatedDulList} -
    -
    - - - -
    - ); -} diff --git a/src/types/model.ts b/src/types/model.ts index 75eca8992..e320adc5f 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -113,6 +113,69 @@ export interface Dataset { alternativeDataSharingPlanFile: FileStorageObject; } +interface DacTerm { + dacId: number; + dacName: string; + dacEmail: string; +} + +interface InstitutionTerm { + id: number; + name: string; +} + +interface UserTerm { + userId: number; + displayName: string; + institution: InstitutionTerm; +} + +export interface StudyTerm { + description: string; + studyName: string; + studyId: number; + phsId: string; + phenotype: string; + species: string; + piName: string; + dataSubmitterEmail: string; + dataSubmitterId: number; + dataCustodianEmail: string[]; + publicVisibility: boolean; + dataTypes: string[]; +} + +interface DataUseTerm { + code: string; + description: string; +} + +interface DataUseSummary { + primary: DataUseTerm[]; + secondary: DataUseTerm[]; +} + +export interface DatasetTerm { + datasetId: number; + createUserId: number; + createUserDisplayName: string; + datasetIdentifier: string; + deletable: boolean; + datasetName: string; + participantCount: number; + dataUse: DataUseSummary; + dataLocation: string; + url: string; + dacId: number; + dacApproval: boolean; + accessManagement: string; + approvedUserIds: number[]; + study: StudyTerm; + submitter: UserTerm; + updateUser: UserTerm; + dac: DacTerm; +} + interface DataUseRequirements { required: string[]; } diff --git a/src/types/tdrModel.ts b/src/types/tdrModel.ts new file mode 100644 index 000000000..8778ab065 --- /dev/null +++ b/src/types/tdrModel.ts @@ -0,0 +1,31 @@ +type CloudPlatform = 'gcp' | 'azure'; +interface StorageResourceModel { + region: string; + cloudResource: string; + cloudPlatform: CloudPlatform; +} + +interface ResourceLocks { + exclusive?: string; + shared?: string[]; +} + +export interface SnapshotSummaryModel { + id: string; + name: string; + description?: string; + createdDate?: string; + profileId?: string; + storage?: StorageResourceModel[]; + secureMonitoringEnabled?: boolean; + consentCode?: string; + phsId?: string; + cloudPlatform: CloudPlatform; + dataProject?: string; + storageAccount?: string; + selfHosted?: boolean; + globalFileIds?: boolean; + tags?: string[]; + resourceLocks: ResourceLocks; + duosId: string; +}