Skip to content

Commit

Permalink
feat(ui-debug): display output matrices as raw text
Browse files Browse the repository at this point in the history
  • Loading branch information
hdinia committed Dec 13, 2024
1 parent 709190f commit a730983
Show file tree
Hide file tree
Showing 4 changed files with 186 additions and 35 deletions.
39 changes: 20 additions & 19 deletions webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand All @@ -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();

Expand Down Expand Up @@ -123,15 +119,19 @@ function Text({ studyId, filePath, filename, canEdit }: DataCompProps) {
onUploadSuccessful={handleUploadSuccessful}
/>
)}
<DownloadButton
onClick={handleDownload}
disabled={isEmptyContent(text)}
/>
<DownloadButton onClick={handleDownload} />
</Menubar>
{isEmptyContent(text) ? (
{isEmptyContent(text) ? ( // TODO remove when files become editable
<EmptyView icon={GridOffIcon} title={t("study.results.noData")} />
) : (
<Box sx={{ overflow: "auto" }}>
<Box
sx={{
overflow: "auto",
height: 1,
display: "flex",
flexDirection: "column",
}}
>
<SyntaxHighlighter
style={atomOneDark}
lineNumberStyle={{
Expand All @@ -143,8 +143,9 @@ function Text({ studyId, filePath, filename, canEdit }: DataCompProps) {
padding: theme.spacing(2),
borderRadius: theme.shape.borderRadius,
fontSize: theme.typography.body2.fontSize,
flex: 1,
}}
{...getSyntaxProps(text)}
{...getSyntaxProps(parseContent(text, { filePath, fileType }))}
/>
</Box>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -56,11 +56,13 @@ function Unsupported({ studyId, filePath, filename }: DataCompProps) {
<Flex>
<Menubar>
<Filename>{filename}</Filename>
<UploadFileButton
studyId={studyId}
path={filePath}
onUploadSuccessful={handleUploadSuccessful}
/>
{canEdit && (
<UploadFileButton
studyId={studyId}
path={filePath}
onUploadSuccessful={handleUploadSuccessful}
/>
)}
<DownloadButton onClick={handleDownload} />
</Menubar>
<EmptyView icon={BlockIcon} title={t("study.debug.file.unsupported")} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -38,7 +43,8 @@ const componentByFileType: Record<FileType, ComponentType<DataCompProps>> = {
} 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 (
<ViewWrapper>
Expand Down
158 changes: 150 additions & 8 deletions webapp/src/components/App/Singlestudy/explore/Debug/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,6 +50,11 @@ export interface DataCompProps extends FileInfo {
reloadTreeData: () => void;
}

interface ContentParsingOptions {
filePath: string;
fileType: string;
}

////////////////////////////////////////////////////////////////
// Utils
////////////////////////////////////////////////////////////////
Expand All @@ -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<FileType, SvgIconComponent> = {
Expand Down Expand Up @@ -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";
}

/**
Expand All @@ -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

0 comments on commit a730983

Please sign in to comment.