diff --git a/.gitignore b/.gitignore index eed8155aee..f1e4f7be30 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ server/docs *settings_secret.py *settings_custom.py *settings_local.py +*settings_forms.py docker_repo.var server/src diff --git a/README.md b/README.md index 0bb69c515c..ea868d2507 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ The base Portal code for TACC WMA Workspace Portals # Local Development Setup -## Prerequisites for running the portal application +## Prerequisites for running the portal application: * Docker > 28 * Python 3.12.x diff --git a/client/package-lock.json b/client/package-lock.json index cd5dc59888..d507d13a29 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@mui/material": "^6.1.1", + "@mui/x-tree-view": "^7.26.0", "@niivue/niivue": "^0.32.0", "@tanstack/react-query": "^5.59.16", "antd": "^5.29.3", @@ -33,6 +34,7 @@ "react-redux": "^7.2.5", "react-resize-detector": "^11.0.1", "react-router-dom": "^5.3.0", + "react-step-wizard": "^5.3.11", "react-table": "^7.0.5", "react-virtualized-auto-sizer": "^1.0.6", "react-window": "^1.8.6", @@ -4189,6 +4191,62 @@ } } }, + "node_modules/@mui/x-internals": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz", + "integrity": "sha512-VxTCYQcZ02d3190pdvys2TDg9pgbvewAVakEopiOgReKAUhLdRlgGJHcOA/eAuGLyK1YIo26A6Ow6ZKlSRLwMg==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@mui/x-tree-view": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@mui/x-tree-view/-/x-tree-view-7.26.0.tgz", + "integrity": "sha512-adZwVj6/edNowi2RIeyGPTcfdP4EXtMGo0mk2LQogG8m8bZkZRjOQoQ7pkBF0UPMaIAwzCadq2OWj3MPH4DP5A==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.26.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, "node_modules/@niivue/niivue": { "version": "0.32.0", "resolved": "https://registry.npmjs.org/@niivue/niivue/-/niivue-0.32.0.tgz", @@ -16929,6 +16987,14 @@ "dev": true, "license": "MIT" }, + "node_modules/react-step-wizard": { + "version": "5.3.11", + "resolved": "https://registry.npmjs.org/react-step-wizard/-/react-step-wizard-5.3.11.tgz", + "integrity": "sha512-TD3ocUdt4XWvisTNEiATh+cFuh1RK0LgvYqOResTIhLtFmdWnXzFVaNIZJ3qzW5Bm6yeotzZ4KAmRjmsqBXnWQ==", + "peerDependencies": { + "react": ">=15.0.1" + } + }, "node_modules/react-table": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.8.0.tgz", diff --git a/client/package.json b/client/package.json index 51b7d8a5f7..ed10b4a7d6 100644 --- a/client/package.json +++ b/client/package.json @@ -7,6 +7,7 @@ "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "@mui/material": "^6.1.1", + "@mui/x-tree-view": "^7.26.0", "@niivue/niivue": "^0.32.0", "@tanstack/react-query": "^5.59.16", "antd": "^5.29.3", @@ -29,6 +30,7 @@ "react-redux": "^7.2.5", "react-resize-detector": "^11.0.1", "react-router-dom": "^5.3.0", + "react-step-wizard": "^5.3.11", "react-table": "^7.0.5", "react-virtualized-auto-sizer": "^1.0.6", "react-window": "^1.8.6", diff --git a/client/src/components/Applications/AppBrowser/AppBrowser.scss b/client/src/components/Applications/AppBrowser/AppBrowser.scss index 19f28b283a..9d784cdc16 100644 --- a/client/src/components/Applications/AppBrowser/AppBrowser.scss +++ b/client/src/components/Applications/AppBrowser/AppBrowser.scss @@ -10,10 +10,10 @@ --panel-vertical-buffer: 10px; --panel-max-item-count: 5; - --panel-item-height: 34px; /* (~30px design * 1.2 design-to-app ratio) */ + --panel-item-height: 35px; /* (~30px design * 1.2 design-to-app ratio) */ --panel-height: calc( (var(--panel-max-item-count) * var(--panel-item-height)) + - var(--panel-vertical-buffer) + (var(--panel-vertical-buffer) * 2) ); border-bottom: 1px solid #707070; diff --git a/client/src/components/DataFiles/CombinedBreadcrumbs/CombinedBreadcrumbs.jsx b/client/src/components/DataFiles/CombinedBreadcrumbs/CombinedBreadcrumbs.jsx index 7f5ce80c59..914773b185 100644 --- a/client/src/components/DataFiles/CombinedBreadcrumbs/CombinedBreadcrumbs.jsx +++ b/client/src/components/DataFiles/CombinedBreadcrumbs/CombinedBreadcrumbs.jsx @@ -20,6 +20,7 @@ CombinedBreadcrumbs.propTypes = { path: PropTypes.string.isRequired, section: PropTypes.string.isRequired, isPublic: PropTypes.bool, + basePath: PropTypes.string, className: PropTypes.string, }; diff --git a/client/src/components/DataFiles/DataFiles.jsx b/client/src/components/DataFiles/DataFiles.jsx index 7d67a05968..ff84ace104 100644 --- a/client/src/components/DataFiles/DataFiles.jsx +++ b/client/src/components/DataFiles/DataFiles.jsx @@ -11,7 +11,11 @@ import { SectionMessage, LoadingSpinner, } from '_common'; -import { useFileListing, useSystems } from 'hooks/datafiles'; +import { + useFileListing, + useSystems, + useAddonComponents, +} from 'hooks/datafiles'; import DataFilesToolbar from './DataFilesToolbar/DataFilesToolbar'; import DataFilesListing from './DataFilesListing/DataFilesListing'; import DataFilesSidebar from './DataFilesSidebar/DataFilesSidebar'; @@ -20,6 +24,9 @@ import DataFilesModals from './DataFilesModals/DataFilesModals'; import DataFilesProjectsList from './DataFilesProjectsList/DataFilesProjectsList'; import DataFilesProjectFileListing from './DataFilesProjectFileListing/DataFilesProjectFileListing'; import { useSystemRole } from './DataFilesProjectMembers/_cells/SystemRoleSelector'; +import DataFilesPublicationsList from './DataFilesPublicationsList/DataFilesPublicationsList'; +import DataFilesReviewProjectList from './DataFilesReviewProjectsList/DataFilesReviewProjectList'; +import { getDecodedPath } from 'utils/datafilesUtil'; const DefaultSystemRedirect = () => { const systems = useSelector( @@ -30,28 +37,91 @@ const DefaultSystemRedirect = () => { useEffect(() => { if (systems.length === 0) return; const defaultSystem = systems[0]; - history.push( - `/workbench/data/${defaultSystem.api}/${defaultSystem.scheme}/${ - defaultSystem.scheme === 'projects' - ? '' - : `${defaultSystem.system}${defaultSystem.homeDir || ''}/` - }` - ); + + let path = `/workbench/data/${defaultSystem.api}/${defaultSystem.scheme}`; + + if (defaultSystem.scheme === 'projects') { + path += defaultSystem.system ? `/${defaultSystem.system}` : '/'; + } else { + path += `/${defaultSystem.system}${defaultSystem.homeDir || ''}/`; + } + + history.push(path); }, [systems]); return <>; }; const DataFilesSwitch = React.memo(() => { const { path } = useRouteMatch(); + + const portalName = useSelector((state) => state.workbench.portalName); + + const { DataFilesProjectPublish, DataFilesProjectReview } = + useAddonComponents({ portalName }); + + const systems = useSelector( + (state) => state.systems.storage.configuration.filter((s) => !s.hidden), + shallowEqual + ); + return ( + {DataFilesProjectPublish && ( + { + return ( + + + + ); + }} + /> + )} + {DataFilesProjectReview && ( + { + return ( + + + + ); + }} + /> + )} { + const system = systems.find((s) => s.system === params.system); + + if (system.publicationProject) { + return ; + } else if (system.reviewProject) { + return ; + } + + return ; + }} + /> + { + + const decodedPath = getDecodedPath(params.path); + return ( ); }} @@ -71,9 +141,6 @@ const DataFilesSwitch = React.memo(() => { ); }} /> - - - diff --git a/client/src/components/DataFiles/DataFilesBreadcrumbs/DataFilesBreadcrumbs.jsx b/client/src/components/DataFiles/DataFilesBreadcrumbs/DataFilesBreadcrumbs.jsx index 636a4e5f70..010d880650 100644 --- a/client/src/components/DataFiles/DataFilesBreadcrumbs/DataFilesBreadcrumbs.jsx +++ b/client/src/components/DataFiles/DataFilesBreadcrumbs/DataFilesBreadcrumbs.jsx @@ -141,7 +141,7 @@ const DataFilesBreadcrumbs = ({ }); }; - const { fetchSelectedSystem } = useSystems(); + const { fetchSelectedSystem, isRootProjectSystem } = useSystems(); const selectedSystem = fetchSelectedSystem({ scheme, system, path }); @@ -183,9 +183,7 @@ const DataFilesBreadcrumbs = ({
{currentDirectory.length === 0 ? ( - - {truncateMiddle(systemName || 'Shared Workspaces', 30)} - + {truncateMiddle(systemName, 30)} ) : ( currentDirectory.map((pathComp, i) => { if (i === fullPath.length - 1) { @@ -194,11 +192,13 @@ const DataFilesBreadcrumbs = ({ }) )}
- {systemName && api === 'tapis' && ( - - )} + {systemName && + api === 'tapis' && + !isRootProjectSystem(selectedSystem ?? '') && ( + + )}
); }; diff --git a/client/src/components/DataFiles/DataFilesDropdown/DataFilesDropdown.jsx b/client/src/components/DataFiles/DataFilesDropdown/DataFilesDropdown.jsx index affe12ef2a..0e9cfbc009 100644 --- a/client/src/components/DataFiles/DataFilesDropdown/DataFilesDropdown.jsx +++ b/client/src/components/DataFiles/DataFilesDropdown/DataFilesDropdown.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { useSelector, shallowEqual } from 'react-redux'; import PropTypes from 'prop-types'; import { Link, useLocation } from 'react-router-dom'; import { Button } from '_common'; @@ -16,31 +17,43 @@ const BreadcrumbsDropdown = ({ scheme, system, path, + basePath, section, isPublic, }) => { + + // encode path to handle special characters + path = path.split('/').map(encodeURIComponent).join('/'); + const paths = []; const [dropdownOpen, setDropdownOpen] = useState(false); const toggleDropdown = () => setDropdownOpen(!dropdownOpen); const location = useLocation(); const pathParts = location.pathname.split('/'); + const projectId = pathParts.includes('projects') + ? pathParts[pathParts.indexOf('projects') + 2] + : null; + + const rootProjectSystem = pathParts.includes('projects') ? pathParts[pathParts.indexOf('projects') + 1] : null; const handleNavigation = (targetPath) => { - const basePath = isPublic ? '/public-data' : '/workbench/data'; + if (!basePath) basePath = isPublic ? '/public-data' : '/workbench/data'; let url; if (scheme === 'projects' && targetPath === systemName) { - url = `${basePath}/${api}/projects/${projectId}/`; + url = `${basePath}/${api}/projects/${rootProjectSystem}/${projectId}/`; } else if (scheme === 'projects' && !targetPath) { - url = `${basePath}/${api}/projects/`; + url = `${basePath}/${api}/projects/${rootProjectSystem}`; } else if (api === 'googledrive' && !targetPath) { url = `${basePath}/${api}/${scheme}/${system}/`; } else if (api === 'tapis' && scheme !== 'projects' && !targetPath) { url = `${basePath}/${api}/${scheme}/${system}/`; + } else if (scheme === 'projects') { + url = `${basePath}/${api}/projects/${rootProjectSystem}/${system}${targetPath}`; } else { url = `${basePath}/${api}/${scheme}/${system}${targetPath}/`; } @@ -62,6 +75,14 @@ const BreadcrumbsDropdown = ({ const overlapIndex = pathComponents.findIndex( (component, index) => startingPathComponents[index] !== component ); + const systems = useSelector( + (state) => state.systems.storage.configuration.filter((s) => !s.hidden), + shallowEqual + ); + + const sharedWorkspacesDisplayName = systems.find( + (e) => e.scheme === 'projects' && e.system === rootProjectSystem + )?.name; let currentPath = startingPath; pathComponents.slice(overlapIndex).forEach((component) => { @@ -72,7 +93,7 @@ const BreadcrumbsDropdown = ({ const fullPath = paths.reverse(); const displayPaths = scheme === 'projects' ? [...fullPath, systemName] : fullPath; - const sliceStart = scheme === 'projects' && systemName ? 0 : 1; + const sliceStart = 1; return (
{ - const folderName = path.split('/').pop(); + let folderName = path.split('/').pop(); + + // decode folder name to remove URL encoding + try { + folderName = decodeURIComponent(folderName); + } catch {} + return ( {scheme === 'projects' - ? 'Shared Workspaces' - : systemName || 'Shared Workspaces'} + ? sharedWorkspacesDisplayName + : systemName || sharedWorkspacesDisplayName} {homeDir ? Root : null} diff --git a/client/src/components/DataFiles/DataFilesListing/DataFilesListing.jsx b/client/src/components/DataFiles/DataFilesListing/DataFilesListing.jsx index 898691e178..ca78b53af3 100644 --- a/client/src/components/DataFiles/DataFilesListing/DataFilesListing.jsx +++ b/client/src/components/DataFiles/DataFilesListing/DataFilesListing.jsx @@ -16,6 +16,7 @@ import { LastModifiedCell, FileIconCell, ViewPathCell, + DataTypeCell, } from './DataFilesListingCells'; import DataFilesTable from '../DataFilesTable/DataFilesTable'; import Searchbar from '_common/Searchbar'; @@ -37,7 +38,15 @@ const fileTypes = [ '3D Visualization', ]; -const DataFilesListing = ({ api, scheme, system, path, isPublic }) => { +const DataFilesListing = ({ + api, + scheme, + system, + path, + isPublic, + rootSystem, + basePath, +}) => { // Redux hooks const location = useLocation(); const systems = useSelector( @@ -46,13 +55,15 @@ const DataFilesListing = ({ api, scheme, system, path, isPublic }) => { ); const sharedWorkspaces = systems.find((e) => e.scheme === 'projects'); const isPortalProject = scheme === 'projects'; - const hideSearchBar = isPortalProject && sharedWorkspaces.hideSearchBar; + const hideSearchBar = isPortalProject && sharedWorkspaces?.hideSearchBar; const showViewPath = useSelector( (state) => api === 'tapis' && state.workbench && state.workbench.config.viewPath ); + const { showDataFileType } = useSelector((state) => state.workbench.config); + const { data: files, loading, @@ -82,6 +93,7 @@ const DataFilesListing = ({ api, scheme, system, path, isPublic }) => { ({ row }) => { return ( { scheme={scheme} href={row.original._links.self.href} isPublic={isPublic} + basePath={basePath} length={row.original.length} + metadata={row.original.metadata} /> ); }, @@ -145,6 +159,33 @@ const DataFilesListing = ({ api, scheme, system, path, isPublic }) => { Cell: (el) => , }); } + + if (showDataFileType) { + cells.splice(3, 0, { + // Inserting at index 3 after 'Name' + Header: 'Data Type', + Cell: (el) => , + width: 0.2, + }); + } + + if (scheme === 'projects' && rootSystem) { + const projectSystem = systems.find( + (s) => s.scheme === 'projects' && s.system === rootSystem + ); + + if (projectSystem && projectSystem.publicationProject) { + const index = cells.findIndex( + (cell) => cell.Header === 'Last Modified' + ); + cells.splice(index, 1); + ['Name', 'Size'].forEach((header) => { + const column = cells.find((col) => col.Header === header); + column.width += 0.1; + }); + } + } + return cells; }, [api, showViewPath, fileNavCellCallback]); diff --git a/client/src/components/DataFiles/DataFilesListing/DataFilesListingCells.jsx b/client/src/components/DataFiles/DataFilesListing/DataFilesListingCells.jsx index 77464c6575..b5eb54c607 100644 --- a/client/src/components/DataFiles/DataFilesListing/DataFilesListingCells.jsx +++ b/client/src/components/DataFiles/DataFilesListing/DataFilesListingCells.jsx @@ -50,7 +50,20 @@ CheckboxCell.propTypes = { }; export const FileNavCell = React.memo( - ({ system, path, name, format, api, scheme, href, isPublic, length }) => { + ({ + system, + path, + name, + format, + api, + scheme, + href, + isPublic, + basePath, + length, + metadata, + rootSystem, + }) => { const dispatch = useDispatch(); const previewCallback = (e) => { e.stopPropagation(); @@ -63,19 +76,25 @@ export const FileNavCell = React.memo( type: 'DATA_FILES_TOGGLE_MODAL', payload: { operation: 'preview', - props: { api, scheme, system, path, name, href, length }, + props: { api, scheme, system, path, name, href, length, metadata }, }, }); }; - const basePath = isPublic ? '/public-data' : '/workbench/data'; + if (!basePath) basePath = isPublic ? '/public-data' : '/workbench/data'; + + // encoding for % and # in path. Done twice due to react-router encoding bug. fixed in react router v6 + path = path.replace(/%/g, encodeURIComponent(encodeURIComponent('%'))) + .replace(/#/g, encodeURIComponent(encodeURIComponent('#'))); return ( <> { ViewPathCell.propTypes = { file: PropTypes.shape({}).isRequired, }; + +export const DataTypeCell = ({ file }) => { + const dataType = file.metadata ? file.metadata.data_type : file.type; + + const formatDatatype = (data_type) => + data_type + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + + return dataType ? ( + {formatDatatype(dataType)} + ) : null; +}; + +DataTypeCell.propTypes = { + file: PropTypes.shape({}).isRequired, +}; diff --git a/client/src/components/DataFiles/DataFilesListing/DataFilesListingCells.scss b/client/src/components/DataFiles/DataFilesListing/DataFilesListingCells.scss index aa3ad86d66..de5092c16e 100644 --- a/client/src/components/DataFiles/DataFilesListing/DataFilesListingCells.scss +++ b/client/src/components/DataFiles/DataFilesListing/DataFilesListingCells.scss @@ -37,3 +37,14 @@ color: #707070; display: flex; } + +.dataTypeBox { + display: inline-block; + background-color: #808080; /* Light grey background */ + color: #ffffff; /* Dark text color */ + padding: 2px 8px; /* Small top/bottom padding, larger left/right padding */ + border-radius: 12px; /* Rounded corners */ + font-size: 0.875rem; /* Smaller font size */ + margin: 8px 0; + font-weight: bold; +} diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesAddProjectModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesAddProjectModal.jsx index 8094380622..33994035b7 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesAddProjectModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesAddProjectModal.jsx @@ -1,12 +1,14 @@ import React, { useState, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import * as Yup from 'yup'; -import { Formik, Form } from 'formik'; +import { Formik, Form, FieldArray } from 'formik'; import FormField from '_common/Form/FormField'; import { Button, InlineMessage } from '_common'; import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; import { useHistory, useRouteMatch } from 'react-router-dom'; import DataFilesProjectMembers from '../DataFilesProjectMembers/DataFilesProjectMembers'; +import { useAddonComponents, useFileListing } from 'hooks/datafiles'; +import getDefaultProjectSystem from 'utils/getDefaultProjectSystem'; const DataFilesAddProjectModal = () => { const history = useHistory(); @@ -24,6 +26,10 @@ const DataFilesAddProjectModal = () => { useSelector((state) => state.workbench.config.enableWorkspaceKeywords) ?? true; + // logic to render addonComponents for DRP + const portalName = useSelector((state) => state.workbench.portalName); + const { DataFilesAddProjectModalAddon } = useAddonComponents({ portalName }); + useEffect(() => { setMembers([ ...members.filter((member) => member.user.username !== user.username), @@ -48,6 +54,13 @@ const DataFilesAddProjectModal = () => { ); }); + const system = useSelector((state) => + getDefaultProjectSystem(state.systems.storage.configuration) + ); + + const sharedWorkspacesDisplayName = system?.name; + const rootSystem = system?.system; + const toggle = () => { dispatch({ type: 'DATA_FILES_TOGGLE_MODAL', @@ -57,10 +70,10 @@ const DataFilesAddProjectModal = () => { const onCreate = (system) => { toggle(); - history.push(`${match.path}/tapis/projects/${system}`); + history.push(`${match.path}/tapis/projects/${rootSystem}/${system}`); }; - const addproject = ({ title, description, keywords }) => { + const addproject = ({ title, description, keywords, ...values }) => { dispatch({ type: 'PROJECTS_CREATE', payload: { @@ -71,6 +84,7 @@ const DataFilesAddProjectModal = () => { username: member.user.username, access: member.access, })), + metadata: DataFilesAddProjectModalAddon ? { title, description, ...values } : null, onCreate, }, }); @@ -120,7 +134,7 @@ const DataFilesAddProjectModal = () => { >
- Add Shared Workspace + Add {sharedWorkspacesDisplayName} { (Maximum {maxTitleLength} characters) +
} + description={'The title should be descriptive and distinctive from related publications.'} /> {!!minDescriptionLength && ( { type="textarea" /> )} + {DataFilesAddProjectModalAddon && } { {error ? ( - Your shared workspace could not be created + Your {sharedWorkspacesDisplayName} could not be created ) : null} diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesCopyModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesCopyModal.jsx index 7cf5c15d98..0807113fab 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesCopyModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesCopyModal.jsx @@ -50,7 +50,10 @@ const DataFilesCopyModal = React.memo(() => { const excludedSystems = systems .filter( - (s) => s.hidden || (s.scheme !== 'private' && s.scheme !== 'projects') + (s) => + s.hidden || + (s.scheme !== 'private' && s.scheme !== 'projects') || + (s.scheme === 'projects' && s.readOnly) ) .filter((s) => !(s.scheme === 'public' && canMakePublic)) .map((s) => `${s.system}${s.homeDir || ''}`); @@ -178,4 +181,4 @@ const DataFilesCopyModal = React.memo(() => { ); }); -export default DataFilesCopyModal; +export default DataFilesCopyModal; \ No newline at end of file diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx new file mode 100644 index 0000000000..aafb52b653 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.jsx @@ -0,0 +1,179 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import { Modal, ModalHeader, ModalBody, ModalFooter, FormText } from 'reactstrap'; +import { DynamicForm } from '_common/Form/DynamicForm'; +import { Formik, Form } from 'formik'; +import * as Yup from 'yup'; +import styles from './DataFilesFormModal.module.scss'; +import { useFileListing, useSelectedFiles } from 'hooks/datafiles'; +import { useHistory, useLocation } from 'react-router-dom'; + +const DataFilesFormModal = () => { + const dispatch = useDispatch(); + const history = useHistory(); + const location = useLocation(); + + const reloadPage = (updatedPath = '') => { + const match = location.pathname.match(/^\/workbench\/data\/tapis\/[^\/]+\/[^\/]+\/[^\/]+/); + if (!match) return; + + const projectUrl = match[0]; + + const cleanProjectUrl = projectUrl.replace(/\/$/, ''); + const cleanUpdatedPath = updatedPath.replace(/^\/+/, ''); + + history.replace(`${cleanProjectUrl}/${cleanUpdatedPath}`); + }; + + const { form, selectedFile, formName, additionalData, useReloadCallback } = + useSelector((state) => state.files.modalProps.dynamicform); + const isOpen = useSelector((state) => state.files.modals.dynamicform); + const { params } = useFileListing('FilesListing'); + + // TODO: Add support for array type fields + const initialValues = form?.form_fields.reduce((acc, field) => { + let value = ''; + if (field.optgroups) { + value = field.optgroups[0].options[0]?.value; + } else if (field.defaultValue) { + value = field.defaultValue; + } else { + value = + field.options && field.options.length > 0 ? field.options[0].value : ''; + } + + acc[field.name] = selectedFile ? selectedFile.metadata[field.name] : value; + return acc; + }, {}); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'dynamicform', props: {} }, + }); + }, []); + + const handleSubmit = (values) => { + Object.keys(values).forEach((key) => { + values[key] = + typeof values[key] === 'string' ? values[key].trim() : values[key]; + }); + + dispatch({ + type: formName, + payload: { + params, + values, + reloadPage: useReloadCallback ? reloadPage : null, + selectedFile, + additionalData, + }, + }); + }; + + const validationSchema = Yup.object().shape({ + ...(form?.form_fields ?? []).reduce((schema, field) => { + + let validator; + + if (field.type === 'number') { + validator = Yup.number(); + } else { + validator = Yup.string(); + } + + if (field.validation?.required) { + validator = validator.required(`${field.label} is required`); + } + + if (field.validation?.pathSafe) { + validator = validator.matches( + /^[\d\w\s\-_.]+$/, + 'Please enter a valid name (accepted characters are A-Z a-z 0-9 - _ .)' + ); + } + + if (field.type === 'link') { + validator = validator + .url(`${field.label} must be a valid URL starting with https://...`) + .matches(/^https:\/\//, `${field.label} must start with https://`); + } + + if (field.type === 'number') { + validator = validator + .max( + field.validation?.max ?? Infinity, + `${field.label} must be less than or equal to ${field.validation?.max}` + ) + .min( + field.validation?.min ?? -Infinity, + `${field.label} must be greater than or equal to ${field.validation?.min}` + ); + } + + if (field.type === 'textarea') { + validator = validator + .min( + field.validation?.min ?? -Infinity, + `${field.label} must be greater than or equal to ${field.validation?.min} characters` + ) + .max( + field.validation?.max ?? Infinity, + `${field.label} must be less than or equal to ${field.validation?.max} characters` + ) + } + + schema[field.name] = validator; + return schema; + }, {}), + }); + + return ( + <> + {form && ( +
+ + +
+ + {form.heading} + + + {form?.description && ( + + {form.description} + + )} + + + {form?.footer && ( + + + + )} +
+
+
+
+ )} + + ); +}; + +export default DataFilesFormModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.module.scss new file mode 100644 index 0000000000..044246cfb1 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesFormModal.module.scss @@ -0,0 +1,5 @@ +.modal-body-container { + width: 100%; + max-height: 70vh; + overflow: auto; +} diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesManageProjectModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesManageProjectModal.jsx index 6a18bdb084..af099b2429 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesManageProjectModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesManageProjectModal.jsx @@ -5,6 +5,7 @@ import { Button, Message } from '_common'; import { Modal, ModalHeader, ModalBody } from 'reactstrap'; import DataFilesProjectMembers from '../DataFilesProjectMembers/DataFilesProjectMembers'; import styles from './DataFilesManageProject.module.scss'; +import { useAddonComponents } from 'hooks/datafiles'; const DataFilesManageProjectModal = () => { const dispatch = useDispatch(); @@ -40,6 +41,15 @@ const DataFilesManageProjectModal = () => { return projectSystem?.readOnly || !canEditSystem; }); + const portalName = useSelector((state) => state.workbench.portalName); + const projectsEnableMetadata = useSelector( + (state) => state.workbench.config.projectsEnableMetadata + ); + + const { DataFilesManageProjectModalAddon } = useAddonComponents({ + portalName, + }); + const toggle = useCallback(() => { setTransferMode(false); dispatch({ @@ -128,7 +138,8 @@ const DataFilesManageProjectModal = () => { className="dataFilesModal" > - {readOnlyTeam ? 'View' : 'Manage'} Team + {readOnlyTeam ? 'View' : 'Manage'}{' '} + {projectsEnableMetadata ? 'Authors' : 'Team'} { {error ? (
- An error occurred while modifying team members + An error occurred while modifying{' '} + {projectsEnableMetadata ? 'authors' : 'team members'}
) : null} @@ -154,6 +166,9 @@ const DataFilesManageProjectModal = () => { ) : null} + {DataFilesManageProjectModalAddon && ( + + )}
diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx index 62623a94b2..beb84e4ecd 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesModals.jsx @@ -19,6 +19,14 @@ import DataFilesDownloadMessageModal from './DataFilesDownloadMessageModal'; import DataFilesUnavailDownloadModal from './DataFilesUnavailDownloadModal'; import DataFilesNoFoldersModal from './DataFilesNoFoldersModal'; import './DataFilesModals.scss'; +import DataFilesFormModal from './DataFilesFormModal'; +import DataFilesPublicationRequestModal from './DataFilesPublicationRequestModal'; +import DataFilesPublicationDownloadModal from './DataFilesPublicationDownloadModal'; +import DataFilesProjectTreeModal from './DataFilesProjectTreeModal'; +import DataFilesProjectDescriptionModal from './DataFilesProjectDescriptionModal'; +import DataFilesViewDataModal from './DataFilesViewDataModal'; +import DataFilesProjectCitationModal from './DataFilesProjectCitationModal'; +import DataFilesPublicationAuthorsModal from './DataFilesPublicationAuthorsModal'; export default function DataFilesModals() { return ( @@ -40,6 +48,14 @@ export default function DataFilesModals() { + + + + + + + + diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesPreviewModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesPreviewModal.jsx index 169bfb1714..f462a4bd6d 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesPreviewModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesPreviewModal.jsx @@ -4,6 +4,7 @@ import { Modal, ModalHeader, ModalBody, Button } from 'reactstrap'; import { LoadingSpinner, SectionMessage } from '_common'; import styles from './DataFilesPreviewModal.module.scss'; import { Niivue } from '@niivue/niivue'; +import { useAddonComponents, useModal } from 'hooks/datafiles'; const NiiVue = ({ imageUrl, fileName }) => { const canvas = useRef(); @@ -36,15 +37,32 @@ const DataFilesPreviewModal = () => { !isLoading && !hasError && params.path && fileType == 'brainmap'; const [isFrameLoading, setIsFrameLoading] = useState(true); + const portalName = useSelector((state) => state.workbench.portalName); + const { DataFilesPreviewModalAddon } = useAddonComponents({ portalName }); + useEffect(() => { if (previewUsingBrainmap) setIsFrameLoading(false); }, [previewUsingBrainmap]); - const toggle = () => - dispatch({ - type: 'DATA_FILES_TOGGLE_MODAL', - payload: { operation: 'preview', props: {} }, - }); + const { toggle } = useModal(); + + const togglePreview = () => toggle({ operation: 'preview', props: {} }); + + const toggleUnavailDownloadModal = () => + toggle({ operation: 'unavailDownload', props: {} }); + + const download = () => { + // Checks to see if the file is less than 2 GB; executes the dispatch if true and displays the Globus alert if false + const maxFileSize = 2 * 1024 * 1024 * 1024; + if (params.length < maxFileSize) { + dispatch({ + type: 'DATA_FILES_DOWNLOAD', + payload: { file: params }, + }); + } else { + toggleUnavailDownloadModal(); + } + }; const onOpen = () => { setIsFrameLoading(true); @@ -78,13 +96,16 @@ const DataFilesPreviewModal = () => { isOpen={isOpen} onOpened={onOpen} onClosed={onClosed} - toggle={toggle} + toggle={togglePreview} className="dataFilesModal" > - + File Preview: {params.name} - + + {DataFilesPreviewModalAddon && !isLoading && params.scheme === 'projects' && ( + + )} {(isLoading || (previewUsingHref && isFrameLoading)) && (
@@ -111,15 +132,15 @@ const DataFilesPreviewModal = () => {
)} {hasError && ( -
+
{error} - diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesPreviewModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesPreviewModal.module.scss index f38b95c41d..471f6467f8 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesPreviewModal.module.scss +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesPreviewModal.module.scss @@ -28,6 +28,10 @@ height: 100%; } +.error-condensed { + height: 20%; +} + .button { background-color: var(--global-color-accent--normal); border-color: var(--global-color-accent--normal); @@ -36,6 +40,7 @@ align-items: center; justify-content: flex-end; white-space: nowrap; + margin-top: 1rem; i { display: flex; @@ -43,3 +48,8 @@ font-size: 1.3em; } } + +.modal-body { + max-height: 80vh; + overflow: auto; +} diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.jsx new file mode 100644 index 0000000000..fcb5d90ce9 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.jsx @@ -0,0 +1,44 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesProjectCitationModal.module.scss'; +import { Citations } from '_common/Citations/Citations'; + +const DataFilesProjectCitationModal = () => { + const dispatch = useDispatch(); + + const isOpen = useSelector((state) => state.files.modals.projectCitation); + const props = useSelector((state) => state.files.modalProps.projectCitation); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'projectCitation', props: {} }, + }); + }, []); + + return ( + <> + {props?.project && ( + + + Citations + + + + + + )} + + ); +}; + +export default DataFilesProjectCitationModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectCitationModal.module.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.jsx new file mode 100644 index 0000000000..621add8014 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.jsx @@ -0,0 +1,40 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesProjectDescriptionModal.module.scss'; + +const DataFilesProjectDescriptionModal = () => { + const dispatch = useDispatch(); + + const isOpen = useSelector((state) => state.files.modals.projectDescription); + const props = useSelector( + (state) => state.files.modalProps.projectDescription + ); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'projectDescription', props: {} }, + }); + }, []); + + return ( + <> + + + {props?.title} + + +

{props?.description}

+
+
+ + ); +}; + +export default DataFilesProjectDescriptionModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectDescriptionModal.module.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectEditDescription.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectEditDescription.module.scss index 6110805607..f6cab1c0c3 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectEditDescription.module.scss +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectEditDescription.module.scss @@ -1,5 +1,5 @@ .description-textarea { - min-height: 10em; + min-height: 100px; } .button-container { @@ -11,3 +11,8 @@ .update-button { margin-left: auto; } + +.modal-body { + max-height: 70vh; + overflow: auto; +} diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectEditDescriptionModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectEditDescriptionModal.jsx index bdaa00fffa..33ef7604dc 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectEditDescriptionModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectEditDescriptionModal.jsx @@ -1,11 +1,13 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import * as Yup from 'yup'; import { Formik, Form } from 'formik'; import FormField from '_common/Form/FormField'; import { Button, Message } from '_common'; -import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from 'reactstrap'; import styles from './DataFilesProjectEditDescription.module.scss'; +import { useAddonComponents } from 'hooks/datafiles'; +import getDefaultProjectSystem from 'utils/getDefaultProjectSystem'; const DataFilesProjectEditDescriptionModal = () => { const dispatch = useDispatch(); @@ -34,6 +36,26 @@ const DataFilesProjectEditDescriptionModal = () => { const enableWorkspaceKeywords = useSelector((state) => state.workbench.config.enableWorkspaceKeywords) ?? true; + const sharedWorkspacesDisplayName = useSelector( + (state) => + getDefaultProjectSystem(state.systems.storage.configuration)?.name + ); + + const portalName = useSelector((state) => state.workbench.portalName); + const { DataFilesProjectEditDescriptionModalAddon } = useAddonComponents({ + portalName, + }); + + const isOwner = useSelector( + (state) => + state.projects.metadata.members + .filter((member) => + member.user + ? member.user.username === state.authenticatedUser?.user?.username + : { access: null } + ) + .map((currentUser) => currentUser.access === 'owner')[0] + ); const initialValues = useMemo( () => ({ @@ -61,14 +83,16 @@ const DataFilesProjectEditDescriptionModal = () => { title: values.title, description: values.description || '', keywords: values.keywords || [], + metadata: DataFilesProjectEditDescriptionModalAddon ? values : null, }, + modal: 'editproject', }, }); }, [projectId, dispatch] ); - const validationSchema = Yup.object().shape({ + const [validationSchema, setValidationSchema] = useState(Yup.object().shape({ title: Yup.string() .min(3, 'Title must be at least 3 characters') .max(maxTitleLength, `Title must be at most ${maxTitleLength} characters`) @@ -83,15 +107,15 @@ const DataFilesProjectEditDescriptionModal = () => { then: (schema) => schema.required('Please enter a description.'), otherwise: (schema) => schema.notRequired(), }), - keywords: Yup.array().of(Yup.string()), - }); + ...(enableWorkspaceKeywords && { + keywords: Yup.array().of(Yup.string() + ), + }), + })); return ( - - - Edit Workspace - - + + {/* */} { > {({ isValid, dirty }) => (
- - Title{' '} - - (Maximum {maxTitleLength} characters) - -
- } - /> - {!!minDescriptionLength && ( + + Edit {sharedWorkspacesDisplayName} + + - Description{' '} + Title{' '} - (Minimum {minDescriptionLength} characters) + (Maximum {maxTitleLength} characters)
} - type="textarea" - className={styles['description-textarea']} - /> - )} - {!!enableWorkspaceKeywords && ( - Keywords} - type="textarea" - className={styles['description-textarea']} /> - )} -
- {updatingError && ( - - Something went wrong. - + {!!minDescriptionLength && ( + + Description{' '} + + (Minimum {minDescriptionLength} characters) + +
+ } + type="textarea" + className={styles['description-textarea']} + /> + )} + {!!enableWorkspaceKeywords && ( + Keywords} + type="textarea" + className={styles['description-textarea']} + /> + )} + {DataFilesProjectEditDescriptionModalAddon && ( + )} - - - - )} - -
+
+ {updatingError && ( + + Something went wrong. + + )} + +
+
+ + )} + + {/* */} ); }; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.jsx new file mode 100644 index 0000000000..4650642c5c --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.jsx @@ -0,0 +1,45 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesProjectTreeModal.module.scss'; +import { ProjectTreeView } from '_custom/drp/DataFilesProjectPublish/DataFilesProjectPublishWizardSteps/ProjectTreeView'; + +const DataFilesProjectTreeModal = () => { + const dispatch = useDispatch(); + + const { projectId } = useSelector((state) => state.projects.metadata); + + const isOpen = useSelector((state) => state.files.modals.projectTree); + const props = useSelector((state) => state.files.modalProps['projectTree']); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'projectTree', props: {} }, + }); + }, []); + + return ( + <> + + + Tree Diagram + + + {' '} + + + + + ); +}; + +export default DataFilesProjectTreeModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.module.scss new file mode 100644 index 0000000000..7012ef5ce7 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesProjectTreeModal.module.scss @@ -0,0 +1,4 @@ +.modal-body { + overflow: auto; + max-height: 80vh; +} diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationAuthorsModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationAuthorsModal.jsx new file mode 100644 index 0000000000..25666ec2d4 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationAuthorsModal.jsx @@ -0,0 +1,61 @@ +import InfiniteScrollTable from '_common/InfiniteScrollTable'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesPublicationAuthorsModal.module.scss'; + +const DataFilesPublicationAuthorsModal = () => { + const dispatch = useDispatch(); + const isOpen = useSelector((state) => state.files.modals.publicationAuthors); + const props = useSelector( + (state) => state.files.modalProps.publicationAuthors + ); + + const columns = [ + { + Header: 'Name', + accessor: (el) => el, + Cell: (el) => { + const { first_name, last_name } = el.value; + return ( + + {first_name} {last_name} + + ); + }, + }, + { + Header: 'Email', + accessor: 'email', + }, + ]; + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'publicationAuthors', props: {} }, + }); + }, [dispatch]); + + return ( + + + Authors + + + + + + ); +}; + +export default DataFilesPublicationAuthorsModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationAuthorsModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationAuthorsModal.module.scss new file mode 100644 index 0000000000..dbd530a026 --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationAuthorsModal.module.scss @@ -0,0 +1,12 @@ +.author-listing { + /* title */ + th:nth-child(1), + td:nth-child(1) { + width: 50%; + } + /* date */ + th:nth-child(2), + td:nth-child(2) { + width: 50%; + } +} diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationDownloadModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationDownloadModal.jsx new file mode 100644 index 0000000000..570ff14fbb --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationDownloadModal.jsx @@ -0,0 +1,97 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import { Button, DescriptionList, LoadingSpinner, Message } from '_common'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesPublicationRequestModal.module.scss'; +import { formatDate, formatDateTime } from 'utils/timeFormat'; +import { useFileDetail, useModal } from 'hooks/datafiles'; + +export function toBytes(bytes) { + if (bytes === 0) return '0 bytes'; + if (!bytes) return '-'; + const units = ['bytes', 'kB', 'MB', 'GB', 'TB', 'PB']; + const orderOfMagnitude = Math.floor(Math.log(bytes) / Math.log(1024)); + const precision = orderOfMagnitude === 0 ? 0 : 1; + const bytesInUnits = bytes / Math.pow(1024, orderOfMagnitude); + return `${bytesInUnits.toFixed(precision)} ${units[orderOfMagnitude]}`; +} + +const DataFilesPublicationDownloadModal = () => { + const dispatch = useDispatch(); + const { toggle: toggleModal, getStatus, getProps } = useModal(); + const { projectId, rootSystem } = getProps('publicationDownload') || {}; + const archiveProjectId = (projectId ?? '').split('.').pop(); + + const isOpen = getStatus('publicationDownload') ?? false; + + const toggle = useCallback(() => { + toggleModal({ operation: 'publicationDownload', props: {} }); + }, []); + + const archivePath = `/archive/${archiveProjectId}/${archiveProjectId}_archive.zip`; + const { isLoading, isError, data } = useFileDetail( + 'tapis', + rootSystem, + 'public', + archivePath, + isOpen + ); + const archiveSize = data?.length ?? 0; + const maxFileSize = 2 * 1024 * 1024 * 1024; + useEffect(() => { + if (isOpen && archiveSize > maxFileSize) { + toggleModal({ operation: 'unavailDownload' }); + toggleModal({ operation: 'publicationDownload', props: {} }); + } + }, [archiveSize, maxFileSize]); + + const downloadFile = () => { + dispatch({ + type: 'DATA_FILES_DOWNLOAD', + payload: { file: { system: rootSystem, path: archivePath } }, + }); + }; + + return ( + <> + + + Download Publication {archiveProjectId} + + + {isError && ( + + The requested data could not be accessed. + + )} + {isLoading && } + {!!data && !isLoading && !isError && ( + <> +

