diff --git a/antarest/__init__.py b/antarest/__init__.py index 8fd62ea94a..adaa163763 100644 --- a/antarest/__init__.py +++ b/antarest/__init__.py @@ -19,9 +19,9 @@ # Standard project metadata -__version__ = "2.18.2" +__version__ = "2.18.3" __author__ = "RTE, Antares Web Team" -__date__ = "2024-12-11" +__date__ = "2024-12-17" # noinspection SpellCheckingInspection __credits__ = "(c) Réseau de Transport de l’Électricité (RTE)" diff --git a/antarest/study/business/xpansion_management.py b/antarest/study/business/xpansion_management.py index c86dd5cab7..3f19e7c8f3 100644 --- a/antarest/study/business/xpansion_management.py +++ b/antarest/study/business/xpansion_management.py @@ -203,7 +203,7 @@ def from_config(cls, config_obj: JSON) -> "GetXpansionSettings": try: return cls(**config_obj) except ValidationError: - return cls.construct(**config_obj) + return cls.model_construct(**config_obj) @all_optional_model @@ -270,11 +270,6 @@ def __init__(self) -> None: super().__init__(http.HTTPStatus.BAD_REQUEST) -class WrongTypeFormat(HTTPException): - def __init__(self, message: str) -> None: - super().__init__(http.HTTPStatus.BAD_REQUEST, message) - - class WrongLinkFormatError(HTTPException): def __init__(self, message: str) -> None: super().__init__(http.HTTPStatus.BAD_REQUEST, message) @@ -295,11 +290,6 @@ def __init__(self, message: str) -> None: super().__init__(http.HTTPStatus.NOT_FOUND, message) -class ConstraintsNotFoundError(HTTPException): - def __init__(self, message: str) -> None: - super().__init__(http.HTTPStatus.NOT_FOUND, message) - - class FileCurrentlyUsedInSettings(HTTPException): def __init__(self, message: str) -> None: super().__init__(http.HTTPStatus.CONFLICT, message) @@ -337,7 +327,10 @@ def create_xpansion_configuration(self, study: Study, zipped_config: t.Optional[ xpansion_settings = XpansionSettings() settings_obj = xpansion_settings.model_dump( - mode="json", by_alias=True, exclude_none=True, exclude={"sensitivity_config"} + mode="json", + by_alias=True, + exclude_none=True, + exclude={"sensitivity_config", "yearly_weights", "additional_constraints"}, ) if xpansion_settings.sensitivity_config: sensitivity_obj = xpansion_settings.sensitivity_config.model_dump( @@ -389,22 +382,33 @@ def update_xpansion_settings( file_study = self.study_storage_service.get_storage(study).get_raw(study) - # Specific handling of the additional constraints file: - # - If the file name is `None`, it means that the user does not want to select an additional constraints file. - # - If the file name is empty, it means that the user wants to deselect the additional constraints file, - # but he does not want to delete it from the expansion configuration folder. - # - If the file name is not empty, it means that the user wants to select an additional constraints file. + # Specific handling for yearly_weights and additional_constraints: + # - If the attributes are given, it means that the user wants to select a file. # It is therefore necessary to check that the file exists. - constraints_file = new_xpansion_settings.additional_constraints - if constraints_file: + # - Else, it means the user want to deselect the additional constraints file, + # but he does not want to delete it from the expansion configuration folder. + excludes = {"sensitivity_config"} + if constraints_file := new_xpansion_settings.additional_constraints: try: constraints_url = ["user", "expansion", "constraints", constraints_file] file_study.tree.get(constraints_url) except ChildNotFoundError: msg = f"Additional constraints file '{constraints_file}' does not exist" raise XpansionFileNotFoundError(msg) from None + else: + excludes.add("additional_constraints") + + if weights_file := new_xpansion_settings.yearly_weights: + try: + weights_url = ["user", "expansion", "weights", weights_file] + file_study.tree.get(weights_url) + except ChildNotFoundError: + msg = f"Additional weights file '{weights_file}' does not exist" + raise XpansionFileNotFoundError(msg) from None + else: + excludes.add("yearly_weights") - config_obj = updated_settings.model_dump(mode="json", by_alias=True, exclude={"sensitivity_config"}) + config_obj = updated_settings.model_dump(mode="json", by_alias=True, exclude=excludes) file_study.tree.save(config_obj, ["user", "expansion", "settings"]) if new_xpansion_settings.sensitivity_config: diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a8c6b3cf13..959376e0d5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,6 +1,18 @@ Antares Web Changelog ===================== +v2.18.3 (2024-12-17) +-------------------- + +## What's Changed + +### Bug Fixes + +* **ui-results**: resolve data misalignment in matrix column filtering [`2269`](https://github.com/AntaresSimulatorTeam/AntaREST/pull/2269) + +**Full Changelog**: https://github.com/AntaresSimulatorTeam/AntaREST/compare/v2.18.2...v2.18.3 + + v2.18.2 (2024-12-11) -------------------- diff --git a/pyproject.toml b/pyproject.toml index 97ced18d04..3b3b9a3849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools"] [project] name = "AntaREST" -version = "2.18.2" +version = "2.18.3" authors = [{name="RTE, Antares Web Team", email="andrea.sgattoni@rte-france.com" }] description="Antares Server" readme = {file = "README.md", content-type = "text/markdown"} diff --git a/sonar-project.properties b/sonar-project.properties index 704e6924d8..8c55e49a3b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,5 +6,5 @@ sonar.exclusions=antarest/gui.py,antarest/main.py sonar.python.coverage.reportPaths=coverage.xml sonar.python.version=3.11 sonar.javascript.lcov.reportPaths=webapp/coverage/lcov.info -sonar.projectVersion=2.18.2 +sonar.projectVersion=2.18.3 sonar.coverage.exclusions=antarest/gui.py,antarest/main.py,antarest/singleton_services.py,antarest/worker/archive_worker_service.py,webapp/**/*,,antarest/fastapi_jwt_auth/** \ No newline at end of file diff --git a/tests/storage/business/test_xpansion_manager.py b/tests/storage/business/test_xpansion_manager.py index 5b1f8d1abd..325ddf7e0c 100644 --- a/tests/storage/business/test_xpansion_manager.py +++ b/tests/storage/business/test_xpansion_manager.py @@ -127,8 +127,6 @@ def set_up_xpansion_manager(tmp_path: Path) -> t.Tuple[FileStudy, RawStudy, Xpan "log_level": 0, "separation_parameter": 0.5, "batch_size": 96, - "yearly-weights": "", - "additional-constraints": "", "timelimit": int(1e12), }, "weights": {}, @@ -228,8 +226,6 @@ def test_update_xpansion_settings(tmp_path: Path) -> None: "max_iteration": 123, "uc_type": UcType.EXPANSION_FAST, "master": Master.INTEGER, - "yearly-weights": "", - "additional-constraints": "", "relaxed_optimality_gap": "1.2%", # percentage "relative_gap": 1e-12, "batch_size": 4, @@ -463,6 +459,63 @@ def test_update_constraints(tmp_path: Path) -> None: assert actual_settings.additional_constraints == "" +@pytest.mark.unit_test +def test_update_constraints_via_the_front(tmp_path: Path) -> None: + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) + empty_study.tree.save({"user": {"expansion": {"constraints": {"constraints.txt": b"0"}}}}) + + # asserts we can update a field without writing the field additional constraint in the file + front_settings = UpdateXpansionSettings(master="relaxed") + xpansion_manager.update_xpansion_settings(study, front_settings) + json_content = empty_study.tree.get(["user", "expansion", "settings"]) + assert "additional-constraints" not in json_content + assert json_content["master"] == "relaxed" + + # asserts the front-end can fill additional constraints + new_constraint = {"additional-constraints": "constraints.txt"} + front_settings = UpdateXpansionSettings.model_validate(new_constraint) + actual_settings = xpansion_manager.update_xpansion_settings(study, front_settings) + assert actual_settings.additional_constraints == "constraints.txt" + json_content = empty_study.tree.get(["user", "expansion", "settings"]) + assert json_content["additional-constraints"] == "constraints.txt" + + # asserts the front-end can unselect this constraint by not filling it + front_settings = UpdateXpansionSettings() + actual_settings = xpansion_manager.update_xpansion_settings(study, front_settings) + assert actual_settings.additional_constraints == "" + json_content = empty_study.tree.get(["user", "expansion", "settings"]) + assert "additional-constraints" not in json_content + + +@pytest.mark.unit_test +def test_update_weights_via_the_front(tmp_path: Path) -> None: + # Same test as the one for constraints + empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) + empty_study.tree.save({"user": {"expansion": {"weights": {"weights.txt": b"0"}}}}) + + # asserts we can update a field without writing the field yearly-weights in the file + front_settings = UpdateXpansionSettings(master="relaxed") + xpansion_manager.update_xpansion_settings(study, front_settings) + json_content = empty_study.tree.get(["user", "expansion", "settings"]) + assert "yearly-weights" not in json_content + assert json_content["master"] == "relaxed" + + # asserts the front-end can fill yearly weights + new_constraint = {"yearly-weights": "weights.txt"} + front_settings = UpdateXpansionSettings.model_validate(new_constraint) + actual_settings = xpansion_manager.update_xpansion_settings(study, front_settings) + assert actual_settings.yearly_weights == "weights.txt" + json_content = empty_study.tree.get(["user", "expansion", "settings"]) + assert json_content["yearly-weights"] == "weights.txt" + + # asserts the front-end can unselect this weight by not filling it + front_settings = UpdateXpansionSettings() + actual_settings = xpansion_manager.update_xpansion_settings(study, front_settings) + assert actual_settings.yearly_weights == "" + json_content = empty_study.tree.get(["user", "expansion", "settings"]) + assert "yearly-weights" not in json_content + + @pytest.mark.unit_test def test_add_resources(tmp_path: Path) -> None: empty_study, study, xpansion_manager = set_up_xpansion_manager(tmp_path) diff --git a/webapp/.nvmrc b/webapp/.nvmrc new file mode 100644 index 0000000000..3876fd4986 --- /dev/null +++ b/webapp/.nvmrc @@ -0,0 +1 @@ +18.16.1 diff --git a/webapp/package-lock.json b/webapp/package-lock.json index 19a7d280fb..7fcf0b0270 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -1,12 +1,12 @@ { "name": "antares-web", - "version": "2.18.2", + "version": "2.18.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "antares-web", - "version": "2.18.2", + "version": "2.18.3", "dependencies": { "@emotion/react": "11.13.3", "@emotion/styled": "11.13.0", diff --git a/webapp/package.json b/webapp/package.json index a4efb1d608..aea940a029 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -1,6 +1,6 @@ { "name": "antares-web", - "version": "2.18.2", + "version": "2.18.3", "private": true, "type": "module", "scripts": { diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 250a75f2e6..7c6a13307a 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -44,6 +44,7 @@ "global.date": "Date", "global.general": "General", "global.files": "Files", + "global.rawFile": "Raw file", "global.none": "None", "global.upload": "Upload", "global.key": "Key", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 98d6ee71b9..395ed91e7e 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -44,6 +44,7 @@ "global.date": "Date", "global.general": "Général", "global.files": "Fichiers", + "global.rawFile": "Fichier brut", "global.none": "Aucun", "global.upload": "Charger", "global.key": "Clé", diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx index f014626bf5..5df699950b 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx @@ -21,16 +21,15 @@ import UsePromiseCond from "../../../../../common/utils/UsePromiseCond"; import type { DataCompProps } from "../utils"; import DownloadButton from "../../../../../common/buttons/DownloadButton"; import { downloadFile } from "../../../../../../utils/fileUtils"; -import { useEffect, useState } from "react"; import { Filename, Flex, Menubar } from "./styles"; import UploadFileButton from "../../../../../common/buttons/UploadFileButton"; +import { getRawFile } from "@/services/api/studies/raw"; function Json({ filePath, filename, studyId, canEdit }: DataCompProps) { const [t] = useTranslation(); const { enqueueSnackbar } = useSnackbar(); - const [currentJson, setCurrentJson] = useState(); - const res = usePromiseWithSnackbarError( + const jsonRes = usePromiseWithSnackbarError( () => getStudyData(studyId, filePath, -1), { errorMessage: t("studies.error.retrieveData"), @@ -38,37 +37,27 @@ function Json({ filePath, filename, studyId, canEdit }: DataCompProps) { }, ); - useEffect(() => { - setCurrentJson(res.data); - }, [res.data]); - //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// + const handleDownload = async () => { + const file = await getRawFile({ studyId, path: filePath }); + downloadFile(file, file.name); + }; + const handleSave: JSONEditorProps["onSave"] = (json) => { return editStudy(json, studyId, filePath); }; - const handleSaveSuccessful: JSONEditorProps["onSaveSuccessful"] = (json) => { - setCurrentJson(json); - + const handleSaveSuccessful: JSONEditorProps["onSaveSuccessful"] = () => { enqueueSnackbar(t("studies.success.saveData"), { variant: "success", }); }; - const handleDownload = () => { - if (currentJson !== undefined) { - downloadFile( - JSON.stringify(currentJson, null, 2), - filename.endsWith(".json") ? filename : `${filename}.json`, - ); - } - }; - const handleUploadSuccessful = () => { - res.reload(); + jsonRes.reload(); }; //////////////////////////////////////////////////////////////// @@ -77,7 +66,7 @@ function Json({ filePath, filename, studyId, canEdit }: DataCompProps) { return ( ( diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx index 1f7a7f4909..1ee8be74c0 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx @@ -33,6 +33,7 @@ import { Filename, Flex, Menubar } from "./styles"; import UploadFileButton from "../../../../../common/buttons/UploadFileButton"; import EmptyView from "@/components/common/page/SimpleContent"; import GridOffIcon from "@mui/icons-material/GridOff"; +import { getRawFile } from "@/services/api/studies/raw"; SyntaxHighlighter.registerLanguage("xml", xml); SyntaxHighlighter.registerLanguage("plaintext", plaintext); @@ -75,7 +76,7 @@ function Text({ const { t } = useTranslation(); const theme = useTheme(); - const res = usePromiseWithSnackbarError( + const textRes = usePromiseWithSnackbarError( () => getStudyData(studyId, filePath).then((text) => parseContent(text, { filePath, fileType }), @@ -90,17 +91,13 @@ function Text({ // Event Handlers //////////////////////////////////////////////////////////////// - const handleDownload = () => { - if (res.data) { - downloadFile( - res.data, - filename.endsWith(".txt") ? filename : `${filename}.txt`, - ); - } + const handleDownload = async () => { + const file = await getRawFile({ studyId, path: filePath }); + downloadFile(file, file.name); }; const handleUploadSuccessful = () => { - res.reload(); + textRes.reload(); }; //////////////////////////////////////////////////////////////// @@ -109,7 +106,7 @@ function Text({ return ( ( diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx index e4bcfccc5c..81307a4edf 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx @@ -19,33 +19,19 @@ import { Filename, Flex, Menubar } from "./styles"; import type { DataCompProps } from "../utils"; import DownloadButton from "@/components/common/buttons/DownloadButton"; import UploadFileButton from "@/components/common/buttons/UploadFileButton"; -import usePromiseWithSnackbarError from "@/hooks/usePromiseWithSnackbarError"; -import { getStudyData } from "@/services/api/study"; import { downloadFile } from "@/utils/fileUtils"; +import { getRawFile } from "@/services/api/studies/raw"; function Unsupported({ studyId, filePath, filename, canEdit }: DataCompProps) { const { t } = useTranslation(); - const res = usePromiseWithSnackbarError( - () => getStudyData(studyId, filePath), - { - errorMessage: t("studies.error.retrieveData"), - deps: [studyId, filePath], - }, - ); - //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleDownload = () => { - if (res.data) { - downloadFile(res.data, filename); - } - }; - - const handleUploadSuccessful = () => { - res.reload(); + const handleDownload = async () => { + const file = await getRawFile({ studyId, path: filePath }); + downloadFile(file, file.name); }; //////////////////////////////////////////////////////////////// @@ -56,13 +42,7 @@ function Unsupported({ studyId, filePath, filename, canEdit }: DataCompProps) { {filename} - {canEdit && ( - - )} + {canEdit && } diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx index fac74da93c..5e460b5d3e 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/ResultFilters.tsx @@ -62,7 +62,7 @@ interface Props { studyId: string; path: string; colHeaders: string[][]; - onColHeadersChange: (colHeaders: string[][]) => void; + onColHeadersChange: (colHeaders: string[][], indices: number[]) => void; } function ResultFilters({ @@ -104,48 +104,61 @@ function ResultFilters({ ); }, [colHeaders]); - useEffect(() => { - const filteredHeaders = parsedHeaders.filter((header) => { - // Apply search filters - if (filters.search) { - const matchesVariable = matchesSearchTerm( - header.variable, - filters.search, - ); + // Process headers while keeping track of their original positions + // This ensures we can properly filter the data matrix later + // Example: if we filter out column 1, we need to know that column 2 + // becomes column 1 in the filtered view but maps to index 2 in the data + const filteredHeaders = useMemo(() => { + return parsedHeaders + .map((header, index) => ({ ...header, index })) + .filter((header) => { + // Apply search filter + if (filters.search) { + const matchesVariable = matchesSearchTerm( + header.variable, + filters.search, + ); - const matchesUnit = matchesSearchTerm(header.unit, filters.search); + const matchesUnit = matchesSearchTerm(header.unit, filters.search); - if (!matchesVariable && !matchesUnit) { - return false; + if (!matchesVariable && !matchesUnit) { + return false; + } } - } - // Apply stat filters - if (header.stat) { - const stat = header.stat.toLowerCase(); + // Apply statistical filters + if (header.stat) { + const stat = header.stat.toLowerCase(); - if (!filters.exp && stat.includes("exp")) { - return false; - } - if (!filters.min && stat.includes("min")) { - return false; - } - if (!filters.max && stat.includes("max")) { - return false; - } - if (!filters.std && stat.includes("std")) { - return false; + if (!filters.exp && stat.includes("exp")) { + return false; + } + if (!filters.min && stat.includes("min")) { + return false; + } + if (!filters.max && stat.includes("max")) { + return false; + } + if (!filters.std && stat.includes("std")) { + return false; + } + if (!filters.values && stat.includes("values")) { + return false; + } } - if (!filters.values && stat.includes("values")) { - return false; - } - } - return true; - }); + return true; + }); + }, [filters, parsedHeaders]); - onColHeadersChange(filteredHeaders.map((h) => h.original)); - }, [filters, parsedHeaders, onColHeadersChange]); + // Notify parent of both filtered headers and their original indices + // This allows the parent to correctly map the filtered view back to the original data + useEffect(() => { + onColHeadersChange( + filteredHeaders.map((h) => h.original), + filteredHeaders.map((h) => h.index), + ); + }, [filteredHeaders, onColHeadersChange]); //////////////////////////////////////////////////////////////// // Event handlers diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index 3aaaf4509b..1814bd876d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -68,6 +68,8 @@ import { getStudyMatrixIndex } from "../../../../../../services/api/matrix.ts"; import { MatrixGridSynthesis } from "@/components/common/Matrix/components/MatrixGridSynthesis"; import { ResultMatrixDTO } from "@/components/common/Matrix/shared/types.ts"; +type SetResultColHeaders = (headers: string[][], indices: number[]) => void; + function ResultDetails() { const { study } = useOutletContext<{ study: StudyMetadata }>(); const { outputId } = useParams(); @@ -84,7 +86,11 @@ function ResultDetails() { const [itemType, setItemType] = useState(OutputItemType.Areas); const [selectedItemId, setSelectedItemId] = useState(""); const [searchValue, setSearchValue] = useState(""); + // Store filtered headers and their original indices separately + // This allows us to correctly map the data rows to their corresponding headers + // when some columns are filtered out const [resultColHeaders, setResultColHeaders] = useState([]); + const [headerIndices, setHeaderIndices] = useState([]); const isSynthesis = itemType === OutputItemType.Synthesis; const { t } = useTranslation(); const navigate = useNavigate(); @@ -143,16 +149,24 @@ function ResultDetails() { } const res = await getStudyData(study.id, path); - // TODO add backend parse + if (typeof res === "string") { const fixed = res .replace(/NaN/g, '"NaN"') .replace(/Infinity/g, '"Infinity"'); - return JSON.parse(fixed); + const parsed = JSON.parse(fixed); + + return { + ...parsed, + indices: Array.from({ length: parsed.columns.length }, (_, i) => i), + }; } - return res; + return { + ...res, + indices: Array.from({ length: res.columns.length }, (_, i) => i), + }; }, { resetDataOnReload: true, @@ -161,6 +175,19 @@ function ResultDetails() { }, ); + // Transform the matrix data by keeping only the columns that match our filters + // headerIndices contains the original positions of our kept columns, ensuring + // the data stays aligned with its corresponding headers + const filteredData = useMemo(() => { + if (!matrixRes.data) { + return []; + } + + return matrixRes.data.data.map((row) => { + return headerIndices.map((index) => row[index]); + }); + }, [matrixRes.data, headerIndices]); + const synthesisRes = usePromise( () => { if (outputId && selectedItem && isSynthesis) { @@ -213,6 +240,11 @@ function ResultDetails() { } }; + const handleColHeadersChange: SetResultColHeaders = (headers, indices) => { + setResultColHeaders(headers); + setHeaderIndices(indices); + }; + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -295,7 +327,7 @@ function ResultDetails() { studyId={study.id} path={path} colHeaders={matrixRes.data?.columns || []} - onColHeadersChange={setResultColHeaders} + onColHeadersChange={handleColHeadersChange} /> { try { - await importFile({ file, studyId, path: url }); + await uploadFile({ file, studyId, path: url }); + // TODO: update the API to return the uploaded file data and remove this await fetchMatrix(); } catch (e) { enqueueErrorSnackbar(t("matrix.error.import"), e as Error); diff --git a/webapp/src/components/common/Matrix/hooks/useMatrix/useMatrix.test.tsx b/webapp/src/components/common/Matrix/hooks/useMatrix/useMatrix.test.tsx index fc1087a927..e4b61d8587 100644 --- a/webapp/src/components/common/Matrix/hooks/useMatrix/useMatrix.test.tsx +++ b/webapp/src/components/common/Matrix/hooks/useMatrix/useMatrix.test.tsx @@ -166,7 +166,7 @@ describe("useMatrix", () => { describe("File operations", () => { test("should handle file import", async () => { const mockFile = new File([""], "test.csv", { type: "text/csv" }); - vi.mocked(rawStudy.importFile).mockResolvedValue(); + vi.mocked(rawStudy.uploadFile).mockResolvedValue(); const hook = await setupHook(); @@ -174,7 +174,7 @@ describe("useMatrix", () => { await hook.result.current.handleUpload(mockFile); }); - expect(rawStudy.importFile).toHaveBeenCalledWith({ + expect(rawStudy.uploadFile).toHaveBeenCalledWith({ file: mockFile, studyId: DATA.studyId, path: DATA.url, diff --git a/webapp/src/components/common/buttons/DownloadMatrixButton.tsx b/webapp/src/components/common/buttons/DownloadMatrixButton.tsx index 55e0d029c3..b50e3e81da 100644 --- a/webapp/src/components/common/buttons/DownloadMatrixButton.tsx +++ b/webapp/src/components/common/buttons/DownloadMatrixButton.tsx @@ -12,13 +12,15 @@ * This file is part of the Antares project. */ -import { downloadMatrix } from "../../../services/api/studies/raw"; +import { getMatrixFile, getRawFile } from "../../../services/api/studies/raw"; import { downloadFile } from "../../../utils/fileUtils"; import { StudyMetadata } from "../../../common/types"; import { useTranslation } from "react-i18next"; import DownloadButton from "./DownloadButton"; import type { TTableExportFormat } from "@/services/api/studies/raw/types"; +type ExportFormat = TTableExportFormat | "raw"; + export interface DownloadMatrixButtonProps { studyId: StudyMetadata["id"]; path: string; @@ -30,7 +32,7 @@ function DownloadMatrixButton(props: DownloadMatrixButtonProps) { const { t } = useTranslation(); const { studyId, path, disabled, label = t("global.export") } = props; - const options: Array<{ label: string; value: TTableExportFormat }> = [ + const options: Array<{ label: string; value: ExportFormat }> = [ { label: "CSV", value: "csv" }, { label: `CSV (${t("global.semicolon").toLowerCase()})`, @@ -38,20 +40,26 @@ function DownloadMatrixButton(props: DownloadMatrixButtonProps) { }, { label: "TSV", value: "tsv" }, { label: "XLSX", value: "xlsx" }, + { label: `${t("global.rawFile")}`, value: "raw" }, ]; //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleDownload = async (format: TTableExportFormat) => { + const handleDownload = async (format: ExportFormat) => { if (!path) { return; } + if (format === "raw") { + const file = await getRawFile({ studyId, path }); + return downloadFile(file, file.name); + } + const isXlsx = format === "xlsx"; - const res = await downloadMatrix({ + const matrixFile = await getMatrixFile({ studyId, path, format, @@ -62,7 +70,7 @@ function DownloadMatrixButton(props: DownloadMatrixButtonProps) { const extension = format === "csv (semicolon)" ? "csv" : format; return downloadFile( - res, + matrixFile, `matrix_${studyId}_${path.replace("/", "_")}.${extension}`, ); }; diff --git a/webapp/src/components/common/buttons/UploadFileButton.tsx b/webapp/src/components/common/buttons/UploadFileButton.tsx index cc16594be0..872b05b01a 100644 --- a/webapp/src/components/common/buttons/UploadFileButton.tsx +++ b/webapp/src/components/common/buttons/UploadFileButton.tsx @@ -21,7 +21,7 @@ import { toError } from "../../../utils/fnUtils"; import { Accept, useDropzone } from "react-dropzone"; import { StudyMetadata } from "../../../common/types"; import { useSnackbar } from "notistack"; -import { importFile } from "../../../services/api/studies/raw"; +import { uploadFile } from "../../../services/api/studies/raw"; type ValidateResult = boolean | null | undefined; type Validate = (file: File) => ValidateResult | Promise; @@ -89,7 +89,7 @@ function UploadFileButton(props: UploadFileButtonProps) { const filePath = typeof path === "function" ? path(fileToUpload) : path; - await importFile({ + await uploadFile({ studyId, path: filePath, file: fileToUpload, diff --git a/webapp/src/services/api/studies/raw/index.ts b/webapp/src/services/api/studies/raw/index.ts index 85524560d5..663877aafc 100644 --- a/webapp/src/services/api/studies/raw/index.ts +++ b/webapp/src/services/api/studies/raw/index.ts @@ -15,28 +15,55 @@ import client from "../../client"; import type { DeleteFileParams, - DownloadMatrixParams, - ImportFileParams, + GetMatrixFileParams, + GetRawFileParams, + UploadFileParams, } from "./types"; -export async function downloadMatrix(params: DownloadMatrixParams) { +/** + * Gets a matrix file from a study's raw files. + * + * @param params - Parameters for getting the matrix + * @param params.studyId - Unique identifier of the study + * @param params.path - Path to the matrix file + * @param params.format - Optional. Export format for the matrix + * @param params.header - Optional. Whether to include headers + * @param params.index - Optional. Whether to include indices + * @returns Promise containing the matrix data as a Blob + */ +export async function getMatrixFile(params: GetMatrixFileParams) { const { studyId, ...queryParams } = params; - const url = `/v1/studies/${studyId}/raw/download`; - - const { data } = await client.get(url, { - params: queryParams, - responseType: "blob", - }); + const { data } = await client.get( + `/v1/studies/${studyId}/raw/download`, + { + params: queryParams, + responseType: "blob", + }, + ); return data; } -export async function importFile(params: ImportFileParams) { +/** + * Uploads a file to a study's raw storage, creating or updating it based on existence. + * + * !Warning: This endpoint currently uses a non-standard REST structure (/raw) which + * may lead to confusion. It handles both create and update operations through PUT, + * while directory creation is managed through a separate flag. + * + * @param params - Parameters for the file upload + * @param params.studyId - Unique identifier of the study + * @param params.path - Destination path for the file + * @param params.file - File content to upload + * @param params.createMissing - Optional. Whether to create missing parent directories + * @param params.onUploadProgress - Optional. Callback for upload progress updates + * @returns Promise that resolves when the upload is complete + */ +export async function uploadFile(params: UploadFileParams) { const { studyId, file, onUploadProgress, ...queryParams } = params; - const url = `/v1/studies/${studyId}/raw`; const body = { file }; - await client.putForm(url, body, { + await client.putForm(`/v1/studies/${studyId}/raw`, body, { params: { ...queryParams, create_missing: queryParams.createMissing, @@ -45,9 +72,54 @@ export async function importFile(params: ImportFileParams) { }); } +/** + * Deletes a raw file from a study. + * + * @param params - Parameters for deleting the file + * @param params.studyId - Unique identifier of the study + * @param params.path - Path to the file to delete + * @returns Promise that resolves when the deletion is complete + */ export async function deleteFile(params: DeleteFileParams) { const { studyId, path } = params; - const url = `/v1/studies/${studyId}/raw`; + await client.delete(`/v1/studies/${studyId}/raw`, { params: { path } }); +} + +/** + * Gets an original raw file from a study with its metadata. + * + * @param params - Parameters for getting the raw file and name + * @param params.studyId - Unique identifier of the study + * @param params.path - Path to the file within the study + * @returns Promise containing the file data and metadata + */ +export async function getRawFile(params: GetRawFileParams) { + const { studyId, path } = params; - await client.delete(url, { params: { path } }); + const { data, headers } = await client.get( + `/v1/studies/${studyId}/raw/original-file`, + { + params: { + path, + }, + responseType: "blob", + }, + ); + + // Get the original file name from the response Headers + const contentDisposition = headers["content-disposition"]; + let filename = path.split("/").pop() || "file"; // fallback filename + + if (contentDisposition) { + const matches = /filename=([^;]+)/.exec(contentDisposition); + + if (matches?.[1]) { + filename = matches[1].replace(/"/g, "").trim(); + } + } + + return new File([data], filename, { + type: data.type, // Preserve the MIME type from the Blob + lastModified: new Date().getTime(), + }); } diff --git a/webapp/src/services/api/studies/raw/types.ts b/webapp/src/services/api/studies/raw/types.ts index 937fd84119..c74958c376 100644 --- a/webapp/src/services/api/studies/raw/types.ts +++ b/webapp/src/services/api/studies/raw/types.ts @@ -17,9 +17,10 @@ import type { StudyMetadata } from "../../../../common/types"; import { O } from "ts-toolbelt"; import { TableExportFormat } from "./constants"; +// Available export formats for matrix tables export type TTableExportFormat = O.UnionOf; -export interface DownloadMatrixParams { +export interface GetMatrixFileParams { studyId: StudyMetadata["id"]; path: string; format?: TTableExportFormat; @@ -27,10 +28,11 @@ export interface DownloadMatrixParams { index?: boolean; } -export interface ImportFileParams { +export interface UploadFileParams { studyId: StudyMetadata["id"]; path: string; file: File; + // Flag to indicate whether to create file and directories if missing createMissing?: boolean; onUploadProgress?: AxiosRequestConfig["onUploadProgress"]; } @@ -39,3 +41,8 @@ export interface DeleteFileParams { studyId: StudyMetadata["id"]; path: string; } + +export interface GetRawFileParams { + studyId: string; + path: string; +} diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index ba4fe1e0f4..71ef9f3906 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -16,7 +16,8 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; -const SERVER_URL = "http://localhost:8080"; +//! Keep '0.0.0.0', because 'localhost' may not working on Mac +const SERVER_URL = "http://0.0.0.0:8080"; // https://vitejs.dev/config/ export default defineConfig(({ mode }) => {