Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions src/components/collections/CollectionHome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import ConceptHome from '../concepts/ConceptHome';
import MappingHome from '../mappings/MappingHome';
import ResponsiveDrawer from '../common/ResponsiveDrawer';
import Breadcrumbs from '../sources/Breadcrumbs';
import { fetchAllVersions, paramsToURI, paramsToParentURI } from '../../common/utils';
import { fetchAllVersions, paramsToURI, paramsToParentURI, isLoggedIn } from '../../common/utils';
import { OperationsContext } from '../app/LayoutContext';

const TABS = ['details', 'concepts', 'mappings', 'references', 'versions', 'summary', 'about']
Expand Down Expand Up @@ -158,7 +158,7 @@ class CollectionHome extends React.Component {
}

getVersions() {
fetchAllVersions(this.collectionPath + 'versions/', {brief: true})
fetchAllVersions(this.collectionPath + 'versions/', {brief: true, includeExternalExports: isLoggedIn()})
.then(versions => this.setState({versions: versions}))
}

Expand Down
18 changes: 16 additions & 2 deletions src/components/collections/Versions.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ import ProcessingIcon from '@mui/icons-material/HourglassTop';
import EvaluateIcon from '@mui/icons-material/Functions';

import APIService from '../../services/APIService';
import { copyURL, toFullAPIURL } from '../../common/utils';
import { copyURL, toFullAPIURL, isLoggedIn } from '../../common/utils';
import LastUpdatedOnLabel from '../common/LastUpdatedOnLabel';
import ConceptContainerVersionForm from '../common/ConceptContainerVersionForm';
import CommonFormDrawer from '../common/CommonFormDrawer';
import ConceptContainerExport from '../common/ConceptContainerExport';
import RepoExternalExports from '../common/RepoExternalExports';
import { GREEN } from '../../common/constants';
import SourceChildVersionAssociationWithContainer from '../common/SourceChildVersionAssociationWithContainer';
import { StateChip } from '../common/ConceptContainerVersionList';
Expand Down Expand Up @@ -306,7 +307,7 @@ const VersionList = ({
APIService.new()
.overrideURL(collection.url)
.appendToUrl('versions/')
.get(null, null, { limit: _pageSize || pageSize, includeSummary: true, verbose: true, page: page, includeStates: true })
.get(null, null, { limit: _pageSize || pageSize, includeSummary: true, verbose: true, page: page, includeStates: true, includeExternalExports: isLoggedIn() })
.then(response => {
const _versions = uniqBy([{ ...collection, id: 'HEAD', version_url: collection.url, version: 'HEAD', uuid: 'HEAD' }, ...response.data], 'id');
setPagination({
Expand Down Expand Up @@ -462,6 +463,19 @@ const VersionList = ({
/>
});

items.push({
key: 'external-export',
label: 'External Exports',
component: <RepoExternalExports
isHEAD={version.id.toLowerCase() === 'head'}
version={version}
resource="collection"
canEdit={canEdit}
variant='menuItem'
onChange={onUpdate}
/>
});

return items;
};

Expand Down
11 changes: 11 additions & 0 deletions src/components/common/ConceptContainerVersionList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import ConceptContainerTip from './ConceptContainerTip';
import ConceptContainerVersionForm from './ConceptContainerVersionForm';
import CommonFormDrawer from './CommonFormDrawer';
import ConceptContainerExport from './ConceptContainerExport';
import RepoExternalExports from './RepoExternalExports';
import ChangelogMarkdown from './ChangelogMarkdown';
import { CONCEPT_CONTAINER_RESOURCE_CHILDREN_TAGS } from '../search/ResultConstants';

Expand Down Expand Up @@ -335,6 +336,16 @@ const ConceptContainerVersionList = ({ versions, resource, canEdit, onUpdate, fh
resource={resource}
/>
}
{
version && !fhir &&
<RepoExternalExports
isHEAD={isHEAD}
version={version}
resource={resource}
canEdit={canEdit}
onChange={onUpdate}
/>
}
{
canEdit && version && !fhir &&
<Tooltip arrow title='Re-compute Summary'>
Expand Down
290 changes: 290 additions & 0 deletions src/components/common/RepoExternalExports.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
/* eslint-disable spellcheck/spell-checker */
import React from 'react';
import alertifyjs from 'alertifyjs';
import {
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
MenuItem,
TextField,
Tooltip,
Typography
} from '@mui/material';
import Alert from '@mui/material/Alert';
import {
CloudUpload as UploadIcon,
Delete as DeleteIcon,
GetApp as DownloadIcon,
} from '@mui/icons-material';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import { filter, get, includes, isEmpty, map } from 'lodash';
import APIService from '../../services/APIService';
import { formatDateTime, formatErrorForDisplay, isLoggedIn } from '../../common/utils';

const ALLOWED_EXTENSIONS = ['sql', 'zip', 'pdf', 'csv'];

const toKey = name => (name || '').replace(/\s/g, '');

const getExtension = file => {
const name = get(file, 'name', '');
const parts = name.split('.');
return parts.length > 1 ? parts.pop().toLowerCase() : '';
}

const getFilenameFromURL = url => {
const parts = (url || '').split('/').filter(Boolean);
return parts[parts.length - 1] || 'external-export';
}

const getExportURL = (version, externalExport) => get(externalExport, 'url') || `${version.version_url}export/${externalExport.key}/`;
const getExternalExportRelativeURL = (version, key) => `${version.version_url}export/${key || '<key>'}/`;

const downloadBlobResponse = (response, filename) => {
const contentType = get(response, 'headers.content-type') || get(response, 'data.type') || 'application/octet-stream';
const blob = response.data instanceof Blob ? response.data : new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => window.URL.revokeObjectURL(url), 1000);
}

const RepoExternalExports = ({ version, resource, canEdit, isHEAD, onChange, variant }) => {
const [exports, setExports] = React.useState(get(version, 'external_exports', []));
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState('');
const [description, setDescription] = React.useState('');
const [file, setFile] = React.useState(null);
const [isUploading, setIsUploading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState({});
const [isDeleting, setIsDeleting] = React.useState({});

React.useEffect(() => {
setExports(get(version, 'external_exports', []));
}, [version]);

if(!isLoggedIn() || isHEAD)
return null;

const canUpload = Boolean(canEdit);
const hasExports = !isEmpty(exports);
if(!canUpload && !hasExports)
return null;

const resetForm = () => {
setName('');
setDescription('');
setFile(null);
}

const updateExports = nextExports => {
setExports(nextExports);
if(onChange)
onChange({...version, external_exports: nextExports});
}

const onFileChange = event => setFile(get(event, 'target.files.0') || null);

const validate = () => {
const key = toKey(name);
if(!key) {
alertifyjs.error('External export name is required.');
return false;
}
if(!file) {
alertifyjs.error('Please choose a file to upload.');
return false;
}
if(includes(map(exports, 'key'), key)) {
alertifyjs.error('External export name must be unique for this version.');
return false;
}
if(!includes(ALLOWED_EXTENSIONS, getExtension(file))) {
alertifyjs.error('External export file must be sql, zip, pdf, or csv.');
return false;
}
return true;
}

const upload = () => {
if(!validate())
return;

const key = toKey(name);
const data = new FormData();
data.append('file', file);
if(description)
data.append('description', description);

setIsUploading(true);
APIService.new()
.overrideURL(`${version.version_url}export/${key}/`)
.request('POST', data, null, {headers: {'Content-Type': 'multipart/form-data'}})
.then(response => {
const externalExport = get(response, 'data');
updateExports([...exports, externalExport]);
resetForm();
alertifyjs.success('External export uploaded.');
})
.catch(error => alertifyjs.error(formatErrorForDisplay(get(error, 'response.data') || error, 'Could not upload external export.'), 5))
.finally(() => setIsUploading(false));
}

const download = externalExport => {
const key = externalExport.key;
setIsDownloading({...isDownloading, [key]: true});
APIService.new()
.overrideURL(getExportURL(version, externalExport))
.getBlob()
.then(response => {
if(get(response, 'status') === 200)
downloadBlobResponse(response, getFilenameFromURL(externalExport.file_path) || key);
else
alertifyjs.error(formatErrorForDisplay(response, 'Could not download external export.'), 5);
})
.catch(error => alertifyjs.error(formatErrorForDisplay(get(error, 'response.data') || error, 'Could not download external export.'), 5))
.finally(() => setIsDownloading({...isDownloading, [key]: false}));
}

const deleteExport = externalExport => {
const key = externalExport.key;
const confirm = alertifyjs.confirm();
confirm.setHeader(`Delete External Export: ${key}`);
confirm.setMessage(`Are you sure you want to permanently delete this external export from the ${resource} version?`);
confirm.set('onok', () => {
setIsDeleting({...isDeleting, [key]: true});
APIService.new()
.overrideURL(getExportURL(version, externalExport))
.delete()
.then(response => {
if(get(response, 'status') === 204) {
updateExports(filter(exports, item => item.key !== key));
alertifyjs.success('External export deleted.');
} else {
alertifyjs.error(formatErrorForDisplay(response, 'Could not delete external export.'), 5);
}
})
.catch(error => alertifyjs.error(formatErrorForDisplay(get(error, 'response.data') || error, 'Could not delete external export.'), 5))
.finally(() => setIsDeleting({...isDeleting, [key]: false}));
});
confirm.show();
}

return (
<React.Fragment>
<Tooltip arrow title="External Exports">
{
variant === 'menuItem' ?
<MenuItem onClick={() => setOpen(true)} component="li">
<Box sx={{ mr: 1, display: 'flex', alignItems: 'center' }}><AttachFileIcon fontSize="small" /></Box>
External Exports
</MenuItem> :
<IconButton onClick={() => setOpen(true)} size="small" color={hasExports ? 'primary' : 'default'}>
<AttachFileIcon fontSize="inherit" />
</IconButton>
}
</Tooltip>
<Dialog open={open} onClose={() => setOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>{`External Exports: ${version.short_code || version.id} / ${version.id || version.version}`}</DialogTitle>
<DialogContent dividers>
{
hasExports ?
<List dense>
{map(exports, externalExport => {
const isBusy = Boolean(isDownloading[externalExport.key] || isDeleting[externalExport.key]);
return (
<ListItem key={externalExport.key} divider>
<ListItemText
primary={externalExport.key}
secondary={
<React.Fragment>
{externalExport.description || 'No description'}
{externalExport.updated_at ? ` - Updated ${formatDateTime(externalExport.updated_at)}` : ''}
</React.Fragment>
}
/>
<ListItemSecondaryAction>
{
isBusy ?
<CircularProgress size={18} /> :
<React.Fragment>
<Tooltip arrow title="Download">
<IconButton onClick={() => download(externalExport)} size="small">
<DownloadIcon fontSize="inherit" />
</IconButton>
</Tooltip>
{
canUpload &&
<Tooltip arrow title="Delete">
<IconButton onClick={() => deleteExport(externalExport)} size="small">
<DeleteIcon fontSize="inherit" />
</IconButton>
</Tooltip>
}
</React.Fragment>
}
</ListItemSecondaryAction>
</ListItem>
)
})}
</List> :
<Alert severity="info">No external exports have been uploaded for this version.</Alert>
}
{
canUpload &&
<Box sx={{mt: 2}}>
<Typography variant="subtitle2" sx={{mb: 1}}>Upload External Export</Typography>
<TextField
fullWidth
size="small"
label="Name"
value={name}
helperText={`This file's relative url will be '${getExternalExportRelativeURL(version, toKey(name))}'`}
onChange={event => setName(event.target.value)}
sx={{mb: 1}}
/>
<TextField
fullWidth
size="small"
label="Description"
value={description}
onChange={event => setDescription(event.target.value)}
sx={{mb: 1}}
/>
<Box sx={{display: 'flex', alignItems: 'center', gap: 1}}>
<Button component="label" variant="outlined" size="small" startIcon={<UploadIcon />}>
Choose File
<input hidden type="file" accept=".sql,.zip,.pdf,.csv" onChange={onFileChange} />
</Button>
<Typography variant="body2" color="text.secondary">{file ? file.name : 'sql, zip, pdf, csv'}</Typography>
</Box>
</Box>
}
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>Close</Button>
{
canUpload &&
<Button onClick={upload} disabled={isUploading} variant="contained" color="primary">
{isUploading ? 'Uploading...' : 'Upload'}
</Button>
}
</DialogActions>
</Dialog>
</React.Fragment>
);
}

export default RepoExternalExports;
2 changes: 1 addition & 1 deletion src/components/sources/SourceHome.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ class SourceHome extends React.Component {
this.setState({isLoadingVersions: true}, () => {
fetchAllVersions(
this.sourcePath + 'versions/',
{verbose: true, includeStates: isLoggedIn(), includeSummary: true}
{verbose: true, includeStates: isLoggedIn(), includeSummary: true, includeExternalExports: isLoggedIn()}
)
.then(versions => this.setState({versions: versions, isLoadingVersions: false}))
.catch(() => this.setState({isLoadingVersions: false}))
Expand Down