+ This download is a ZIP file of the complete project dataset. The + size of the ZIP file is {toBytes(data?.length)} + . +

+

+ +

+ + )} +
+
+ + ); +}; + +export default DataFilesPublicationDownloadModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationRequestModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationRequestModal.jsx new file mode 100644 index 0000000000..d81433537c --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationRequestModal.jsx @@ -0,0 +1,83 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useDispatch, useSelector, shallowEqual } from 'react-redux'; +import { DescriptionList } from '_common'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; +import styles from './DataFilesPublicationRequestModal.module.scss'; +import { formatDate, formatDateTime } from 'utils/timeFormat'; + +const DataFilesPublicationRequestModal = () => { + const dispatch = useDispatch(); + const [data, setData] = useState([]); + + const isOpen = useSelector((state) => state.files.modals.publicationRequest); + const { publicationRequests } = + useSelector((state) => state.files.modalProps.publicationRequest) || []; + + const compareFn = (req1, req2) => { + // sort more recent requests first + const date1 = new Date(req1.created_at); + const date2 = new Date(req2.created_at); + if (date1 < date2) { + return 1; + } + if (date1 > date2) { + return -1; + } + return 0; + }; + + useEffect(() => { + const data = {}; + + publicationRequests?.sort(compareFn).forEach((request, index) => { + const publicationRequestDataObj = { + Status: request.status, + Reviewers: request.reviewers.reduce((acc, reviewer, index) => { + return ( + acc + + (index > 0 ? ', ' : '') + + `${reviewer.first_name} ${reviewer.last_name}` + ); + }, ''), + Submitted: formatDateTime(new Date(request.created_at)), + _order: index, + }; + + const heading = `Publication Request | ${formatDate(new Date(request.created_at))}`; + + data[heading] = ; + }); + + setData(data); + }, [publicationRequests]); + + const toggle = useCallback(() => { + dispatch({ + type: 'DATA_FILES_TOGGLE_MODAL', + payload: { operation: 'publicationRequest', props: {} }, + }); + }, []); + + return ( + <> + + + Publication Requests + + + + + + + ); +}; + +export default DataFilesPublicationRequestModal; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationRequestModal.module.scss b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationRequestModal.module.scss new file mode 100644 index 0000000000..929c10e1ec --- /dev/null +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesPublicationRequestModal.module.scss @@ -0,0 +1,44 @@ +.panel-content { + width: 100%; + overflow-y: scroll; + + /* Cross-browser solution to padding ignored by overflow (in spec-compliant Firefox) */ + /* SEE: https://stackoverflow.com/a/38997047/11817077 */ + padding-bottom: 0; + &::after { + content: ''; + display: block; + height: var(--padding); + } +} + +dl.panel-content { + --buffer-horz: 12px; /* ~10px design * 1.2 design-to-app ratio */ + --buffer-vert: 10px; /* gut feel based loosely on random space from design */ + --border: var(--global-border-width--normal) solid + var(--global-color-primary--light); +} + +dl.panel-content > dt, +dl.panel-content > dd { + padding-left: var(--buffer-horz); + padding-right: var(--buffer-horz); + padding-top: var(--buffer-vert); +} + +dl.panel-content > dt { + border-top: var(--border); +} +dl.panel-content > dt:first-of-type { + border-top: none; +} + +dl.panel-content > dt:nth-of-type(even), +dl.panel-content > dd:nth-of-type(even) { + background-color: var(--global-color-primary--x-light); +} + +/* Remove the colon from top-level labels */ +dl.panel-content > dt::after { + display: none; +} diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesShowPathModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesShowPathModal.jsx index 0b49d5840a..cb3fe5d44c 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesShowPathModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesShowPathModal.jsx @@ -3,6 +3,7 @@ import { useSelector, useDispatch, shallowEqual } from 'react-redux'; import { Modal, ModalHeader, ModalBody } from 'reactstrap'; import { TextCopyField } from '_common'; import styles from './DataFilesShowPathModal.module.scss'; +import { useSystems } from 'hooks/datafiles'; const DataFilesShowPathModal = React.memo(() => { const dispatch = useDispatch(); @@ -27,8 +28,14 @@ const DataFilesShowPathModal = React.memo(() => { } }, [modalParams, dispatch]); + const { isRootProjectSystem } = useSystems(); + useEffect(() => { - if (params.api === 'tapis' && params.system) { + if ( + params.api === 'tapis' && + params.system && + !isRootProjectSystem({ system: params.system }) + ) { dispatch({ type: 'FETCH_SYSTEM_DEFINITION', payload: params.system, diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesUploadModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesUploadModal.jsx index 7de5f6c3ae..373199a97a 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesUploadModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesUploadModal.jsx @@ -11,6 +11,7 @@ import { useFileListing, useModal, useTapisToken, + useAddonComponents, } from 'hooks/datafiles'; import { useUpload } from 'hooks/datafiles/mutations'; import DataFilesUploadModalListingTable from './DataFilesUploadModalListing/DataFilesUploadModalListingTable'; @@ -31,6 +32,8 @@ const DataFilesUploadModal = ({ className, layout }) => { const reloadCallback = () => { history.push(location.pathname); }; + const portalName = useSelector((state) => state.workbench.portalName); + const { DataFilesUploadModalAddon } = useAddonComponents({ portalName }); const maxSizeLabel = useSelector( (state) => state.workbench.config.uploadModalMaxSizeLabel @@ -55,6 +58,7 @@ const DataFilesUploadModal = ({ className, layout }) => { upload({ system: params.system, path: params.path || '', + scheme: params.scheme, files: filteredFiles, reloadCallback, tapisToken, @@ -128,7 +132,7 @@ const DataFilesUploadModal = ({ className, layout }) => { > Upload Files
- +
{ />
)} + {DataFilesUploadModalAddon && params.scheme === 'projects' && ( + + )}