From 43068042805ff4649366d92cb24a58dcf74079b0 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 13 Apr 2022 16:07:35 +0200 Subject: [PATCH 01/43] Fix output cluster download Signed-off-by: Paul Bui-Quang --- antarest/study/storage/study_download_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/antarest/study/storage/study_download_utils.py b/antarest/study/storage/study_download_utils.py index d4a647256a..1b707581ac 100644 --- a/antarest/study/storage/study_download_utils.py +++ b/antarest/study/storage/study_download_utils.py @@ -104,7 +104,7 @@ def level_output_filter( cluster_details += [f"details-res-{data.level.value}"] files_matcher = ( - [f"values-{data.level.value}", cluster_details] + [f"values-{data.level.value}"] + cluster_details if data.includeClusters and target[0] != StudyDownloadType.LINK else [f"values-{data.level.value}"] ) From bfcc96a273ea2943a17afc257aedc5637c46c54c Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 13 Apr 2022 16:31:33 +0200 Subject: [PATCH 02/43] Update version Signed-off-by: Paul Bui-Quang --- antarest/__init__.py | 2 +- setup.py | 2 +- sonar-project.properties | 2 +- webapp/package.json | 2 +- webapp_v2/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/antarest/__init__.py b/antarest/__init__.py index bd97fb4930..3307ad91c3 100644 --- a/antarest/__init__.py +++ b/antarest/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.3.2" +__version__ = "2.3.3" from pathlib import Path diff --git a/setup.py b/setup.py index a815e4ebb2..47e723f7d5 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="AntaREST", - version="2.3.2", + version="2.3.3", description="Antares Server", long_description=long_description, long_description_content_type="text/markdown", diff --git a/sonar-project.properties b/sonar-project.properties index 18a5842879..e73c0c8055 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -4,4 +4,4 @@ sonar.sources=antarest sonar.language=python sonar.exclusions=antarest/gui.py,antarest/main.py sonar.python.coverage.reportPaths=coverage.xml -sonar.projectVersion=2.3.2 \ No newline at end of file +sonar.projectVersion=2.3.3 \ No newline at end of file diff --git a/webapp/package.json b/webapp/package.json index caa107670d..d8783f3fc0 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "antares-web", - "version": "2.3.2", + "version": "2.3.3", "private": true, "dependencies": { "@fortawesome/fontawesome-svg-core": "~1.2.36", diff --git a/webapp_v2/package.json b/webapp_v2/package.json index 04101b0c78..743351a151 100644 --- a/webapp_v2/package.json +++ b/webapp_v2/package.json @@ -1,6 +1,6 @@ { "name": "antares-web", - "version": "2.3.2", + "version": "2.3.3", "private": true, "dependencies": { "@emotion/react": "11.9.0", From 1caec868e2cee00bd6cf716c50b79b456ba36e3f Mon Sep 17 00:00:00 2001 From: Charly Bion <37449809+Hyralc@users.noreply.github.com> Date: Thu, 14 Apr 2022 15:21:54 +0200 Subject: [PATCH 03/43] Fix update config command reversion (#823) --- antarest/launcher/web.py | 2 +- antarest/study/business/utils.py | 4 +- .../model/command/update_config.py | 63 ++++++++++++------- 3 files changed, 46 insertions(+), 23 deletions(-) diff --git a/antarest/launcher/web.py b/antarest/launcher/web.py index 9492602690..6832c54efa 100644 --- a/antarest/launcher/web.py +++ b/antarest/launcher/web.py @@ -167,7 +167,7 @@ def get_engines() -> Any: @bp.get( "/launcher/_versions", tags=[APITag.launcher], - summary="Get list of supported study version for all configures launchers", + summary="Get list of supported study version for all configured launchers", response_model=Dict[str, List[str]], ) def get_versions( diff --git a/antarest/study/business/utils.py b/antarest/study/business/utils.py index 18cb60ee23..ef83062ae3 100644 --- a/antarest/study/business/utils.py +++ b/antarest/study/business/utils.py @@ -24,9 +24,11 @@ def execute_or_add_commands( if not result.status: for i in range(0, len(executed_commands)): executed_command = executed_commands[i] - executed_command.revert( + revert_command_list = executed_command.revert( history=executed_commands[i + 1 :], base=file_study ) + for revert_command in revert_command_list: + revert_command.apply(file_study) raise CommandApplicationError(result.message) executed_commands.append(command) remove_from_cache(storage_service.raw_study_service.cache, study.id) diff --git a/antarest/study/storage/variantstudy/model/command/update_config.py b/antarest/study/storage/variantstudy/model/command/update_config.py index 546202e274..3c321e6692 100644 --- a/antarest/study/storage/variantstudy/model/command/update_config.py +++ b/antarest/study/storage/variantstudy/model/command/update_config.py @@ -1,5 +1,6 @@ import logging -from typing import Any, Union, List, Tuple, Dict +from pathlib import Path +from typing import Any, Union, List, Tuple, Dict, Optional, cast from antarest.core.model import JSON from antarest.study.storage.rawstudy.model.filesystem.config.model import ( @@ -75,29 +76,49 @@ def match(self, other: ICommand, equal: bool = False) -> bool: def revert( self, history: List["ICommand"], base: FileStudy ) -> List["ICommand"]: + update_config_list: List[UpdateConfig] = [] + self_target_path = Path(self.target) + parent_path: Path = Path(".") for command in reversed(history): - if ( - isinstance(command, UpdateConfig) - and command.target == self.target - ): - return [command] - from antarest.study.storage.variantstudy.model.command.utils_extractor import ( - CommandExtraction, - ) + if isinstance(command, UpdateConfig): + # adding all the UpdateConfig commands until we find one containing self (or the end) + update_config_list.append(command) + if command.target == self.target: + return [command] + elif Path(command.target) in self_target_path.parents: + # found the last parent command. + parent_path = Path(command.target) + break + + output_list: List[ICommand] = [ + command + for command in update_config_list[::-1] + if parent_path in Path(command.target).parents + or str(parent_path) == command.target + ] - try: - return [ - ( - self.command_context.command_extractor - or CommandExtraction(self.command_context.matrix_service) - ).generate_update_config(base.tree, self.target.split("/")) - ] - except ChildNotFoundError as e: - logging.getLogger(__name__).warning( - f"Failed to extract revert command for update_config {self.target}", - exc_info=e, + if not output_list: + from antarest.study.storage.variantstudy.model.command.utils_extractor import ( + CommandExtraction, ) - return [] + + try: + output_list = [ + ( + self.command_context.command_extractor + or CommandExtraction( + self.command_context.matrix_service + ) + ).generate_update_config(base.tree, self.target.split("/")) + ] + except ChildNotFoundError as e: + logging.getLogger(__name__).warning( + f"Failed to extract revert command for update_config {self.target}", + exc_info=e, + ) + output_list = [] + + return output_list def _create_diff(self, other: "ICommand") -> List["ICommand"]: return [other] From 1863aff70905c61e23c464ed8127608741d159d6 Mon Sep 17 00:00:00 2001 From: 3lbanna <76211863+3lbanna@users.noreply.github.com> Date: Fri, 15 Apr 2022 16:42:47 +0200 Subject: [PATCH 04/43] Add notes in single study view (#825) --- webapp_v2/package.json | 10 +- webapp_v2/public/locales/en/main.json | 1 + webapp_v2/public/locales/en/singlestudy.json | 1 + webapp_v2/public/locales/en/variants.json | 1 + webapp_v2/public/locales/fr/main.json | 1 + webapp_v2/public/locales/fr/singlestudy.json | 1 + webapp_v2/public/locales/fr/variants.json | 2 + .../src/components/common/BasicModal.tsx | 24 +- .../CreateVariantModal.tsx | 12 +- .../InformationView/LauncherHistory.tsx | 52 ++ .../InformationView/Notes/NoteEditorModal.tsx | 175 ++++++ .../HomeView/InformationView/Notes/index.tsx | 240 ++++++++ .../HomeView/InformationView/Notes/utils.ts | 567 ++++++++++++++++++ .../HomeView/InformationView/index.tsx | 88 ++- .../HomeView/StudyTreeView/index.tsx | 21 - .../components/singlestudy/HomeView/index.tsx | 22 +- .../src/components/singlestudy/NavHeader.tsx | 6 +- webapp_v2/src/services/utils/index.ts | 10 + 18 files changed, 1190 insertions(+), 44 deletions(-) rename webapp_v2/src/components/singlestudy/HomeView/{StudyTreeView => InformationView}/CreateVariantModal.tsx (92%) create mode 100644 webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory.tsx create mode 100644 webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/NoteEditorModal.tsx create mode 100644 webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx create mode 100644 webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/utils.ts diff --git a/webapp_v2/package.json b/webapp_v2/package.json index 743351a151..228282b165 100644 --- a/webapp_v2/package.json +++ b/webapp_v2/package.json @@ -12,11 +12,15 @@ "@testing-library/react": "13.0.0", "@testing-library/user-event": "14.0.4", "@types/d3": "5.16.4", + "@types/draft-convert": "^2.1.4", + "@types/draft-js": "^0.11.9", + "@types/draftjs-to-html": "^0.8.1", "@types/jest": "27.4.1", "@types/node": "16.11.20", "@types/react": "17.0.38", "@types/react-d3-graph": "2.6.3", "@types/react-dom": "17.0.11", + "@types/xml-js": "^1.0.0", "assert": "2.0.0", "axios": "0.26.1", "buffer": "6.0.3", @@ -24,6 +28,9 @@ "d3": "5.16.0", "debug": "4.3.4", "downshift": "6.1.7", + "draft-convert": "^2.1.12", + "draft-js": "^0.11.7", + "draftjs-to-html": "^0.9.1", "fs": "0.0.1-security", "https-browserify": "1.0.0", "i18next": "21.6.14", @@ -58,7 +65,8 @@ "swagger-ui-react": "4.10.3", "url": "0.11.0", "uuid": "8.3.2", - "web-vitals": "2.1.4" + "web-vitals": "2.1.4", + "xml-js": "^1.6.11" }, "scripts": { "start": "react-app-rewired start", diff --git a/webapp_v2/public/locales/en/main.json b/webapp_v2/public/locales/en/main.json index 8df70dffac..cc79f83cd6 100644 --- a/webapp_v2/public/locales/en/main.json +++ b/webapp_v2/public/locales/en/main.json @@ -14,6 +14,7 @@ "archive": "Archive", "export": "Export", "create": "Create", + "open": "Open", "name": "Name", "import": "Import", "allStudies": "All studies", diff --git a/webapp_v2/public/locales/en/singlestudy.json b/webapp_v2/public/locales/en/singlestudy.json index 04aaeed782..bf07f05de9 100644 --- a/webapp_v2/public/locales/en/singlestudy.json +++ b/webapp_v2/public/locales/en/singlestudy.json @@ -93,6 +93,7 @@ "filterOut": "Filter Out", "area1": "Area 1", "area2": "Area 2", + "getAreasInfo": "Failed to fetch areas data", "managedStudy": "Managed study", "properties": "Properties", "validate": "Validate", diff --git a/webapp_v2/public/locales/en/variants.json b/webapp_v2/public/locales/en/variants.json index 032375479b..2edfc56b44 100644 --- a/webapp_v2/public/locales/en/variants.json +++ b/webapp_v2/public/locales/en/variants.json @@ -2,6 +2,7 @@ "variants": "Variant management", "variantDependencies": "Variant dependencies", "createVariant": "Create variant", + "createNewVariant": "Create new variant", "editionMode": "Edition mode", "testGeneration": "Test generation", "newVariant": "New variant", diff --git a/webapp_v2/public/locales/fr/main.json b/webapp_v2/public/locales/fr/main.json index c5d5cb3a15..ccc790d35e 100644 --- a/webapp_v2/public/locales/fr/main.json +++ b/webapp_v2/public/locales/fr/main.json @@ -14,6 +14,7 @@ "archive": "Archiver", "export": "Exporter", "create": "Créer", + "open": "Ouvrir", "name": "Nom", "import": "Importer", "allStudies": "Toutes les études", diff --git a/webapp_v2/public/locales/fr/singlestudy.json b/webapp_v2/public/locales/fr/singlestudy.json index bc9e34f16c..e763a874bc 100644 --- a/webapp_v2/public/locales/fr/singlestudy.json +++ b/webapp_v2/public/locales/fr/singlestudy.json @@ -93,6 +93,7 @@ "filterOut": "Filtre d'exclusion (Regex)", "area1": "Zone 1", "area2": "Zone 2", + "getAreasInfo": "Impossible de récupérer les informations sur les zones", "managedStudy": "Étude managée", "properties": "Propriétés", "validate": "Valider", diff --git a/webapp_v2/public/locales/fr/variants.json b/webapp_v2/public/locales/fr/variants.json index b07d157c2e..ebb36741d0 100644 --- a/webapp_v2/public/locales/fr/variants.json +++ b/webapp_v2/public/locales/fr/variants.json @@ -1,6 +1,8 @@ { + "variants": "Gestion des variantes", "variantDependencies": "Vue dépendences", "createVariant": "Créer une variante", + "createNewVariant": "Créer une nouvelle variante", "editionMode": "Mode édition", "testGeneration": "Tester la géneration", "newVariant": "Nouvelle variante", diff --git a/webapp_v2/src/components/common/BasicModal.tsx b/webapp_v2/src/components/common/BasicModal.tsx index 1b87b081ee..41d6a2624f 100644 --- a/webapp_v2/src/components/common/BasicModal.tsx +++ b/webapp_v2/src/components/common/BasicModal.tsx @@ -10,6 +10,16 @@ import { Theme, } from "@mui/material"; +type ColorVariant = + | "inherit" + | "success" + | "primary" + | "secondary" + | "error" + | "info" + | "warning" + | undefined; + interface Props { title: string; open: boolean; @@ -19,6 +29,8 @@ interface Props { onActionButtonClick?: () => void; actionButtonDisabled?: boolean; rootStyle: SxProps | undefined; + actionColor?: ColorVariant; + closeColor?: ColorVariant; } function BasicModal(props: PropsWithChildren) { @@ -31,6 +43,8 @@ function BasicModal(props: PropsWithChildren) { actionButtonLabel, onActionButtonClick, rootStyle, + actionColor, + closeColor, children, } = props; const [t] = useTranslation(); @@ -96,7 +110,11 @@ function BasicModal(props: PropsWithChildren) { p={2} boxSizing="border-box" > - + + + + +
+
+ + {editionMode && ( + setEditionMode(false)} + onSave={onSave} + /> + )} + + ); +} diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/utils.ts b/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/utils.ts new file mode 100644 index 0000000000..85776f653e --- /dev/null +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/utils.ts @@ -0,0 +1,567 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-param-reassign */ +/* eslint-disable no-plusplus */ +import { ContentState, convertToRaw, EditorState } from "draft-js"; +import draftToHtml from "draftjs-to-html"; +import { convertFromHTML } from "draft-convert"; +import { Element as XMLElement, js2xml, xml2json } from "xml-js"; + +interface BlockMap { + from: string; + to: string; +} + +const blockMap: Array = [ + { from: "ins", to: "u" }, + { from: "em", to: "i" }, + { from: "strong", to: "b" }, +]; + +const XmlToHTML = { paragraph: "p", text: "span" }; + +type ListType = "Numbered List" | "Bullet List"; + +interface AttributesUtils { + openBalise: string; + closeBalise: string; + list?: ListType; +} + +interface NodeProcessResult { + result: string; + listSeq?: ListType; +} + +const replaceAll = (string: string, search: string, replace: string): string => + string.split(search).join(replace); + +const toDraftJsHtml = (data: string): string => { + let tmp = data; + blockMap.forEach((elm) => { + tmp = replaceAll(tmp, `<${elm.from}>`, `<${elm.to}>`); + tmp = replaceAll(tmp, ``, ``); + }); + return tmp; +}; + +/* +------------------------------------------------------ +CONVERT CUSTOM ANTARES XML TO DRAFT JS INTERNAL MODEL +------------------------------------------------------ +*/ + +const parseXMLAttributes = (node: XMLElement): AttributesUtils => { + let openBalise = ""; + let closeBalise = ""; + let listType: ListType = "Bullet List"; + let isList = false; + if (node.attributes !== undefined) { + const list = Object.keys(node.attributes); + for (let i = 0; i < list.length; i++) { + switch (list[i]) { + case "fontweight": + if (parseInt(node.attributes[list[i]] as string, 10) > 0) { + // BOLD + openBalise += ""; + closeBalise = `${closeBalise}`; + } + break; + case "fontunderlined": + openBalise += ""; + closeBalise = `${closeBalise}`; + break; + case "fontstyle": + if (parseInt(node.attributes[list[i]] as string, 10) > 0) { + // BOLD + openBalise += ""; + closeBalise = `${closeBalise}`; + } + break; + case "liststyle": + if ( + node.attributes[list[i]] === "Bullet List" || + node.attributes[list[i]] === "Numbered List" + ) { + // BOLD + isList = true; + listType = node.attributes[list[i]] as ListType; + } + break; + default: + break; + } + } + } + return { openBalise, closeBalise, list: isList ? listType : undefined }; +}; + +const parseXMLToHTMLNode = ( + node: XMLElement, + parent: XMLElement, + prevListSeq: ListType | undefined, + isLastSon = false +): NodeProcessResult => { + const res: NodeProcessResult = { result: "" }; + if (node.type !== undefined) { + if (node.type === "element") { + if (node.name !== undefined) { + let attributesUtils: AttributesUtils = { + openBalise: "", + closeBalise: "", + }; + if (node.name === "symbol") { + if ( + node.elements !== undefined && + node.elements.length === 1 && + node.elements[0].type === "text" && + node.elements[0].text !== undefined + ) { + if (node.elements[0].text === "9") { + return { result: " " }; + } + if (node.elements[0].text === "34") { + return { result: '"' }; + } + } + } else if (Object.keys(XmlToHTML).includes(node.name)) { + attributesUtils = parseXMLAttributes(node); + + if (attributesUtils.list !== undefined) { + if (prevListSeq === undefined) { + attributesUtils.openBalise = `${ + attributesUtils.list === "Numbered List" + ? "
  1. " + : "
    • " + }${attributesUtils.openBalise}`; + } else if (prevListSeq !== attributesUtils.list) { + const closePrevBalise = + prevListSeq === "Numbered List" ? "
" : ""; // Close previous list + attributesUtils.openBalise = `${closePrevBalise}${ + attributesUtils.list === "Numbered List" + ? "
  1. " + : "
    • " + }${attributesUtils.openBalise}`; + } else { + attributesUtils.openBalise = `
    • ${attributesUtils.openBalise}`; + } + attributesUtils.closeBalise += "
    • "; + if (isLastSon) + attributesUtils.closeBalise += + attributesUtils.list === "Numbered List" ? "
" : ""; + } else if (prevListSeq !== undefined) { + const closePrevBalise = + prevListSeq === "Numbered List" ? "" : ""; // Close previous list + attributesUtils.openBalise = `${closePrevBalise}<${ + (XmlToHTML as any)[node.name] + }>${attributesUtils.openBalise}`; + attributesUtils.closeBalise += ``; + } else { + attributesUtils.openBalise = `<${(XmlToHTML as any)[node.name]}>${ + attributesUtils.openBalise + }`; + attributesUtils.closeBalise += ``; + } + } + + if (node.elements !== undefined && node.elements.length > 0) { + let completeResult: NodeProcessResult = { result: "" }; + for (let j = 0; j < node.elements.length; j++) { + completeResult = parseXMLToHTMLNode( + node.elements[j], + node, + completeResult.listSeq, + j === node.elements.length - 1 + ); + res.result += completeResult.result; + } + return { + result: + attributesUtils.openBalise + + res.result + + attributesUtils.closeBalise, + listSeq: attributesUtils.list, + }; + } + } + } else if (node.type === "text") { + if (node.text !== undefined) + return { result: replaceAll(node.text as string, '"', "") }; + } + } else if (node.elements !== undefined) { + let completeResult: NodeProcessResult = { result: "" }; + for (let i = 0; i < node.elements.length; i++) { + completeResult = parseXMLToHTMLNode( + node.elements[i], + node, + completeResult.listSeq, + i === node.elements.length - 1 + ); + res.result += completeResult.result; + } + } + + return res; +}; + +const convertXMLToHTML = (data: string): string => { + const xmlStr = xml2json(data, { compact: false, spaces: 4 }); + const xmlElement: XMLElement = JSON.parse(xmlStr); + return parseXMLToHTMLNode(xmlElement, xmlElement, undefined, true).result; +}; + +export const convertXMLToDraftJS = (data: string): ContentState => { + const htmlData = convertXMLToHTML(data); + return convertFromHTML(htmlData); +}; + +/* +------------------------------------------------------ +CONVERT DRAFT JS INTERNAL MODEL TO CUSTOM ANTARES XML +------------------------------------------------------ +*/ + +const HTMLToAttributes = { + b: { fontweight: "92" }, + i: { fontstyle: "93" }, + u: { fontunderlined: "1" }, +}; + +enum ParseHTMLToXMLNodeActions { + DELETE = "DELETE", + COPYCHILD = "COPYCHILD", + TEXTTOELEMENTS = "TEXTTOELEMENTS", + NONE = "NONE", +} + +interface ParseHTMLToXMLActionList { + action: ParseHTMLToXMLNodeActions; + node: XMLElement; +} + +const parseHTMLToXMLNode = ( + node: XMLElement, + parent: XMLElement, + lastListSeq = 0 +): ParseHTMLToXMLNodeActions => { + let action: ParseHTMLToXMLNodeActions = ParseHTMLToXMLNodeActions.NONE; + const parseChild = (nodeElement: XMLElement): ParseHTMLToXMLNodeActions => { + let resultAction: ParseHTMLToXMLNodeActions = + ParseHTMLToXMLNodeActions.NONE; + if (nodeElement.elements !== undefined) { + const actionList: Array = []; + let childAction: ParseHTMLToXMLNodeActions = + ParseHTMLToXMLNodeActions.NONE; + for (let i = 0; i < nodeElement.elements.length; i++) { + childAction = parseHTMLToXMLNode( + nodeElement.elements[i], + nodeElement, + i + ); + if (childAction !== ParseHTMLToXMLNodeActions.NONE) { + actionList.push({ + action: childAction, + node: nodeElement.elements[i], + }); + } + } + actionList.forEach((elm: ParseHTMLToXMLActionList) => { + if (nodeElement.elements !== undefined) { + if (elm.action === ParseHTMLToXMLNodeActions.DELETE) { + nodeElement.elements = nodeElement.elements.filter( + (item) => item !== elm.node + ); + } else if ( + elm.node.elements !== undefined && + elm.node.elements.length > 0 + ) { + let newElements: Array = []; + const index = nodeElement.elements.findIndex( + (item) => item === elm.node + ); + if (index !== undefined && index >= 0) { + newElements = newElements.concat( + nodeElement.elements.slice(0, index) + ); + newElements = newElements.concat(elm.node.elements); + newElements = newElements.concat( + nodeElement.elements.slice(index + 1) + ); + node.elements = newElements; + + if (elm.action === ParseHTMLToXMLNodeActions.TEXTTOELEMENTS) { + resultAction = elm.action; + } + } + } + } + }); + } + return resultAction; + }; + + const checkForQuote = ( + data: string, + elements: Array + ): boolean => { + const quoteList: Array = data.split('"'); + if (quoteList.length > 1) { + for (let j = 0; j < quoteList.length; j++) { + if (quoteList[j].length > 0) { + elements.push({ + type: "element", + name: "text", + elements: [ + { + type: "text", + text: + quoteList[j][0] === " " || + quoteList[j][quoteList[j].length - 1] === " " + ? `"${quoteList[j]}"` + : quoteList[j], + }, + ], + }); + } + if (j !== quoteList.length - 1) { + elements.push({ + type: "element", + name: "symbol", + elements: [ + { + type: "text", + text: 34, + }, + ], + }); + } + } + } else if (data.length > 0) { + elements.push({ + type: "element", + name: "text", + elements: [ + { + type: "text", + text: + data[0] === " " || data[data.length - 1] === " " + ? `"${data}"` + : data, + }, + ], + }); + return true; + } + return false; + }; + + if (node.type !== undefined) { + if (node.type === "element") { + if (node.name !== undefined) { + switch (node.name) { + case "p": + node.name = "paragraph"; + if (node.elements === undefined || node.elements.length === 0) { + node.elements = [ + { + type: "element", + name: "text", + elements: [ + { + type: "text", + text: "", + }, + ], + }, + ]; + } else { + action = parseChild(node); + } + break; + + case "b": + case "i": + case "u": + if ( + parent !== node && + (parent.name === "paragraph" || parent.name === "text") && + parent.elements !== undefined && + parent.elements.length === 1 + ) { + parent.attributes = { + ...parent.attributes, + ...(HTMLToAttributes as any)[node.name], + }; + parent.elements = node.elements; + action = parseChild(parent); + } else { + node.attributes = { + ...node.attributes, + ...(HTMLToAttributes as any)[node.name], + }; + node.name = "text"; + action = parseChild(node); + } + break; + + case "span": + node.name = "text"; + action = parseChild(node); + break; + + case "li": + if ( + parent !== node && + parent.name !== undefined && + (parent.name === "ol" || parent.name === "ul") + ) { + node.attributes = { + ...node.attributes, + alignment: "1", + leftindent: "60", + leftsubindent: "60", + bulletstyle: parent.name === "ol" ? "4353" : "512", + bulletnumber: lastListSeq.toString(), + liststyle: + parent.name === "ol" ? "Numbered List" : "Bullet List", + }; + if (parent.name === "ul") + node.attributes = { + ...node.attributes, + bulletname: "standard/circle", + }; + node.name = "paragraph"; + parseChild(node); + } + break; + + case "ul": + case "ol": + if ( + node.elements !== undefined && + node.elements.length > 0 && + parent !== node && + parent.elements && + parent.elements.length > 0 + ) { + parseChild(node); + action = ParseHTMLToXMLNodeActions.COPYCHILD; + } else { + action = ParseHTMLToXMLNodeActions.DELETE; + } + break; + + default: + parseChild(node); + break; + } + } + } else if (node.type === "text") { + if (node.text !== undefined) { + node.type = "element"; + const { text } = node; + if (text !== undefined && typeof text === "string" && text.length > 0) { + const elements: Array = []; + const tabList = text.split(" "); + if (tabList.length > 1) { + for (let i = 0; i < tabList.length; i++) { + checkForQuote(tabList[i], elements); + if (i !== tabList.length - 1) { + elements.push({ + type: "element", + name: "symbol", + elements: [ + { + type: "text", + text: 9, + }, + ], + }); + } + } + node.text = undefined; + node.name = "paragraph"; + node.elements = elements; + if (parent.type === "paragraph") { + action = ParseHTMLToXMLNodeActions.COPYCHILD; + } else { + action = ParseHTMLToXMLNodeActions.TEXTTOELEMENTS; + } + } else { + const isSelf = checkForQuote(text, elements); + if (isSelf) { + if (parent.name !== undefined && parent.name !== "text") { + node.text = undefined; + node.type = elements[0].type; + node.name = elements[0].name; + node.elements = elements[0].elements; + } else { + node.text = text; + node.type = "text"; + node.elements = undefined; + } + } else { + node.text = undefined; + node.type = "element"; + node.name = "paragraph"; + node.elements = elements; + if (parent.name === "paragraph") { + action = ParseHTMLToXMLNodeActions.COPYCHILD; + } else { + action = ParseHTMLToXMLNodeActions.TEXTTOELEMENTS; + } + } + } + } else if (parent.name !== undefined && parent.name !== "text") { + node.text = undefined; + node.name = "text"; + node.elements = [ + { + type: "text", + text, + }, + ]; + } else { + node.text = text; + node.type = "text"; + node.elements = undefined; + } + } + } + } else if (node.elements !== undefined) { + parseChild(node); + } + + return action; +}; + +const convertHTMLToXML = (data: string): string => { + const htmlStr: string = xml2json(data, { compact: false, spaces: 4 }); + const xmlElement: XMLElement = JSON.parse(htmlStr); + parseHTMLToXMLNode(xmlElement, xmlElement); + const res = js2xml(xmlElement, { compact: false, spaces: 4 }); + return res; +}; + +export const addXMLHeader = (xmlData: string): string => { + let res = ''; + res += ''; + res += + ''; + res += xmlData; + res += ""; + res += ""; + return res; +}; + +export const convertDraftJSToXML = (editorState: EditorState): string => { + const rawContentState = convertToRaw(editorState.getCurrentContent()); + const htmlElement = toDraftJsHtml(draftToHtml(rawContentState)); + let htmlToXml = addXMLHeader(htmlElement); + htmlToXml = convertHTMLToXML(htmlToXml); + return htmlToXml; +}; +export default {}; diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/index.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/index.tsx index a7271eb54b..dcebf6adac 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/InformationView/index.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/index.tsx @@ -1,34 +1,108 @@ -import { Paper, Button } from "@mui/material"; +import { useState } from "react"; +import { Paper, Button, Box, Divider } from "@mui/material"; import { useNavigate } from "react-router-dom"; -import { StudyMetadata } from "../../../../common/types"; +import { useTranslation } from "react-i18next"; +import { StudyMetadata, VariantTree } from "../../../../common/types"; +import CreateVariantModal from "./CreateVariantModal"; +import LauncherHistory from "./LauncherHistory"; +import Notes from "./Notes"; +import LauncherModal from "../../../studies/LauncherModal"; interface Props { // eslint-disable-next-line react/no-unused-prop-types study: StudyMetadata | undefined; + tree: VariantTree | undefined; } function InformationView(props: Props) { - const { study } = props; + const { study, tree } = props; const navigate = useNavigate(); + const [t] = useTranslation(); + const [openVariantModal, setOpenVariantModal] = useState(false); + const [openLauncherModal, setOpenLauncherModal] = useState(false); return ( - {study && ( + + + + + + + + + + + + {study && tree && openVariantModal && ( + setOpenVariantModal(false)} + tree={tree} + parentId={study.id} + /> + )} + {openLauncherModal && ( + setOpenLauncherModal(false)} + /> )} ); diff --git a/webapp_v2/src/components/singlestudy/HomeView/StudyTreeView/index.tsx b/webapp_v2/src/components/singlestudy/HomeView/StudyTreeView/index.tsx index 3a1a23d098..765e65076e 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/StudyTreeView/index.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/StudyTreeView/index.tsx @@ -2,8 +2,6 @@ import { useEffect, useMemo, useState } from "react"; import * as React from "react"; import { Box, styled } from "@mui/material"; -import Fab from "@mui/material/Fab"; -import AddIcon from "@mui/icons-material/Add"; import { StudyMetadata, VariantTree } from "../../../../common/types"; import { StudyTree, getTreeNodes } from "./utils"; import { scrollbarStyle } from "../../../../theme"; @@ -28,7 +26,6 @@ import { ZOOM_OUT, CURVE_OFFSET, } from "./treeconfig"; -import CreateVariantModal from "./CreateVariantModal"; export const SVGCircle = styled("circle")({ cursor: "pointer", @@ -51,7 +48,6 @@ export default function CustomizedTreeView(props: Props) { const { study, tree, onClick } = props; const [studyTree, setStudyTree] = useState(); const [hoverId, setHoverId] = useState(""); - const [openVariantModal, setOpenVariantModal] = useState(false); const rectWidth = useMemo(() => { if (studyTree === undefined) return 0; @@ -239,23 +235,6 @@ export default function CustomizedTreeView(props: Props) { > {studyTree && renderTree(studyTree)} - setOpenVariantModal(true)} - > - - - {study && studyTree && openVariantModal && ( - setOpenVariantModal(false)} - studyTree={studyTree} - parentId={study.id} - /> - )} ); } diff --git a/webapp_v2/src/components/singlestudy/HomeView/index.tsx b/webapp_v2/src/components/singlestudy/HomeView/index.tsx index d7f11a0084..7154fe01d7 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/index.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/index.tsx @@ -6,6 +6,7 @@ import { StudyMetadata, VariantTree } from "../../../common/types"; import "./Split.css"; import StudyTreeView from "./StudyTreeView"; import InformationView from "./InformationView"; +import { scrollbarStyle } from "../../../theme"; interface Props { study: StudyMetadata | undefined; @@ -47,13 +48,26 @@ function HomeView(props: Props) { - + + + ); diff --git a/webapp_v2/src/components/singlestudy/NavHeader.tsx b/webapp_v2/src/components/singlestudy/NavHeader.tsx index 809a469205..1447256ee3 100644 --- a/webapp_v2/src/components/singlestudy/NavHeader.tsx +++ b/webapp_v2/src/components/singlestudy/NavHeader.tsx @@ -93,7 +93,7 @@ function NavHeader(props: PropTypes) { const navigate = useNavigate(); const [anchorEl, setAnchorEl] = useState(null); const [openMenu, setOpenMenu] = useState(""); - const [openLaunncherModal, setOpenLauncherModal] = useState(false); + const [openLauncherModal, setOpenLauncherModal] = useState(false); const [openPropertiesModal, setOpenPropertiesModal] = useState(false); const [openDeleteModal, setOpenDeleteModal] = useState(false); @@ -465,9 +465,9 @@ function NavHeader(props: PropTypes) { )} - {openLaunncherModal && ( + {openLauncherModal && ( setOpenLauncherModal(false)} /> diff --git a/webapp_v2/src/services/utils/index.ts b/webapp_v2/src/services/utils/index.ts index 88e58f2fbe..c3be43a8d1 100644 --- a/webapp_v2/src/services/utils/index.ts +++ b/webapp_v2/src/services/utils/index.ts @@ -221,6 +221,16 @@ export const findNodeInTree = ( return undefined; }; +export const createListFromTree = (tree: VariantTree): Array => { + const { node, children } = tree; + const { id, name } = node; + let res: Array = [{ id, name }]; + children.forEach((elm) => { + res = res.concat(createListFromTree(elm)); + }); + return res; +}; + export const rgbToHsl = (rgbStr: string): Array => { const [r, g, b] = rgbStr.slice(4, -1).split(",").map(Number); const red = r / 255; From 0ca24e5eaad6e50cc04484827d61851635c99d81 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Tue, 19 Apr 2022 09:46:19 +0200 Subject: [PATCH 05/43] Improve study tree and folder navigation performances (#827) --- .../src/components/studies/StudyTree.tsx | 31 ++-- webapp_v2/src/pages/Studies/index.tsx | 142 ++++++++++-------- 2 files changed, 95 insertions(+), 78 deletions(-) diff --git a/webapp_v2/src/components/studies/StudyTree.tsx b/webapp_v2/src/components/studies/StudyTree.tsx index ce8efd465b..742b697b5d 100644 --- a/webapp_v2/src/components/studies/StudyTree.tsx +++ b/webapp_v2/src/components/studies/StudyTree.tsx @@ -3,7 +3,6 @@ import { useState, MouseEvent as ReactMouseEvent, Fragment, - SyntheticEvent, } from "react"; import { Menu, MenuItem, Typography } from "@mui/material"; import TreeView from "@mui/lab/TreeView"; @@ -27,7 +26,6 @@ function StudyTree(props: Props) { const { tree, folder, setFolder } = props; const { enqueueSnackbar } = useSnackbar(); const [t] = useTranslation(); - const [expanded, setExpanded] = useState([tree.name]); const [menuId, setMenuId] = useState(""); const [contextMenu, setContextMenu] = useState<{ mouseX: number; @@ -54,12 +52,6 @@ function StudyTree(props: Props) { setMenuId(id); }; - const handleToggle = (event: SyntheticEvent, nodeIds: Array) => { - if ((event.target as HTMLElement).classList.contains("MuiSvgIcon-root")) { - setExpanded(nodeIds); - } - }; - const handleClose = () => { setContextMenu(null); }; @@ -93,22 +85,29 @@ function StudyTree(props: Props) { const buildTree = (children: Array, parentId: string) => children.map((elm) => { const newId = `${parentId}/${elm.name}`; - const elements = buildTree((elm as StudyTreeNode).children, newId).filter( - (item) => item - ); return ( onContextMenu(e, elm.path)}> + { + e.preventDefault(); + e.stopPropagation(); + setFolder(newId); + }} + onContextMenu={(e) => onContextMenu(e, elm.path)} + > {elm.name} } - collapseIcon={elements.length > 0 ? : undefined} - expandIcon={elements.length > 0 ? : undefined} - onClick={() => setFolder(newId)} + collapseIcon={ + elm.children.length > 0 ? : undefined + } + expandIcon={ + elm.children.length > 0 ? : undefined + } > {buildTree((elm as StudyTreeNode).children, newId)} @@ -144,8 +143,6 @@ function StudyTree(props: Props) { defaultSelected={getDefaultSelected()} defaultExpanded={getDefaultExpanded()} selected={[folder]} - expanded={expanded} - onNodeToggle={handleToggle} sx={{ flexGrow: 1, height: 0, width: "100%", py: 1 }} > => { - const tmpStudies: Array = ( - [] as Array - ).concat(studies); - if (currentSortItem) { - tmpStudies.sort((studyA: StudyMetadata, studyB: StudyMetadata) => { - const firstElm = - currentSortItem.status === SortStatus.INCREASE ? studyA : studyB; - const secondElm = - currentSortItem.status === SortStatus.INCREASE ? studyB : studyA; - if (currentSortItem.element === SortElement.NAME) { - return firstElm.name.localeCompare(secondElm.name); - } - return moment(firstElm.modificationDate).isAfter( - moment(secondElm.modificationDate) - ) - ? 1 - : -1; - }); - } - return tmpStudies; - }; + const sortStudies = useCallback( + (studyList: Array): Array => { + const tmpStudies: Array = ( + [] as Array + ).concat(studyList); + if (currentSortItem) { + tmpStudies.sort((studyA: StudyMetadata, studyB: StudyMetadata) => { + const firstElm = + currentSortItem.status === SortStatus.INCREASE ? studyA : studyB; + const secondElm = + currentSortItem.status === SortStatus.INCREASE ? studyB : studyA; + if (currentSortItem.element === SortElement.NAME) { + return firstElm.name.localeCompare(secondElm.name); + } + return moment(firstElm.modificationDate).isAfter( + moment(secondElm.modificationDate) + ) + ? 1 + : -1; + }); + } + return tmpStudies; + }, + [currentSortItem] + ); const insideFolder = (study: StudyMetadata): boolean => { let studyNodeId = ""; @@ -162,44 +165,61 @@ function Studies(props: PropTypes) { return studyNodeId.startsWith(currentFolder as string); }; - const filter = (currentName: string): StudyMetadata[] => - sortStudies() - .filter( - (s) => - !currentName || - s.name.search(new RegExp(currentName, "i")) !== -1 || - s.id.search(new RegExp(currentName, "i")) !== -1 - ) - .filter((s) => - currentTag - ? s.tags && - s.tags.findIndex((elm) => - (currentTag as Array).includes(elm) - ) >= 0 - : true - ) - .filter( - (s) => - !currentVersion || - currentVersion.map((elm) => elm.id).includes(s.version) - ) - .filter((s) => - currentUser - ? s.owner.id && - (currentUser as Array) - .map((elm) => elm.id) - .includes(s.owner.id) - : true - ) - .filter((s) => - currentGroup - ? s.groups.findIndex((elm) => - (currentGroup as Array).includes(elm) - ) >= 0 - : true - ) - .filter((s) => (managedFilter ? s.managed : true)) - .filter((s) => insideFolder(s)); + const filterFromFolder = useCallback( + (studyList: StudyMetadata[]) => { + return studyList.filter((s) => insideFolder(s)); + }, + [currentFolder] + ); + + const filter = useCallback( + (currentName: string): StudyMetadata[] => + sortStudies(filterFromFolder(studies)) + .filter( + (s) => + !currentName || + s.name.search(new RegExp(currentName, "i")) !== -1 || + s.id.search(new RegExp(currentName, "i")) !== -1 + ) + .filter((s) => + currentTag + ? s.tags && + s.tags.findIndex((elm) => + (currentTag as Array).includes(elm) + ) >= 0 + : true + ) + .filter( + (s) => + !currentVersion || + currentVersion.map((elm) => elm.id).includes(s.version) + ) + .filter((s) => + currentUser + ? s.owner.id && + (currentUser as Array) + .map((elm) => elm.id) + .includes(s.owner.id) + : true + ) + .filter((s) => + currentGroup + ? s.groups.findIndex((elm) => + (currentGroup as Array).includes(elm) + ) >= 0 + : true + ) + .filter((s) => (managedFilter ? s.managed : true)), + [ + currentVersion, + currentUser, + currentGroup, + currentTag, + managedFilter, + filterFromFolder, + sortStudies, + ] + ); const applyFilter = (): void => { setLoaded(false); From 413a5e1986428bf3472e2e089dfbd5121ad8fbb0 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Tue, 19 Apr 2022 12:25:43 +0200 Subject: [PATCH 06/43] Readd symbolic link + small fix (#829) --- webapp_v2/public/locales/fr-FR | 1 + webapp_v2/public/locales/fr-FR/data.json | 38 ------------ webapp_v2/public/locales/fr-FR/downloads.json | 5 -- webapp_v2/public/locales/fr-FR/jobs.json | 6 -- webapp_v2/public/locales/fr-FR/main.json | 35 ----------- webapp_v2/public/locales/fr-FR/settings.json | 61 ------------------- .../public/locales/fr-FR/singlestudy.json | 57 ----------------- .../public/locales/fr-FR/studymanager.json | 34 ----------- webapp_v2/public/locales/fr-FR/variants.json | 38 ------------ webapp_v2/src/services/api/client.ts | 12 ++-- 10 files changed, 8 insertions(+), 279 deletions(-) create mode 120000 webapp_v2/public/locales/fr-FR delete mode 100644 webapp_v2/public/locales/fr-FR/data.json delete mode 100644 webapp_v2/public/locales/fr-FR/downloads.json delete mode 100644 webapp_v2/public/locales/fr-FR/jobs.json delete mode 100644 webapp_v2/public/locales/fr-FR/main.json delete mode 100644 webapp_v2/public/locales/fr-FR/settings.json delete mode 100644 webapp_v2/public/locales/fr-FR/singlestudy.json delete mode 100644 webapp_v2/public/locales/fr-FR/studymanager.json delete mode 100644 webapp_v2/public/locales/fr-FR/variants.json diff --git a/webapp_v2/public/locales/fr-FR b/webapp_v2/public/locales/fr-FR new file mode 120000 index 0000000000..717280ac26 --- /dev/null +++ b/webapp_v2/public/locales/fr-FR @@ -0,0 +1 @@ +fr \ No newline at end of file diff --git a/webapp_v2/public/locales/fr-FR/data.json b/webapp_v2/public/locales/fr-FR/data.json deleted file mode 100644 index 3345550dd8..0000000000 --- a/webapp_v2/public/locales/fr-FR/data.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "data": "Données", - "matrixSearchbarPlaceholder": "Chercher des matrices...", - "createMatrix": "Ajouter des données", - "upload": "Charger", - "metadata": "Metadata", - "key": "Clé", - "value": "Valeur", - "emptyString": "Chaine de caractères vide", - "emptyName": "Le nom ne peut être vide", - "groupsLabel": "Groupes", - "publicLabel": "Publique", - "matrixListError": "Impossible de récupérer la liste des matrices", - "matrixError": "Impossible de récupérer les données de la matrice", - "choosefile": "Choisir un fichier", - "matrix": "Matrice", - "newMatrixTitle": "Créer un nouveau jeu de données", - "cancelButton": "Annuler", - "saveButton": "Sauvegarder", - "fileNotUploaded": "Veuillez sélectionner un fichier", - "permissionsLabel": "Permissions", - "matrixNameLabel": "Nom du groupe de matrices", - "onMatrixUpdate": "Matrice chargée avec succès", - "onMatrixCreation": "Matrice créée", - "onMatrixSaveError": "Matrice non sauvegardée", - "onMatrixDeleteError": "Matrice non supprimée", - "onMatrixDeleteSuccess": "Matrice supprimée", - "deleteMatrixConfirmation": "Etes vous sûr de vouloir supprimer ce jeu de donnée ?", - "uploadHelp": "Le fichier doit être une matrice simple ou un zip contenant à plat des fichiers de matrices", - "uploadingmatrix": "Chargement des matrices", - "analyzingmatrix": "Analyse des matrices", - "copyid": "Copie l'identifiant de la matrice", - "onMatrixIdCopySuccess": "Identifiant de matrice copié !", - "jsonFormat": "Format JSON", - "graphSelector": "Colonnes", - "monotonicView": "Monotone", - "matrixEmpty": "Matrice vide" -} diff --git a/webapp_v2/public/locales/fr-FR/downloads.json b/webapp_v2/public/locales/fr-FR/downloads.json deleted file mode 100644 index 60d9d08520..0000000000 --- a/webapp_v2/public/locales/fr-FR/downloads.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "expirationDate": "Date d'expiration", - "newDownload": "Nouveau téléchargement créé", - "downloadReady": "Téléchargement prêt" -} diff --git a/webapp_v2/public/locales/fr-FR/jobs.json b/webapp_v2/public/locales/fr-FR/jobs.json deleted file mode 100644 index 84ce7e1195..0000000000 --- a/webapp_v2/public/locales/fr-FR/jobs.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "failedtoretrievejobs": "Échec de la récupération des tâches", - "failedtoretrievelogs": "Échec de la récupération des logs", - "failedtoretrievedownloads": "Échec de la récupération des exports", - "logdetails": "Pas de logs" -} diff --git a/webapp_v2/public/locales/fr-FR/main.json b/webapp_v2/public/locales/fr-FR/main.json deleted file mode 100644 index cf0a0d74c1..0000000000 --- a/webapp_v2/public/locales/fr-FR/main.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "connexion": "Connexion", - "settings": "Paramètres", - "version": "Version", - "logout": "Déconnexion", - "password": "Mot de passe", - "delete": "Supprimer", - "archive": "Archiver", - "export": "Exporter", - "create": "Créer", - "name": "Nom", - "import": "Importer", - "allStudies": "Toutes les études", - "loginError": "Echec de l'authentification", - "launch": "Lancer", - "studies": "Etudes", - "jobs": "Tâches", - "exports": "Exports", - "unknown": "Inconnu", - "confirmationModalTitle": "Confirmation", - "yesButton": "Oui", - "noButton": "Non", - "backButton": "Retour", - "closeButton": "Fermer", - "save": "Sauvegarder", - "edit": "Editer", - "copy": "Copie", - "data": "Données", - "websocketstatusmessage": "Connexion websocket non disponible. Vérifier la connexion internet ou tentez de rafraichir la page.", - "noContent": "Pas de contenu", - "appUnderMaintenance": "Site en maintenance", - "comeBackLater": "Merci de revenir plus tard", - "onGetMessageInfoError": "Impossible de récupérer le message d'info", - "onGetMaintenanceError": "Impossible de récupérer le status de maintenance de l'application" -} diff --git a/webapp_v2/public/locales/fr-FR/settings.json b/webapp_v2/public/locales/fr-FR/settings.json deleted file mode 100644 index 864b86a638..0000000000 --- a/webapp_v2/public/locales/fr-FR/settings.json +++ /dev/null @@ -1,61 +0,0 @@ -{ - "users": "Utilisateurs", - "tokens": "Tokens", - "groups": "Groupes", - "maintenance": "Maintenance", - "usersSearchbarPlaceholder": "Recherchez des utilisateurs...", - "tokensSearchbarPlaceholder": "Recherchez des tokens...", - "groupsSearchbarPlaceholder": "Recherchez des groupes...", - "createUser": "Nouvel utilisateur", - "createToken": "Nouveau token", - "createGroup": "Nouveau groupe", - "groupsError": "Impossible de récupérer la liste des groupes", - "usersError": "Impossible de récupérer la liste des utilisateurs", - "tokensError": "Impossible de récupérer la liste des tokens", - "group": "Groupe", - "groupNameLabel": "Nom du groupe", - "newGroupTitle": "Nouveau groupe", - "usernameLabel": "nom", - "passwordLabel": "Mot de passe", - "newUserTitle": "Nouvel utilisateur", - "cancelButton": "Fermer", - "saveButton": "Sauvegarder", - "roleLabel": "Role", - "addButton": "Ajouter", - "permissionsLabel": "Permissions", - "readerRole": "LECTURE", - "writerRole": "ECRITURE", - "runnerRole": "EXECUTION", - "adminRole": "ADMIN", - "onGroupUpdate": "Groupe modifié avec succès", - "onGroupCreation": "Groupe créé avec succès", - "onGroupSaveError": "Groupe non sauvegardé", - "onGroupDeleteError": "Groupe non supprimé", - "onUserDeleteError": "Utilisateur non supprimé", - "onTokenDeleteError": "Token non supprimé", - "onGroupDeleteSuccess": "Groupe supprimé avec succès", - "onUserDeleteSuccess": "Utilisateur supprimé avec succès", - "onTokenDeleteSuccess": "Token supprimé avec succès", - "groupInfosError": "Erreur lors de la récupération des groupes", - "onUserCreation": "Utilisateur créé avec succès", - "onUserUpdate": "Utilisateur modifié avec succès", - "onUserSaveError": "Utilisateur non sauvegardé", - "newTokenTitle": "Nouveau token", - "tokenNameLabel": "Nom du token", - "onTokenSaveError": "Token non sauvegardé", - "onTokenCreation": "Token créé avec succès", - "linkTokenLabel": "Lier à l'utilisateur", - "deleteGroupConfirmation": "Êtes-vous sûr de vouloir supprimer ce groupe ?", - "deleteUserConfirmation": "Êtes-vous sûr de vouloir supprimer cet utilisateur ?", - "deleteTokenConfirmation": "Êtes-vous sûr de vouloir supprimer ce token ?", - "printTokenMessage": "Veuillez noter et enregistrer le token qui s'affiche ci-dessous en lieu sûr", - "removeUserFromGroup": "Êtes vous sûr de vouloir supprimer cet utilisateur du groupe ?", - "messageMode": "Message", - "maintenanceMode": "Mode maintenance", - "onUpdateMessageInfo": "Message d'information modifié avec succès", - "onUpdateMaintenance": "Status de maintenance modifié avec succès", - "onUpdateMaintenanceError": "Erreur lors du changement du status de maintenance", - "onUpdateMessageInfoError": "Erreur lors du changement du message d'information", - "updateMaintenanceModeConfirmation": "Êtes vous sûr de vouloir changer le status de maintenance de l'application ?", - "updateMessageModeConfirmation": "Êtes vous sûr de vouloir changer le message d'information ?" -} diff --git a/webapp_v2/public/locales/fr-FR/singlestudy.json b/webapp_v2/public/locales/fr-FR/singlestudy.json deleted file mode 100644 index 38e8d5ada5..0000000000 --- a/webapp_v2/public/locales/fr-FR/singlestudy.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "informations": "Informations", - "treeView": "Vue détaillée", - "variants": "Gestion des variantes", - "runStudy": "Lancer l'étude", - "xpansionMode": "Mode Xpansion", - "postProcessing": "Post processing", - "timeLimit": "Limite de temps", - "timeLimitHelper": "Limite de temps (en heures)", - "nbCpu": "Nombre de coeurs", - "currentTask": "Tâches", - "failtoloadjobs": "Echec du chargement des jobs", - "taskId": "Id", - "taskStatus": "Status", - "taskCreationDate": "Date de création", - "taskCompletionDate": "Date de complétion", - "taskMessage": "Message", - "taskOutputId": "Id de la simulation", - "taskLog": "Logs", - "noTasks": "Pas de tâches en cours", - "failtofetchlogs": "Echec du chargement des logs", - "failtokilltask": "Échec de l'annulation de l'étude", - "owner": "Propriétaire", - "groupsLabel": "Groupes", - "publicMode": "Mode public", - "onPermissionUpdate": "Permissions modifié avec succès", - "onPermissionError": "Permissions non modifié", - "version": "Version", - "creationDate": "Date de création", - "modificationDate": "Date de modification", - "permission": "Permissions", - "nonePublicMode": "Aucune", - "readPublicMode": "Lecture", - "executePublicMode": "Execution", - "editPublicMode": "Edition", - "fullPublicMode": "Total", - "nonePublicModeText": "Aucune permission publique", - "readPublicModeText": "Permission publique en lecture", - "executePublicModeText": "Permission publique de lancement", - "editPublicModeText": "Permission publique en écriture", - "fullPublicModeText": "Permission publique totale", - "generalInfo": "Informations générales", - "renamestudy": "Renommer l'étude", - "killStudy": "Arrêter", - "exportOutput": "Exporter les sorties", - "confirmKill": "Êtes-vous sûr de vouloir arrêter la tâche de simulation ?", - "failedToExportOutput": "Echec lors de l'export de la sortie", - "failedToListOutputs": "Echec de la récupération des sorties", - "notes": "Notes", - "fetchCommentsError": "Echec lors de la récupération des commentaires", - "commentsSaved": "Commentaires enregistrés avec succès", - "commentsNotSaved": "Erreur lors de l'enregistrement des commentaires", - "onStudyIdCopySuccess": "Identifiant de l'étude copié", - "onStudyIdCopyError": "Erreur lors de la copie de l'identifiant de l'étude", - "copyId": "Copie l'identifiant de l'étude", - "copyIdDir": "Copier l'ID" -} diff --git a/webapp_v2/public/locales/fr-FR/studymanager.json b/webapp_v2/public/locales/fr-FR/studymanager.json deleted file mode 100644 index 627d1865d5..0000000000 --- a/webapp_v2/public/locales/fr-FR/studymanager.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "confirmdelete": "Etes vous sûr de vouloir supprimer cette étude ?", - "nameofstudy": "Nom de l'étude", - "failtoretrievedata": "Echec de la récupération des données", - "searchstudy": "Rechercher des études", - "failtoretrievestudies": "Echec de la récupération des études", - "failtodeletestudy": "Echec de la suppression de l'étude", - "failtoloadstudy": "Echec du chargement de l'étude", - "failtorunstudy": "Echec du lancement de l'étude", - "studylaunched": "{{studyname}} lancée !", - "savedatasuccess": "Données sauvegardées avec succès", - "importcopy": "Copier", - "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", - "archive": "Archiver", - "unarchive": "Désarchiver", - "archivesuccess": "Etude {{studyname}} archivée", - "archivefailure": "Erreur lors de l'archivage de l'étude {{studyname}}", - "unarchivesuccess": "Etude {{studyname}} désarchivée", - "unarchivefailure": "Erreur lors du désarchivage de l'étude {{studyname}}", - "permission": "Permission", - "refresh": "Charger les études", - "sortByName": "Trier par nom", - "sortByDate": "Trier par date", - "managedStudiesFilter": "Etudes managées", - "userFilter": "Filtrer par utilisateur", - "groupFilter": "Filtrer par groupe", - "versionFilter": "Filtrer par version", - "copyWith": "Copier avec les sorties", - "copyWithout": "Copier sans les sorties", - "exportWith": "Exporter avec les sorties", - "exportWithout": "Exporter sans les sorties" -} diff --git a/webapp_v2/public/locales/fr-FR/variants.json b/webapp_v2/public/locales/fr-FR/variants.json deleted file mode 100644 index b07d157c2e..0000000000 --- a/webapp_v2/public/locales/fr-FR/variants.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "variantDependencies": "Vue dépendences", - "createVariant": "Créer une variante", - "editionMode": "Mode édition", - "testGeneration": "Tester la géneration", - "newVariant": "Nouvelle variante", - "variantNameLabel": "Nom", - "variantMode": "Vue dépendances", - "onVariantCreationError": "Erreur lors de la création du variant", - "add": "Ajouter", - "save": "Sauvegarder", - "confirmsave": "Êtes-vous sûr de vouloir sauvegarder ces commandes ?", - "newCommand": "Ajouter une nouvelle commande", - "commandNameLabel": "Nom (optionel)", - "commandActionLabel": "Sélectionnez le type", - "saveSuccess": "Commande modifié avec succès", - "deleteSuccess": "Commande supprimée avec succès", - "addSuccess": "Command ajoutée avec succès", - "moveSuccess": "Votre commande a changé de position", - "saveError": "Erreur lors de la modification de votre commande", - "deleteError": "Erreur lors de la suppression de votre commande", - "addError": "Erreur lors de l'ajout de votre commande", - "moveError": "Erreur lors du changement de position", - "fetchCommandError": "Erreur lors de la récupération des commandes", - "fetchSynthesisError": "Erreur lors de la récupération de la synthèse de l'étude", - "importSuccess": "Fichier importé avec succès", - "importError": "Erreur lors de l'importation du fichier", - "jsonParsingError": "Erreur lors de la lecture du fichier de commande", - "exportError": "Erreur lors de l'exportation du fichier", - "launchGenerationSuccess": "Génération de variante lancée avec succès", - "launchGenerationError": "Erreur lors du lancement de la génération", - "generationInProgress": "Generation de l'étude en cours...", - "taskCompleted": "Tâche terminée avec succès", - "taskFailed": "Erreur: l'exécution de la tâche a échoué", - "nameEmptyError": "Entrée invalide, le nom est vide", - "noCommands": "Liste de commandes vide", - "newCommandButton": "Nouvelle commande" -} diff --git a/webapp_v2/src/services/api/client.ts b/webapp_v2/src/services/api/client.ts index 63a44bb823..73d9e65468 100644 --- a/webapp_v2/src/services/api/client.ts +++ b/webapp_v2/src/services/api/client.ts @@ -17,11 +17,13 @@ export const setLogoutInterceptor = ( async (c): Promise => c, async (e) => { logError("api error", e.response); - const { status } = e.response; - if (e && status === 401) { - client.defaults.headers.common.Authorization = ""; - clearStudies(); - logoutCallback(); + if (e.response) { + const { status } = e.response; + if (e && status === 401) { + client.defaults.headers.common.Authorization = ""; + clearStudies(); + logoutCallback(); + } } return Promise.reject(e); } From 149a6823a02fec27deec194116b326787de966d1 Mon Sep 17 00:00:00 2001 From: Wintxer <47366828+Wintxer@users.noreply.github.com> Date: Thu, 21 Apr 2022 12:09:09 +0200 Subject: [PATCH 07/43] Task page integration in v2 (#820) --- webapp/public/locales/fr/data.json | 6 +- webapp/public/locales/fr/main.json | 19 +- webapp/public/locales/fr/singlestudy.json | 23 +- webapp/public/locales/fr/studymanager.json | 49 +- webapp_v2/public/locales/en/jobs.json | 15 +- webapp_v2/public/locales/en/main.json | 3 +- webapp_v2/public/locales/en/singlestudy.json | 1 + webapp_v2/public/locales/fr/data.json | 2 +- webapp_v2/public/locales/fr/jobs.json | 15 +- webapp_v2/public/locales/fr/main.json | 7 +- webapp_v2/public/locales/fr/singlestudy.json | 11 +- webapp_v2/public/locales/fr/studymanager.json | 16 +- webapp_v2/src/common/types.ts | 24 +- .../src/components/common/DownloadLink.tsx | 49 ++ webapp_v2/src/components/common/LogModal.tsx | 240 ++++++++ .../src/components/common/PropertiesView.tsx | 6 +- .../src/components/studies/StudyTree.tsx | 1 - .../src/components/tasks/JobTableView.tsx | 202 +++++++ .../src/components/tasks/LaunchJobLogView.tsx | 129 ++++ .../components/tasks/NotificationBadge.tsx | 126 ++++ webapp_v2/src/pages/Tasks.tsx | 556 +++++++++++++++++- .../src/pages/wrappers/MenuWrapper/index.tsx | 88 ++- webapp_v2/src/services/api/study.ts | 15 +- webapp_v2/src/services/api/tasks.ts | 42 ++ webapp_v2/src/store/global.ts | 34 +- webapp_v2/src/theme.ts | 14 +- 26 files changed, 1604 insertions(+), 89 deletions(-) create mode 100644 webapp_v2/src/components/common/DownloadLink.tsx create mode 100644 webapp_v2/src/components/common/LogModal.tsx create mode 100644 webapp_v2/src/components/tasks/JobTableView.tsx create mode 100644 webapp_v2/src/components/tasks/LaunchJobLogView.tsx create mode 100644 webapp_v2/src/components/tasks/NotificationBadge.tsx create mode 100644 webapp_v2/src/services/api/tasks.ts diff --git a/webapp/public/locales/fr/data.json b/webapp/public/locales/fr/data.json index 8b75b3137d..6b9f54abcb 100644 --- a/webapp/public/locales/fr/data.json +++ b/webapp/public/locales/fr/data.json @@ -23,9 +23,9 @@ "onMatrixUpdate": "Matrice chargée avec succès", "onMatrixCreation": "Matrice créée", "onMatrixSaveError": "Matrice non sauvegardée", - "onMatrixDeleteError": "Matrice non supprimée", - "onMatrixDeleteSuccess": "Matrice supprimée", - "deleteMatrixConfirmation": "Etes vous sûr de vouloir supprimer ce jeu de donnée ?" , + "onMatrixDeleteError": "Matrice non supprimée", + "onMatrixDeleteSuccess": "Matrice supprimée", + "deleteMatrixConfirmation": "Êtes vous sûr de vouloir supprimer ce jeu de donnée ?", "uploadHelp": "Le fichier doit être une matrice simple ou un zip contenant à plat des fichiers de matrices", "uploadingmatrix": "Chargement des matrices", "analyzingmatrix": "Analyse des matrices", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index bd349bb0d4..ef1f315524 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -1,7 +1,13 @@ { "connexion": "Connexion", "general": "Général", + "tasks": "Tâches", + "api": "API", + "documentation": "Documentation", + "github": "Github", + "hide": "Masquer", "settings": "Paramètres", + "recentStudy": "Etude récente", "version": "Version", "logout": "Déconnexion", "password": "Mot de passe", @@ -12,9 +18,9 @@ "name": "Nom", "import": "Importer", "allStudies": "Toutes les études", - "loginError": "Echec de l'authentification", + "loginError": "Échec de l'authentification", "launch": "Lancer", - "studies": "Etudes", + "studies": "Études", "jobs": "Tâches", "exports": "Exports", "unknown": "Inconnu", @@ -23,6 +29,7 @@ "noButton": "Non", "backButton": "Retour", "closeButton": "Fermer", + "cancelButton": "Annuler", "save": "Sauvegarder", "edit": "Editer", "copy": "Copie", @@ -36,5 +43,11 @@ "search": "Rechercher", "files": "Fichiers", "none": "Aucun", + "daysSymbol": "jours", + "hoursSymbol": "heures", + "minutesSymbol": "minutes", + "secondsSymbol": "secondes", + "loading": "Chargement", + "logoutModalMessage": "Êtes vous sûr de vouloir vous déconnecter ?", "date": "Date" -} \ No newline at end of file +} diff --git a/webapp/public/locales/fr/singlestudy.json b/webapp/public/locales/fr/singlestudy.json index c8c6d690e4..a58d9a060e 100644 --- a/webapp/public/locales/fr/singlestudy.json +++ b/webapp/public/locales/fr/singlestudy.json @@ -24,7 +24,7 @@ "monthly": "Mensuel", "annual": "Annuel", "currentTask": "Tâches", - "failtoloadjobs": "Echec du chargement des jobs", + "failtoloadjobs": "Échec du chargement des jobs", "taskId": "Id", "taskStatus": "Status", "taskCreationDate": "Date de création", @@ -34,7 +34,7 @@ "taskLog": "Logs", "taskErrorLog": "Logs d'erreurs", "noTasks": "Pas de tâches en cours", - "failtofetchlogs": "Echec du chargement des logs", + "failtofetchlogs": "Échec du chargement des logs", "failtokilltask": "Échec de l'annulation de l'étude", "owner": "Propriétaire", "groupsLabel": "Groupes", @@ -59,11 +59,11 @@ "renamestudy": "Renommer l'étude", "killStudy": "Arrêter", "exportOutput": "Exporter les sorties", - "confirmKill" : "Êtes-vous sûr de vouloir arrêter la tâche de simulation ?", - "failedToExportOutput": "Echec lors de l'export de la sortie", - "failedToListOutputs": "Echec de la récupération des sorties", + "confirmKill": "Êtes-vous sûr de vouloir arrêter la tâche de simulation ?", + "failedToExportOutput": "Échec lors de l'export de la sortie", + "failedToListOutputs": "Échec de la récupération des sorties", "notes": "Notes", - "fetchCommentsError": "Echec lors de la récupération des commentaires", + "fetchCommentsError": "Échec lors de la récupération des commentaires", "commentsSaved": "Commentaires enregistrés avec succès", "commentsNotSaved": "Erreur lors de l'enregistrement des commentaires", "onStudyIdCopySuccess": "Identifiant de l'étude copié", @@ -94,5 +94,12 @@ "filterIn": "Filtre d'inclusion (Regex)", "filterOut": "Filtre d'exclusion (Regex)", "area1": "Zone 1", - "area2": "Zone 2" -} \ No newline at end of file + "area2": "Zone 2", + "managedStudy": "Étude managée", + "properties": "Propriétés", + "validate": "Valider", + "parentStudy": "Etude parente", + "modifiedStudySuccess": "L'étude {{studyname}} a été modifiée avec succès", + "modifiedStudyFailed": "Erreur lors de la modification de l'étude {{studyname}}", + "versionSource": "Source" +} diff --git a/webapp/public/locales/fr/studymanager.json b/webapp/public/locales/fr/studymanager.json index 150f1cda23..feae03c9e5 100644 --- a/webapp/public/locales/fr/studymanager.json +++ b/webapp/public/locales/fr/studymanager.json @@ -1,17 +1,17 @@ { - "confirmdelete": "Etes vous sûr de vouloir supprimer cette étude ?", + "confirmdelete": "Êtes vous sûr de vouloir supprimer cette étude ?", "nameofstudy": "Nom de l'étude", - "failtoretrievedata": "Echec de la récupération des données", + "failtoretrievedata": "Échec de la récupération des données", "searchstudy": "Rechercher des études", - "failtoretrievestudies": "Echec de la récupération des études", - "failtodeletestudy": "Echec de la suppression de l'étude", - "failtoloadstudy": "Echec du chargement de l'étude", - "failtorunstudy": "Echec du lancement de l'étude", + "failtoretrievestudies": "Échec de la récupération des études", + "failtodeletestudy": "Échec de la suppression de l'étude", + "failtoloadstudy": "Échec du chargement de l'étude", + "failtorunstudy": "Échec du lancement de l'étude", "studylaunched": "{{studyname}} lancée !", "savedatasuccess": "Données sauvegardées avec succès", "scanFolder": "Scanner le dossier", "scanFolderSuccess": "Scan du dossier lancé", - "scanFolderError": "Echec du lancement du scan", + "scanFolderError": "Échec du lancement du scan", "importcopy": "Copier", "failtosavedata": "Erreur lors de la sauvegarde des données", "failtocopystudy": "Erreur lors de la copie de l'étude", @@ -27,14 +27,39 @@ "unarchivefailure": "Erreur lors du désarchivage de l'étude {{studyname}}", "permission": "Permission", "refresh": "Charger les études", - "sortByName": "Trier par nom", - "sortByDate": "Trier par date", - "managedStudiesFilter": "Etudes managées", + "sortByName": "Ordre alphabétique", + "sortByDate": "Date", + "managedStudiesFilter": "Études managées", "userFilter": "Filtrer par utilisateur", "groupFilter": "Filtrer par groupe", "versionFilter": "Filtrer par version", "copyWith": "Copier avec les sorties", "copyWithout": "Copier sans les sorties", + "export": "Exporter", + "delete": "Supprimer", "exportWith": "Exporter avec les sorties", - "exportWithout": "Exporter sans les sorties" -} \ No newline at end of file + "exportWithout": "Exporter sans les sorties", + "sortBy": "Trier par", + "createNewStudy": "Créer une nouvelle étude", + "createStudySuccess": "L'étude {{studyname}} a été créé avec succès", + "createStudyFailed": "Erreur lors de la création de l'étude {{studyname}}", + "studyName": "Nom de l'étude", + "version": "Version", + "baselineStudy": "Etude de base", + "description": "Description", + "Permission": "Permission", + "rightToChange": "Droit de changement", + "metadata": "Métadonnées", + "group": "Groupe", + "tag": "Tag", + "enterTag": "Entrer un tag", + "exploreButton": "Explorer", + "copyID": "Copier l'ID", + "studies": "études", + "versionsLabel": "Versions", + "usersLabel": "Utilisateurs", + "groupsLabel": "Groupes", + "tagsLabel": "Tags", + "bookmark": "Ajouter aux favoris", + "removeFavorite": "Retirer des favoris" +} diff --git a/webapp_v2/public/locales/en/jobs.json b/webapp_v2/public/locales/en/jobs.json index 2dc0dbdeb3..d06f060f56 100644 --- a/webapp_v2/public/locales/en/jobs.json +++ b/webapp_v2/public/locales/en/jobs.json @@ -5,5 +5,18 @@ "logdetails": "No logs found", "launches": "Launches", "exports": "Exports", - "others": "Others" + "others": "Others", + "download": "Download", + "loading": "Loading", + "action": "Action", + "DOWNLOAD": "Download", + "LAUNCH": "Launch", + "COPY": "Copy", + "ARCHIVE": "Archive", + "UNARCHIVE": "Unarchive", + "SCAN": "Scan", + "UNKNOWN": "Unknown", + "typeFilter": "Filter by type", + "all": "All", + "runningTasks": "Running tasks" } diff --git a/webapp_v2/public/locales/en/main.json b/webapp_v2/public/locales/en/main.json index cc79f83cd6..614b597395 100644 --- a/webapp_v2/public/locales/en/main.json +++ b/webapp_v2/public/locales/en/main.json @@ -46,5 +46,6 @@ "minutesSymbol": "minutes", "secondsSymbol": "seconds", "loading": "Loading", - "logoutModalMessage": "Are you sure you want to logout ?" + "logoutModalMessage": "Are you sure you want to logout ?", + "date": "Date" } diff --git a/webapp_v2/public/locales/en/singlestudy.json b/webapp_v2/public/locales/en/singlestudy.json index bf07f05de9..82002cd4a0 100644 --- a/webapp_v2/public/locales/en/singlestudy.json +++ b/webapp_v2/public/locales/en/singlestudy.json @@ -31,6 +31,7 @@ "taskMessage": "Message", "taskOutputId": "Simulation output Id", "taskLog": "Logs", + "taskErrorLog": "Error logs", "noTasks": "No tasks", "failtofetchlogs": "Failed to fetch logs", "failtokilltask": "Failed to kill task", diff --git a/webapp_v2/public/locales/fr/data.json b/webapp_v2/public/locales/fr/data.json index 3345550dd8..b141f62a07 100644 --- a/webapp_v2/public/locales/fr/data.json +++ b/webapp_v2/public/locales/fr/data.json @@ -25,7 +25,7 @@ "onMatrixSaveError": "Matrice non sauvegardée", "onMatrixDeleteError": "Matrice non supprimée", "onMatrixDeleteSuccess": "Matrice supprimée", - "deleteMatrixConfirmation": "Etes vous sûr de vouloir supprimer ce jeu de donnée ?", + "deleteMatrixConfirmation": "Êtes vous sûr de vouloir supprimer ce jeu de donnée ?", "uploadHelp": "Le fichier doit être une matrice simple ou un zip contenant à plat des fichiers de matrices", "uploadingmatrix": "Chargement des matrices", "analyzingmatrix": "Analyse des matrices", diff --git a/webapp_v2/public/locales/fr/jobs.json b/webapp_v2/public/locales/fr/jobs.json index 13f8d9018d..a9107c781f 100644 --- a/webapp_v2/public/locales/fr/jobs.json +++ b/webapp_v2/public/locales/fr/jobs.json @@ -5,5 +5,18 @@ "logdetails": "Pas de logs", "launches": "Lancements", "exports": "Exports", - "others": "Autres" + "others": "Autres", + "download": "Télécharger", + "loading": "Chargement", + "action": "Action", + "DOWNLOAD": "Téléchargement", + "LAUNCH": "Lancement", + "COPY": "Copie", + "ARCHIVE": "Archivage", + "UNARCHIVE": "Désarchivage", + "SCAN": "Scan", + "UNKNOWN": "Inconnu", + "typeFilter": "Filtrer par type", + "all": "Tous", + "runningTasks": "Tâches en cours" } diff --git a/webapp_v2/public/locales/fr/main.json b/webapp_v2/public/locales/fr/main.json index ccc790d35e..4dd76977d9 100644 --- a/webapp_v2/public/locales/fr/main.json +++ b/webapp_v2/public/locales/fr/main.json @@ -18,9 +18,9 @@ "name": "Nom", "import": "Importer", "allStudies": "Toutes les études", - "loginError": "Echec de l'authentification", + "loginError": "Échec de l'authentification", "launch": "Lancer", - "studies": "Etudes", + "studies": "Études", "jobs": "Tâches", "exports": "Exports", "unknown": "Inconnu", @@ -46,5 +46,6 @@ "minutesSymbol": "minutes", "secondsSymbol": "secondes", "loading": "Chargement", - "logoutModalMessage": "Êtes vous sûr de vouloir vous déconnecter ?" + "logoutModalMessage": "Êtes vous sûr de vouloir vous déconnecter ?", + "date": "Date" } diff --git a/webapp_v2/public/locales/fr/singlestudy.json b/webapp_v2/public/locales/fr/singlestudy.json index e763a874bc..ee171933a7 100644 --- a/webapp_v2/public/locales/fr/singlestudy.json +++ b/webapp_v2/public/locales/fr/singlestudy.json @@ -23,7 +23,7 @@ "monthly": "Mensuel", "annual": "Annuel", "currentTask": "Tâches", - "failtoloadjobs": "Echec du chargement des jobs", + "failtoloadjobs": "Échec du chargement des jobs", "taskId": "Id", "taskStatus": "Status", "taskCreationDate": "Date de création", @@ -31,8 +31,9 @@ "taskMessage": "Message", "taskOutputId": "Id de la simulation", "taskLog": "Logs", + "taskErrorLog": "Logs d'erreurs", "noTasks": "Pas de tâches en cours", - "failtofetchlogs": "Echec du chargement des logs", + "failtofetchlogs": "Échec du chargement des logs", "failtokilltask": "Échec de l'annulation de l'étude", "owner": "Propriétaire", "groupsLabel": "Groupes", @@ -58,10 +59,10 @@ "killStudy": "Arrêter", "exportOutput": "Exporter les sorties", "confirmKill": "Êtes-vous sûr de vouloir arrêter la tâche de simulation ?", - "failedToExportOutput": "Echec lors de l'export de la sortie", - "failedToListOutputs": "Echec de la récupération des sorties", + "failedToExportOutput": "Échec lors de l'export de la sortie", + "failedToListOutputs": "Échec de la récupération des sorties", "notes": "Notes", - "fetchCommentsError": "Echec lors de la récupération des commentaires", + "fetchCommentsError": "Échec lors de la récupération des commentaires", "commentsSaved": "Commentaires enregistrés avec succès", "commentsNotSaved": "Erreur lors de l'enregistrement des commentaires", "onStudyIdCopySuccess": "Identifiant de l'étude copié", diff --git a/webapp_v2/public/locales/fr/studymanager.json b/webapp_v2/public/locales/fr/studymanager.json index 87335fe64b..feae03c9e5 100644 --- a/webapp_v2/public/locales/fr/studymanager.json +++ b/webapp_v2/public/locales/fr/studymanager.json @@ -1,17 +1,17 @@ { - "confirmdelete": "Etes vous sûr de vouloir supprimer cette étude ?", + "confirmdelete": "Êtes vous sûr de vouloir supprimer cette étude ?", "nameofstudy": "Nom de l'étude", - "failtoretrievedata": "Echec de la récupération des données", + "failtoretrievedata": "Échec de la récupération des données", "searchstudy": "Rechercher des études", - "failtoretrievestudies": "Echec de la récupération des études", - "failtodeletestudy": "Echec de la suppression de l'étude", - "failtoloadstudy": "Echec du chargement de l'étude", - "failtorunstudy": "Echec du lancement de l'étude", + "failtoretrievestudies": "Échec de la récupération des études", + "failtodeletestudy": "Échec de la suppression de l'étude", + "failtoloadstudy": "Échec du chargement de l'étude", + "failtorunstudy": "Échec du lancement de l'étude", "studylaunched": "{{studyname}} lancée !", "savedatasuccess": "Données sauvegardées avec succès", "scanFolder": "Scanner le dossier", "scanFolderSuccess": "Scan du dossier lancé", - "scanFolderError": "Echec du lancement du scan", + "scanFolderError": "Échec du lancement du scan", "importcopy": "Copier", "failtosavedata": "Erreur lors de la sauvegarde des données", "failtocopystudy": "Erreur lors de la copie de l'étude", @@ -29,7 +29,7 @@ "refresh": "Charger les études", "sortByName": "Ordre alphabétique", "sortByDate": "Date", - "managedStudiesFilter": "Etudes managées", + "managedStudiesFilter": "Études managées", "userFilter": "Filtrer par utilisateur", "groupFilter": "Filtrer par groupe", "versionFilter": "Filtrer par version", diff --git a/webapp_v2/src/common/types.ts b/webapp_v2/src/common/types.ts index 4272197955..38c9d78bea 100644 --- a/webapp_v2/src/common/types.ts +++ b/webapp_v2/src/common/types.ts @@ -355,6 +355,18 @@ export enum TaskStatus { CANCELLED = 6, } +export enum TaskType { + LAUNCH = "LAUNCH", + EXPORT = "EXPORT", + VARIANT_GENERATION = "VARIANT_GENERATION", + COPY = "COPY", + ARCHIVE = "ARCHIVE", + UNARCHIVE = "UNARCHIVE", + DOWNLOAD = "DOWNLOAD", + SCAN = "SCAN", + UNKNOWN = "UNKNOWN", +} + export interface TaskDTO { id: string; name: string; @@ -364,7 +376,7 @@ export interface TaskDTO { completion_date_utc?: string; result?: TaskResult; logs?: Array; - type?: string; + type?: TaskType; ref_id?: string; } @@ -580,4 +592,14 @@ export const isNode = (el: NodeProperties | LinkProperties): boolean => // eslint-disable-next-line @typescript-eslint/no-explicit-any (el as any).id !== undefined; +export interface TaskView { + id: string; + name: ReactNode; + dateView: ReactNode; + action: ReactNode; + date: string; + type: TaskType; + status: string; +} + export default {}; diff --git a/webapp_v2/src/components/common/DownloadLink.tsx b/webapp_v2/src/components/common/DownloadLink.tsx new file mode 100644 index 0000000000..273782e457 --- /dev/null +++ b/webapp_v2/src/components/common/DownloadLink.tsx @@ -0,0 +1,49 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import { ReactNode } from "react"; +import { connect, ConnectedProps } from "react-redux"; +import { loginUser, logoutAction } from "../../store/auth"; +import { refresh } from "../../services/api/auth"; +import { AppState } from "../../store/reducers"; + +const mapState = (state: AppState) => ({ + user: state.auth.user, +}); + +const mapDispatch = { + login: loginUser, + logout: logoutAction, +}; + +const connector = connect(mapState, mapDispatch); +type PropsFromRedux = ConnectedProps; + +interface OwnProps { + url: string; + children?: ReactNode; +} +type PropTypes = PropsFromRedux & OwnProps; + +function DownloadLink(props: PropTypes) { + const { user, login, logout, children, url } = props; + + const handleClick = async () => { + if (user) { + await refresh(user, login, logout); + } + // eslint-disable-next-line no-restricted-globals + location.href = url; + }; + + return ( + + {children} + + ); +} + +DownloadLink.defaultProps = { + children: null, +}; + +export default connector(DownloadLink); diff --git a/webapp_v2/src/components/common/LogModal.tsx b/webapp_v2/src/components/common/LogModal.tsx new file mode 100644 index 0000000000..c001234f73 --- /dev/null +++ b/webapp_v2/src/components/common/LogModal.tsx @@ -0,0 +1,240 @@ +import { + useCallback, + useEffect, + useState, + useRef, + UIEvent, + KeyboardEvent, + CSSProperties, +} from "react"; +import { Box, Button, Paper, Typography, Modal, Backdrop } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import DownloadIcon from "@mui/icons-material/Download"; +import { connect, ConnectedProps } from "react-redux"; +import { exportText } from "../../services/utils/index"; +import { addListener, removeListener } from "../../store/websockets"; +import { WSEvent, WSLogMessage, WSMessage } from "../../common/types"; +import SimpleLoader from "./loaders/SimpleLoader"; +import { scrollbarStyle } from "../../theme"; + +interface OwnTypes { + isOpen: boolean; + title: string; + jobId?: string; + followLogs?: boolean; + content?: string; + close: () => void; + style?: CSSProperties; + loading?: boolean; +} + +const mapState = () => ({}); + +const mapDispatch = { + addWsListener: addListener, + removeWsListener: removeListener, +}; + +const connector = connect(mapState, mapDispatch); +type ReduxProps = ConnectedProps; +type PropTypes = ReduxProps & OwnTypes; + +function LogModal(props: PropTypes) { + const { + title, + style, + jobId, + followLogs, + loading, + isOpen, + content, + close, + addWsListener, + removeWsListener, + } = props; + const [logDetail, setLogDetail] = useState(content); + const divRef = useRef(null); + const logRef = useRef(null); + const [autoscroll, setAutoScroll] = useState(true); + const [t] = useTranslation(); + + const updateLog = useCallback( + (ev: WSMessage) => { + if (ev.type === WSEvent.STUDY_JOB_LOG_UPDATE) { + const logEvent = ev.payload as WSLogMessage; + if (logEvent.job_id === jobId) { + setLogDetail((logDetail || "") + logEvent.log); + } + } + }, + [jobId, logDetail] + ); + + const handleGlobalKeyDown = ( + keyboardEvent: KeyboardEvent + ) => { + if (keyboardEvent.key === "a" && keyboardEvent.ctrlKey) { + if (divRef.current) { + const selection = window.getSelection(); + if (selection !== null) { + selection.selectAllChildren(divRef.current); + } + } + keyboardEvent.preventDefault(); + } + }; + + const scrollToEnd = () => { + if (logRef.current) { + const myDiv = logRef.current.scrollHeight; + logRef.current.scrollTo(0, myDiv - 10); + } + }; + + const onDownload = () => { + if (logDetail !== undefined) { + exportText(logDetail, "log_detail.txt"); + } + }; + + const onScroll = (ev: UIEvent) => { + const element = ev.target as HTMLDivElement; + if (element.scrollHeight - element.scrollTop <= element.clientHeight + 20) { + setAutoScroll(true); + } else { + setAutoScroll(false); + } + }; + + useEffect(() => { + setLogDetail(content); + }, [content]); + + useEffect(() => { + if (logRef.current) { + if (autoscroll) { + scrollToEnd(); + } + } + }, [logDetail, autoscroll]); + + useEffect(() => { + if (followLogs) { + addWsListener(updateLog); + return () => removeWsListener(updateLog); + } + return () => { + /* noop */ + }; + }, [updateLog, followLogs, addWsListener, removeWsListener]); + + return ( + + + + + {title} + + + + + {loading ? ( + + ) : ( + + {logDetail} + + )} + + + + + + + ); +} + +LogModal.defaultProps = { + content: undefined, + jobId: undefined, + followLogs: false, + loading: false, + style: {}, +}; + +export default connector(LogModal); diff --git a/webapp_v2/src/components/common/PropertiesView.tsx b/webapp_v2/src/components/common/PropertiesView.tsx index 1df37bc1e5..45c06c1f11 100644 --- a/webapp_v2/src/components/common/PropertiesView.tsx +++ b/webapp_v2/src/components/common/PropertiesView.tsx @@ -7,13 +7,13 @@ import AddIcon from "@mui/icons-material/Add"; const StyledAddIcon = styled(AddIcon)(({ theme }) => ({ cursor: "pointer", color: "black", - width: "40px", - height: "40px", + width: "56px", + height: "56px", position: "absolute", left: "5%", bottom: "25px", borderRadius: "30px", - padding: "8px", + padding: "16px", backgroundColor: theme.palette.primary.main, "&:hover": { backgroundColor: theme.palette.primary.dark, diff --git a/webapp_v2/src/components/studies/StudyTree.tsx b/webapp_v2/src/components/studies/StudyTree.tsx index 742b697b5d..c23d6454fb 100644 --- a/webapp_v2/src/components/studies/StudyTree.tsx +++ b/webapp_v2/src/components/studies/StudyTree.tsx @@ -59,7 +59,6 @@ function StudyTree(props: Props) { const orderFolderScan = async (folderPath: string): Promise => { try { await scanFolder(folderPath); - enqueueSnackbar(t("studymanager:scanFolderSuccess"), { variant: "info" }); } catch (e) { enqueueErrorSnackbar( enqueueSnackbar, diff --git a/webapp_v2/src/components/tasks/JobTableView.tsx b/webapp_v2/src/components/tasks/JobTableView.tsx new file mode 100644 index 0000000000..08ef2d635e --- /dev/null +++ b/webapp_v2/src/components/tasks/JobTableView.tsx @@ -0,0 +1,202 @@ +import { useState } from "react"; +import moment from "moment"; +import { + Paper, + TableContainer, + Table, + TableHead, + TableRow, + TableCell, + TableBody, + Box, + FormControl, + InputLabel, + Select, + MenuItem, + SelectChangeEvent, + Checkbox, + FormControlLabel, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import ArrowDropUpIcon from "@mui/icons-material/ArrowDropUp"; +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import { grey } from "@mui/material/colors"; +import { TaskView, TaskType } from "../../common/types"; +import { scrollbarStyle } from "../../theme"; + +interface PropType { + content: Array; +} + +function JobTableView(props: PropType) { + const { content } = props; + const [t] = useTranslation(); + const [sorted, setSorted] = useState(); + const [type, setType] = useState("all"); + const [filterRunningStatus, setFilterRunningStatus] = + useState(false); + const [currentContent, setCurrentContent] = useState(content); + + const handleChange = (event: SelectChangeEvent) => { + setType(event.target.value as string); + if (event.target.value !== "all") { + if (filterRunningStatus) { + setCurrentContent( + content + .filter((o) => o.type === type) + .filter((o) => o.status === "running") + ); + } else { + setCurrentContent(content.filter((o) => o.type === event.target.value)); + } + } else if (filterRunningStatus) { + setCurrentContent(content.filter((o) => o.status === "running")); + } else { + setCurrentContent(content); + } + }; + + const handleFilterStatusChange = () => { + setFilterRunningStatus(!filterRunningStatus); + if (!filterRunningStatus) { + setCurrentContent(currentContent.filter((o) => o.status === "running")); + } else if (type !== "all") { + setCurrentContent(content.filter((o) => o.type === type)); + } else { + setCurrentContent(content); + } + }; + + const filterList = [ + "all", + TaskType.DOWNLOAD, + TaskType.LAUNCH, + TaskType.COPY, + TaskType.ARCHIVE, + TaskType.UNARCHIVE, + TaskType.SCAN, + ]; + + return ( + + + + } + label={t("jobs:runningTasks") as string} + /> + + + {t("jobs:typeFilter")} + + + + + + + + + {t("main:jobs")} + {t("singlestudy:type")} + + + {t("main:date")} + {!sorted ? ( + setSorted("date")} + /> + ) : ( + setSorted(undefined)} + /> + )} + + + {t("jobs:action")} + + + + {currentContent + .sort((a, b) => { + if (!sorted && sorted !== "date") { + return moment(a.date).isAfter(moment(b.date)) ? -1 : 1; + } + return moment(a.date).isAfter(moment(b.date)) ? 1 : -1; + }) + .map((row) => ( + td, &:last-child > th": { + border: 0, + }, + }} + > + + {row.name} + + {t(`jobs:${row.type}`)} + {row.dateView} + {row.action} + + ))} + +
+
+
+ ); +} + +export default JobTableView; diff --git a/webapp_v2/src/components/tasks/LaunchJobLogView.tsx b/webapp_v2/src/components/tasks/LaunchJobLogView.tsx new file mode 100644 index 0000000000..ec65f44dda --- /dev/null +++ b/webapp_v2/src/components/tasks/LaunchJobLogView.tsx @@ -0,0 +1,129 @@ +import { useState } from "react"; +import { AxiosError } from "axios"; +import { useSnackbar } from "notistack"; +import { useTranslation } from "react-i18next"; +import { Box, Tooltip } from "@mui/material"; +import ErrorIcon from "@mui/icons-material/Error"; +import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile"; +import { getStudyJobLog } from "../../services/api/study"; +import enqueueErrorSnackbar from "../common/ErrorSnackBar"; +import LogModal from "../common/LogModal"; +import { LaunchJob } from "../../common/types"; + +interface PropsType { + job: LaunchJob; + logButton?: boolean; + logErrorButton?: boolean; +} + +function LaunchJobLogView(props: PropsType) { + const { job, logButton, logErrorButton } = props; + const [t] = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [jobIdDetail, setJobIdDetail] = useState(); + const [followLogs, setFollowLogs] = useState(false); + const [logModalContent, setLogModalContent] = useState(); + const [logModalContentLoading, setLogModalContentLoading] = + useState(false); + + const openLogView = (jobId: string, errorLogs = false) => { + setJobIdDetail(jobId); + setLogModalContentLoading(true); + setFollowLogs(!errorLogs); + (async () => { + try { + const logData = await getStudyJobLog( + jobId, + errorLogs ? "STDERR" : "STDOUT" + ); + setLogModalContent(logData); + } catch (e) { + enqueueErrorSnackbar( + enqueueSnackbar, + t("singlestudy:failtofetchlogs"), + e as AxiosError + ); + } finally { + setLogModalContentLoading(false); + } + })(); + }; + + return ( + + {logButton && ( + + + openLogView(job.id)} + /> + + + )} + {logErrorButton && ( + + openLogView(job.id, true)} + > + + + + + )} + setJobIdDetail(undefined)} + /> + + ); +} + +LaunchJobLogView.defaultProps = { + logButton: false, + logErrorButton: false, +}; + +export default LaunchJobLogView; diff --git a/webapp_v2/src/components/tasks/NotificationBadge.tsx b/webapp_v2/src/components/tasks/NotificationBadge.tsx new file mode 100644 index 0000000000..d606e9c34c --- /dev/null +++ b/webapp_v2/src/components/tasks/NotificationBadge.tsx @@ -0,0 +1,126 @@ +import { PropsWithChildren, useCallback, useEffect, useRef } from "react"; +import debug from "debug"; +import { connect, ConnectedProps } from "react-redux"; +import { Box, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import { useLocation } from "react-router-dom"; +import CircleIcon from "@mui/icons-material/Circle"; +import { useSnackbar, VariantType } from "notistack"; +import { red } from "@mui/material/colors"; +import { addListener, removeListener } from "../../store/websockets"; +import { TaskEventPayload, WSEvent, WSMessage } from "../../common/types"; +import { getTask } from "../../services/api/tasks"; +import { + addTasksNotification, + clearTasksNotification, +} from "../../store/global"; +import { AppState } from "../../store/reducers"; + +const logError = debug("antares:downloadbadge:error"); + +const mapState = (state: AppState) => ({ + notificationCount: state.global.tasksNotificationCount, +}); + +const mapDispatch = { + addWsListener: addListener, + removeWsListener: removeListener, + addTasksNotification, + clearTasksNotification, +}; + +const connector = connect(mapState, mapDispatch); +type ReduxProps = ConnectedProps; +type PropTypes = PropsWithChildren; + +function NotificationBadge(props: PropTypes) { + const { + addWsListener, + removeWsListener, + children, + notificationCount, + addTasksNotification, + clearTasksNotification, + } = props; + const [t] = useTranslation(); + const location = useLocation(); + const { enqueueSnackbar } = useSnackbar(); + const ref = useRef(null); + + const newNotification = useCallback( + (message: string, variantType?: VariantType) => { + if (location.pathname !== "/tasks") { + addTasksNotification(); + } + enqueueSnackbar(t(message), { variant: variantType || "info" }); + }, + [addTasksNotification, enqueueSnackbar, location.pathname, t] + ); + + useEffect(() => { + const listener = async (ev: WSMessage) => { + if (ev.type === WSEvent.DOWNLOAD_CREATED) { + newNotification("downloads:newDownload"); + } else if (ev.type === WSEvent.DOWNLOAD_READY) { + newNotification("downloads:downloadReady"); + } else if (ev.type === WSEvent.DOWNLOAD_FAILED) { + newNotification("singlestudy:failedToExportOutput", "error"); + } else if (ev.type === WSEvent.TASK_ADDED) { + const taskId = (ev.payload as TaskEventPayload).id; + try { + const task = await getTask(taskId); + if (task.type === "COPY") { + newNotification("studymanager:studycopying"); + } else if (task.type === "ARCHIVE") { + newNotification("studymanager:studyarchiving"); + } else if (task.type === "UNARCHIVE") { + newNotification("studymanager:studyunarchiving"); + } else if (task.type === "SCAN") { + newNotification("studymanager:scanFolderSuccess"); + } + } catch (error) { + logError(error); + } + } + }; + addWsListener(listener); + return () => removeWsListener(listener); + }, [addWsListener, removeWsListener, newNotification]); + + useEffect(() => { + if (location.pathname === "/tasks") { + clearTasksNotification(); + } + }, [location, clearTasksNotification]); + + return ( + + {children} + {ref.current && notificationCount > 0 && ( + + + {notificationCount} + + + + )} + + ); +} + +export default connector(NotificationBadge); diff --git a/webapp_v2/src/pages/Tasks.tsx b/webapp_v2/src/pages/Tasks.tsx index 0ffb9cfac8..5721781712 100644 --- a/webapp_v2/src/pages/Tasks.tsx +++ b/webapp_v2/src/pages/Tasks.tsx @@ -1,15 +1,565 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { useState, useEffect, useMemo } from "react"; +import { AxiosError } from "axios"; +import { connect, ConnectedProps } from "react-redux"; +import debug from "debug"; import { useTranslation } from "react-i18next"; import AssignmentIcon from "@mui/icons-material/Assignment"; +import { useSnackbar } from "notistack"; +import moment from "moment"; +import { + useTheme, + Typography, + Box, + CircularProgress, + Tooltip, +} from "@mui/material"; +import { Link } from "react-router-dom"; +import { debounce } from "lodash"; +import BlockIcon from "@mui/icons-material/Block"; +import InfoIcon from "@mui/icons-material/Info"; +import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; +import CalendarTodayIcon from "@mui/icons-material/CalendarToday"; +import EventAvailableIcon from "@mui/icons-material/EventAvailable"; +import DownloadIcon from "@mui/icons-material/Download"; +import { grey } from "@mui/material/colors"; import RootPage from "../components/common/page/RootPage"; +import SimpleLoader from "../components/common/loaders/SimpleLoader"; +import DownloadLink from "../components/common/DownloadLink"; +import LogModal from "../components/common/LogModal"; +import { + addListener, + removeListener, + subscribe, + unsubscribe, + WsChannel, +} from "../store/websockets"; +import JobTableView from "../components/tasks/JobTableView"; +import { convertUTCToLocalTime, useNotif } from "../services/utils/index"; +import { + downloadJobOutput, + killStudy, + getStudyJobs, + getStudies, +} from "../services/api/study"; +import { + convertFileDownloadDTO, + FileDownload, + getDownloadUrl, + FileDownloadDTO, + getDownloadsList, +} from "../services/api/downloads"; +import { initStudies } from "../store/study"; +import { + LaunchJob, + TaskDTO, + TaskEventPayload, + WSEvent, + WSMessage, + TaskType, + TaskStatus, +} from "../common/types"; +import enqueueErrorSnackbar from "../components/common/ErrorSnackBar"; +import BasicModal from "../components/common/BasicModal"; +import { getAllMiscRunningTasks, getTask } from "../services/api/tasks"; +import { AppState } from "../store/reducers"; +import LaunchJobLogView from "../components/tasks/LaunchJobLogView"; -function Tasks() { +const logError = debug("antares:studymanagement:error"); + +const mapState = (state: AppState) => ({ + studies: state.study.studies, +}); + +const mapDispatch = { + loadStudies: initStudies, + addWsListener: addListener, + removeWsListener: removeListener, + subscribeChannel: subscribe, + unsubscribeChannel: unsubscribe, +}; + +const connector = connect(mapState, mapDispatch); +type ReduxProps = ConnectedProps; +type PropTypes = ReduxProps; + +function JobsListing(props: PropTypes) { + const { + studies, + loadStudies, + addWsListener, + removeWsListener, + subscribeChannel, + unsubscribeChannel, + } = props; const [t] = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const theme = useTheme(); + const [jobs, setJobs] = useState([]); + const [downloads, setDownloads] = useState([]); + const [tasks, setTasks] = useState>([]); + const createNotif = useNotif(); + const [loaded, setLoaded] = useState(false); + const [openConfirmationModal, setOpenConfirmationModal] = useState< + string | undefined + >(); + const [messageModalOpen, setMessageModalOpen] = useState< + string | undefined + >(); + + const init = async () => { + setLoaded(false); + try { + if (studies.length === 0) { + const allStudies = await getStudies(); + loadStudies(allStudies); + } + const allJobs = await getStudyJobs(undefined, false); + setJobs(allJobs); + const dlList = await getDownloadsList(); + setDownloads(dlList); + const allTasks = await getAllMiscRunningTasks(); + const dateThreshold = moment().subtract(1, "m"); + setTasks( + allTasks.filter( + (task) => + !task.completion_date_utc || + moment.utc(task.completion_date_utc).isAfter(dateThreshold) + ) + ); + } catch (e) { + logError("woops", e); + enqueueErrorSnackbar( + createNotif, + t("jobs:failedtoretrievejobs"), + e as AxiosError + ); + } finally { + setLoaded(true); + } + }; + + const renderStatus = (job: LaunchJob) => { + let color = theme.palette.grey[400]; + if (job.status === "success") { + color = theme.palette.success.main; + } else if (job.status === "failed") { + color = theme.palette.error.main; + } else if (job.status === "running") { + color = theme.palette.warning.main; + } + return ( + + ); + }; + + const exportJobOutput = debounce( + async (jobId: string): Promise => { + try { + await downloadJobOutput(jobId); + } catch (e) { + enqueueErrorSnackbar( + enqueueSnackbar, + t("singlestudy:failedToExportOutput"), + e as AxiosError + ); + } + }, + 2000, + { leading: true } + ); + + const killTask = (jobId: string) => { + (async () => { + try { + await killStudy(jobId); + } catch (e) { + enqueueErrorSnackbar( + enqueueSnackbar, + t("singlestudy:failtokilltask"), + e as AxiosError + ); + } + setOpenConfirmationModal(undefined); + })(); + }; + + useEffect(() => { + const listener = async (ev: WSMessage) => { + if ( + ev.type === WSEvent.TASK_COMPLETED || + ev.type === WSEvent.TASK_FAILED + ) { + const taskId = (ev.payload as TaskEventPayload).id; + if (tasks?.find((task) => task.id === taskId)) { + try { + const updatedTask = await getTask(taskId); + setTasks( + tasks + .filter((task) => task.id !== updatedTask.id) + .concat([updatedTask]) + ); + } catch (error) { + logError(error); + } + } + } else if (ev.type === WSEvent.DOWNLOAD_CREATED) { + setDownloads( + (downloads || []).concat([ + convertFileDownloadDTO(ev.payload as FileDownloadDTO), + ]) + ); + } else if (ev.type === WSEvent.DOWNLOAD_READY) { + setDownloads( + (downloads || []).map((d) => { + const fileDownload = ev.payload as FileDownloadDTO; + if (d.id === fileDownload.id) { + return convertFileDownloadDTO(fileDownload); + } + return d; + }) + ); + } else if ( + ev.type === WSEvent.DOWNLOAD_READY || + ev.type === WSEvent.DOWNLOAD_FAILED + ) { + setDownloads( + (downloads || []).map((d) => { + const fileDownload = ev.payload as FileDownloadDTO; + if (d.id === fileDownload.id) { + return convertFileDownloadDTO(fileDownload); + } + return d; + }) + ); + } else if (ev.type === WSEvent.DOWNLOAD_EXPIRED) { + setDownloads( + (downloads || []).filter((d) => { + const fileDownload = ev.payload as FileDownloadDTO; + return d.id !== fileDownload.id; + }) + ); + } + }; + addWsListener(listener); + return () => { + removeWsListener(listener); + }; + }, [addWsListener, removeWsListener, downloads, tasks, setTasks]); + + useEffect(() => { + if (tasks) { + tasks.forEach((task) => { + subscribeChannel(WsChannel.TASK + task.id); + }); + return () => { + tasks.forEach((task) => { + unsubscribeChannel(WsChannel.TASK + task.id); + }); + }; + } + return () => { + /* noop */ + }; + }, [tasks, subscribeChannel, unsubscribeChannel]); + + useEffect(() => { + init(); + }, []); + + const jobsMemo = useMemo( + () => + jobs.map((job) => ({ + id: job.id, + name: ( + + {renderStatus(job)} + + + {studies.find((s) => s.id === job.studyId)?.name || + `${t("main:unknown")} (${job.id})`} + + + + ), + dateView: ( + + + + {convertUTCToLocalTime(job.creationDate)} + + + {job.completionDate && ( + <> + + {convertUTCToLocalTime(job.completionDate)} + + )} + + + ), + action: ( + + + {job.status === "running" ? ( + + setOpenConfirmationModal(job.id)} + /> + + ) : ( + + )} + + + {job.status === "success" ? ( + + exportJobOutput(job.id)} + /> + + ) : ( + + )} + + + + ), + date: job.completionDate || job.creationDate, + type: TaskType.LAUNCH, + status: job.status === "running" ? "running" : "", + })), + [jobs] + ); + + const downloadsMemo = useMemo( + () => + downloads.map((download) => ({ + id: download.id, + name: ( + + {download.name} + + ), + dateView: ( + + {`(${t("downloads:expirationDate")} : ${convertUTCToLocalTime( + download.expirationDate + )})`} + + ), + action: download.failed ? ( + + setMessageModalOpen(download.errorMessage)} + /> + + ) : ( + + {download.ready ? ( + + + + + + ) : ( + + + + )} + + ), + date: moment(download.expirationDate) + .subtract(1, "days") + .format("YYYY-MM-DD HH:mm:ss"), + type: TaskType.DOWNLOAD, + status: !download.ready && !download.failed ? "running" : "", + })), + [downloads] + ); + + const tasksMemo = useMemo( + () => + tasks.map((task) => ({ + id: task.id, + name: ( + + {task.name} + + ), + dateView: ( + + + + {convertUTCToLocalTime(task.creation_date_utc)} + + + {task.completion_date_utc && ( + <> + + {convertUTCToLocalTime(task.completion_date_utc)} + + )} + + + ), + action: ( + + {!task.completion_date_utc && ( + + + + )} + {task.result && !task.result.success && ( + + setMessageModalOpen(`${task.result?.message}`)} + /> + + )} + + ), + date: task.completion_date_utc || task.creation_date_utc, + type: task.type || TaskType.UNKNOWN, + status: task.status === TaskStatus.RUNNING ? "running" : "", + })), + [tasks] + ); + + const content = jobsMemo.concat(downloadsMemo.concat(tasksMemo)); return ( - In progress + + {!loaded && } + {loaded && } + {openConfirmationModal && ( + killTask(openConfirmationModal)} + onClose={() => setOpenConfirmationModal(undefined)} + rootStyle={{ + maxWidth: "800px", + maxHeight: "800px", + display: "flex", + flexFlow: "column nowrap", + alignItems: "center", + }} + > + + {t("singlestudy:confirmKill")} + + + )} + setMessageModalOpen(undefined)} + style={{ width: "600px", height: "300px" }} + /> + ); } -export default Tasks; +export default connector(JobsListing); diff --git a/webapp_v2/src/pages/wrappers/MenuWrapper/index.tsx b/webapp_v2/src/pages/wrappers/MenuWrapper/index.tsx index 5ee2446770..00db2b1c9a 100644 --- a/webapp_v2/src/pages/wrappers/MenuWrapper/index.tsx +++ b/webapp_v2/src/pages/wrappers/MenuWrapper/index.tsx @@ -15,8 +15,8 @@ import List from "@mui/material/List"; import Typography from "@mui/material/Typography"; import Divider from "@mui/material/Divider"; import TravelExploreOutlinedIcon from "@mui/icons-material/TravelExploreOutlined"; -import ShowChartOutlinedIcon from "@mui/icons-material/ShowChartOutlined"; -import PlaylistAddCheckOutlinedIcon from "@mui/icons-material/PlaylistAddCheckOutlined"; +import StorageIcon from "@mui/icons-material/Storage"; +import AssignmentIcon from "@mui/icons-material/Assignment"; import CenterFocusStrongIcon from "@mui/icons-material/CenterFocusStrong"; import ApiIcon from "@mui/icons-material/Api"; @@ -29,6 +29,7 @@ import ReadMoreOutlinedIcon from "@mui/icons-material/ReadMoreOutlined"; import { SvgIconProps, useTheme } from "@mui/material"; import logo from "../../../assets/logo.png"; +import NotificationBadge from "../../../components/tasks/NotificationBadge"; import topRightBackground from "../../../assets/top-right-background.png"; import { AppState } from "../../../store/reducers"; import { setMenuExtensionStatusAction } from "../../../store/ui"; @@ -53,6 +54,7 @@ interface MenuItem { const mapState = (state: AppState) => ({ extended: state.ui.menuExtended, currentStudy: state.study.current, + websocketConnected: state.websockets.connected, }); const mapDispatch = { @@ -76,8 +78,8 @@ function MenuWrapper(props: PropsWithChildren) { strict: true, icon: TravelExploreOutlinedIcon, }, - { id: "tasks", link: "/tasks", icon: PlaylistAddCheckOutlinedIcon }, - { id: "data", link: "/data", icon: ShowChartOutlinedIcon }, + { id: "tasks", link: "/tasks", icon: AssignmentIcon }, + { id: "data", link: "/data", icon: StorageIcon }, { id: "api", link: "/api", icon: ApiIcon }, { id: "documentation", @@ -108,33 +110,57 @@ function MenuWrapper(props: PropsWithChildren) { const settings = navigation[navigation.length - 1]; - const drawMenuItem = (elm: MenuItem): ReactNode => ( - - {elm.newTab === true ? ( - - - - - {extended && } - - ) : ( - ({ - background: isActive - ? theme.palette.primary.outlinedHoverBackground - : undefined, - })} - > - - - - {extended && } - - )} - - ); + const drawMenuItem = (elm: MenuItem): ReactNode => { + if (elm.id === "tasks") { + return ( + + ({ + background: isActive + ? theme.palette.primary.outlinedHoverBackground + : undefined, + })} + > + + + + + + {extended && } + + + ); + } + return ( + + {elm.newTab === true ? ( + + + + + {extended && } + + ) : ( + ({ + background: isActive + ? theme.palette.primary.outlinedHoverBackground + : undefined, + })} + > + + + + {extended && } + + )} + + ); + }; const topMenuLastIndexOffset = currentStudy ? 1 : 0; diff --git a/webapp_v2/src/services/api/study.ts b/webapp_v2/src/services/api/study.ts index 3c364bd5b3..abed998b2a 100644 --- a/webapp_v2/src/services/api/study.ts +++ b/webapp_v2/src/services/api/study.ts @@ -299,8 +299,13 @@ export const mapLaunchJobDTO = (j: LaunchJobDTO): LaunchJob => ({ exitCode: j.exit_code, }); -export const getStudyJobs = async (sid?: string): Promise => { - const query = sid ? `?study=${sid}` : ""; +export const getStudyJobs = async ( + sid?: string, + filterOrphans = true +): Promise => { + const query = sid + ? `?study=${sid}&filter_orphans=${filterOrphans}` + : `?filter_orphans=${filterOrphans}`; const res = await client.get(`/v1/launcher/jobs${query}`); const data = await res.data; return data.map(mapLaunchJobDTO); @@ -316,6 +321,12 @@ export const getStudyJobLog = async ( return res.data; }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const downloadJobOutput = async (jobId: string): Promise => { + const res = await client.get(`/v1/launcher/jobs/${jobId}/output`); + return res.data; +}; + export const changeStudyOwner = async ( studyId: string, newOwner: number diff --git a/webapp_v2/src/services/api/tasks.ts b/webapp_v2/src/services/api/tasks.ts new file mode 100644 index 0000000000..47b7d47460 --- /dev/null +++ b/webapp_v2/src/services/api/tasks.ts @@ -0,0 +1,42 @@ +import { TaskDTO, TaskStatus } from "../../common/types"; +import client from "./client"; + +export const getStudyRunningTasks = async ( + sid: string +): Promise> => { + const res = await client.post("/v1/tasks", { + ref_id: sid, + status: [TaskStatus.RUNNING, TaskStatus.PENDING], + }); + return res.data; +}; + +export const getAllRunningTasks = async (): Promise> => { + const res = await client.post("/v1/tasks", { + status: [TaskStatus.RUNNING, TaskStatus.PENDING], + }); + return res.data; +}; + +export const getAllMiscRunningTasks = async (): Promise> => { + const res = await client.post("/v1/tasks", { + status: [ + TaskStatus.RUNNING, + TaskStatus.PENDING, + TaskStatus.FAILED, + TaskStatus.COMPLETED, + ], + type: ["COPY", "ARCHIVE", "UNARCHIVE", "SCAN"], + }); + return res.data; +}; + +export const getTask = async ( + id: string, + withLogs = false +): Promise => { + const res = await client.get(`/v1/tasks/${id}?with_logs=${withLogs}`); + return res.data; +}; + +export default {}; diff --git a/webapp_v2/src/store/global.ts b/webapp_v2/src/store/global.ts index 2e81b97253..eb6bd9bd33 100644 --- a/webapp_v2/src/store/global.ts +++ b/webapp_v2/src/store/global.ts @@ -14,18 +14,36 @@ export interface GlobalState { onCloseListeners: { [id: string]: (event: Event) => void }; maintenanceMode: boolean; messageInfo: string; + tasksNotificationCount: number; } const initialState: GlobalState = { onCloseListeners: {}, maintenanceMode: false, messageInfo: "", + tasksNotificationCount: 0, }; /** ******************************************* */ /* Actions */ /** ******************************************* */ +export interface AddTasksNotificationAction extends Action { + type: "GLOBAL/ADD_TASKS_NOTIFICATION"; +} + +export const addTasksNotification = (): AddTasksNotificationAction => ({ + type: "GLOBAL/ADD_TASKS_NOTIFICATION", +}); + +export interface ClearTasksNotificationAction extends Action { + type: "GLOBAL/CLEAR_TASKS_NOTIFICATION"; +} + +export const clearTasksNotification = (): ClearTasksNotificationAction => ({ + type: "GLOBAL/CLEAR_TASKS_NOTIFICATION", +}); + export interface AddOnCloseListenerAction extends Action { type: "GLOBAL/ADD_ONCLOSE_LISTENER"; payload: { @@ -104,7 +122,9 @@ type GlobalAction = | AddOnCloseListenerAction | RemoveOnCloseListenerAction | SetMaintenanceModeAction - | SetMessageInfoAction; + | SetMessageInfoAction + | AddTasksNotificationAction + | ClearTasksNotificationAction; /** ******************************************* */ /* Selectors / Misc */ @@ -153,6 +173,18 @@ export default (state = initialState, action: GlobalAction): GlobalState => { messageInfo: action.payload, }; } + case "GLOBAL/ADD_TASKS_NOTIFICATION": { + return { + ...state, + tasksNotificationCount: state.tasksNotificationCount + 1, + }; + } + case "GLOBAL/CLEAR_TASKS_NOTIFICATION": { + return { + ...state, + tasksNotificationCount: 0, + }; + } default: return state; } diff --git a/webapp_v2/src/theme.ts b/webapp_v2/src/theme.ts index 82b91ecb35..a66b57b35b 100644 --- a/webapp_v2/src/theme.ts +++ b/webapp_v2/src/theme.ts @@ -86,6 +86,18 @@ const theme = createTheme({ }, ], }, + MuiFormControl: { + variants: [ + { + props: { variant: "outlined" }, + style: { + "> div > fieldset": { + borderColor: "rgba(255,255,255,0.09)", + }, + }, + }, + ], + }, }, typography: { @@ -129,7 +141,7 @@ const theme = createTheme({ }, action: { active: "rgba(255, 255, 255, 0.56)", - hover: "rgba(255, 255, 255, 0.08)", + hover: "rgba(255, 255, 255, 0.32)", selected: "rgba(255, 255, 255, 0.16)", disabled: "rgba(255, 255, 255, 0.3)", disabledBackground: "rgba(255, 255, 255, 0.12)", From 0a90c993d427af6941ea2c86e57e38eaf97971b7 Mon Sep 17 00:00:00 2001 From: 3lbanna <76211863+3lbanna@users.noreply.github.com> Date: Thu, 21 Apr 2022 15:36:19 +0200 Subject: [PATCH 08/43] Add launcher history (#831) --- webapp_v2/public/locales/en/singlestudy.json | 3 + webapp_v2/public/locales/fr/singlestudy.json | 5 +- .../components/common/ConfirmationModal.tsx | 48 ++++ .../InformationView/LauncherHistory.tsx | 52 ---- .../LauncherHistory/JobStepper.tsx | 250 ++++++++++++++++++ .../InformationView/LauncherHistory/index.tsx | 164 ++++++++++++ .../HomeView/InformationView/Notes/index.tsx | 1 + .../HomeView/InformationView/index.tsx | 2 +- .../src/components/tasks/LaunchJobLogView.tsx | 5 +- 9 files changed, 475 insertions(+), 55 deletions(-) create mode 100644 webapp_v2/src/components/common/ConfirmationModal.tsx delete mode 100644 webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory.tsx create mode 100644 webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/JobStepper.tsx create mode 100644 webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/index.tsx diff --git a/webapp_v2/public/locales/en/singlestudy.json b/webapp_v2/public/locales/en/singlestudy.json index 82002cd4a0..0809884ec9 100644 --- a/webapp_v2/public/locales/en/singlestudy.json +++ b/webapp_v2/public/locales/en/singlestudy.json @@ -67,7 +67,10 @@ "commentsNotSaved": "Comments not saved", "onStudyIdCopySucces": "Study id copied !", "onStudyIdCopyError": "Failed to copy study id", + "onJobIdCopySucces": "Job id copied !", + "onJobIdCopyError": "Failed to copy job id", "copyId": "Copy the study id", + "copyJobId": "Copy the job id", "copyIdDir": "Copy id", "newArea": "New Area", "newLink": "New Link", diff --git a/webapp_v2/public/locales/fr/singlestudy.json b/webapp_v2/public/locales/fr/singlestudy.json index ee171933a7..792b2f5849 100644 --- a/webapp_v2/public/locales/fr/singlestudy.json +++ b/webapp_v2/public/locales/fr/singlestudy.json @@ -67,8 +67,11 @@ "commentsNotSaved": "Erreur lors de l'enregistrement des commentaires", "onStudyIdCopySuccess": "Identifiant de l'étude copié", "onStudyIdCopyError": "Erreur lors de la copie de l'identifiant de l'étude", - "copyId": "Copie l'identifiant de l'étude", + "onJobIdCopySucces": "Identifiant de la tâche copié", + "onJobIdCopyError": "Erreur lors de la copie de l'identifiant de la tâche", + "copyId": "Copier l'identifiant de l'étude", "copyIdDir": "Copier l'ID", + "copyJobId": "Copie l'identifiant de la tâche", "newArea": "Nouvelle zone", "newLink": "Nouveau lien", "areaName": "Nom de la zone", diff --git a/webapp_v2/src/components/common/ConfirmationModal.tsx b/webapp_v2/src/components/common/ConfirmationModal.tsx new file mode 100644 index 0000000000..5e999a5803 --- /dev/null +++ b/webapp_v2/src/components/common/ConfirmationModal.tsx @@ -0,0 +1,48 @@ +import { Box, Typography } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import BasicModal from "./BasicModal"; + +interface Props { + open: boolean; + message: string; + handleNo: () => void; + handleYes: () => void; +} + +export default function ConfirmationModal(props: Props) { + const [t] = useTranslation(); + const { open, message, handleNo, handleYes } = props; + + return ( + + + {message} + + + ); +} diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory.tsx deleted file mode 100644 index bc81b00a99..0000000000 --- a/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Paper } from "@mui/material"; -import { connect, ConnectedProps } from "react-redux"; -import { StudyMetadata } from "../../../../common/types"; -import { - addListener, - removeListener, - subscribe, - unsubscribe, -} from "../../../../store/websockets"; - -interface OwnTypes { - // eslint-disable-next-line react/no-unused-prop-types - study: StudyMetadata | undefined; -} - -const mapState = () => ({}); - -const mapDispatch = { - subscribeChannel: subscribe, - unsubscribeChannel: unsubscribe, - addWsListener: addListener, - removeWsListener: removeListener, -}; - -const connector = connect(mapState, mapDispatch); -type ReduxProps = ConnectedProps; -type PropTypes = ReduxProps & OwnTypes; - -function LauncherHistory(props: PropTypes) { - const { study } = props; - - return ( - - {study && study.name} - - ); -} - -export default connector(LauncherHistory); diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/JobStepper.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/JobStepper.tsx new file mode 100644 index 0000000000..4e649cbfb3 --- /dev/null +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/JobStepper.tsx @@ -0,0 +1,250 @@ +import Box from "@mui/material/Box"; +import Stepper from "@mui/material/Stepper"; +import Step from "@mui/material/Step"; +import StepLabel from "@mui/material/StepLabel"; +import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; +import BlockIcon from "@mui/icons-material/Block"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import { + StepConnector, + stepConnectorClasses, + StepIconProps, + styled, + Tooltip, + Typography, +} from "@mui/material"; +import moment from "moment"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSnackbar } from "notistack"; +import { AxiosError } from "axios"; +import { JobStatus, LaunchJob } from "../../../../../common/types"; +import { convertUTCToLocalTime } from "../../../../../services/utils"; +import { scrollbarStyle } from "../../../../../theme"; +import ConfirmationModal from "../../../../common/ConfirmationModal"; +import { killStudy } from "../../../../../services/api/study"; +import enqueueErrorSnackbar from "../../../../common/ErrorSnackBar"; +import LaunchJobLogView from "../../../../tasks/LaunchJobLogView"; + +const QontoConnector = styled(StepConnector)(({ theme }) => ({ + [`&.${stepConnectorClasses.disabled}`]: { + [`& .${stepConnectorClasses.line}`]: { + height: "auto", + }, + }, +})); + +const QontoStepIconRoot = styled("div")(({ theme }) => ({ + color: theme.palette.mode === "dark" ? theme.palette.grey[700] : "#eaeaf0", + display: "flex", + width: "24px", + justifyContent: "center", + alignItems: "center", + "& .QontoStepIcon-inprogress": { + width: 16, + height: 16, + color: theme.palette.primary.main, + }, +})); + +const ColorStatus = { + running: "warning.main", + pending: "grey.400", + success: "success.main", + failed: "error.main", +}; + +function QontoStepIcon(props: { + className: string | undefined; + status: JobStatus; +}) { + const { className, status } = props; + return ( + + + + ); +} + +interface Props { + jobs: Array; +} + +export default function VerticalLinearStepper(props: Props) { + const { jobs } = props; + const [t] = useTranslation(); + const { enqueueSnackbar } = useSnackbar(); + const [openConfirmationModal, setOpenConfirmationModal] = + useState(false); + const [jobIdKill, setJobIdKill] = useState(); + + const openConfirmModal = (jobId: string) => { + setOpenConfirmationModal(true); + setJobIdKill(jobId); + }; + + const killTask = (jobId: string) => { + (async () => { + try { + await killStudy(jobId); + } catch (e) { + enqueueErrorSnackbar( + enqueueSnackbar, + t("singlestudy:failtokilltask"), + e as AxiosError + ); + } + setOpenConfirmationModal(false); + })(); + }; + + const copyId = (jobId: string): void => { + try { + navigator.clipboard.writeText(jobId); + enqueueSnackbar(t("singlestudy:onJobIdCopySucces"), { + variant: "success", + }); + } catch (e) { + enqueueErrorSnackbar( + enqueueSnackbar, + t("singlestudy:onJobIdCopyError"), + e as AxiosError + ); + } + }; + + return ( + 0 ? "flex-start" : "center", + alignItems: jobs.length > 0 ? "flex-start" : "center", + overflowX: "hidden", + overflowY: "auto", + ...scrollbarStyle, + }} + > + } + sx={{ width: "100%", px: 2, boxSizing: "border-box" }} + > + {jobs.map((job, index) => ( + + + QontoStepIcon({ className, status: job.status }) + } + sx={{ + display: "flex", + justifyContent: "flex-start", + alignItems: "flex-start", + mt: 1, + }} + > + + + + {moment(convertUTCToLocalTime(job.creationDate)).format( + "ddd, MMM D YYYY, HH:mm:ss" + )} + {job.completionDate && + ` => ${moment( + convertUTCToLocalTime(job.completionDate) + ).format("ddd, MMM D YYYY, HH:mm:ss")}`} + + + + {job.outputId} + + + + copyId(job.id)} + sx={{ + m: 0.5, + height: "22px", + cursor: "pointer", + "&:hover": { + color: "action.hover", + }, + }} + /> + + + {job.status && ( + + + openConfirmModal(job.id)} + sx={{ + m: 0.5, + height: "22px", + cursor: "pointer", + color: "error.light", + "&:hover": { color: "error.dark" }, + }} + /> + + + )} + + + + + ))} + + {openConfirmationModal && ( + killTask(jobIdKill as string)} + handleNo={() => setOpenConfirmationModal(false)} + /> + )} + + ); +} diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/index.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/index.tsx new file mode 100644 index 0000000000..2d5e353376 --- /dev/null +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/index.tsx @@ -0,0 +1,164 @@ +import { Box, Paper, styled, Typography } from "@mui/material"; +import { useSnackbar } from "notistack"; +import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect, ConnectedProps } from "react-redux"; +import { AxiosError } from "axios"; +import HistoryIcon from "@mui/icons-material/History"; +import { + LaunchJob, + LaunchJobDTO, + StudyMetadata, + WSEvent, + WSMessage, +} from "../../../../../common/types"; +import { + getStudyJobs, + mapLaunchJobDTO, +} from "../../../../../services/api/study"; +import { + addListener, + removeListener, + subscribe, + unsubscribe, + WsChannel, +} from "../../../../../store/websockets"; +import JobStepper from "./JobStepper"; +import enqueueErrorSnackbar from "../../../../common/ErrorSnackBar"; + +const TitleHeader = styled(Box)(({ theme }) => ({ + display: "flex", + justifyContent: "flex-start", + alignItems: "center", + width: "100%", + height: "60px", +})); + +interface OwnTypes { + // eslint-disable-next-line react/no-unused-prop-types + study: StudyMetadata | undefined; +} + +const mapState = () => ({}); + +const mapDispatch = { + subscribeChannel: subscribe, + unsubscribeChannel: unsubscribe, + addWsListener: addListener, + removeWsListener: removeListener, +}; + +const connector = connect(mapState, mapDispatch); +type ReduxProps = ConnectedProps; +type PropTypes = ReduxProps & OwnTypes; + +function LauncherHistory(props: PropTypes) { + const { + study, + addWsListener, + removeWsListener, + subscribeChannel, + unsubscribeChannel, + } = props; + const [t] = useTranslation(); + const [studyJobs, setStudyJobs] = useState>([]); + const { enqueueSnackbar } = useSnackbar(); + + const handleEvents = useCallback( + (msg: WSMessage): void => { + if (study === undefined) return; + if (msg.type === WSEvent.STUDY_JOB_STARTED) { + const newJob = mapLaunchJobDTO(msg.payload as LaunchJobDTO); + if (newJob.studyId === study.id) { + const existingJobs = studyJobs || []; + setStudyJobs([newJob].concat(existingJobs)); + } + } else if ( + msg.type === WSEvent.STUDY_JOB_STATUS_UPDATE || + msg.type === WSEvent.STUDY_JOB_COMPLETED + ) { + const newJob = mapLaunchJobDTO(msg.payload as LaunchJobDTO); + if (newJob.studyId === study.id) { + const existingJobs = studyJobs || []; + if (!existingJobs.find((j) => j.id === newJob.id)) { + setStudyJobs([newJob].concat(existingJobs)); + } else { + setStudyJobs( + existingJobs.map((j) => { + if (j.id === newJob.id) { + return newJob; + } + return j; + }) + ); + } + } + } else if (msg.type === WSEvent.STUDY_JOB_LOG_UPDATE) { + // TODO + } else if (msg.type === WSEvent.STUDY_EDITED) { + // TODO + } + }, + [study, studyJobs] + ); + + useEffect(() => { + if (study) { + const fetchStudyJob = async (sid: string) => { + try { + const data = await getStudyJobs(sid); + setStudyJobs(data.reverse()); + } catch (e) { + enqueueErrorSnackbar( + enqueueSnackbar, + t("jobs:failedtoretrievejobs"), + e as AxiosError + ); + } + }; + fetchStudyJob(study.id); + } + }, [study, t, enqueueSnackbar]); + + useEffect(() => { + addWsListener(handleEvents); + return () => removeWsListener(handleEvents); + }, [addWsListener, removeWsListener, handleEvents]); + + useEffect(() => { + studyJobs.forEach((job) => { + subscribeChannel(WsChannel.JOB_STATUS + job.id); + }); + return () => { + studyJobs.forEach((job) => { + unsubscribeChannel(WsChannel.JOB_STATUS + job.id); + }); + }; + }, [studyJobs, subscribeChannel, unsubscribeChannel]); + + return ( + + + + {t("main:jobs")} + + + + ); +} + +export default connector(LauncherHistory); diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx index 0a33949150..6f4f814ac6 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx @@ -44,6 +44,7 @@ const NoteHeader = styled(Box)(({ theme }) => ({ alignItems: "center", width: "100%", height: "60px", + boxSizing: "border-box", })); const NoteFooter = NoteHeader; diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/index.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/index.tsx index dcebf6adac..accd5e19fa 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/InformationView/index.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/index.tsx @@ -37,7 +37,7 @@ function InformationView(props: Props) { > Date: Thu, 21 Apr 2022 16:55:10 +0200 Subject: [PATCH 09/43] Allow using task for json output download (#833) --- antarest/study/service.py | 147 +++++++++++------- .../study/storage/study_download_utils.py | 117 ++++++++------ tests/storage/test_service.py | 3 - 3 files changed, 157 insertions(+), 110 deletions(-) diff --git a/antarest/study/service.py b/antarest/study/service.py index 097361ac31..e0e28c1742 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -48,6 +48,7 @@ TaskUpdateNotifier, noop_notifier, ) +from antarest.core.utils.utils import StopWatch from antarest.login.model import Group from antarest.login.service import LoginService from antarest.matrixstore.business.matrix_editor import Operation, MatrixSlice @@ -984,69 +985,99 @@ def download_outputs( params.get_user_id(), ) - matrix = StudyDownloader.build( - self.storage_service.get_storage(study).get_raw(study), - output_id, - data, - ) + if use_task: + logger.info(f"Exporting {output_id} from study {study_id}") + export_name = ( + f"Study filtered output {study.name}/{output_id} export" + ) + export_file_download = self.file_transfer_manager.request_download( + f"{study.name}-{study_id}-{output_id}_filtered.{'tar.gz' if filetype == ExportFormat.TAR_GZ else 'zip'}", + export_name, + params.user, + ) + export_path = Path(export_file_download.path) + export_id = export_file_download.id - if filetype != ExportFormat.JSON: - if use_task: - logger.info(f"Exporting {output_id} from study {study_id}") - export_name = ( - f"Study filtered output {study.name}/{output_id} export" - ) - export_file_download = self.file_transfer_manager.request_download( - f"{study.name}-{study_id}-{output_id}_filtered.{'tar.gz' if filetype == ExportFormat.TAR_GZ else 'zip'}", - export_name, - params.user, - ) - export_path = Path(export_file_download.path) - export_id = export_file_download.id - - def export_task(notifier: TaskUpdateNotifier) -> TaskResult: - try: - StudyDownloader.export(matrix, filetype, export_path) - self.file_transfer_manager.set_ready(export_id) - return TaskResult( - success=True, - message=f"Study filtered output {study_id}/{output_id} successfully exported", + def export_task(notifier: TaskUpdateNotifier) -> TaskResult: + try: + study = self.get_study(study_id) + stopwatch = StopWatch() + matrix = StudyDownloader.build( + self.storage_service.get_storage(study).get_raw(study), + output_id, + data, + ) + stopwatch.log_elapsed( + lambda x: logger.info( + f"Study {study_id} filtered output {output_id} built in {x}s" ) - except Exception as e: - self.file_transfer_manager.fail(export_id, str(e)) - raise e - - task_id = self.task_service.add_task( - export_task, - export_name, - task_type=TaskType.EXPORT, - ref_id=study.id, - custom_event_messages=None, - request_params=params, - ) + ) + StudyDownloader.export(matrix, filetype, export_path) + stopwatch.log_elapsed( + lambda x: logger.info( + f"Study {study_id} filtered output {output_id} exported in {x}s" + ) + ) + self.file_transfer_manager.set_ready(export_id) + return TaskResult( + success=True, + message=f"Study filtered output {study_id}/{output_id} successfully exported", + ) + except Exception as e: + self.file_transfer_manager.fail(export_id, str(e)) + raise e + + task_id = self.task_service.add_task( + export_task, + export_name, + task_type=TaskType.EXPORT, + ref_id=study.id, + custom_event_messages=None, + request_params=params, + ) - return FileDownloadTaskDTO( - file=export_file_download.to_dto(), task=task_id + return FileDownloadTaskDTO( + file=export_file_download.to_dto(), task=task_id + ) + else: + stopwatch = StopWatch() + matrix = StudyDownloader.build( + self.storage_service.get_storage(study).get_raw(study), + output_id, + data, + ) + stopwatch.log_elapsed( + lambda x: logger.info( + f"Study {study_id} filtered output {output_id} built in {x}s" ) - else: - if tmp_export_file is not None: - StudyDownloader.export(matrix, filetype, tmp_export_file) - return FileResponse( - tmp_export_file, - headers={ - "Content-Disposition": f'attachment; filename="output-{output_id}.{"tar.gz" if filetype == ExportFormat.TAR_GZ else "zip"}' - }, - media_type=filetype, + ) + if tmp_export_file is not None: + StudyDownloader.export(matrix, filetype, tmp_export_file) + stopwatch.log_elapsed( + lambda x: logger.info( + f"Study {study_id} filtered output {output_id} exported in {x}s" ) - - json_response = json.dumps( - matrix.dict(), - ensure_ascii=False, - allow_nan=True, - indent=None, - separators=(",", ":"), - ).encode("utf-8") - return Response(content=json_response, media_type="application/json") + ) + return FileResponse( + tmp_export_file, + headers={"Content-Disposition": "inline"} + if filetype == ExportFormat.JSON + else { + "Content-Disposition": f'attachment; filename="output-{output_id}.{"tar.gz" if filetype == ExportFormat.TAR_GZ else "zip"}' + }, + media_type=filetype, + ) + else: + json_response = json.dumps( + matrix.dict(), + ensure_ascii=False, + allow_nan=True, + indent=None, + separators=(",", ":"), + ).encode("utf-8") + return Response( + content=json_response, media_type="application/json" + ) def get_study_sim_result( self, study_id: str, params: RequestParameters diff --git a/antarest/study/storage/study_download_utils.py b/antarest/study/storage/study_download_utils.py index 1b707581ac..1bc1a9f901 100644 --- a/antarest/study/storage/study_download_utils.py +++ b/antarest/study/storage/study_download_utils.py @@ -1,4 +1,5 @@ import csv +import json import logging import os import re @@ -373,57 +374,75 @@ def export( filetype: ExportFormat, target_file: Path, ) -> None: - # 1- Zip/tar+gz container - with ( - ZipFile(target_file, "w", ZIP_DEFLATED) # type: ignore - if filetype == ExportFormat.ZIP - else tarfile.open(target_file, mode="w:gz") - ) as output_data: - - # 2 - Create CSV files - for ts_data in matrix.data: - output = StringIO() - writer = csv.writer(output, quoting=csv.QUOTE_NONE) - nb_rows, csv_titles = StudyDownloader.export_infos( - ts_data.data + if filetype == ExportFormat.JSON: + # 1- JSON + with open(target_file, "w") as fh: + json.dump( + matrix.dict(), + fh, + ensure_ascii=False, + allow_nan=True, + indent=None, + separators=(",", ":"), ) - if nb_rows == -1: - raise Exception( - f"Outputs export: No rows for {ts_data.name} csv" + else: + # 1- Zip/tar+gz container + with ( + ZipFile(target_file, "w", ZIP_DEFLATED) # type: ignore + if filetype == ExportFormat.ZIP + else tarfile.open(target_file, mode="w:gz") + ) as output_data: + + # 2 - Create CSV files + for ts_data in matrix.data: + output = StringIO() + writer = csv.writer(output, quoting=csv.QUOTE_NONE) + nb_rows, csv_titles = StudyDownloader.export_infos( + ts_data.data ) - writer.writerow(csv_titles) - row_date = datetime.strptime( - matrix.index.start_date, "%Y-%m-%d %H:%M:%S" - ) - for year in ts_data.data: - for i in range(0, nb_rows): - columns = ts_data.data[year] - csv_row: List[Optional[Union[int, float, str]]] = [ - str(row_date), - int(year), - ] - csv_row.extend( - [column_data.data[i] for column_data in columns] + if nb_rows == -1: + raise Exception( + f"Outputs export: No rows for {ts_data.name} csv" ) - writer.writerow(csv_row) - if ( - matrix.index.level == StudyDownloadLevelDTO.WEEKLY - and i == 0 - ): - row_date = row_date + timedelta( - days=matrix.index.first_week_size + writer.writerow(csv_titles) + row_date = datetime.strptime( + matrix.index.start_date, "%Y-%m-%d %H:%M:%S" + ) + for year in ts_data.data: + for i in range(0, nb_rows): + columns = ts_data.data[year] + csv_row: List[Optional[Union[int, float, str]]] = [ + str(row_date), + int(year), + ] + csv_row.extend( + [ + column_data.data[i] + for column_data in columns + ] ) - else: - row_date = matrix.index.level.inc_date(row_date) + writer.writerow(csv_row) + if ( + matrix.index.level + == StudyDownloadLevelDTO.WEEKLY + and i == 0 + ): + row_date = row_date + timedelta( + days=matrix.index.first_week_size + ) + else: + row_date = matrix.index.level.inc_date( + row_date + ) - bytes_data = str.encode(output.getvalue(), "utf-8") - if isinstance(output_data, ZipFile): - output_data.writestr(f"{ts_data.name}.csv", bytes_data) - else: - data_file = BytesIO(bytes_data) - data_file.seek(0, os.SEEK_END) - file_size = data_file.tell() - data_file.seek(0) - info = tarfile.TarInfo(name=f"{ts_data.name}.csv") - info.size = file_size - output_data.addfile(tarinfo=info, fileobj=data_file) + bytes_data = str.encode(output.getvalue(), "utf-8") + if isinstance(output_data, ZipFile): + output_data.writestr(f"{ts_data.name}.csv", bytes_data) + else: + data_file = BytesIO(bytes_data) + data_file.seek(0, os.SEEK_END) + file_size = data_file.tell() + data_file.seek(0) + info = tarfile.TarInfo(name=f"{ts_data.name}.csv") + info.size = file_size + output_data.addfile(tarinfo=info, fileobj=data_file) diff --git a/tests/storage/test_service.py b/tests/storage/test_service.py index 5f642a01a8..161408b638 100644 --- a/tests/storage/test_service.py +++ b/tests/storage/test_service.py @@ -603,9 +603,6 @@ def test_download_output() -> None: res_study_details, output_config, res_study, - res_study_details, - output_config, - res_study, output_config, res_study, res_study_details, From b0929f017b3a2602e03a5d2916c9d415d61f0172 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Fri, 22 Apr 2022 12:12:18 +0200 Subject: [PATCH 10/43] issue 798 settings base (#837) --- antarest/login/service.py | 40 ++++-- antarest/login/web.py | 9 +- tests/login/test_web.py | 3 +- webapp_v2/.eslintrc.json | 11 +- webapp_v2/.husky/pre-push | 4 - webapp_v2/package.json | 28 ++-- webapp_v2/src/common/types.ts | 61 +++------ .../src/components/common/ErrorSnackBar.tsx | 27 ---- .../components/common/SnackErrorMessage.tsx | 68 ++++++---- .../components/common/dialogs/BasicDialog.tsx | 126 ++++++++++++++++++ .../common/dialogs/ConfirmationDialog.tsx | 67 ++++++++++ .../components/common/dialogs/FormDialog.tsx | 77 +++++++++++ .../src/components/common/page/BasicPage.tsx | 45 ++----- .../src/components/common/page/RootPage.tsx | 63 +++++---- .../InformationView/CreateVariantModal.tsx | 4 +- .../LauncherHistory/JobStepper.tsx | 15 +-- .../InformationView/LauncherHistory/index.tsx | 13 +- .../HomeView/InformationView/Notes/index.tsx | 12 +- .../src/components/singlestudy/NavHeader.tsx | 8 +- .../singlestudy/PropertiesModal.tsx | 6 +- .../explore/Modelization/Map/index.tsx | 25 +--- .../components/studies/CreateStudyModal.tsx | 4 +- .../src/components/studies/HeaderBottom.tsx | 1 + .../{HeaderRight.tsx => HeaderTopRight.tsx} | 2 +- .../src/components/studies/LauncherModal.tsx | 9 +- .../src/components/studies/StudiesList.tsx | 14 +- .../src/components/studies/StudyCard.tsx | 4 +- .../src/components/studies/StudyTree.tsx | 11 +- .../src/components/tasks/LaunchJobLogView.tsx | 11 +- .../src/hooks/useEnqueueErrorSnackbar.tsx | 38 ++++++ webapp_v2/src/hooks/usePromise.ts | 57 ++++++++ .../src/hooks/usePromiseWithSnackbarError.ts | 28 ++++ webapp_v2/src/pages/Settings.tsx | 51 ++++++- webapp_v2/src/pages/Studies/index.tsx | 10 +- webapp_v2/src/pages/Tasks.tsx | 21 +-- .../MaintenanceWrapper/MessageInfoModal.tsx | 13 +- webapp_v2/src/services/api/user.ts | 58 ++++---- webapp_v2/src/services/utils/index.ts | 11 -- webapp_v2/src/store/reducers.ts | 3 +- webapp_v2/tsconfig.dev.json | 6 + webapp_v2/tsconfig.json | 2 +- 41 files changed, 710 insertions(+), 356 deletions(-) delete mode 100755 webapp_v2/.husky/pre-push delete mode 100644 webapp_v2/src/components/common/ErrorSnackBar.tsx create mode 100644 webapp_v2/src/components/common/dialogs/BasicDialog.tsx create mode 100644 webapp_v2/src/components/common/dialogs/ConfirmationDialog.tsx create mode 100644 webapp_v2/src/components/common/dialogs/FormDialog.tsx rename webapp_v2/src/components/studies/{HeaderRight.tsx => HeaderTopRight.tsx} (97%) create mode 100644 webapp_v2/src/hooks/useEnqueueErrorSnackbar.tsx create mode 100644 webapp_v2/src/hooks/usePromise.ts create mode 100644 webapp_v2/src/hooks/usePromiseWithSnackbarError.ts create mode 100644 webapp_v2/tsconfig.dev.json diff --git a/antarest/login/service.py b/antarest/login/service.py index 62ca42c329..6dc72c823a 100644 --- a/antarest/login/service.py +++ b/antarest/login/service.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, List +from typing import Optional, List, Union from fastapi import HTTPException @@ -27,6 +27,7 @@ UserGroup, UserRoleDTO, GroupDTO, + UserInfo, ) from antarest.login.repository import ( UserRepository, @@ -600,17 +601,21 @@ def _get_user_by_group(self, group: str) -> List[Identity]: user_list.append(user) return user_list - def get_all_users(self, params: RequestParameters) -> List[Identity]: + def get_all_users( + self, params: RequestParameters, details: Optional[bool] = False + ) -> List[Union[UserInfo, IdentityDTO]]: """ Get all users. Permission: SADMIN Args: params: request parameters + details: get all user information, including roles Returns: list of groups """ if params.user: + user_list = [] roles = self.roles.get_all_by_user(params.user.id) groups = [r.group for r in roles] if any( @@ -619,18 +624,29 @@ def get_all_users(self, params: RequestParameters) -> List[Identity]: params.user.is_group_admin(groups), ) ): - return self.ldap.get_all() + self.users.get_all() + user_list = self.ldap.get_all() + self.users.get_all() + else: + for group in groups: + user_list.extend( + [ + usr + for usr in self._get_user_by_group(group.id) + if usr not in user_list + ] + ) - user_list = [] - for group in groups: - user_list.extend( - [ - usr - for usr in self._get_user_by_group(group.id) - if usr not in user_list + return ( + [ + usr + for usr in [ + self.get_user_info(user.id, params) + for user in user_list ] - ) - return user_list + if usr is not None + ] + if details + else [user.to_dto() for user in user_list] + ) else: logger.error( "user %s has not permission to get all users", diff --git a/antarest/login/web.py b/antarest/login/web.py index fa5a05e540..a1ea4513bc 100644 --- a/antarest/login/web.py +++ b/antarest/login/web.py @@ -114,13 +114,18 @@ def refresh(jwt_manager: AuthJWT = Depends()) -> Any: else: raise HTTPException(status_code=403, detail="Token invalid") - @bp.get("/users", tags=[APITag.users], response_model=List[UserInfo]) + @bp.get( + "/users", + tags=[APITag.users], + response_model=List[Union[IdentityDTO, UserInfo]], + ) def users_get_all( + details: Optional[bool] = False, current_user: JWTUser = Depends(auth.get_current_user), ) -> Any: logger.info(f"Fetching users list", extra={"user": current_user.id}) params = RequestParameters(user=current_user) - return [u.to_dto() for u in service.get_all_users(params)] + return service.get_all_users(params, details) @bp.get( "/users/{id}", diff --git a/tests/login/test_web.py b/tests/login/test_web.py index eb85b9324e..3284591f8f 100644 --- a/tests/login/test_web.py +++ b/tests/login/test_web.py @@ -26,6 +26,7 @@ IdentityDTO, BotRoleCreateDTO, RoleDetailDTO, + UserInfo, ) from antarest.main import JwtSettings @@ -181,7 +182,7 @@ def test_refresh() -> None: @pytest.mark.unit_test def test_user() -> None: service = Mock() - service.get_all_users.return_value = [User(id=1, name="user")] + service.get_all_users.return_value = [UserInfo(id=1, name="user")] app = create_app(service) client = TestClient(app) diff --git a/webapp_v2/.eslintrc.json b/webapp_v2/.eslintrc.json index 0cd72f563a..0dde3624ac 100644 --- a/webapp_v2/.eslintrc.json +++ b/webapp_v2/.eslintrc.json @@ -43,6 +43,8 @@ "ignoreRestSiblings": true } ], + "consistent-return": "off", + "default-case": "off", "import/extensions": [ "error", "ignorePackages", @@ -53,6 +55,7 @@ "tsx": "never" } ], + "import/prefer-default-export": "off", "no-shadow": "off", "no-unused-vars": "off", "no-use-before-define": ["error", { "functions": false }], @@ -60,7 +63,12 @@ "react/react-in-jsx-scope": "off", "react/jsx-props-no-spreading": "off", "react/jsx-uses-react": "off", - "react-hooks/exhaustive-deps": "warn" + "react-hooks/exhaustive-deps": "warn", + "spaced-comment": [ + "error", + "always", + { "line": { "exceptions": ["/"], "markers": ["/"] } } + ] }, "overrides": [ { @@ -72,6 +80,7 @@ } ], "settings": { + "import/core-modules": ["ts-essentials"], "import/resolver": { "node": { "extensions": [".js", ".jsx", ".ts", ".tsx"] diff --git a/webapp_v2/.husky/pre-push b/webapp_v2/.husky/pre-push deleted file mode 100755 index 7c0d695b56..0000000000 --- a/webapp_v2/.husky/pre-push +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" -cd webapp_v2 -npm run lint diff --git a/webapp_v2/package.json b/webapp_v2/package.json index 228282b165..39c718f41a 100644 --- a/webapp_v2/package.json +++ b/webapp_v2/package.json @@ -5,6 +5,7 @@ "dependencies": { "@emotion/react": "11.9.0", "@emotion/styled": "11.8.1", + "@hookform/resolvers": "2.8.8", "@mui/icons-material": "5.6.0", "@mui/lab": "5.0.0-alpha.76", "@mui/material": "5.6.0", @@ -12,15 +13,15 @@ "@testing-library/react": "13.0.0", "@testing-library/user-event": "14.0.4", "@types/d3": "5.16.4", - "@types/draft-convert": "^2.1.4", - "@types/draft-js": "^0.11.9", - "@types/draftjs-to-html": "^0.8.1", + "@types/draft-convert": "2.1.4", + "@types/draft-js": "0.11.9", + "@types/draftjs-to-html": "0.8.1", "@types/jest": "27.4.1", "@types/node": "16.11.20", "@types/react": "17.0.38", "@types/react-d3-graph": "2.6.3", "@types/react-dom": "17.0.11", - "@types/xml-js": "^1.0.0", + "@types/xml-js": "1.0.0", "assert": "2.0.0", "axios": "0.26.1", "buffer": "6.0.3", @@ -28,14 +29,15 @@ "d3": "5.16.0", "debug": "4.3.4", "downshift": "6.1.7", - "draft-convert": "^2.1.12", - "draft-js": "^0.11.7", - "draftjs-to-html": "^0.9.1", + "draft-convert": "2.1.12", + "draft-js": "0.11.7", + "draftjs-to-html": "0.9.1", "fs": "0.0.1-security", "https-browserify": "1.0.0", "i18next": "21.6.14", "i18next-browser-languagedetector": "6.1.4", "i18next-xhr-backend": "3.2.2", + "immer": "9.0.12", "js-cookie": "3.0.1", "jwt-decode": "3.1.2", "lodash": "4.17.21", @@ -43,6 +45,8 @@ "notistack": "2.0.3", "os": "0.1.2", "os-browserify": "0.3.0", + "ramda": "0.28.0", + "ramda-adjunct": "3.0.0", "react": "17.0.2", "react-color": "2.19.3", "react-d3-graph": "2.6.0", @@ -55,26 +59,26 @@ "react-scripts": "5.0.0", "react-split": "2.0.14", "react-tsparticles": "1.43.1", + "react-use": "17.3.2", "react-virtualized-auto-sizer": "1.0.6", "react-window": "1.8.6", "redux": "4.1.2", "redux-devtools-extension": "2.13.9", - "redux-logger": "3.0.6", "redux-thunk": "2.4.1", "stream-http": "3.2.0", "swagger-ui-react": "4.10.3", "url": "0.11.0", "uuid": "8.3.2", "web-vitals": "2.1.4", - "xml-js": "^1.6.11" + "xml-js": "1.6.11", + "yup": "0.32.11" }, "scripts": { "start": "react-app-rewired start", "build": "react-app-rewired build", "lint": "eslint . --ext .ts,.tsx,.js,.jsx", "test": "react-app-rewired test", - "eject": "react-app-rewired eject", - "prepare": "cd .. && husky install webapp_v2/.husky" + "eject": "react-app-rewired eject" }, "browserslist": { "production": [ @@ -94,6 +98,7 @@ "@types/debug": "4.1.7", "@types/js-cookie": "3.0.1", "@types/lodash": "4.14.181", + "@types/ramda": "0.28.9", "@types/react-color": "3.0.6", "@types/react-virtualized-auto-sizer": "1.0.1", "@types/react-window": "1.8.5", @@ -115,6 +120,7 @@ "process": "0.11.10", "react-app-rewired": "2.2.1", "stream-browserify": "3.0.0", + "ts-essentials": "9.1.2", "typescript": "4.6.3" } } diff --git a/webapp_v2/src/common/types.ts b/webapp_v2/src/common/types.ts index 38c9d78bea..ec751b328b 100644 --- a/webapp_v2/src/common/types.ts +++ b/webapp_v2/src/common/types.ts @@ -4,6 +4,11 @@ import { ReactNode } from "react"; export type IDType = number | string; +export interface IdentityDTO { + id: T; + name: string; +} + export enum SortElement { DATE = "DATE", NAME = "NAME", @@ -41,10 +46,8 @@ export interface StudyMetadataOwner { export type StudyType = "variantstudy" | "rawstudy"; -export interface StudyMetadataDTO { - id: string; +export interface StudyMetadataDTO extends IdentityDTO { owner: StudyMetadataOwner; - name: string; type: StudyType; created: string; updated: string; @@ -52,7 +55,7 @@ export interface StudyMetadataDTO { workspace: string; managed: boolean; archived: boolean; - groups: Array<{ id: string; name: string }>; + groups: Array; public_mode: StudyPublicMode; folder?: string; horizon?: string; @@ -154,21 +157,17 @@ export interface RoleCreationDTO { type: RoleType; } -export interface UserDTO { - id: number; - name: string; +export type UserDTO = IdentityDTO; + +export interface UserDetailsDTO extends UserDTO { + roles: Array; } -export interface UserRoleDTO { - id: number; - name: string; +export interface UserRoleDTO extends IdentityDTO { role: RoleType; } -export interface GroupDTO { - id: string; - name: string; -} +export type GroupDTO = IdentityDTO; export interface JWTGroup { id: string; @@ -186,22 +185,7 @@ export interface UserInfo { refreshToken: string; expirationDate?: Moment; } - -export interface Identity { - id: number; - name: string; - type: string; -} - -export interface IdentityDTO { - id: number; - name: string; - roles: Array; -} - -export interface BotDTO { - id: number; - name: string; +export interface BotDTO extends IdentityDTO { owner: number; isAuthor: boolean; } @@ -217,9 +201,7 @@ export interface BotCreateDTO { roles: Array; } -export interface BotIdentityDTO { - id: number; - name: string; +export interface BotDetailsDTO extends IdentityDTO { isAuthor: boolean; roles: Array; } @@ -240,14 +222,9 @@ export interface MatrixType { data: Array>; } -export interface MatrixInfoDTO { - id: string; - name: string; -} +export type MatrixInfoDTO = IdentityDTO; -export interface MatrixDataSetDTO { - id: string; - name: string; +export interface MatrixDataSetDTO extends IdentityDTO { public: boolean; groups: Array; matrices: Array; @@ -266,13 +243,13 @@ export interface MatrixDataSetUpdateDTO { } export interface MatrixDTO { + id: string; width: number; height: number; index: Array; columns: Array; data: Array>; created_at: number; - id: string; } export interface CommandDTO { @@ -367,7 +344,7 @@ export enum TaskType { UNKNOWN = "UNKNOWN", } -export interface TaskDTO { +export interface TaskDTO extends IdentityDTO { id: string; name: string; owner?: number; diff --git a/webapp_v2/src/components/common/ErrorSnackBar.tsx b/webapp_v2/src/components/common/ErrorSnackBar.tsx deleted file mode 100644 index cf479c0d29..0000000000 --- a/webapp_v2/src/components/common/ErrorSnackBar.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { AxiosError } from "axios"; -import { OptionsObject, SnackbarKey, SnackbarMessage } from "notistack"; -import SnackErrorMessage from "./SnackErrorMessage"; - -export type SnackbarDetails = { - status: string; - description: string; - exception: string; -}; - -const enqueueErrorSnackbar = ( - enqueueSnackbar: ( - message: SnackbarMessage, - options?: OptionsObject | undefined - ) => SnackbarKey, - message: SnackbarMessage, - details: AxiosError -) => - enqueueSnackbar(message, { - variant: "error", - persist: true, - content: (key, msg) => ( - - ), - }); - -export default enqueueErrorSnackbar; diff --git a/webapp_v2/src/components/common/SnackErrorMessage.tsx b/webapp_v2/src/components/common/SnackErrorMessage.tsx index ffcfd9650f..cd1bf936a2 100644 --- a/webapp_v2/src/components/common/SnackErrorMessage.tsx +++ b/webapp_v2/src/components/common/SnackErrorMessage.tsx @@ -2,7 +2,7 @@ import { useState, forwardRef, useCallback } from "react"; import * as React from "react"; import { useSnackbar, SnackbarContent } from "notistack"; -import { AxiosError } from "axios"; +import axios, { AxiosError } from "axios"; import { Box, Card, @@ -17,6 +17,7 @@ import { import CancelRoundedIcon from "@mui/icons-material/CancelRounded"; import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import * as R from "ramda"; const Snackbar = styled(SnackbarContent)(({ theme }) => ({ [theme.breakpoints.up("sm")]: { @@ -43,7 +44,7 @@ export const ExpandButton = styled(IconButton, { interface Props { id: string | number; message: string | React.ReactNode; - details: AxiosError; + details: string | Error; } const SnackErrorMessage = forwardRef( @@ -93,32 +94,43 @@ const SnackErrorMessage = forwardRef( - {details.response !== undefined && ( - - - - - - {details.response.status} - - - - {details.response.data.exception} - - - - - {details.response.data.description} - - - - - - )} + + + {R.cond([ + [ + axios.isAxiosError, + () => { + const err = details as AxiosError; + return ( + + + + {err.response?.status} + + + + + {err.response?.data.exception} + + + + + + {err.response?.data.description} + + + + ); + }, + ], + [R.T, () => <>{details.toString()}], + ])(details)} + + ); diff --git a/webapp_v2/src/components/common/dialogs/BasicDialog.tsx b/webapp_v2/src/components/common/dialogs/BasicDialog.tsx new file mode 100644 index 0000000000..13392b205f --- /dev/null +++ b/webapp_v2/src/components/common/dialogs/BasicDialog.tsx @@ -0,0 +1,126 @@ +import { + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogProps, + DialogTitle, + IconButton, + styled, + experimental_sx as sx, +} from "@mui/material"; +import { ElementType, ReactNode } from "react"; +import * as RA from "ramda-adjunct"; +import { SvgIconComponent } from "@mui/icons-material"; +import CloseIcon from "@mui/icons-material/Close"; +import * as R from "ramda"; + +/** + * Types + */ + +enum Alert { + "success", + "error", + "warning", + "info", +} + +export interface BasicDialogProps extends DialogProps { + open: boolean; + title?: string; + titleIcon?: ElementType; + actions?: ReactNode; + alert?: keyof typeof Alert; + noCloseIcon?: boolean; +} + +/** + * Styled + */ + +const AlertBorder = styled("span", { + shouldForwardProp: (prop: string) => !prop.startsWith("$"), +})<{ $type: keyof typeof Alert }>(({ $type }) => + sx({ + position: "absolute", + top: 0, + width: 1, + borderTop: 4, + borderColor: R.cond([ + [R.equals(Alert.success), () => "success.main"], + [R.equals(Alert.error), () => "error.main"], + [R.equals(Alert.warning), () => "warning.main"], + [R.equals(Alert.info), () => "info.main"], + ])(Alert[$type]), + }) +); + +/** + * Component + */ + +function BasicDialog(props: BasicDialogProps) { + const { + title, + titleIcon, + children, + actions, + alert, + noCloseIcon, + ...dialogProps + } = props; + const { onClose } = dialogProps; + const TitleIcon = titleIcon as SvgIconComponent; + + return ( + + {alert && } + {(title || TitleIcon || onClose) && ( + + {TitleIcon && ( + + )} + {title} + {onClose && !noCloseIcon && ( + void} + sx={{ + position: "absolute", + right: 8, + top: 8, + color: (theme) => theme.palette.grey[500], + }} + > + + + )} + + )} + {RA.isString(children) ? ( + + {children} + + ) : ( + children + )} + {actions && {actions}} + + ); +} + +BasicDialog.defaultProps = { + title: null, + titleIcon: null, + actions: null, + alert: false, + noCloseIcon: false, +}; + +export default BasicDialog; diff --git a/webapp_v2/src/components/common/dialogs/ConfirmationDialog.tsx b/webapp_v2/src/components/common/dialogs/ConfirmationDialog.tsx new file mode 100644 index 0000000000..5f21c8baa7 --- /dev/null +++ b/webapp_v2/src/components/common/dialogs/ConfirmationDialog.tsx @@ -0,0 +1,67 @@ +import { Button, ButtonProps } from "@mui/material"; +import { MouseEventHandler } from "react"; +import { useTranslation } from "react-i18next"; +import * as RA from "ramda-adjunct"; +import BasicDialog, { BasicDialogProps } from "./BasicDialog"; + +/** + * Types + */ + +export interface ConfirmationDialogProps + extends Omit { + cancelButtonText?: string; + confirmButtonText?: string; + cancelButtonProps?: Omit; + confirmButtonProps?: Omit; + onConfirm: MouseEventHandler; + onCancel: MouseEventHandler; +} + +/** + * Component + */ + +function ConfirmationDialog(props: ConfirmationDialogProps) { + const { + title, + cancelButtonText, + confirmButtonText, + cancelButtonProps, + confirmButtonProps, + onConfirm, + onCancel, + ...basicDialogProps + } = props; + + const { t } = useTranslation(); + + return ( + + + + + } + /> + ); +} + +ConfirmationDialog.defaultProps = { + cancelButtonText: null, + confirmButtonText: null, + cancelButtonProps: null, + confirmButtonProps: null, +}; + +export default ConfirmationDialog; diff --git a/webapp_v2/src/components/common/dialogs/FormDialog.tsx b/webapp_v2/src/components/common/dialogs/FormDialog.tsx new file mode 100644 index 0000000000..526efd5f76 --- /dev/null +++ b/webapp_v2/src/components/common/dialogs/FormDialog.tsx @@ -0,0 +1,77 @@ +import { Backdrop, Box, CircularProgress } from "@mui/material"; +import { ReactNode } from "react"; +import { FieldValues, SubmitHandler, useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { Head } from "ts-essentials"; +import ConfirmationDialog, { + ConfirmationDialogProps, +} from "./ConfirmationDialog"; + +/** + * Types + */ + +export type FormObj = Omit, "handleSubmit">; + +export interface FormDialogProps + extends Omit { + formOptions?: Head>; + onSubmit: SubmitHandler; + children: (formObj: FormObj) => ReactNode; +} + +/** + * Component + */ + +function FormDialog(props: FormDialogProps) { + const { formOptions, onSubmit, children, ...dialogProps } = props; + const { handleSubmit, ...formObj } = useForm(formOptions); + const { t } = useTranslation(); + const { + formState: { isValid, isSubmitting }, + } = formObj; + + return ( + { + if (!isSubmitting) { + dialogProps.onClose?.(...args); + } + }} + > + + {children(formObj)} + theme.zIndex.drawer + 1, + }} + > + + + + + ); +} + +FormDialog.defaultProps = { + formOptions: null, +}; + +export default FormDialog; diff --git a/webapp_v2/src/components/common/page/BasicPage.tsx b/webapp_v2/src/components/common/page/BasicPage.tsx index 5b7e4b8961..1adec419e2 100644 --- a/webapp_v2/src/components/common/page/BasicPage.tsx +++ b/webapp_v2/src/components/common/page/BasicPage.tsx @@ -1,48 +1,30 @@ -import { Box, Divider, styled } from "@mui/material"; +import { Box, Divider } from "@mui/material"; import { PropsWithChildren, ReactNode } from "react"; -/** - * Styles - */ - -const Header = styled("div")(({ theme }) => ({ - width: "100%", - display: "flex", - flexFlow: "column nowrap", - justifyContent: "flex-start", - alignItems: "center", - padding: theme.spacing(2, 3), - boxSizing: "border-box", -})); - /** * Types */ -type PropTypes = { +interface Props { header?: ReactNode; -}; + hideHeaderDivider?: boolean; +} /** * Component */ -function BasicPage(props: PropsWithChildren) { - const { header, children } = props; +function BasicPage(props: PropsWithChildren) { + const { header, hideHeaderDivider, children } = props; return ( - - {header &&
{header}
} - + + {header && ( + + {header} + {hideHeaderDivider ? null : } + + )} {children} ); @@ -50,6 +32,7 @@ function BasicPage(props: PropsWithChildren) { BasicPage.defaultProps = { header: null, + hideHeaderDivider: false, }; export default BasicPage; diff --git a/webapp_v2/src/components/common/page/RootPage.tsx b/webapp_v2/src/components/common/page/RootPage.tsx index 8c85f3d49a..a8e2cd7f01 100644 --- a/webapp_v2/src/components/common/page/RootPage.tsx +++ b/webapp_v2/src/components/common/page/RootPage.tsx @@ -1,3 +1,4 @@ +import { SvgIconComponent } from "@mui/icons-material"; import { Box, Typography } from "@mui/material"; import { ElementType, PropsWithChildren, ReactNode } from "react"; import BasicPage from "./BasicPage"; @@ -6,33 +7,48 @@ import BasicPage from "./BasicPage"; * Types */ -type PropTypes = { +interface Props { title: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - titleIcon?: ElementType; // TODO: replace any - headerRight?: ReactNode; + titleIcon?: ElementType; + headerTopRight?: ReactNode; headerBottom?: ReactNode; -}; + hideHeaderDivider?: boolean; +} /** * Component */ -function RootPage(props: PropsWithChildren) { +function RootPage(props: PropsWithChildren) { const { title, - titleIcon: TitleIcon, - headerRight, + titleIcon, + headerTopRight, headerBottom, children, + hideHeaderDivider, } = props; + const TitleIcon = titleIcon as SvgIconComponent; + return ( - - + + {TitleIcon && ( ) { }} /> )} - - {title} - + {title} - {headerRight && ( + {headerTopRight && ( - {headerRight} + {headerTopRight} )} - {headerBottom && ( - - {headerBottom} - - )} + {headerBottom && {headerBottom}} } + hideHeaderDivider={hideHeaderDivider} > {children} @@ -72,8 +84,9 @@ function RootPage(props: PropsWithChildren) { RootPage.defaultProps = { titleIcon: null, - headerRight: null, + headerTopRight: null, headerBottom: null, + hideHeaderDivider: false, }; export default RootPage; diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/CreateVariantModal.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/CreateVariantModal.tsx index ca51146d18..215a30bb64 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/InformationView/CreateVariantModal.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/CreateVariantModal.tsx @@ -8,10 +8,10 @@ import BasicModal from "../../../common/BasicModal"; import FilledTextInput from "../../../common/FilledTextInput"; import SingleSelect from "../../../common/SelectSingle"; import { GenericInfo, VariantTree } from "../../../../common/types"; -import enqueueErrorSnackbar from "../../../common/ErrorSnackBar"; import { scrollbarStyle } from "../../../../theme"; import { createVariant } from "../../../../services/api/variant"; import { createListFromTree } from "../../../../services/utils"; +import useEnqueueErrorSnackbar from "../../../../hooks/useEnqueueErrorSnackbar"; interface Props { open: boolean; @@ -25,6 +25,7 @@ function CreateVariantModal(props: Props) { const { open, parentId, tree, onClose } = props; const navigate = useNavigate(); const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [studyName, setStudyName] = useState(""); const [versionSourceList, setVersionSourceList] = useState< Array @@ -43,7 +44,6 @@ function CreateVariantModal(props: Props) { navigate(`/studies/${newId}`); } catch (e) { enqueueErrorSnackbar( - enqueueSnackbar, t("variants:onVariantCreationError"), e as AxiosError ); diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/JobStepper.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/JobStepper.tsx index 4e649cbfb3..7162264a5f 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/JobStepper.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/JobStepper.tsx @@ -23,8 +23,8 @@ import { convertUTCToLocalTime } from "../../../../../services/utils"; import { scrollbarStyle } from "../../../../../theme"; import ConfirmationModal from "../../../../common/ConfirmationModal"; import { killStudy } from "../../../../../services/api/study"; -import enqueueErrorSnackbar from "../../../../common/ErrorSnackBar"; import LaunchJobLogView from "../../../../tasks/LaunchJobLogView"; +import useEnqueueErrorSnackbar from "../../../../../hooks/useEnqueueErrorSnackbar"; const QontoConnector = styled(StepConnector)(({ theme }) => ({ [`&.${stepConnectorClasses.disabled}`]: { @@ -80,6 +80,7 @@ export default function VerticalLinearStepper(props: Props) { const { jobs } = props; const [t] = useTranslation(); const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [openConfirmationModal, setOpenConfirmationModal] = useState(false); const [jobIdKill, setJobIdKill] = useState(); @@ -94,11 +95,7 @@ export default function VerticalLinearStepper(props: Props) { try { await killStudy(jobId); } catch (e) { - enqueueErrorSnackbar( - enqueueSnackbar, - t("singlestudy:failtokilltask"), - e as AxiosError - ); + enqueueErrorSnackbar(t("singlestudy:failtokilltask"), e as AxiosError); } setOpenConfirmationModal(false); })(); @@ -111,11 +108,7 @@ export default function VerticalLinearStepper(props: Props) { variant: "success", }); } catch (e) { - enqueueErrorSnackbar( - enqueueSnackbar, - t("singlestudy:onJobIdCopyError"), - e as AxiosError - ); + enqueueErrorSnackbar(t("singlestudy:onJobIdCopyError"), e as AxiosError); } }; diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/index.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/index.tsx index 2d5e353376..7c7464aeca 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/index.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/LauncherHistory/index.tsx @@ -1,5 +1,4 @@ import { Box, Paper, styled, Typography } from "@mui/material"; -import { useSnackbar } from "notistack"; import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { connect, ConnectedProps } from "react-redux"; @@ -24,7 +23,7 @@ import { WsChannel, } from "../../../../../store/websockets"; import JobStepper from "./JobStepper"; -import enqueueErrorSnackbar from "../../../../common/ErrorSnackBar"; +import useEnqueueErrorSnackbar from "../../../../../hooks/useEnqueueErrorSnackbar"; const TitleHeader = styled(Box)(({ theme }) => ({ display: "flex", @@ -62,7 +61,7 @@ function LauncherHistory(props: PropTypes) { } = props; const [t] = useTranslation(); const [studyJobs, setStudyJobs] = useState>([]); - const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const handleEvents = useCallback( (msg: WSMessage): void => { @@ -109,16 +108,12 @@ function LauncherHistory(props: PropTypes) { const data = await getStudyJobs(sid); setStudyJobs(data.reverse()); } catch (e) { - enqueueErrorSnackbar( - enqueueSnackbar, - t("jobs:failedtoretrievejobs"), - e as AxiosError - ); + enqueueErrorSnackbar(t("jobs:failedtoretrievejobs"), e as AxiosError); } }; fetchStudyJob(study.id); } - }, [study, t, enqueueSnackbar]); + }, [study, t, enqueueErrorSnackbar]); useEffect(() => { addWsListener(handleEvents); diff --git a/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx b/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx index 6f4f814ac6..1a0987a302 100644 --- a/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx +++ b/webapp_v2/src/components/singlestudy/HomeView/InformationView/Notes/index.tsx @@ -14,9 +14,9 @@ import { import { convertXMLToDraftJS } from "./utils"; import { StudyMetadata } from "../../../../../common/types"; import { scrollbarStyle } from "../../../../../theme"; -import enqueueErrorSnackbar from "../../../../common/ErrorSnackBar"; import NoteEditorModal from "./NoteEditorModal"; import SimpleLoader from "../../../../common/loaders/SimpleLoader"; +import useEnqueueErrorSnackbar from "../../../../../hooks/useEnqueueErrorSnackbar"; const Root = styled(Box)(({ theme }) => ({ flex: "0 0 40%", @@ -114,6 +114,7 @@ export default function Notes(props: Props) { const { study } = props; const [t] = useTranslation(); const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [editorState, setEditorState] = useState(() => EditorState.createEmpty() ); @@ -135,7 +136,6 @@ export default function Notes(props: Props) { setEditionMode(false); } catch (e) { enqueueErrorSnackbar( - enqueueSnackbar, t("singlestudy:commentsNotSaved"), e as AxiosError ); @@ -184,15 +184,11 @@ export default function Notes(props: Props) { setNbAreas(areas.length); setNbLinks(links); } catch (e) { - enqueueErrorSnackbar( - enqueueSnackbar, - t("singlestudy:getAreasInfo"), - e as AxiosError - ); + enqueueErrorSnackbar(t("singlestudy:getAreasInfo"), e as AxiosError); } } })(); - }, [enqueueSnackbar, study, t]); + }, [enqueueErrorSnackbar, study, t]); return ( diff --git a/webapp_v2/src/components/singlestudy/NavHeader.tsx b/webapp_v2/src/components/singlestudy/NavHeader.tsx index 1447256ee3..360a5c2af6 100644 --- a/webapp_v2/src/components/singlestudy/NavHeader.tsx +++ b/webapp_v2/src/components/singlestudy/NavHeader.tsx @@ -30,11 +30,9 @@ import SecurityOutlinedIcon from "@mui/icons-material/SecurityOutlined"; import AccountTreeOutlinedIcon from "@mui/icons-material/AccountTreeOutlined"; import PersonOutlineOutlinedIcon from "@mui/icons-material/PersonOutlineOutlined"; import { useTranslation } from "react-i18next"; -import { useSnackbar } from "notistack"; import { connect, ConnectedProps } from "react-redux"; import { GenericInfo, StudyMetadata, VariantTree } from "../../common/types"; import { STUDIES_HEIGHT_HEADER } from "../../theme"; -import enqueueErrorSnackbar from "../common/ErrorSnackBar"; import { deleteStudy as callDeleteStudy, archiveStudy as callArchiveStudy, @@ -51,6 +49,7 @@ import { countAllChildrens, } from "../../services/utils"; import DeleteStudyModal from "../studies/DeleteStudyModal"; +import useEnqueueErrorSnackbar from "../../hooks/useEnqueueErrorSnackbar"; const logError = debug("antares:singlestudy:navheader:error"); @@ -97,7 +96,7 @@ function NavHeader(props: PropTypes) { const [openPropertiesModal, setOpenPropertiesModal] = useState(false); const [openDeleteModal, setOpenDeleteModal] = useState(false); - const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const publicModeList: Array = [ { id: "NONE", name: t("singlestudy:nonePublicModeText") }, @@ -144,7 +143,6 @@ function NavHeader(props: PropTypes) { await callArchiveStudy(study.id); } catch (e) { enqueueErrorSnackbar( - enqueueSnackbar, t("studymanager:archivefailure", { studyname: study.name }), e as AxiosError ); @@ -156,7 +154,6 @@ function NavHeader(props: PropTypes) { await callUnarchiveStudy(study.id); } catch (e) { enqueueErrorSnackbar( - enqueueSnackbar, t("studymanager:unarchivefailure", { studyname: study.name }), e as AxiosError ); @@ -170,7 +167,6 @@ function NavHeader(props: PropTypes) { removeStudy(study.id); } catch (e) { enqueueErrorSnackbar( - enqueueSnackbar, t("studymanager:failtodeletestudy"), e as AxiosError ); diff --git a/webapp_v2/src/components/singlestudy/PropertiesModal.tsx b/webapp_v2/src/components/singlestudy/PropertiesModal.tsx index 225c71007d..c17d87c9d5 100644 --- a/webapp_v2/src/components/singlestudy/PropertiesModal.tsx +++ b/webapp_v2/src/components/singlestudy/PropertiesModal.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import { isEqual } from "lodash"; import debug from "debug"; -import { useSnackbar } from "notistack"; import { Box } from "@mui/material"; import { useTranslation } from "react-i18next"; import { AxiosError } from "axios"; +import { useSnackbar } from "notistack"; import BasicModal from "../common/BasicModal"; import SingleSelect from "../common/SelectSingle"; import MultiSelect from "../common/SelectMulti"; @@ -24,9 +24,9 @@ import { updateStudyMetadata, } from "../../services/api/study"; import { getGroups } from "../../services/api/user"; -import enqueueErrorSnackbar from "../common/ErrorSnackBar"; import TagTextInput from "../common/TagTextInput"; import { scrollbarStyle } from "../../theme"; +import useEnqueueErrorSnackbar from "../../hooks/useEnqueueErrorSnackbar"; const logErr = debug("antares:createstudyform:error"); @@ -40,6 +40,7 @@ function PropertiesModal(props: Props) { const [t] = useTranslation(); const { open, onClose, study } = props; const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); // NOTE: GET TAG LIST FROM BACKEND const tagList: Array = []; @@ -121,7 +122,6 @@ function PropertiesModal(props: Props) { } catch (e) { logErr("Failed to modify study", studyName, e); enqueueErrorSnackbar( - enqueueSnackbar, t("singlestudy:modifiedStudyFailed", { studyname: studyName }), e as AxiosError ); diff --git a/webapp_v2/src/components/singlestudy/explore/Modelization/Map/index.tsx b/webapp_v2/src/components/singlestudy/explore/Modelization/Map/index.tsx index 4e9a1a9ffe..ff551e05ce 100644 --- a/webapp_v2/src/components/singlestudy/explore/Modelization/Map/index.tsx +++ b/webapp_v2/src/components/singlestudy/explore/Modelization/Map/index.tsx @@ -5,9 +5,7 @@ import { Box, Typography } from "@mui/material"; import AutoSizer from "react-virtualized-auto-sizer"; import { useTranslation } from "react-i18next"; import { Graph, GraphLink, GraphNode } from "react-d3-graph"; -import { useSnackbar } from "notistack"; import { AxiosError } from "axios"; -import enqueueErrorSnackbar from "../../../../common/ErrorSnackBar"; import { AreasConfig, isNode, @@ -34,6 +32,7 @@ import GraphView from "./GraphView"; import MapPropsView from "./MapPropsView"; import CreateAreaModal from "./CreateAreaModal"; import mapbackground from "../../../../../assets/mapbackground.png"; +import useEnqueueErrorSnackbar from "../../../../../hooks/useEnqueueErrorSnackbar"; const FONT_SIZE = 16; const NODE_HEIGHT = 400; @@ -65,7 +64,7 @@ const GraphViewMemo = memo(GraphView); function Map() { const [t] = useTranslation(); - const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const { study } = useOutletContext<{ study?: StudyMetadata }>(); const [loaded, setLoaded] = useState(false); const [selectedItem, setSelectedItem] = useState< @@ -142,11 +141,7 @@ function Map() { ]); } } catch (e) { - enqueueErrorSnackbar( - enqueueSnackbar, - t("singlestudy:createAreaError"), - e as AxiosError - ); + enqueueErrorSnackbar(t("singlestudy:createAreaError"), e as AxiosError); } }; @@ -186,11 +181,7 @@ function Map() { } } catch (e) { setNodeData([...nodeData]); - enqueueErrorSnackbar( - enqueueSnackbar, - t("singlestudy:updateUIError"), - e as AxiosError - ); + enqueueErrorSnackbar(t("singlestudy:updateUIError"), e as AxiosError); } } }; @@ -221,7 +212,6 @@ function Map() { } catch (e) { setLinkData([...linkData]); enqueueErrorSnackbar( - enqueueSnackbar, t("singlestudy:deleteAreaOrLink"), e as AxiosError ); @@ -240,7 +230,6 @@ function Map() { setLinkData([...linkData]); setNodeData([...nodeData]); enqueueErrorSnackbar( - enqueueSnackbar, t("singlestudy:deleteAreaOrLink"), e as AxiosError ); @@ -274,7 +263,6 @@ function Map() { ) ); enqueueErrorSnackbar( - enqueueSnackbar, t("singlestudy:createLinkError"), e as AxiosError ); @@ -283,7 +271,7 @@ function Map() { }; init(); } - }, [enqueueSnackbar, t, firstNode, secondNode, study?.id, linkData]); + }, [enqueueErrorSnackbar, t, firstNode, secondNode, study?.id, linkData]); useEffect(() => { if (study) { @@ -330,7 +318,6 @@ function Map() { } } catch (e) { enqueueErrorSnackbar( - enqueueSnackbar, t("studymanager:failtoloadstudy"), e as AxiosError ); @@ -340,7 +327,7 @@ function Map() { }; init(); } - }, [enqueueSnackbar, study?.id, t]); + }, [enqueueErrorSnackbar, study?.id, t]); useEffect(() => { if (selectedItem && isNode(selectedItem)) { diff --git a/webapp_v2/src/components/studies/CreateStudyModal.tsx b/webapp_v2/src/components/studies/CreateStudyModal.tsx index a2cdc06a81..7cf3f31041 100644 --- a/webapp_v2/src/components/studies/CreateStudyModal.tsx +++ b/webapp_v2/src/components/studies/CreateStudyModal.tsx @@ -26,9 +26,9 @@ import { } from "../../services/api/study"; import { addStudies, initStudiesVersion } from "../../store/study"; import { getGroups } from "../../services/api/user"; -import enqueueErrorSnackbar from "../common/ErrorSnackBar"; import TagTextInput from "../common/TagTextInput"; import { scrollbarStyle } from "../../theme"; +import useEnqueueErrorSnackbar from "../../hooks/useEnqueueErrorSnackbar"; const logErr = debug("antares:createstudyform:error"); @@ -53,6 +53,7 @@ function CreateStudyModal(props: PropTypes) { const [t] = useTranslation(); const { versions, open, addStudy, onClose } = props; const { enqueueSnackbar } = useSnackbar(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const versionList = convertVersions(versions || []); // NOTE: GET TAG LIST FROM BACKEND @@ -94,7 +95,6 @@ function CreateStudyModal(props: PropTypes) { } catch (e) { logErr("Failed to create new study", studyName, e); enqueueErrorSnackbar( - enqueueSnackbar, t("studymanager:createStudyFailed", { studyname: studyName }), e as AxiosError ); diff --git a/webapp_v2/src/components/studies/HeaderBottom.tsx b/webapp_v2/src/components/studies/HeaderBottom.tsx index a577fc6bc1..fd9bf0d797 100644 --- a/webapp_v2/src/components/studies/HeaderBottom.tsx +++ b/webapp_v2/src/components/studies/HeaderBottom.tsx @@ -69,6 +69,7 @@ function HeaderBottom(props: PropTypes) { ), }} + sx={{ mx: 0 }} />