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/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/core/logging/utils.py b/antarest/core/logging/utils.py index 3f001e744e..4e15b808c5 100644 --- a/antarest/core/logging/utils.py +++ b/antarest/core/logging/utils.py @@ -110,9 +110,9 @@ def filter(self, record: logging.LogRecord) -> bool: request: Optional[Request] = _request.get() request_id: Optional[str] = _request_id.get() if request is not None: - record.ip = request.scope.get("client", ("undefined"))[0] # type: ignore - record.trace_id = request_id # type: ignore - record.pid = os.getpid() # type: ignore + record.ip = request.scope.get("client", ("undefined"))[0] + record.trace_id = request_id + record.pid = os.getpid() return True diff --git a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py index 4ecbb86af3..5bf3c1c7c0 100644 --- a/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py +++ b/antarest/launcher/adapters/slurm_launcher/slurm_launcher.py @@ -311,11 +311,12 @@ def _check_studies_state(self) -> None: study_list = self.data_repo_tinydb.get_list_of_studies() nb_study_done = 0 - + studies_to_cleanup = [] for study in study_list: nb_study_done += 1 if (study.finished or study.with_error) else 0 if study.done: try: + studies_to_cleanup.append(study.name) self.log_tail_manager.stop_tracking( SlurmLauncher._get_log_path(study) ) @@ -347,8 +348,6 @@ def _check_studies_state(self) -> None: f"Failed to finalize study {study.name} launch", exc_info=e, ) - finally: - self._clean_up_study(study.name) else: self.log_tail_manager.track( SlurmLauncher._get_log_path(study), @@ -356,8 +355,13 @@ def _check_studies_state(self) -> None: ) # we refetch study list here because by the time the import_output is done, maybe some new studies has been added - if nb_study_done == len(self.data_repo_tinydb.get_list_of_studies()): - self.stop() + # also we clean up the study after because it remove the study in the database + with self.antares_launcher_lock: + nb_studies = self.data_repo_tinydb.get_list_of_studies() + for study_id in studies_to_cleanup: + self._clean_up_study(study_id) + if nb_study_done == len(nb_studies): + self.stop() @staticmethod def _get_log_path( @@ -444,9 +448,9 @@ def _run_study( ) -> None: study_path = Path(self.launcher_args.studies_in) / str(launch_uuid) - try: - # export study - with self.antares_launcher_lock: + with self.antares_launcher_lock: + try: + # export study self.callbacks.export_study( launch_uuid, study_uuid, study_path, launcher_params ) @@ -468,25 +472,27 @@ def _run_study( ) logger.info("Study exported and run with launcher") - self.callbacks.update_status( - str(launch_uuid), JobStatus.RUNNING, None, None - ) - except Exception as e: - logger.error(f"Failed to launch study {study_uuid}", exc_info=e) - self.callbacks.append_after_log( - launch_uuid, - f"Unexpected error when launching study : {str(e)}", - ) - self.callbacks.update_status( - str(launch_uuid), JobStatus.FAILED, str(e), None - ) - self._clean_up_study(str(launch_uuid)) + self.callbacks.update_status( + str(launch_uuid), JobStatus.RUNNING, None, None + ) + except Exception as e: + logger.error( + f"Failed to launch study {study_uuid}", exc_info=e + ) + self.callbacks.append_after_log( + launch_uuid, + f"Unexpected error when launching study : {str(e)}", + ) + self.callbacks.update_status( + str(launch_uuid), JobStatus.FAILED, str(e), None + ) + self._clean_up_study(str(launch_uuid)) + finally: + self._delete_workspace_file(study_path) if not self.thread: self.start() - self._delete_workspace_file(study_path) - def _check_and_apply_launcher_params( self, launcher_params: LauncherParametersDTO ) -> argparse.Namespace: 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)) } 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/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 cd5864f797..632ed3833a 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: @@ -328,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) @@ -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" @@ -551,20 +552,18 @@ 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 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 @@ -593,6 +592,7 @@ def _import_output( None, ), ), + job_launch_params.auto_unzip, ) except StudyNotFoundError: return self._import_fallback_output( @@ -607,6 +607,9 @@ def _import_output( ), ), ) + finally: + if zip_path: + os.unlink(zip_path) raise JobNotFound() def _download_fallback_output( @@ -669,6 +672,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/antarest/study/business/area_management.py b/antarest/study/business/area_management.py index 7276c1951b..ec8a49cb2c 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 @@ -187,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/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/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..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,8 +1332,15 @@ 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": - self.unarchive_output(uuid, output_id, True, params) + 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 + ) return output_id @@ -2258,6 +2267,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 +2277,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 +2287,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 +2299,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/rawstudy/model/filesystem/ini_file_node.py b/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py index e332ff535d..417c559379 100644 --- a/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py +++ b/antarest/study/storage/rawstudy/model/filesystem/ini_file_node.py @@ -1,5 +1,10 @@ +import os +import tempfile +from pathlib import Path from typing import List, Optional, cast, Dict, Any, Union +from filelock import FileLock + from antarest.core.model import JSON, SUB_JSON from antarest.study.storage.rawstudy.io.reader import IniReader from antarest.study.storage.rawstudy.io.reader.ini_reader import IReader @@ -103,19 +108,25 @@ def get_node( def save(self, data: SUB_JSON, url: Optional[List[str]] = None) -> None: self._assert_not_in_zipped_file() url = url or [] - json = self.reader.read(self.path) if self.path.exists() else {} - formatted_data = data - if isinstance(data, str): - formatted_data = IniReader.parse_value(data) - if len(url) == 2: - if url[0] not in json: - json[url[0]] = {} - json[url[0]][url[1]] = formatted_data - elif len(url) == 1: - json[url[0]] = formatted_data - else: - json = cast(JSON, formatted_data) - self.writer.write(json, self.path) + with FileLock( + str( + Path(tempfile.gettempdir()) + / f"{self.config.study_id}-{self.path.relative_to(self.config.study_path).name.replace(os.sep, '.')}.lock" + ) + ): + json = self.reader.read(self.path) if self.path.exists() else {} + formatted_data = data + if isinstance(data, str): + formatted_data = IniReader.parse_value(data) + if len(url) == 2: + if url[0] not in json: + json[url[0]] = {} + json[url[0]][url[1]] = formatted_data + elif len(url) == 1: + json[url[0]] = formatted_data + else: + json = cast(JSON, formatted_data) + self.writer.write(json, self.path) def delete(self, url: Optional[List[str]] = None) -> None: url = url or [] 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/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/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/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/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 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/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/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/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 deaa4c1109..78c8163b5d 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 @@ -557,10 +558,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 +615,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( @@ -695,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( @@ -706,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() diff --git a/tests/storage/business/test_arealink_manager.py b/tests/storage/business/test_arealink_manager.py index 6d05bfc525..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", @@ -321,6 +323,7 @@ def test_get_all_area(): { "id": "a", "name": "A", + "enabled": True, "unitcount": 1, "nominalcapacity": 500, "group": None, 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/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/.eslintrc.json b/webapp/.eslintrc.json index 890b2dede6..0a8176b05f 100644 --- a/webapp/.eslintrc.json +++ b/webapp/.eslintrc.json @@ -70,6 +70,10 @@ "react/react-in-jsx-scope": "off", "react/jsx-props-no-spreading": "off", "react/jsx-uses-react": "off", + "react/no-unstable-nested-components": [ + "error", + { "allowAsProps": true } + ], "react/prop-types": "off", "react/require-default-props": "off", "react-hooks/exhaustive-deps": "warn", diff --git a/webapp/package.json b/webapp/package.json index 7fa2c81505..123cb29fc8 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", @@ -27,6 +27,7 @@ "assert": "2.0.0", "axios": "0.27.2", "buffer": "6.0.3", + "clsx": "1.1.1", "crypto-browserify": "3.12.0", "d3": "5.16.0", "debug": "4.3.4", @@ -36,6 +37,7 @@ "draftjs-to-html": "0.9.1", "fs": "0.0.1-security", "handsontable": "12.0.1", + "hoist-non-react-statics": "3.3.2", "https-browserify": "1.0.0", "i18next": "21.8.5", "i18next-browser-languagedetector": "6.1.4", diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 535295c87d..1a6653f69d 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", @@ -164,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", @@ -173,12 +177,15 @@ "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", "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", @@ -272,6 +279,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 ?", @@ -288,6 +333,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 !", @@ -421,11 +467,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..967933263b 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", @@ -93,7 +95,7 @@ "maintenance.error.messageInfoError": "Impossible de récupérer le message d'info", "maintenance.error.maintenanceError": "Impossible de récupérer le status de maintenance de l'application", "form.submit.error": "Erreur lors de la soumission", - "form.field.required": "Field required", + "form.field.required": "Champ requis", "form.field.minLength": "{{0}} caractère(s) minimum", "form.field.notAllowedValue": "Valeur non autorisée", "matrix.graphSelector": "Colonnes", @@ -164,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", @@ -173,12 +177,15 @@ "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", "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", @@ -272,6 +279,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 ?", @@ -288,6 +333,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é !", @@ -421,11 +467,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/common/types.ts b/webapp/src/common/types.ts index 031d6ebafe..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; @@ -414,9 +417,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 { @@ -604,4 +607,67 @@ export interface TaskView { status: string; } -export default {}; +export interface ThematicTrimmingConfigDTO { + "OV. COST": boolean; + "OP. COST": boolean; + "MRG. PRICE": boolean; + "CO2 EMIS.": boolean; + "DTG by plant": boolean; + BALANCE: boolean; + "ROW BAL.": boolean; + PSP: boolean; + "MISC. NDG": boolean; + LOAD: boolean; + "H. ROR": boolean; + WIND: boolean; + SOLAR: boolean; + NUCLEAR: boolean; + LIGNITE: boolean; + COAL: boolean; + GAS: boolean; + OIL: boolean; + "MIX. FUEL": boolean; + "MISC. DTG": boolean; + "H. STOR": boolean; + "H. PUMP": boolean; + "H. LEV": boolean; + "H. INFL": boolean; + "H. OVFL": boolean; + "H. VAL": boolean; + "H. COST": boolean; + "UNSP. ENRG": boolean; + "SPIL. ENRG": boolean; + LOLD: boolean; + LOLP: boolean; + "AVL DTG": boolean; + "DTG MRG": boolean; + "MAX MRG": boolean; + "NP COST": boolean; + "NP Cost by plant": boolean; + NODU: boolean; + "NODU by plant": boolean; + "FLOW LIN.": boolean; + "UCAP LIN.": boolean; + "LOOP FLOW": boolean; + "FLOW QUAD.": boolean; + "CONG. FEE (ALG.)": boolean; + "CONG. FEE (ABS.)": boolean; + "MARG. COST": boolean; + "CONG. PROD +": boolean; + "CONG. PROD -": boolean; + "HURDLE COST": boolean; + // Study version >= 810 + "RES generation by plant"?: boolean; + "MISC. DTG 2"?: boolean; + "MISC. DTG 3"?: boolean; + "MISC. DTG 4"?: boolean; + "WIND OFFSHORE"?: boolean; + "WIND ONSHORE"?: boolean; + "SOLAR CONCRT."?: boolean; + "SOLAR PV"?: boolean; + "SOLAR ROOFT"?: boolean; + "RENW. 1"?: boolean; + "RENW. 2"?: boolean; + "RENW. 3"?: boolean; + "RENW. 4"?: boolean; +} 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/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 && ( + ) : ( )} - + ); 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..b810c7a292 --- /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 { useState } from "react"; +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 { + getFieldNames, + ThematicTrimmingConfig, + thematicTrimmingConfigToDTO, +} from "./utils"; +import SearchFE from "../../../../../../../common/fieldEditors/SearchFE"; +import { isSearchMatching } from "../../../../../../../../utils/textUtils"; + +interface Props { + study: StudyMetadata; + open: boolean; + onClose: VoidFunction; +} + +function ThematicTrimmingDialog(props: Props) { + const { study, open, onClose } = props; + const { t } = useTranslation(); + const [search, setSearch] = useState(""); + const { control, register, getValues, setValue } = + useFormContext(); + + //////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////// + + const getCurrentConfig = () => { + return getValues("thematicTrimmingConfig"); + }; + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleUpdateConfig = (fn: Pred) => () => { + setSearch(""); + + const config = getCurrentConfig(); + const newConfig: ThematicTrimmingConfig = R.map(fn, 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 }, + }} + // TODO: add `maxHeight` and `fullHeight` in BasicDialog` + PaperProps={{ sx: { height: "calc(100% - 64px)", maxHeight: "900px" } }} + > + + + + + + + + + + + {getFieldNames(getCurrentConfig()) + .filter(([, label]) => isSearchMatching(search, label)) + .map(([name, label]) => ( + + ))} + + + ); +} + +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..8fda36681d --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ThematicTrimmingDialog/utils.ts @@ -0,0 +1,163 @@ +import { camelCase } from "lodash"; +import * as R from "ramda"; +import * as RA from "ramda-adjunct"; +import { 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: Record = { + 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", + // Study version >= 810 + 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", +}; + +// 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 { + 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; +} 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..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,36 +1,62 @@ 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 { PromiseStatus } from "../../../../../../hooks/usePromise"; 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 ); - return R.cond([ - [ - R.either(R.equals(PromiseStatus.Idle), R.equals(PromiseStatus.Pending)), - () => , - ], - [R.equals(PromiseStatus.Rejected), () =>
{error}
], + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleCloseDialog = () => { + setDialog(""); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + const renderDialog = R.cond([ [ - R.equals(PromiseStatus.Resolved), + R.equals("thematicTrimming"), () => ( -
- - + ), ], - ])(status); + ]); + + return ( + } + ifRejected={(error) =>
{error}
} + ifResolved={(data) => ( +
+ + {renderDialog(dialog)} + + )} + /> + ); } export default GeneralParameters; 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/Configuration/General/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/utils.ts index e99b91e072..cc807e852d 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,17 @@ 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"; + +//////////////////////////////////////////////////////////////// +// Enums +//////////////////////////////////////////////////////////////// enum Month { January = "january", @@ -27,34 +38,9 @@ enum WeekDay { Sunday = "Sunday", } -// 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); +//////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////// interface SettingsGeneralDataGeneral { // Mode @@ -98,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"]; @@ -115,13 +107,47 @@ export interface FormValues { mcScenario: SettingsGeneralDataOutput["storenewset"]; geographicTrimming: SettingsGeneralDataGeneral["geographic-trimming"]; thematicTrimming: SettingsGeneralDataGeneral["thematic-trimming"]; + thematicTrimmingConfig: ThematicTrimmingConfig; filtering: SettingsGeneralDataGeneral["filtering"]; } -const DEFAULT_VALUES: FormValues = { - mode: "Adequacy", +//////////////////////////////////////////////////////////////// +// 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: "Economy", firstDay: 1, - lastDay: 1, + lastDay: 365, horizon: "", firstMonth: Month.January, firstWeekDay: WeekDay.Monday, @@ -130,7 +156,7 @@ const DEFAULT_VALUES: FormValues = { nbYears: 1, buildingMode: "Automatic", selectionMode: false, - simulationSynthesis: false, + simulationSynthesis: true, yearByYear: false, mcScenario: false, geographicTrimming: false, @@ -138,13 +164,18 @@ const DEFAULT_VALUES: FormValues = { filtering: false, }; +//////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////// + export async function getFormValues( studyId: StudyMetadata["id"] ): Promise { - 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, @@ -162,6 +193,8 @@ export async function getFormValues( buildingMode = "Custom"; } + const thematicTrimmingConfigDto = await getThematicTrimmingConfig(studyId); + return { ...DEFAULT_VALUES, ...RA.renameKeys( @@ -188,5 +221,8 @@ export async function getFormValues( output ), buildingMode, + thematicTrimmingConfig: formatThematicTrimmingConfigDTO( + thematicTrimmingConfigDto + ), }; } 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..0075df72ba 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"; @@ -9,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); @@ -44,7 +44,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/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/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 74e8d27b8a..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 @@ -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 ColorPicker from "../../../../../../common/fieldEditors/ColorPickerFE"; +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(", ")); + }, + }} /> ); @@ -74,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: watch("posX"), - y: watch("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), - })} - /> - - handleAutoSubmit(path.energieCostSpilled, value), - })} - /> + {`${t( + "study.modelization.nodeProperties.energyCost" + )} (€/Wh)`} + + + handleAutoSubmit(path.energieCostUnsupplied, value), + }} + /> + + handleAutoSubmit(path.energieCostSpilled, value), + }} + /> + - - - - {t("study.modelization.nodeProperties.lastResortShedding")} - - - handleAutoSubmit(path.nonDispatchPower, value), - })} - /> - - handleAutoSubmit(path.dispatchHydroPower, value), - })} - /> - - handleAutoSubmit(path.otherDispatchPower, value), - })} - /> + + {t("study.modelization.nodeProperties.lastResortShedding")} + + + + 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/Properties/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Properties/index.tsx index bdddc0a461..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 @@ -1,49 +1,42 @@ -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 { getDefaultValues } from "./utils"; import SimpleLoader from "../../../../../../common/loaders/SimpleLoader"; +import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; 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] ); return ( - {R.cond([ - [R.equals(PromiseStatus.Pending), () => ], - [ - R.equals(PromiseStatus.Resolved), - () => ( -
- {(formObj) => - PropertiesForm({ - ...formObj, - areaName: currentArea, - studyId: study.id, - }) - } -
- ), - ], - ])(status)} + } + ifResolved={(data) => ( +
+ {(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/Areas/Renewables/RenewableForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx new file mode 100644 index 0000000000..2e4a07a408 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/RenewableForm.tsx @@ -0,0 +1,118 @@ +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"; +import { transformNameToId } from "../../../../../../../services/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 83586ebc7a..0000000000 Binary files a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Renewables/preview.png and /dev/null differ 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/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/Thermal/ThermalForm.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx new file mode 100644 index 0000000000..e9bf986c42 --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/Thermal/ThermalForm.tsx @@ -0,0 +1,218 @@ +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"; +import { transformNameToId } from "../../../../../../../services/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/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/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/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 14ac6a533b..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 @@ -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(), @@ -166,57 +171,54 @@ export default function LinkForm( options: Array<{ label: string; value: string }>, onAutoSubmit?: AutoSubmitHandler ) => ( - - { - handleAutoSubmit(path[filterName], value); - }), - })} - defaultValue={(defaultValues || {})[filterName] || []} - variant="filled" - options={options} - formControlProps={{ - sx: { - flex: 1, - mx: 2, - boxSizing: "border-box", - }, - }} - sx={{ width: "100%", minWidth: "200px" }} - label={t(`study.modelization.links.${filterName}`)} - /> - + { + 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 !== "") + : []; + 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"); - }} - defaultValue={(defaultValues || {})[filterName] || []} - variant="filled" - options={filterOptions} - sx={{ minWidth: "200px" }} - label={t(`study.modelization.nodeProperties.${filterName}`)} - /> - + handleAutoSubmit(path[filterName], selection.join(", ")); + }, + }} + /> ); return ( @@ -224,7 +226,6 @@ export default function LinkForm( 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), + }} + /> + handleAutoSubmit(path.pst, value), }} - > - {renderFilter("filterSynthesis")} - {renderFilter("filterByYear")} - + /> + {renderSelect("transmissionCapa", optionTransCap)} + {renderSelect("type", optionType, handleTypeAutoSubmit)} +
+
+ {renderFilter("filterSynthesis")} + {renderFilter("filterByYear")}
); } + +export default LinkForm; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkMatrixView.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkMatrixView.tsx index 429092c5d9..72809f0e3f 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkMatrixView.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/LinkView/LinkMatrixView.tsx @@ -9,7 +9,7 @@ import { MatrixStats, StudyMetadata } from "../../../../../../../common/types"; import MatrixInput from "../../../../../../common/MatrixInput"; export const StyledTab = styled(Tabs)({ - width: "98%", + width: "100%", borderBottom: 1, borderColor: "divider", }); @@ -78,7 +78,7 @@ function LinkMatrixView(props: Props) { url={`input/links/${area1.toLowerCase()}/capacities/${area2.toLowerCase()}_direct`} computStats={MatrixStats.NOCOL} /> - + (); const { link } = props; - const { data: defaultValues, status } = usePromise( + const res = usePromise( () => getDefaultValues(study.id, link.area1, link.area2), [study.id, link.area1, link.area2] ); return ( - {R.cond([ - [R.equals(PromiseStatus.Pending), () => ], - [ - R.equals(PromiseStatus.Resolved), - () => ( -
- {(formObj) => - LinkForm({ - ...formObj, - link, - study, - }) - } + + } + ifResolved={(data) => ( + + - ), - ], - ])(status)} + )} + /> +
); } 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 d8b43793ba..9b4f5681f9 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", @@ -33,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(); @@ -87,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/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 && ( 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 /> ({}); + const [options, setOptions] = useState({ + nb_cpu: LAUNCH_LOAD_DEFAULT, + auto_unzip: true, + }); const [solverVersion, setSolverVersion] = useState(); const [isLaunching, setIsLaunching] = useState(false); const isMounted = useMountedState(); @@ -51,6 +63,11 @@ function LauncherDialog(props: Props) { shallowEqual ); + const { data: load } = usePromiseWithSnackbarError(() => getLauncherLoad(), { + errorMessage: t("study.error.launchLoad"), + deps: [open], + }); + //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// @@ -145,7 +162,7 @@ function LauncherDialog(props: Props) { alignItems: "flex-start", px: 2, boxSizing: "border-box", - overflowY: "scroll", + overflowY: "auto", overflowX: "hidden", }} > @@ -248,7 +265,38 @@ function LauncherDialog(props: Props) { max: LAUNCH_DURATION_MAX_HOURS, })} /> + + {t("study.nbCpu")} + {load && ( + + )} + + 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")} + /> + + ; @@ -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/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/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/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 7185b1eae6..010bb34a9c 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} @@ -130,9 +131,6 @@ function FileTable(props: PropType) { ({ - "&> th": { - padding: 1, - }, "&> th, >td": { borderBottom: "solid 1px", borderColor: theme.palette.divider, @@ -203,6 +201,19 @@ function FileTable(props: PropType) { )} + {onAssign && ( + onAssign(row.id as string)} + sx={{ + ml: 1, + color: "primary.main", + }} + > + + + + + )} ))} @@ -210,7 +221,7 @@ function FileTable(props: PropType) {
- {openConfirmationModal && openConfirmationModal.length > 0 && ( + {openConfirmationModal && onDelete && ( = 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 UseFormReturnPlus< TFieldValues extends FieldValues = FieldValues, TContext = any > extends UseFormReturn { register: UseFormRegisterPlus; + control: ControlPlus; defaultValues?: UseFormProps["defaultValues"]; } @@ -70,32 +76,24 @@ 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; + onSubmitError?: SubmitErrorHandler; 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( @@ -104,21 +102,36 @@ function Form( const { config, onSubmit, + onSubmitError, children, submitButtonText, hideSubmitButton, onStateChange, autoSubmit, - id, + ...formProps } = props; + const formObj = useForm({ mode: "onChange", + delayError: 750, ...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; + // * /!\ `formState` is a proxy + 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); @@ -126,7 +139,15 @@ function Form( const fieldAutoSubmitListeners = useRef< Record any | Promise) | undefined> >({}); - const lastDataSubmitted = useRef>(); + const preventClose = useRef(false); + const [showLoader, setLoader] = useDebouncedState(false, 750); + + useUpdateEffect(() => { + setLoader(isSubmitting); + if (isSubmitting) { + setLoader.flush(); + } + }, [isSubmitting]); useUpdateEffect( () => { @@ -134,13 +155,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 //////////////////////////////////////////////////////////////// @@ -148,9 +185,7 @@ function Form( const handleFormSubmit = (event: FormEvent) => { event.preventDefault(); - handleSubmit((data, e) => { - lastDataSubmitted.current = data; - + handleSubmit(function onValid(data, e) { const dirtyValues = getDirtyValues(dirtyFields, data) as Partial< typeof data >; @@ -174,62 +209,51 @@ function Form( } return Promise.all(res); - })().catch((error) => { - enqueueErrorSnackbar(t("form.submit.error"), error); - }); + }, onSubmitError)() + .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 +285,61 @@ 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, 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/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 + ), + ]) ); } 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/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/components/common/MatrixInput/MatrixAssignDialog.tsx b/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx new file mode 100644 index 0000000000..70f8c0ea6e --- /dev/null +++ b/webapp/src/components/common/MatrixInput/MatrixAssignDialog.tsx @@ -0,0 +1,206 @@ +import { Box, Button, Divider, Typography } from "@mui/material"; +import { AxiosError } from "axios"; +import { useSnackbar } from "notistack"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { MatrixInfoDTO, StudyMetadata } from "../../../common/types"; +import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar"; +import usePromiseWithSnackbarError from "../../../hooks/usePromiseWithSnackbarError"; +import { getMatrix, getMatrixList } from "../../../services/api/matrix"; +import { appendCommands } from "../../../services/api/variant"; +import DataPropsView from "../../App/Data/DataPropsView"; +import { CommandEnum } from "../../App/Singlestudy/Commands/Edition/commandTypes"; +import ButtonBack from "../ButtonBack"; +import BasicDialog, { BasicDialogProps } from "../dialogs/BasicDialog"; +import EditableMatrix from "../EditableMatrix"; +import FileTable from "../FileTable"; +import SimpleLoader from "../loaders/SimpleLoader"; +import SplitLayoutView from "../SplitLayoutView"; +import UsePromiseCond from "../utils/UsePromiseCond"; + +interface Props { + study: StudyMetadata; + path: string; + open: BasicDialogProps["open"]; + onClose: VoidFunction; +} + +function MatrixAssignDialog(props: Props) { + const [t] = useTranslation(); + const { study, path, open, onClose } = props; + const [selectedItem, setSelectedItem] = useState(""); + const [currentMatrix, setCurrentMatrix] = useState(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const { enqueueSnackbar } = useSnackbar(); + + const resList = usePromiseWithSnackbarError(() => getMatrixList(), { + errorMessage: t("data.error.matrixList"), + }); + + const resMatrix = 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 = resList.data?.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", + }); + onClose(); + } catch (e) { + enqueueErrorSnackbar(t("data.error.matrixAssignation"), e as AxiosError); + } + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + {t("button.close")}} + contentProps={{ + sx: { width: "1200px", height: "700px" }, + }} + > + } + ifRejected={(error) =>
{error}
} + ifResolved={(dataset) => + dataset && ( + + } + right={ + + {selectedItem && !currentMatrix && ( + + + {matrixName} + + + } + content={matrices || []} + onRead={handleMatrixClick} + onAssign={handleAssignation} + /> + )} + } + ifRejected={(error) =>
{error}
} + ifResolved={(matrix) => + matrix && ( + <> + + + {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..a1690f2480 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, @@ -62,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], } ); @@ -122,8 +132,8 @@ function MatrixInput(props: PropsType) { {title || t("xpansion.timeSeries")} - {!isLoading && data?.columns?.length > 1 && ( - + {!isLoading && data?.columns?.length >= 1 && ( + setToggleView((prev) => !prev)}> {toggleView ? ( @@ -133,6 +143,18 @@ function MatrixInput(props: PropsType) { )} + @@ -96,6 +99,7 @@ function FormDialog( >
diff --git a/webapp/src/components/common/fieldEditors/BooleanFE.tsx b/webapp/src/components/common/fieldEditors/BooleanFE.tsx index 3152ac196b..807cec0fa0 100644 --- a/webapp/src/components/common/fieldEditors/BooleanFE.tsx +++ b/webapp/src/components/common/fieldEditors/BooleanFE.tsx @@ -1,9 +1,10 @@ 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 { +export interface BooleanFEProps + extends Omit { defaultValue?: boolean; value?: boolean; trueText?: string; @@ -31,7 +32,7 @@ function toValidEvent< } as T; } -const BooleanFE = forwardRef((props: BooleanFEProps, ref) => { +function BooleanFE(props: BooleanFEProps) { const { defaultValue, value, @@ -39,6 +40,7 @@ const BooleanFE = forwardRef((props: BooleanFEProps, ref) => { falseText, onChange, onBlur, + inputRef, ...rest } = props; @@ -58,11 +60,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/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/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 c5d7962007..77e86a8d6f 100644 --- a/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx +++ b/webapp/src/components/common/fieldEditors/ColorPickerFE/index.tsx @@ -1,78 +1,89 @@ -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, 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"; +import reactHookFormSupport from "../../../../hoc/reactHookFormSupport"; -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(); +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(); + 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(inputRef, internalRef)} InputProps={{ startAdornment: ( @@ -87,40 +98,18 @@ 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"; +} -export default ColorPicker; +export default reactHookFormSupport({ defaultValue: "" })(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/fieldEditors/NumberFE.tsx b/webapp/src/components/common/fieldEditors/NumberFE.tsx new file mode 100644 index 0000000000..86026d5fb6 --- /dev/null +++ b/webapp/src/components/common/fieldEditors/NumberFE.tsx @@ -0,0 +1,19 @@ +import { TextField, TextFieldProps } from "@mui/material"; +import * as RA from "ramda-adjunct"; +import withReactHookFormSupport from "../../../hoc/reactHookFormSupport"; + +export type NumberFEProps = { + value?: number; + defaultValue?: number; +} & Omit; + +function NumberFE(props: NumberFEProps) { + return ; +} + +export default withReactHookFormSupport({ + 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/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/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/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 new file mode 100644 index 0000000000..a348387faf --- /dev/null +++ b/webapp/src/components/common/utils/UsePromiseCond.tsx @@ -0,0 +1,32 @@ +import * as R from "ramda"; +import { PromiseStatus, UsePromiseResponse } from "../../../hooks/usePromise"; + +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 { response, ifPending, ifRejected, ifResolved } = props; + const { status, data, error } = response; + + return ( + <> + {R.cond([ + [ + R.either( + R.equals(PromiseStatus.Idle), + R.equals(PromiseStatus.Pending) + ), + () => ifPending?.(), + ], + [R.equals(PromiseStatus.Rejected), () => ifRejected?.(error)], + [R.equals(PromiseStatus.Resolved), () => ifResolved?.(data)], + ])(status)} + + ); +} + +export default UsePromiseCond; diff --git a/webapp/src/components/wrappers/LoginWrapper.tsx b/webapp/src/components/wrappers/LoginWrapper.tsx index 982203b4a1..dc0ccab5ee 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..8e3b731904 --- /dev/null +++ b/webapp/src/hoc/reactHookFormSupport.tsx @@ -0,0 +1,161 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import hoistNonReactStatics from "hoist-non-react-statics"; +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 +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" + // Not necessary + | "onChange" + | "onBlur" + >; + shouldUnregister?: boolean; + name: TFieldName; + } + | { + control?: undefined; + rules?: never; + shouldUnregister?: never; + }; + +function reactHookFormSupport( + options: ReactHookFormSupport = {} +) { + const { preValidate, 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, ...feProps } = props; + const { validate } = rules; + + 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 ( + ( + { + // 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" + ? event.target.checked + : event.target.value + ) + ); + }} + 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} + /> + )} + /> + ); + } + + 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/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; diff --git a/webapp/src/hooks/usePromise.ts b/webapp/src/hooks/usePromise.ts index c43f25e6d7..e42e838541 100644 --- a/webapp/src/hooks/usePromise.ts +++ b/webapp/src/hooks/usePromise.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { usePromise as usePromiseWrapper } from "react-use"; import { isDependencyList } from "../utils/reactUtils"; 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 //////////////////////////////////////////////////////////////// diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index 821129b1ca..4caab05413 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"; @@ -125,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; }; @@ -268,6 +274,8 @@ export interface LaunchOptions { output_suffix?: string; // eslint-disable-next-line camelcase other_options?: string; + // eslint-disable-next-line camelcase + auto_unzip?: boolean; } export const launchStudy = async ( @@ -283,6 +291,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; @@ -294,6 +312,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, @@ -387,4 +406,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..b7c5a9092f 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", @@ -99,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", }, }, }, @@ -130,7 +128,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 b5031ffce8..fce9e26f73 100644 --- a/webapp/src/utils/reactUtils.ts +++ b/webapp/src/utils/reactUtils.ts @@ -1,5 +1,21 @@ +import { setRef } from "@mui/material"; + export function isDependencyList( value: unknown ): value is React.DependencyList { return Array.isArray(value); } + +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"; +} 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); + } +);