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 f2ac0a31e1..667c073204 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx @@ -26,7 +26,7 @@ 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"; @@ -44,16 +44,6 @@ const logsRegex = /^(\[[^\]]*\]){3}/; // Ex: "EXP : 0" const propertiesRegex = /^[^:]+ : [^:]+/; -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(); -} - function getSyntaxProps(data: string | string[]): SyntaxHighlighterProps { const isArray = Array.isArray(data); const text = isArray ? data.join("\n") : data; @@ -75,7 +65,13 @@ 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(); @@ -123,15 +119,19 @@ function Text({ studyId, filePath, filename, canEdit }: DataCompProps) { onUploadSuccessful={handleUploadSuccessful} /> )} - + - {isEmptyContent(text) ? ( + {isEmptyContent(text) ? ( // TODO remove when files become editable ) : ( - + )} 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 163235ba6f..e4bcfccc5c 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx @@ -23,7 +23,7 @@ import usePromiseWithSnackbarError from "@/hooks/usePromiseWithSnackbarError"; import { getStudyData } from "@/services/api/study"; import { downloadFile } from "@/utils/fileUtils"; -function Unsupported({ studyId, filePath, filename }: DataCompProps) { +function Unsupported({ studyId, filePath, filename, canEdit }: DataCompProps) { const { t } = useTranslation(); const res = usePromiseWithSnackbarError( @@ -56,11 +56,13 @@ function Unsupported({ studyId, filePath, filename }: DataCompProps) { {filename} - + {canEdit && ( + + )} 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 de7e0ca24e..9d09c246a5 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/index.tsx @@ -17,7 +17,12 @@ import Text from "./Text"; 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"; @@ -38,7 +43,8 @@ const componentByFileType: Record> = { } as const; function Data({ study, setSelectedFile, reloadTreeData, ...fileInfo }: Props) { - const DataViewer = componentByFileType[fileInfo.fileType]; + const fileType = getEffectiveFileType(fileInfo.filePath, fileInfo.fileType); + const DataViewer = componentByFileType[fileType]; return ( diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts index f0e9c428b8..bf686a9439 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts @@ -20,6 +20,7 @@ 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 @@ -49,6 +50,11 @@ export interface DataCompProps extends FileInfo { reloadTreeData: () => void; } +interface ContentParsingOptions { + filePath: string; + fileType: string; +} + //////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////// @@ -59,7 +65,14 @@ const URL_SCHEMES = { FILE: "file://", } as const; -const SUPPORTED_EXTENSIONS = [".txt", ".log", ".csv", ".tsv", ".ini"] as const; +const SUPPORTED_EXTENSIONS = [ + ".txt", + ".log", + ".csv", + ".tsv", + ".ini", + ".yml", +] as const; // Maps file types to their corresponding icon components. const iconByFileType: Record = { @@ -108,15 +121,13 @@ export function getFileType(treeData: TreeData): FileType { // 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 - if ( - treeData.startsWith(URL_SCHEMES.FILE) && + return treeData.startsWith(URL_SCHEMES.FILE) && SUPPORTED_EXTENSIONS.some((ext) => treeData.endsWith(ext)) - ) { - return "text"; - } + ? "text" + : "unsupported"; } - return "unsupported"; + return "text"; } /** @@ -130,7 +141,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 isOutputFolder(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 (isOutputFolder(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 (isOutputFolder(filePath) && fileType === "matrix") { + // Apply special handling for matrices in output folders + return parseResponse(content); + } + + return content || ""; +} + +// !End of Matrix Display Workaround