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)} component="li"> + + External Exports + : + setOpen(true)} size="small" color={hasExports ? 'primary' : 'default'}> + + + } + + setOpen(false)} maxWidth="sm" fullWidth> + {`External Exports: ${version.short_code || version.id} / ${version.id || version.version}`} + + { + hasExports ? + + {map(exports, externalExport => { + const isBusy = Boolean(isDownloading[externalExport.key] || isDeleting[externalExport.key]); + return ( + + + {externalExport.description || 'No description'} + {externalExport.updated_at ? ` - Updated ${formatDateTime(externalExport.updated_at)}` : ''} + + } + /> + + { + 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}} + /> + + + {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}))