From 3ea0fae26114fbe0dde1b72474c9be941b33b563 Mon Sep 17 00:00:00 2001
From: Samir Kamal <1954121+skamril@users.noreply.github.com>
Date: Fri, 6 Sep 2024 12:30:05 +0200
Subject: [PATCH] feat(ui-debug): add Folder component
---
webapp/public/locales/en/main.json | 1 +
webapp/public/locales/fr/main.json | 1 +
.../Singlestudy/explore/Debug/Data/Folder.tsx | 68 +++++++++++++++++++
.../Singlestudy/explore/Debug/Data/Matrix.tsx | 4 +-
.../Singlestudy/explore/Debug/Data/index.tsx | 10 ++-
.../Singlestudy/explore/Debug/DebugContext.ts | 2 +-
.../explore/Debug/Tree/FileTreeItem.tsx | 20 +++---
.../Singlestudy/explore/Debug/Tree/index.tsx | 52 ++++++++++++--
.../App/Singlestudy/explore/Debug/index.tsx | 45 ++++++++++--
.../App/Singlestudy/explore/Debug/utils.ts | 20 +++---
webapp/src/theme.ts | 7 ++
11 files changed, 194 insertions(+), 36 deletions(-)
create mode 100644 webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx
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: {