diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 952091b0d4..df948d3920 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -251,7 +251,7 @@ "study.district": "District", "study.bindingconstraints": "Binding Constraints", "study.debug": "Debug", - "study.debug.file.image": "Image file", + "study.debug.file.unsupported": "Unsupported file type", "study.debug.file.deleteConfirm.title": "Delete File?", "study.debug.file.deleteConfirm.message": "Are you sure you want to delete the file?", "study.debug.folder.empty": "Folder is empty", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 81ffca6679..3dacacb6e0 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -251,8 +251,8 @@ "study.district": "District", "study.bindingconstraints": "Contraintes Couplantes", "study.debug": "Debug", - "study.debug.file.image": "Fichier image", "study.debug.folder.empty": "Le dossier est vide", + "study.debug.file.unsupported": "Type de fichier non supporté", "study.debug.file.deleteConfirm.title": "Supprimer le fichier ?", "study.debug.file.deleteConfirm.message": "Êtes-vous sûr de vouloir supprimer le fichier ?", "study.debug.folder.upload.replaceFileConfirm.title": "Remplacer le fichier ?", diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Image.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Image.tsx deleted file mode 100644 index 23aa34e993..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Image.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { useTranslation } from "react-i18next"; -import EmptyView from "../../../../../common/page/SimpleContent"; -import ImageIcon from "@mui/icons-material/Image"; -import { Filename, Flex, Menubar } from "./styles"; -import type { DataCompProps } from "../utils"; - -function Image({ filename }: DataCompProps) { - const { t } = useTranslation(); - - return ( - - - {filename} - - - - ); -} - -export default Image; diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx index a140fe71e8..26a211251d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx @@ -15,7 +15,7 @@ import Matrix from "../../../../../common/Matrix"; import type { DataCompProps } from "../utils"; -function DebugMatrix({ studyId, filename, filePath, canEdit }: DataCompProps) { +function DebugMatrix({ filename, filePath, 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 7380186bc3..1f7a7f4909 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx @@ -26,11 +26,13 @@ import plaintext from "react-syntax-highlighter/dist/esm/languages/hljs/plaintex import ini from "react-syntax-highlighter/dist/esm/languages/hljs/ini"; import properties from "react-syntax-highlighter/dist/esm/languages/hljs/properties"; import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs"; -import type { DataCompProps } from "../utils"; +import { isEmptyContent, parseContent, type DataCompProps } from "../utils"; import DownloadButton from "../../../../../common/buttons/DownloadButton"; import { downloadFile } from "../../../../../../utils/fileUtils"; 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"; SyntaxHighlighter.registerLanguage("xml", xml); SyntaxHighlighter.registerLanguage("plaintext", plaintext); @@ -63,15 +65,24 @@ function getSyntaxProps(data: string | string[]): SyntaxHighlighterProps { }; } -function Text({ studyId, filePath, filename, canEdit }: DataCompProps) { +function Text({ + studyId, + filePath, + filename, + fileType, + canEdit, +}: DataCompProps) { const { t } = useTranslation(); const theme = useTheme(); const res = usePromiseWithSnackbarError( - () => getStudyData(studyId, filePath), + () => + getStudyData(studyId, filePath).then((text) => + parseContent(text, { filePath, fileType }), + ), { errorMessage: t("studies.error.retrieveData"), - deps: [studyId, filePath], + deps: [studyId, filePath, fileType], }, ); @@ -113,22 +124,34 @@ function Text({ studyId, filePath, filename, canEdit }: DataCompProps) { )} - - + ) : ( + - + > + + + )} )} /> diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx new file mode 100644 index 0000000000..e4bcfccc5c --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx @@ -0,0 +1,73 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { useTranslation } from "react-i18next"; +import EmptyView from "../../../../../common/page/SimpleContent"; +import BlockIcon from "@mui/icons-material/Block"; +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"; + +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(); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + + {filename} + {canEdit && ( + + )} + + + + + ); +} + +export default Unsupported; diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/index.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/index.tsx index f387b7ef9c..9d09c246a5 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/index.tsx @@ -12,15 +12,21 @@ * This file is part of the Antares project. */ +import { ComponentType } from "react"; import Text from "./Text"; -import Image from "./Image"; -import Json from "./Json"; +import Unsupported from "./Unsupported"; import Matrix from "./Matrix"; import Folder from "./Folder"; -import { canEditFile, type FileInfo, type FileType } from "../utils"; +import { + canEditFile, + getEffectiveFileType, + type FileInfo, + type FileType, +} from "../utils"; import type { DataCompProps } from "../utils"; import ViewWrapper from "../../../../../common/page/ViewWrapper"; import type { StudyMetadata } from "../../../../../../common/types"; +import Json from "./Json"; interface Props extends FileInfo { study: StudyMetadata; @@ -28,20 +34,16 @@ interface Props extends FileInfo { reloadTreeData: () => void; } -type DataComponent = React.ComponentType; - -const componentByFileType: Record = { +const componentByFileType: Record> = { matrix: Matrix, json: Json, text: Text, - image: Image, + unsupported: Unsupported, folder: Folder, } as const; -function Data(props: Props) { - const { study, setSelectedFile, reloadTreeData, ...fileInfo } = props; - const { fileType, filePath } = fileInfo; - const canEdit = canEditFile(study, filePath); +function Data({ study, setSelectedFile, reloadTreeData, ...fileInfo }: Props) { + const fileType = getEffectiveFileType(fileInfo.filePath, fileInfo.fileType); const DataViewer = componentByFileType[fileType]; return ( @@ -49,7 +51,7 @@ function Data(props: Props) { diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts index b1d8653f07..d546a1e4dc 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts @@ -14,12 +14,13 @@ import DataObjectIcon from "@mui/icons-material/DataObject"; import TextSnippetIcon from "@mui/icons-material/TextSnippet"; -import ImageIcon from "@mui/icons-material/Image"; +import BlockIcon from "@mui/icons-material/Block"; import FolderIcon from "@mui/icons-material/Folder"; import DatasetIcon from "@mui/icons-material/Dataset"; import { SvgIconComponent } from "@mui/icons-material"; import * as RA from "ramda-adjunct"; import type { StudyMetadata } from "../../../../../common/types"; +import { MatrixDataDTO } from "@/components/common/Matrix/shared/types"; //////////////////////////////////////////////////////////////// // Types @@ -33,7 +34,7 @@ export interface TreeFolder { export type TreeData = TreeFolder | TreeFile; -export type FileType = "json" | "matrix" | "text" | "image" | "folder"; +export type FileType = "json" | "matrix" | "text" | "folder" | "unsupported"; export interface FileInfo { fileType: FileType; @@ -49,19 +50,43 @@ export interface DataCompProps extends FileInfo { reloadTreeData: () => void; } +interface ContentParsingOptions { + filePath: string; + fileType: string; +} + //////////////////////////////////////////////////////////////// -// File Info +// Constants //////////////////////////////////////////////////////////////// +const URL_SCHEMES = { + MATRIX: ["matrix://", "matrixfile://"], + JSON: "json://", + FILE: "file://", +} as const; + +const SUPPORTED_EXTENSIONS = [ + ".txt", + ".log", + ".csv", + ".tsv", + ".ini", + ".yml", +] as const; + // Maps file types to their corresponding icon components. const iconByFileType: Record = { matrix: DatasetIcon, json: DataObjectIcon, text: TextSnippetIcon, - image: ImageIcon, folder: FolderIcon, + unsupported: BlockIcon, } as const; +//////////////////////////////////////////////////////////////// +// Functions +//////////////////////////////////////////////////////////////// + /** * Gets the icon component for a given file type. * @@ -83,26 +108,33 @@ export function isFolder(treeData: TreeData): treeData is TreeFolder { * @returns The corresponding file type. */ export function getFileType(treeData: TreeData): FileType { + if (isFolder(treeData)) { + return "folder"; + } + if (typeof treeData === "string") { - if ( - treeData.startsWith("matrix://") || - treeData.startsWith("matrixfile://") - ) { + if (URL_SCHEMES.MATRIX.some((scheme) => treeData.startsWith(scheme))) { return "matrix"; } - if (treeData.startsWith("json://") || treeData.endsWith(".json")) { + + if (treeData.startsWith(URL_SCHEMES.JSON)) { return "json"; } - if (treeData.startsWith("file://") && treeData.endsWith(".ico")) { - return "image"; - } + + // Handle regular files with file:// prefix + // All files except matrices and json-formatted content use this prefix + // We filter to only allow extensions that can be properly displayed (.txt, .log, .csv, .tsv, .ini) + // Other extensions (like .RDS or .xlsx) are marked as unsupported since they can't be shown in the UI + return treeData.startsWith(URL_SCHEMES.FILE) && + SUPPORTED_EXTENSIONS.some((ext) => + treeData.toLowerCase().endsWith(ext.toLowerCase()), + ) + ? "text" + : "unsupported"; } - return isFolder(treeData) ? "folder" : "text"; -} -//////////////////////////////////////////////////////////////// -// Rights -//////////////////////////////////////////////////////////////// + return "text"; +} /** * Checks if a study's file can be edited. @@ -115,7 +147,138 @@ export function canEditFile(study: StudyMetadata, filePath: string): boolean { return ( !study.archived && (filePath === "user" || filePath.startsWith("user/")) && - // To remove when Xpansion tool configuration will be moved to "input/expansion" directory + // TODO: remove when Xpansion tool configuration will be moved to "input/expansion" directory !(filePath === "user/expansion" || filePath.startsWith("user/expansion/")) ); } + +/** + * Checks if a file path is within the output folder + * + * @param path - The file path to check + * @returns Whether the path is in the output folder + */ +export function isInOutputFolder(path: string): boolean { + return path.startsWith("output/"); +} + +/** + * Determines if .txt files content is empty + * + * @param text - Content of .txt to check + * @returns boolean indicating if content is effectively empty + */ +export function isEmptyContent(text: string | string[]): boolean { + if (Array.isArray(text)) { + return ( + !text || text.every((line) => typeof line === "string" && !line.trim()) + ); + } + + return typeof text === "string" && !text.trim(); +} + +/** + * !Temporary workaround for matrix data display in output folders. + * + * Context: + * In output folders, matrix data can be returned by the API in two different formats: + * 1. As an unparsed JSON string containing the matrix data + * 2. As an already parsed MatrixDataDTO object + * + * This inconsistency exists because the API's matrix parsing behavior differs between + * output folders and other locations. Additionally, there's a UI requirement to display + * matrices from output folders as raw text rather than formatted tables. + * + * The workaround consists of three functions: + * 1. getEffectiveFileType: Forces matrix files in output folders to use text display + * 2. parseResponse: Handles the dual format nature of the API response + * 3. parseContent: Orchestrates the parsing logic based on file location and type + * + * TODO: This temporary solution will be removed once: + * - The API provides consistent matrix parsing across all folders + * - UI requirements for matrix display are finalized + */ + +/** + * Forces matrix files in output folders to be displayed as text + * This is necessary because matrices in output folders need to be shown + * in their raw format rather than as formatted tables + * + * @param filePath - Path to the file being displayed + * @param originalType - Original file type as determined by the system + * @returns Modified file type (forces 'text' for matrices in output folders) + */ +export function getEffectiveFileType( + filePath: string, + originalType: FileType, +): FileType { + if (isInOutputFolder(filePath) && originalType === "matrix") { + return "text"; + } + + return originalType; +} + +/** + * Formats a 2D number array into a string representation + * + * @param matrix - 2D array of numbers to format + * @returns String representation of the matrix + */ +function formatMatrixToString(matrix: number[][]): string { + return matrix + .map((row) => row.map((val) => val.toString()).join("\t")) + .join("\n"); +} + +/** + * Handles parsing of matrix data from the API, dealing with both + * string and pre-parsed object formats + * + * @param res - API response containing matrix data (either MatrixDataDTO or string) + * @returns Extracted matrix data as a string + */ +function parseResponse(res: string | MatrixDataDTO): string { + if (typeof res === "object") { + // Handle case where API has already parsed the JSON into MatrixDataDTO + return formatMatrixToString(res.data); + } + + try { + // Handle case where API returns unparsed JSON string + // Replace special numeric values with their string representations + const sanitizedJson = res + .replace(/NaN/g, '"NaN"') + .replace(/Infinity/g, '"Infinity"'); + + const parsed = JSON.parse(sanitizedJson); + return formatMatrixToString(parsed.data); + } catch (e) { + // If JSON parsing fails, assume it's plain text + return res; + } +} + +/** + * Main content parsing function that orchestrates the matrix display workaround + * + * @param content - Raw content from the API (either string or parsed object) + * @param options - Configuration options including file path and type + * @returns Processed content ready for display + */ +export function parseContent( + content: string, + options: ContentParsingOptions, +): string { + const { filePath, fileType } = options; + + if (isInOutputFolder(filePath) && fileType === "matrix") { + // Apply special handling for matrices in output folders + return parseResponse(content); + } + + return content || ""; +} + +// !End of Matrix Display Workaround