From ca0582b525fa512ed839464840f8e529ff949d65 Mon Sep 17 00:00:00 2001 From: Hatim Dinia <33469289+hdinia@users.noreply.github.com> Date: Fri, 20 Dec 2024 17:24:29 +0100 Subject: [PATCH] feat(ui-debug): add download in original file format (#2277) --- webapp/public/locales/en/main.json | 1 + webapp/public/locales/fr/main.json | 1 + .../Singlestudy/explore/Debug/Data/Json.tsx | 31 ++---- .../Singlestudy/explore/Debug/Data/Text.tsx | 17 ++- .../explore/Debug/Data/Unsupported.tsx | 30 +----- .../common/Matrix/hooks/useMatrix/index.ts | 5 +- .../Matrix/hooks/useMatrix/useMatrix.test.tsx | 4 +- .../common/buttons/DownloadMatrixButton.tsx | 18 +++- .../common/buttons/UploadFileButton.tsx | 4 +- webapp/src/services/api/studies/raw/index.ts | 100 +++++++++++++++--- webapp/src/services/api/studies/raw/types.ts | 11 +- 11 files changed, 139 insertions(+), 83 deletions(-) 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/common/Matrix/hooks/useMatrix/index.ts b/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts index 6558e764e8..e89f99be12 100644 --- a/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts +++ b/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts @@ -39,7 +39,7 @@ import { } from "../../shared/utils"; import useUndo from "use-undo"; import { GridCellKind } from "@glideapps/glide-data-grid"; -import { importFile } from "../../../../../services/api/studies/raw"; +import { uploadFile } from "../../../../../services/api/studies/raw"; import { fetchMatrixFn } from "../../../../App/Singlestudy/explore/Modelization/Areas/Hydro/utils"; import usePrompt from "../../../../../hooks/usePrompt"; import { Aggregate, Column, Operation } from "../../shared/constants"; @@ -251,7 +251,8 @@ export function useMatrix( const handleUpload = async (file: File) => { 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; +}