From 63cdaa9812c6fa11e033f887a9461ab6fd3ab0fb Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 6 Jul 2022 17:58:41 +0200 Subject: [PATCH 01/31] Update version Signed-off-by: Paul Bui-Quang --- antarest/__init__.py | 2 +- setup.py | 2 +- sonar-project.properties | 2 +- webapp/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/antarest/__init__.py b/antarest/__init__.py index 3736a3ba3b..f4bfc33136 100644 --- a/antarest/__init__.py +++ b/antarest/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.5.1" +__version__ = "2.6.0" from pathlib import Path diff --git a/setup.py b/setup.py index 98ea7b806a..a6ce1b22df 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name="AntaREST", - version="2.5.1", + version="2.6.0", description="Antares Server", long_description=long_description, long_description_content_type="text/markdown", diff --git a/sonar-project.properties b/sonar-project.properties index 58628f8ce8..80ae91f68c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -5,5 +5,5 @@ sonar.language=python, js sonar.exclusions=antarest/gui.py,antarest/main.py sonar.python.coverage.reportPaths=coverage.xml sonar.javascript.lcov.reportPaths=webapp/coverage/lcov.info -sonar.projectVersion=2.5.1 +sonar.projectVersion=2.6.0 sonar.coverage.exclusions=antarest/gui.py,antarest/main.py,webapp/**/* \ No newline at end of file diff --git a/webapp/package.json b/webapp/package.json index 7fa2c81505..d7c4e279bc 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "antares-web", - "version": "2.5.1", + "version": "2.6.0", "private": true, "dependencies": { "@emotion/react": "11.9.0", From 997e4a984f812fcdca656c6c7cbb4b15ce8d4bb5 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Thu, 7 Jul 2022 09:18:56 +0200 Subject: [PATCH 02/31] Fix xpansion candidate listing view Signed-off-by: Paul Bui-Quang --- .../Xpansion/Candidates/XpansionPropsView.tsx | 128 ++++++------------ 1 file changed, 43 insertions(+), 85 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/XpansionPropsView.tsx b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/XpansionPropsView.tsx index f03ad1c4d0..74d3568069 100644 --- a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/XpansionPropsView.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/XpansionPropsView.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Box, Button } from "@mui/material"; import { useTranslation } from "react-i18next"; import DeleteIcon from "@mui/icons-material/Delete"; @@ -25,103 +25,61 @@ function XpansionPropsView(props: PropsType) { deleteXpansion, } = props; const [filteredCandidates, setFilteredCandidates] = - useState>(); + useState>(candidateList); + const [searchFilter, setSearchFilter] = useState(""); const [openConfirmationModal, setOpenConfirmationModal] = useState(false); - const filter = (currentName: string): XpansionCandidate[] => { - if (candidateList) { - return candidateList.filter( - (item) => - !currentName || item.name.search(new RegExp(currentName, "i")) !== -1 - ); - } - return []; - }; + const filter = useCallback( + (currentName: string): XpansionCandidate[] => { + if (candidateList) { + return candidateList.filter( + (item) => + !currentName || + item.name.search(new RegExp(currentName, "i")) !== -1 + ); + } + return []; + }, + [candidateList] + ); - const onChange = async (currentName: string) => { - if (currentName !== "") { - const f = filter(currentName); - setFilteredCandidates(f); - } else { - setFilteredCandidates(undefined); - } - }; + useEffect(() => { + setFilteredCandidates(filter(searchFilter)); + }, [filter, searchFilter]); return ( <> - setSelectedItem(elm.name)} - /> - - - - - ) + setSelectedItem(elm.name)} + /> } secondaryContent={ - filteredCandidates && ( - + - - - ) + {t("global.delete")} + + } - onSearchFilterChange={(e) => onChange(e as string)} + onSearchFilterChange={setSearchFilter} onAdd={onAdd} /> {openConfirmationModal && candidateList && ( From f751115d7f34d3f94c43ceb583b840ba3723cf31 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Fri, 8 Jul 2022 12:10:50 +0200 Subject: [PATCH 03/31] Fix additional log import in output Signed-off-by: Paul Bui-Quang --- antarest/launcher/service.py | 7 +++++-- tests/launcher/test_service.py | 10 ++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index cd5864f797..9c0354fd49 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -551,13 +551,13 @@ def _import_output( output_true_path, job_launch_params, ) - self._save_solver_stats(job_result, output_path) + self._save_solver_stats(job_result, output_true_path) if additional_logs: for log_name, log_paths in additional_logs.items(): concat_files( log_paths, - output_path / log_name, + output_true_path / log_name, ) zip_path: Optional[Path] = None @@ -607,6 +607,9 @@ def _import_output( ), ), ) + finally: + if zip_path: + os.unlink(zip_path) raise JobNotFound() def _download_fallback_output( diff --git a/tests/launcher/test_service.py b/tests/launcher/test_service.py index deaa4c1109..61f55deff6 100644 --- a/tests/launcher/test_service.py +++ b/tests/launcher/test_service.py @@ -557,10 +557,12 @@ def test_manage_output(tmp_path: Path): task_service=Mock(), ) - output_path = tmp_path / "new_output" + output_path = tmp_path / "output" os.mkdir(output_path) - (output_path / "log").touch() - (output_path / "data").touch() + new_output_path = output_path / "new_output" + os.mkdir(new_output_path) + (new_output_path / "log").touch() + (new_output_path / "data").touch() additional_log = tmp_path / "output.log" additional_log.write_text("some log") job_id = "job_id" @@ -612,7 +614,7 @@ def test_manage_output(tmp_path: Path): is None ) - (output_path / "info.antares-output").write_text( + (new_output_path / "info.antares-output").write_text( f"[general]\nmode=eco\nname=foo\ntimestamp={time.time()}" ) output_name = launcher_service._import_output( From 0cb129e506701c2f65e3ae3699d92321352af703 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Fri, 8 Jul 2022 13:11:33 +0200 Subject: [PATCH 04/31] Fix adequacy patch cleanup function Signed-off-by: Paul Bui-Quang --- .../adequacy_patch/resources/post-processing-legacy.R | 6 +++--- .../extensions/adequacy_patch/resources/post-processing.R | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/antarest/launcher/extensions/adequacy_patch/resources/post-processing-legacy.R b/antarest/launcher/extensions/adequacy_patch/resources/post-processing-legacy.R index 624d60862a..65394006de 100644 --- a/antarest/launcher/extensions/adequacy_patch/resources/post-processing-legacy.R +++ b/antarest/launcher/extensions/adequacy_patch/resources/post-processing-legacy.R @@ -37,11 +37,11 @@ remove_data <- function(path_prefix, data_type, data_list, include_id) { if (!data_list[[item]]) { item_data <- paste(c(path_prefix, data_type, item), collapse="/") cat(paste0("Removing from ", data_type, " ", item, " in ", path_prefix, "\n")) - unlink(file.path(paste0(item_data, "values-hourly.txt", collapse="/"))) + unlink(file.path(paste0(c(item_data, "values-hourly.txt"), collapse="/"))) if (include_id) { - unlink(file.path(paste0(item_data, "id-hourly.txt", collapse="/"))) + unlink(file.path(paste0(c(item_data, "id-hourly.txt"), collapse="/"))) } - unlink(file.path(paste0(item_data, "details-hourly.txt", collapse="/"))) + unlink(file.path(paste0(c(item_data, "details-hourly.txt"), collapse="/"))) if (length(list.files(file.path(item_data))) == 0) { unlink(file.path(item_data)) } diff --git a/antarest/launcher/extensions/adequacy_patch/resources/post-processing.R b/antarest/launcher/extensions/adequacy_patch/resources/post-processing.R index 25147a71c0..da42d705d5 100644 --- a/antarest/launcher/extensions/adequacy_patch/resources/post-processing.R +++ b/antarest/launcher/extensions/adequacy_patch/resources/post-processing.R @@ -39,11 +39,11 @@ remove_data <- function(path_prefix, data_type, data_list, include_id) { if (!data_list[[item]]) { item_data <- paste(c(path_prefix, data_type, item), collapse="/") cat(paste0("Removing from ", data_type, " ", item, " in ", path_prefix, "\n")) - unlink(file.path(paste0(item_data, "values-hourly.txt", collapse="/"))) + unlink(file.path(paste0(c(item_data, "values-hourly.txt"), collapse="/"))) if (include_id) { - unlink(file.path(paste0(item_data, "id-hourly.txt", collapse="/"))) + unlink(file.path(paste0(c(item_data, "id-hourly.txt"), collapse="/"))) } - unlink(file.path(paste0(item_data, "details-hourly.txt", collapse="/"))) + unlink(file.path(paste0(c(item_data, "details-hourly.txt"), collapse="/"))) if (length(list.files(file.path(item_data))) == 0) { unlink(file.path(item_data)) } From 4bb8ae31f63cf66799debfab47beb76639885b99 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Wed, 13 Jul 2022 17:44:40 +0200 Subject: [PATCH 05/31] Fix ColorPickerFE + fix general config + add UsePromiseCond (#987) --- .../explore/Configuration/General/index.tsx | 24 ++- .../explore/Configuration/General/utils.ts | 2 +- .../Areas/Properties/PropertiesForm.tsx | 6 +- .../Modelization/Areas/Properties/index.tsx | 44 +++-- .../Modelization/Areas/Properties/utils.ts | 4 +- .../Modelization/Links/LinkView/index.tsx | 44 +++-- .../fieldEditors/ColorPickerFE/index.tsx | 153 ++++++++---------- .../fieldEditors/ColorPickerFE/utils.ts | 2 +- .../common/utils/UsePromiseCond.tsx | 31 ++++ webapp/src/utils/reactUtils.ts | 9 ++ 10 files changed, 168 insertions(+), 151 deletions(-) create mode 100644 webapp/src/components/common/utils/UsePromiseCond.tsx diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx index 504ba05a26..0a9d34b7de 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx @@ -1,12 +1,11 @@ import { useOutletContext } from "react-router"; -import * as R from "ramda"; import { StudyMetadata } from "../../../../../../common/types"; import usePromiseWithSnackbarError from "../../../../../../hooks/usePromiseWithSnackbarError"; import { getFormValues } from "./utils"; -import { PromiseStatus } from "../../../../../../hooks/usePromise"; import Form from "../../../../../common/Form"; import Fields from "./Fields"; import SimpleLoader from "../../../../../common/loaders/SimpleLoader"; +import UsePromiseCond from "../../../../../common/utils/UsePromiseCond"; function GeneralParameters() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -16,21 +15,18 @@ function GeneralParameters() { { errorMessage: "Cannot get study data", deps: [study.id] } // TODO i18n ); - return R.cond([ - [ - R.either(R.equals(PromiseStatus.Idle), R.equals(PromiseStatus.Pending)), - () => , - ], - [R.equals(PromiseStatus.Rejected), () =>
{error}
], - [ - R.equals(PromiseStatus.Resolved), - () => ( + return ( + } + ifRejected={
{error}
} + ifResolved={
- ), - ], - ])(status); + } + /> + ); } export default GeneralParameters; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts index e99b91e072..c93ab20f4b 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts @@ -141,7 +141,7 @@ const DEFAULT_VALUES: FormValues = { export async function getFormValues( studyId: StudyMetadata["id"] ): Promise { - const { general, output } = await getStudyData<{ + const { general = {}, output = {} } = await getStudyData<{ general: Partial; output: Partial; }>(studyId, "settings/generaldata", 2); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx index 74e8d27b8a..02943ad9fa 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx @@ -7,7 +7,7 @@ import SelectFE from "../../../../../../common/fieldEditors/SelectFE"; import useEnqueueErrorSnackbar from "../../../../../../../hooks/useEnqueueErrorSnackbar"; import Fieldset from "../../../../../../common/Fieldset"; import { FormObj } from "../../../../../../common/Form"; -import ColorPicker from "../../../../../../common/fieldEditors/ColorPickerFE"; +import ColorPickerFE from "../../../../../../common/fieldEditors/ColorPickerFE"; import { stringToRGB } from "../../../../../../common/fieldEditors/ColorPickerFE/utils"; import { getPropertiesPath, PropertiesFields } from "./utils"; import SwitchFE from "../../../../../../common/fieldEditors/SwitchFE"; @@ -101,8 +101,8 @@ export default function PropertiesForm( } disabled /> - { const color = stringToRGB(value); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx index bdddc0a461..3a627d10ed 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx @@ -1,17 +1,15 @@ -import * as R from "ramda"; import { Box } from "@mui/material"; import { useTranslation } from "react-i18next"; import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../../../../../common/types"; -import usePromise, { - PromiseStatus, -} from "../../../../../../../hooks/usePromise"; +import usePromise from "../../../../../../../hooks/usePromise"; import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import Form from "../../../../../../common/Form"; import PropertiesForm from "./PropertiesForm"; import { getDefaultValues, PropertiesFields } from "./utils"; import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; +import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; function Properties() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -24,26 +22,24 @@ function Properties() { return ( - {R.cond([ - [R.equals(PromiseStatus.Pending), () => ], - [ - R.equals(PromiseStatus.Resolved), - () => ( -
- {(formObj) => - PropertiesForm({ - ...formObj, - areaName: currentArea, - studyId: study.id, - }) - } -
- ), - ], - ])(status)} + } + ifResolved={ +
+ {(formObj) => + PropertiesForm({ + ...formObj, + areaName: currentArea, + studyId: study.id, + }) + } +
+ } + />
); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/utils.ts index 7789f4a76f..f198fa70b3 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/utils.ts @@ -1,7 +1,7 @@ import { FieldValues } from "react-hook-form"; import { TFunction } from "react-i18next"; import { getStudyData } from "../../../../../../../services/api/study"; -import { RGBToString } from "../../../../../../common/fieldEditors/ColorPickerFE/utils"; +import { rgbToString } from "../../../../../../common/fieldEditors/ColorPickerFE/utils"; export interface PropertiesType { ui: { @@ -84,7 +84,7 @@ export async function getDefaultValues( // Return element return { name: areaName, - color: RGBToString({ + color: rgbToString({ r: uiElement.color_r, g: uiElement.color_g, b: uiElement.color_b, diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/index.tsx index f180bf152f..ac514449d9 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/index.tsx @@ -1,14 +1,12 @@ -import * as R from "ramda"; import { Box } from "@mui/material"; import { useOutletContext } from "react-router"; import { LinkElement, StudyMetadata } from "../../../../../../../common/types"; -import usePromise, { - PromiseStatus, -} from "../../../../../../../hooks/usePromise"; +import usePromise from "../../../../../../../hooks/usePromise"; import Form from "../../../../../../common/Form"; import LinkForm from "./LinkForm"; import { getDefaultValues, LinkFields } from "./utils"; import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; +import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; interface Props { link: LinkElement; @@ -24,26 +22,24 @@ function LinkView(props: Props) { return ( - {R.cond([ - [R.equals(PromiseStatus.Pending), () => ], - [ - R.equals(PromiseStatus.Resolved), - () => ( -
- {(formObj) => - LinkForm({ - ...formObj, - link, - study, - }) - } -
- ), - ], - ])(status)} + } + ifResolved={ +
+ {(formObj) => + LinkForm({ + ...formObj, + link, + study, + }) + } +
+ } + />
); } diff --git a/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx b/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx index c5d7962007..1a17d77b42 100644 --- a/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx +++ b/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx @@ -1,78 +1,87 @@ -import { - Box, - Button, - TextField, - TextFieldProps, - InputAdornment, - setRef, -} from "@mui/material"; -import { ChangeEvent, forwardRef, useEffect, useRef, useState } from "react"; +import { Box, TextField, TextFieldProps, InputAdornment } from "@mui/material"; +import { ChangeEvent, forwardRef, useRef, useState } from "react"; import { ColorResult, SketchPicker } from "react-color"; import { useTranslation } from "react-i18next"; -import CancelRoundedIcon from "@mui/icons-material/CancelRounded"; import SquareRoundedIcon from "@mui/icons-material/SquareRounded"; -import { RGBToString, stringToRGB } from "./utils"; +import { useClickAway, useKey, useUpdateEffect } from "react-use"; +import { rgbToString, stringToRGB } from "./utils"; +import { mergeSxProp } from "../../../../utils/muiUtils"; +import { composeRefs } from "../../../../utils/reactUtils"; -interface Props { - currentColor?: string; -} +export type ColorPickerFEProps = Omit< + TextFieldProps, + "type" | "defaultChecked" +> & { + value?: string; // Format: R,G,B - ex: "255,255,255" + defaultValue?: string; +}; -const ColorPicker = forwardRef((props: Props & TextFieldProps, ref) => { - const { currentColor, onChange, ...other } = props; - const [color, setColor] = useState(currentColor || ""); - const [t] = useTranslation(); +const ColorPickerFE = forwardRef((props: ColorPickerFEProps, ref) => { + const { value, defaultValue, onChange, sx, ...textFieldProps } = props; + const [currentColor, setCurrentColor] = useState(defaultValue || value || ""); const [isPickerOpen, setIsPickerOpen] = useState(false); - const internalRef = useRef(); + const internalRef = useRef(); + const pickerWrapperRef = useRef(null); + const { t } = useTranslation(); + + useUpdateEffect(() => { + setCurrentColor(value ?? ""); + }, [value]); + + useClickAway(pickerWrapperRef, () => { + setIsPickerOpen(false); + }); + + useKey("Escape", () => setIsPickerOpen(false)); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// - useEffect(() => { - if (color && internalRef.current) { - if (onChange) { - onChange({ - target: internalRef.current, - } as ChangeEvent); - } - } - }, [color, onChange]); + const handleChange = ({ hex, rgb }: ColorResult) => { + setCurrentColor( + ["transparent", "#0000"].includes(hex) ? "" : rgbToString(rgb) + ); + }; - useEffect(() => { - if (currentColor) { - setColor(currentColor); - } - }, [currentColor]); + const handleChangeComplete = () => { + onChange?.({ + target: internalRef.current, + type: "change", + } as ChangeEvent); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// return ( { - setRef(ref, instance); - setRef(internalRef, instance); - }} - value={color} - InputLabelProps={ - // Allow to show placeholder when field is empty - currentColor !== undefined ? { shrink: true } : {} - } - {...other} + {...textFieldProps} + sx={{ mx: 1 }} + value={currentColor} + placeholder={currentColor} + inputRef={composeRefs(ref, internalRef)} InputProps={{ startAdornment: ( @@ -87,40 +96,20 @@ const ColorPicker = forwardRef((props: Props & TextFieldProps, ref) => { top: "calc(100% + 8px)", zIndex: 1000, }} + ref={pickerWrapperRef} > - ) => { - setColor(RGBToString(color.rgb)); - }} + color={currentColor && stringToRGB(currentColor)} + onChange={handleChange} + onChangeComplete={handleChangeComplete} + disableAlpha /> - )} ); }); -ColorPicker.displayName = "ColorPicker"; +ColorPickerFE.displayName = "ColorPicker"; -export default ColorPicker; +export default ColorPickerFE; diff --git a/webapp/src/components/common/fieldEditors/ColorPickerFE/utils.ts b/webapp/src/components/common/fieldEditors/ColorPickerFE/utils.ts index e6404cb2b9..8939585f75 100644 --- a/webapp/src/components/common/fieldEditors/ColorPickerFE/utils.ts +++ b/webapp/src/components/common/fieldEditors/ColorPickerFE/utils.ts @@ -20,7 +20,7 @@ export function stringToRGB(color: string): ColorResult["rgb"] | undefined { return undefined; } -export function RGBToString(color: Partial): string { +export function rgbToString(color: Partial): string { const { r, g, b } = color; if (r === undefined || g === undefined || b === undefined) return ""; return `${r},${g},${b}`; diff --git a/webapp/src/components/common/utils/UsePromiseCond.tsx b/webapp/src/components/common/utils/UsePromiseCond.tsx new file mode 100644 index 0000000000..4581d87bbf --- /dev/null +++ b/webapp/src/components/common/utils/UsePromiseCond.tsx @@ -0,0 +1,31 @@ +import * as R from "ramda"; +import { PromiseStatus } from "../../../hooks/usePromise"; + +export interface UsePromiseCondProps { + status: PromiseStatus; + ifPending?: React.ReactNode; + ifRejected?: React.ReactNode; + ifResolved?: React.ReactNode; +} + +function UsePromiseCond(props: UsePromiseCondProps) { + const { status, ifPending, ifRejected, ifResolved } = props; + + return ( + <> + {R.cond([ + [ + R.either( + R.equals(PromiseStatus.Idle), + R.equals(PromiseStatus.Pending) + ), + () => ifPending, + ], + [R.equals(PromiseStatus.Rejected), () => ifRejected], + [R.equals(PromiseStatus.Resolved), () => ifResolved], + ])(status)} + + ); +} + +export default UsePromiseCond; diff --git a/webapp/src/utils/reactUtils.ts b/webapp/src/utils/reactUtils.ts index b5031ffce8..2a3e1d0783 100644 --- a/webapp/src/utils/reactUtils.ts +++ b/webapp/src/utils/reactUtils.ts @@ -1,5 +1,14 @@ +import { setRef } from "@mui/material"; +import React from "react"; + export function isDependencyList( value: unknown ): value is React.DependencyList { return Array.isArray(value); } + +export function composeRefs(...refs: React.Ref[]) { + return function refCallback(instance: unknown): void { + refs.forEach((ref) => setRef(ref, instance)); + }; +} From cdc26297d4cd30944ab1e7c258316205eae11e6b Mon Sep 17 00:00:00 2001 From: Wintxer <47366828+Wintxer@users.noreply.github.com> Date: Fri, 15 Jul 2022 11:14:19 +0200 Subject: [PATCH 06/31] Add matrix store assignation (#984) * Added matrixassigndialog * fix * Fix reviw * Fix review * usePromiseCond * Fix review * Fix * fix Co-authored-by: Alexis --- webapp/public/locales/en/main.json | 7 +- webapp/public/locales/fr/main.json | 7 +- .../src/components/App/Data/DataPropsView.tsx | 2 +- .../DraggableCommands/CommandImportButton.tsx | 2 +- .../src/components/App/Studies/StudyCard.tsx | 2 +- webapp/src/components/common/ButtonBack.tsx | 34 +++ .../common/EditableMatrix/MatrixGraphView.tsx | 2 +- webapp/src/components/common/FileTable.tsx | 26 ++- .../common/MatrixInput/MatrixAssignDialog.tsx | 215 ++++++++++++++++++ .../components/common/MatrixInput/index.tsx | 34 ++- webapp/src/hooks/usePromise.ts | 2 +- webapp/src/utils/reactUtils.ts | 1 - 12 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 webapp/src/components/common/ButtonBack.tsx create mode 100644 webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 535295c87d..83c8bc69a0 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -57,7 +57,9 @@ "global.color": "Color", "global.advancedParams": "Advanced parameters", "global.matrix": "Matrix", + "global.matrixes": "Matrixes", "global.chooseFile": "Choose a file", + "global.assign": "Assign", "global.errorLogs": "Error logs", "global.error.emptyName": "Name cannot be empty", "global.error.failedtoretrievejobs": "Failed to retrieve job information", @@ -421,11 +423,14 @@ "data.analyzingmatrix": "Analyzing matrices", "data.success.matrixIdCopied": "Matrix id copied !", "data.jsonFormat": "JSON Format", + "data.assignMatrix": "Assign a matrix", "data.error.matrixList": "Unable to retrieve matrix list", "data.error.matrix": "Unable to retrieve matrix data", "data.error.fileNotUploaded": "Please select a file", "data.error.matrixDelete": "Matrix not deleted", - "data.error.copyMatrixId": "Failed to copy the matrix Id", + "data.error.copyMatrixId": "Failed to copy the matrix ID", + "data.error.matrixAssignation": "Failed to assign the matrix", + "data.succes.matrixAssignation": "Matrix successfully assigned", "data.success.matrixUpdate": "Matrix successfully updated", "data.success.matrixCreation": "Matrix successfully created", "data.success.matrixDelete": "Matrix successfully deleted", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 1e1189140b..9368037822 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -57,7 +57,9 @@ "global.color": "Couleur", "global.advancedParams": "Paramètres avancés", "global.matrix": "Matrice", + "global.matrixes": "Matrices", "global.chooseFile": "Choisir un fichier", + "global.assign": "Assigner", "global.errorLogs": "Logs d'erreurs", "global.error.emptyName": "Le nom ne peut pas être vide", "global.error.failedtoretrievejobs": "Échec de la récupération des tâches", @@ -421,11 +423,14 @@ "data.analyzingmatrix": "Analyse des matrices", "data.success.matrixIdCopied": "Id de la Matrice copié !", "data.jsonFormat": "Format JSON", + "data.assignMatrix": "Assigner une matrice", "data.error.matrixList": "Impossible de récupérer la liste des matrices", "data.error.matrix": "Impossible de récupérer les données de la matrice", "data.error.fileNotUploaded": "Veuillez sélectionner un fichier", "data.error.matrixDelete": "Matrice non supprimée", - "data.error.copyMatrixId": "Erreur lors de la copie de l'Id de la matrice", + "data.error.copyMatrixId": "Erreur lors de la copie de l'ID de la matrice", + "data.error.matrixAssignation": "Erreur lors de l'assignation de la matrice", + "data.succes.matrixAssignation": "Matrice assignée avec succès", "data.success.matrixUpdate": "Matrice chargée avec succès", "data.success.matrixCreation": "Matrice créée avec succès", "data.success.matrixDelete": "Matrice supprimée avec succès", diff --git a/webapp/src/components/App/Data/DataPropsView.tsx b/webapp/src/components/App/Data/DataPropsView.tsx index 490d8379d2..3621377611 100644 --- a/webapp/src/components/App/Data/DataPropsView.tsx +++ b/webapp/src/components/App/Data/DataPropsView.tsx @@ -8,7 +8,7 @@ interface PropTypes { dataset: Array; selectedItem: string; setSelectedItem: (item: string) => void; - onAdd: () => void; + onAdd?: () => void; } function DataPropsView(props: PropTypes) { diff --git a/webapp/src/components/App/Singlestudy/Commands/Edition/DraggableCommands/CommandImportButton.tsx b/webapp/src/components/App/Singlestudy/Commands/Edition/DraggableCommands/CommandImportButton.tsx index 19f00abb39..4440d848d6 100644 --- a/webapp/src/components/App/Singlestudy/Commands/Edition/DraggableCommands/CommandImportButton.tsx +++ b/webapp/src/components/App/Singlestudy/Commands/Edition/DraggableCommands/CommandImportButton.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import { useRef } from "react"; import { useTranslation } from "react-i18next"; import { useSnackbar } from "notistack"; import { Box, ButtonBase } from "@mui/material"; diff --git a/webapp/src/components/App/Studies/StudyCard.tsx b/webapp/src/components/App/Studies/StudyCard.tsx index 96d0653615..bb22e4dfa1 100644 --- a/webapp/src/components/App/Studies/StudyCard.tsx +++ b/webapp/src/components/App/Studies/StudyCard.tsx @@ -1,4 +1,4 @@ -import React, { memo, useState } from "react"; +import { memo, useState } from "react"; import { NavLink } from "react-router-dom"; import { AxiosError } from "axios"; import { useSnackbar } from "notistack"; diff --git a/webapp/src/components/common/ButtonBack.tsx b/webapp/src/components/common/ButtonBack.tsx new file mode 100644 index 0000000000..6ba10a0411 --- /dev/null +++ b/webapp/src/components/common/ButtonBack.tsx @@ -0,0 +1,34 @@ +import { Box, Button } from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import { useTranslation } from "react-i18next"; + +interface Props { + onClick: VoidFunction; +} + +function ButtonBack(props: Props) { + const { onClick } = props; + const [t] = useTranslation(); + + return ( + + onClick()} + sx={{ cursor: "pointer" }} + /> + + + ); +} + +export default ButtonBack; diff --git a/webapp/src/components/common/EditableMatrix/MatrixGraphView.tsx b/webapp/src/components/common/EditableMatrix/MatrixGraphView.tsx index a9d1a3e1b0..f3d7f6e41c 100644 --- a/webapp/src/components/common/EditableMatrix/MatrixGraphView.tsx +++ b/webapp/src/components/common/EditableMatrix/MatrixGraphView.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import { useState } from "react"; import Plot from "react-plotly.js"; import AutoSizer from "react-virtualized-auto-sizer"; import { diff --git a/webapp/src/components/common/FileTable.tsx b/webapp/src/components/common/FileTable.tsx index 7185b1eae6..3517db2a2c 100644 --- a/webapp/src/components/common/FileTable.tsx +++ b/webapp/src/components/common/FileTable.tsx @@ -22,6 +22,7 @@ import GetAppOutlinedIcon from "@mui/icons-material/GetAppOutlined"; import DeleteIcon from "@mui/icons-material/Delete"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import DownloadIcon from "@mui/icons-material/Download"; +import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import useEnqueueErrorSnackbar from "../../hooks/useEnqueueErrorSnackbar"; import ConfirmationDialog from "./dialogs/ConfirmationDialog"; import { GenericInfo } from "../../common/types"; @@ -33,10 +34,11 @@ const logErr = debug("antares:createimportform:error"); interface PropType { title: ReactNode; content: Array; - onDelete: (id: string) => Promise; + onDelete?: (id: string) => Promise; onRead: (id: string) => Promise; uploadFile?: (file: File) => Promise; onFileDownload?: (id: string) => string; + onAssign?: (id: string) => Promise; allowImport?: boolean; allowDelete?: boolean; copyId?: boolean; @@ -53,12 +55,12 @@ function FileTable(props: PropType) { onRead, uploadFile, onFileDownload, + onAssign, allowImport, allowDelete, copyId, } = props; - const [openConfirmationModal, setOpenConfirmationModal] = - useState(""); + const [openConfirmationModal, setOpenConfirmationModal] = useState(""); const [openImportDialog, setOpenImportDialog] = useState(false); const onImport = async (file: File) => { @@ -83,7 +85,6 @@ function FileTable(props: PropType) { width="100%" height="100%" flexDirection="column" - sx={{ px: 1 }} > {title} @@ -203,6 +204,19 @@ function FileTable(props: PropType) { )} + {onAssign && ( + onAssign(row.id as string)} + sx={{ + mx: 1, + color: "action.active", + }} + > + + + + + )} ))} @@ -210,7 +224,7 @@ function FileTable(props: PropType) { - {openConfirmationModal && openConfirmationModal.length > 0 && ( + {openConfirmationModal && onDelete && ( (); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const { enqueueSnackbar } = useSnackbar(); + + const { + data: dataList, + status: statusList, + error: errorList, + } = usePromiseWithSnackbarError(() => getMatrixList(), { + errorMessage: t("data.error.matrixList"), + }); + + const { + data: dataMatrix, + status: statusMatrix, + error: errorMatrix, + } = usePromiseWithSnackbarError( + async () => { + if (currentMatrix) { + const res = await getMatrix(currentMatrix.id); + return res; + } + }, + { + errorMessage: t("data.error.matrix"), + deps: [currentMatrix], + } + ); + + useEffect(() => { + setCurrentMatrix(undefined); + }, [selectedItem]); + + const dataSet = dataList?.find((item) => item.id === selectedItem); + + const matrices = dataSet?.matrices; + + const matrixName = `${t("global.matrixes")} - ${dataSet?.name}`; + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleMatrixClick = async (id: string) => { + if (matrices) { + setCurrentMatrix({ + id, + name: matrices.find((o) => o.id === id)?.name || "", + }); + } + }; + + const handleAssignation = async (matrixId: string) => { + try { + await appendCommands(study.id, [ + { + action: CommandEnum.REPLACE_MATRIX, + args: { + target: path, + matrix: matrixId, + }, + }, + ]); + enqueueSnackbar(t("data.succes.matrixAssignation"), { + variant: "success", + }); + } catch (e) { + enqueueErrorSnackbar(t("data.error.matrixAssignation"), e as AxiosError); + } + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + {t("button.close")}} + contentProps={{ + sx: { width: "1200px", height: "700px" }, + }} + > + {dataList && ( + } + ifRejected={
{errorList}
} + ifResolved={ + + } + right={ + + {selectedItem && !currentMatrix && ( + + + {matrixName} + + + } + content={matrices || []} + onRead={handleMatrixClick} + onAssign={handleAssignation} + /> + )} + {currentMatrix && dataMatrix && ( + } + ifRejected={
{errorMatrix}
} + ifResolved={ + <> + + + {matrixName} + + + + setCurrentMatrix(undefined)} + /> + + + + + + } + /> + )} + + } + /> + } + /> + )} +
+ ); +} + +export default MatrixAssignDialog; diff --git a/webapp/src/components/common/MatrixInput/index.tsx b/webapp/src/components/common/MatrixInput/index.tsx index 8d8a14a08f..35c1930923 100644 --- a/webapp/src/components/common/MatrixInput/index.tsx +++ b/webapp/src/components/common/MatrixInput/index.tsx @@ -3,10 +3,18 @@ import { useSnackbar } from "notistack"; import { useState } from "react"; import { AxiosError } from "axios"; import debug from "debug"; -import { Typography, Box, ButtonGroup, Button, Divider } from "@mui/material"; +import { + Typography, + Box, + ButtonGroup, + Button, + Divider, + Tooltip, +} from "@mui/material"; import TableViewIcon from "@mui/icons-material/TableView"; import BarChartIcon from "@mui/icons-material/BarChart"; import GetAppOutlinedIcon from "@mui/icons-material/GetAppOutlined"; +import InventoryIcon from "@mui/icons-material/Inventory"; import { MatrixEditDTO, MatrixStats, @@ -21,6 +29,7 @@ import SimpleLoader from "../loaders/SimpleLoader"; import NoContent from "../page/NoContent"; import EditableMatrix from "../EditableMatrix"; import ImportDialog from "../dialogs/ImportDialog"; +import MatrixAssignDialog from "./MatrixAssignDialog"; const logErr = debug("antares:createimportform:error"); @@ -39,6 +48,7 @@ function MatrixInput(props: PropsType) { const [t] = useTranslation(); const [toggleView, setToggleView] = useState(true); const [openImportDialog, setOpenImportDialog] = useState(false); + const [openMatrixAsignDialog, setOpenMatrixAsignDialog] = useState(false); const { data, @@ -123,7 +133,7 @@ function MatrixInput(props: PropsType) { {!isLoading && data?.columns?.length > 1 && ( - + setToggleView((prev) => !prev)}> {toggleView ? ( @@ -133,6 +143,18 @@ function MatrixInput(props: PropsType) { )} + + ) : ( )} diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/index.tsx new file mode 100644 index 0000000000..e93e614f89 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/index.tsx @@ -0,0 +1,133 @@ +import { Box, Button, Divider } from "@mui/material"; +import { useTranslation } from "react-i18next"; +import * as R from "ramda"; +import { Pred } from "ramda"; +import { StudyMetadata } from "../../../../../../../../common/types"; +import { setThematicTrimmingConfig } from "../../../../../../../../services/api/study"; +import BasicDialog from "../../../../../../../common/dialogs/BasicDialog"; +import SwitchFE from "../../../../../../../common/fieldEditors/SwitchFE"; +import { useFormContext } from "../../../../../../../common/Form"; +import { FormValues } from "../../utils"; +import { + getColumns, + getFieldNames, + ThematicTrimmingConfig, + thematicTrimmingConfigToDTO, +} from "./utils"; + +interface Props { + study: StudyMetadata; + open: boolean; + onClose: VoidFunction; +} + +function ThematicTrimmingDialog(props: Props) { + const { study, open, onClose } = props; + const { t } = useTranslation(); + const { control, register, getValues, setValue } = + useFormContext(); + + //////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////// + + const getCurrentConfig = () => { + return getValues("thematicTrimmingConfig"); + }; + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleUpdateConfig = (fn: Pred) => () => { + const config = getCurrentConfig(); + const fieldNames = getFieldNames(study.version); + const newConfig: ThematicTrimmingConfig = { + ...getCurrentConfig(), + ...R.map(fn, R.pick(fieldNames, config)), + }; + + // More performant than `setValue('thematicTrimmingConfig', newConfig);` + Object.entries(newConfig).forEach(([key, value]) => { + setValue( + `thematicTrimmingConfig.${key as keyof ThematicTrimmingConfig}`, + value + ); + }); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + register("thematicTrimmingConfig", { + onAutoSubmit: () => { + const config = getCurrentConfig(); + const configDTO = thematicTrimmingConfigToDTO(config); + return setThematicTrimmingConfig(study.id, configDTO); + }, + }); + + return ( + {t("button.close")}} + contentProps={{ + sx: { pb: 0 }, + }} + > + + + + + + + + {getColumns(study.version).map((column, index) => ( + + {column.map(([label, name]) => ( + + ))} + + ))} + + + ); +} + +export default ThematicTrimmingDialog; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/utils.ts new file mode 100644 index 0000000000..81a3833de4 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/utils.ts @@ -0,0 +1,196 @@ +import { camelCase } from "lodash"; +import * as R from "ramda"; +import * as RA from "ramda-adjunct"; +import { + StudyMetadata, + ThematicTrimmingConfigDTO, +} from "../../../../../../../../common/types"; + +export interface ThematicTrimmingConfig { + ovCost: boolean; + opCost: boolean; + mrgPrice: boolean; + co2Emis: boolean; + dtgByPlant: boolean; + balance: boolean; + rowBal: boolean; + psp: boolean; + miscNdg: boolean; + load: boolean; + hRor: boolean; + wind: boolean; + solar: boolean; + nuclear: boolean; + lignite: boolean; + coal: boolean; + gas: boolean; + oil: boolean; + mixFuel: boolean; + miscDtg: boolean; + hStor: boolean; + hPump: boolean; + hLev: boolean; + hInfl: boolean; + hOvfl: boolean; + hVal: boolean; + hCost: boolean; + unspEnrg: boolean; + spilEnrg: boolean; + lold: boolean; + lolp: boolean; + avlDtg: boolean; + dtgMrg: boolean; + maxMrg: boolean; + npCost: boolean; + npCostByPlant: boolean; + nodu: boolean; + noduByPlant: boolean; + flowLin: boolean; + ucapLin: boolean; + loopFlow: boolean; + flowQuad: boolean; + congFeeAlg: boolean; + congFeeAbs: boolean; + margCost: boolean; + congProdPlus: boolean; + congProdMinus: boolean; + hurdleCost: boolean; + // Study version >= 810 + resGenerationByPlant?: boolean; + miscDtg2?: boolean; + miscDtg3?: boolean; + miscDtg4?: boolean; + windOffshore?: boolean; + windOnshore?: boolean; + solarConcrt?: boolean; + solarPv?: boolean; + solarRooft?: boolean; + renw1?: boolean; + renw2?: boolean; + renw3?: boolean; + renw4?: boolean; +} + +const keysMap = { + ovCost: "OV. COST", + opCost: "OP. COST", + mrgPrice: "MRG. PRICE", + co2Emis: "CO2 EMIS.", + dtgByPlant: "DTG by plant", + balance: "BALANCE", + rowBal: "ROW BAL.", + psp: "PSP", + miscNdg: "MISC. NDG", + load: "LOAD", + hRor: "H. ROR", + wind: "WIND", + solar: "SOLAR", + nuclear: "NUCLEAR", + lignite: "LIGNITE", + coal: "COAL", + gas: "GAS", + oil: "OIL", + mixFuel: "MIX. FUEL", + miscDtg: "MISC. DTG", + hStor: "H. STOR", + hPump: "H. PUMP", + hLev: "H. LEV", + hInfl: "H. INFL", + hOvfl: "H. OVFL", + hVal: "H. VAL", + hCost: "H. COST", + unspEnrg: "UNSP. ENRG", + spilEnrg: "SPIL. ENRG", + lold: "LOLD", + lolp: "LOLP", + avlDtg: "AVL DTG", + dtgMrg: "DTG MRG", + maxMrg: "MAX MRG", + npCost: "NP COST", + npCostByPlant: "NP Cost by plant", + nodu: "NODU", + noduByPlant: "NODU by plant", + flowLin: "FLOW LIN.", + ucapLin: "UCAP LIN.", + loopFlow: "LOOP FLOW", + flowQuad: "FLOW QUAD.", + congFeeAlg: "CONG. FEE (ALG.)", + congFeeAbs: "CONG. FEE (ABS.)", + margCost: "MARG. COST", + congProdPlus: "CONG. PROD +", + congProdMinus: "CONG. PROD -", + hurdleCost: "HURDLE COST", + resGenerationByPlant: "RES generation by plant", + miscDtg2: "MISC. DTG 2", + miscDtg3: "MISC. DTG 3", + miscDtg4: "MISC. DTG 4", + windOffshore: "WIND OFFSHORE", + windOnshore: "WIND ONSHORE", + solarConcrt: "SOLAR CONCRT.", + solarPv: "SOLAR PV", + solarRooft: "SOLAR ROOFT", + renw1: "RENW. 1", + renw2: "RENW. 2", + renw3: "RENW. 3", + renw4: "RENW. 4", +}; + +export function formatThematicTrimmingConfigDTO( + configDTO: ThematicTrimmingConfigDTO +): ThematicTrimmingConfig { + return Object.entries(configDTO).reduce((acc, [key, value]) => { + const newKey = R.cond([ + [R.equals("CONG. PROD +"), R.always("congProdPlus")], + [R.equals("CONG. PROD -"), R.always("congProdMinus")], + [R.T, camelCase], + ])(key) as keyof ThematicTrimmingConfig; + + acc[newKey] = value; + return acc; + }, {} as ThematicTrimmingConfig); +} + +export function thematicTrimmingConfigToDTO( + config: ThematicTrimmingConfig +): ThematicTrimmingConfigDTO { + return RA.renameKeys(keysMap, config) as ThematicTrimmingConfigDTO; +} + +export function getColumns( + studyVersion: StudyMetadata["version"] +): Array> { + const version = Number(studyVersion); + + return [ + [ + ["OV. Cost", "ovCost"], + ["CO2 Emis.", "co2Emis"], + ["Balance", "balance"], + ["MISC. NDG", "miscNdg"], + ["Wind", "wind"], + ["Lignite", "lignite"], + ], + [ + ["OP. Cost", "opCost"], + ["DTG by plant", "dtgByPlant"], + ["Row bal.", "rowBal"], + ["Load", "load"], + ["Solar", "solar"], + ], + [ + ["MRG. Price", "mrgPrice"], + version >= 810 && ["RES generation by plant", "resGenerationByPlant"], + ["PSP", "psp"], + ["H. ROR", "hRor"], + ["Nuclear", "nuclear"], + ].filter(Boolean) as Array<[string, keyof ThematicTrimmingConfig]>, + ]; +} + +export function getFieldNames( + studyVersion: StudyMetadata["version"] +): Array { + return getColumns(studyVersion).flatMap((column) => { + return column.map(([, fieldName]) => fieldName); + }); +} diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx index 0a9d34b7de..5e45f072c0 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/index.tsx @@ -1,30 +1,60 @@ import { useOutletContext } from "react-router"; +import * as R from "ramda"; +import { useState } from "react"; import { StudyMetadata } from "../../../../../../common/types"; import usePromiseWithSnackbarError from "../../../../../../hooks/usePromiseWithSnackbarError"; import { getFormValues } from "./utils"; import Form from "../../../../../common/Form"; import Fields from "./Fields"; import SimpleLoader from "../../../../../common/loaders/SimpleLoader"; +import ThematicTrimmingDialog from "./dialogs/ThematicTrimmingDialog"; import UsePromiseCond from "../../../../../common/utils/UsePromiseCond"; function GeneralParameters() { const { study } = useOutletContext<{ study: StudyMetadata }>(); + const [dialog, setDialog] = useState<"thematicTrimming" | "">(""); - const { data, status, error } = usePromiseWithSnackbarError( + const res = usePromiseWithSnackbarError( () => getFormValues(study.id), { errorMessage: "Cannot get study data", deps: [study.id] } // TODO i18n ); + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleCloseDialog = () => { + setDialog(""); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + const renderDialog = R.cond([ + [ + R.equals("thematicTrimming"), + () => ( + + ), + ], + ]); + return ( } - ifRejected={
{error}
} - ifResolved={ + response={res} + ifPending={() => } + ifRejected={(error) =>
{error}
} + ifResolved={(data) => (
- + + {renderDialog(dialog)} - } + )} /> ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts index c93ab20f4b..5c9da45f6b 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts @@ -1,6 +1,13 @@ import * as RA from "ramda-adjunct"; import { StudyMetadata } from "../../../../../../common/types"; -import { getStudyData } from "../../../../../../services/api/study"; +import { + getStudyData, + getThematicTrimmingConfig, +} from "../../../../../../services/api/study"; +import { + ThematicTrimmingConfig, + formatThematicTrimmingConfigDTO, +} from "./dialogs/ThematicTrimmingDialog/utils"; enum Month { January = "january", @@ -115,10 +122,11 @@ export interface FormValues { mcScenario: SettingsGeneralDataOutput["storenewset"]; geographicTrimming: SettingsGeneralDataGeneral["geographic-trimming"]; thematicTrimming: SettingsGeneralDataGeneral["thematic-trimming"]; + thematicTrimmingConfig: ThematicTrimmingConfig; filtering: SettingsGeneralDataGeneral["filtering"]; } -const DEFAULT_VALUES: FormValues = { +const DEFAULT_VALUES: Omit = { mode: "Adequacy", firstDay: 1, lastDay: 1, @@ -141,9 +149,10 @@ const DEFAULT_VALUES: FormValues = { export async function getFormValues( studyId: StudyMetadata["id"] ): Promise { + // For unknown reason, `general` and `output` may be empty const { general = {}, output = {} } = await getStudyData<{ - general: Partial; - output: Partial; + general?: Partial; + output?: Partial; }>(studyId, "settings/generaldata", 2); const { @@ -162,6 +171,8 @@ export async function getFormValues( buildingMode = "Custom"; } + const thematicTrimmingConfigDto = await getThematicTrimmingConfig(studyId); + return { ...DEFAULT_VALUES, ...RA.renameKeys( @@ -188,5 +199,8 @@ export async function getFormValues( output ), buildingMode, + thematicTrimmingConfig: formatThematicTrimmingConfigDTO( + thematicTrimmingConfigDto + ), }; } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx index 02943ad9fa..216f6443bf 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx @@ -6,19 +6,20 @@ import { editStudy } from "../../../../../../../services/api/study"; import SelectFE from "../../../../../../common/fieldEditors/SelectFE"; import useEnqueueErrorSnackbar from "../../../../../../../hooks/useEnqueueErrorSnackbar"; import Fieldset from "../../../../../../common/Fieldset"; -import { FormObj } from "../../../../../../common/Form"; +import { UseFormReturnPlus } from "../../../../../../common/Form"; import ColorPickerFE from "../../../../../../common/fieldEditors/ColorPickerFE"; import { stringToRGB } from "../../../../../../common/fieldEditors/ColorPickerFE/utils"; import { getPropertiesPath, PropertiesFields } from "./utils"; import SwitchFE from "../../../../../../common/fieldEditors/SwitchFE"; +import NumberFE from "../../../../../../common/fieldEditors/NumberFE"; export default function PropertiesForm( - props: FormObj & { + props: UseFormReturnPlus & { studyId: string; areaName: string; } ) { - const { register, watch, defaultValues, studyId, areaName } = props; + const { control, getValues, defaultValues, studyId, areaName } = props; const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [t] = useTranslation(); const filterOptions = ["hourly", "daily", "weekly", "monthly", "annual"].map( @@ -43,15 +44,10 @@ export default function PropertiesForm( const renderFilter = (filterName: string) => ( { - const selection = value - ? (value as Array).filter((val) => val !== "") - : []; - handleAutoSubmit(path[filterName], selection.join(", ")); - }, - })} renderValue={(value: unknown) => { const selection = value ? (value as Array).filter((val) => val !== "") @@ -63,8 +59,15 @@ export default function PropertiesForm( defaultValue={(defaultValues || {})[filterName] || []} variant="filled" options={filterOptions} - sx={{ minWidth: "200px" }} - label={t(`study.modelization.nodeProperties.${filterName}`)} + control={control} + rules={{ + onAutoSubmit: (value) => { + const selection = value + ? (value as Array).filter((val) => val !== "") + : []; + handleAutoSubmit(path[filterName], selection.join(", ")); + }, + }} /> ); @@ -102,8 +105,10 @@ export default function PropertiesForm( disabled /> { const color = stringToRGB(value); if (color) { @@ -111,37 +116,39 @@ export default function PropertiesForm( color_r: color.r, color_g: color.g, color_b: color.b, - x: watch("posX"), - y: watch("posY"), + x: getValues("posX"), + y: getValues("posY"), }); } }, - })} + }} /> - handleAutoSubmit(path.posX, value), - })} + }} /> - handleAutoSubmit(path.posY, value), - })} + }} /> @@ -165,36 +172,38 @@ export default function PropertiesForm( mt: 1, }} > - handleAutoSubmit(path.energieCostUnsupplied, value), - })} + }} /> - handleAutoSubmit(path.energieCostSpilled, value), - })} + }} /> @@ -216,29 +225,35 @@ export default function PropertiesForm( }} > handleAutoSubmit(path.nonDispatchPower, value), - })} + }} /> handleAutoSubmit(path.dispatchHydroPower, value), - })} + }} /> handleAutoSubmit(path.otherDispatchPower, value), - })} + }} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx index 3a627d10ed..97f7f4adac 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx @@ -7,7 +7,7 @@ import useAppSelector from "../../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../../redux/selectors"; import Form from "../../../../../../common/Form"; import PropertiesForm from "./PropertiesForm"; -import { getDefaultValues, PropertiesFields } from "./utils"; +import { getDefaultValues } from "./utils"; import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; @@ -15,7 +15,7 @@ function Properties() { const { study } = useOutletContext<{ study: StudyMetadata }>(); const currentArea = useAppSelector(getCurrentAreaId); const [t] = useTranslation(); - const { data: defaultValues, status } = usePromise( + const res = usePromise( () => getDefaultValues(study.id, currentArea, t), [study.id, currentArea] ); @@ -23,13 +23,10 @@ function Properties() { return ( } - ifResolved={ -
+ response={res} + ifPending={() => } + ifResolved={(data) => ( + {(formObj) => PropertiesForm({ ...formObj, @@ -38,7 +35,7 @@ function Properties() { }) } - } + )} />
); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx index 14ac6a533b..972a29c9fb 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx @@ -5,7 +5,10 @@ import { useTranslation } from "react-i18next"; import { editStudy } from "../../../../../../../services/api/study"; import useEnqueueErrorSnackbar from "../../../../../../../hooks/useEnqueueErrorSnackbar"; import Fieldset from "../../../../../../common/Fieldset"; -import { AutoSubmitHandler, FormObj } from "../../../../../../common/Form"; +import { + AutoSubmitHandler, + useFormContext, +} from "../../../../../../common/Form"; import { getLinkPath, LinkFields } from "./utils"; import SwitchFE from "../../../../../../common/fieldEditors/SwitchFE"; import { @@ -17,13 +20,13 @@ import SelectFE from "../../../../../../common/fieldEditors/SelectFE"; import MatrixInput from "../../../../../../common/MatrixInput"; import LinkMatrixView from "./LinkMatrixView"; -export default function LinkForm( - props: FormObj & { - link: LinkElement; - study: StudyMetadata; - } -) { - const { register, defaultValues, study, link } = props; +interface Props { + link: LinkElement; + study: StudyMetadata; +} + +function LinkForm(props: Props) { + const { study, link } = props; const studyId = study.id; const isTabMatrix = useMemo((): boolean => { let version = 0; @@ -42,6 +45,8 @@ export default function LinkForm( return getLinkPath(area1, area2); }, [area1, area2]); + const { control, defaultValues } = useFormContext(); + const optionTransCap = ["infinite", "ignore", "enabled"].map((item) => ({ label: t(`study.modelization.links.transmissionCapa.${item}`), value: item.toLowerCase(), @@ -168,14 +173,7 @@ export default function LinkForm( ) => ( { - handleAutoSubmit(path[filterName], value); - }), - })} - defaultValue={(defaultValues || {})[filterName] || []} + name={filterName} variant="filled" options={options} formControlProps={{ @@ -187,21 +185,22 @@ export default function LinkForm( }} sx={{ width: "100%", minWidth: "200px" }} label={t(`study.modelization.links.${filterName}`)} + control={control} + rules={{ + onAutoSubmit: + onAutoSubmit || + ((value) => { + handleAutoSubmit(path[filterName], value); + }), + }} /> ); const renderFilter = (filterName: string) => ( { - const selection = value - ? (value as Array).filter((val) => val !== "") - : []; - handleAutoSubmit(path[filterName], selection.join(", ")); - }, - })} renderValue={(value: unknown) => { const selection = value ? (value as Array).filter((val) => val !== "") @@ -210,11 +209,19 @@ export default function LinkForm( ? selection.map((elm) => t(`study.${elm}`)).join(", ") : t("global.none"); }} - defaultValue={(defaultValues || {})[filterName] || []} variant="filled" options={filterOptions} sx={{ minWidth: "200px" }} label={t(`study.modelization.nodeProperties.${filterName}`)} + control={control} + rules={{ + onAutoSubmit: (value) => { + const selection = value + ? (value as Array).filter((val) => val !== "") + : []; + handleAutoSubmit(path[filterName], selection.join(", ")); + }, + }} /> ); @@ -242,26 +249,32 @@ export default function LinkForm( }} > handleAutoSubmit(path.hurdleCost, value), - })} + }} /> handleAutoSubmit(path.loopFlows, value), - })} + }} /> handleAutoSubmit(path.pst, value), - })} + }} /> {renderSelect("transmissionCapa", optionTransCap)} {renderSelect("type", optionType, handleTypeAutoSubmit)} @@ -305,3 +318,5 @@ export default function LinkForm( ); } + +export default LinkForm; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/index.tsx index ac514449d9..369ef48a26 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/index.tsx @@ -4,7 +4,7 @@ import { LinkElement, StudyMetadata } from "../../../../../../../common/types"; import usePromise from "../../../../../../../hooks/usePromise"; import Form from "../../../../../../common/Form"; import LinkForm from "./LinkForm"; -import { getDefaultValues, LinkFields } from "./utils"; +import { getDefaultValues } from "./utils"; import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; @@ -15,7 +15,7 @@ interface Props { function LinkView(props: Props) { const { study } = useOutletContext<{ study: StudyMetadata }>(); const { link } = props; - const { data: defaultValues, status } = usePromise( + const res = usePromise( () => getDefaultValues(study.id, link.area1, link.area2), [study.id, link.area1, link.area2] ); @@ -23,22 +23,13 @@ function LinkView(props: Props) { return ( } - ifResolved={ -
- {(formObj) => - LinkForm({ - ...formObj, - link, - study, - }) - } + response={res} + ifPending={() => } + ifResolved={(data) => ( + + - } + )} />
); diff --git a/webapp/src/components/common/Form/index.tsx b/webapp/src/components/common/Form/index.tsx index 098345cc12..870c97591f 100644 --- a/webapp/src/components/common/Form/index.tsx +++ b/webapp/src/components/common/Form/index.tsx @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { FormEvent, useCallback, useRef } from "react"; +import { FormEvent, useCallback, useEffect, useMemo, useRef } from "react"; import { + Control, FieldPath, FieldPathValue, FieldValues, @@ -23,7 +24,6 @@ import { Button } from "@mui/material"; import { useUpdateEffect } from "react-use"; import * as R from "ramda"; import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; -import BackdropLoading from "../loaders/BackdropLoading"; import useDebounce from "../../../hooks/useDebounce"; import { getDirtyValues, stringToPath, toAutoSubmitConfig } from "./utils"; @@ -39,30 +39,40 @@ export type AutoSubmitHandler< TFieldName extends FieldPath = FieldPath > = (value: FieldPathValue) => any | Promise; -export interface UseFormRegisterReturnPlus< +export interface RegisterOptionsPlus< TFieldValues extends FieldValues = FieldValues, TFieldName extends FieldPath = FieldPath -> extends UseFormRegisterReturn { - defaultValue?: FieldPathValue; - error?: boolean; - helperText?: string; +> extends RegisterOptions { + onAutoSubmit?: AutoSubmitHandler; } export type UseFormRegisterPlus< TFieldValues extends FieldValues = FieldValues > = = FieldPath>( name: TFieldName, - options?: RegisterOptions & { - onAutoSubmit?: AutoSubmitHandler; - } -) => UseFormRegisterReturnPlus; + options?: RegisterOptionsPlus +) => UseFormRegisterReturn; -export interface FormObj< +export interface ControlPlus< + TFieldValues extends FieldValues = FieldValues, + TContext = any +> extends Control { + register: UseFormRegisterPlus; +} + +export interface FormStatePlus + extends FormState { + isSubmitAllowed: boolean; +} + +export interface UseFormReturnPlus< TFieldValues extends FieldValues = FieldValues, TContext = any > extends UseFormReturn { register: UseFormRegisterPlus; + control: ControlPlus; defaultValues?: UseFormProps["defaultValues"]; + formState: FormStatePlus; } export type AutoSubmitConfig = { enable: boolean; wait?: number }; @@ -70,32 +80,23 @@ export type AutoSubmitConfig = { enable: boolean; wait?: number }; export interface FormProps< TFieldValues extends FieldValues = FieldValues, TContext = any -> { +> extends Omit, "onSubmit"> { config?: UseFormProps; onSubmit?: ( data: SubmitHandlerData, event?: React.BaseSyntheticEvent ) => any | Promise; children: - | ((formObj: FormObj) => React.ReactNode) + | ((formObj: UseFormReturnPlus) => React.ReactNode) | React.ReactNode; submitButtonText?: string; hideSubmitButton?: boolean; onStateChange?: (state: FormState) => void; autoSubmit?: boolean | AutoSubmitConfig; - id?: string; } -interface UseFormReturnPlus - extends UseFormReturn { - register: UseFormRegisterPlus; - defaultValues?: UseFormProps["defaultValues"]; -} - -export function useFormContext< - TFieldValues extends FieldValues ->(): UseFormReturnPlus { - return useFormContextOriginal(); +export function useFormContext() { + return useFormContextOriginal() as UseFormReturnPlus; } function Form( @@ -109,16 +110,27 @@ function Form( hideSubmitButton, onStateChange, autoSubmit, - id, + ...formProps } = props; + const formObj = useForm({ mode: "onChange", ...config, }); - const { handleSubmit, formState, register, unregister, reset, setValue } = - formObj; - const { isValid, isSubmitting, isDirty, dirtyFields, errors } = formState; - const allowSubmit = isDirty && isValid && !isSubmitting; + + const { + getValues, + register, + unregister, + setValue, + control, + handleSubmit, + formState, + reset, + } = formObj; + + const { isValid, isSubmitting, isDirty, dirtyFields } = formState; + const isSubmitAllowed = isDirty && isValid && !isSubmitting; const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const { t } = useTranslation(); const submitRef = useRef(null); @@ -126,7 +138,7 @@ function Form( const fieldAutoSubmitListeners = useRef< Record any | Promise) | undefined> >({}); - const lastDataSubmitted = useRef>(); + const preventClose = useRef(false); useUpdateEffect( () => { @@ -134,13 +146,29 @@ function Form( // It's recommended to reset inside useEffect after submission: https://react-hook-form.com/api/useform/reset if (formState.isSubmitSuccessful) { - reset(lastDataSubmitted.current); + reset(getValues()); } }, // Entire `formState` must be put in the deps: https://react-hook-form.com/api/useform/formstate [formState] ); + // Prevent browser close if a submit is pending (auto submit enabled) + useEffect(() => { + const listener = (event: BeforeUnloadEvent) => { + if (preventClose.current) { + // eslint-disable-next-line no-param-reassign + event.returnValue = "Form not submitted yet. Sure you want to leave?"; // TODO i18n + } + }; + + window.addEventListener("beforeunload", listener); + + return () => { + window.removeEventListener("beforeunload", listener); + }; + }, []); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -149,8 +177,6 @@ function Form( event.preventDefault(); handleSubmit((data, e) => { - lastDataSubmitted.current = data; - const dirtyValues = getDirtyValues(dirtyFields, data) as Partial< typeof data >; @@ -174,62 +200,51 @@ function Form( } return Promise.all(res); - })().catch((error) => { - enqueueErrorSnackbar(t("form.submit.error"), error); - }); + })() + .catch((error) => { + enqueueErrorSnackbar(t("form.submit.error"), error); + }) + .finally(() => { + preventClose.current = false; + }); }; //////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////// - const simulateSubmit = useDebounce(() => { + const simulateSubmitClick = useDebounce(() => { submitRef.current?.click(); }, autoSubmitConfig.wait); + const simulateSubmit = useCallback(() => { + preventClose.current = true; + simulateSubmitClick(); + }, [simulateSubmitClick]); + + //////////////////////////////////////////////////////////////// + // API + //////////////////////////////////////////////////////////////// + const registerWrapper = useCallback>( (name, options) => { if (options?.onAutoSubmit) { fieldAutoSubmitListeners.current[name] = options.onAutoSubmit; } - const newOptions = { + const newOptions: typeof options = { ...options, - onChange: (e: unknown) => { - options?.onChange?.(e); + onChange: (event: any) => { + options?.onChange?.(event); if (autoSubmitConfig.enable) { simulateSubmit(); } }, }; - const res = register(name, newOptions) as UseFormRegisterReturnPlus< - TFieldValues, - typeof name - >; - - const error = errors[name]; - - if (RA.isNotNil(config?.defaultValues?.[name])) { - res.defaultValue = config?.defaultValues?.[name]; - } - - if (error) { - res.error = true; - if (error.message) { - res.helperText = error.message; - } - } - - return res; + return register(name, newOptions); }, - [ - autoSubmitConfig.enable, - config?.defaultValues, - errors, - register, - simulateSubmit, - ] + [autoSubmitConfig.enable, register, simulateSubmit] ); const unregisterWrapper = useCallback>( @@ -261,41 +276,51 @@ function Form( [autoSubmitConfig.enable, setValue, simulateSubmit] ); + const controlWrapper = useMemo>(() => { + // Don't use spread to keep getters and setters + control.register = registerWrapper; + control.unregister = unregisterWrapper; + return control; + }, [control, registerWrapper, unregisterWrapper]); + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// const sharedProps = { ...formObj, + formState: { + ...formState, + isSubmitAllowed, + }, defaultValues: config?.defaultValues, register: registerWrapper, unregister: unregisterWrapper, setValue: setValueWrapper, + control: controlWrapper, }; return ( - -
- {RA.isFunction(children) ? ( - children(sharedProps) - ) : ( - {children} - )} - -
-
+
+ {RA.isFunction(children) ? ( + children(sharedProps) + ) : ( + {children} + )} + +
); } diff --git a/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx b/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx index e8c1dee593..396484fa8d 100644 --- a/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx +++ b/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx @@ -3,7 +3,7 @@ import { AxiosError } from "axios"; import { useSnackbar } from "notistack"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { MatrixDTO, MatrixInfoDTO, StudyMetadata } from "../../../common/types"; +import { MatrixInfoDTO, StudyMetadata } from "../../../common/types"; import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; import usePromiseWithSnackbarError from "../../../hooks/usePromiseWithSnackbarError"; import { getMatrix, getMatrixList } from "../../../services/api/matrix"; @@ -33,19 +33,11 @@ function MatrixAssignDialog(props: Props) { const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const { enqueueSnackbar } = useSnackbar(); - const { - data: dataList, - status: statusList, - error: errorList, - } = usePromiseWithSnackbarError(() => getMatrixList(), { + const resList = usePromiseWithSnackbarError(() => getMatrixList(), { errorMessage: t("data.error.matrixList"), }); - const { - data: dataMatrix, - status: statusMatrix, - error: errorMatrix, - } = usePromiseWithSnackbarError( + const resMatrix = usePromiseWithSnackbarError( async () => { if (currentMatrix) { const res = await getMatrix(currentMatrix.id); @@ -62,10 +54,8 @@ function MatrixAssignDialog(props: Props) { setCurrentMatrix(undefined); }, [selectedItem]); - const dataSet = dataList?.find((item) => item.id === selectedItem); - + const dataSet = resList.data?.find((item) => item.id === selectedItem); const matrices = dataSet?.matrices; - const matrixName = `${t("global.matrixes")} - ${dataSet?.name}`; //////////////////////////////////////////////////////////////// @@ -117,16 +107,16 @@ function MatrixAssignDialog(props: Props) { sx: { width: "1200px", height: "700px" }, }} > - {dataList && ( - } - ifRejected={
{errorList}
} - ifResolved={ + } + ifRejected={(error) =>
{error}
} + ifResolved={(dataset) => + dataset && ( @@ -160,12 +150,12 @@ function MatrixAssignDialog(props: Props) { onAssign={handleAssignation} /> )} - {currentMatrix && dataMatrix && ( - } - ifRejected={
{errorMatrix}
} - ifResolved={ + } + ifRejected={(error) =>
{error}
} + ifResolved={(matrix) => + matrix && ( <> - } - /> - )} + ) + } + /> } /> - } - /> - )} + ) + } + /> ); } diff --git a/webapp/src/components/common/fieldEditors/BooleanFE.tsx b/webapp/src/components/common/fieldEditors/BooleanFE.tsx index 3152ac196b..b5dbb59f5b 100644 --- a/webapp/src/components/common/fieldEditors/BooleanFE.tsx +++ b/webapp/src/components/common/fieldEditors/BooleanFE.tsx @@ -1,6 +1,6 @@ import { SelectChangeEvent } from "@mui/material"; import * as RA from "ramda-adjunct"; -import { forwardRef } from "react"; +import reactHookFormSupport from "../../../hoc/reactHookFormSupport"; import SelectFE, { SelectFEProps } from "./SelectFE"; interface BooleanFEProps extends Omit { @@ -31,7 +31,7 @@ function toValidEvent< } as T; } -const BooleanFE = forwardRef((props: BooleanFEProps, ref) => { +function BooleanFE(props: BooleanFEProps) { const { defaultValue, value, @@ -39,6 +39,7 @@ const BooleanFE = forwardRef((props: BooleanFEProps, ref) => { falseText, onChange, onBlur, + inputRef, ...rest } = props; @@ -58,11 +59,9 @@ const BooleanFE = forwardRef((props: BooleanFEProps, ref) => { { label: trueText || "True", value: "true" }, { label: falseText || "False", value: "false" }, ]} - ref={ref} + inputRef={inputRef} /> ); -}); - -BooleanFE.displayName = "BooleanFE"; +} -export default BooleanFE; +export default reactHookFormSupport({ defaultValue: false })(BooleanFE); diff --git a/webapp/src/components/common/fieldEditors/CheckboxesTagsFE.tsx b/webapp/src/components/common/fieldEditors/CheckboxesTagsFE.tsx index c46749a013..5fc65903f4 100644 --- a/webapp/src/components/common/fieldEditors/CheckboxesTagsFE.tsx +++ b/webapp/src/components/common/fieldEditors/CheckboxesTagsFE.tsx @@ -6,7 +6,6 @@ import { } from "@mui/material"; import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank"; import CheckBoxIcon from "@mui/icons-material/CheckBox"; -import { forwardRef } from "react"; import { mergeSxProp } from "../../../utils/muiUtils"; interface CheckboxesTagsFEProps< @@ -24,16 +23,14 @@ interface CheckboxesTagsFEProps< label?: string; error?: boolean; helperText?: string; + inputRef?: React.Ref; } function CheckboxesTagsFE< T, DisableClearable extends boolean | undefined = undefined, FreeSolo extends boolean | undefined = undefined ->( - props: CheckboxesTagsFEProps, - ref: React.Ref -) { +>(props: CheckboxesTagsFEProps) { const { label, sx, @@ -42,13 +39,14 @@ function CheckboxesTagsFE< getOptionLabel = (option: any) => option?.label ?? option, error, helperText, + inputRef, ...rest } = props; return ( ( - props: CheckboxesTagsFEProps & { - ref?: React.Ref; - } -) => ReturnType; +export default CheckboxesTagsFE; diff --git a/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx b/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx index 1a17d77b42..77e86a8d6f 100644 --- a/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx +++ b/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx @@ -1,5 +1,5 @@ import { Box, TextField, TextFieldProps, InputAdornment } from "@mui/material"; -import { ChangeEvent, forwardRef, useRef, useState } from "react"; +import { ChangeEvent, useRef, useState } from "react"; import { ColorResult, SketchPicker } from "react-color"; import { useTranslation } from "react-i18next"; import SquareRoundedIcon from "@mui/icons-material/SquareRounded"; @@ -7,6 +7,7 @@ import { useClickAway, useKey, useUpdateEffect } from "react-use"; import { rgbToString, stringToRGB } from "./utils"; import { mergeSxProp } from "../../../../utils/muiUtils"; import { composeRefs } from "../../../../utils/reactUtils"; +import reactHookFormSupport from "../../../../hoc/reactHookFormSupport"; export type ColorPickerFEProps = Omit< TextFieldProps, @@ -16,8 +17,9 @@ export type ColorPickerFEProps = Omit< defaultValue?: string; }; -const ColorPickerFE = forwardRef((props: ColorPickerFEProps, ref) => { - const { value, defaultValue, onChange, sx, ...textFieldProps } = props; +function ColorPickerFE(props: ColorPickerFEProps) { + const { value, defaultValue, onChange, sx, inputRef, ...textFieldProps } = + props; const [currentColor, setCurrentColor] = useState(defaultValue || value || ""); const [isPickerOpen, setIsPickerOpen] = useState(false); const internalRef = useRef(); @@ -75,7 +77,7 @@ const ColorPickerFE = forwardRef((props: ColorPickerFEProps, ref) => { sx={{ mx: 1 }} value={currentColor} placeholder={currentColor} - inputRef={composeRefs(ref, internalRef)} + inputRef={composeRefs(inputRef, internalRef)} InputProps={{ startAdornment: ( @@ -108,8 +110,6 @@ const ColorPickerFE = forwardRef((props: ColorPickerFEProps, ref) => { )} ); -}); +} -ColorPickerFE.displayName = "ColorPicker"; - -export default ColorPickerFE; +export default reactHookFormSupport({ defaultValue: "" })(ColorPickerFE); diff --git a/webapp/src/components/common/fieldEditors/NumberFE.tsx b/webapp/src/components/common/fieldEditors/NumberFE.tsx new file mode 100644 index 0000000000..02ba592808 --- /dev/null +++ b/webapp/src/components/common/fieldEditors/NumberFE.tsx @@ -0,0 +1,16 @@ +import { TextField, TextFieldProps } from "@mui/material"; +import withReactHookFormSupport from "../../../hoc/reactHookFormSupport"; + +export type NumberFEProps = { + value?: number; + defaultValue?: number; +} & Omit; + +function NumberFE(props: NumberFEProps) { + return ; +} + +export default withReactHookFormSupport({ + defaultValue: 0, + setValueAs: Number, +})(NumberFE); diff --git a/webapp/src/components/common/fieldEditors/PasswordFE.tsx b/webapp/src/components/common/fieldEditors/PasswordFE.tsx new file mode 100644 index 0000000000..4a0d1447cf --- /dev/null +++ b/webapp/src/components/common/fieldEditors/PasswordFE.tsx @@ -0,0 +1,13 @@ +import { TextField, TextFieldProps } from "@mui/material"; +import reactHookFormSupport from "../../../hoc/reactHookFormSupport"; + +export type PasswordFEProps = { + value?: string; + defaultValue?: string; +} & Omit; + +function PasswordFE(props: PasswordFEProps) { + return ; +} + +export default reactHookFormSupport({ defaultValue: "" })(PasswordFE); diff --git a/webapp/src/components/common/fieldEditors/SelectFE.tsx b/webapp/src/components/common/fieldEditors/SelectFE.tsx index 2728a72b70..0fb7bf900c 100644 --- a/webapp/src/components/common/fieldEditors/SelectFE.tsx +++ b/webapp/src/components/common/fieldEditors/SelectFE.tsx @@ -7,19 +7,19 @@ import { Select, SelectProps, } from "@mui/material"; -import { forwardRef, useMemo, useRef } from "react"; +import { useMemo, useRef } from "react"; import { v4 as uuidv4 } from "uuid"; import * as RA from "ramda-adjunct"; import { startCase } from "lodash"; import { O } from "ts-toolbelt"; +import reactHookFormSupport from "../../../hoc/reactHookFormSupport"; type OptionObj = { label: string; value: string | number; } & T; -export interface SelectFEProps - extends Omit { +export interface SelectFEProps extends Omit { options: Array; helperText?: React.ReactNode; emptyValue?: boolean; @@ -35,13 +35,14 @@ function formatOptions( })); } -const SelectFE = forwardRef((props: SelectFEProps, ref) => { +function SelectFE(props: SelectFEProps) { const { options, helperText, emptyValue, variant = "filled", formControlProps, + inputRef, ...selectProps } = props; const { label } = selectProps; @@ -56,12 +57,7 @@ const SelectFE = forwardRef((props: SelectFEProps, ref) => { return ( {label} - {emptyValue && ( {/* TODO i18n */} @@ -77,8 +73,6 @@ const SelectFE = forwardRef((props: SelectFEProps, ref) => { {helperText && {helperText}} ); -}); - -SelectFE.displayName = "SelectFE"; +} -export default SelectFE; +export default reactHookFormSupport()(SelectFE); diff --git a/webapp/src/components/common/fieldEditors/StringFE.tsx b/webapp/src/components/common/fieldEditors/StringFE.tsx new file mode 100644 index 0000000000..a915a40289 --- /dev/null +++ b/webapp/src/components/common/fieldEditors/StringFE.tsx @@ -0,0 +1,13 @@ +import { TextField, TextFieldProps } from "@mui/material"; +import reactHookFormSupport from "../../../hoc/reactHookFormSupport"; + +export type StringFEProps = { + value?: string; + defaultValue?: string; +} & Omit; + +function StringFE(props: StringFEProps) { + return ; +} + +export default reactHookFormSupport({ defaultValue: "" })(StringFE); diff --git a/webapp/src/components/common/fieldEditors/SwitchFE.tsx b/webapp/src/components/common/fieldEditors/SwitchFE.tsx index d7c5b3e33b..2472db05f1 100644 --- a/webapp/src/components/common/fieldEditors/SwitchFE.tsx +++ b/webapp/src/components/common/fieldEditors/SwitchFE.tsx @@ -4,13 +4,11 @@ import { Switch, SwitchProps, } from "@mui/material"; -import { forwardRef } from "react"; +import clsx from "clsx"; +import reactHookFormSupport from "../../../hoc/reactHookFormSupport"; export interface SwitchFEProps - extends Omit< - SwitchProps, - "checked" | "defaultChecked" | "defaultValue" | "inputRef" - > { + extends Omit { value?: boolean; defaultValue?: boolean; label?: string; @@ -19,7 +17,7 @@ export interface SwitchFEProps helperText?: React.ReactNode; } -const SwitchFE = forwardRef((props: SwitchFEProps, ref) => { +function SwitchFE(props: SwitchFEProps) { const { value, defaultValue, @@ -27,6 +25,7 @@ const SwitchFE = forwardRef((props: SwitchFEProps, ref) => { labelPlacement, helperText, error, + className, sx, ...rest } = props; @@ -34,10 +33,10 @@ const SwitchFE = forwardRef((props: SwitchFEProps, ref) => { const fieldEditor = ( ); @@ -45,6 +44,7 @@ const SwitchFE = forwardRef((props: SwitchFEProps, ref) => { return ( { } return fieldEditor; -}); - -SwitchFE.displayName = "SwitchFE"; +} -export default SwitchFE; +export default reactHookFormSupport({ defaultValue: false })(SwitchFE); diff --git a/webapp/src/components/common/utils/UsePromiseCond.tsx b/webapp/src/components/common/utils/UsePromiseCond.tsx index 4581d87bbf..a348387faf 100644 --- a/webapp/src/components/common/utils/UsePromiseCond.tsx +++ b/webapp/src/components/common/utils/UsePromiseCond.tsx @@ -1,15 +1,16 @@ import * as R from "ramda"; -import { PromiseStatus } from "../../../hooks/usePromise"; +import { PromiseStatus, UsePromiseResponse } from "../../../hooks/usePromise"; -export interface UsePromiseCondProps { - status: PromiseStatus; - ifPending?: React.ReactNode; - ifRejected?: React.ReactNode; - ifResolved?: React.ReactNode; +export interface UsePromiseCondProps { + response: UsePromiseResponse; + ifPending?: () => React.ReactNode; + ifRejected?: (error: UsePromiseResponse["error"]) => React.ReactNode; + ifResolved?: (data: UsePromiseResponse["data"]) => React.ReactNode; } -function UsePromiseCond(props: UsePromiseCondProps) { - const { status, ifPending, ifRejected, ifResolved } = props; +function UsePromiseCond(props: UsePromiseCondProps) { + const { response, ifPending, ifRejected, ifResolved } = props; + const { status, data, error } = response; return ( <> @@ -19,10 +20,10 @@ function UsePromiseCond(props: UsePromiseCondProps) { R.equals(PromiseStatus.Idle), R.equals(PromiseStatus.Pending) ), - () => ifPending, + () => ifPending?.(), ], - [R.equals(PromiseStatus.Rejected), () => ifRejected], - [R.equals(PromiseStatus.Resolved), () => ifResolved], + [R.equals(PromiseStatus.Rejected), () => ifRejected?.(error)], + [R.equals(PromiseStatus.Resolved), () => ifResolved?.(data)], ])(status)} ); diff --git a/webapp/src/components/wrappers/LoginWrapper.tsx b/webapp/src/components/wrappers/LoginWrapper.tsx index 982203b4a1..a3a5b93fbd 100644 --- a/webapp/src/components/wrappers/LoginWrapper.tsx +++ b/webapp/src/components/wrappers/LoginWrapper.tsx @@ -1,13 +1,7 @@ import { ReactNode, useState } from "react"; -import { - Box, - Button, - CircularProgress, - TextField, - Typography, -} from "@mui/material"; +import { Box, Typography } from "@mui/material"; import { useTranslation } from "react-i18next"; -import { SubmitHandler, useForm } from "react-hook-form"; +import { LoadingButton } from "@mui/lab"; import { login } from "../../redux/ducks/auth"; import logo from "../../assets/logo.png"; import topRightBackground from "../../assets/top-right-background.png"; @@ -19,8 +13,11 @@ import usePromiseWithSnackbarError from "../../hooks/usePromiseWithSnackbarError import useAppSelector from "../../redux/hooks/useAppSelector"; import useAppDispatch from "../../redux/hooks/useAppDispatch"; import storage, { StorageKey } from "../../services/utils/localStorage"; +import Form, { SubmitHandlerData } from "../common/Form"; +import StringFE from "../common/fieldEditors/StringFE"; +import PasswordFE from "../common/fieldEditors/PasswordFE"; -interface Inputs { +interface FormValues { username: string; password: string; } @@ -31,7 +28,6 @@ interface Props { function LoginWrapper(props: Props) { const { children } = props; - const { register, handleSubmit, reset, formState } = useForm(); const [loginError, setLoginError] = useState(""); const { t } = useTranslation(); const user = useAppSelector(getAuthUser); @@ -68,18 +64,18 @@ function LoginWrapper(props: Props) { // Event Handlers //////////////////////////////////////////////////////////////// - const handleLoginSubmit: SubmitHandler = async (data) => { + const handleSubmit = async (data: SubmitHandlerData) => { + const { values } = data; + setLoginError(""); - setTimeout(async () => { - try { - await dispatch(login(data)).unwrap(); - } catch (e) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setLoginError((e as any).data?.message || t("login.error")); - } finally { - reset({ username: data.username }); - } - }, 500); + + try { + await dispatch(login(values)).unwrap(); + } catch (err) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + setLoginError((err as any).data?.message || t("login.error")); + throw err; + } }; //////////////////////////////////////////////////////////////// @@ -152,53 +148,50 @@ function LoginWrapper(props: Props) { -
- - - {loginError && ( - - {loginError} - - )} - - - - + + + {t("global.connexion")} + + + + )} +
diff --git a/webapp/src/hoc/reactHookFormSupport.tsx b/webapp/src/hoc/reactHookFormSupport.tsx new file mode 100644 index 0000000000..ef62703ffb --- /dev/null +++ b/webapp/src/hoc/reactHookFormSupport.tsx @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import hoistNonReactStatics from "hoist-non-react-statics"; +import React from "react"; +import { Controller, FieldPath, FieldValues } from "react-hook-form"; +import * as R from "ramda"; +import { ControlPlus, RegisterOptionsPlus } from "../components/common/Form"; +import { getComponentDisplayName } from "../utils/reactUtils"; + +interface ReactHookFormSupport { + defaultValue?: NonNullable; + setValueAs?: (value: any) => any; +} + +// `...args: any` allows to be compatible with all field editors +type EventHandler = (...args: any) => void; + +interface FieldEditorProps { + value?: TValue; + defaultValue?: TValue; + onChange?: EventHandler; + onBlur?: EventHandler; + name?: string; +} + +type ReactHookFormSupportProps< + TFieldValues extends FieldValues = FieldValues, + TFieldName extends FieldPath = FieldPath, + TContext = any +> = + | { + control: ControlPlus; + rules?: Omit< + RegisterOptionsPlus, + // cf. UseControllerProps#rules + "valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled" + >; + shouldUnregister?: boolean; + name: TFieldName; + } + | { + control?: undefined; + rules?: never; + shouldUnregister?: never; + }; + +function reactHookFormSupport( + options: ReactHookFormSupport = {} +) { + const { defaultValue, setValueAs = R.identity } = options; + + function wrapWithReactHookFormSupport< + TProps extends FieldEditorProps + >(FieldEditor: React.ComponentType) { + function ReactHookFormSupport< + TFieldValues extends FieldValues = FieldValues, + TFieldName extends FieldPath = FieldPath, + TContext = any + >( + props: ReactHookFormSupportProps & + TProps + ) { + const { control, rules, shouldUnregister, ...fieldEditorProps } = props; + const { name, onChange, onBlur } = fieldEditorProps; + + if (control && name) { + return ( + { + onChange?.(event); + rules?.onChange?.(event); + }, + onBlur: (event) => { + onBlur?.(event); + rules?.onBlur?.(event); + }, + }} + shouldUnregister={shouldUnregister} + render={({ + field: { ref, onChange, ...fieldProps }, + fieldState: { error }, + }) => ( + { + onChange( + setValueAs( + event.target.type === "checkbox" + ? event.target.checked + : event.target.value + ) + ); + }} + inputRef={ref} + error={!!error} + helperText={error?.message} + /> + )} + /> + ); + } + + return ; + } + + ReactHookFormSupport.displayName = `ReactHookFormSupport(${getComponentDisplayName( + FieldEditor + )})`; + + return hoistNonReactStatics(ReactHookFormSupport, FieldEditor); + } + + return wrapWithReactHookFormSupport; +} + +export default reactHookFormSupport; diff --git a/webapp/src/hooks/useAutoUpdateRef.tsx b/webapp/src/hooks/useAutoUpdateRef.tsx new file mode 100644 index 0000000000..70d489b5b2 --- /dev/null +++ b/webapp/src/hooks/useAutoUpdateRef.tsx @@ -0,0 +1,13 @@ +import { useEffect, useRef } from "react"; + +function useAutoUpdateRef(value: T): React.MutableRefObject { + const ref = useRef(value); + + useEffect(() => { + ref.current = value; + }); + + return ref; +} + +export default useAutoUpdateRef; diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index 821129b1ca..7d75e27646 100644 --- a/webapp/src/services/api/study.ts +++ b/webapp/src/services/api/study.ts @@ -13,6 +13,7 @@ import { AreasConfig, LaunchJobDTO, StudyMetadataPatchDTO, + ThematicTrimmingConfigDTO, } from "../../common/types"; import { getConfig } from "../config"; import { convertStudyDtoToMetadata } from "../utils"; @@ -387,4 +388,18 @@ export const scanFolder = async (folderPath: string): Promise => { await client.post(`/v1/watcher/_scan?path=${encodeURIComponent(folderPath)}`); }; -export default {}; +export const getThematicTrimmingConfig = async ( + studyId: StudyMetadata["id"] +): Promise => { + const res = await client.get( + `/v1/studies/${studyId}/config/thematic_trimming` + ); + return res.data; +}; + +export const setThematicTrimmingConfig = async ( + studyId: StudyMetadata["id"], + config: ThematicTrimmingConfigDTO +): Promise => { + await client.put(`/v1/studies/${studyId}/config/thematic_trimming`, config); +}; diff --git a/webapp/src/theme.ts b/webapp/src/theme.ts index 0cb4c1dce9..bfb2d709b5 100644 --- a/webapp/src/theme.ts +++ b/webapp/src/theme.ts @@ -11,7 +11,6 @@ export const STUDIES_FILTER_WIDTH = 300; const secondaryMainColor = "#00B2FF"; export const PAPER_BACKGROUND_NO_TRANSPARENCY = "#212c38"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any export const scrollbarStyle = { "&::-webkit-scrollbar": { width: "7px", @@ -130,7 +129,6 @@ const theme = createTheme({ background: "rgba(255, 255, 255, 0.09)", borderRadius: "4px 4px 0px 0px", borderBottom: "1px solid rgba(255, 255, 255, 0.42)", - paddingRight: 6, ".MuiSelect-icon": { backgroundColor: "#222333", }, diff --git a/webapp/src/utils/reactUtils.ts b/webapp/src/utils/reactUtils.ts index 88829376a5..fce9e26f73 100644 --- a/webapp/src/utils/reactUtils.ts +++ b/webapp/src/utils/reactUtils.ts @@ -6,8 +6,16 @@ export function isDependencyList( return Array.isArray(value); } -export function composeRefs(...refs: React.Ref[]) { +export function composeRefs( + ...refs: Array | undefined | null> +) { return function refCallback(instance: unknown): void { refs.forEach((ref) => setRef(ref, instance)); }; } + +export function getComponentDisplayName( + comp: React.ComponentType +): string { + return comp.displayName || comp.name || "Component"; +} From 3c1457c0e2bd2bae5d7aff831de641585ef515fd Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 20 Jul 2022 18:18:22 +0200 Subject: [PATCH 10/31] Fix editStudy for data 0 Signed-off-by: Paul Bui-Quang --- webapp/src/services/api/study.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index 7d75e27646..307e291fe8 100644 --- a/webapp/src/services/api/study.ts +++ b/webapp/src/services/api/study.ts @@ -126,7 +126,12 @@ export const editStudy = async ( } const res = await client.post( `/v1/studies/${sid}/raw?path=${encodeURIComponent(path)}&depth=${depth}`, - formattedData + formattedData, + { + headers: { + "content-type": "application/json", + }, + } ); return res.data; }; From c39f476cdd0eede362b7a968a3308c3c4d75aa8c Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Thu, 21 Jul 2022 15:13:50 +0200 Subject: [PATCH 11/31] Fix some styles (#989) --- .../Configuration/General/Fields/index.tsx | 20 +- .../explore/Configuration/General/styles.ts | 17 - .../explore/Modelization/Areas/Load.tsx | 5 +- .../explore/Modelization/Areas/MiscGen.tsx | 15 +- .../Areas/Properties/PropertiesForm.tsx | 307 +++++++++--------- .../explore/Modelization/Areas/Reserve.tsx | 15 +- .../explore/Modelization/Areas/Solar.tsx | 5 +- .../explore/Modelization/Areas/Wind.tsx | 5 +- .../explore/Modelization/Areas/style.ts | 7 + .../Modelization/Links/LinkView/LinkForm.tsx | 173 ++++------ .../Links/LinkView/LinkMatrixView.tsx | 4 +- .../Modelization/Links/LinkView/index.tsx | 22 +- .../common/EditableMatrix/index.tsx | 2 +- webapp/src/components/common/Fieldset.tsx | 18 +- webapp/src/components/common/FileTable.tsx | 7 +- .../common/MatrixInput/MatrixAssignDialog.tsx | 1 + .../components/common/MatrixInput/index.tsx | 7 +- .../components/common/MatrixInput/style.ts | 5 +- 18 files changed, 306 insertions(+), 329 deletions(-) delete mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/styles.ts create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/style.ts diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields/index.tsx index 90a237fb99..34843aa997 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/Fields/index.tsx @@ -3,7 +3,6 @@ import { Box, Button, Divider } from "@mui/material"; import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import SettingsIcon from "@mui/icons-material/Settings"; -import { StyledFieldset } from "../styles"; import SelectFE from "../../../../../../common/fieldEditors/SelectFE"; import { StudyMetadata } from "../../../../../../../common/types"; import { editStudy } from "../../../../../../../services/api/study"; @@ -19,6 +18,7 @@ import { useFormContext } from "../../../../../../common/Form"; import useDebouncedEffect from "../../../../../../../hooks/useDebouncedEffect"; import StringFE from "../../../../../../common/fieldEditors/StringFE"; import NumberFE from "../../../../../../common/fieldEditors/NumberFE"; +import Fieldset from "../../../../../../common/Fieldset"; interface Props { study: StudyMetadata; @@ -93,7 +93,7 @@ function Fields(props: Props) { return ( <> - +
- - +
+
- +
- - + - )} - + ); diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/styles.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/styles.ts deleted file mode 100644 index 47e21d4fb3..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/styles.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { styled, experimental_sx as sx } from "@mui/material"; -import Fieldset from "../../../../../common/Fieldset"; - -export const StyledFieldset = styled(Fieldset)( - sx({ - p: 0, - pb: 5, - ".MuiBox-root": { - display: "flex", - flexWrap: "wrap", - gap: 2, - ".MuiFormControl-root": { - width: 220, - }, - }, - }) -); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx index 4ce7112f71..95dea6fd71 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Load.tsx @@ -3,6 +3,7 @@ import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; import MatrixInput from "../../../../../common/MatrixInput"; +import { Root } from "./style"; function Load() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -10,7 +11,9 @@ function Load() { const url = `input/load/series/load_${currentArea}`; return ( - + + + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/MiscGen.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/MiscGen.tsx index 98fe2ca559..adb2711b19 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/MiscGen.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/MiscGen.tsx @@ -3,6 +3,7 @@ import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; import MatrixInput from "../../../../../common/MatrixInput"; +import { Root } from "./style"; function MiscGen() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -20,12 +21,14 @@ function MiscGen() { ]; return ( - + + + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx index 216f6443bf..ad8115f5b0 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/PropertiesForm.tsx @@ -77,7 +77,7 @@ export default function PropertiesForm( sx={{ width: "100%", height: "100%", - py: 2, + p: 2, }} > -
- + + { + const color = stringToRGB(value); + if (color) { + handleAutoSubmit(path.color, { + color_r: color.r, + color_g: color.g, + color_b: color.b, + x: getValues("posX"), + y: getValues("posY"), + }); + } + }, }} - > - - { - const color = stringToRGB(value); - if (color) { - handleAutoSubmit(path.color, { - color_r: color.r, - color_g: color.g, - color_b: color.b, - x: getValues("posX"), - y: getValues("posY"), - }); - } - }, - }} - /> - handleAutoSubmit(path.posX, value), - }} - /> - handleAutoSubmit(path.posY, value), - }} - /> - + /> + handleAutoSubmit(path.posX, value), + }} + /> + handleAutoSubmit(path.posY, value), + }} + />
- {`${t( - "study.modelization.nodeProperties.energyCost" - )} (€/Wh)`} - - handleAutoSubmit(path.energieCostUnsupplied, value), + {`${t( + "study.modelization.nodeProperties.energyCost" + )} (€/Wh)`} + - - handleAutoSubmit(path.energieCostSpilled, value), - }} - /> + > + + handleAutoSubmit(path.energieCostUnsupplied, value), + }} + /> + + handleAutoSubmit(path.energieCostSpilled, value), + }} + /> + - - - - {t("study.modelization.nodeProperties.lastResortShedding")} - - - handleAutoSubmit(path.nonDispatchPower, value), - }} - /> - - handleAutoSubmit(path.dispatchHydroPower, value), + + {t("study.modelization.nodeProperties.lastResortShedding")} + + - - handleAutoSubmit(path.otherDispatchPower, value), - }} - /> + > + + handleAutoSubmit(path.nonDispatchPower, value), + }} + /> + + handleAutoSubmit(path.dispatchHydroPower, value), + }} + /> + + handleAutoSubmit(path.otherDispatchPower, value), + }} + /> +
-
- - {renderFilter("filterSynthesis")} - {renderFilter("filterByYear")} - +
+ {renderFilter("filterSynthesis")} + {renderFilter("filterByYear")}
diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Reserve.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Reserve.tsx index 74c319e540..691461c5cf 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Reserve.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Reserve.tsx @@ -3,6 +3,7 @@ import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; import MatrixInput from "../../../../../common/MatrixInput"; +import { Root } from "./style"; function Reserve() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -15,12 +16,14 @@ function Reserve() { ]; return ( - + + + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Solar.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Solar.tsx index debfe9f6cd..9724b4a948 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Solar.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Solar.tsx @@ -3,6 +3,7 @@ import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; import MatrixInput from "../../../../../common/MatrixInput"; +import { Root } from "./style"; function Solar() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -10,7 +11,9 @@ function Solar() { const url = `input/solar/series/solar_${currentArea}`; return ( - + + + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Wind.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Wind.tsx index 6e82215308..8afc9e385c 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Wind.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Wind.tsx @@ -3,6 +3,7 @@ import useAppSelector from "../../../../../../redux/hooks/useAppSelector"; import { getCurrentAreaId } from "../../../../../../redux/selectors"; import { MatrixStats, StudyMetadata } from "../../../../../../common/types"; import MatrixInput from "../../../../../common/MatrixInput"; +import { Root } from "./style"; function Wind() { const { study } = useOutletContext<{ study: StudyMetadata }>(); @@ -10,7 +11,9 @@ function Wind() { const url = `input/wind/series/wind_${currentArea}`; return ( - + + + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/style.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/style.ts new file mode 100644 index 0000000000..ba2de46bd6 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/style.ts @@ -0,0 +1,7 @@ +import { styled, Box } from "@mui/material"; + +export const Root = styled(Box)(({ theme }) => ({ + width: "100%", + height: "100%", + padding: theme.spacing(2), +})); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx index 972a29c9fb..1bd35dde50 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkForm.tsx @@ -171,59 +171,54 @@ function LinkForm(props: Props) { options: Array<{ label: string; value: string }>, onAutoSubmit?: AutoSubmitHandler ) => ( - - { - handleAutoSubmit(path[filterName], value); - }), - }} - /> - + { + handleAutoSubmit(path[filterName], value); + }), + }} + /> ); const renderFilter = (filterName: string) => ( - - { + { + const selection = value + ? (value as Array).filter((val) => val !== "") + : []; + return selection.length > 0 + ? selection.map((elm) => t(`study.${elm}`)).join(", ") + : t("global.none"); + }} + variant="filled" + options={filterOptions} + sx={{ minWidth: "200px" }} + label={t(`study.modelization.nodeProperties.${filterName}`)} + control={control} + rules={{ + onAutoSubmit: (value) => { const selection = value ? (value as Array).filter((val) => val !== "") : []; - return selection.length > 0 - ? selection.map((elm) => t(`study.${elm}`)).join(", ") - : t("global.none"); - }} - variant="filled" - options={filterOptions} - sx={{ minWidth: "200px" }} - label={t(`study.modelization.nodeProperties.${filterName}`)} - control={control} - rules={{ - onAutoSubmit: (value) => { - const selection = value - ? (value as Array).filter((val) => val !== "") - : []; - handleAutoSubmit(path[filterName], selection.join(", ")); - }, - }} - /> - + handleAutoSubmit(path[filterName], selection.join(", ")); + }, + }} + /> ); return ( @@ -231,7 +226,6 @@ function LinkForm(props: Props) { sx={{ width: "100%", height: "100%", - py: 2, }} > -
- + handleAutoSubmit(path.hurdleCost, value), }} - > - - handleAutoSubmit(path.hurdleCost, value), - }} - /> - - handleAutoSubmit(path.loopFlows, value), - }} - /> - handleAutoSubmit(path.pst, value), - }} - /> - {renderSelect("transmissionCapa", optionTransCap)} - {renderSelect("type", optionType, handleTypeAutoSubmit)} - -
-
- + handleAutoSubmit(path.loopFlows, value), }} - > - {renderFilter("filterSynthesis")} - {renderFilter("filterByYear")} - + /> + handleAutoSubmit(path.pst, value), + }} + /> + {renderSelect("transmissionCapa", optionTransCap)} + {renderSelect("type", optionType, handleTypeAutoSubmit)} +
+
+ {renderFilter("filterSynthesis")} + {renderFilter("filterByYear")}
- + - } - ifResolved={(data) => ( -
- - - )} - /> + + } + ifResolved={(data) => ( +
+ + + )} + /> +
); } diff --git a/webapp/src/components/common/EditableMatrix/index.tsx b/webapp/src/components/common/EditableMatrix/index.tsx index 8c35caff54..f79a065ed5 100644 --- a/webapp/src/components/common/EditableMatrix/index.tsx +++ b/webapp/src/components/common/EditableMatrix/index.tsx @@ -82,7 +82,7 @@ function EditableMatrix(props: PropTypes) { 0, prependIndex ? 1 : 0, hot.countRows() - 1, - hot.countCols() - (computStats ? cols : 1) - (prependIndex ? 1 : 0) + hot.countCols() - (computStats ? cols : 0) - 1 ); } } diff --git a/webapp/src/components/common/Fieldset.tsx b/webapp/src/components/common/Fieldset.tsx index 27fda81a3f..4f5d4e2ea1 100644 --- a/webapp/src/components/common/Fieldset.tsx +++ b/webapp/src/components/common/Fieldset.tsx @@ -15,7 +15,23 @@ function Fieldset(props: FieldsetProps) { {legend && ( <> diff --git a/webapp/src/components/common/FileTable.tsx b/webapp/src/components/common/FileTable.tsx index 3517db2a2c..010bb34a9c 100644 --- a/webapp/src/components/common/FileTable.tsx +++ b/webapp/src/components/common/FileTable.tsx @@ -131,9 +131,6 @@ function FileTable(props: PropType) { ({ - "&> th": { - padding: 1, - }, "&> th, >td": { borderBottom: "solid 1px", borderColor: theme.palette.divider, @@ -208,8 +205,8 @@ function FileTable(props: PropType) { onAssign(row.id as string)} sx={{ - mx: 1, - color: "action.active", + ml: 1, + color: "primary.main", }} > diff --git a/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx b/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx index 396484fa8d..70f8c0ea6e 100644 --- a/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx +++ b/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx @@ -85,6 +85,7 @@ function MatrixAssignDialog(props: Props) { enqueueSnackbar(t("data.succes.matrixAssignation"), { variant: "success", }); + onClose(); } catch (e) { enqueueErrorSnackbar(t("data.error.matrixAssignation"), e as AxiosError); } diff --git a/webapp/src/components/common/MatrixInput/index.tsx b/webapp/src/components/common/MatrixInput/index.tsx index 35c1930923..d6a760375f 100644 --- a/webapp/src/components/common/MatrixInput/index.tsx +++ b/webapp/src/components/common/MatrixInput/index.tsx @@ -165,7 +165,7 @@ function MatrixInput(props: PropsType) { - + {isLoading && } {!isLoading && data?.columns?.length > 1 ? ( setOpenMatrixAsignDialog(false)} + onClose={() => { + setOpenMatrixAsignDialog(false); + reloadMatrix(); + }} /> )} diff --git a/webapp/src/components/common/MatrixInput/style.ts b/webapp/src/components/common/MatrixInput/style.ts index ddccd4d821..6fd49fd167 100644 --- a/webapp/src/components/common/MatrixInput/style.ts +++ b/webapp/src/components/common/MatrixInput/style.ts @@ -8,7 +8,6 @@ export const Root = styled(Box)(({ theme }) => ({ flexFlow: "column nowrap", justifyContent: "flex-start", alignItems: "center", - padding: theme.spacing(1, 1), })); export const Header = styled(Box)(({ theme }) => ({ @@ -16,12 +15,10 @@ export const Header = styled(Box)(({ theme }) => ({ display: "flex", flexFlow: "row nowrap", justifyContent: "space-between", - alignItems: "center", - padding: theme.spacing(0, 1), + alignItems: "flex-end", })); export const Content = styled(Box)(({ theme }) => ({ - padding: theme.spacing(1), boxSizing: "border-box", flex: 1, width: "100%", From d3f52c7ba7c5555e75742b61c6151bc7845ae658 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Sun, 24 Jul 2022 11:54:39 +0200 Subject: [PATCH 12/31] fix(common): issue in Form component with formState proxy (#994) --- webapp/src/components/common/Form/index.tsx | 13 ++----------- webapp/src/components/wrappers/LoginWrapper.tsx | 4 ++-- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/webapp/src/components/common/Form/index.tsx b/webapp/src/components/common/Form/index.tsx index 870c97591f..85625588bd 100644 --- a/webapp/src/components/common/Form/index.tsx +++ b/webapp/src/components/common/Form/index.tsx @@ -60,11 +60,6 @@ export interface ControlPlus< register: UseFormRegisterPlus; } -export interface FormStatePlus - extends FormState { - isSubmitAllowed: boolean; -} - export interface UseFormReturnPlus< TFieldValues extends FieldValues = FieldValues, TContext = any @@ -72,7 +67,6 @@ export interface UseFormReturnPlus< register: UseFormRegisterPlus; control: ControlPlus; defaultValues?: UseFormProps["defaultValues"]; - formState: FormStatePlus; } export type AutoSubmitConfig = { enable: boolean; wait?: number }; @@ -128,7 +122,7 @@ function Form( formState, reset, } = formObj; - + // * /!\ `formState` is a proxy const { isValid, isSubmitting, isDirty, dirtyFields } = formState; const isSubmitAllowed = isDirty && isValid && !isSubmitting; const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); @@ -289,10 +283,7 @@ function Form( const sharedProps = { ...formObj, - formState: { - ...formState, - isSubmitAllowed, - }, + formState, defaultValues: config?.defaultValues, register: registerWrapper, unregister: unregisterWrapper, diff --git a/webapp/src/components/wrappers/LoginWrapper.tsx b/webapp/src/components/wrappers/LoginWrapper.tsx index a3a5b93fbd..dc0ccab5ee 100644 --- a/webapp/src/components/wrappers/LoginWrapper.tsx +++ b/webapp/src/components/wrappers/LoginWrapper.tsx @@ -149,7 +149,7 @@ function LoginWrapper(props: Props) {
- {({ control, formState: { isSubmitting, isSubmitAllowed } }) => ( + {({ control, formState: { isDirty, isSubmitting } }) => ( <> {t("global.connexion")} From 63ca0a5b413515ed5c5c3b86c6fd25a2291a71a1 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Mon, 25 Jul 2022 14:09:12 +0200 Subject: [PATCH 13/31] Change antares simulator execution info file name Signed-off-by: Paul Bui-Quang --- antarest/launcher/service.py | 3 ++- tests/launcher/test_service.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index 9c0354fd49..379302955c 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -72,6 +72,7 @@ def __init__(self, engine: str): ORPHAN_JOBS_VISIBILITY_THRESHOLD = 10 # days LAUNCHER_PARAM_NAME_SUFFIX = "output_suffix" +EXECUTION_INFO_FILE = "execution_info.ini" class LauncherService: @@ -519,7 +520,7 @@ def _save_solver_stats( self, job_result: JobResult, output_path: Path ) -> None: try: - measurement_file = output_path / "time_measurement.txt" + measurement_file = output_path / EXECUTION_INFO_FILE if measurement_file.exists(): job_result.solver_stats = measurement_file.read_text( encoding="utf-8" diff --git a/tests/launcher/test_service.py b/tests/launcher/test_service.py index 61f55deff6..70ed0619a9 100644 --- a/tests/launcher/test_service.py +++ b/tests/launcher/test_service.py @@ -40,6 +40,7 @@ ORPHAN_JOBS_VISIBILITY_THRESHOLD, JobNotFound, LAUNCHER_PARAM_NAME_SUFFIX, + EXECUTION_INFO_FILE, ) from antarest.login.auth import Auth from antarest.login.model import User @@ -697,7 +698,7 @@ def test_save_stats(tmp_path: Path) -> None: tsgen_thermal 407 2 tsgen_wind 2500 1 """ - (output_path / "time_measurement.txt").write_text(expected_saved_stats) + (output_path / EXECUTION_INFO_FILE).write_text(expected_saved_stats) launcher_service._save_solver_stats(job_result, output_path) launcher_service.job_result_repository.save.assert_called_with( From bdae4bc66b2b7a0277c9c900f85d9068016e0f7c Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Mon, 25 Jul 2022 15:36:50 +0200 Subject: [PATCH 14/31] Fix handle submit for unchanging values (#995) --- webapp/src/components/common/Form/utils.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/webapp/src/components/common/Form/utils.ts b/webapp/src/components/common/Form/utils.ts index 172856cf83..c4696283af 100644 --- a/webapp/src/components/common/Form/utils.ts +++ b/webapp/src/components/common/Form/utils.ts @@ -28,13 +28,15 @@ export function getDirtyValues( // Here, we have an object. return Object.fromEntries( - Object.keys(dirtyFields).map((key) => [ - key, - getDirtyValues( - dirtyFields[key] as UnknownArrayOrObject | true, - (allValues as Record)[key] as UnknownArrayOrObject - ), - ]) + Object.keys(dirtyFields) + .filter((key) => dirtyFields[key] !== false) + .map((key) => [ + key, + getDirtyValues( + dirtyFields[key] as UnknownArrayOrObject | true, + (allValues as Record)[key] as UnknownArrayOrObject + ), + ]) ); } From 0498c0f384eba1ebce49e93c77eff2217b2cd171 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Tue, 26 Jul 2022 10:58:25 +0200 Subject: [PATCH 15/31] Add launcher load info (#997) --- antarest/core/config.py | 2 + antarest/launcher/repository.py | 7 ++ antarest/launcher/service.py | 44 ++++++++++ antarest/launcher/web.py | 13 +++ tests/conftest.py | 5 +- tests/launcher/test_repository.py | 133 +++++++++++++++--------------- tests/launcher/test_service.py | 67 +++++++++++++++ 7 files changed, 201 insertions(+), 70 deletions(-) diff --git a/antarest/core/config.py b/antarest/core/config.py index 49e29a0457..0f8f01ed96 100644 --- a/antarest/core/config.py +++ b/antarest/core/config.py @@ -163,6 +163,7 @@ class SlurmConfig: default_n_cpu: int = 1 default_json_db_name: str = "" slurm_script_path: str = "" + max_cores: int = 64 antares_versions_on_remote_server: List[str] = field( default_factory=lambda: [] ) @@ -185,6 +186,7 @@ def from_dict(data: JSON) -> "SlurmConfig": antares_versions_on_remote_server=data[ "antares_versions_on_remote_server" ], + max_cores=data.get("max_cores", 64), ) diff --git a/antarest/launcher/repository.py b/antarest/launcher/repository.py index 483d6e8652..09051a53c7 100644 --- a/antarest/launcher/repository.py +++ b/antarest/launcher/repository.py @@ -44,6 +44,13 @@ def get_all( job_results: List[JobResult] = query.all() return job_results + def get_running(self) -> List[JobResult]: + query = db.session.query(JobResult).where( + JobResult.completion_date == None + ) + job_results: List[JobResult] = query.all() + return job_results + def find_by_study(self, study_id: str) -> List[JobResult]: logger.debug(f"Retrieving JobResults from study {study_id}") job_results: List[JobResult] = ( diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index 379302955c..16759c7b0e 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -673,6 +673,50 @@ def download_output( ) raise JobNotFound() + def get_load(self, from_cluster: bool = False) -> Dict[str, float]: + all_running_jobs = self.job_result_repository.get_running() + local_running_jobs = [] + slurm_running_jobs = [] + for job in all_running_jobs: + if job.launcher == "slurm": + slurm_running_jobs.append(job) + elif job.launcher == "local": + local_running_jobs.append(job) + else: + logger.warning(f"Unknown job launcher {job.launcher}") + load = {} + if self.config.launcher.slurm: + if from_cluster: + raise NotImplementedError + slurm_used_cpus = reduce( + lambda count, j: count + + ( + LauncherParametersDTO.parse_raw( + j.launcher_params or "{}" + ).nb_cpu + or self.config.launcher.slurm.default_n_cpu # type: ignore + ), + slurm_running_jobs, + 0, + ) + load["slurm"] = ( + float(slurm_used_cpus) / self.config.launcher.slurm.max_cores + ) + if self.config.launcher.local: + local_used_cpus = reduce( + lambda count, j: count + + ( + LauncherParametersDTO.parse_raw( + j.launcher_params or "{}" + ).nb_cpu + or 1 + ), + local_running_jobs, + 0, + ) + load["local"] = float(local_used_cpus) / (os.cpu_count() or 1) + return load + def get_versions(self, params: RequestParameters) -> Dict[str, List[str]]: version_dict = {} if self.config.launcher.local: diff --git a/antarest/launcher/web.py b/antarest/launcher/web.py index a621f3bb69..6bbccaa164 100644 --- a/antarest/launcher/web.py +++ b/antarest/launcher/web.py @@ -171,6 +171,19 @@ def get_engines() -> Any: logger.info(f"Listing launch engines") return LauncherEnginesDTO(engines=service.get_launchers()) + @bp.get( + "/launcher/load", + tags=[APITag.launcher], + summary="Get the cluster load in usage percent", + ) + def get_load( + from_cluster: bool = False, + current_user: JWTUser = Depends(auth.get_current_user), + ) -> Dict[str, float]: + params = RequestParameters(user=current_user) + logger.info("Fetching launcher load") + return service.get_load(from_cluster) + @bp.get( "/launcher/_versions", tags=[APITag.launcher], diff --git a/tests/conftest.py b/tests/conftest.py index 43689454c9..f8384538de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,7 +10,7 @@ from sqlalchemy import create_engine from antarest.core.model import SUB_JSON -from antarest.core.utils.fastapi_sqlalchemy import DBSessionMiddleware +from antarest.core.utils.fastapi_sqlalchemy import DBSessionMiddleware, db from antarest.dbmodel import Base project_dir: Path = Path(__file__).parent.parent @@ -32,7 +32,8 @@ def wrapper(*args, **kwds): custom_engine=engine, session_args={"autocommit": False, "autoflush": False}, ) - return f(*args, **kwds) + with db(): + return f(*args, **kwds) return wrapper diff --git a/tests/launcher/test_repository.py b/tests/launcher/test_repository.py index 37b2875295..d8ebb3c74f 100644 --- a/tests/launcher/test_repository.py +++ b/tests/launcher/test_repository.py @@ -11,88 +11,85 @@ from antarest.launcher.repository import JobResultRepository from antarest.study.model import RawStudy from antarest.study.repository import StudyMetadataRepository +from tests.conftest import with_db_context @pytest.mark.unit_test +@with_db_context def test_job_result() -> None: - engine = create_engine("sqlite:///:memory:", echo=True) - Base.metadata.create_all(engine) - DBSessionMiddleware( - Mock(), - custom_engine=engine, - session_args={"autocommit": False, "autoflush": False}, + repo = JobResultRepository() + study_id = str(uuid4()) + study_repo = StudyMetadataRepository(Mock()) + study_repo.save(RawStudy(id=study_id)) + a = JobResult( + id=str(uuid4()), + study_id=study_id, + job_status=JobStatus.SUCCESS, + msg="Hello, World!", + exit_code=0, + ) + b = JobResult( + id=str(uuid4()), + study_id=study_id, + job_status=JobStatus.FAILED, + creation_date=datetime.datetime.utcfromtimestamp(1655136710), + completion_date=datetime.datetime.utcfromtimestamp(1655136720), + msg="You failed !!", + exit_code=1, + ) + b2 = JobResult( + id=str(uuid4()), + study_id=study_id, + job_status=JobStatus.FAILED, + creation_date=datetime.datetime.utcfromtimestamp(1655136740), + msg="You failed !!", + exit_code=1, + ) + b3 = JobResult( + id=str(uuid4()), + study_id="other_study", + job_status=JobStatus.FAILED, + creation_date=datetime.datetime.utcfromtimestamp(1655136729), + msg="You failed !!", + exit_code=1, ) - with db(): - repo = JobResultRepository() - study_id = str(uuid4()) - study_repo = StudyMetadataRepository(Mock()) - study_repo.save(RawStudy(id=study_id)) - a = JobResult( - id=str(uuid4()), - study_id=study_id, - job_status=JobStatus.SUCCESS, - msg="Hello, World!", - exit_code=0, - ) - b = JobResult( - id=str(uuid4()), - study_id=study_id, - job_status=JobStatus.FAILED, - creation_date=datetime.datetime.utcfromtimestamp(1655136710), - msg="You failed !!", - exit_code=1, - ) - b2 = JobResult( - id=str(uuid4()), - study_id=study_id, - job_status=JobStatus.FAILED, - creation_date=datetime.datetime.utcfromtimestamp(1655136740), - msg="You failed !!", - exit_code=1, - ) - b3 = JobResult( - id=str(uuid4()), - study_id="other_study", - job_status=JobStatus.FAILED, - creation_date=datetime.datetime.utcfromtimestamp(1655136729), - msg="You failed !!", - exit_code=1, - ) + a = repo.save(a) + b = repo.save(b) + b2 = repo.save(b2) + b3 = repo.save(b3) + c = repo.get(a.id) + assert a == c - a = repo.save(a) - b = repo.save(b) - b2 = repo.save(b2) - b3 = repo.save(b3) - c = repo.get(a.id) - assert a == c + d = repo.find_by_study(study_id) + assert len(d) == 3 + assert a == d[0] - d = repo.find_by_study(study_id) - assert len(d) == 3 - assert a == d[0] + running = repo.get_running() + assert len(running) == 3 - all = repo.get_all() - assert len(all) == 4 - assert all[0] == a - assert all[1] == b2 - assert all[2] == b3 - assert all[3] == b + all = repo.get_all() + assert len(all) == 4 + assert all[0] == a + assert all[1] == b2 + assert all[2] == b3 + assert all[3] == b - all = repo.get_all(filter_orphan=True) - assert len(all) == 3 + all = repo.get_all(filter_orphan=True) + assert len(all) == 3 - all = repo.get_all(latest=2) - assert len(all) == 2 + all = repo.get_all(latest=2) + assert len(all) == 2 - repo.delete(a.id) - assert repo.get(a.id) is None + repo.delete(a.id) + assert repo.get(a.id) is None - assert len(repo.find_by_study(study_id)) == 2 + assert len(repo.find_by_study(study_id)) == 2 - repo.delete_by_study_id(study_id=study_id) - assert repo.get(b.id) is None - assert repo.get(b2.id) is None - assert repo.get(b3.id) is not None + repo.delete_by_study_id(study_id=study_id) + assert repo.get(b.id) is None + assert repo.get(b2.id) is None + assert repo.get(b3.id) is not None @pytest.mark.unit_test diff --git a/tests/launcher/test_service.py b/tests/launcher/test_service.py index 70ed0619a9..78c8163b5d 100644 --- a/tests/launcher/test_service.py +++ b/tests/launcher/test_service.py @@ -709,3 +709,70 @@ def test_save_stats(tmp_path: Path) -> None: solver_stats=expected_saved_stats, ) ) + + +def test_get_load(tmp_path: Path): + study_service = Mock() + job_repository = Mock() + + launcher_service = LauncherService( + config=Mock( + storage=StorageConfig(tmp_dir=tmp_path), + launcher=LauncherConfig( + local=LocalConfig(), slurm=SlurmConfig(default_n_cpu=12) + ), + ), + study_service=study_service, + job_result_repository=job_repository, + event_bus=Mock(), + factory_launcher=Mock(), + file_transfer_manager=Mock(), + task_service=Mock(), + ) + + job_repository.get_running.side_effect = [ + [], + [], + [ + Mock( + spec=JobResult, + launcher="slurm", + launcher_params=None, + ), + ], + [ + Mock( + spec=JobResult, + launcher="slurm", + launcher_params='{"nb_cpu": 18}', + ), + Mock( + spec=JobResult, + launcher="local", + launcher_params=None, + ), + Mock( + spec=JobResult, + launcher="slurm", + launcher_params=None, + ), + Mock( + spec=JobResult, + launcher="local", + launcher_params='{"nb_cpu": 7}', + ), + ], + ] + + with pytest.raises(NotImplementedError): + launcher_service.get_load(from_cluster=True) + + load = launcher_service.get_load() + assert load["slurm"] == 0 + assert load["local"] == 0 + load = launcher_service.get_load() + assert load["slurm"] == 12.0 / 64 + assert load["local"] == 0 + load = launcher_service.get_load() + assert load["slurm"] == 30.0 / 64 + assert load["local"] == 8.0 / os.cpu_count() From 143d975030281fd80b60df6b60853dbbd556418a Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Tue, 26 Jul 2022 11:42:12 +0200 Subject: [PATCH 16/31] Keep zip on output import (#998) --- antarest/study/common/studystorage.py | 4 +++- antarest/study/service.py | 13 +++++++++---- antarest/study/storage/abstract_storage_service.py | 6 ++++-- antarest/study/storage/study_download_utils.py | 3 --- antarest/study/web/studies_blueprint.py | 1 + tests/storage/business/test_raw_study_service.py | 9 +++++++-- webapp/public/locales/en/main.json | 2 ++ webapp/public/locales/fr/main.json | 2 ++ webapp/src/common/types.ts | 4 ++-- 9 files changed, 30 insertions(+), 14 deletions(-) diff --git a/antarest/study/common/studystorage.py b/antarest/study/common/studystorage.py index cf8aa02d46..0b2585f57f 100644 --- a/antarest/study/common/studystorage.py +++ b/antarest/study/common/studystorage.py @@ -283,5 +283,7 @@ def archive_study_output(self, study: T, output_id: str) -> bool: raise NotImplementedError() @abstractmethod - def unarchive_study_output(self, study: T, output_id: str) -> bool: + def unarchive_study_output( + self, study: T, output_id: str, keep_src_zip: bool + ) -> bool: raise NotImplementedError() diff --git a/antarest/study/service.py b/antarest/study/service.py index eda37362d3..b7fb398d6c 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -1331,7 +1331,9 @@ def import_output( ) if output_id and isinstance(output, Path) and output.suffix == ".zip": - self.unarchive_output(uuid, output_id, True, params) + self.unarchive_output( + uuid, output_id, True, not is_managed(study), params + ) return output_id @@ -2258,6 +2260,7 @@ def unarchive_output( study_id: str, output_id: str, use_task: bool, + keep_src_zip: bool, params: RequestParameters, ) -> Optional[str]: study = self.get_study(study_id) @@ -2267,7 +2270,7 @@ def unarchive_output( if not use_task: stopwatch = StopWatch() self.storage_service.get_storage(study).unarchive_study_output( - study, output_id + study, output_id, keep_src_zip ) stopwatch.log_elapsed( lambda x: logger.info( @@ -2277,7 +2280,9 @@ def unarchive_output( return None else: - task_name = f"Unarchive output {study_id}/{output_id}" + task_name = ( + f"Unarchive output {study.name}/{output_id} ({study_id})" + ) def unarchive_output_task( notifier: TaskUpdateNotifier, @@ -2287,7 +2292,7 @@ def unarchive_output_task( stopwatch = StopWatch() self.storage_service.get_storage( study - ).unarchive_study_output(study, output_id) + ).unarchive_study_output(study, output_id, keep_src_zip) stopwatch.log_elapsed( lambda x: logger.info( f"Output {output_id} of study {study_id} unarchived in {x}s" diff --git a/antarest/study/storage/abstract_storage_service.py b/antarest/study/storage/abstract_storage_service.py index 68f35b428e..73e4467a40 100644 --- a/antarest/study/storage/abstract_storage_service.py +++ b/antarest/study/storage/abstract_storage_service.py @@ -386,12 +386,14 @@ def archive_study_output(self, study: T, output_id: str) -> bool: ) return False - def unarchive_study_output(self, study: T, output_id: str) -> bool: + def unarchive_study_output( + self, study: T, output_id: str, keep_src_zip: bool + ) -> bool: try: unzip( Path(study.path) / "output" / output_id, Path(study.path) / "output" / f"{output_id}.zip", - remove_source_zip=True, + remove_source_zip=not keep_src_zip, ) remove_from_cache(self.cache, study.id) return True diff --git a/antarest/study/storage/study_download_utils.py b/antarest/study/storage/study_download_utils.py index f1a3e03318..9c22dd15b1 100644 --- a/antarest/study/storage/study_download_utils.py +++ b/antarest/study/storage/study_download_utils.py @@ -334,9 +334,6 @@ def build( Returns: JSON content file """ - if file_study.config.outputs[output_id].archived: - raise OutputArchivedError(f"The output {output_id} is archived") - url = f"/output/{output_id}" matrix: MatrixAggregationResult = MatrixAggregationResult( index=get_start_date(file_study, output_id, data.level), diff --git a/antarest/study/web/studies_blueprint.py b/antarest/study/web/studies_blueprint.py index 535e4fbd28..b8243e473e 100644 --- a/antarest/study/web/studies_blueprint.py +++ b/antarest/study/web/studies_blueprint.py @@ -577,6 +577,7 @@ def unarchive_output( study_id, output_id, use_task, + False, params, ) return content diff --git a/tests/storage/business/test_raw_study_service.py b/tests/storage/business/test_raw_study_service.py index eb735c1979..5f20714ffc 100644 --- a/tests/storage/business/test_raw_study_service.py +++ b/tests/storage/business/test_raw_study_service.py @@ -480,7 +480,7 @@ def test_zipped_output(tmp_path: Path) -> None: assert output_name == expected_output_name assert (study_path / "output" / (expected_output_name + ".zip")).exists() - study_service.unarchive_study_output(md, expected_output_name) + study_service.unarchive_study_output(md, expected_output_name, False) assert (study_path / "output" / expected_output_name).exists() assert not ( study_path / "output" / (expected_output_name + ".zip") @@ -489,7 +489,12 @@ def test_zipped_output(tmp_path: Path) -> None: assert not (study_path / "output" / expected_output_name).exists() output_name = study_service.import_output(md, zipped_output) - study_service.unarchive_study_output(md, expected_output_name) + study_service.unarchive_study_output(md, expected_output_name, True) + assert (study_path / "output" / (expected_output_name + ".zip")).exists() + os.unlink(study_path / "output" / (expected_output_name + ".zip")) + assert not ( + study_path / "output" / (expected_output_name + ".zip") + ).exists() study_service.archive_study_output(md, expected_output_name) assert not (study_path / "output" / expected_output_name).exists() assert (study_path / "output" / (expected_output_name + ".zip")).exists() diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 83c8bc69a0..18e2f46b21 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -180,7 +180,9 @@ "study.years": "Years", "study.type": "Type", "study.includeClusters": "Include clusters", + "study.area": "Area", "study.areas": "Areas", + "study.link": "Link", "study.links": "Links", "study.district": "District", "study.bindingconstraints": "Binding Constraints", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index aa59af6e49..5fbf6e0331 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -180,7 +180,9 @@ "study.years": "Années", "study.type": "Type", "study.includeClusters": "Inclure les clusters", + "study.area": "Zone", "study.areas": "Zones", + "study.link": "Lien", "study.links": "Liens", "study.district": "District", "study.bindingconstraints": "Contraintes Couplantes", diff --git a/webapp/src/common/types.ts b/webapp/src/common/types.ts index b506ad173c..a1ba3ea63b 100644 --- a/webapp/src/common/types.ts +++ b/webapp/src/common/types.ts @@ -414,9 +414,9 @@ export interface LinkElement { export type LinkListElement = { [elm: string]: LinkElement }; export enum StudyOutputDownloadType { - LINKS = "LINKS", + LINKS = "LINK", DISTRICT = "DISTRICT", - AREAS = "AREAS", + AREAS = "AREA", } export enum StudyOutputDownloadLevelDTO { From 237429ac8216017e29e299af155b5229f3c6d3ca Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Tue, 26 Jul 2022 17:24:51 +0200 Subject: [PATCH 17/31] Add enable info to cluster info (#1001) --- antarest/study/business/area_management.py | 1 + tests/storage/business/test_arealink_manager.py | 1 + 2 files changed, 2 insertions(+) diff --git a/antarest/study/business/area_management.py b/antarest/study/business/area_management.py index 7276c1951b..914682cfc1 100644 --- a/antarest/study/business/area_management.py +++ b/antarest/study/business/area_management.py @@ -46,6 +46,7 @@ class AreaCreationDTO(BaseModel): class ClusterInfoDTO(PatchCluster): id: str name: str + enabled: bool = True unitcount: int = 0 nominalcapacity: int = 0 group: Optional[str] = None diff --git a/tests/storage/business/test_arealink_manager.py b/tests/storage/business/test_arealink_manager.py index 6d05bfc525..89c1677b8a 100644 --- a/tests/storage/business/test_arealink_manager.py +++ b/tests/storage/business/test_arealink_manager.py @@ -321,6 +321,7 @@ def test_get_all_area(): { "id": "a", "name": "A", + "enabled": True, "unitcount": 1, "nominalcapacity": 500, "group": None, From a9b640f6ee3e26e9b3c9727da89b32eec1a40d08 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Tue, 26 Jul 2022 18:05:17 +0200 Subject: [PATCH 18/31] issue-850-Configuration_General_params_Thematic_trimming (#1003) --- .../components/App/Settings/Groups/Header.tsx | 17 +--- .../components/App/Settings/Groups/index.tsx | 8 +- .../components/App/Settings/Tokens/Header.tsx | 17 +--- .../components/App/Settings/Tokens/index.tsx | 10 +-- .../components/App/Settings/Users/Header.tsx | 17 +--- .../components/App/Settings/Users/index.tsx | 6 +- .../dialogs/ThematicTrimmingDialog/index.tsx | 87 +++++++++---------- .../dialogs/ThematicTrimmingDialog/utils.ts | 55 +++--------- .../components/App/Studies/HeaderBottom.tsx | 26 ++---- .../src/components/common/PropertiesView.tsx | 19 +--- .../common/fieldEditors/SearchFE.tsx | 39 +++++++++ webapp/src/theme.ts | 11 ++- webapp/src/utils/textUtils.ts | 11 +++ 13 files changed, 135 insertions(+), 188 deletions(-) create mode 100644 webapp/src/components/common/fieldEditors/SearchFE.tsx create mode 100644 webapp/src/utils/textUtils.ts diff --git a/webapp/src/components/App/Settings/Groups/Header.tsx b/webapp/src/components/App/Settings/Groups/Header.tsx index 511f4f3cea..e47ec3d575 100644 --- a/webapp/src/components/App/Settings/Groups/Header.tsx +++ b/webapp/src/components/App/Settings/Groups/Header.tsx @@ -1,12 +1,12 @@ -import { Box, Button, InputAdornment, TextField } from "@mui/material"; +import { Box, Button } from "@mui/material"; import GroupAddIcon from "@mui/icons-material/GroupAdd"; import { useTranslation } from "react-i18next"; -import SearchIcon from "@mui/icons-material/Search"; import { useState } from "react"; import { GroupDetailsDTO } from "../../../../common/types"; import CreateGroupDialog from "./dialog/CreateGroupDialog"; import { isAuthUserAdmin } from "../../../../redux/selectors"; import useAppSelector from "../../../../redux/hooks/useAppSelector"; +import SearchFE from "../../../common/fieldEditors/SearchFE"; /** * Types @@ -38,18 +38,7 @@ function Header(props: Props) { mb: "5px", }} > - - - - ), - }} - onChange={(event) => setSearchValue(event.target.value)} - /> + {isUserAdmin && ( } contentProps={{ sx: { pb: 0 }, }} + PaperProps={{ sx: { height: "100%" } }} > - - - - + + + + + + + - - {getColumns(study.version).map((column, index) => ( - - {column.map(([label, name]) => ( - - ))} - - ))} + + {getFieldNames(getCurrentConfig()) + .filter(([, label]) => isSearchMatching(search, label)) + .map(([name, label]) => ( + + ))} ); diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/utils.ts index 81a3833de4..8fda36681d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/utils.ts @@ -1,10 +1,7 @@ import { camelCase } from "lodash"; import * as R from "ramda"; import * as RA from "ramda-adjunct"; -import { - StudyMetadata, - ThematicTrimmingConfigDTO, -} from "../../../../../../../../common/types"; +import { ThematicTrimmingConfigDTO } from "../../../../../../../../common/types"; export interface ThematicTrimmingConfig { ovCost: boolean; @@ -71,7 +68,7 @@ export interface ThematicTrimmingConfig { renw4?: boolean; } -const keysMap = { +const keysMap: Record = { ovCost: "OV. COST", opCost: "OP. COST", mrgPrice: "MRG. PRICE", @@ -120,6 +117,7 @@ const keysMap = { congProdPlus: "CONG. PROD +", congProdMinus: "CONG. PROD -", hurdleCost: "HURDLE COST", + // Study version >= 810 resGenerationByPlant: "RES generation by plant", miscDtg2: "MISC. DTG 2", miscDtg3: "MISC. DTG 3", @@ -135,6 +133,14 @@ const keysMap = { renw4: "RENW. 4", }; +// Allow to support all study versions +// by using directly the server config +export function getFieldNames( + config: ThematicTrimmingConfig +): Array<[keyof ThematicTrimmingConfig, string]> { + return R.toPairs(R.pick(R.keys(config), keysMap)); +} + export function formatThematicTrimmingConfigDTO( configDTO: ThematicTrimmingConfigDTO ): ThematicTrimmingConfig { @@ -155,42 +161,3 @@ export function thematicTrimmingConfigToDTO( ): ThematicTrimmingConfigDTO { return RA.renameKeys(keysMap, config) as ThematicTrimmingConfigDTO; } - -export function getColumns( - studyVersion: StudyMetadata["version"] -): Array> { - const version = Number(studyVersion); - - return [ - [ - ["OV. Cost", "ovCost"], - ["CO2 Emis.", "co2Emis"], - ["Balance", "balance"], - ["MISC. NDG", "miscNdg"], - ["Wind", "wind"], - ["Lignite", "lignite"], - ], - [ - ["OP. Cost", "opCost"], - ["DTG by plant", "dtgByPlant"], - ["Row bal.", "rowBal"], - ["Load", "load"], - ["Solar", "solar"], - ], - [ - ["MRG. Price", "mrgPrice"], - version >= 810 && ["RES generation by plant", "resGenerationByPlant"], - ["PSP", "psp"], - ["H. ROR", "hRor"], - ["Nuclear", "nuclear"], - ].filter(Boolean) as Array<[string, keyof ThematicTrimmingConfig]>, - ]; -} - -export function getFieldNames( - studyVersion: StudyMetadata["version"] -): Array { - return getColumns(studyVersion).flatMap((column) => { - return column.map(([, fieldName]) => fieldName); - }); -} diff --git a/webapp/src/components/App/Studies/HeaderBottom.tsx b/webapp/src/components/App/Studies/HeaderBottom.tsx index d62184c9ef..e1b9c2d742 100644 --- a/webapp/src/components/App/Studies/HeaderBottom.tsx +++ b/webapp/src/components/App/Studies/HeaderBottom.tsx @@ -1,12 +1,4 @@ -import { - Box, - Button, - Chip, - Divider, - InputAdornment, - TextField, -} from "@mui/material"; -import SearchOutlinedIcon from "@mui/icons-material/SearchOutlined"; +import { Box, Button, Chip, Divider } from "@mui/material"; import { useTranslation } from "react-i18next"; import { indigo, purple } from "@mui/material/colors"; import useDebounce from "../../../hooks/useDebounce"; @@ -16,6 +8,7 @@ import useAppDispatch from "../../../redux/hooks/useAppDispatch"; import { StudyFilters, updateStudyFilters } from "../../../redux/ducks/studies"; import { GroupDTO, UserDTO } from "../../../common/types"; import { displayVersionName } from "../../../services/utils"; +import SearchFE from "../../common/fieldEditors/SearchFE"; type PropTypes = { onOpenFilterClick: VoidFunction; @@ -67,20 +60,11 @@ function HeaderBottom(props: PropTypes) { return ( - - - - ), - }} - sx={{ mx: 0 }} + useLabel /> {onSearchFilterChange && ( - - - - ), - }} - onChange={(e) => onSearchFilterChange(e.target.value as string)} - /> + )} {mainContent} {secondaryContent} diff --git a/webapp/src/components/common/fieldEditors/SearchFE.tsx b/webapp/src/components/common/fieldEditors/SearchFE.tsx new file mode 100644 index 0000000000..3dce719bd4 --- /dev/null +++ b/webapp/src/components/common/fieldEditors/SearchFE.tsx @@ -0,0 +1,39 @@ +import { InputAdornment } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import { useTranslation } from "react-i18next"; +import StringFE, { StringFEProps } from "./StringFE"; + +export interface SearchFE extends Omit { + InputProps?: Omit; + setSearchValue?: (value: string) => void; + useLabel?: boolean; +} + +function SearchFE(props: SearchFE) { + const { setSearchValue, onChange, InputProps, useLabel, ...rest } = props; + const { t } = useTranslation(); + const placeholderOrLabel = { + [useLabel ? "label" : "placeholder"]: t("global.search"), + }; + + return ( + + + + ), + }} + onChange={(event) => { + onChange?.(event); + setSearchValue?.(event.target.value); + }} + /> + ); +} + +export default SearchFE; diff --git a/webapp/src/theme.ts b/webapp/src/theme.ts index bfb2d709b5..b7c5a9092f 100644 --- a/webapp/src/theme.ts +++ b/webapp/src/theme.ts @@ -98,13 +98,12 @@ const theme = createTheme({ props: { variant: "outlined" }, style: { margin: "8px", - // TODO Remove the fixed height? - "& .MuiOutlinedInput-root:not(.MuiInputBase-multiline):not(.MuiAutocomplete-inputRoot)": + "& .MuiOutlinedInput-root:not(.MuiInputBase-multiline):not(.MuiAutocomplete-inputRoot) .MuiInputAdornment-sizeMedium + .MuiOutlinedInput-input": { - height: "50px", - "& .MuiOutlinedInput-notchedOutline": { - borderColor: "rgba(255,255,255,0.09)", - }, + // Default value: 'padding: 16.5px 14px' + // Don't use 'padding' to support adornments left and right + paddingTop: "13.5px", + paddingBottom: "13.5px", }, }, }, diff --git a/webapp/src/utils/textUtils.ts b/webapp/src/utils/textUtils.ts new file mode 100644 index 0000000000..cd0e85ca7b --- /dev/null +++ b/webapp/src/utils/textUtils.ts @@ -0,0 +1,11 @@ +import { deburr } from "lodash"; +import * as R from "ramda"; +import * as RA from "ramda-adjunct"; + +export const isSearchMatching = R.curry( + (search: string, values: string | string[]) => { + const format = R.o(R.toLower, deburr); + const isMatching = R.o(R.includes(format(search)), format); + return RA.ensureArray(values).find(isMatching); + } +); From 45e893f73ecd807406cc45d4e7f4302db754b78c Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 09:10:02 +0200 Subject: [PATCH 19/31] Allow admin token to see all jobs Signed-off-by: Paul Bui-Quang --- antarest/launcher/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index 16759c7b0e..e1781aead1 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -329,7 +329,7 @@ def _filter_from_user_permission( allowed_job_results.append(job_result) except StudyNotFoundError: if ( - (user and user.is_site_admin()) + (user and (user.is_site_admin() or user.is_admin_token())) or job_result.creation_date >= orphan_visibility_threshold ): allowed_job_results.append(job_result) From 5c2a306cddb5c6d32c62a4048bdd524864ff46c2 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 10:07:10 +0200 Subject: [PATCH 20/31] Add launcher tags (#1004) --- webapp/src/common/types.ts | 3 +++ webapp/src/components/App/Tasks/index.tsx | 27 ++++++++++++++++++++++- webapp/src/services/api/study.ts | 1 + 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/webapp/src/common/types.ts b/webapp/src/common/types.ts index a1ba3ea63b..a2c4f5ad3f 100644 --- a/webapp/src/common/types.ts +++ b/webapp/src/common/types.ts @@ -1,4 +1,5 @@ import { ReactNode } from "react"; +import { LaunchOptions } from "../services/api/study"; export type IdType = number | string; @@ -110,6 +111,7 @@ export interface LaunchJob { status: JobStatus; creationDate: string; completionDate: string; + launcherParams?: LaunchOptions; msg: string; outputId: string; exitCode: number; @@ -121,6 +123,7 @@ export interface LaunchJobDTO { status: JobStatus; creation_date: string; completion_date: string; + launcher_params: string; msg: string; output_id: string; exit_code: number; diff --git a/webapp/src/components/App/Tasks/index.tsx b/webapp/src/components/App/Tasks/index.tsx index df5bd50188..7241f904ec 100644 --- a/webapp/src/components/App/Tasks/index.tsx +++ b/webapp/src/components/App/Tasks/index.tsx @@ -11,6 +11,7 @@ import { Box, CircularProgress, Tooltip, + Chip, } from "@mui/material"; import { Link } from "react-router-dom"; import { debounce } from "lodash"; @@ -20,7 +21,7 @@ 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 { grey, indigo } from "@mui/material/colors"; import RootPage from "../../common/page/RootPage"; import SimpleLoader from "../../common/loaders/SimpleLoader"; import DownloadLink from "../../common/DownloadLink"; @@ -127,6 +128,29 @@ function JobsListing() { ); }; + const renderTags = (job: LaunchJob) => { + return ( + + {job.launcherParams?.xpansion && ( + + )} + {job.launcherParams?.adequacy_patch && ( + + )} + + ); + }; + const exportJobOutput = debounce( async (jobId: string): Promise => { try { @@ -238,6 +262,7 @@ function JobsListing() { `${t("global.unknown")} (${job.id})`} + {renderTags(job)} ), dateView: ( diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index 307e291fe8..a3450452cc 100644 --- a/webapp/src/services/api/study.ts +++ b/webapp/src/services/api/study.ts @@ -300,6 +300,7 @@ export const mapLaunchJobDTO = (j: LaunchJobDTO): LaunchJob => ({ status: j.status, creationDate: j.creation_date, completionDate: j.completion_date, + launcherParams: JSON.parse(j.launcher_params), msg: j.msg, outputId: j.output_id, exitCode: j.exit_code, From 054a10938e7498dd746ba733ced9fed9c285136d Mon Sep 17 00:00:00 2001 From: 3lbanna <76211863+3lbanna@users.noreply.github.com> Date: Wed, 27 Jul 2022 13:13:15 +0200 Subject: [PATCH 21/31] implement cluster list (#958) --- .../command/create_renewables_cluster.py | 3 + .../variantstudy/variant_study_service.py | 7 + webapp/public/locales/en/main.json | 38 ++ webapp/public/locales/fr/main.json | 38 ++ .../Commands/Edition/commandTypes.ts | 2 + .../Modelization/Areas/Hydro/index.tsx | 2 +- .../Areas/{Thermal => Hydro}/preview.png | Bin .../Areas/Renewables/RenewableForm.tsx | 115 ++++++ .../Modelization/Areas/Renewables/index.tsx | 32 +- .../Modelization/Areas/Renewables/preview.png | Bin 97105 -> 0 bytes .../Modelization/Areas/Renewables/utils.ts | 39 ++ .../Areas/Thermal/ThermalForm.tsx | 213 +++++++++++ .../Areas/Thermal/ThermalMatrixView.tsx | 119 ++++++ .../Modelization/Areas/Thermal/index.tsx | 31 +- .../Modelization/Areas/Thermal/utils.ts | 99 +++++ .../AddClusterDialog/AddClusterForm.tsx | 70 ++++ .../ClusterRoot/AddClusterDialog/index.tsx | 72 ++++ .../Areas/common/ClusterRoot/ClusterView.tsx | 63 +++ .../Areas/common/ClusterRoot/index.tsx | 362 ++++++++++++++++++ .../Areas/common/ClusterRoot/style.ts | 46 +++ .../Areas/common/ClusterRoot/utils.ts | 29 ++ .../Modelization/Areas/common/utils.ts | 51 +++ .../App/Singlestudy/explore/TabWrapper.tsx | 1 + .../FormGenerator/AutoSubmitGenerator.tsx | 44 +++ .../components/common/FormGenerator/index.tsx | 167 ++++++++ .../common/fieldEditors/BooleanFE.tsx | 3 +- webapp/src/redux/selectors.ts | 13 + 27 files changed, 1651 insertions(+), 8 deletions(-) rename webapp/src/components/App/Singlestudy/explore/Modelization/Areas/{Thermal => Hydro}/preview.png (100%) create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx delete mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/preview.png create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalMatrixView.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/AddClusterDialog/AddClusterForm.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/AddClusterDialog/index.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/ClusterView.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/index.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/style.ts create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/utils.ts create mode 100644 webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts create mode 100644 webapp/src/components/common/FormGenerator/AutoSubmitGenerator.tsx create mode 100644 webapp/src/components/common/FormGenerator/index.tsx diff --git a/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py b/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py index 8c7cf58b4e..d3ff859268 100644 --- a/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py +++ b/antarest/study/storage/variantstudy/model/command/create_renewables_cluster.py @@ -93,6 +93,9 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: cluster_list_config = study_data.tree.get( ["input", "renewables", "clusters", self.area_id, "list"] ) + # default values + if "ts-interpretation" not in self.parameters: + self.parameters["ts-interpretation"] = "power-generation" cluster_list_config[self.cluster_name] = self.parameters self.parameters["name"] = self.cluster_name diff --git a/antarest/study/storage/variantstudy/variant_study_service.py b/antarest/study/storage/variantstudy/variant_study_service.py index 8acf75c9d8..4e4067df6a 100644 --- a/antarest/study/storage/variantstudy/variant_study_service.py +++ b/antarest/study/storage/variantstudy/variant_study_service.py @@ -214,6 +214,13 @@ def append_command( ) study.commands.append(command_block) self.invalidate_cache(study) + self.event_bus.push( + Event( + type=EventType.STUDY_DATA_EDITED, + payload=study.to_json_summary(), + permissions=create_permission_from_study(study), + ) + ) return new_id def append_commands( diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 18e2f46b21..15fd93b935 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -276,6 +276,44 @@ "study.modelization.renewables": "Renewables Clus.", "study.modelization.reserves": "Reserves", "study.modelization.miscGen": "Misc. Gen.", + "study.modelization.clusters.byGroups": "Clusters by groups", + "study.modelization.clusters.addCluster": "Add cluster", + "study.modelization.clusters.newCluster": "New cluster", + "study.modelization.clusters.clusterGroup": "Cluster's group", + "study.modelization.clusters.operatingParameters": "Operating parameters", + "study.modelization.clusters.unitcount": "Unit", + "study.modelization.clusters.enabled": "Enabled", + "study.modelization.clusters.nominalCapacity": "Nominal capacity", + "study.modelization.clusters.mustRun": "Must run", + "study.modelization.clusters.minStablePower": "Min stable power", + "study.modelization.clusters.minUpTime": "Min up time", + "study.modelization.clusters.minDownTime": "Min down time", + "study.modelization.clusters.spinning": "Spinning", + "study.modelization.clusters.co2": "CO2", + "study.modelization.clusters.operatingCosts": "Operating costs", + "study.modelization.clusters.marginalCost": "Marginal cost", + "study.modelization.clusters.fixedCost": "Fixed cost", + "study.modelization.clusters.startupCost": "Startup cost", + "study.modelization.clusters.marketBidCost": "Market bid", + "study.modelization.clusters.spreadCost": "Spread cost", + "study.modelization.clusters.timeSeriesGen": "Timeseries generation", + "study.modelization.clusters.genTs": "Generate timeseries", + "study.modelization.clusters.volatilityForced": "Volatility forced", + "study.modelization.clusters.volatilityPlanned": "Volatility planned", + "study.modelization.clusters.lawForced": "Law forced", + "study.modelization.clusters.lawPlanned": "Law planned", + "study.modelization.clusters.matrix.common": "Common", + "study.modelization.clusters.matrix.tsGen": "TS generator", + "study.modelization.clusters.matrix.timeSeries": "Time-Series", + "study.modelization.clusters.backClusterList": "Back to cluster list", + "study.modelization.clusters.tsInterpretation": "Timeseries mode", + "study.modelization.clusters.group": "Group", + "studies.modelization.clusters.question.delete": "Are you sure you want to delete this cluster ?", + "study.error.addCluster": "Failed to add cluster", + "study.error.deleteCluster": "Failed to delete cluster", + "study.error.form.clusterName": "Cluster name already exist", + "study.success.addCluster": "Cluster added successfully", + "study.success.deleteCluster": "Cluster deleted successfully", "study.message.outputExportInProgress": "Downloading study outputs...", "study.question.deleteLink": "Are you sure you want to delete this link ?", "study.question.deleteArea": "Are you sure you want to delete this area ?", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 5fbf6e0331..f05fa792cf 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -276,6 +276,44 @@ "study.modelization.renewables": "Clus. Renouvelables", "study.modelization.reserves": "Réserves", "study.modelization.miscGen": "Divers Gen.", + "study.modelization.clusters.byGroups": "Clusters par groupes", + "study.modelization.clusters.addCluster": "Ajouter un cluster", + "study.modelization.clusters.newCluster": "Nouveau cluster", + "study.modelization.clusters.clusterGroup": "Groupe du cluster", + "study.modelization.clusters.operatingParameters": "Paramètres de fonctionnement", + "study.modelization.clusters.unitcount": "Unit", + "study.modelization.clusters.enabled": "Activé", + "study.modelization.clusters.nominalCapacity": "Capacité nominale", + "study.modelization.clusters.mustRun": "Must run", + "study.modelization.clusters.minStablePower": "Puissance stable min", + "study.modelization.clusters.minUpTime": "Temps de disponibilité min", + "study.modelization.clusters.minDownTime": "Temps d'arrêt min", + "study.modelization.clusters.spinning": "Spinning", + "study.modelization.clusters.co2": "CO2", + "study.modelization.clusters.operatingCosts": "Coûts d'exploitation", + "study.modelization.clusters.marginalCost": "Coûts marginaux", + "study.modelization.clusters.fixedCost": "Coûts fixe", + "study.modelization.clusters.startupCost": "Coûts de démarrage", + "study.modelization.clusters.marketBidCost": "Offre de marché", + "study.modelization.clusters.spreadCost": "Coûts de répartition", + "study.modelization.clusters.timeSeriesGen": "Génération des Timeseries", + "study.modelization.clusters.genTs": "Générer des timeseries", + "study.modelization.clusters.volatilityForced": "Volatilité forcée", + "study.modelization.clusters.volatilityPlanned": "Volatilité prévue", + "study.modelization.clusters.lawForced": "Loi forcée", + "study.modelization.clusters.lawPlanned": "Loi planifiée", + "study.modelization.clusters.matrix.common": "Common", + "study.modelization.clusters.matrix.tsGen": "TS generator", + "study.modelization.clusters.matrix.timeSeries": "Time-Series", + "study.modelization.clusters.backClusterList": "Retour à la liste des clusters", + "study.modelization.clusters.tsInterpretation": "Timeseries mode", + "study.modelization.clusters.group": "Groupes", + "studies.modelization.clusters.question.delete": "Êtes-vous sûr de vouloir supprimer ce cluster ?", + "study.error.addCluster": "Échec lors de la création du cluster", + "study.error.deleteCluster": "Échec lors de la suppression du cluster", + "study.error.form.clusterName": "Ce cluster existe déjà", + "study.success.addCluster": "Cluster créé avec succès", + "study.success.deleteCluster": "Cluster supprimé avec succès", "study.message.outputExportInProgress": "Téléchargement des sorties en cours...", "study.question.deleteLink": "Êtes-vous sûr de vouloir supprimer ce lien ?", "study.question.deleteArea": "Êtes-vous sûr de vouloir supprimer cette zone ?", diff --git a/webapp/src/components/App/Singlestudy/Commands/Edition/commandTypes.ts b/webapp/src/components/App/Singlestudy/Commands/Edition/commandTypes.ts index c933a939ac..c62ffaa0d7 100644 --- a/webapp/src/components/App/Singlestudy/Commands/Edition/commandTypes.ts +++ b/webapp/src/components/App/Singlestudy/Commands/Edition/commandTypes.ts @@ -24,6 +24,8 @@ export enum CommandEnum { CREATE_BINDING_CONSTRAINT = "create_binding_constraint", UPDATE_BINDING_CONSTRAINT = "update_binding_constraint", REMOVE_BINDING_CONSTRAINT = "remove_binding_constraint", + CREATE_RENEWABLES_CLUSTER = "create_renewables_cluster", + REMOVE_RENEWABLES_CLUSTER = "remove_renewables_cluster", CREATE_CLUSTER = "create_cluster", REMOVE_CLUSTER = "remove_cluster", REPLACE_MATRIX = "replace_matrix", diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx index 030ffe91cc..ded8203b3a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/index.tsx @@ -1,5 +1,5 @@ import UnderConstruction from "../../../../../../common/page/UnderConstruction"; -import previewImage from "../Thermal/preview.png"; +import previewImage from "./preview.png"; function Hydro() { return ; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/preview.png b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/preview.png similarity index 100% rename from webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/preview.png rename to webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Hydro/preview.png diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx new file mode 100644 index 0000000000..1ea75f6c27 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx @@ -0,0 +1,115 @@ +import { Box } from "@mui/material"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { noDataValues, RenewableType, tsModeOptions } from "./utils"; +import { MatrixStats, StudyMetadata } from "../../../../../../../common/types"; +import MatrixInput from "../../../../../../common/MatrixInput"; +import { IFormGenerator } from "../../../../../../common/FormGenerator"; +import AutoSubmitGeneratorForm from "../../../../../../common/FormGenerator/AutoSubmitGenerator"; +import { saveField } from "../common/utils"; + +interface Props { + area: string; + cluster: string; + study: StudyMetadata; + groupList: Array; +} + +export default function RenewableForm(props: Props) { + const { groupList, study, area, cluster } = props; + const [t] = useTranslation(); + const pathPrefix = useMemo( + () => `input/renewables/clusters/${area}/list/${cluster}`, + [area, cluster] + ); + const studyId = study.id; + + const groupOptions = useMemo( + () => groupList.map((item) => ({ label: item, value: item })), + // eslint-disable-next-line react-hooks/exhaustive-deps + [JSON.stringify(groupList)] + ); + + const saveValue = useMemo( + () => saveField(studyId, pathPrefix, noDataValues), + [pathPrefix, studyId] + ); + + const jsonGenerator: IFormGenerator = useMemo( + () => [ + { + translationId: "global.general", + fields: [ + { + type: "text", + name: "name", + path: `${pathPrefix}/name`, + label: t("global.name"), + disabled: true, + }, + { + type: "select", + name: "group", + path: `${pathPrefix}/group`, + label: t("study.modelization.clusters.group"), + options: groupOptions, + }, + { + type: "select", + name: "ts-interpretation", + path: `${pathPrefix}/ts-interpretation`, + label: t("study.modelization.clusters.tsInterpretation"), + options: tsModeOptions, + }, + ], + }, + { + translationId: "study.modelization.clusters.operatingParameters", + fields: [ + { + type: "switch", + name: "enabled", + path: `${pathPrefix}/enabled`, + label: t("study.modelization.clusters.enabled"), + }, + { + type: "number", + name: "unitcount", + path: `${pathPrefix}/unitcount`, + label: t("study.modelization.clusters.unitcount"), + }, + { + type: "number", + name: "nominalcapacity", + path: `${pathPrefix}/nominalcapacity`, + label: t("study.modelization.clusters.nominalCapacity"), + }, + ], + }, + ], + [groupOptions, pathPrefix, t] + ); + + return ( + <> + + + + + + ); +} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx index f2307f8ff0..0fb3216cf9 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/index.tsx @@ -1,8 +1,34 @@ -import UnderConstruction from "../../../../../../common/page/UnderConstruction"; -import previewImage from "./preview.png"; +import { useTranslation } from "react-i18next"; +import { useOutletContext } from "react-router-dom"; +import { StudyMetadata } from "../../../../../../../common/types"; +import ClusterRoot from "../common/ClusterRoot"; +import { getDefaultValues } from "../common/utils"; +import RenewableForm from "./RenewableForm"; +import { fixedGroupList, noDataValues } from "./utils"; function Renewables() { - return ; + const { study } = useOutletContext<{ study: StudyMetadata }>(); + const [t] = useTranslation(); + + return ( + + {({ study, cluster, area, groupList }) => ( + + )} + + ); } export default Renewables; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/preview.png b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/preview.png deleted file mode 100644 index 83586ebc7aef2a391a06ec4c0ba42789eed6dcca..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 97105 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 diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts new file mode 100644 index 0000000000..f60a99d35f --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/utils.ts @@ -0,0 +1,39 @@ +import { FieldValues } from "react-hook-form"; + +type TsModeType = "power-generation" | "production-factor"; + +export interface RenewableType extends FieldValues { + name: string; + group?: string; + "ts-interpretation": TsModeType; + enabled: boolean; // Default: true + unitcount: number; // Default: 0 + nominalcapacity: number; // Default: 0 +} + +export const noDataValues: Partial = { + enabled: true, + unitcount: 0, + nominalcapacity: 0, +}; + +export const tsModeOptions = ["power-generation", "production-factor"].map( + (item) => ({ + label: item, + value: item, + }) +); + +export const fixedGroupList = [ + "Wind Onshore", + "Wind Offshore", + "Solar Thermal", + "Solar PV", + "Solar Rooftop", + "Other RES 1", + "Other RES 2", + "Other RES 3", + "Other RES 4", +]; + +export type RenewablePath = Record; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx new file mode 100644 index 0000000000..ea3525a3f7 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx @@ -0,0 +1,213 @@ +import { Box } from "@mui/material"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { genTsOptions, lawOptions, noDataValues, ThermalType } from "./utils"; +import { StudyMetadata } from "../../../../../../../common/types"; +import ThermalMatrixView from "./ThermalMatrixView"; +import { IFormGenerator } from "../../../../../../common/FormGenerator"; +import AutoSubmitGeneratorForm from "../../../../../../common/FormGenerator/AutoSubmitGenerator"; +import { saveField } from "../common/utils"; + +interface Props { + area: string; + cluster: string; + study: StudyMetadata; + groupList: Array; +} + +export default function ThermalForm(props: Props) { + const { groupList, study, area, cluster } = props; + const [t] = useTranslation(); + const pathPrefix = useMemo( + () => `input/thermal/clusters/${area}/list/${cluster}`, + [area, cluster] + ); + const studyId = study.id; + + const groupOptions = useMemo( + () => groupList.map((item) => ({ label: item, value: item })), + [groupList] + ); + + const saveValue = useMemo( + () => saveField(studyId, pathPrefix, noDataValues), + [pathPrefix, studyId] + ); + + const jsonGenerator: IFormGenerator = useMemo( + () => [ + { + translationId: "global.general", + fields: [ + { + type: "text", + name: "name", + label: t("global.name"), + path: `${pathPrefix}/name`, + disabled: true, + }, + { + type: "select", + name: "group", + label: t("study.modelization.clusters.group"), + path: `${pathPrefix}/group`, + options: groupOptions, + }, + ], + }, + { + translationId: "study.modelization.clusters.operatingParameters", + fields: [ + { + type: "switch", + name: "enabled", + path: `${pathPrefix}/enabled`, + label: t("study.modelization.clusters.enabled"), + }, + { + type: "switch", + name: "must-run", + path: `${pathPrefix}/must-run`, + label: t("study.modelization.clusters.mustRun"), + }, + { + type: "number", + name: "unitcount", + path: `${pathPrefix}/unitcount`, + label: t("study.modelization.clusters.unitcount"), + }, + { + type: "number", + name: "nominalcapacity", + path: `${pathPrefix}/nominalcapacity`, + label: t("study.modelization.clusters.nominalCapacity"), + }, + { + type: "number", + name: "min-stable-power", + path: `${pathPrefix}/min-stable-power`, + label: t("study.modelization.clusters.minStablePower"), + }, + { + type: "number", + name: "spinning", + path: `${pathPrefix}/spinning`, + label: t("study.modelization.clusters.spinning"), + }, + { + type: "number", + name: "min-up-time", + path: `${pathPrefix}/min-up-time`, + label: t("study.modelization.clusters.minUpTime"), + }, + { + type: "number", + name: "min-down-time", + path: `${pathPrefix}/min-down-time`, + label: t("study.modelization.clusters.minDownTime"), + }, + { + type: "number", + name: "co2", + path: `${pathPrefix}/co2`, + label: t("study.modelization.clusters.co2"), + }, + ], + }, + { + translationId: "study.modelization.clusters.operatingCosts", + fields: [ + { + type: "number", + name: "marginal-cost", + path: `${pathPrefix}/marginal-cost`, + label: t("study.modelization.clusters.marginalCost"), + }, + { + type: "number", + name: "fixed-cost", + path: `${pathPrefix}/fixed-cost`, + label: t("study.modelization.clusters.fixedCost"), + }, + { + type: "number", + name: "startup-cost", + path: `${pathPrefix}/startup-cost`, + label: t("study.modelization.clusters.startupCost"), + }, + { + type: "number", + name: "market-bid-cost", + path: `${pathPrefix}/market-bid-cost`, + label: t("study.modelization.clusters.marketBidCost"), + }, + { + type: "number", + name: "spread-cost", + path: `${pathPrefix}/spread-cost`, + label: t("study.modelization.clusters.spreadCost"), + }, + ], + }, + + { + translationId: "study.modelization.clusters.timeSeriesGen", + fields: [ + { + type: "select", + name: "gen-ts", + path: `${pathPrefix}/gen-ts`, + label: t("study.modelization.clusters.genTs"), + options: genTsOptions, + }, + { + type: "number", + name: "volatility.forced", + path: `${pathPrefix}/volatility.forced`, + label: t("study.modelization.clusters.volatilityForced"), + }, + { + type: "number", + name: "volatility.planned", + path: `${pathPrefix}/volatility.planned`, + label: t("study.modelization.clusters.volatilityPlanned"), + }, + { + type: "select", + name: "law.forced", + path: `${pathPrefix}/law.forced`, + label: t("study.modelization.clusters.lawForced"), + options: lawOptions, + }, + { + type: "select", + name: "law.planned", + path: `${pathPrefix}/law.planned`, + label: t("study.modelization.clusters.lawPlanned"), + options: lawOptions, + }, + ], + }, + ], + [t, pathPrefix, groupOptions] + ); + + return ( + <> + + + + + + ); +} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalMatrixView.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalMatrixView.tsx new file mode 100644 index 0000000000..274e3a10a8 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalMatrixView.tsx @@ -0,0 +1,119 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import * as React from "react"; +import * as R from "ramda"; +import { styled } from "@mui/material"; +import Tabs from "@mui/material/Tabs"; +import Tab from "@mui/material/Tab"; +import Box from "@mui/material/Box"; +import { useTranslation } from "react-i18next"; +import { + Cluster, + MatrixStats, + StudyMetadata, +} from "../../../../../../../common/types"; +import MatrixInput from "../../../../../../common/MatrixInput"; + +export const StyledTab = styled(Tabs)({ + width: "98%", + borderBottom: 1, + borderColor: "divider", +}); + +interface Props { + study: StudyMetadata; + area: string; + cluster: Cluster["id"]; +} + +function ThermalMatrixView(props: Props) { + const [t] = useTranslation(); + const { study, area, cluster } = props; + const [value, setValue] = React.useState(0); + + const commonNames = [ + "Marginal Cost modulation", + "Market bid modulation", + "Capacity mod", + "Mid Gen modulation", + ]; + + const tsGenNames = [ + "FO Duration", + "PO Duration", + "FO Rate", + "PO Rate", + "NPO Min", + "NPO Max", + ]; + + const handleChange = (event: React.SyntheticEvent, newValue: number) => { + setValue(newValue); + }; + return ( + + + + + + + + {R.cond([ + [ + () => value === 0, + () => ( + + ), + ], + [ + () => value === 1, + () => ( + + ), + ], + [ + R.T, + () => ( + + ), + ], + ])()} + + + ); +} + +export default ThermalMatrixView; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx index 12417d1c0c..5f7b8bcfe2 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/index.tsx @@ -1,8 +1,33 @@ -import UnderConstruction from "../../../../../../common/page/UnderConstruction"; -import previewImage from "./preview.png"; +import { useTranslation } from "react-i18next"; +import { useOutletContext } from "react-router-dom"; +import { StudyMetadata } from "../../../../../../../common/types"; +import ClusterRoot from "../common/ClusterRoot"; +import { getDefaultValues } from "../common/utils"; +import ThermalForm from "./ThermalForm"; +import { fixedGroupList, noDataValues } from "./utils"; function Thermal() { - return ; + const { study } = useOutletContext<{ study: StudyMetadata }>(); + const [t] = useTranslation(); + return ( + + {({ study, cluster, area, groupList }) => ( + + )} + + ); } export default Thermal; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts new file mode 100644 index 0000000000..2e2158b4ce --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/utils.ts @@ -0,0 +1,99 @@ +import { FieldValues } from "react-hook-form"; +import { Cluster } from "../../../../../../../common/types"; +import { getStudyData } from "../../../../../../../services/api/study"; + +type GenTsType = + | "use global parameter" + | "force no generation" + | "force generation"; + +type LawType = "geometric" | "uniform"; + +export interface ThermalType extends FieldValues { + name: string; + group: string; + enabled?: boolean; // Default: true + unitcount?: number; // Default: 0 + nominalcapacity?: number; // Default: 0 + "gen-ts"?: GenTsType; // Default: use global parameter + "min-stable-power"?: number; // Default: 0 + "min-up-time"?: number; // Default: 1 + "min-down-time"?: number; // Default: 1 + "must-run"?: boolean; // Default: false + spinning?: number; // Default: 0 + co2?: number; // Default: 0 + "volatility.forced"?: number; // Default: 0 + "volatility.planned"?: number; // Default: 0 + "law.forced"?: LawType; // Default: uniform + "law.planned"?: LawType; // Default: uniform + "marginal-cost"?: number; // Default: 0 + "spread-cost"?: number; // Default: 0 + "fixed-cost"?: number; // Default: 0 + "startup-cost"?: number; // Default: 0 + "market-bid-cost"?: number; // Default: 0 */ +} + +export const noDataValues: Partial = { + name: "", + group: "", + enabled: true, + unitcount: 0, + nominalcapacity: 0, + "gen-ts": "use global parameter", + "min-stable-power": 0, + "min-up-time": 1, + "min-down-time": 1, + "must-run": false, + spinning: 0, + co2: 0, + "volatility.forced": 0, + "volatility.planned": 0, + "law.forced": "uniform", + "law.planned": "uniform", + "marginal-cost": 0, + "spread-cost": 0, + "fixed-cost": 0, + "startup-cost": 0, + "market-bid-cost": 0, +}; + +export const genTsOptions = [ + "use global parameter", + "force no generation", + "force generation", +].map((item) => ({ label: item, value: item })); + +export const lawOptions = ["uniform", "geometric"].map((item) => ({ + label: item, + value: item, +})); + +export const fixedGroupList = [ + "Gas", + "Hard Coal", + "Lignite", + "Mixed fuel", + "Nuclear", + "Oil", + "Other", + "Other 2", + "Other 3", + "Other 4", +]; + +export type ThermalPath = Record; + +export async function getDefaultValues( + studyId: string, + area: string, + cluster: Cluster["id"] +): Promise { + const pathPrefix = `input/thermal/clusters/${area}/list/${cluster}`; + const data: ThermalType = await getStudyData(studyId, pathPrefix, 3); + Object.keys(noDataValues).forEach((item) => { + data[item] = data[item] !== undefined ? data[item] : noDataValues[item]; + }); + return data; +} + +export default {}; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/AddClusterDialog/AddClusterForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/AddClusterDialog/AddClusterForm.tsx new file mode 100644 index 0000000000..043c5df337 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/AddClusterDialog/AddClusterForm.tsx @@ -0,0 +1,70 @@ +import { useTranslation } from "react-i18next"; +import { Box } from "@mui/material"; +import { useFormContext } from "../../../../../../../../common/Form"; +import SelectFE from "../../../../../../../../common/fieldEditors/SelectFE"; +import { AddClustersFields } from "../utils"; +import StringFE from "../../../../../../../../common/fieldEditors/StringFE"; + +interface Props { + clusterGroupList: Array; +} + +function AddClusterForm(props: Props) { + const { clusterGroupList } = props; + const { control } = useFormContext(); + const { t } = useTranslation(); + const groupOptions = clusterGroupList.map((item) => ({ + label: item, + value: item, + })); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + <> + {/* Name */} + + + + + + ); +} + +export default AddClusterForm; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/AddClusterDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/AddClusterDialog/index.tsx new file mode 100644 index 0000000000..69f29cc3bc --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/AddClusterDialog/index.tsx @@ -0,0 +1,72 @@ +import { AxiosError } from "axios"; +import { useTranslation } from "react-i18next"; +import { useSnackbar } from "notistack"; +import { AddClustersFields, ClusterList } from "../utils"; +import FormDialog, { + FormDialogProps, +} from "../../../../../../../../common/dialogs/FormDialog"; +import AddClusterForm from "./AddClusterForm"; +import { SubmitHandlerData } from "../../../../../../../../common/Form"; +import useEnqueueErrorSnackbar from "../../../../../../../../../hooks/useEnqueueErrorSnackbar"; +import { appendCommands } from "../../../../../../../../../services/api/variant"; +import { CommandEnum } from "../../../../../../Commands/Edition/commandTypes"; + +interface PropType extends Omit { + clusterData: ClusterList | undefined; + clusterGroupList: Array; + studyId: string; + area: string; + type: "thermals" | "renewables"; +} + +function AddClusterDialog(props: PropType) { + const [t] = useTranslation(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const { enqueueSnackbar } = useSnackbar(); + const { type, clusterGroupList, clusterData, studyId, area, ...dialogProps } = + props; + const { onCancel } = dialogProps; + const defaultValues: AddClustersFields = { + name: "", + group: "", + }; + + const handleSubmit = async (data: SubmitHandlerData) => { + const { name, group } = data.dirtyValues; + try { + await appendCommands(studyId, [ + { + action: + type === "thermals" + ? CommandEnum.CREATE_CLUSTER + : CommandEnum.CREATE_RENEWABLES_CLUSTER, + args: { + area_id: area, + cluster_name: (name as string).toLowerCase(), + parameters: { + group: group || "*", + }, + }, + }, + ]); + enqueueSnackbar(t("study.success.addCluster"), { variant: "success" }); + } catch (e) { + enqueueErrorSnackbar(t("study.error.addCluster"), e as AxiosError); + } finally { + onCancel(); + } + }; + + return ( + + + + ); +} + +export default AddClusterDialog; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/ClusterView.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/ClusterView.tsx new file mode 100644 index 0000000000..5bdc415c86 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/ClusterView.tsx @@ -0,0 +1,63 @@ +import { Box } from "@mui/material"; +import { DeepPartial, FieldValues, UnpackNestedValue } from "react-hook-form"; +import { PropsWithChildren } from "react"; +import Form from "../../../../../../../common/Form"; +import { Cluster, StudyMetadata } from "../../../../../../../../common/types"; +import usePromise from "../../../../../../../../hooks/usePromise"; +import SimpleLoader from "../../../../../../../common/loaders/SimpleLoader"; +import UsePromiseCond from "../../../../../../../common/utils/UsePromiseCond"; + +interface ClusterViewProps { + area: string; + cluster: Cluster["id"]; + studyId: StudyMetadata["id"]; + noDataValues: Partial; + type: "thermals" | "renewables"; + getDefaultValues: ( + studyId: StudyMetadata["id"], + area: string, + cluster: string, + noDataValues: Partial, + type: "thermals" | "renewables" + ) => Promise; +} + +export default function ClusterView( + props: PropsWithChildren> +) { + const { + area, + getDefaultValues, + cluster, + studyId, + noDataValues, + type, + children, + } = props; + + const res = usePromise( + () => getDefaultValues(studyId, area, cluster, noDataValues, type), + [studyId, area] + ); + + return ( + + } + ifResolved={(data) => ( + > + | undefined, + }} + > + {children} + + )} + /> + + ); +} diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/index.tsx new file mode 100644 index 0000000000..49981c8bec --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/index.tsx @@ -0,0 +1,362 @@ +import { + Box, + Button, + List, + ListSubheader, + Collapse, + ListItemText, + IconButton, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import * as R from "ramda"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { useEffect, useMemo, useState } from "react"; +import { AxiosError } from "axios"; +import { useSnackbar } from "notistack"; +import { FieldValues } from "react-hook-form"; +import { + Header, + ListContainer, + Root, + GroupButton, + ClusterButton, +} from "./style"; +import usePromise from "../../../../../../../../hooks/usePromise"; +import SimpleLoader from "../../../../../../../common/loaders/SimpleLoader"; +import useAppSelector from "../../../../../../../../redux/hooks/useAppSelector"; +import { Cluster, StudyMetadata } from "../../../../../../../../common/types"; +import { + getCurrentAreaId, + getCurrentClusters, +} from "../../../../../../../../redux/selectors"; +import { getStudyData } from "../../../../../../../../services/api/study"; +import { Clusters, byGroup, ClusterElement } from "./utils"; +import AddClusterDialog from "./AddClusterDialog"; +import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; +import { appendCommands } from "../../../../../../../../services/api/variant"; +import { CommandEnum } from "../../../../../Commands/Edition/commandTypes"; +import ClusterView from "./ClusterView"; +import UsePromiseCond from "../../../../../../../common/utils/UsePromiseCond"; +import ConfirmationDialog from "../../../../../../../common/dialogs/ConfirmationDialog"; + +interface ClusterRootProps { + children: (elm: { + study: StudyMetadata; + cluster: Cluster["id"]; + area: string; + groupList: Array; + }) => React.ReactNode; + getDefaultValues: ( + studyId: StudyMetadata["id"], + area: string, + cluster: string, + noDataValues: Partial, + type: "thermals" | "renewables" + ) => Promise; + noDataValues: Partial; + study: StudyMetadata; + fixedGroupList: Array; + type: "thermals" | "renewables"; + backButtonName: string; +} + +function ClusterRoot(props: ClusterRootProps) { + const [t] = useTranslation(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const { enqueueSnackbar } = useSnackbar(); + const { + study, + type, + fixedGroupList, + backButtonName, + noDataValues, + getDefaultValues, + children, + } = props; + const currentArea = useAppSelector(getCurrentAreaId); + const clusterInitList = useAppSelector((state) => + getCurrentClusters(type, study.id, state) + ); + // TO DO: Replace this and Optimize to add/remove the right clusters + const res = usePromise( + () => + getStudyData( + study.id, + `input/${ + type === "thermals" ? "thermal" : type + }/clusters/${currentArea}/list`, + 3 + ), + [study.id, currentArea, clusterInitList] + ); + + const { data: clusterData } = res; + + const clusters = useMemo(() => { + const tmpData: Array = clusterData + ? Object.keys(clusterData).map((item) => ({ + id: item, + name: clusterData[item].name, + group: clusterData[item].group ? clusterData[item].group : "*", + })) + : []; + const clusterDataByGroup: Record = + byGroup(tmpData); + const clustersObj = Object.keys(clusterDataByGroup).map( + (group) => + [group, { items: clusterDataByGroup[group], isOpen: true }] as Readonly< + [ + string, + { + items: Array; + isOpen: boolean; + } + ] + > + ); + const clusterListObj: Clusters = R.fromPairs(clustersObj); + return clusterListObj; + }, [clusterData]); + + const [clusterList, setClusterList] = useState(clusters); + const [isAddClusterDialogOpen, setIsAddClusterDialogOpen] = useState(false); + const [currentCluster, setCurrentCluster] = useState(); + const [clusterForDeletion, setClusterForDeletion] = useState(); + + const clusterGroupList: Array = useMemo(() => { + const tab = [...new Set([...fixedGroupList, ...Object.keys(clusters)])]; + return tab; + }, [clusters, fixedGroupList]); + + useEffect(() => { + setClusterList({ ...clusters }); + }, [clusters]); + + useEffect(() => { + setCurrentCluster(undefined); + }, [currentArea]); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleToggleGroupOpen = (groupName: string): void => { + setClusterList({ + ...clusterList, + [groupName]: { + items: clusterList[groupName].items, + isOpen: !clusterList[groupName].isOpen, + }, + }); + }; + + const handleClusterDeletion = async (id: Cluster["id"]) => { + try { + const tmpData = { ...clusterData }; + delete tmpData[id]; + await appendCommands(study.id, [ + { + action: + type === "thermals" + ? CommandEnum.REMOVE_CLUSTER + : CommandEnum.REMOVE_RENEWABLES_CLUSTER, + args: { + area_id: currentArea, + cluster_id: id, + }, + }, + ]); + enqueueSnackbar(t("study.success.deleteCluster"), { variant: "success" }); + } catch (e) { + enqueueErrorSnackbar(t("study.error.deleteCluster"), e as AxiosError); + } finally { + setClusterForDeletion(undefined); + } + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return currentCluster === undefined ? ( + +

+ +
+ + } + ifResolved={(data) => ( + + {t("study.modelization.clusters.byGroups")} + + } + > + + {Object.keys(clusterList).map((group) => { + const clusterItems = clusterList[group]; + const { items, isOpen } = clusterItems; + return ( + + handleToggleGroupOpen(group)}> + + {group} + + } + /> + {isOpen ? ( + + ) : ( + + )} + + {items.map((item: ClusterElement) => ( + + + setCurrentCluster(item.id)} + > + + { + e.stopPropagation(); + setClusterForDeletion(item.id); + }} + > + + + + + + ))} + + ); + })} + + {clusterForDeletion && ( + setClusterForDeletion(undefined)} + onConfirm={() => handleClusterDeletion(clusterForDeletion)} + alert="warning" + open + > + {t("studies.modelization.clusters.question.delete")} + + )} + {isAddClusterDialogOpen && ( + setIsAddClusterDialogOpen(false)} + /> + )} + + )} + /> + + + ) : ( + +
+ +
+ + + {children({ + study, + cluster: currentCluster, + area: currentArea, + groupList: clusterGroupList, + })} + + +
+ ); +} + +export default ClusterRoot; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/style.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/style.ts new file mode 100644 index 0000000000..59e51ade98 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/style.ts @@ -0,0 +1,46 @@ +import { Box, ListItemButton, styled } from "@mui/material"; + +export const Root = styled(Box)(({ theme }) => ({ + width: "100%", + height: "calc(100% - 50px)", + boxSizing: "border-box", + display: "flex", + flexDirection: "column", + padding: theme.spacing(1), +})); + +export const Header = styled(Box)(({ theme }) => ({ + width: "100%", + height: "60px", + display: "flex", + justifyContent: "flex-end", + alignItems: "center", +})); + +export const ListContainer = styled(Box)(({ theme }) => ({ + width: "100%", + flex: 1, + display: "flex", + flexFlow: "column nowrap", + boxSizing: "border-box", + padding: theme.spacing(1), + overflow: "hidden", +})); + +export const GroupButton = styled(ListItemButton)(({ theme }) => ({ + width: "100%", + height: "auto", + marginBottom: theme.spacing(1), + borderWidth: "1px", + borderRadius: "4px", + borderStyle: "solid", + borderLeftWidth: "4px", + borderColor: theme.palette.divider, + borderLeftColor: theme.palette.primary.main, +})); + +export const ClusterButton = styled(ListItemButton)(({ theme }) => ({ + paddingLeft: theme.spacing(4), + margin: theme.spacing(0.5, 0), + height: "auto", +})); diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/utils.ts new file mode 100644 index 0000000000..42a5bcdd7a --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/ClusterRoot/utils.ts @@ -0,0 +1,29 @@ +import * as R from "ramda"; +import { FieldValues } from "react-hook-form"; +import { Cluster } from "../../../../../../../../common/types"; + +export interface ClusterElement { + id: Cluster["id"]; + name: string; + group: string; +} + +export type ClusterList = { + [cluster: string]: ClusterElement; +}; + +export type Clusters = { + [group: string]: { + items: Array; + isOpen: boolean; + }; +}; + +export interface AddClustersFields extends FieldValues { + name: string; + group: string; +} + +export const byGroup = R.groupBy((cluster: ClusterElement) => cluster.group); + +export default {}; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts new file mode 100644 index 0000000000..8d6d986a05 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/common/utils.ts @@ -0,0 +1,51 @@ +import * as R from "ramda"; +import { FieldValues } from "react-hook-form"; +import { Cluster, StudyMetadata } from "../../../../../../../common/types"; +import { + editStudy, + getStudyData, +} from "../../../../../../../services/api/study"; + +export async function getDefaultValues( + studyId: string, + area: string, + cluster: Cluster["id"], + noDataValues: Partial, + type: "thermals" | "renewables" +): Promise { + const pathType = type === "thermals" ? "thermal" : type; + const pathPrefix = `input/${pathType}/clusters/${area}/list/${cluster}`; + const data: T = await getStudyData(studyId, pathPrefix, 3); + Object.keys(noDataValues).forEach((item) => { + (data as any)[item] = + data[item] !== undefined ? data[item] : noDataValues[item]; + }); + return data; +} + +export const saveField = R.curry( + ( + studyId: StudyMetadata["id"], + pathPrefix: string, + noDataValues: Partial, + name: string, + path: string, + defaultValues: any, + data: any + ): Promise => { + if (data === (noDataValues as any)[name] || data === undefined) { + const { [name]: ignore, ...toEdit } = defaultValues; + let edit = {}; + Object.keys(toEdit).forEach((item) => { + if ( + toEdit[item] !== (noDataValues as any)[item] && + toEdit[item] !== undefined + ) { + edit = { ...edit, [item]: toEdit[item] }; + } + }); + return editStudy(edit, studyId, pathPrefix); + } + return editStudy(data, studyId, path); + } +); diff --git a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx index d8b43793ba..c2332a0f44 100644 --- a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx +++ b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx @@ -13,6 +13,7 @@ export const StyledTab = styled(Tabs, { })<{ border?: boolean; tabStyle?: "normal" | "withoutBorder" }>( ({ theme, border, tabStyle }) => ({ width: "98%", + height: "50px", ...(border === true && { borderBottom: 1, borderColor: "divider", diff --git a/webapp/src/components/common/FormGenerator/AutoSubmitGenerator.tsx b/webapp/src/components/common/FormGenerator/AutoSubmitGenerator.tsx new file mode 100644 index 0000000000..7461cf1035 --- /dev/null +++ b/webapp/src/components/common/FormGenerator/AutoSubmitGenerator.tsx @@ -0,0 +1,44 @@ +import * as R from "ramda"; +import { useMemo } from "react"; +import { FieldValues } from "react-hook-form"; +import FormGenerator, { + IFieldsetType, + IFormGenerator, + IGeneratorField, +} from "."; + +interface AutoSubmitGeneratorFormProps { + jsonTemplate: IFormGenerator; + saveField: ( + name: IGeneratorField["name"], + path: string, + defaultValues: any, + data: any + ) => void; +} +export default function AutoSubmitGeneratorForm( + props: AutoSubmitGeneratorFormProps +) { + const { saveField, jsonTemplate } = props; + + const formatedJsonTemplate: IFormGenerator = useMemo( + () => + jsonTemplate.map((fieldset) => { + const { fields, ...otherProps } = fieldset; + const formatedFields: IFieldsetType["fields"] = fields.map( + (field) => ({ + ...field, + rules: (name, path, required, defaultValues) => ({ + onAutoSubmit: R.curry(saveField)(name, path, defaultValues), + required, + }), + }) + ); + + return { fields: formatedFields, ...otherProps }; + }), + [jsonTemplate, saveField] + ); + + return ; +} diff --git a/webapp/src/components/common/FormGenerator/index.tsx b/webapp/src/components/common/FormGenerator/index.tsx new file mode 100644 index 0000000000..3f2cbfbd55 --- /dev/null +++ b/webapp/src/components/common/FormGenerator/index.tsx @@ -0,0 +1,167 @@ +import * as R from "ramda"; +import { v4 as uuidv4 } from "uuid"; +import { + DeepPartial, + FieldValues, + Path, + UnpackNestedValue, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { SxProps, Theme } from "@mui/material"; +import { Fragment, useMemo } from "react"; +import SelectFE, { SelectFEProps } from "../fieldEditors/SelectFE"; +import StringFE from "../fieldEditors/StringFE"; +import Fieldset from "../Fieldset"; +import { RegisterOptionsPlus, useFormContext } from "../Form"; +import NumberFE from "../fieldEditors/NumberFE"; +import SwitchFE from "../fieldEditors/SwitchFE"; +import BooleanFE, { BooleanFEProps } from "../fieldEditors/BooleanFE"; + +export type GeneratorFieldType = + | "text" + | "number" + | "select" + | "switch" + | "boolean"; + +export interface IGeneratorField { + type: GeneratorFieldType; + name: Path & (string | undefined); + label: string; + path: string; + sx?: SxProps | undefined; + required?: boolean | string; + rules?: ( + name: IGeneratorField["name"], + path: string, + required?: boolean | string, + defaultValues?: UnpackNestedValue> | undefined + ) => + | Omit< + RegisterOptionsPlus & (string | undefined)>, + "disabled" | "valueAsNumber" | "valueAsDate" | "setValueAs" + > + | undefined; +} + +export interface SelectField extends IGeneratorField { + options: SelectFEProps["options"]; +} + +export interface BooleanField extends IGeneratorField { + falseText: BooleanFEProps["falseText"]; + trueText: BooleanFEProps["trueText"]; +} + +export type IGeneratorFieldType = + | IGeneratorField + | SelectField + | BooleanField; + +export interface IFieldsetType { + translationId: string; + fields: Array>; +} + +export type IFormGenerator = Array>; + +export interface FormGeneratorProps { + jsonTemplate: IFormGenerator; +} + +function formateFieldset(fieldset: IFieldsetType) { + const { fields, ...otherProps } = fieldset; + const formatedFields = fields.map((field) => ({ ...field, id: uuidv4() })); + return { ...otherProps, fields: formatedFields, id: uuidv4() }; +} + +export default function FormGenerator( + props: FormGeneratorProps +) { + const { jsonTemplate } = props; + const formatedTemplate = useMemo( + () => jsonTemplate.map(formateFieldset), + [jsonTemplate] + ); + const [t] = useTranslation(); + const { control, defaultValues } = useFormContext(); + + return ( + <> + {formatedTemplate.map((fieldset) => ( +
+ {fieldset.fields.map((field) => { + const { id, path, rules, type, required, ...otherProps } = field; + const vRules = rules + ? rules(field.name, path, required, defaultValues) + : undefined; + return ( + + {R.cond([ + [ + R.equals("text"), + () => ( + + ), + ], + [ + R.equals("number"), + () => ( + + ), + ], + [ + R.equals("switch"), + () => ( + + ), + ], + [ + R.equals("boolean"), + () => ( + + ), + ], + [ + R.equals("select"), + () => ( + , "id" | "rules">) + .options || [] + } + {...otherProps} + variant="filled" + control={control} + rules={vRules} + /> + ), + ], + ])(type)} + + ); + })} +
+ ))} + + ); +} diff --git a/webapp/src/components/common/fieldEditors/BooleanFE.tsx b/webapp/src/components/common/fieldEditors/BooleanFE.tsx index b5dbb59f5b..807cec0fa0 100644 --- a/webapp/src/components/common/fieldEditors/BooleanFE.tsx +++ b/webapp/src/components/common/fieldEditors/BooleanFE.tsx @@ -3,7 +3,8 @@ import * as RA from "ramda-adjunct"; import reactHookFormSupport from "../../../hoc/reactHookFormSupport"; import SelectFE, { SelectFEProps } from "./SelectFE"; -interface BooleanFEProps extends Omit { +export interface BooleanFEProps + extends Omit { defaultValue?: boolean; value?: boolean; trueText?: string; diff --git a/webapp/src/redux/selectors.ts b/webapp/src/redux/selectors.ts index 2ab92a005b..7341dcb622 100644 --- a/webapp/src/redux/selectors.ts +++ b/webapp/src/redux/selectors.ts @@ -1,5 +1,6 @@ import { createEntityAdapter, createSelector } from "@reduxjs/toolkit"; import { + Cluster, FileStudyTreeConfigDTO, GroupDetailsDTO, LinkListElement, @@ -231,6 +232,18 @@ export const getStudyLinks = createSelector(getStudyData, (data) => { return []; }); +export const getCurrentClusters = ( + type: "thermals" | "renewables", + studyId: string, + state: AppState +): Array => { + const currentStudyState = getStudyDataState(state); + const { currentArea } = currentStudyState; + const clusters = + currentStudyState.entities[studyId]?.areas[currentArea][type]; + return clusters || []; +}; + //////////////////////////////////////////////////////////////// // UI //////////////////////////////////////////////////////////////// From 0ddb02ed00f8ba83f8c32073e971fa97c0775c68 Mon Sep 17 00:00:00 2001 From: Wintxer <47366828+Wintxer@users.noreply.github.com> Date: Wed, 27 Jul 2022 13:14:33 +0200 Subject: [PATCH 22/31] issue 946 Add a launcher option (#999) --- webapp/public/locales/en/main.json | 2 + webapp/public/locales/fr/main.json | 2 + .../components/App/Studies/LauncherDialog.tsx | 52 ++++++++- .../src/components/App/Tasks/JobTableView.tsx | 108 ++++++++++++------ .../src/components/common/LoadIndicator.tsx | 50 ++++++++ webapp/src/services/api/study.ts | 10 ++ 6 files changed, 188 insertions(+), 36 deletions(-) create mode 100644 webapp/src/components/common/LoadIndicator.tsx diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 15fd93b935..e6841b02f2 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -175,6 +175,7 @@ "study.timeLimit": "Time limit", "study.timeLimitHelper": "Time limit in hours (max: {{max}}h)", "study.nbCpu": "Number of core", + "study.clusterLoad": "Cluster load", "study.synthesis": "Synthesis", "study.level": "Level", "study.years": "Years", @@ -330,6 +331,7 @@ "study.error.deleteAreaOrLink": "Area or link not deleted", "study.error.getAreasInfo": "Failed to fetch areas data", "study.error.modifiedStudy": "Study {{studyname}} not updated", + "study.error.launchLoad": "Failed to retrieve the load of the cluster", "study.success.commentsSaved": "Comments saved successfully", "study.success.studyIdCopy": "Study id copied !", "study.success.jobIdCopy": "Job id copied !", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index f05fa792cf..63c3a6c608 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -175,6 +175,7 @@ "study.timeLimit": "Limite de temps", "study.timeLimitHelper": "Limite de temps en heures (max: {{max}}h)", "study.nbCpu": "Nombre de coeurs", + "study.clusterLoad": "Charge du cluster", "study.synthesis": "Synthesis", "study.level": "Niveau", "study.years": "Années", @@ -330,6 +331,7 @@ "study.error.deleteAreaOrLink": "Zone ou lien non supprimé", "study.error.getAreasInfo": "Impossible de récupérer les informations sur les zones", "study.error.modifiedStudy": "Erreur lors de la modification de l'étude {{studyname}}", + "study.error.launchLoad": "Échec lors de la récupération de la charge du cluster", "study.success.commentsSaved": "Commentaires enregistrés avec succès", "study.success.studyIdCopy": "Identifiant de l'étude copié !", "study.success.jobIdCopy": "Identifiant de la tâche copié !", diff --git a/webapp/src/components/App/Studies/LauncherDialog.tsx b/webapp/src/components/App/Studies/LauncherDialog.tsx index 125ab569d3..f246ee99b4 100644 --- a/webapp/src/components/App/Studies/LauncherDialog.tsx +++ b/webapp/src/components/App/Studies/LauncherDialog.tsx @@ -12,6 +12,7 @@ import { FormGroup, List, ListItem, + Slider, TextField, Typography, useTheme, @@ -22,13 +23,21 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { useMountedState } from "react-use"; import { shallowEqual } from "react-redux"; import { StudyMetadata } from "../../../common/types"; -import { LaunchOptions, launchStudy } from "../../../services/api/study"; +import { + getLauncherLoad, + LaunchOptions, + launchStudy, +} from "../../../services/api/study"; import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; import BasicDialog from "../../common/dialogs/BasicDialog"; import useAppSelector from "../../../redux/hooks/useAppSelector"; import { getStudy } from "../../../redux/selectors"; +import usePromiseWithSnackbarError from "../../../hooks/usePromiseWithSnackbarError"; +import LoadIndicator from "../../common/LoadIndicator"; const LAUNCH_DURATION_MAX_HOURS = 240; +const LAUNCH_LOAD_DEFAULT = 12; +const LAUNCH_LOAD_SLIDER = { step: 1, min: 1, max: 24 }; interface Props { open: boolean; @@ -42,7 +51,9 @@ function LauncherDialog(props: Props) { const { enqueueSnackbar } = useSnackbar(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const theme = useTheme(); - const [options, setOptions] = useState({}); + const [options, setOptions] = useState({ + nb_cpu: LAUNCH_LOAD_DEFAULT, + }); const [solverVersion, setSolverVersion] = useState(); const [isLaunching, setIsLaunching] = useState(false); const isMounted = useMountedState(); @@ -51,6 +62,11 @@ function LauncherDialog(props: Props) { shallowEqual ); + const { data: load } = usePromiseWithSnackbarError(() => getLauncherLoad(), { + errorMessage: t("study.error.launchLoad"), + deps: [open], + }); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -145,7 +161,7 @@ function LauncherDialog(props: Props) { alignItems: "flex-start", px: 2, boxSizing: "border-box", - overflowY: "scroll", + overflowY: "auto", overflowX: "hidden", }} > @@ -248,6 +264,36 @@ function LauncherDialog(props: Props) { max: LAUNCH_DURATION_MAX_HOURS, })} /> + + {t("study.nbCpu")} + {load && ( + + )} + + handleChange("nb_cpu", val as number)} + /> ; @@ -40,6 +44,12 @@ function JobTableView(props: PropType) { useState(false); const [currentContent, setCurrentContent] = useState(content); + const { data: load, reload: reloadLauncherLoad } = + usePromiseWithSnackbarError(() => getLauncherLoad(), { + errorMessage: t("study.error.launchLoad"), + deps: [], + }); + const applyFilter = useCallback( (taskList: TaskView[]) => { let filteredContent = taskList; @@ -86,42 +96,74 @@ function JobTableView(props: PropType) { overflowY: "auto", display: "flex", flexDirection: "column", - alignItems: "flex-end", }} > - - - - - + + {t("study.clusterLoad")} + {load && ( + - } - label={t("tasks.runningTasks") as string} - /> - - - {t("tasks.typeFilter")} - - - + )} + + + + + + + } + label={t("tasks.runningTasks") as string} + /> + + + {t("tasks.typeFilter")} + + + + diff --git a/webapp/src/components/common/LoadIndicator.tsx b/webapp/src/components/common/LoadIndicator.tsx new file mode 100644 index 0000000000..a0482a35bc --- /dev/null +++ b/webapp/src/components/common/LoadIndicator.tsx @@ -0,0 +1,50 @@ +import { + Tooltip, + Box, + LinearProgress, + Typography, + LinearProgressProps, +} from "@mui/material"; +import * as R from "ramda"; + +interface PropsType { + indicator: number; + size?: string; + tooltip: string; +} + +function LoadIndicator(props: PropsType) { + const { indicator, size, tooltip } = props; + + const renderLoadColor = (val: number): LinearProgressProps["color"] => + R.cond([ + [R.lt(0.9), () => "error"], + [R.lt(0.75), () => "primary"], + [R.T, () => "success"], + ])(val) as LinearProgressProps["color"]; + + return ( + + + + 100 ? 100 : indicator * 100} + /> + + + {`${Math.round( + indicator * 100 + )}%`} + + + + ); +} + +LoadIndicator.defaultProps = { + size: "100%", +}; + +export default LoadIndicator; diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index a3450452cc..247dd43f57 100644 --- a/webapp/src/services/api/study.ts +++ b/webapp/src/services/api/study.ts @@ -289,6 +289,16 @@ export const launchStudy = async ( return res.data; }; +interface LauncherLoadDTO { + slurm: number; + local: number; +} + +export const getLauncherLoad = async (): Promise => { + const res = await client.get("/v1/launcher/load"); + return res.data; +}; + export const killStudy = async (jid: string): Promise => { const res = await client.post(`/v1/launcher/jobs/${jid}/kill`); return res.data; From b318609d26f22b8b39c3a5a6700808f61aaf1b70 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 14:46:30 +0200 Subject: [PATCH 23/31] Fix matrixinput view for single colomn matrix Signed-off-by: Paul Bui-Quang --- webapp/src/components/common/MatrixInput/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/common/MatrixInput/index.tsx b/webapp/src/components/common/MatrixInput/index.tsx index d6a760375f..0e1da0b6ad 100644 --- a/webapp/src/components/common/MatrixInput/index.tsx +++ b/webapp/src/components/common/MatrixInput/index.tsx @@ -132,7 +132,7 @@ function MatrixInput(props: PropsType) { {title || t("xpansion.timeSeries")} - {!isLoading && data?.columns?.length > 1 && ( + {!isLoading && data?.columns?.length >= 1 && ( setToggleView((prev) => !prev)}> {toggleView ? ( @@ -167,7 +167,7 @@ function MatrixInput(props: PropsType) { {isLoading && } - {!isLoading && data?.columns?.length > 1 ? ( + {!isLoading && data?.columns?.length >= 1 ? ( Date: Wed, 27 Jul 2022 16:22:11 +0200 Subject: [PATCH 24/31] Fix area position modification (#1007) --- antarest/study/business/area_management.py | 10 ++++++++++ .../variantstudy/model/command/create_area.py | 6 +++--- tests/integration/test_integration.py | 12 ++++++------ tests/storage/business/test_arealink_manager.py | 2 ++ 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/antarest/study/business/area_management.py b/antarest/study/business/area_management.py index 914682cfc1..ec8a49cb2c 100644 --- a/antarest/study/business/area_management.py +++ b/antarest/study/business/area_management.py @@ -188,11 +188,21 @@ def update_area_ui( data=area_ui.x, command_context=self.storage_service.variant_study_service.command_factory.command_context, ), + UpdateConfig( + target=f"input/areas/{area_id}/ui/layerX/0", + data=area_ui.x, + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ), UpdateConfig( target=f"input/areas/{area_id}/ui/ui/y", data=area_ui.y, command_context=self.storage_service.variant_study_service.command_factory.command_context, ), + UpdateConfig( + target=f"input/areas/{area_id}/ui/layerY/0", + data=area_ui.y, + command_context=self.storage_service.variant_study_service.command_factory.command_context, + ), UpdateConfig( target=f"input/areas/{area_id}/ui/ui/color_r", data=area_ui.color_rgb[0], diff --git a/antarest/study/storage/variantstudy/model/command/create_area.py b/antarest/study/storage/variantstudy/model/command/create_area.py index 13c5a8f55f..21987fa22f 100644 --- a/antarest/study/storage/variantstudy/model/command/create_area.py +++ b/antarest/study/storage/variantstudy/model/command/create_area.py @@ -131,9 +131,9 @@ def _apply(self, study_data: FileStudy) -> CommandOutput: "color_b": 44, "layers": 0, }, - "layerX": {"O": 0}, - "layerY": {"O": 0}, - "layerColor": {"O": "230 , 108 , 44"}, + "layerX": {"0": 0}, + "layerY": {"0": 0}, + "layerColor": {"0": "230 , 108 , 44"}, }, }, }, diff --git a/tests/integration/test_integration.py b/tests/integration/test_integration.py index b61854b243..5a2155e8de 100644 --- a/tests/integration/test_integration.py +++ b/tests/integration/test_integration.py @@ -659,9 +659,9 @@ def test_area_management(app: FastAPI): "color_b": 100, "layers": 0, }, - "layerX": {"O": 0}, - "layerY": {"O": 0}, - "layerColor": {"O": "230 , 108 , 44"}, + "layerX": {"0": 100}, + "layerY": {"0": 100}, + "layerColor": {"0": "230 , 108 , 44"}, }, "area 2": { "ui": { @@ -672,9 +672,9 @@ def test_area_management(app: FastAPI): "color_b": 44, "layers": 0, }, - "layerX": {"O": 0}, - "layerY": {"O": 0}, - "layerColor": {"O": "230 , 108 , 44"}, + "layerX": {"0": 0}, + "layerY": {"0": 0}, + "layerColor": {"0": "230 , 108 , 44"}, }, } diff --git a/tests/storage/business/test_arealink_manager.py b/tests/storage/business/test_arealink_manager.py index 89c1677b8a..ad4f240638 100644 --- a/tests/storage/business/test_arealink_manager.py +++ b/tests/storage/business/test_arealink_manager.py @@ -184,7 +184,9 @@ def test_area_crud( action=CommandName.UPDATE_CONFIG.value, args=[ {"target": "input/areas/test/ui/ui/x", "data": "100"}, + {"target": "input/areas/test/ui/layerX/0", "data": "100"}, {"target": "input/areas/test/ui/ui/y", "data": "200"}, + {"target": "input/areas/test/ui/layerY/0", "data": "200"}, { "target": "input/areas/test/ui/ui/color_r", "data": "255", From c36fd0b7bd16f0b51ed9200889b93504336feb5c Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 16:34:59 +0200 Subject: [PATCH 25/31] Fix commands drag n drop server update when moving to same index Signed-off-by: Paul Bui-Quang --- .../src/components/App/Singlestudy/Commands/Edition/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/Commands/Edition/index.tsx b/webapp/src/components/App/Singlestudy/Commands/Edition/index.tsx index 294df8f5ac..32315f8cd5 100644 --- a/webapp/src/components/App/Singlestudy/Commands/Edition/index.tsx +++ b/webapp/src/components/App/Singlestudy/Commands/Edition/index.tsx @@ -77,8 +77,8 @@ function EditionView(props: Props) { const taskTimeoutId = useRef(); const onDragEnd = async ({ destination, source }: DropResult) => { - // dropped outside the list - if (!destination) return; + // dropped outside the list or same place + if (!destination || source.index === destination.index) return; const oldCommands = commands.concat([]); try { const elm = commands[source.index]; From fe7ca9ac3c811504ffcdb828875838eac94f663e Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 17:05:34 +0200 Subject: [PATCH 26/31] Readd alt adequacy patch + auto unzip option (#1009) --- antarest/launcher/model.py | 1 + antarest/launcher/service.py | 5 +- antarest/study/service.py | 9 +- webapp/public/locales/en/main.json | 2 + webapp/public/locales/fr/main.json | 2 + .../components/App/Studies/LauncherDialog.tsx | 105 ++++++++++++------ webapp/src/services/api/study.ts | 2 + 7 files changed, 89 insertions(+), 37 deletions(-) diff --git a/antarest/launcher/model.py b/antarest/launcher/model.py index 5ec663243f..20b4544b99 100644 --- a/antarest/launcher/model.py +++ b/antarest/launcher/model.py @@ -18,6 +18,7 @@ class LauncherParametersDTO(BaseModel): xpansion: bool = False xpansion_r_version: bool = False archive_output: bool = True + auto_unzip: bool = True output_suffix: Optional[str] = None other_options: Optional[str] = None # add extensions field here diff --git a/antarest/launcher/service.py b/antarest/launcher/service.py index e1781aead1..632ed3833a 100644 --- a/antarest/launcher/service.py +++ b/antarest/launcher/service.py @@ -563,9 +563,7 @@ def _import_output( zip_path: Optional[Path] = None stopwatch = StopWatch() - if LauncherParametersDTO.parse_raw( - job_result.launcher_params or "{}" - ).archive_output: + if job_launch_params.archive_output: logger.info("Re zipping output for transfer") zip_path = ( output_true_path.parent @@ -594,6 +592,7 @@ def _import_output( None, ), ), + job_launch_params.auto_unzip, ) except StudyNotFoundError: return self._import_fallback_output( diff --git a/antarest/study/service.py b/antarest/study/service.py index b7fb398d6c..c18f1b6572 100644 --- a/antarest/study/service.py +++ b/antarest/study/service.py @@ -1302,6 +1302,7 @@ def import_output( output: Union[IO[bytes], Path], params: RequestParameters, output_name_suffix: Optional[str] = None, + auto_unzip: bool = True, ) -> Optional[str]: """ Import specific output simulation inside study @@ -1310,6 +1311,7 @@ def import_output( output: zip file with simulation folder or simulation folder path params: request parameters output_name_suffix: optional suffix name for the output + auto_unzip: add a task to unzip the output after import Returns: output simulation json formatted @@ -1330,7 +1332,12 @@ def import_output( "output added to study %s by user %s", uuid, params.get_user_id() ) - if output_id and isinstance(output, Path) and output.suffix == ".zip": + if ( + output_id + and isinstance(output, Path) + and output.suffix == ".zip" + and auto_unzip + ): self.unarchive_output( uuid, output_id, True, not is_managed(study), params ) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index e6841b02f2..1a6653f69d 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -166,6 +166,8 @@ "settings.error.groupRolesSave": "Role(s) for group '{{0}}' not saved", "settings.error.tokenSave": "'{{0}}' token not saved", "settings.error.updateMaintenance": "Maintenance mode not updated", + "launcher.additionalModes": "Additional modes", + "launcher.autoUnzip": "Automatically unzip", "study.runStudy": "Study launch", "study.otherOptions": "Other options", "study.xpansionMode": "Xpansion mode", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 63c3a6c608..967933263b 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -166,6 +166,8 @@ "settings.error.groupRolesSave": "Role(s) pour le groupe '{{0}}' non sauvegardé", "settings.error.tokenSave": "Token '{{0}}' non sauvegardé", "settings.error.updateMaintenance": "Erreur lors du changement du status de maintenance", + "launcher.additionalModes": "Mode additionnels", + "launcher.autoUnzip": "Dézippage automatique", "study.runStudy": "Lancement d'étude", "study.otherOptions": "Autres options", "study.xpansionMode": "Mode Xpansion", diff --git a/webapp/src/components/App/Studies/LauncherDialog.tsx b/webapp/src/components/App/Studies/LauncherDialog.tsx index f246ee99b4..f769817929 100644 --- a/webapp/src/components/App/Studies/LauncherDialog.tsx +++ b/webapp/src/components/App/Studies/LauncherDialog.tsx @@ -53,6 +53,7 @@ function LauncherDialog(props: Props) { const theme = useTheme(); const [options, setOptions] = useState({ nb_cpu: LAUNCH_LOAD_DEFAULT, + auto_unzip: true, }); const [solverVersion, setSolverVersion] = useState(); const [isLaunching, setIsLaunching] = useState(false); @@ -295,6 +296,7 @@ function LauncherDialog(props: Props) { onChange={(event, val) => handleChange("nb_cpu", val as number)} /> + {t("launcher.additionalModes")} - { - handleChange("xpansion", checked); - }} - /> - } - label={t("study.xpansionMode") as string} - /> - - handleChange("xpansion_r_version", checked) - } - /> - } - label={t("study.useXpansionVersionR") as string} - /> - - handleChange("adequacy_patch", checked ? {} : undefined) - } - /> - } - label="Adequacy patch" - /> + + { + handleChange("xpansion", checked); + }} + /> + } + label={t("study.xpansionMode") as string} + /> + + handleChange("xpansion_r_version", checked) + } + /> + } + label={t("study.useXpansionVersionR") as string} + /> + + + + handleChange("adequacy_patch", checked ? {} : undefined) + } + /> + } + label="Adequacy patch" + /> + + handleChange( + "adequacy_patch", + checked ? { legacy: true } : {} + ) + } + /> + } + label="Adequacy patch non linearized" + /> + + + handleChange("auto_unzip", checked) + } + /> + } + label={t("launcher.autoUnzip")} + /> + + Date: Wed, 27 Jul 2022 17:55:33 +0200 Subject: [PATCH 27/31] Issue 852 Configuration Time series management (#982) --- antarest/study/business/config_management.py | 4 +- tests/storage/business/test_config_manager.py | 8 +- .../dialogs/ThematicTrimmingDialog/index.tsx | 5 +- .../explore/Configuration/General/utils.ts | 94 ++++++---- .../TimeSeriesManagement/Fields/index.tsx | 165 ++++++++++++++++++ .../TimeSeriesManagement/index.tsx | 33 +++- .../TimeSeriesManagement/utils.ts | 150 ++++++++++++++++ .../explore/Configuration/index.tsx | 4 +- .../explore/Modelization/Map/utils.ts | 44 +---- .../App/Singlestudy/explore/TabWrapper.tsx | 6 +- webapp/src/components/common/Form/index.tsx | 38 +++- .../components/common/dialogs/FormDialog.tsx | 32 ++-- .../common/fieldEditors/CheckBoxFE.tsx | 60 +++++++ .../common/fieldEditors/NumberFE.tsx | 7 +- webapp/src/hoc/reactHookFormSupport.tsx | 80 ++++++--- webapp/src/hooks/useDebouncedState.tsx | 33 ++++ 16 files changed, 637 insertions(+), 126 deletions(-) create mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/Fields/index.tsx create mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/utils.ts create mode 100644 webapp/src/components/common/fieldEditors/CheckBoxFE.tsx create mode 100644 webapp/src/hooks/useDebouncedState.tsx diff --git a/antarest/study/business/config_management.py b/antarest/study/business/config_management.py index 8a5509d1da..26c0adcc5b 100644 --- a/antarest/study/business/config_management.py +++ b/antarest/study/business/config_management.py @@ -104,7 +104,7 @@ def get_thematic_trimming(self, study: Study) -> Dict[str, bool]: storage_service = self.storage_service.get_storage(study) file_study = storage_service.get_raw(study) config = file_study.tree.get(["settings", "generaldata"]) - trimming_config = config.get("variable selection", None) + trimming_config = config.get("variables selection", None) variable_list = self.get_output_variables(study) if trimming_config: if trimming_config.get("selected_vars_reset", True): @@ -137,7 +137,7 @@ def set_thematic_trimming( "select_var +": state_by_active[True], } command = UpdateConfig( - target="settings/generaldata/variable selection", + target="settings/generaldata/variables selection", data=config_data, command_context=self.storage_service.variant_study_service.command_factory.command_context, ) diff --git a/tests/storage/business/test_config_manager.py b/tests/storage/business/test_config_manager.py index 88e0833bd1..d6fe772cb1 100644 --- a/tests/storage/business/test_config_manager.py +++ b/tests/storage/business/test_config_manager.py @@ -57,9 +57,9 @@ def test_thematic_trimming_config(): ) file_tree_mock.get.side_effect = [ {}, - {"variable selection": {"select_var -": ["AVL DTG"]}}, + {"variables selection": {"select_var -": ["AVL DTG"]}}, { - "variable selection": { + "variables selection": { "selected_vars_reset": False, "select_var +": ["CONG. FEE (ALG.)"], } @@ -83,7 +83,7 @@ def test_thematic_trimming_config(): config_manager.set_thematic_trimming(study, new_config) assert variant_study_service.append_commands.called_with( UpdateConfig( - target="settings/generaldata/variable selection", + target="settings/generaldata/variables selection", data={"select_var -": [OutputVariableBase.COAL.value]}, command_context=command_context, ) @@ -93,7 +93,7 @@ def test_thematic_trimming_config(): config_manager.set_thematic_trimming(study, new_config) assert variant_study_service.append_commands.called_with( UpdateConfig( - target="settings/generaldata/variable selection", + target="settings/generaldata/variables selection", data={ "selected_vars_reset": False, "select_var +": [OutputVariable810.RENW_1.value], diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/index.tsx index a0a7eabec7..b810c7a292 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/index.tsx @@ -43,6 +43,8 @@ function ThematicTrimmingDialog(props: Props) { //////////////////////////////////////////////////////////////// const handleUpdateConfig = (fn: Pred) => () => { + setSearch(""); + const config = getCurrentConfig(); const newConfig: ThematicTrimmingConfig = R.map(fn, config); @@ -78,7 +80,8 @@ function ThematicTrimmingDialog(props: Props) { contentProps={{ sx: { pb: 0 }, }} - PaperProps={{ sx: { height: "100%" } }} + // TODO: add `maxHeight` and `fullHeight` in BasicDialog` + PaperProps={{ sx: { height: "calc(100% - 64px)", maxHeight: "900px" } }} > = [ - { label: "JAN - DEC", value: Month.January }, - { label: "FEB - JAN", value: Month.February }, - { label: "MAR - FEB", value: Month.March }, - { label: "APR - MAR", value: Month.April }, - { label: "MAY - APR", value: Month.May }, - { label: "JUN - MAY", value: Month.June }, - { label: "JUL - JUN", value: Month.July }, - { label: "AUG - JUL", value: Month.August }, - { label: "SEP - AUG", value: Month.September }, - { label: "OCT - SEP", value: Month.October }, - { label: "NOV - OCT", value: Month.November }, - { label: "DEC - NOV", value: Month.December }, -]; - -export const WEEK_OPTIONS: Array<{ label: string; value: WeekDay }> = [ - { label: "MON - SUN", value: WeekDay.Monday }, - { label: "TUE - MON", value: WeekDay.Tuesday }, - { label: "WED - TUE", value: WeekDay.Wednesday }, - { label: "THU - WED", value: WeekDay.Thursday }, - { label: "FRI - THU", value: WeekDay.Friday }, - { label: "SAT - FRI", value: WeekDay.Saturday }, - { label: "SUN - SAT", value: WeekDay.Sunday }, -]; - -export const FIRST_JANUARY_OPTIONS = Object.values(WeekDay); +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// interface SettingsGeneralDataGeneral { // Mode @@ -105,6 +84,12 @@ interface SettingsGeneralDataOutput { storenewset: boolean; } +interface SettingsGeneralData { + // For unknown reason, `general` and `output` may be empty + general?: Partial; + output?: Partial; +} + export interface FormValues { mode: SettingsGeneralDataGeneral["mode"]; firstDay: SettingsGeneralDataGeneral["simulation.start"]; @@ -126,10 +111,43 @@ export interface FormValues { filtering: SettingsGeneralDataGeneral["filtering"]; } +//////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////// + +// TODO i18n + +export const YEAR_OPTIONS: Array<{ label: string; value: Month }> = [ + { label: "JAN - DEC", value: Month.January }, + { label: "FEB - JAN", value: Month.February }, + { label: "MAR - FEB", value: Month.March }, + { label: "APR - MAR", value: Month.April }, + { label: "MAY - APR", value: Month.May }, + { label: "JUN - MAY", value: Month.June }, + { label: "JUL - JUN", value: Month.July }, + { label: "AUG - JUL", value: Month.August }, + { label: "SEP - AUG", value: Month.September }, + { label: "OCT - SEP", value: Month.October }, + { label: "NOV - OCT", value: Month.November }, + { label: "DEC - NOV", value: Month.December }, +]; + +export const WEEK_OPTIONS: Array<{ label: string; value: WeekDay }> = [ + { label: "MON - SUN", value: WeekDay.Monday }, + { label: "TUE - MON", value: WeekDay.Tuesday }, + { label: "WED - TUE", value: WeekDay.Wednesday }, + { label: "THU - WED", value: WeekDay.Thursday }, + { label: "FRI - THU", value: WeekDay.Friday }, + { label: "SAT - FRI", value: WeekDay.Saturday }, + { label: "SUN - SAT", value: WeekDay.Sunday }, +]; + +export const FIRST_JANUARY_OPTIONS = Object.values(WeekDay); + const DEFAULT_VALUES: Omit = { - mode: "Adequacy", + mode: "Economy", firstDay: 1, - lastDay: 1, + lastDay: 365, horizon: "", firstMonth: Month.January, firstWeekDay: WeekDay.Monday, @@ -138,7 +156,7 @@ const DEFAULT_VALUES: Omit = { nbYears: 1, buildingMode: "Automatic", selectionMode: false, - simulationSynthesis: false, + simulationSynthesis: true, yearByYear: false, mcScenario: false, geographicTrimming: false, @@ -146,14 +164,18 @@ const DEFAULT_VALUES: Omit = { filtering: false, }; +//////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////// + export async function getFormValues( studyId: StudyMetadata["id"] ): Promise { - // For unknown reason, `general` and `output` may be empty - const { general = {}, output = {} } = await getStudyData<{ - general?: Partial; - output?: Partial; - }>(studyId, "settings/generaldata", 2); + const { general = {}, output = {} } = await getStudyData( + studyId, + "settings/generaldata", + 2 + ); const { "custom-ts-numbers": customTsNumbers, diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/Fields/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/Fields/index.tsx new file mode 100644 index 0000000000..453a1f3403 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/Fields/index.tsx @@ -0,0 +1,165 @@ +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from "@mui/material"; +import { StudyMetadata } from "../../../../../../../common/types"; +import CheckBoxFE from "../../../../../../common/fieldEditors/CheckBoxFE"; +import SelectFE from "../../../../../../common/fieldEditors/SelectFE"; +import SwitchFE from "../../../../../../common/fieldEditors/SwitchFE"; +import { useFormContext } from "../../../../../../common/Form"; +import { + FormValues, + SEASONAL_CORRELATION_OPTIONS, + TimeSeriesType, +} from "../utils"; + +const borderStyle = "1px solid rgba(255, 255, 255, 0.12)"; + +interface Props { + study: StudyMetadata; +} + +function Fields(props: Props) { + const { register } = useFormContext(); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + +
+ + + + + Ready made TS + + + Stochastic TS + + + Draw correlation + + + + + Status + Status + Number + Refresh + Refresh interval + Season correlation + Store in input + Store in output + Intra-modal + inter-modal + + + + {( + Object.keys(TimeSeriesType) as Array + ).map((row) => { + const type = TimeSeriesType[row]; + const isSpecialType = + type === TimeSeriesType.Renewables || type === TimeSeriesType.NTC; + const emptyDisplay = "-"; + + const render = (node: React.ReactNode) => { + return isSpecialType ? emptyDisplay : node; + }; + + return ( + + {row} + + + + + {render( + + )} + + + {render( + + )} + + + {render()} + + + {render( + + )} + + + {render( + type !== TimeSeriesType.Thermal ? ( + + ) : ( + "n/a" + ) + )} + + + {render()} + + + {render( + + )} + + + + + + {type !== TimeSeriesType.NTC ? ( + + ) : ( + emptyDisplay + )} + + + ); + })} + +
+
+ ); +} + +export default Fields; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/index.tsx index 2957cbc159..d97dea9fe7 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/index.tsx @@ -1,7 +1,36 @@ -import UnderConstruction from "../../../../../common/page/UnderConstruction"; +import { useOutletContext } from "react-router"; +import { StudyMetadata } from "../../../../../../common/types"; +import usePromiseWithSnackbarError from "../../../../../../hooks/usePromiseWithSnackbarError"; +import Form from "../../../../../common/Form"; +import SimpleLoader from "../../../../../common/loaders/SimpleLoader"; +import UsePromiseCond from "../../../../../common/utils/UsePromiseCond"; +import Fields from "./Fields"; +import { getFormValues } from "./utils"; function TimeSeriesManagement() { - return ; + const { study } = useOutletContext<{ study: StudyMetadata }>(); + + const res = usePromiseWithSnackbarError( + () => getFormValues(study.id), + { errorMessage: "Cannot get study data", deps: [study.id] } // TODO i18n + ); + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + } + ifRejected={(error) =>
{error}
} + ifResolved={(data) => ( +
+ + + )} + /> + ); } export default TimeSeriesManagement; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/utils.ts new file mode 100644 index 0000000000..a0c42cc6f8 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/utils.ts @@ -0,0 +1,150 @@ +import { StudyMetadata } from "../../../../../../common/types"; +import { getStudyData } from "../../../../../../services/api/study"; + +//////////////////////////////////////////////////////////////// +// Enums +//////////////////////////////////////////////////////////////// + +export enum TimeSeriesType { + Load = "load", + Hydro = "hydro", + Thermal = "thermal", + Wind = "wind", + Solar = "solar", + Renewables = "renewables", + NTC = "ntc", +} + +enum SeasonCorrelation { + Monthly = "monthly", + Annual = "annual", +} + +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// + +interface SettingsGeneralDataGeneral { + generate: string; + nbtimeseriesload: number; + nbtimeserieshydro: number; + nbtimeserieswind: number; + nbtimeseriesthermal: number; + nbtimeseriessolar: number; + refreshtimeseries: string; + refreshintervalload: number; + refreshintervalhydro: number; + refreshintervalwind: number; + refreshintervalthermal: number; + refreshintervalsolar: number; + "intra-modal": string; + "inter-modal": string; +} + +type SettingsGeneralDataInput = { + [key in Exclude< + TimeSeriesType, + TimeSeriesType.Thermal | TimeSeriesType.Renewables | TimeSeriesType.NTC + >]: { + prepro?: { + correlation?: { + general?: { + mode?: SeasonCorrelation; + }; + }; + }; + }; +} & { import: string }; + +interface SettingsGeneralDataOutput { + archives: string; +} + +interface SettingsGeneralData { + // For unknown reason, `general`, `input` and `output` may be empty + general?: Partial; + input?: Partial; + output?: Partial; +} + +interface TimeSeriesValues { + readyMadeTsStatus: boolean; + stochasticTsStatus: boolean; + number: number; + refresh: boolean; + refreshInterval: number; + seasonCorrelation: SeasonCorrelation | undefined; + storeInInput: boolean; + storeInOutput: boolean; + intraModal: boolean; + interModal: boolean; +} + +export type FormValues = Record; + +//////////////////////////////////////////////////////////////// +// Constants +//////////////////////////////////////////////////////////////// + +export const SEASONAL_CORRELATION_OPTIONS = Object.values(SeasonCorrelation); + +//////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////// + +function makeTimeSeriesValues( + type: TimeSeriesType, + data: SettingsGeneralData +): TimeSeriesValues { + const { general = {}, output = {}, input = {} } = data; + const { + generate = "", + refreshtimeseries = "", + "intra-modal": intraModal = "", + "inter-modal": interModal = "", + } = general; + const { import: imp = "" } = input; + const { archives = "" } = output; + const isGenerateHasType = generate.includes(type); + const isSpecialType = + type === TimeSeriesType.Renewables || type === TimeSeriesType.NTC; + + return { + readyMadeTsStatus: !isGenerateHasType, + stochasticTsStatus: isGenerateHasType, + number: isSpecialType ? NaN : general[`nbtimeseries${type}`] ?? 1, + refresh: refreshtimeseries.includes(type), + refreshInterval: isSpecialType + ? NaN + : general[`refreshinterval${type}`] ?? 100, + seasonCorrelation: + isSpecialType || type === TimeSeriesType.Thermal + ? undefined + : input[type]?.prepro?.correlation?.general?.mode || + SeasonCorrelation.Annual, + storeInInput: imp.includes(type), + storeInOutput: archives.includes(type), + intraModal: intraModal.includes(type), + interModal: interModal.includes(type), + }; +} + +export async function getFormValues( + studyId: StudyMetadata["id"] +): Promise { + const data = await getStudyData( + studyId, + "settings/generaldata", + 2 + ); + + return { + load: makeTimeSeriesValues(TimeSeriesType.Load, data), + thermal: makeTimeSeriesValues(TimeSeriesType.Thermal, data), + hydro: makeTimeSeriesValues(TimeSeriesType.Hydro, data), + wind: makeTimeSeriesValues(TimeSeriesType.Wind, data), + solar: makeTimeSeriesValues(TimeSeriesType.Solar, data), + renewables: makeTimeSeriesValues(TimeSeriesType.Renewables, data), + ntc: makeTimeSeriesValues(TimeSeriesType.NTC, data), + }; +} diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx index ff552b1fce..948a8f1e9f 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx @@ -2,6 +2,7 @@ import { Paper } from "@mui/material"; import * as R from "ramda"; import { useMemo, useState } from "react"; +import UnderConstruction from "../../../../common/page/UnderConstruction"; import PropertiesView from "../../../../common/PropertiesView"; import SplitLayoutView from "../../../../common/SplitLayoutView"; import ListElement from "../common/ListElement"; @@ -44,7 +45,8 @@ function Configuration() { {R.cond([ [R.equals(0), () => ], - [R.equals(1), () => ], + // [R.equals(1), () => ], + [R.equals(1), () => ], [R.equals(2), () => ], [R.equals(3), () => ], [R.equals(4), () => ], diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/utils.ts b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/utils.ts index 58cc038a00..3d6ce10e6f 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Map/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Map/utils.ts @@ -1,38 +1,10 @@ import * as R from "ramda"; -export const linkStyle = (linkStyle: string): Array | string> => { - const linkCond = R.cond([ - [ - R.equals("dot"), - (): Array | string> => { - return [[1, 5], "round"]; - }, - ], - [ - R.equals("dash"), - (): Array | string> => { - return [[16, 8], "square"]; - }, - ], - [ - R.equals("dotdash"), - (): Array | string> => { - return [[10, 6, 1, 6], "square"]; - }, - ], - [ - (_: string): boolean => true, - (): Array | string> => { - return [[0], "butt"]; - }, - ], - ]); - - const values = linkCond(linkStyle); - const style = values[0] as Array; - const linecap = values[1] as string; - - return [style, linecap]; -}; - -export default {}; +type LinkStyleReturn = [number[], string]; + +export const linkStyle = R.cond<[string], LinkStyleReturn>([ + [R.equals("dot"), (): LinkStyleReturn => [[1, 5], "round"]], + [R.equals("dash"), (): LinkStyleReturn => [[16, 8], "square"]], + [R.equals("dotdash"), (): LinkStyleReturn => [[10, 6, 1, 6], "square"]], + [R.T, (): LinkStyleReturn => [[0], "butt"]], +]); diff --git a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx index c2332a0f44..9b4f5681f9 100644 --- a/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx +++ b/webapp/src/components/App/Singlestudy/explore/TabWrapper.tsx @@ -34,7 +34,7 @@ interface Props { tabStyle?: "normal" | "withoutBorder"; } -function BasicTabs(props: Props) { +function TabWrapper(props: Props) { const { study, tabList, border, tabStyle } = props; const location = useLocation(); const navigate = useNavigate(); @@ -88,9 +88,9 @@ function BasicTabs(props: Props) { ); } -BasicTabs.defaultProps = { +TabWrapper.defaultProps = { border: undefined, tabStyle: "normal", }; -export default BasicTabs; +export default TabWrapper; diff --git a/webapp/src/components/common/Form/index.tsx b/webapp/src/components/common/Form/index.tsx index 85625588bd..0c24d72b9e 100644 --- a/webapp/src/components/common/Form/index.tsx +++ b/webapp/src/components/common/Form/index.tsx @@ -9,6 +9,7 @@ import { FormState, Path, RegisterOptions, + SubmitErrorHandler, UnpackNestedValue, useForm, useFormContext as useFormContextOriginal, @@ -20,12 +21,13 @@ import { } from "react-hook-form"; import { useTranslation } from "react-i18next"; import * as RA from "ramda-adjunct"; -import { Button } from "@mui/material"; +import { Box, Button, CircularProgress } from "@mui/material"; import { useUpdateEffect } from "react-use"; import * as R from "ramda"; import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; import useDebounce from "../../../hooks/useDebounce"; import { getDirtyValues, stringToPath, toAutoSubmitConfig } from "./utils"; +import useDebouncedState from "../../../hooks/useDebouncedState"; export interface SubmitHandlerData< TFieldValues extends FieldValues = FieldValues @@ -80,6 +82,7 @@ export interface FormProps< data: SubmitHandlerData, event?: React.BaseSyntheticEvent ) => any | Promise; + onSubmitError?: SubmitErrorHandler; children: | ((formObj: UseFormReturnPlus) => React.ReactNode) | React.ReactNode; @@ -99,6 +102,7 @@ function Form( const { config, onSubmit, + onSubmitError, children, submitButtonText, hideSubmitButton, @@ -109,6 +113,7 @@ function Form( const formObj = useForm({ mode: "onChange", + delayError: 750, ...config, }); @@ -123,8 +128,10 @@ function Form( reset, } = formObj; // * /!\ `formState` is a proxy - const { isValid, isSubmitting, isDirty, dirtyFields } = formState; - const isSubmitAllowed = isDirty && isValid && !isSubmitting; + const { isSubmitting, isDirty, dirtyFields } = formState; + // Don't add `isValid` because we need to trigger fields validation. + // In case we have invalid default value for example. + const isSubmitAllowed = isDirty && !isSubmitting; const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const { t } = useTranslation(); const submitRef = useRef(null); @@ -133,6 +140,14 @@ function Form( Record any | Promise) | undefined> >({}); const preventClose = useRef(false); + const [showLoader, setLoader] = useDebouncedState(false, 750); + + useUpdateEffect(() => { + setLoader(isSubmitting); + if (isSubmitting) { + setLoader.flush(); + } + }, [isSubmitting]); useUpdateEffect( () => { @@ -170,7 +185,7 @@ function Form( const handleFormSubmit = (event: FormEvent) => { event.preventDefault(); - handleSubmit((data, e) => { + handleSubmit(function onValid(data, e) { const dirtyValues = getDirtyValues(dirtyFields, data) as Partial< typeof data >; @@ -194,7 +209,7 @@ function Form( } return Promise.all(res); - })() + }, onSubmitError)() .catch((error) => { enqueueErrorSnackbar(t("form.submit.error"), error); }) @@ -293,6 +308,19 @@ function Form( return (
+ {showLoader && ( + + + + )} {RA.isFunction(children) ? ( children(sharedProps) ) : ( diff --git a/webapp/src/components/common/dialogs/FormDialog.tsx b/webapp/src/components/common/dialogs/FormDialog.tsx index ebc5d9a605..0604e4826b 100644 --- a/webapp/src/components/common/dialogs/FormDialog.tsx +++ b/webapp/src/components/common/dialogs/FormDialog.tsx @@ -10,7 +10,7 @@ type SuperType = Omit< BasicDialogProps, "onSubmit" | "children" > & - Omit, "disableSubmitButton">; + Omit, "hideSubmitButton">; export interface FormDialogProps< TFieldValues extends FieldValues = FieldValues, @@ -27,46 +27,49 @@ function FormDialog( const { config, onSubmit, + onSubmitError, children, + autoSubmit, + onStateChange, onCancel, onClose, cancelButtonText, submitButtonText, ...dialogProps } = props; - const formId = useRef(uuidv4()).current; + const formProps = { config, onSubmit, + onSubmitError, children, - id: formId, - disableSubmitButton: true, + autoSubmit, }; + const { t } = useTranslation(); + const formId = useRef(uuidv4()).current; const [isSubmitting, setIsSubmitting] = useState(false); - const [allowSubmit, setAllowSubmit] = useState(false); + const [isSubmitAllowed, setIsSubmitAllowed] = useState(false); //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleFormStateChange = ({ - isDirty, - isValid, - isSubmitting, - }: FormState) => { + const handleFormStateChange = (formState: FormState) => { + const { isSubmitting, isDirty } = formState; + onStateChange?.(formState); setIsSubmitting(isSubmitting); - setAllowSubmit(isDirty && isValid && !isSubmitting); + setIsSubmitAllowed(isDirty && !isSubmitting); }; //////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////// - const handleClose = ((...args) => { + const handleClose: FormDialogProps["onClose"] = (...args) => { onCancel(); onClose?.(...args); - }) as FormDialogProps["onClose"]; + }; //////////////////////////////////////////////////////////////// // JSX @@ -87,7 +90,7 @@ function FormDialog( type="submit" form={formId} variant="contained" - disabled={!allowSubmit} + disabled={!isSubmitAllowed} > {submitButtonText || t("global.save")} @@ -96,6 +99,7 @@ function FormDialog( > diff --git a/webapp/src/components/common/fieldEditors/CheckBoxFE.tsx b/webapp/src/components/common/fieldEditors/CheckBoxFE.tsx new file mode 100644 index 0000000000..6679c4e42f --- /dev/null +++ b/webapp/src/components/common/fieldEditors/CheckBoxFE.tsx @@ -0,0 +1,60 @@ +import { + Checkbox, + CheckboxProps, + FormControlLabel, + FormControlLabelProps, +} from "@mui/material"; +import clsx from "clsx"; +import reactHookFormSupport from "../../../hoc/reactHookFormSupport"; + +export interface CheckBoxFEProps + extends Omit { + value?: boolean; + defaultValue?: boolean; + label?: string; + labelPlacement?: FormControlLabelProps["labelPlacement"]; + error?: boolean; + helperText?: React.ReactNode; +} + +function CheckBoxFE(props: CheckBoxFEProps) { + const { + value, + defaultValue, + label, + labelPlacement, + helperText, + error, + className, + sx, + inputRef, + ...rest + } = props; + + const fieldEditor = ( + + ); + + if (label) { + return ( + + ); + } + + return fieldEditor; +} + +export default reactHookFormSupport({ defaultValue: false })(CheckBoxFE); diff --git a/webapp/src/components/common/fieldEditors/NumberFE.tsx b/webapp/src/components/common/fieldEditors/NumberFE.tsx index 02ba592808..86026d5fb6 100644 --- a/webapp/src/components/common/fieldEditors/NumberFE.tsx +++ b/webapp/src/components/common/fieldEditors/NumberFE.tsx @@ -1,4 +1,5 @@ import { TextField, TextFieldProps } from "@mui/material"; +import * as RA from "ramda-adjunct"; import withReactHookFormSupport from "../../../hoc/reactHookFormSupport"; export type NumberFEProps = { @@ -11,6 +12,8 @@ function NumberFE(props: NumberFEProps) { } export default withReactHookFormSupport({ - defaultValue: 0, - setValueAs: Number, + defaultValue: "" as unknown as number, + // Returning empty string allow to type negative number + setValueAs: (v) => (v === "" ? "" : Number(v)), + preValidate: RA.isNumber, })(NumberFE); diff --git a/webapp/src/hoc/reactHookFormSupport.tsx b/webapp/src/hoc/reactHookFormSupport.tsx index ef62703ffb..8e3b731904 100644 --- a/webapp/src/hoc/reactHookFormSupport.tsx +++ b/webapp/src/hoc/reactHookFormSupport.tsx @@ -1,14 +1,22 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import hoistNonReactStatics from "hoist-non-react-statics"; -import React from "react"; -import { Controller, FieldPath, FieldValues } from "react-hook-form"; +import React, { useMemo } from "react"; +import { + Controller, + FieldPath, + FieldPathValue, + FieldValues, + Validate, +} from "react-hook-form"; import * as R from "ramda"; +import * as RA from "ramda-adjunct"; import { ControlPlus, RegisterOptionsPlus } from "../components/common/Form"; import { getComponentDisplayName } from "../utils/reactUtils"; interface ReactHookFormSupport { defaultValue?: NonNullable; setValueAs?: (value: any) => any; + preValidate?: (value: any) => boolean; } // `...args: any` allows to be compatible with all field editors @@ -32,7 +40,13 @@ type ReactHookFormSupportProps< rules?: Omit< RegisterOptionsPlus, // cf. UseControllerProps#rules - "valueAsNumber" | "valueAsDate" | "setValueAs" | "disabled" + | "valueAsNumber" + | "valueAsDate" + | "setValueAs" + | "disabled" + // Not necessary + | "onChange" + | "onBlur" >; shouldUnregister?: boolean; name: TFieldName; @@ -46,7 +60,7 @@ type ReactHookFormSupportProps< function reactHookFormSupport( options: ReactHookFormSupport = {} ) { - const { defaultValue, setValueAs = R.identity } = options; + const { preValidate, setValueAs = R.identity } = options; function wrapWithReactHookFormSupport< TProps extends FieldEditorProps @@ -59,35 +73,54 @@ function reactHookFormSupport( props: ReactHookFormSupportProps & TProps ) { - const { control, rules, shouldUnregister, ...fieldEditorProps } = props; - const { name, onChange, onBlur } = fieldEditorProps; + const { control, rules = {}, shouldUnregister, ...feProps } = props; + const { validate } = rules; - if (control && name) { + const validateWrapper = useMemo< + RegisterOptionsPlus["validate"] + >(() => { + if (preValidate) { + if (RA.isFunction(validate)) { + return (v) => preValidate?.(v) && validate(v); + } + + if (RA.isPlainObj(validate)) { + return Object.keys(validate).reduce((acc, key) => { + acc[key] = (v) => preValidate?.(v) && validate[key](v); + return acc; + }, {} as Record>>); + } + + return preValidate; + } + return validate; + }, [validate]); + + if (control && feProps.name) { return ( { - onChange?.(event); - rules?.onChange?.(event); - }, - onBlur: (event) => { - onBlur?.(event); - rules?.onBlur?.(event); - }, + validate: validateWrapper, }} shouldUnregister={shouldUnregister} render={({ - field: { ref, onChange, ...fieldProps }, + field: { ref, onChange, onBlur, ...fieldProps }, fieldState: { error }, }) => ( { + // Called here instead of Controller's rules, to keep original event + feProps.onChange?.(event); + + // https://github.com/react-hook-form/react-hook-form/discussions/8068#discussioncomment-2415789 + // Data send back to hook form onChange( setValueAs( event.target.type === "checkbox" @@ -96,6 +129,13 @@ function reactHookFormSupport( ) ); }} + onBlur={(event) => { + // Called here instead of Controller's rules, to keep original event + feProps.onBlur?.(event); + + // Report input has been interacted (focus and blur) + onBlur(); + }} inputRef={ref} error={!!error} helperText={error?.message} @@ -105,7 +145,7 @@ function reactHookFormSupport( ); } - return ; + return ; } ReactHookFormSupport.displayName = `ReactHookFormSupport(${getComponentDisplayName( diff --git a/webapp/src/hooks/useDebouncedState.tsx b/webapp/src/hooks/useDebouncedState.tsx new file mode 100644 index 0000000000..2ae876a7c0 --- /dev/null +++ b/webapp/src/hooks/useDebouncedState.tsx @@ -0,0 +1,33 @@ +import { + DebouncedFunc, + DebouncedFuncLeading, + DebounceSettingsLeading, +} from "lodash"; +import { useState } from "react"; +import useDebounce, { UseDebounceParams } from "./useDebounce"; + +type WaitOrParams = number | UseDebounceParams; + +type DebounceFn = (state: S) => void; + +type UseDebouncedStateReturn = [ + S, + U extends DebounceSettingsLeading + ? DebouncedFuncLeading> + : DebouncedFunc> +]; + +function useDebouncedState( + initialValue: S | (() => S), + params?: U +): UseDebouncedStateReturn { + const [state, setState] = useState(initialValue); + + const debounceFn = useDebounce((newState) => { + setState(newState); + }, params); + + return [state, debounceFn]; +} + +export default useDebouncedState; From 3ade017e549ba858dd68643fe61ccda5648eaaee Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 18:16:26 +0200 Subject: [PATCH 28/31] Fix build Signed-off-by: Paul Bui-Quang --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index 4dd7d0742b..58edfdb6c8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ mistune~=0.8.4 m2r~=0.2.1 +wheel jsonref~=0.2 PyYAML~=5.4.1 filelock~=3.4.2 From 5e7da62e146139e8243e46da0177fdf27e80dcba Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 18:55:57 +0200 Subject: [PATCH 29/31] Fix lint Signed-off-by: Paul Bui-Quang --- .../components/App/Singlestudy/explore/Configuration/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx index 948a8f1e9f..0075df72ba 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/index.tsx @@ -10,7 +10,6 @@ import AdvancedParameters from "./AdvancedParameters"; import General from "./General"; import OptimizationPreferences from "./OptimizationPreferences"; import RegionalDistricts from "./RegionalDistricts"; -import TimeSeriesManagement from "./TimeSeriesManagement"; function Configuration() { const [currentElementIndex, setCurrentElementIndex] = useState(0); From 3e0a7804eee078116b294ba7898cbcb87ebfc522 Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 19:25:24 +0200 Subject: [PATCH 30/31] Fix cluster matrix url Signed-off-by: Paul Bui-Quang --- .../Modelization/Areas/Renewables/RenewableForm.tsx | 5 ++++- .../explore/Modelization/Areas/Thermal/ThermalForm.tsx | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx index 1ea75f6c27..2e4a07a408 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx @@ -7,6 +7,7 @@ import MatrixInput from "../../../../../../common/MatrixInput"; import { IFormGenerator } from "../../../../../../common/FormGenerator"; import AutoSubmitGeneratorForm from "../../../../../../common/FormGenerator/AutoSubmitGenerator"; import { saveField } from "../common/utils"; +import { transformNameToId } from "../../../../../../../services/utils"; interface Props { area: string; @@ -106,7 +107,9 @@ export default function RenewableForm(props: Props) { > diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx index ea3525a3f7..e9bf986c42 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx @@ -7,6 +7,7 @@ import ThermalMatrixView from "./ThermalMatrixView"; import { IFormGenerator } from "../../../../../../common/FormGenerator"; import AutoSubmitGeneratorForm from "../../../../../../common/FormGenerator/AutoSubmitGenerator"; import { saveField } from "../common/utils"; +import { transformNameToId } from "../../../../../../../services/utils"; interface Props { area: string; @@ -206,7 +207,11 @@ export default function ThermalForm(props: Props) { height: "500px", }} > - + ); From 028b611581e9639b8de4a4f63d058acf78f3168e Mon Sep 17 00:00:00 2001 From: Paul Bui-Quang Date: Wed, 27 Jul 2022 19:26:05 +0200 Subject: [PATCH 31/31] Fix trad Signed-off-by: Paul Bui-Quang --- webapp/src/components/common/MatrixInput/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/components/common/MatrixInput/index.tsx b/webapp/src/components/common/MatrixInput/index.tsx index 0e1da0b6ad..a1690f2480 100644 --- a/webapp/src/components/common/MatrixInput/index.tsx +++ b/webapp/src/components/common/MatrixInput/index.tsx @@ -72,7 +72,7 @@ function MatrixInput(props: PropsType) { const { data: matrixIndex } = usePromiseWithSnackbarError( () => getStudyMatrixIndex(study.id, url), { - errorMessage: t("matrix.error.failedtoretrieveindex"), + errorMessage: t("matrix.error.failedToretrieveIndex"), deps: [study, url], } );