diff --git a/src/components/collections/CollectionHome.jsx b/src/components/collections/CollectionHome.jsx
index 2ea00816..5bd309ff 100644
--- a/src/components/collections/CollectionHome.jsx
+++ b/src/components/collections/CollectionHome.jsx
@@ -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']
@@ -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}))
}
diff --git a/src/components/collections/Versions.jsx b/src/components/collections/Versions.jsx
index 449ce767..594183a5 100644
--- a/src/components/collections/Versions.jsx
+++ b/src/components/collections/Versions.jsx
@@ -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';
@@ -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({
@@ -462,6 +463,19 @@ const VersionList = ({
/>
});
+ items.push({
+ key: 'external-export',
+ label: 'External Exports',
+ component:
+ });
+
return items;
};
diff --git a/src/components/common/ConceptContainerVersionList.jsx b/src/components/common/ConceptContainerVersionList.jsx
index 9d389821..0b35de6d 100644
--- a/src/components/common/ConceptContainerVersionList.jsx
+++ b/src/components/common/ConceptContainerVersionList.jsx
@@ -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';
@@ -335,6 +336,16 @@ const ConceptContainerVersionList = ({ versions, resource, canEdit, onUpdate, fh
resource={resource}
/>
}
+ {
+ version && !fhir &&
+
+ }
{
canEdit && version && !fhir &&
diff --git a/src/components/common/RepoExternalExports.jsx b/src/components/common/RepoExternalExports.jsx
new file mode 100644
index 00000000..3be58c09
--- /dev/null
+++ b/src/components/common/RepoExternalExports.jsx
@@ -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 || ''}/`;
+
+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 (
+
+
+ {
+ variant === 'menuItem' ?
+ :
+ setOpen(true)} size="small" color={hasExports ? 'primary' : 'default'}>
+
+
+ }
+
+
+ }
+ />
+
+ {
+ isBusy ?
+ :
+
+
+ download(externalExport)} size="small">
+
+
+
+ {
+ canUpload &&
+
+ deleteExport(externalExport)} size="small">
+
+
+
+ }
+
+ }
+
+
+ )
+ })}
+ :
+ No external exports have been uploaded for this version.
+ }
+ {
+ canUpload &&
+
+ Upload External Export
+ setName(event.target.value)}
+ sx={{mb: 1}}
+ />
+ setDescription(event.target.value)}
+ sx={{mb: 1}}
+ />
+
+ }>
+ Choose File
+
+
+ {file ? file.name : 'sql, zip, pdf, csv'}
+
+
+ }
+
+
+
+ {
+ canUpload &&
+
+ }
+
+
+
+ );
+}
+
+export default RepoExternalExports;
diff --git a/src/components/sources/SourceHome.jsx b/src/components/sources/SourceHome.jsx
index 03684b85..1d927d02 100644
--- a/src/components/sources/SourceHome.jsx
+++ b/src/components/sources/SourceHome.jsx
@@ -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}))