From 55263f3fb8690965918b76362d83d435cb75e37c Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Mon, 16 Sep 2024 11:17:31 +0200 Subject: [PATCH] feat(ui-debug): add import and export buttons on data views * add `path` URL parameter * display filename --- .../Singlestudy/explore/Debug/Data/Folder.tsx | 143 ++++++++++++------ .../Singlestudy/explore/Debug/Data/Image.tsx | 13 +- .../Singlestudy/explore/Debug/Data/Json.tsx | 23 ++- .../Singlestudy/explore/Debug/Data/Text.tsx | 23 ++- .../Singlestudy/explore/Debug/Data/index.tsx | 8 +- .../Singlestudy/explore/Debug/Data/styles.ts | 13 -- .../Singlestudy/explore/Debug/Data/styles.tsx | 23 +++ .../Singlestudy/explore/Debug/Tree/index.tsx | 31 ++-- .../App/Singlestudy/explore/Debug/index.tsx | 45 +++++- .../App/Singlestudy/explore/Debug/utils.ts | 21 +++ 10 files changed, 249 insertions(+), 94 deletions(-) delete mode 100644 webapp/src/components/App/Singlestudy/explore/Debug/Data/styles.ts create mode 100644 webapp/src/components/App/Singlestudy/explore/Debug/Data/styles.tsx diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx index e52a403c90..33ac0465d6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx @@ -16,56 +16,109 @@ import { import { Fragment } from "react"; import EmptyView from "../../../../../common/page/SimpleContent"; import { useTranslation } from "react-i18next"; +import { Filename, Menubar } from "./styles"; +import UploadFileButton from "../../../../../common/buttons/UploadFileButton"; +import ConfirmationDialog from "../../../../../common/dialogs/ConfirmationDialog"; +import useConfirm from "../../../../../../hooks/useConfirm"; + +function Folder(props: DataCompProps) { + const { + filename, + filePath, + treeData, + enableImport, + setSelectedFile, + reloadTreeData, + studyId, + } = props; -function Folder({ - filename, - filePath, - treeData, - setSelectedFile, -}: DataCompProps) { const { t } = useTranslation(); - const list = Object.entries(treeData as TreeFolder); + const replaceFile = useConfirm(); + const treeFolder = treeData as TreeFolder; + const list = Object.entries(treeFolder); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleValidateUpload = (file: File) => { + if (treeFolder[file.name]) { + return replaceFile.showConfirm(); + } + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// return ( - {filename}} - sx={{ - height: 1, - overflow: "auto", - }} - dense - > - {list.length > 0 ? ( - list.map(([filename, data], index, arr) => { - const fileType = getFileType(data); - const Icon = getFileIcon(fileType); - const isLast = index === arr.length - 1; + <> + + + {filename} + {enableImport && ( + `${filePath}/${file.name}`} + onUploadSuccessful={reloadTreeData} + validate={handleValidateUpload} + /> + )} + + + } + sx={{ + height: 1, + overflow: "auto", + }} + 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 && } - - ); - }) - ) : ( - - )} - + return ( + + + setSelectedFile({ + fileType, + filename, + filePath: `${filePath}/${filename}`, + treeData: data, + }) + } + > + + + + + + {!isLast && } + + ); + }) + ) : ( + + )} + + + Another file with the same name already exists. Replacing it will + overwrite its content. + + ); } diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Image.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Image.tsx index 8ef8ea39c1..87fcdbb3c8 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Image.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Image.tsx @@ -1,11 +1,20 @@ 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() { +function Image({ filename }: DataCompProps) { const { t } = useTranslation(); - return ; + return ( + + + {filename} + + + + ); } export default Image; diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx index 6863fd92b1..0bd555f103 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Json.tsx @@ -8,9 +8,10 @@ import type { DataCompProps } from "../utils"; import DownloadButton from "../../../../../common/buttons/DownloadButton"; import { downloadFile } from "../../../../../../utils/fileUtils"; import { useEffect, useState } from "react"; -import { Flex, Menubar } from "./styles"; +import { Filename, Flex, Menubar } from "./styles"; +import UploadFileButton from "../../../../../common/buttons/UploadFileButton"; -function Json({ filePath, filename, studyId }: DataCompProps) { +function Json({ filePath, filename, studyId, enableImport }: DataCompProps) { const [t] = useTranslation(); const { enqueueSnackbar } = useSnackbar(); const [currentJson, setCurrentJson] = useState(); @@ -45,10 +46,17 @@ function Json({ filePath, filename, studyId }: DataCompProps) { const handleDownload = () => { if (currentJson !== undefined) { - downloadFile(JSON.stringify(currentJson, null, 2), `${filename}.json`); + downloadFile( + JSON.stringify(currentJson, null, 2), + filename.endsWith(".json") ? filename : `${filename}.json`, + ); } }; + const handleUploadSuccessful = () => { + res.reload(); + }; + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -59,6 +67,15 @@ function Json({ filePath, filename, studyId }: DataCompProps) { ifResolved={(json) => ( + {filename} + {enableImport && ( + + )} { if (res.data) { - downloadFile(res.data, `${filename}.txt`); + downloadFile( + res.data, + filename.endsWith(".txt") ? filename : `${filename}.txt`, + ); } }; + const handleUploadSuccessful = () => { + res.reload(); + }; + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -80,6 +88,15 @@ function Text({ studyId, filePath, filename }: DataCompProps) { ifResolved={(text) => ( + {filename} + {enableImport && ( + + )} 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 8271049f04..6a46036da6 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/index.tsx @@ -10,6 +10,7 @@ import ViewWrapper from "../../../../../common/page/ViewWrapper"; interface Props extends FileInfo { studyId: string; setSelectedFile: (file: FileInfo) => void; + reloadTreeData: () => void; } type DataComponent = React.ComponentType; @@ -22,13 +23,15 @@ const componentByFileType: Record = { folder: Folder, } as const; -function Data({ studyId, setSelectedFile, ...fileInfo }: Props) { +function Data(props: Props) { + const { studyId, setSelectedFile, reloadTreeData, ...fileInfo } = props; const { fileType, filePath } = fileInfo; + const DataViewer = componentByFileType[fileType]; + const enableImport = (filePath === "user" || filePath.startsWith("user/")) && // To remove when Xpansion tool configuration will be moved to "input/expansion" directory !(filePath === "user/expansion" || filePath.startsWith("user/expansion/")); - const DataViewer = componentByFileType[fileType]; return ( @@ -37,6 +40,7 @@ function Data({ studyId, setSelectedFile, ...fileInfo }: Props) { studyId={studyId} enableImport={enableImport} setSelectedFile={setSelectedFile} + reloadTreeData={reloadTreeData} /> ); diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/styles.ts b/webapp/src/components/App/Singlestudy/explore/Debug/Data/styles.ts deleted file mode 100644 index 416233c03c..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/styles.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { styled } from "@mui/material"; - -export const Flex = styled("div")(({ theme }) => ({ - height: "100%", - display: "flex", - flexDirection: "column", - gap: theme.spacing(1), -})); - -export const Menubar = styled("div")({ - display: "flex", - justifyContent: "flex-end", -}); diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/styles.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/styles.tsx new file mode 100644 index 0000000000..8969d1ae8a --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/styles.tsx @@ -0,0 +1,23 @@ +import { styled } from "@mui/material"; + +export const Flex = styled("div")(({ theme }) => ({ + height: "100%", + display: "flex", + flexDirection: "column", + gap: theme.spacing(1), +})); + +export const Menubar = styled("div")(({ theme }) => ({ + display: "flex", + justifyContent: "flex-end", + alignItems: "center", + gap: theme.spacing(1), +})); + +export const Filename = styled((props: { children?: string }) => ( +
+))({ + flex: 1, + overflow: "hidden", + textOverflow: "ellipsis", +}); 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 c7ad5146cb..4bec856a77 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Tree/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Tree/index.tsx @@ -1,24 +1,17 @@ import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView"; import FileTreeItem from "./FileTreeItem"; -import type { TreeFolder } from "../utils"; -import { useState } from "react"; +import { getParentPaths, type TreeFolder } from "../utils"; interface Props { data: TreeFolder; - // `selectedItems` must not be undefined to make `SimpleTreeView` controlled - selectedItemId: string | null; + // `currentPath` must not be undefined to make `SimpleTreeView` controlled + currentPath: string | null; + expandedItems: string[]; + setExpandedItems: React.Dispatch>; } -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([]); +function Tree(props: Props) { + const { data, currentPath, expandedItems, setExpandedItems } = props; //////////////////////////////////////////////////////////////// // Event Handlers @@ -36,14 +29,16 @@ function Tree({ data, selectedItemId }: Props) { //////////////////////////////////////////////////////////////// // `SimpleTreeView` must be controlled because selected item can be changed manually - // by `Folder` component + // by `Folder` component, or by the `path` URL parameter at view mount. + // The use of `selectedItems` and `expandedItems` make the component controlled. return ( (); const [selectedFile, setSelectedFile] = useState(null); + // Allow to keep expanded items when the tree is reloaded with `reloadTreeData` + const [expandedItems, setExpandedItems] = useState([]); + const [searchParams, setSearchParams] = useSearchParams(); + const pathInUrl = searchParams.get("path"); const res = usePromiseWithSnackbarError( async () => { @@ -45,21 +49,43 @@ function Debug() { useUpdateEffect(() => { const firstChildName = Object.keys(res.data ?? {})[0]; - const treeData = R.path([firstChildName], res.data); + const firstChildTreeData = R.path([firstChildName], res.data); - if (treeData) { - const fileInfo = { - fileType: getFileType(treeData), + const pathInUrlParts = pathInUrl?.split("/"); + const urlPathTreeData = pathInUrlParts + ? R.path(pathInUrlParts, res.data) + : null; + + let fileInfo: FileInfo | null = null; + + if (urlPathTreeData) { + fileInfo = { + fileType: getFileType(urlPathTreeData), + treeData: urlPathTreeData, + filename: R.last(pathInUrlParts!)!, + filePath: pathInUrl!, + }; + } else if (firstChildTreeData) { + fileInfo = { + fileType: getFileType(firstChildTreeData), + treeData: firstChildTreeData, filename: firstChildName, filePath: firstChildName, - treeData, }; + } + if (fileInfo) { setSelectedFile(fileInfo); } else { setSelectedFile(null); } - }, [res?.data]); + }, [res.data, pathInUrl]); + + useUpdateEffect(() => { + if (selectedFile?.filePath !== pathInUrl) { + setSearchParams({ path: selectedFile?.filePath || "" }); + } + }, [selectedFile?.filePath]); //////////////////////////////////////////////////////////////// // JSX @@ -74,7 +100,9 @@ function Debug() { )} @@ -85,6 +113,7 @@ function Debug() { )} diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts index 50d3ae73cf..86be694f83 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts +++ b/webapp/src/components/App/Singlestudy/explore/Debug/utils.ts @@ -31,6 +31,7 @@ export interface DataCompProps extends FileInfo { studyId: string; enableImport: boolean; setSelectedFile: (file: FileInfo) => void; + reloadTreeData: () => void; } //////////////////////////////////////////////////////////////// @@ -83,3 +84,23 @@ export function getFileType(treeData: TreeData): FileType { } return isFolder(treeData) ? "folder" : "text"; } + +//////////////////////////////////////////////////////////////// +// Tree +//////////////////////////////////////////////////////////////// + +/** + * Get parent paths of a given path. + * + * @example + * getParentPaths("a/b/c/d"); // Returns: ["a", "a/b", "a/b/c"] + * + * @param path - The path from which to get the parent paths. + * @returns The parent paths. + */ +export function getParentPaths(path: string) { + return path + .split("/") + .slice(0, -1) // Remove the last item + .map((_, index, arr) => arr.slice(0, index + 1).join("/")); +}