diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index fbf36b7380..311c5f72a6 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -240,6 +240,7 @@ "study.bindingconstraints": "Binding Constraints", "study.debug": "Debug", "study.debug.file.image": "Image file", + "study.debug.folder.empty": "Folder is empty", "study.failtofetchlogs": "Failed to fetch logs", "study.failtokilltask": "Failed to kill task", "study.publicMode": "Public mode", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 46b90928e5..fca4ba978c 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -240,6 +240,7 @@ "study.bindingconstraints": "Contraintes Couplantes", "study.debug": "Debug", "study.debug.file.image": "Fichier image", + "study.debug.folder.empty": "Le dossier est vide", "study.failtofetchlogs": "Échec du chargement des logs", "study.failtokilltask": "Échec de l'annulation de l'étude", "study.publicMode": "Mode public", diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx new file mode 100644 index 0000000000..4d8490713f --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx @@ -0,0 +1,68 @@ +import { + Divider, + List, + ListItemButton, + ListItemIcon, + ListItemText, + ListSubheader, +} from "@mui/material"; +import FolderIcon from "@mui/icons-material/Folder"; +import { + getFileIcon, + getFileType, + type TreeFolder, + type DataCompProps, +} from "../utils"; +import ViewWrapper from "../../../../../common/page/ViewWrapper"; +import { Fragment } from "react"; +import EmptyView from "../../../../../common/page/SimpleContent"; +import { useTranslation } from "react-i18next"; + +function Folder({ + filename, + filePath, + treeData, + setSelectedFile, +}: DataCompProps) { + const { t } = useTranslation(); + const list = Object.entries(treeData as TreeFolder); + + return ( + + {filename}} dense> + {list.length > 0 ? ( + list.map(([filename, data], index, arr) => { + const fileType = getFileType(data); + const Icon = getFileIcon(fileType); + const isLast = index === arr.length - 1; + + return ( + + + setSelectedFile({ + fileType, + filename, + filePath: `${filePath}/${filename}`, + treeData: data, + }) + } + > + + + + + + {!isLast && } + + ); + }) + ) : ( + + )} + + + ); +} + +export default Folder; 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 7b48f04bc6..efcdd7089d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Matrix.tsx @@ -3,9 +3,7 @@ import MatrixInput from "../../../../../common/MatrixInput"; import ViewWrapper from "../../../../../common/page/ViewWrapper"; import type { DataCompProps } from "../utils"; -function Matrix({ studyId, filePath, enableImport }: DataCompProps) { - const filename = filePath.split("/").pop(); - +function Matrix({ studyId, filename, filePath, enableImport }: DataCompProps) { return ( void; } type DataComponent = React.ComponentType; @@ -16,18 +18,20 @@ const componentByFileType: Record = { json: Json, text: Text, image: Image, - folder: ({ filePath }) => filePath, + folder: Folder, } as const; -function Data({ studyId, fileType, filePath }: Props) { +function Data({ studyId, setSelectedFile, ...fileInfo }: Props) { + const { fileType, filePath } = fileInfo; const isUserFolder = filePath.startsWith("/user/"); const DataViewer = componentByFileType[fileType]; return ( ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/DebugContext.ts b/webapp/src/components/App/Singlestudy/explore/Debug/DebugContext.ts index 436baa3560..a179a56980 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/DebugContext.ts +++ b/webapp/src/components/App/Singlestudy/explore/Debug/DebugContext.ts @@ -3,7 +3,7 @@ import type { FileInfo } from "./utils"; import { voidFn } from "../../../../../utils/fnUtils"; const initialDebugContextValue = { - onFileSelect: voidFn<[FileInfo]>, + setSelectedFile: voidFn<[FileInfo]>, reloadTreeData: voidFn, }; diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Tree/FileTreeItem.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Tree/FileTreeItem.tsx index 80a7da9443..0adc476129 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Tree/FileTreeItem.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Tree/FileTreeItem.tsx @@ -6,14 +6,14 @@ import { useContext } from "react"; interface Props { name: string; - content: TreeData; path: string; + treeData: TreeData; } -function FileTreeItem({ name, content, path }: Props) { - const { onFileSelect } = useContext(DebugContext); - const filePath = `${path}/${name}`; - const fileType = getFileType(content); +function FileTreeItem({ name, treeData, path }: Props) { + const { setSelectedFile } = useContext(DebugContext); + const filePath = path ? `${path}/${name}` : name; + const fileType = getFileType(treeData); const FileIcon = getFileIcon(fileType); //////////////////////////////////////////////////////////////// @@ -21,9 +21,7 @@ function FileTreeItem({ name, content, path }: Props) { //////////////////////////////////////////////////////////////// const handleClick = () => { - if (fileType !== "folder") { - onFileSelect({ fileType, filePath }); - } + setSelectedFile({ fileType, filename: name, filePath, treeData }); }; //////////////////////////////////////////////////////////////// @@ -41,13 +39,13 @@ function FileTreeItem({ name, content, path }: Props) { } onClick={handleClick} > - {isFolder(content) && - Object.keys(content).map((childName) => ( + {isFolder(treeData) && + Object.keys(treeData).map((childName) => ( ))} diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Tree/index.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Tree/index.tsx index 917efdec83..c7ad5146cb 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Tree/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Tree/index.tsx @@ -1,16 +1,60 @@ import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView"; import FileTreeItem from "./FileTreeItem"; import type { TreeFolder } from "../utils"; +import { useState } from "react"; interface Props { data: TreeFolder; + // `selectedItems` must not be undefined to make `SimpleTreeView` controlled + selectedItemId: string | null; } -function Tree({ data }: Props) { +function getParentItemIds(itemId: string) { + // "a/b/c/d" -> ["a", "a/b", "a/b/c"] + return itemId + .split("/") + .slice(0, -1) // Remove the last item + .map((_, index, arr) => arr.slice(0, index + 1).join("/")); +} + +function Tree({ data, selectedItemId }: Props) { + const [expandedItems, setExpandedItems] = useState([]); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleExpandedItemsChange = ( + event: React.SyntheticEvent, + itemIds: string[], + ) => { + setExpandedItems(itemIds); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + // `SimpleTreeView` must be controlled because selected item can be changed manually + // by `Folder` component + return ( - - {Object.keys(data).map((key) => ( - + + {Object.keys(data).map((filename) => ( + ))} ); diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/index.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/index.tsx index 97e20f34d3..864895b528 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/index.tsx @@ -9,14 +9,20 @@ import UsePromiseCond from "../../../../common/utils/UsePromiseCond"; import usePromiseWithSnackbarError from "../../../../../hooks/usePromiseWithSnackbarError"; import { getStudyData } from "../../../../../services/api/study"; import DebugContext from "./DebugContext"; -import type { FileInfo, TreeFolder } from "./utils"; +import { + getFileType, + type TreeData, + type FileInfo, + type TreeFolder, +} from "./utils"; import * as R from "ramda"; import SplitView from "../../../../common/SplitView"; +import { useUpdateEffect } from "react-use"; function Debug() { const [t] = useTranslation(); const { study } = useOutletContext<{ study: StudyMetadata }>(); - const [selectedFile, setSelectedFile] = useState(); + const [selectedFile, setSelectedFile] = useState(null); const res = usePromiseWithSnackbarError( async () => { @@ -31,12 +37,30 @@ function Debug() { const contextValue = useMemo( () => ({ - onFileSelect: setSelectedFile, + setSelectedFile, reloadTreeData: res.reload, }), [res.reload], ); + useUpdateEffect(() => { + const firstChildName = Object.keys(res.data ?? {})[0]; + const treeData = R.path([firstChildName], res.data); + + if (treeData) { + const fileInfo = { + fileType: getFileType(treeData), + filename: firstChildName, + filePath: firstChildName, + treeData, + }; + + setSelectedFile(fileInfo); + } else { + setSelectedFile(null); + } + }, [res?.data]); + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -48,12 +72,23 @@ function Debug() { response={res} ifResolved={(data) => ( - + )} /> - {selectedFile && } + + {selectedFile && ( + + )} + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts index cda06034a3..50d3ae73cf 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts @@ -10,13 +10,6 @@ import * as RA from "ramda-adjunct"; // Types //////////////////////////////////////////////////////////////// -export type FileType = "json" | "matrix" | "text" | "image" | "folder"; - -export interface FileInfo { - fileType: FileType; - filePath: string; -} - export type TreeFile = string | string[]; export interface TreeFolder { @@ -25,10 +18,19 @@ export interface TreeFolder { export type TreeData = TreeFolder | TreeFile; -export interface DataCompProps { - studyId: string; +export type FileType = "json" | "matrix" | "text" | "image" | "folder"; + +export interface FileInfo { + fileType: FileType; + filename: string; filePath: string; + treeData: TreeData; +} + +export interface DataCompProps extends FileInfo { + studyId: string; enableImport: boolean; + setSelectedFile: (file: FileInfo) => void; } //////////////////////////////////////////////////////////////// diff --git a/webapp/src/theme.ts b/webapp/src/theme.ts index efbe96392a..2ef92d99df 100644 --- a/webapp/src/theme.ts +++ b/webapp/src/theme.ts @@ -75,6 +75,13 @@ const theme = createTheme({ }, }, }, + MuiListSubheader: { + styleOverrides: { + root: { + backgroundColor: "#222333", + }, + }, + }, MuiAlert: { styleOverrides: { root: {