Skip to content

Commit

Permalink
[DUOS-2807] Add export to terra in DUOS libraries (#2450)
Browse files Browse the repository at this point in the history
Co-authored-by: nmalfroy <[email protected]>
  • Loading branch information
fboulnois and nmalfroy authored Feb 14, 2024
1 parent fcb7cc6 commit 4b6afe7
Show file tree
Hide file tree
Showing 7 changed files with 227 additions and 23 deletions.
2 changes: 2 additions & 0 deletions config/base_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"tag": "",
"apiUrl": "",
"ontologyApiUrl": "",
"terraUrl": "",
"tdrApiUrl": "",
"clientId": "",
"errorApiKey": "",
"profileUrl": "",
Expand Down
2 changes: 2 additions & 0 deletions public/config-example.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"hash": "dev",
"apiUrl": "http://localhost:8180/api",
"ontologyApiUrl": "https://ontologyURL.org/",
"terraUrl": "https://terraURL.org/",
"tdrApiUrl": "https://tdrApiUrl.org/",
"clientId": "111111111111-11111111111111111111111111111111.apps.googleusercontent.com",
"errorApiKey": "example",
"gaId": "",
Expand Down
73 changes: 56 additions & 17 deletions src/components/CollapsibleTable.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
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';
Expand Down Expand Up @@ -27,7 +29,7 @@ const table = {
value: 'Header 1',
},
{
value: 'Header 2',
value: () => 'Header 2',
},
{
value: 'Header 3',
Expand Down Expand Up @@ -154,15 +156,50 @@ const TruncatedTableCell = styled(StyledTableCell)(() => ({
},
}));

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 } = 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 (
<React.Fragment>
{/* main table row */}
Expand All @@ -179,9 +216,11 @@ const CollapsibleRow = (props) => {
<IconButton
aria-label="expand row"
size="small"
onClick={() => setOpen(!open)}
onClick={(event) => toggleHandler(event, row)}
disabled={toggling}
>
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
{toggling && <CircularProgress size={14} />}
{!toggling ? (open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />) : null}
</IconButton>
</StyledTableCell>
{row.data.map((cell, i) => {
Expand All @@ -199,7 +238,7 @@ const CollapsibleRow = (props) => {
<SubtableRow>
<StyledTableCell component="th" />
{row.subtable.headers.map((header, i) => (
<StyledTableCell key={i}>{header.value}</StyledTableCell>
<StyledTableCell key={i}>{renderValue(header)}</StyledTableCell>
))}
</SubtableRow>
</TableHead>
Expand Down Expand Up @@ -230,7 +269,7 @@ const CollapsibleRow = (props) => {
};

export const CollapsibleTable = (props) => {
const { data, summary, selected, selectHandler } = props;
const { data, summary, selected, selectHandler, expandHandler } = props;

const [allSelected, setAllSelected] = useState(false);
const [someSelected, setSomeSelected] = useState(false);
Expand Down Expand Up @@ -262,15 +301,15 @@ export const CollapsibleTable = (props) => {
/>
</HeaderCell>
<HeaderCell component="th" />
{data.headers.map((header) => (
<HeaderCell key={header.value} component="th">{header.value}</HeaderCell>
{data.headers.map((header, i) => (
<HeaderCell key={i} component="th">{renderValue(header)}</HeaderCell>
))}
</TableRow>
</TableHead>
{/* main table rows */}
<TableBody>
{data.rows.map((row) => (
<CollapsibleRow key={row.id} row={row} selected={selected} selectHandler={selectHandler} />
<CollapsibleRow key={row.id} row={row} selected={selected} selectHandler={selectHandler} expandHandler={expandHandler} />
))}
</TableBody>
</Table>
Expand Down Expand Up @@ -323,7 +362,7 @@ const TableCellRenderer = ({ cell }) => {
disableRestoreFocus
>
<Typography sx={{ p: 1, fontFamily: 'Montserrat', fontSize: '14px', maxWidth: '80rem' }}>
{cell.value}
{renderValue(cell)}
</Typography>
</Popover>
</StyledTableCell>
Expand All @@ -332,39 +371,39 @@ const TableCellRenderer = ({ cell }) => {

if (cell?.truncate && cell?.increaseWidth) {
return <TruncatedTableCell style={{ maxWidth: '30ch' }}>
{cell.value}
{renderValue(cell)}
</TruncatedTableCell>;
}

if (cell?.truncate) {
return <TruncatedTableCell>
{cell.value}
{renderValue(cell)}
</TruncatedTableCell>;
}

if (cell?.increaseWidth) {
return <StyledTableCell style={{ width: '37ch' }}>
{cell.value}
{renderValue(cell)}
</StyledTableCell>;
}

// Default case:
return <StyledTableCell>
{cell.value}
{renderValue(cell)}
</StyledTableCell>;
};

const SubtableCellRenderer = ({ cell }) => {

if (cell?.increaseWidth) {
return <StyledTableCell style={{ width: '15ch' }}>
{cell.value}
{renderValue(cell)}
</StyledTableCell>;
}

// Default case:
return <StyledTableCell>
{cell.value}
{renderValue(cell)}
</StyledTableCell>;

};
};
53 changes: 53 additions & 0 deletions src/components/data_search/DatasetExportButton.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as React from 'react';
import { CircularProgress, IconButton, Link } from '@mui/material';
import { useState } from 'react';
import IosShareIcon from '@mui/icons-material/IosShare';
import { TerraDataRepo } from '../../libs/ajax';

export const DatasetExportButton = (props) => {
const { snapshot, title } = props;
// The exportStatus flow is: initial -> prepping -> ready
// TODO: error handling?
const [exportStatus, setExportStatus] = useState('initial');
const [exportResult, setExportResult] = useState(null);

// Not a supported export location
if (!snapshot) {
return null;
}

const prepExportHandler = async () => {
setExportStatus('prepping');
const job = await TerraDataRepo.prepareExport(snapshot.id);
const result = await TerraDataRepo.waitForJob(job.id);
setExportResult(result);
setExportStatus('ready');
};

if (exportStatus === 'initial') {
return (
<IconButton aria-label="prepare export to Terra" size="medium" onClick={prepExportHandler}>
<IosShareIcon size={15} />
</IconButton>
);
}

if (exportStatus === 'prepping') {
return (
<IconButton aria-label="prepare export to Terra" size="medium" onClick={() => ({})} disabled>
<CircularProgress size={15} />,
</IconButton>
);
}

if (exportStatus === 'ready') {
return (
<Link href={exportResult.terraImportLink} target="_blank" rel="noopener noreferrer" title={title} aria-label={title}>Terra</Link>
);
}

return null;

};

export default DatasetExportButton;
77 changes: 71 additions & 6 deletions src/components/data_search/DatasetSearchTable.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import * as React from 'react';
import { Button, Link } from '@mui/material';
import _ from 'lodash';
import { Box, Button, Link } from '@mui/material';
import { useEffect, useState } from 'react';
import { groupBy, isEmpty } from 'lodash';
import CollapsibleTable from '../CollapsibleTable';
import TableHeaderSection from '../TableHeaderSection';
import { DAR } from '../../libs/ajax';
import DatasetExportButton from './DatasetExportButton';
import { DAR, TerraDataRepo } from '../../libs/ajax';
import { Config } from '../../libs/config';
import DatasetFilterList from './DatasetFilterList';
import { Box } from '@mui/material';
import { Notifications } from '../../libs/utils';

const studyTableHeader = [
'Study Name',
Expand All @@ -26,6 +29,7 @@ const datasetTableHeader = [
'Participants',
'Data Location',
'DAC',
'Export to Terra',
];

export const DatasetSearchTable = (props) => {
Expand All @@ -34,6 +38,8 @@ export const DatasetSearchTable = (props) => {
const [filtered, setFiltered] = useState([]);
const [tableData, setTableData] = useState({});
const [selected, setSelected] = useState([]);
const [exportableDatasets, setExportableDatasets] = useState({}); // datasetId -> snapshot
const [tdrApiUrl, setTdrApiUrl] = useState('');

const isFiltered = (filter) => filters.indexOf(filter) > -1;

Expand Down Expand Up @@ -96,6 +102,34 @@ export const DatasetSearchTable = (props) => {
setSelected(newSelected);
};

const getExportableDatasets = async (event, data) => {
setTdrApiUrl(await Config.getTdrApiUrl());
// Note the dataset id is the first column in subrows.
// If columns are ever reordereable, this will need to be updated to be not hardcoded to look for the first column.
const datasetIds = data.subtable.rows.map((row) => row.data[0].value);
const snapshots = await TerraDataRepo.listSnapshotsByDatasetIds(datasetIds);
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 });
Expand Down Expand Up @@ -172,11 +206,35 @@ export const DatasetSearchTable = (props) => {
value: dataset.participantCount,
},
{
value: dataset.url ? <Link href={dataset.url}>{dataset.dataLocation}</Link> : dataset.dataLocation,
value: () => {
const exportableSnapshots = exportableDatasets[dataset.datasetIdentifier] || [];
if (exportableSnapshots.length === 0) {
return dataset.dataLocation;
}
return exportableSnapshots.map((snapshot, i) =>
<Link
key={`${i}`}
href={`${tdrApiUrl}/snapshots/${snapshot.id}`}
target="_blank"
>
{snapshot.name}
</Link>);
}
},
{
value: dataset.dac?.dacEmail ? <Link href={'mailto:' + dataset.dac.dacEmail}>{dataset.dac?.dacName}</Link> : dataset.dac?.dacName,
},
{
value: () => {
const exportableSnapshots = exportableDatasets[dataset.datasetIdentifier] || [];
return exportableSnapshots
.map((snapshot, i) =>
<DatasetExportButton
key={`${i}`}
snapshot={snapshot}
title={`Export snapshot ${snapshot.name}`} />);
}
},
],
};
}),
Expand All @@ -186,7 +244,7 @@ export const DatasetSearchTable = (props) => {
};

setTableData(table);
}, [filtered]);
}, [filtered, exportableDatasets, tdrApiUrl]);

useEffect(() => {
setFiltered(datasets);
Expand All @@ -206,7 +264,14 @@ export const DatasetSearchTable = (props) => {
<h1>No datasets registered for this library.</h1>
</Box>
:
<CollapsibleTable data={tableData} selected={selected} selectHandler={selectHandler} summary='faceted study search table' />
<CollapsibleTable
data={tableData}
selected={selected}
selectHandler={selectHandler}
expandHandler={expandHandler}
collapseHandler={collapseHandler}
summary='faceted study search table'
/>
}
</Box>
</Box>
Expand Down
Loading

0 comments on commit 4b6afe7

Please sign in to comment.