From f9714f21be3d7b12f28d63b9f31ca085260cae17 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Tue, 13 Jul 2021 18:29:41 +0200 Subject: [PATCH] Study listing improvements (refacto, new ui components, add import) (#420) * Refacto study listing view Signed-off-by: Paul Bui-Quang * Fix tests Signed-off-by: Paul Bui-Quang * Fix linter Signed-off-by: Paul Bui-Quang --- .../storage/business/raw_study_service.py | 10 +- antarest/storage/service.py | 6 +- antarest/storage/web/studies_blueprint.py | 4 +- conf/gunicorn.py | 2 +- tests/integration/test_integration.py | 2 +- tests/storage/integration/test_STA_mini.py | 2 +- tests/storage/web/test_studies_bp.py | 1 + webapp/public/locales/en/main.json | 3 +- webapp/public/locales/en/studymanager.json | 4 +- webapp/public/locales/fr/main.json | 3 +- webapp/public/locales/fr/studymanager.json | 4 +- webapp/src/App/theme.tsx | 7 + .../StudyListing/StudyBlockSummaryView.tsx | 181 ------------------ .../StudyListing/StudyListSummaryView.tsx | 166 ---------------- .../StudyBlockSummaryView.test.tsx | 0 .../StudyBlockSummaryView.tsx | 139 ++++++++++++++ .../StudyListSummaryView.test.tsx | 0 .../StudyListSummaryView.tsx | 147 ++++++++++++++ .../StudyListingItemView/index.tsx | 79 ++++++++ .../StudyListingItemView/types.ts | 9 + webapp/src/components/StudyListing/index.tsx | 30 +-- webapp/src/components/ui/ButtonLoader.tsx | 67 +++++++ webapp/src/components/ui/MultiButton.tsx | 131 +++++++++++++ webapp/src/services/api/study.ts | 5 + 24 files changed, 631 insertions(+), 371 deletions(-) delete mode 100644 webapp/src/components/StudyListing/StudyBlockSummaryView.tsx delete mode 100644 webapp/src/components/StudyListing/StudyListSummaryView.tsx rename webapp/src/components/StudyListing/{ => StudyListingItemView}/StudyBlockSummaryView.test.tsx (100%) create mode 100644 webapp/src/components/StudyListing/StudyListingItemView/StudyBlockSummaryView.tsx rename webapp/src/components/StudyListing/{ => StudyListingItemView}/StudyListSummaryView.test.tsx (100%) create mode 100644 webapp/src/components/StudyListing/StudyListingItemView/StudyListSummaryView.tsx create mode 100644 webapp/src/components/StudyListing/StudyListingItemView/index.tsx create mode 100644 webapp/src/components/StudyListing/StudyListingItemView/types.ts create mode 100644 webapp/src/components/ui/ButtonLoader.tsx create mode 100644 webapp/src/components/ui/MultiButton.tsx diff --git a/antarest/storage/business/raw_study_service.py b/antarest/storage/business/raw_study_service.py index 109691d741..779d66aa59 100644 --- a/antarest/storage/business/raw_study_service.py +++ b/antarest/storage/business/raw_study_service.py @@ -260,12 +260,18 @@ def create_study(self, metadata: RawStudy) -> RawStudy: metadata.path = str(path_study) return metadata - def copy_study(self, src_meta: RawStudy, dest_meta: RawStudy) -> RawStudy: + def copy_study( + self, + src_meta: RawStudy, + dest_meta: RawStudy, + with_outputs: bool = False, + ) -> RawStudy: """ Copy study to a new destination Args: src_meta: source study dest_meta: destination study + with_outputs: indicate weither to copy the output or not Returns: destination study @@ -277,7 +283,7 @@ def copy_study(self, src_meta: RawStudy, dest_meta: RawStudy) -> RawStudy: shutil.copytree(src_path, dest_path) output = dest_path / "output" - if output.exists(): + if not with_outputs and output.exists(): shutil.rmtree(output) _, study = self.study_factory.create_from_fs( diff --git a/antarest/storage/service.py b/antarest/storage/service.py index 9b540ed1c0..c29c2a5d36 100644 --- a/antarest/storage/service.py +++ b/antarest/storage/service.py @@ -319,6 +319,7 @@ def copy_study( dest_study_name: str, group_ids: List[str], params: RequestParameters, + with_outputs: bool = False, ) -> str: """ Copy study to an other location. @@ -328,6 +329,7 @@ def copy_study( dest_study_name: destination study group_ids: group to attach on new study params: request parameters + with_outputs: indicate if outputs should be copied too Returns: @@ -353,7 +355,9 @@ def copy_study( version=src_study.version, ) - study = self.study_service.copy_study(src_study, dest_study) + study = self.study_service.copy_study( + src_study, dest_study, with_outputs + ) self._save_study(study, params.user, group_ids) self.event_bus.push( Event(EventType.STUDY_CREATED, study.to_json_summary()) diff --git a/antarest/storage/web/studies_blueprint.py b/antarest/storage/web/studies_blueprint.py index 9ff40ca08e..4e633adf30 100644 --- a/antarest/storage/web/studies_blueprint.py +++ b/antarest/storage/web/studies_blueprint.py @@ -91,6 +91,7 @@ def import_study( def copy_study( uuid: str, dest: str, + with_outputs: bool = False, groups: Optional[str] = None, current_user: JWTUser = Depends(auth.get_current_user), ) -> Any: @@ -105,10 +106,11 @@ def copy_study( src_uuid=source_uuid_sanitized, dest_study_name=destination_name_sanitized, group_ids=group_ids, + with_outputs=with_outputs, params=params, ) - return f"/studies/{destination_uuid}" + return destination_uuid @bp.post( "/studies", diff --git a/conf/gunicorn.py b/conf/gunicorn.py index ebf16122c1..49d0020160 100644 --- a/conf/gunicorn.py +++ b/conf/gunicorn.py @@ -27,4 +27,4 @@ loglevel = "info" errorlog = "-" accesslog = "-" -preload_app = False \ No newline at end of file +preload_app = False diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index 019b3c9b8a..66d19e44b8 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -134,7 +134,7 @@ def test_main(app: FastAPI): # Study delete client.delete( - f"/v1{copied.json()}", + f"/v1/studies/{copied.json()}", headers={ "Authorization": f'Bearer {george_credentials["access_token"]}' }, diff --git a/tests/storage/integration/test_STA_mini.py b/tests/storage/integration/test_STA_mini.py index b3a049d750..b8070b48f3 100644 --- a/tests/storage/integration/test_STA_mini.py +++ b/tests/storage/integration/test_STA_mini.py @@ -410,7 +410,7 @@ def test_sta_mini_copy(storage_service) -> None: ) assert result.status_code == HTTPStatus.CREATED.value - uuid = result.json()[len("/studies/") :] + uuid = result.json() parameters = RequestParameters(user=ADMIN) data_source = storage_service.get(source_study_name, "/", -1, parameters) diff --git a/tests/storage/web/test_studies_bp.py b/tests/storage/web/test_studies_bp.py index ec08a53fce..52aed96855 100644 --- a/tests/storage/web/test_studies_bp.py +++ b/tests/storage/web/test_studies_bp.py @@ -221,6 +221,7 @@ def test_copy_study(tmp_path: Path, storage_service_builder) -> None: src_uuid="existing-study", dest_study_name="study-copied", group_ids=[], + with_outputs=False, params=PARAMS, ) assert result.status_code == HTTPStatus.CREATED.value diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 26e3bbb9e6..3ab4b1f740 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -21,5 +21,6 @@ "backButton": "Back", "closeButton": "Close", "save": "Save", - "edit": "Edit" + "edit": "Edit", + "copy": "Copy" } \ No newline at end of file diff --git a/webapp/public/locales/en/studymanager.json b/webapp/public/locales/en/studymanager.json index 3684e34404..611e9dc562 100644 --- a/webapp/public/locales/en/studymanager.json +++ b/webapp/public/locales/en/studymanager.json @@ -9,5 +9,7 @@ "failtorunstudy": "Failed to run study", "studylaunched": "{{studyname}} launched !", "savedatasuccess": "Data saved with success", - "failtosavedata": "Failed to save data" + "failtosavedata": "Failed to save data", + "failtocopystudy": "Failed to copy study", + "studycopiedsuccess": "Success copying study" } \ No newline at end of file diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 4ee48516a0..3297a1f6e9 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -21,5 +21,6 @@ "backButton": "Retour", "closeButton": "Fermer", "save": "Sauvegarder", - "edit": "Editer" + "edit": "Editer", + "copy": "Copie" } \ No newline at end of file diff --git a/webapp/public/locales/fr/studymanager.json b/webapp/public/locales/fr/studymanager.json index 72fe91a68f..38da6ac095 100644 --- a/webapp/public/locales/fr/studymanager.json +++ b/webapp/public/locales/fr/studymanager.json @@ -9,5 +9,7 @@ "failtorunstudy": "Echec du lancement de l'étude", "studylaunched": "{{studyname}} lancée !", "savedatasuccess": "Données sauvegardées avec succès", - "failtosavedata": "Erreur lors de la sauvegarde des données" + "failtosavedata": "Erreur lors de la sauvegarde des données", + "failtocopystudy": "Erreur lors de la copie de l'étude", + "studycopiedsuccess": "Etude copiée avec succès" } \ No newline at end of file diff --git a/webapp/src/App/theme.tsx b/webapp/src/App/theme.tsx index 4670edbc05..215abcda8b 100644 --- a/webapp/src/App/theme.tsx +++ b/webapp/src/App/theme.tsx @@ -2,6 +2,13 @@ import { createMuiTheme } from '@material-ui/core'; export const TOOLBAR_HEIGHT = '48px'; +export const jobStatusColors = { + 'JobStatus.RUNNING': 'orange', + 'JobStatus.PENDING': 'orange', + 'JobStatus.SUCCESS': 'green', + 'JobStatus.FAILED': 'red', +}; + export default createMuiTheme({ overrides: { MuiToolbar: { diff --git a/webapp/src/components/StudyListing/StudyBlockSummaryView.tsx b/webapp/src/components/StudyListing/StudyBlockSummaryView.tsx deleted file mode 100644 index 9bd55ae7f7..0000000000 --- a/webapp/src/components/StudyListing/StudyBlockSummaryView.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import clsx from 'clsx'; -import moment from 'moment'; -import { makeStyles, Button, createStyles, Theme, Card, CardContent, Typography, Grid, CardActions } from '@material-ui/core'; -import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; -import { useTheme } from '@material-ui/core/styles'; -import { useSnackbar } from 'notistack'; -import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { JobStatus, LaunchJob, StudyMetadata } from '../../common/types'; -import { getExportUrl, getStudyJobs } from '../../services/api/study'; -import DownloadLink from '../ui/DownloadLink'; -import ConfirmationModal from '../ui/ConfirmationModal'; - -const useStyles = makeStyles((theme: Theme) => createStyles({ - root: { - margin: '10px', - padding: '4px', - width: '400px', - height: '170px', - }, - jobStatus: { - width: '20px', - marginLeft: theme.spacing(1), - flexFlow: 'column nowrap', - justifyContent: 'flex-start', - alignItems: 'flex-end', - }, - buttons: { - width: '100%', - }, - title: { - color: theme.palette.primary.main, - fontWeight: 'bold', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - textDecoration: 'none', - }, - managed: { - backgroundColor: theme.palette.secondary.main, - }, - titleContainer: { - display: 'flex', - alignItems: 'center', - }, - workspace: { - marginLeft: theme.spacing(1), - }, - workspaceBadge: { - border: `1px solid ${theme.palette.primary.main}`, - borderRadius: '4px', - padding: '0 4px', - fontSize: '0.8em', - }, - id: { - fontFamily: "'Courier', sans-serif", - fontSize: 'small', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - info: { - marginTop: theme.spacing(1), - }, - infotxt: { - marginLeft: theme.spacing(1), - }, - pos: { - marginBottom: 12, - }, -})); - -interface PropTypes { - study: StudyMetadata; - launchStudy: (study: StudyMetadata) => void; - deleteStudy: (study: StudyMetadata) => void; -} - -const StudyBlockSummaryView = (props: PropTypes) => { - const classes = useStyles(); - const theme = useTheme(); - const [t] = useTranslation(); - const { enqueueSnackbar } = useSnackbar(); - const { study, launchStudy, deleteStudy } = props; - const [lastJobStatus, setLastJobsStatus] = useState(); - const [openConfirmationModal, setOpenConfirmationModal] = useState(false); - - const statusColor = { - 'JobStatus.RUNNING': 'orange', - 'JobStatus.PENDING': 'orange', - 'JobStatus.SUCCESS': 'green', - 'JobStatus.FAILED': 'red', - }; - - const deleteStudyAndCloseModal = () => { - deleteStudy(study); - setOpenConfirmationModal(false); - }; - - useEffect(() => { - const init = async () => { - try { - const jobList = await getStudyJobs(study.id); - jobList.sort((a: LaunchJob, b: LaunchJob) => (moment(a.completionDate).isAfter(moment(b.completionDate)) ? -1 : 1)); - if (jobList.length > 0) setLastJobsStatus(jobList[0].status); - } catch (e) { - enqueueSnackbar(t('singlestudy:failtoloadjobs'), { variant: 'error' }); - } - }; - init(); - }, [t, enqueueSnackbar, study.id]); - - return ( -
- - -
- - - {study.name} - { - !!lastJobStatus && ( - - - - ) - } - - -
-
- {study.workspace} -
-
-
- - {study.id} - - - - - {study.author} - - - - {moment.unix(study.creationDate).format('YYYY/MM/DD HH:mm')} - - - - {study.version} - - - - {moment.unix(study.modificationDate).format('YYYY/MM/DD HH:mm')} - - -
- -
- - -
- -
- {openConfirmationModal && ( - setOpenConfirmationModal(false)} - /> - )} -
-
- ); -}; - -export default StudyBlockSummaryView; diff --git a/webapp/src/components/StudyListing/StudyListSummaryView.tsx b/webapp/src/components/StudyListing/StudyListSummaryView.tsx deleted file mode 100644 index 5f951d0ead..0000000000 --- a/webapp/src/components/StudyListing/StudyListSummaryView.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import clsx from 'clsx'; -import { makeStyles, Button, createStyles, Theme, Paper, Typography } from '@material-ui/core'; -import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; -import { useTheme } from '@material-ui/core/styles'; -import { useTranslation } from 'react-i18next'; -import { useSnackbar } from 'notistack'; -import { Link } from 'react-router-dom'; -import moment from 'moment'; -import { JobStatus, LaunchJob, StudyMetadata } from '../../common/types'; -import { getExportUrl, getStudyJobs } from '../../services/api/study'; -import DownloadLink from '../ui/DownloadLink'; -import ConfirmationModal from '../ui/ConfirmationModal'; - -const useStyles = makeStyles((theme: Theme) => createStyles({ - root: { - margin: theme.spacing(1), - paddingRight: theme.spacing(1), - paddingLeft: theme.spacing(1), - width: '90%', - height: '50px', - display: 'flex', - flexFlow: 'row nowrap', - justifyContent: 'flex-start', - alignItems: 'center', - }, - jobStatus: { - width: '10px', - height: '10px', - marginLeft: theme.spacing(0.5), - display: 'flex', - flexFlow: 'column nowrap', - justifyContent: 'flex-start', - alignItems: 'flex-start', - }, - title: { - color: theme.palette.primary.main, - fontWeight: 'bold', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - textDecoration: 'none', - }, - managed: { - backgroundColor: theme.palette.secondary.main, - }, - titleContainer: { - display: 'flex', - alignItems: 'center', - }, - workspace: { - marginLeft: theme.spacing(1), - }, - workspaceBadge: { - border: `1px solid ${theme.palette.primary.main}`, - borderRadius: '4px', - padding: '0 4px', - fontSize: '0.8em', - }, - id: { - fontFamily: "'Courier', sans-serif", - fontSize: 'small', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - info: { - flex: 1, - display: 'flex', - flexFlow: 'column nowrap', - justifyContent: 'center', - alignItems: 'flex-start', - }, - buttons: { - flex: 1, - display: 'flex', - flexFlow: 'row nowrap', - justifyContent: 'flex-end', - alignItems: 'center', - }, -})); - -interface PropTypes { - study: StudyMetadata; - launchStudy: (study: StudyMetadata) => void; - deleteStudy: (study: StudyMetadata) => void; -} - -const StudyListSummaryView = (props: PropTypes) => { - const classes = useStyles(); - const theme = useTheme(); - const [t] = useTranslation(); - const { enqueueSnackbar } = useSnackbar(); - const { study, launchStudy, deleteStudy } = props; - const [lastJobStatus, setLastJobsStatus] = useState(); - const [openConfirmationModal, setOpenConfirmationModal] = useState(false); - - const statusColor = { - 'JobStatus.RUNNING': 'orange', - 'JobStatus.PENDING': 'orange', - 'JobStatus.SUCCESS': 'green', - 'JobStatus.FAILED': 'red', - }; - - const deleteStudyAndCloseModal = () => { - deleteStudy(study); - setOpenConfirmationModal(false); - }; - - useEffect(() => { - const init = async () => { - try { - const jobList = await getStudyJobs(study.id); - jobList.sort((a: LaunchJob, b: LaunchJob) => (moment(a.completionDate).isAfter(moment(b.completionDate)) ? -1 : 1)); - if (jobList.length > 0) setLastJobsStatus(jobList[0].status); - } catch (e) { - enqueueSnackbar(t('singlestudy:failtoloadjobs'), { variant: 'error' }); - } - }; - init(); - }, [t, enqueueSnackbar, study.id]); - - return ( - -
-
- - - {study.name} - - - { - !!lastJobStatus && ( -
- -
- )} -
-
- {study.workspace} -
-
-
- - {study.id} - -
-
- - - -
- {openConfirmationModal && ( - setOpenConfirmationModal(false)} - /> - )} -
- ); -}; - -export default StudyListSummaryView; diff --git a/webapp/src/components/StudyListing/StudyBlockSummaryView.test.tsx b/webapp/src/components/StudyListing/StudyListingItemView/StudyBlockSummaryView.test.tsx similarity index 100% rename from webapp/src/components/StudyListing/StudyBlockSummaryView.test.tsx rename to webapp/src/components/StudyListing/StudyListingItemView/StudyBlockSummaryView.test.tsx diff --git a/webapp/src/components/StudyListing/StudyListingItemView/StudyBlockSummaryView.tsx b/webapp/src/components/StudyListing/StudyListingItemView/StudyBlockSummaryView.tsx new file mode 100644 index 0000000000..cb4e31dc7c --- /dev/null +++ b/webapp/src/components/StudyListing/StudyListingItemView/StudyBlockSummaryView.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import clsx from 'clsx'; +import moment from 'moment'; +import { makeStyles, Button, createStyles, Theme, Card, CardContent, Typography, Grid, CardActions } from '@material-ui/core'; +import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; +import { useTheme } from '@material-ui/core/styles'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { getExportUrl } from '../../../services/api/study'; +import DownloadLink from '../../ui/DownloadLink'; +import { jobStatusColors } from '../../../App/theme'; +import { StudyListingItemPropTypes } from './types'; + +const useStyles = makeStyles((theme: Theme) => createStyles({ + root: { + margin: '10px', + padding: '4px', + width: '400px', + height: '170px', + }, + jobStatus: { + width: '20px', + marginLeft: theme.spacing(1), + flexFlow: 'column nowrap', + justifyContent: 'flex-start', + alignItems: 'flex-end', + }, + buttons: { + width: '100%', + }, + title: { + color: theme.palette.primary.main, + fontWeight: 'bold', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + textDecoration: 'none', + }, + managed: { + backgroundColor: theme.palette.secondary.main, + }, + titleContainer: { + display: 'flex', + alignItems: 'center', + }, + workspace: { + marginLeft: theme.spacing(1), + }, + workspaceBadge: { + border: `1px solid ${theme.palette.primary.main}`, + borderRadius: '4px', + padding: '0 4px', + fontSize: '0.8em', + }, + id: { + fontFamily: "'Courier', sans-serif", + fontSize: 'small', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + info: { + marginTop: theme.spacing(1), + }, + infotxt: { + marginLeft: theme.spacing(1), + }, + pos: { + marginBottom: 12, + }, +})); + +const StudyBlockSummaryView = (props: StudyListingItemPropTypes) => { + const classes = useStyles(); + const theme = useTheme(); + const [t] = useTranslation(); + const { study, launchStudy, openDeletionModal, lastJobStatus } = props; + + return ( + + +
+ + + {study.name} + { + !!lastJobStatus && ( + + + + ) + } + + +
+
+ {study.workspace} +
+
+
+ + {study.id} + + + + + {study.author} + + + + {moment.unix(study.creationDate).format('YYYY/MM/DD HH:mm')} + + + + {study.version} + + + + {moment.unix(study.modificationDate).format('YYYY/MM/DD HH:mm')} + + +
+ +
+ + +
+ +
+
+ ); +}; + +StudyBlockSummaryView.defaultProps = { + lastJobStatus: undefined, +}; + +export default StudyBlockSummaryView; diff --git a/webapp/src/components/StudyListing/StudyListSummaryView.test.tsx b/webapp/src/components/StudyListing/StudyListingItemView/StudyListSummaryView.test.tsx similarity index 100% rename from webapp/src/components/StudyListing/StudyListSummaryView.test.tsx rename to webapp/src/components/StudyListing/StudyListingItemView/StudyListSummaryView.test.tsx diff --git a/webapp/src/components/StudyListing/StudyListingItemView/StudyListSummaryView.tsx b/webapp/src/components/StudyListing/StudyListingItemView/StudyListSummaryView.tsx new file mode 100644 index 0000000000..72a2e724cb --- /dev/null +++ b/webapp/src/components/StudyListing/StudyListingItemView/StudyListSummaryView.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import clsx from 'clsx'; +import { makeStyles, Button, createStyles, Theme, Paper, Typography } from '@material-ui/core'; +import FiberManualRecordIcon from '@material-ui/icons/FiberManualRecord'; +import { useTheme } from '@material-ui/core/styles'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { getExportUrl } from '../../../services/api/study'; +import DownloadLink from '../../ui/DownloadLink'; +import { jobStatusColors } from '../../../App/theme'; +import { StudyListingItemPropTypes } from './types'; +import ButtonLoader from '../../ui/ButtonLoader'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + margin: theme.spacing(1), + paddingRight: theme.spacing(1), + paddingLeft: theme.spacing(1), + width: '90%', + height: '50px', + display: 'flex', + flexFlow: 'row nowrap', + justifyContent: 'flex-start', + alignItems: 'center', + }, + jobStatus: { + width: '10px', + height: '10px', + marginLeft: theme.spacing(0.5), + display: 'flex', + flexFlow: 'column nowrap', + justifyContent: 'flex-start', + alignItems: 'flex-start', + }, + title: { + color: theme.palette.primary.main, + fontWeight: 'bold', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + textDecoration: 'none', + }, + managed: { + backgroundColor: theme.palette.secondary.main, + }, + titleContainer: { + display: 'flex', + alignItems: 'center', + }, + workspace: { + marginLeft: theme.spacing(1), + }, + workspaceBadge: { + border: `1px solid ${theme.palette.primary.main}`, + borderRadius: '4px', + padding: '0 4px', + fontSize: '0.8em', + }, + id: { + fontFamily: "'Courier', sans-serif", + fontSize: 'small', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + info: { + flex: 1, + display: 'flex', + flexFlow: 'column nowrap', + justifyContent: 'center', + alignItems: 'flex-start', + }, + buttons: { + flex: 1, + display: 'flex', + flexFlow: 'row nowrap', + justifyContent: 'flex-end', + alignItems: 'center', + }, + })); + +const StudyListSummaryView = (props: StudyListingItemPropTypes) => { + const classes = useStyles(); + const theme = useTheme(); + const [t] = useTranslation(); + const { study, launchStudy, openDeletionModal, lastJobStatus, importStudy } = props; + + return ( + +
+
+ + + {study.name} + + + {!!lastJobStatus && ( +
+ +
+ )} +
+
+ {study.workspace} +
+
+
+ + {study.id} + +
+
+ + importStudy(study)} + > + {t('main:import')} + + + + + +
+
+ ); +}; + +export default StudyListSummaryView; diff --git a/webapp/src/components/StudyListing/StudyListingItemView/index.tsx b/webapp/src/components/StudyListing/StudyListingItemView/index.tsx new file mode 100644 index 0000000000..63cdc95930 --- /dev/null +++ b/webapp/src/components/StudyListing/StudyListingItemView/index.tsx @@ -0,0 +1,79 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSnackbar } from 'notistack'; +import moment from 'moment'; +import { JobStatus, LaunchJob, StudyMetadata } from '../../../common/types'; +import { getStudyJobs } from '../../../services/api/study'; +import ConfirmationModal from '../../ui/ConfirmationModal'; +import StudyBlockSummaryView from './StudyBlockSummaryView'; +import StudyListSummaryView from './StudyListSummaryView'; + +interface PropTypes { + study: StudyMetadata; + launchStudy: (study: StudyMetadata) => void; + deleteStudy: (study: StudyMetadata) => void; + importStudy: (study: StudyMetadata, withOutputs?: boolean) => void; + listMode: boolean; +} + +const StudyListElementView = (props: PropTypes) => { + const [t] = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const { study, launchStudy, deleteStudy, importStudy, listMode } = props; + const [lastJobStatus, setLastJobsStatus] = useState(); + const [openConfirmationModal, setOpenConfirmationModal] = useState(false); + + const deleteStudyAndCloseModal = () => { + deleteStudy(study); + setOpenConfirmationModal(false); + }; + + useEffect(() => { + const init = async () => { + try { + const jobList = await getStudyJobs(study.id); + jobList.sort((a: LaunchJob, b: LaunchJob) => + (moment(a.completionDate).isAfter(moment(b.completionDate)) ? -1 : 1)); + if (jobList.length > 0) setLastJobsStatus(jobList[0].status); + } catch (e) { + enqueueSnackbar(t('singlestudy:failtoloadjobs'), { variant: 'error' }); + } + }; + init(); + }, [t, enqueueSnackbar, study.id]); + + return ( + <> + { + listMode ? ( + setOpenConfirmationModal(true)} + /> + ) : ( + setOpenConfirmationModal(true)} + /> + ) + } + {openConfirmationModal && ( + setOpenConfirmationModal(false)} + /> + )} + + ); +}; + +export default StudyListElementView; diff --git a/webapp/src/components/StudyListing/StudyListingItemView/types.ts b/webapp/src/components/StudyListing/StudyListingItemView/types.ts new file mode 100644 index 0000000000..7ef5340074 --- /dev/null +++ b/webapp/src/components/StudyListing/StudyListingItemView/types.ts @@ -0,0 +1,9 @@ +import { StudyMetadata } from '../../../common/types'; + +export interface StudyListingItemPropTypes { + study: StudyMetadata; + launchStudy: (study: StudyMetadata) => void; + importStudy: (study: StudyMetadata, withOutputs?: boolean) => void; + openDeletionModal: () => void; + lastJobStatus?: 'JobStatus.RUNNING' | 'JobStatus.PENDING' | 'JobStatus.SUCCESS' | 'JobStatus.FAILED'; +} diff --git a/webapp/src/components/StudyListing/index.tsx b/webapp/src/components/StudyListing/index.tsx index 8c4a72d3e4..55df9a809e 100644 --- a/webapp/src/components/StudyListing/index.tsx +++ b/webapp/src/components/StudyListing/index.tsx @@ -4,11 +4,10 @@ import { connect, ConnectedProps } from 'react-redux'; import { createStyles, makeStyles, Theme } from '@material-ui/core'; import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; -import StudyBlockSummaryView from './StudyBlockSummaryView'; -import StudyListSummaryView from './StudyListSummaryView'; import { StudyMetadata } from '../../common/types'; import { removeStudies } from '../../ducks/study'; -import { deleteStudy as callDeleteStudy, launchStudy as callLaunchStudy } from '../../services/api/study'; +import { deleteStudy as callDeleteStudy, launchStudy as callLaunchStudy, copyStudy as callCopyStudy } from '../../services/api/study'; +import StudyListElementView from './StudyListingItemView'; const logError = debug('antares:studyblockview:error'); @@ -64,6 +63,16 @@ const StudyListing = (props: PropTypes) => { } }; + const importStudy = async (study: StudyMetadata, withOutputs = false) => { + try { + await callCopyStudy(study.id, `${study.name} (${t('main:copy')})`, withOutputs); + enqueueSnackbar(t('studymanager:studycopiedsuccess', { studyname: study.name }), { variant: 'success' }); + } catch (e) { + enqueueSnackbar(t('studymanager:failtocopystudy'), { variant: 'error' }); + logError('Failed to copy/import study', study, e); + } + }; + const deleteStudy = async (study: StudyMetadata) => { // eslint-disable-next-line no-alert try { @@ -79,21 +88,16 @@ const StudyListing = (props: PropTypes) => {
{ - studies.map((s) => (isList ? ( - - ) : ( - ( + - ))) + )) }
diff --git a/webapp/src/components/ui/ButtonLoader.tsx b/webapp/src/components/ui/ButtonLoader.tsx new file mode 100644 index 0000000000..ceb860f6a3 --- /dev/null +++ b/webapp/src/components/ui/ButtonLoader.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import { blue } from '@material-ui/core/colors'; +import Button, { ButtonProps } from '@material-ui/core/Button'; + +interface OwnProps { + // eslint-disable-next-line react/no-unused-prop-types + progressColor?: string; +} + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + display: 'flex', + alignItems: 'center', + }, + wrapper: { + margin: theme.spacing(1), + position: 'relative', + }, + buttonProgress: { + color: (props) => props.progressColor, + position: 'absolute', + top: '50%', + left: '50%', + marginTop: -12, + marginLeft: -12, + }, + })); + +const ButtonLoader = (props: ButtonProps & OwnProps) => { + const classes = useStyles(props); + const { children, onClick } = props; + const [loading, setLoading] = React.useState(false); + + const handleButtonClick = async (event: React.MouseEvent) => { + if (!loading && !!onClick) { + setLoading(true); + try { + await onClick(event); + } finally { + setLoading(false); + } + } + }; + + return ( +
+ + {loading && } +
+ ); +}; + +ButtonLoader.defaultProps = { + progressColor: blue[400], +}; + +export default ButtonLoader; diff --git a/webapp/src/components/ui/MultiButton.tsx b/webapp/src/components/ui/MultiButton.tsx new file mode 100644 index 0000000000..91cb281c01 --- /dev/null +++ b/webapp/src/components/ui/MultiButton.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import Grid from '@material-ui/core/Grid'; +import Button from '@material-ui/core/Button'; +import ButtonGroup from '@material-ui/core/ButtonGroup'; +import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown'; +import ClickAwayListener from '@material-ui/core/ClickAwayListener'; +import Grow from '@material-ui/core/Grow'; +import Paper from '@material-ui/core/Paper'; +import Popper from '@material-ui/core/Popper'; +import MenuItem from '@material-ui/core/MenuItem'; +import MenuList from '@material-ui/core/MenuList'; +import { useTranslation } from 'react-i18next'; +import { makeStyles } from '@material-ui/core'; + +const useStyles = makeStyles({ + root: {}, + button: {}, +}); + +interface PropTypes { + options: Array<{ + name: string; + callback: () => void; + }>; + size?: 'small' | 'medium' | 'large'; + color?: 'primary' | 'secondary'; +} + +const MultiButton = (props: PropTypes) => { + const [t] = useTranslation(); + const [open, setOpen] = React.useState(false); + const classes = useStyles(); + const anchorRef = React.useRef(null); + const { options, size, color } = props; + + const handleClick = (selectedOption: number) => { + options[selectedOption].callback(); + }; + + const handleMenuItemClick = ( + event: React.MouseEvent, + index: number, + ) => { + setOpen(false); + handleClick(index); + }; + + const handleToggle = () => { + setOpen((prevOpen) => !prevOpen); + }; + + const handleClose = (event: React.MouseEvent) => { + if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement)) { + return; + } + + setOpen(false); + }; + + return ( +
+ + + + + + + + {({ TransitionProps, placement }) => ( + + + + + {options.map((option, index) => ( + handleMenuItemClick(event, index)} + > + {t(option.name)} + + ))} + + + + + )} + + + +
+ ); +}; + +MultiButton.defaultProps = { + size: 'medium', + color: 'primary', +}; + +export default MultiButton; diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index af2fc9fd93..4205961361 100644 --- a/webapp/src/services/api/study.ts +++ b/webapp/src/services/api/study.ts @@ -37,6 +37,11 @@ export const editStudy = async (data: object, sid: string, path = '', depth = 1) return res.data; }; +export const copyStudy = async (sid: string, name: string, withOutputs = false): Promise => { + const res = await client.post(`/v1/studies/${sid}/copy?dest=${name}?with_outputs=${withOutputs}`); + return res.data; +}; + export const deleteStudy = async (sid: string): Promise => { const res = await client.delete(`/v1/studies/${sid}`); return res.data;