From 7c08c711d4b202e67fb45931ee9d4a1f35f7aea8 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Tue, 27 Apr 2021 16:00:39 +0200 Subject: [PATCH 01/62] Decoupling functions --- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 96 ++++++++++- .../src/Components/Modules/SearchModule.tsx | 49 +++++- .../files-ui/src/Contexts/DriveContext.tsx | 154 ++---------------- 3 files changed, 150 insertions(+), 149 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index f218a238df..eb58348dbc 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -1,6 +1,6 @@ -import React, { useCallback, useEffect, useMemo } from "react" +import React, { useCallback, useEffect, useMemo, useReducer, useState } from "react" import { Crumb, useToaster } from "@chainsafe/common-components" -import { useDrive } from "../../../Contexts/DriveContext" +import { useDrive, FileSystemItem, BucketType } from "../../../Contexts/DriveContext" import { getArrayOfPaths, getPathFromArray } from "../../../Utils/pathUtils" import { IBulkOperations, IFilesBrowserModuleProps, IFilesTableBrowserProps } from "./types" import FilesTableView from "./views/FilesTable.view" @@ -8,6 +8,7 @@ import { CONTENT_TYPES } from "../../../Utils/Constants" import DragAndDrop from "../../../Contexts/DnDContext" import { useQuery } from "../../../Utils/Helpers" import { t } from "@lingui/macro" +import { guessContentType } from "../../../Utils/contentTypeGuesser" const CSFFileBrowser: React.FC = ({ controls = true }: IFilesBrowserModuleProps) => { const { @@ -15,17 +16,95 @@ const CSFFileBrowser: React.FC = ({ controls = true }: downloadFile, renameFile, moveFile, - currentPath, - updateCurrentPath, - pathContents, uploadFiles, uploadsInProgress, - loadingCurrentPath, - bucketType + list } = useDrive() const queryPath = useQuery().get("path") + // const { currentPath } = useParams<{ currentPath: string }>() + const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) + const [pathContents, setPathContents] = useState([]) + const [bucketType, setBucketType] = useState("csf") + + const currentPathReducer = ( + currentPath: string, + action: + | { type: "update"; payload: string } + | { type: "refreshOnSamePath"; payload: string } + ): string => { + switch (action.type) { + case "update": { + return action.payload + } + case "refreshOnSamePath": { + // check user has not navigated to other folder + // using then catch as awaits won't work in reducer + if (action.payload === currentPath) { + refreshContents(currentPath, bucketType, false) + } + return currentPath + } + default: + return currentPath + } + } + const [currentPath, dispatchCurrentPath] = useReducer(currentPathReducer, "/") + + const refreshContents = useCallback( + async ( + path: string, + bucketTypeParam?: BucketType, + showLoading?: boolean + ) => { + try { + showLoading && setLoadingCurrentPath(true) + const newContents = await list({ + path, + source: { + type: bucketTypeParam || bucketType + } + }) + showLoading && setLoadingCurrentPath(false) + + if (newContents) { + // Remove this when the API returns dates + setPathContents( + newContents?.map((fcr) => ({ + ...fcr, + content_type: + fcr.content_type !== "application/octet-stream" + ? fcr.content_type + : guessContentType(fcr.name), + isFolder: + fcr.content_type === "application/chainsafe-files-directory" + })) + ) + } + } catch (error) { + showLoading && setLoadingCurrentPath(false) + } + }, + [bucketType, list] + ) + + // From drive + const setCurrentPath = useCallback((newPath: string, newBucketType?: BucketType, showLoading?: boolean) => { + dispatchCurrentPath({ type: "update", payload: newPath }) + if (newBucketType) { + setBucketType(newBucketType) + } + refreshContents(newPath, newBucketType || bucketType, showLoading) + }, [bucketType, refreshContents]) + + const updateCurrentPath = useCallback((newPath: string, bucketType?: BucketType, showLoading?: boolean) => { + newPath.endsWith("/") + ? setCurrentPath(`${newPath}`, bucketType, showLoading) + : setCurrentPath(`${newPath}/`, bucketType, showLoading) + }, [setCurrentPath]) + // END + useEffect(() => { updateCurrentPath( queryPath || "/", @@ -47,7 +126,8 @@ const CSFFileBrowser: React.FC = ({ controls = true }: path: path, new_path: new_path }) - }, [moveFile]) + await refreshContents(currentPath) + }, [moveFile, refreshContents, currentPath]) // Breadcrumbs/paths const arrayOfPaths = useMemo(() => getArrayOfPaths(currentPath), [currentPath]) diff --git a/packages/files-ui/src/Components/Modules/SearchModule.tsx b/packages/files-ui/src/Components/Modules/SearchModule.tsx index bcd73b090d..86170566e0 100644 --- a/packages/files-ui/src/Components/Modules/SearchModule.tsx +++ b/packages/files-ui/src/Components/Modules/SearchModule.tsx @@ -11,17 +11,23 @@ import { Button, SearchBar, Typography, - useHistory + useHistory, + useToaster } from "@chainsafe/common-components" import { useState } from "react" import clsx from "clsx" import { ROUTE_LINKS } from "../FilesRoutes" -import { useDrive, SearchEntry } from "../../Contexts/DriveContext" +import { useDrive, BucketType, SearchEntry } from "../../Contexts/DriveContext" import { CONTENT_TYPES } from "../../Utils/Constants" import { getParentPathFromFilePath } from "../../Utils/pathUtils" import { t, Trans } from "@lingui/macro" import { CSFTheme } from "../../Themes/types" +interface SearchParams { + bucketType: BucketType + bucketId: string +} + const useStyles = makeStyles( ({ breakpoints, palette, constants, animation, zIndex, shadows }: CSFTheme) => createStyles({ @@ -151,7 +157,44 @@ const SearchModule: React.FC = ({ const [searchQuery, setSearchQuery] = useState("") const [searchResults, setSearchResults] = useState<{results: SearchEntry[]; query: string} | undefined>(undefined) const ref = useRef(null) - const { getSearchResults, currentSearchBucket } = useDrive() + const { listBuckets, searchFiles } = useDrive() + const { addToastMessage } = useToaster() + + const [bucketType] = useState("csf") + + const [currentSearchBucket, setCurrentSearchBucket] = useState() + + const getSearchResults = async (searchString: string) => { + try { + if (!searchString) return [] + let bucketId + if ( + currentSearchBucket && + currentSearchBucket.bucketType === bucketType + ) { + // we have the bucket id + bucketId = currentSearchBucket.bucketId + } else { + // fetch bucket id + const results = await listBuckets(bucketType) + const bucket1 = results[0] + setCurrentSearchBucket({ + bucketType, + bucketId: bucket1.id + }) + bucketId = bucket1.id + } + const results = await searchFiles(bucketId || "", searchString) + return results + } catch (err) { + addToastMessage({ + message: t`There was an error getting search results`, + appearance: "error" + }) + return Promise.reject(err) + } + } + const { redirect } = useHistory() diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index b70b9fcc26..14a8e53a6b 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -5,6 +5,7 @@ import { FilesPathRequest, DirectoryContentResponse, BucketType, + Bucket, SearchEntry } from "@chainsafe/files-api-client" import React, { useCallback, useEffect, useReducer } from "react" @@ -16,7 +17,6 @@ import { downloadsInProgressReducer, uploadsInProgressReducer } from "./DriveReducer" -import { guessContentType } from "../Utils/contentTypeGuesser" import { CancelToken } from "axios" import { t } from "@lingui/macro" import { readFileAsync } from "../Utils/Helpers" @@ -56,10 +56,6 @@ interface GetFileContentParams { path?: string } -interface SearchParams { - bucketType: BucketType - bucketId: string -} type DriveContext = { uploadFiles: (files: File[], path: string) => void @@ -73,18 +69,14 @@ type DriveContext = { downloadFile: (cid: string) => Promise getFileContent: ({ cid, cancelToken, onDownloadProgress, file }: GetFileContentParams) => Promise list: (body: FilesPathRequest) => Promise - currentPath: string - updateCurrentPath: (newPath: string, bucketType?: BucketType, showLoading?: boolean) => void - pathContents: FileSystemItem[] + listBuckets: (bucketType: BucketType) => Promise + searchFiles: (bucketId: string, searchString: string) => Promise uploadsInProgress: UploadProgress[] downloadsInProgress: DownloadProgress[] spaceUsed: number getFolderTree: () => Promise - getFileInfo: (path: string) => Promise + getFileInfo: (path: string) => Promise getSearchResults: (searchString: string) => Promise - currentSearchBucket: SearchParams | undefined - bucketType: BucketType - loadingCurrentPath: boolean secureAccountWithMasterPassword: (candidatePassword: string) => Promise } @@ -112,90 +104,9 @@ const DriveProvider = ({ children }: DriveContextProps) => { } = useImployApi() const { publicKey, encryptForPublicKey, decryptMessageWithThresholdKey } = useThresholdKey() const { addToastMessage } = useToaster() - const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) - const [bucketType, setBucketType] = useState("csf") - const [pathContents, setPathContents] = useState([]) const [spaceUsed, setSpaceUsed] = useState(0) - const [currentSearchBucket, setCurrentSearchBucket] = useState() const [encryptionKey, setEncryptionKey] = useState() - const refreshContents = useCallback( - async ( - path: string, - bucketTypeParam?: BucketType, - showLoading?: boolean - ) => { - try { - showLoading && setLoadingCurrentPath(true) - const newContents = await imployApiClient?.getCSFChildList({ - path, - source: { - type: bucketTypeParam || bucketType - } - }) - showLoading && setLoadingCurrentPath(false) - - if (newContents) { - // Remove this when the API returns dates - setPathContents( - newContents?.map((fcr) => ({ - ...fcr, - content_type: - fcr.content_type !== "application/octet-stream" - ? fcr.content_type - : guessContentType(fcr.name), - isFolder: - fcr.content_type === "application/chainsafe-files-directory" - })) - ) - } - } catch (error) { - showLoading && setLoadingCurrentPath(false) - } - }, - [imployApiClient, bucketType] - ) - - const currentPathReducer = ( - currentPath: string, - action: - | { type: "update"; payload: string } - | { type: "refreshOnSamePath"; payload: string } - ): string => { - switch (action.type) { - case "update": { - return action.payload - } - case "refreshOnSamePath": { - // check user has not navigated to other folder - // using then catch as awaits won't work in reducer - if (action.payload === currentPath) { - refreshContents(currentPath, bucketType, false) - } - return currentPath - } - default: - return currentPath - } - } - const [currentPath, dispatchCurrentPath] = useReducer(currentPathReducer, "/") - - const setCurrentPath = useCallback((newPath: string, newBucketType?: BucketType, showLoading?: boolean) => { - dispatchCurrentPath({ type: "update", payload: newPath }) - if (newBucketType) { - setBucketType(newBucketType) - } - refreshContents(newPath, newBucketType || bucketType, showLoading) - }, [bucketType, refreshContents]) - - // Ensure path contents are refreshed - useEffect(() => { - if (isLoggedIn) { - refreshContents("/") - } else { - setCurrentSearchBucket(undefined) - } - }, [imployApiClient, refreshContents, isLoggedIn]) // Space used counter useEffect(() => { @@ -210,7 +121,7 @@ const DriveProvider = ({ children }: DriveContextProps) => { if (isLoggedIn) { getSpaceUsage() } - }, [imployApiClient, pathContents, isLoggedIn]) + }, [imployApiClient, isLoggedIn]) // Reset password on log out useEffect(() => { @@ -712,38 +623,15 @@ const DriveProvider = ({ children }: DriveContextProps) => { } } - const getSearchResults = async (searchString: string) => { - try { - if (!searchString) return [] - let bucketId - if ( - currentSearchBucket && - currentSearchBucket.bucketType === bucketType - ) { - // we have the bucket id - bucketId = currentSearchBucket.bucketId - } else { - // fetch bucket id - const results = await imployApiClient.listBuckets(bucketType) - const bucket1 = results[0] - setCurrentSearchBucket({ - bucketType, - bucketId: bucket1.id - }) - bucketId = bucket1.id - } - const results = await imployApiClient.searchFiles({ - bucket_id: bucketId || "", - query: searchString - }) - return results - } catch (err) { - addToastMessage({ - message: t`There was an error getting search results`, - appearance: "error" - }) - return Promise.reject(err) - } + const listBuckets = async (bucketType: BucketType) => { + return await imployApiClient.listBuckets(bucketType) + } + + const searchFiles = async (bucketId: string, searchString: string) => { + return await imployApiClient.searchFiles({ + bucket_id: bucketId || "", + query: searchString + }) } // const setPassword = async (password: string) => { @@ -757,11 +645,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { // } // } - const updateCurrentPath = useCallback((newPath: string, bucketType?: BucketType, showLoading?: boolean) => { - newPath.endsWith("/") - ? setCurrentPath(`${newPath}`, bucketType, showLoading) - : setCurrentPath(`${newPath}/`, bucketType, showLoading) - }, [setCurrentPath]) return ( { getFileContent, recoverFile, list, - currentPath, - updateCurrentPath, - pathContents, uploadsInProgress, spaceUsed, downloadsInProgress, getFolderTree, - getSearchResults, - currentSearchBucket, - loadingCurrentPath, + listBuckets, + searchFiles, getFileInfo, - bucketType, secureAccountWithMasterPassword }} > From 45469cc36a7a3adfdb33bf156e1874edbee44906 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Tue, 27 Apr 2021 16:25:41 +0200 Subject: [PATCH 02/62] Parking --- packages/files-ui/src/Contexts/DriveContext.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index 14a8e53a6b..91c17ecf1d 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -372,7 +372,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { const moveFile = useCallback(async (body: FilesMvRequest) => { try { await imployApiClient.moveCSFObject(body) - await refreshContents(currentPath) addToastMessage({ message: t`File moved successfully`, appearance: "success" @@ -385,7 +384,7 @@ const DriveProvider = ({ children }: DriveContextProps) => { }) return Promise.reject() } - }, [addToastMessage, currentPath, imployApiClient, refreshContents]) + }, [addToastMessage, imployApiClient]) const moveFiles = useCallback(async (filesToMove: FilesMvRequest[]) => { return Promise.all( From ff6e692db69bcf66782e2487e5f28ca6a42d2121 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 28 Apr 2021 10:33:19 +0200 Subject: [PATCH 03/62] Bin browser started --- .../Components/Modules/CreateFolderModule.tsx | 7 +- .../Modules/FileBrowsers/BinFileBrowser.tsx | 175 +++++++++++++++++- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 62 ++++++- .../files-ui/src/Contexts/DriveContext.tsx | 136 +------------- 4 files changed, 232 insertions(+), 148 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/CreateFolderModule.tsx b/packages/files-ui/src/Components/Modules/CreateFolderModule.tsx index 41bcffd12f..3d2a59def6 100644 --- a/packages/files-ui/src/Components/Modules/CreateFolderModule.tsx +++ b/packages/files-ui/src/Components/Modules/CreateFolderModule.tsx @@ -71,15 +71,19 @@ const useStyles = makeStyles( interface ICreateFolderModuleProps { modalOpen: boolean + currentPath: string + refreshCurrentPath: () => void close: () => void } const CreateFolderModule: React.FC = ({ modalOpen, + refreshCurrentPath, + currentPath, close }: ICreateFolderModuleProps) => { const classes = useStyles() - const { createFolder, currentPath } = useDrive() + const { createFolder } = useDrive() const [creatingFolder, setCreatingFolder] = useState(false) const desktop = useMediaQuery("md") @@ -123,6 +127,7 @@ const CreateFolderModule: React.FC = ({ try { setCreatingFolder(true) await createFolder({ path: currentPath + values.name }) + refreshCurrentPath() setCreatingFolder(false) helpers.resetForm() close() diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index b5668fd530..3ac6d600be 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -1,26 +1,184 @@ -import React, { useEffect, useMemo } from "react" -import { useDrive } from "../../../Contexts/DriveContext" +import React, { useCallback, useEffect, useMemo, useReducer, useState } from "react" +import { BucketType, FileSystemItem, useDrive } from "../../../Contexts/DriveContext" import { IFilesBrowserModuleProps } from "./types" import FilesTableView from "./views/FilesTable.view" import DragAndDrop from "../../../Contexts/DnDContext" import { t } from "@lingui/macro" import { CONTENT_TYPES } from "../../../Utils/Constants" import { IFilesTableBrowserProps } from "../../Modules/FileBrowsers/types" +import { guessContentType } from "../../../Utils/contentTypeGuesser" +import { useToaster } from "@chainsafe/common-components" +import { getPathWithFile } from "../../../Utils/pathUtils" const BinFileBrowser: React.FC = ({ controls = false }: IFilesBrowserModuleProps) => { const { - deleteFiles, - updateCurrentPath, - pathContents, - loadingCurrentPath, - bucketType, - recoverFile + removeCSFObjects, + moveCSFObject, + list } = useDrive() + const { addToastMessage } = useToaster() + + const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) + const [pathContents, setPathContents] = useState([]) + const [bucketType, setBucketType] = useState("csf") + const currentPathReducer = ( + currentPath: string, + action: + | { type: "update"; payload: string } + | { type: "refreshOnSamePath"; payload: string } + ): string => { + switch (action.type) { + case "update": { + return action.payload + } + case "refreshOnSamePath": { + // check user has not navigated to other folder + // using then catch as awaits won't work in reducer + if (action.payload === currentPath) { + refreshContents(currentPath, bucketType, false) + } + return currentPath + } + default: + return currentPath + } + } + + const refreshContents = useCallback( + async ( + path: string, + bucketTypeParam?: BucketType, + showLoading?: boolean + ) => { + try { + showLoading && setLoadingCurrentPath(true) + const newContents = await list({ + path, + source: { + type: bucketTypeParam || bucketType + } + }) + showLoading && setLoadingCurrentPath(false) + + if (newContents) { + // Remove this when the API returns dates + setPathContents( + newContents?.map((fcr) => ({ + ...fcr, + content_type: + fcr.content_type !== "application/octet-stream" + ? fcr.content_type + : guessContentType(fcr.name), + isFolder: + fcr.content_type === "application/chainsafe-files-directory" + })) + ) + } + } catch (error) { + showLoading && setLoadingCurrentPath(false) + } + }, + [bucketType, list] + ) + + const [currentPath, dispatchCurrentPath] = useReducer(currentPathReducer, "/") + const setCurrentPath = useCallback((newPath: string, newBucketType?: BucketType, showLoading?: boolean) => { + dispatchCurrentPath({ type: "update", payload: newPath }) + if (newBucketType) { + setBucketType(newBucketType) + } + refreshContents(newPath, newBucketType || bucketType, showLoading) + }, [bucketType, refreshContents]) + const updateCurrentPath = useCallback((newPath: string, bucketType?: BucketType, showLoading?: boolean) => { + newPath.endsWith("/") + ? setCurrentPath(`${newPath}`, bucketType, showLoading) + : setCurrentPath(`${newPath}/`, bucketType, showLoading) + }, [setCurrentPath]) useEffect(() => { updateCurrentPath("/", "trash", bucketType !== "trash") // eslint-disable-next-line }, []) + const deleteFile = useCallback(async (cid: string) => { + const itemToDelete = pathContents.find((i) => i.cid === cid) + + if (!itemToDelete) { + console.error("No item found to delete") + return + } + + try { + await removeCSFObjects({ + paths: [`${currentPath}${itemToDelete.name}`], + source: { + type: bucketType + } + }) + await refreshContents(currentPath) + const message = `${ + itemToDelete.isFolder ? t`Folder` : t`File` + } ${t`deleted successfully`}` + addToastMessage({ + message: message, + appearance: "success" + }) + return Promise.resolve() + } catch (error) { + const message = `${t`There was an error deleting this`} ${ + itemToDelete.isFolder ? t`folder` : t`file` + }` + addToastMessage({ + message: message, + appearance: "error" + }) + return Promise.reject() + } + }, [addToastMessage, bucketType, currentPath, imployApiClient, pathContents, refreshContents]) + + const deleteFiles = useCallback(async (cids: string[]) => { + return Promise.all( + cids.map((cid: string) => + deleteFile(cid) + )) + }, [deleteFile]) + + + const recoverFile = async (cid: string) => { + const itemToRestore = pathContents.find((i) => i.cid === cid) + if (!itemToRestore) return + try { + await moveCSFObject({ + path: getPathWithFile("/", itemToRestore.name), + new_path: getPathWithFile("/", itemToRestore.name), + source: { + type: "trash" + }, + destination: { + type: "csf" + } + }) + await refreshContents(currentPath) + + const message = `${ + itemToRestore.isFolder ? t`Folder` : t`File` + } ${t`recovered successfully`}` + + addToastMessage({ + message: message, + appearance: "success" + }) + return Promise.resolve() + } catch (error) { + const message = `${t`There was an error recovering this`} ${ + itemToRestore.isFolder ? t`folder` : t`file` + }` + addToastMessage({ + message: message, + appearance: "error" + }) + return Promise.reject() + } + } const handleRecover = async (cid: string) => { // TODO set loading @@ -31,6 +189,7 @@ const BinFileBrowser: React.FC = ({ controls = false } } } + const itemOperations: IFilesTableBrowserProps["itemOperations"] = useMemo(() => ({ [CONTENT_TYPES.File]: ["recover", "delete"], [CONTENT_TYPES.Directory]: ["recover", "delete"] diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index eb58348dbc..b143ea30f9 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useReducer, useState } from "react" import { Crumb, useToaster } from "@chainsafe/common-components" import { useDrive, FileSystemItem, BucketType } from "../../../Contexts/DriveContext" -import { getArrayOfPaths, getPathFromArray } from "../../../Utils/pathUtils" +import { getArrayOfPaths, getPathFromArray, getPathWithFile } from "../../../Utils/pathUtils" import { IBulkOperations, IFilesBrowserModuleProps, IFilesTableBrowserProps } from "./types" import FilesTableView from "./views/FilesTable.view" import { CONTENT_TYPES } from "../../../Utils/Constants" @@ -12,14 +12,15 @@ import { guessContentType } from "../../../Utils/contentTypeGuesser" const CSFFileBrowser: React.FC = ({ controls = true }: IFilesBrowserModuleProps) => { const { - moveFilesToTrash, downloadFile, renameFile, + moveCSFObject, moveFile, uploadFiles, uploadsInProgress, list } = useDrive() + const { addToastMessage } = useToaster() const queryPath = useQuery().get("path") @@ -103,6 +104,51 @@ const CSFFileBrowser: React.FC = ({ controls = true }: ? setCurrentPath(`${newPath}`, bucketType, showLoading) : setCurrentPath(`${newPath}/`, bucketType, showLoading) }, [setCurrentPath]) + + const moveFileToTrash = useCallback(async (cid: string) => { + const itemToDelete = pathContents.find((i) => i.cid === cid) + + if (!itemToDelete) { + console.error("No item found to move to the trash") + return + } + + try { + await moveCSFObject({ + path: getPathWithFile(currentPath, itemToDelete.name), + new_path: getPathWithFile("/", itemToDelete.name), + destination: { + type: "trash" + } + }) + await refreshContents(currentPath) + const message = `${ + itemToDelete.isFolder ? t`Folder` : t`File` + } ${t`deleted successfully`}` + addToastMessage({ + message: message, + appearance: "success" + }) + return Promise.resolve() + } catch (error) { + const message = `${t`There was an error deleting this`} ${ + itemToDelete.isFolder ? t`folder` : t`file` + }` + addToastMessage({ + message: message, + appearance: "error" + }) + return Promise.reject() + } + }, [addToastMessage, currentPath, pathContents, refreshContents, moveCSFObject]) + + const moveFilesToTrash = useCallback(async (cids: string[]) => { + return Promise.all( + cids.map((cid: string) => + moveFileToTrash(cid) + )) + }, [moveFileToTrash]) + // END useEffect(() => { @@ -118,7 +164,8 @@ const CSFFileBrowser: React.FC = ({ controls = true }: const handleRename = useCallback(async (path: string, newPath: string) => { // TODO set loading await renameFile({ path: path, new_path: newPath }) - }, [renameFile]) + await refreshContents(currentPath) + }, [renameFile, currentPath, refreshContents]) const handleMove = useCallback(async (path: string, new_path: string) => { // TODO set loading @@ -141,9 +188,8 @@ const CSFFileBrowser: React.FC = ({ controls = true }: ) })), [arrayOfPaths, updateCurrentPath]) - const { addToastMessage } = useToaster() - const handleUploadOnDrop = useCallback((files: File[], fileItems: DataTransferItemList, path: string) => { + const handleUploadOnDrop = useCallback(async (files: File[], fileItems: DataTransferItemList, path: string) => { let hasFolder = false for (let i = 0; i < files.length; i++) { if (fileItems[i].webkitGetAsEntry().isDirectory) { @@ -156,7 +202,11 @@ const CSFFileBrowser: React.FC = ({ controls = true }: appearance: "error" }) } else { - uploadFiles(files, path) + await uploadFiles(files, path) + // refresh contents + // using reducer because user may navigate to other paths + // need to check currentPath and upload path is same + dispatchCurrentPath({ type: "refreshOnSamePath", payload: path }) } }, [addToastMessage, uploadFiles]) diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index 91c17ecf1d..d0f0baa64b 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -65,7 +65,7 @@ type DriveContext = { moveFiles: (bodies: FilesMvRequest[]) => Promise recoverFile: (cid: string) => Promise deleteFiles: (cids: string[]) => Promise - moveFilesToTrash: (cids: string[]) => Promise + moveCSFObject: (body: FilesMvRequest) => Promise downloadFile: (cid: string) => Promise getFileContent: ({ cid, cancelToken, onDownloadProgress, file }: GetFileContentParams) => Promise list: (body: FilesPathRequest) => Promise @@ -272,11 +272,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { } ) - // refresh contents - // using reducer because user may navigate to other paths - // need to check currentPath and upload path is same - dispatchCurrentPath({ type: "refreshOnSamePath", payload: path }) - // setting complete dispatchUploadsInProgress({ type: "complete", payload: { id } }) setTimeout(() => { @@ -307,7 +302,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { const createFolder = async (body: FilesPathRequest) => { try { const result = await imployApiClient.addCSFDirectory(body) - await refreshContents(currentPath) addToastMessage({ message: t`Folder created successfully`, appearance: "success" @@ -352,7 +346,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { try { if (body.path !== body.new_path) { await imployApiClient.moveCSFObject(body) - await refreshContents(currentPath) addToastMessage({ message: t`File renamed successfully`, appearance: "success" @@ -367,7 +360,7 @@ const DriveProvider = ({ children }: DriveContextProps) => { }) return Promise.reject() } - }, [addToastMessage, currentPath, imployApiClient, refreshContents]) + }, [addToastMessage, imployApiClient]) const moveFile = useCallback(async (body: FilesMvRequest) => { try { @@ -394,130 +387,7 @@ const DriveProvider = ({ children }: DriveContextProps) => { ) }, [moveFile]) - const deleteFile = useCallback(async (cid: string) => { - const itemToDelete = pathContents.find((i) => i.cid === cid) - - if (!itemToDelete) { - console.error("No item found to delete") - return - } - - try { - await imployApiClient.removeCSFObjects({ - paths: [`${currentPath}${itemToDelete.name}`], - source: { - type: bucketType - } - }) - await refreshContents(currentPath) - const message = `${ - itemToDelete.isFolder ? t`Folder` : t`File` - } ${t`deleted successfully`}` - addToastMessage({ - message: message, - appearance: "success" - }) - return Promise.resolve() - } catch (error) { - const message = `${t`There was an error deleting this`} ${ - itemToDelete.isFolder ? t`folder` : t`file` - }` - addToastMessage({ - message: message, - appearance: "error" - }) - return Promise.reject() - } - }, [addToastMessage, bucketType, currentPath, imployApiClient, pathContents, refreshContents]) - - const deleteFiles = useCallback(async (cids: string[]) => { - return Promise.all( - cids.map((cid: string) => - deleteFile(cid) - )) - }, [deleteFile]) - - const moveFileToTrash = useCallback(async (cid: string) => { - const itemToDelete = pathContents.find((i) => i.cid === cid) - - if (!itemToDelete) { - console.error("No item found to move to the trash") - return - } - - try { - await imployApiClient.moveCSFObject({ - path: getPathWithFile(currentPath, itemToDelete.name), - new_path: getPathWithFile("/", itemToDelete.name), - destination: { - type: "trash" - } - }) - await refreshContents(currentPath) - const message = `${ - itemToDelete.isFolder ? t`Folder` : t`File` - } ${t`deleted successfully`}` - addToastMessage({ - message: message, - appearance: "success" - }) - return Promise.resolve() - } catch (error) { - const message = `${t`There was an error deleting this`} ${ - itemToDelete.isFolder ? t`folder` : t`file` - }` - addToastMessage({ - message: message, - appearance: "error" - }) - return Promise.reject() - } - }, [addToastMessage, currentPath, imployApiClient, pathContents, refreshContents]) - - const moveFilesToTrash = useCallback(async (cids: string[]) => { - return Promise.all( - cids.map((cid: string) => - moveFileToTrash(cid) - )) - }, [moveFileToTrash]) - - const recoverFile = async (cid: string) => { - const itemToRestore = pathContents.find((i) => i.cid === cid) - if (!itemToRestore) return - try { - await imployApiClient.moveCSFObject({ - path: getPathWithFile("/", itemToRestore.name), - new_path: getPathWithFile("/", itemToRestore.name), - source: { - type: "trash" - }, - destination: { - type: "csf" - } - }) - await refreshContents(currentPath) - - const message = `${ - itemToRestore.isFolder ? t`Folder` : t`File` - } ${t`recovered successfully`}` - - addToastMessage({ - message: message, - appearance: "success" - }) - return Promise.resolve() - } catch (error) { - const message = `${t`There was an error recovering this`} ${ - itemToRestore.isFolder ? t`folder` : t`file` - }` - addToastMessage({ - message: message, - appearance: "error" - }) - return Promise.reject() - } - } - + const getFileContent = useCallback(async ({ cid, cancelToken, onDownloadProgress, file, path }: GetFileContentParams) => { if (!encryptionKey) { throw new Error("No encryption key") From 1d7276882b868c7197ac9b699115775483cacab5 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 28 Apr 2021 13:20:17 +0200 Subject: [PATCH 04/62] No errors --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 5 +- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 13 +++-- .../Components/Modules/FileBrowsers/types.ts | 3 +- .../files-ui/src/Contexts/DriveContext.tsx | 53 ++++++++++--------- 4 files changed, 45 insertions(+), 29 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 3ac6d600be..ad5c73d946 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -99,6 +99,7 @@ const BinFileBrowser: React.FC = ({ controls = false } updateCurrentPath("/", "trash", bucketType !== "trash") // eslint-disable-next-line }, []) + const deleteFile = useCallback(async (cid: string) => { const itemToDelete = pathContents.find((i) => i.cid === cid) @@ -133,7 +134,7 @@ const BinFileBrowser: React.FC = ({ controls = false } }) return Promise.reject() } - }, [addToastMessage, bucketType, currentPath, imployApiClient, pathContents, refreshContents]) + }, [addToastMessage, bucketType, currentPath, pathContents, refreshContents, removeCSFObjects]) const deleteFiles = useCallback(async (cids: string[]) => { return Promise.all( @@ -201,6 +202,8 @@ const BinFileBrowser: React.FC = ({ controls = false } crumbs={undefined} recoverFile={handleRecover} deleteFiles={deleteFiles} + currentPath={currentPath} + refreshContents={() => refreshContents(currentPath)} loadingCurrentPath={loadingCurrentPath} showUploadsInTable={false} sourceFiles={pathContents} diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index b143ea30f9..526171cc1d 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -14,9 +14,9 @@ const CSFFileBrowser: React.FC = ({ controls = true }: const { downloadFile, renameFile, - moveCSFObject, moveFile, uploadFiles, + moveCSFObject, uploadsInProgress, list } = useDrive() @@ -148,7 +148,6 @@ const CSFFileBrowser: React.FC = ({ controls = true }: moveFileToTrash(cid) )) }, [moveFileToTrash]) - // END useEffect(() => { @@ -176,6 +175,13 @@ const CSFFileBrowser: React.FC = ({ controls = true }: await refreshContents(currentPath) }, [moveFile, refreshContents, currentPath]) + const handleDownload = useCallback(async (cid: string) => { + const target = pathContents.find(item => item.cid === cid) + if (!target) return + + await downloadFile(target, currentPath, bucketType) + }, [pathContents, downloadFile, currentPath, bucketType]) + // Breadcrumbs/paths const arrayOfPaths = useMemo(() => getArrayOfPaths(currentPath), [currentPath]) const crumbs: Crumb[] = useMemo(() => arrayOfPaths.map((path, index) => ({ @@ -231,8 +237,9 @@ const CSFFileBrowser: React.FC = ({ controls = true }: bulkOperations={bulkOperations} crumbs={crumbs} currentPath={currentPath} + refreshContents={() => refreshContents(currentPath)} deleteFiles={moveFilesToTrash} - downloadFile={downloadFile} + downloadFile={handleDownload} handleMove={handleMove} handleRename={handleRename} handleUploadOnDrop={handleUploadOnDrop} diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts index 4fdd234d88..957cb49a2f 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts @@ -50,12 +50,13 @@ export interface IFilesTableBrowserProps newBucketType?: BucketType, showLoading?: boolean, ) => void + refreshContents: () => Promise + currentPath: string loadingCurrentPath: boolean uploadsInProgress?: UploadProgress[] showUploadsInTable: boolean sourceFiles: FileSystemItem[] - currentPath?: string crumbs: Crumb[] | undefined getPath?: (cid: string) => string | undefined isSearch?: boolean diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index d0f0baa64b..347d52b7dd 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -6,7 +6,8 @@ import { DirectoryContentResponse, BucketType, Bucket, - SearchEntry + SearchEntry, + FilesRmRequest } from "@chainsafe/files-api-client" import React, { useCallback, useEffect, useReducer } from "react" import { useState } from "react" @@ -21,7 +22,6 @@ import { CancelToken } from "axios" import { t } from "@lingui/macro" import { readFileAsync } from "../Utils/Helpers" import { useBeforeunload } from "react-beforeunload" -import { getPathWithFile } from "../Utils/pathUtils" import { useThresholdKey } from "./ThresholdKeyContext" type DriveContextProps = { @@ -52,8 +52,9 @@ interface GetFileContentParams { cid: string cancelToken?: CancelToken onDownloadProgress?: (progressEvent: ProgressEvent) => void - file?: FileSystemItem - path?: string + file: FileSystemItem + path: string + bucketType: BucketType } @@ -62,11 +63,10 @@ type DriveContext = { createFolder: (body: FilesPathRequest) => Promise renameFile: (body: FilesMvRequest) => Promise moveFile: (body: FilesMvRequest) => Promise - moveFiles: (bodies: FilesMvRequest[]) => Promise - recoverFile: (cid: string) => Promise - deleteFiles: (cids: string[]) => Promise + moveFiles: (filesToMove: FilesMvRequest[]) => Promise moveCSFObject: (body: FilesMvRequest) => Promise - downloadFile: (cid: string) => Promise + removeCSFObjects: (body: FilesRmRequest) => Promise + downloadFile: (itemToDownload: FileSystemItem, path: string, bucketType: BucketType) => void getFileContent: ({ cid, cancelToken, onDownloadProgress, file }: GetFileContentParams) => Promise list: (body: FilesPathRequest) => Promise listBuckets: (bucketType: BucketType) => Promise @@ -76,7 +76,6 @@ type DriveContext = { spaceUsed: number getFolderTree: () => Promise getFileInfo: (path: string) => Promise - getSearchResults: (searchString: string) => Promise secureAccountWithMasterPassword: (candidatePassword: string) => Promise } @@ -107,7 +106,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { const [spaceUsed, setSpaceUsed] = useState(0) const [encryptionKey, setEncryptionKey] = useState() - // Space used counter useEffect(() => { const getSpaceUsage = async () => { @@ -387,22 +385,22 @@ const DriveProvider = ({ children }: DriveContextProps) => { ) }, [moveFile]) - - const getFileContent = useCallback(async ({ cid, cancelToken, onDownloadProgress, file, path }: GetFileContentParams) => { + const getFileContent = useCallback(async ({ cid, cancelToken, onDownloadProgress, file, path, bucketType }: GetFileContentParams) => { if (!encryptionKey) { throw new Error("No encryption key") } // when a file is accessed from the search page, a file and a path are passed in // because the current path will not reflect the right state of the app - const fileToGet = file || pathContents.find((i) => i.cid === cid) + const fileToGet = file + // TODO: move to implementations if (!fileToGet) { - console.error("No file passed, and no file found for cid:", cid, "in pathContents:", pathContents) + console.error("No file passed, and no file found for cid:", cid, "in pathContents:", path) throw new Error("No file found.") } - const pathToUse = path || currentPath + fileToGet.name + const pathToUse = path try { const result = await imployApiClient.getFileContent( { @@ -432,11 +430,9 @@ const DriveProvider = ({ children }: DriveContextProps) => { console.error(error) return Promise.reject() } - }, [currentPath, encryptionKey, imployApiClient, pathContents, bucketType]) + }, [encryptionKey, imployApiClient]) - const downloadFile = useCallback(async (cid: string) => { - const itemToDownload = pathContents.find((i) => i.cid === cid) - if (!itemToDownload) return + const downloadFile = useCallback(async (itemToDownload: FileSystemItem, path: string, bucketType: BucketType) => { const toastId = uuidv4() try { const downloadProgress: DownloadProgress = { @@ -449,6 +445,9 @@ const DriveProvider = ({ children }: DriveContextProps) => { dispatchDownloadsInProgress({ type: "add", payload: downloadProgress }) const result = await getFileContent({ cid: itemToDownload.cid, + bucketType: bucketType, + file: itemToDownload, + path: path, onDownloadProgress: (progressEvent) => { dispatchDownloadsInProgress({ type: "progress", @@ -482,7 +481,7 @@ const DriveProvider = ({ children }: DriveContextProps) => { dispatchDownloadsInProgress({ type: "error", payload: { id: toastId } }) return Promise.reject() } - }, [getFileContent, pathContents]) + }, [getFileContent]) const list = async (body: FilesPathRequest) => { try { @@ -496,6 +495,14 @@ const DriveProvider = ({ children }: DriveContextProps) => { return await imployApiClient.listBuckets(bucketType) } + const removeCSFObjects = async (body: FilesRmRequest) => { + return await imployApiClient.removeCSFObjects(body) + } + + const moveCSFObject = async (body: FilesMvRequest) => { + await moveCSFObject(body) + } + const searchFiles = async (bucketId: string, searchString: string) => { return await imployApiClient.searchFiles({ bucket_id: bucketId || "", @@ -514,7 +521,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { // } // } - return ( { renameFile, moveFile, moveFiles, - deleteFiles, - moveFilesToTrash, + moveCSFObject, + removeCSFObjects, downloadFile, getFileContent, - recoverFile, list, uploadsInProgress, spaceUsed, From 7a27b3df9d7e404314391b32ee9afef2a72acff6 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 28 Apr 2021 16:39:36 +0200 Subject: [PATCH 05/62] Landing loads --- .../files-ui/src/Components/FilesRoutes.tsx | 30 ++++++--- .../src/Components/Layouts/AppHeader.tsx | 2 +- .../src/Components/Layouts/AppNav.tsx | 7 +-- .../Components/Modules/CreateFolderModule.tsx | 2 +- .../Modules/FileBrowsers/BinFileBrowser.tsx | 61 ++++--------------- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 47 ++++---------- .../FileBrowsers/SearchFileBrowser.tsx | 55 ++++++++++++++--- .../Components/Modules/FileBrowsers/types.ts | 18 ++---- .../FileBrowsers/views/FileSystemItemRow.tsx | 20 +++--- .../FileBrowsers/views/FilesTable.view.tsx | 35 +++++++---- .../Components/Modules/FilePreviewModal.tsx | 18 +++--- .../src/Components/Modules/SearchModule.tsx | 6 +- .../src/Components/Modules/Settings/index.tsx | 2 +- .../Components/Modules/UploadFileModule.tsx | 6 +- .../Pages/{HomePage.tsx => DrivePage.tsx} | 4 +- 15 files changed, 161 insertions(+), 152 deletions(-) rename packages/files-ui/src/Components/Pages/{HomePage.tsx => DrivePage.tsx} (71%) diff --git a/packages/files-ui/src/Components/FilesRoutes.tsx b/packages/files-ui/src/Components/FilesRoutes.tsx index b1bad41dce..6556c46929 100644 --- a/packages/files-ui/src/Components/FilesRoutes.tsx +++ b/packages/files-ui/src/Components/FilesRoutes.tsx @@ -3,7 +3,7 @@ import { Switch, ConditionalRoute } from "@chainsafe/common-components" import LoginPage from "./Pages/LoginPage" import SettingsPage from "./Pages/SettingsPage" import { useImployApi } from "@chainsafe/common-contexts" -import HomePage from "./Pages/HomePage" +import DrivePage from "./Pages/DrivePage" import SearchPage from "./Pages/SearchPage" import BinPage from "./Pages/BinPage" import PurchasePlanPage from "./Pages/PurchasePlanPage" @@ -17,8 +17,8 @@ export const ROUTE_LINKS = { ChainSafe: "https://chainsafe.io/", // TODO: update link ApplyCryptography: "https://chainsafe.io/", - Home: (path?: string) => `/home${path ? `?path=${path}` : ""}`, - Search: (search?: string) => `/search${search ? `?search=${search}` : ""}`, + Drive: (currentPath: string) => `/drive${currentPath}`, + Search: (searchTerm: string) => `/search${searchTerm}`, Bin: "/bin", Settings: `${SETTINGS_BASE}/:path`, SettingsDefault: `${SETTINGS_BASE}`, @@ -42,18 +42,32 @@ const FilesRoutes = () => { path={ROUTE_LINKS.Landing} isAuthorized={!isAuthorized} component={LoginPage} - redirectPath={ROUTE_LINKS.Home()} + redirectPath={ROUTE_LINKS.Drive("/")} /> + + { diff --git a/packages/files-ui/src/Components/Layouts/AppHeader.tsx b/packages/files-ui/src/Components/Layouts/AppHeader.tsx index 3e913ac6fb..1d4b1cf5bb 100644 --- a/packages/files-ui/src/Components/Layouts/AppHeader.tsx +++ b/packages/files-ui/src/Components/Layouts/AppHeader.tsx @@ -230,7 +230,7 @@ const AppHeader: React.FC = ({ /> = ({ navOpen, setNavOpen }: IAppNav) => { const { desktop } = useThemeSwitcher() const classes = useStyles() - const { spaceUsed, updateCurrentPath } = useDrive() + const { spaceUsed } = useDrive() const { isLoggedIn, secured } = useImployApi() const { publicKey, isNewDevice, shouldInitializeAccount, logout } = useThresholdKey() @@ -256,7 +256,7 @@ const AppNav: React.FC = ({ navOpen, setNavOpen }: IAppNav) => {
@@ -280,10 +280,9 @@ const AppNav: React.FC = ({ navOpen, setNavOpen }: IAppNav) => { { handleOnClick() - updateCurrentPath("/", "csf", true) }} className={classes.navItem} - to={ROUTE_LINKS.Home()} + to={ROUTE_LINKS.Drive("/")} > void close: () => void + currentPath: string } const CreateFolderModule: React.FC = ({ diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index ad5c73d946..5b42ba473a 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useReducer, useState } from "react" +import React, { useCallback, useEffect, useMemo, useState } from "react" import { BucketType, FileSystemItem, useDrive } from "../../../Contexts/DriveContext" import { IFilesBrowserModuleProps } from "./types" import FilesTableView from "./views/FilesTable.view" @@ -7,8 +7,9 @@ import { t } from "@lingui/macro" import { CONTENT_TYPES } from "../../../Utils/Constants" import { IFilesTableBrowserProps } from "../../Modules/FileBrowsers/types" import { guessContentType } from "../../../Utils/contentTypeGuesser" -import { useToaster } from "@chainsafe/common-components" +import { useParams, useToaster } from "@chainsafe/common-components" import { getPathWithFile } from "../../../Utils/pathUtils" +import { ROUTE_LINKS } from "../../FilesRoutes" const BinFileBrowser: React.FC = ({ controls = false }: IFilesBrowserModuleProps) => { const { @@ -20,40 +21,18 @@ const BinFileBrowser: React.FC = ({ controls = false } const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) const [pathContents, setPathContents] = useState([]) - const [bucketType, setBucketType] = useState("csf") - const currentPathReducer = ( - currentPath: string, - action: - | { type: "update"; payload: string } - | { type: "refreshOnSamePath"; payload: string } - ): string => { - switch (action.type) { - case "update": { - return action.payload - } - case "refreshOnSamePath": { - // check user has not navigated to other folder - // using then catch as awaits won't work in reducer - if (action.payload === currentPath) { - refreshContents(currentPath, bucketType, false) - } - return currentPath - } - default: - return currentPath - } - } + const [bucketType] = useState("csf") + const { currentPath } = useParams<{ currentPath: string }>() const refreshContents = useCallback( async ( - path: string, bucketTypeParam?: BucketType, showLoading?: boolean ) => { try { showLoading && setLoadingCurrentPath(true) const newContents = await list({ - path, + path: currentPath, source: { type: bucketTypeParam || bucketType } @@ -78,25 +57,11 @@ const BinFileBrowser: React.FC = ({ controls = false } showLoading && setLoadingCurrentPath(false) } }, - [bucketType, list] + [bucketType, list, currentPath] ) - const [currentPath, dispatchCurrentPath] = useReducer(currentPathReducer, "/") - const setCurrentPath = useCallback((newPath: string, newBucketType?: BucketType, showLoading?: boolean) => { - dispatchCurrentPath({ type: "update", payload: newPath }) - if (newBucketType) { - setBucketType(newBucketType) - } - refreshContents(newPath, newBucketType || bucketType, showLoading) - }, [bucketType, refreshContents]) - const updateCurrentPath = useCallback((newPath: string, bucketType?: BucketType, showLoading?: boolean) => { - newPath.endsWith("/") - ? setCurrentPath(`${newPath}`, bucketType, showLoading) - : setCurrentPath(`${newPath}/`, bucketType, showLoading) - }, [setCurrentPath]) - useEffect(() => { - updateCurrentPath("/", "trash", bucketType !== "trash") + refreshContents() // eslint-disable-next-line }, []) @@ -115,7 +80,7 @@ const BinFileBrowser: React.FC = ({ controls = false } type: bucketType } }) - await refreshContents(currentPath) + await refreshContents() const message = `${ itemToDelete.isFolder ? t`Folder` : t`File` } ${t`deleted successfully`}` @@ -158,7 +123,7 @@ const BinFileBrowser: React.FC = ({ controls = false } type: "csf" } }) - await refreshContents(currentPath) + await refreshContents() const message = `${ itemToRestore.isFolder ? t`Folder` : t`File` @@ -190,7 +155,6 @@ const BinFileBrowser: React.FC = ({ controls = false } } } - const itemOperations: IFilesTableBrowserProps["itemOperations"] = useMemo(() => ({ [CONTENT_TYPES.File]: ["recover", "delete"], [CONTENT_TYPES.Directory]: ["recover", "delete"] @@ -203,13 +167,14 @@ const BinFileBrowser: React.FC = ({ controls = false } recoverFile={handleRecover} deleteFiles={deleteFiles} currentPath={currentPath} - refreshContents={() => refreshContents(currentPath)} + moduleRootPath={ROUTE_LINKS.Bin} + refreshContents={refreshContents} loadingCurrentPath={loadingCurrentPath} showUploadsInTable={false} sourceFiles={pathContents} - updateCurrentPath={updateCurrentPath} heading={t`Bin`} controls={controls} + bucketType={bucketType} itemOperations={itemOperations} /> diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index 526171cc1d..0b951d81af 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -1,14 +1,14 @@ -import React, { useCallback, useEffect, useMemo, useReducer, useState } from "react" -import { Crumb, useToaster } from "@chainsafe/common-components" +import React, { useCallback, useMemo, useReducer, useState } from "react" +import { Crumb, useToaster, useHistory } from "@chainsafe/common-components" import { useDrive, FileSystemItem, BucketType } from "../../../Contexts/DriveContext" import { getArrayOfPaths, getPathFromArray, getPathWithFile } from "../../../Utils/pathUtils" import { IBulkOperations, IFilesBrowserModuleProps, IFilesTableBrowserProps } from "./types" import FilesTableView from "./views/FilesTable.view" import { CONTENT_TYPES } from "../../../Utils/Constants" import DragAndDrop from "../../../Contexts/DnDContext" -import { useQuery } from "../../../Utils/Helpers" import { t } from "@lingui/macro" import { guessContentType } from "../../../Utils/contentTypeGuesser" +import { ROUTE_LINKS } from "../../FilesRoutes" const CSFFileBrowser: React.FC = ({ controls = true }: IFilesBrowserModuleProps) => { const { @@ -22,12 +22,12 @@ const CSFFileBrowser: React.FC = ({ controls = true }: } = useDrive() const { addToastMessage } = useToaster() - const queryPath = useQuery().get("path") - // const { currentPath } = useParams<{ currentPath: string }>() const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) const [pathContents, setPathContents] = useState([]) - const [bucketType, setBucketType] = useState("csf") + const [bucketType] = useState("csf") + + const { redirect } = useHistory() const currentPathReducer = ( currentPath: string, @@ -91,20 +91,6 @@ const CSFFileBrowser: React.FC = ({ controls = true }: ) // From drive - const setCurrentPath = useCallback((newPath: string, newBucketType?: BucketType, showLoading?: boolean) => { - dispatchCurrentPath({ type: "update", payload: newPath }) - if (newBucketType) { - setBucketType(newBucketType) - } - refreshContents(newPath, newBucketType || bucketType, showLoading) - }, [bucketType, refreshContents]) - - const updateCurrentPath = useCallback((newPath: string, bucketType?: BucketType, showLoading?: boolean) => { - newPath.endsWith("/") - ? setCurrentPath(`${newPath}`, bucketType, showLoading) - : setCurrentPath(`${newPath}/`, bucketType, showLoading) - }, [setCurrentPath]) - const moveFileToTrash = useCallback(async (cid: string) => { const itemToDelete = pathContents.find((i) => i.cid === cid) @@ -150,15 +136,6 @@ const CSFFileBrowser: React.FC = ({ controls = true }: }, [moveFileToTrash]) // END - useEffect(() => { - updateCurrentPath( - queryPath || "/", - "csf", - bucketType !== "csf" || queryPath !== null - ) - // eslint-disable-next-line - }, [queryPath]) - // Rename const handleRename = useCallback(async (path: string, newPath: string) => { // TODO set loading @@ -182,17 +159,16 @@ const CSFFileBrowser: React.FC = ({ controls = true }: await downloadFile(target, currentPath, bucketType) }, [pathContents, downloadFile, currentPath, bucketType]) + // Breadcrumbs/paths const arrayOfPaths = useMemo(() => getArrayOfPaths(currentPath), [currentPath]) const crumbs: Crumb[] = useMemo(() => arrayOfPaths.map((path, index) => ({ text: path, onClick: () => - updateCurrentPath( - getPathFromArray(arrayOfPaths.slice(0, index + 1)), - undefined, - true + redirect( + ROUTE_LINKS.Drive(getPathFromArray(arrayOfPaths.slice(0, index + 1))) ) - })), [arrayOfPaths, updateCurrentPath]) + })), [arrayOfPaths, redirect]) const handleUploadOnDrop = useCallback(async (files: File[], fileItems: DataTransferItemList, path: string) => { @@ -236,6 +212,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: refreshContents(currentPath)} deleteFiles={moveFilesToTrash} @@ -247,8 +224,8 @@ const CSFFileBrowser: React.FC = ({ controls = true }: loadingCurrentPath={loadingCurrentPath} showUploadsInTable={true} sourceFiles={pathContents} - updateCurrentPath={updateCurrentPath} heading = {t`My Files`} + bucketType={bucketType} controls={controls} allowDropUpload={true} itemOperations={ItemOperations} diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx index 9a0e36c79c..8ffe1640a0 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx @@ -1,17 +1,53 @@ import React, { useCallback, useEffect, useMemo, useState } from "react" -import { FileSystemItem, SearchEntry, useDrive } from "../../../Contexts/DriveContext" +import { BucketType, FileSystemItem, SearchEntry, useDrive } from "../../../Contexts/DriveContext" import { IFilesBrowserModuleProps, IFilesTableBrowserProps } from "./types" import FilesTableView from "./views/FilesTable.view" import { CONTENT_TYPES } from "../../../Utils/Constants" import DragAndDrop from "../../../Contexts/DnDContext" -import { useHistory } from "@chainsafe/common-components" +import { useHistory, useParams, useToaster } from "@chainsafe/common-components" import { getParentPathFromFilePath } from "../../../Utils/pathUtils" import { ROUTE_LINKS } from "../../FilesRoutes" import { useQuery } from "../../../Utils/Helpers" import { t } from "@lingui/macro" +import { SearchParams } from "../SearchModule" const SearchFileBrowser: React.FC = ({ controls = false }: IFilesBrowserModuleProps) => { - const { updateCurrentPath, getSearchResults } = useDrive() + const { searchTerm } = useParams<{ searchTerm: string }>() + const { listBuckets, searchFiles } = useDrive() + + const [bucketType] = useState("csf") + const [currentSearchBucket, setCurrentSearchBucket] = useState() + const { addToastMessage } = useToaster() + const getSearchResults = async (searchString: string) => { + try { + if (!searchString) return [] + let bucketId + if ( + currentSearchBucket && + currentSearchBucket.bucketType === bucketType + ) { + // we have the bucket id + bucketId = currentSearchBucket.bucketId + } else { + // fetch bucket id + const results = await listBuckets(bucketType) + const bucket1 = results[0] + setCurrentSearchBucket({ + bucketType, + bucketId: bucket1.id + }) + bucketId = bucket1.id + } + const results = await searchFiles(bucketId || "", searchString) + return results + } catch (err) { + addToastMessage({ + message: t`There was an error getting search results`, + appearance: "error" + }) + return Promise.reject(err) + } + } const { redirect } = useHistory() const [loadingSearchResults, setLoadingSearchResults] = useState(true) @@ -46,16 +82,17 @@ const SearchFileBrowser: React.FC = ({ controls = fals const searchEntry = getSearchEntry(cid) if (searchEntry) { if (searchEntry.content.content_type === CONTENT_TYPES.Directory) { - redirect(ROUTE_LINKS.Home(searchEntry.path)) + redirect(ROUTE_LINKS.Drive(searchEntry.path)) } else { - redirect(ROUTE_LINKS.Home(getParentPathFromFilePath(searchEntry.path))) + redirect(ROUTE_LINKS.Drive(getParentPathFromFilePath(searchEntry.path))) } } } - const getPath = useCallback((cid: string) => { + const getPath = useCallback((cid: string): string => { const searchEntry = getSearchEntry(cid) - return searchEntry?.path + // Set like this as look ups should always be using available cids + return searchEntry ? searchEntry.path : "" }, [getSearchEntry]) const pathContents: FileSystemItem[] = useMemo(() => @@ -78,11 +115,13 @@ const SearchFileBrowser: React.FC = ({ controls = fals showUploadsInTable={false} viewFolder={viewFolder} sourceFiles={pathContents} - updateCurrentPath={updateCurrentPath} + moduleRootPath={undefined} + currentPath={searchTerm} heading={t`Search results`} controls={controls} itemOperations={itemOperations} isSearch + bucketType={bucketType} getPath={getPath} /> diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts index 957cb49a2f..497b02274d 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts @@ -1,9 +1,5 @@ import { Crumb } from "@chainsafe/common-components" -import { - FileSystemItem, - BucketType, - UploadProgress -} from "../../../Contexts/DriveContext" +import { BucketType, FileSystemItem, UploadProgress } from "../../../Contexts/DriveContext" export type FileOperation = | "rename" @@ -45,19 +41,17 @@ export interface IFilesTableBrowserProps path: string, ) => void - updateCurrentPath: ( - newPath: string, - newBucketType?: BucketType, - showLoading?: boolean, - ) => void - refreshContents: () => Promise + refreshContents?: () => Promise currentPath: string + bucketType: BucketType loadingCurrentPath: boolean uploadsInProgress?: UploadProgress[] showUploadsInTable: boolean sourceFiles: FileSystemItem[] crumbs: Crumb[] | undefined - getPath?: (cid: string) => string | undefined + moduleRootPath: string | undefined + + getPath?: (cid: string) => string isSearch?: boolean } diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx index 9dd16935c6..0ee80e268b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx @@ -23,12 +23,13 @@ import { ExportSvg, ShareAltSvg, ExclamationCircleInverseSvg, - ZoomInSvg + ZoomInSvg, + useHistory } from "@chainsafe/common-components" import { makeStyles, createStyles, useDoubleClick, useThemeSwitcher } from "@chainsafe/common-theme" import clsx from "clsx" import { Formik, Form } from "formik" -import { FileSystemItem, BucketType } from "../../../../Contexts/DriveContext" +import { FileSystemItem } from "../../../../Contexts/DriveContext" import CustomModal from "../../../Elements/CustomModal" import { Trans } from "@lingui/macro" import { useDrag, useDrop } from "react-dnd" @@ -164,8 +165,8 @@ interface IFileSystemItemRowProps { index: number file: FileSystemItem files: FileSystemItem[] - currentPath?: string - updateCurrentPath: (path: string, newBucketType?: BucketType, showLoading?: boolean) => void + currentPath: string + moduleRootPath?: string selected: string[] handleSelect(selected: string): void editing: string | undefined @@ -189,11 +190,11 @@ const FileSystemItemRow: React.FC = ({ index, file, files, - currentPath, - updateCurrentPath, selected, editing, setEditing, + currentPath, + moduleRootPath, renameSchema, handleRename, handleMove, @@ -224,6 +225,8 @@ const FileSystemItemRow: React.FC = ({ const { desktop, themeKey } = useThemeSwitcher() const classes = useStyles() + const { redirect } = useHistory() + const allMenuItems: Record = { rename: { contents: ( @@ -373,9 +376,10 @@ const FileSystemItemRow: React.FC = ({ } const onFolderClick = useCallback(() => { - updateCurrentPath(`${currentPath}${name}`, undefined, true) + if (!moduleRootPath) return + redirect(`${moduleRootPath}/${currentPath}${name}`) resetSelectedFiles() - }, [currentPath, name, resetSelectedFiles, updateCurrentPath]) + }, [currentPath, name, resetSelectedFiles, redirect, moduleRootPath]) const onFileClick = useCallback(() => { setPreviewFileIndex(files?.indexOf(file)) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx index 05c1ecfcc5..42edfbeda2 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx @@ -19,7 +19,8 @@ import { UploadIcon, Dialog, Loading, - CheckboxInput + CheckboxInput, + useHistory } from "@chainsafe/common-components" import { useState } from "react" import { useMemo } from "react" @@ -43,6 +44,7 @@ import { CONTENT_TYPES } from "../../../../Utils/Constants" import { CSFTheme } from "../../../../Themes/types" import MimeMatcher from "../../../../Utils/MimeMatcher" import { useLanguageContext } from "../../../../Contexts/LanguageContext" +import { getPathWithFile } from "../../../../Utils/pathUtils" interface IStyleProps { themeKey: string @@ -249,7 +251,6 @@ const FilesTableView = ({ sourceFiles, handleUploadOnDrop, bulkOperations, - updateCurrentPath, crumbs, handleRename, handleMove, @@ -258,13 +259,16 @@ const FilesTableView = ({ recoverFile, viewFolder, currentPath, + refreshContents, loadingCurrentPath, uploadsInProgress, showUploadsInTable, allowDropUpload, itemOperations, getPath, - isSearch + moduleRootPath, + isSearch, + bucketType }: IFilesTableBrowserProps) => { const { themeKey, desktop } = useThemeSwitcher() const classes = useStyles({ themeKey }) @@ -309,6 +313,8 @@ const FilesTableView = ({ return direction === "descend" ? temp.reverse().sort(sortFoldersFirst) : temp.sort(sortFoldersFirst) }, [sourceFiles, direction, column, selectedLocale]) + const { redirect } = useHistory() + const files = useMemo( () => items.filter((i) => !i.isFolder) , [items] @@ -516,10 +522,10 @@ const FilesTableView = ({
- {crumbs ? ( + {crumbs && moduleRootPath ? ( updateCurrentPath("/", undefined, true)} + homeOnClick={() => redirect(moduleRootPath)} showDropDown={!desktop} /> ) : null} @@ -753,8 +759,8 @@ const FilesTableView = ({ index={index} file={file} files={files} + moduleRootPath={moduleRootPath} currentPath={currentPath} - updateCurrentPath={updateCurrentPath} selected={selectedCids} handleSelect={handleSelect} editing={editing} @@ -790,11 +796,12 @@ const FilesTableView = ({ 0 ? setPreviousPreview : undefined} - path={isSearch && getPath ? getPath(files[previewFileIndex].cid) : undefined} + path={isSearch && getPath ? getPath(files[previewFileIndex].cid) : getPathWithFile(currentPath, files[previewFileIndex].name)} /> )} - setCreateFolderModalOpen(false)} - /> + { + refreshContents && ( + setCreateFolderModalOpen(false)} + /> + ) + } setIsUploadModalOpen(false)} diff --git a/packages/files-ui/src/Components/Modules/FilePreviewModal.tsx b/packages/files-ui/src/Components/Modules/FilePreviewModal.tsx index fe11dfa372..d8863f78b2 100644 --- a/packages/files-ui/src/Components/Modules/FilePreviewModal.tsx +++ b/packages/files-ui/src/Components/Modules/FilePreviewModal.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef } from "react" import { useState } from "react" import { createStyles, makeStyles, useThemeSwitcher } from "@chainsafe/common-theme" -import { FileSystemItem, useDrive } from "../../Contexts/DriveContext" +import { BucketType, FileSystemItem, useDrive } from "../../Contexts/DriveContext" import MimeMatcher from "./../../Utils/MimeMatcher" import axios, { CancelTokenSource } from "axios" import { @@ -152,14 +152,15 @@ const useStyles = makeStyles( ) interface Props { - file?: FileSystemItem + file: FileSystemItem nextFile?: () => void previousFile?: () => void closePreview: () => void - path?: string + path: string + bucketType: BucketType } -const FilePreviewModal = ({ file, nextFile, previousFile, closePreview, path }: Props) => { +const FilePreviewModal = ({ file, nextFile, previousFile, closePreview, path, bucketType }: Props) => { const classes = useStyles() const { getFileContent, downloadFile } = useDrive() const { desktop } = useThemeSwitcher() @@ -204,7 +205,8 @@ const FilePreviewModal = ({ file, nextFile, previousFile, closePreview, path }: setLoadingProgress((evt.loaded / size) * 100) }, file, - path + path, + bucketType: bucketType }) if (content) { @@ -227,7 +229,7 @@ const FilePreviewModal = ({ file, nextFile, previousFile, closePreview, path }: if (content_type && compatibleFilesMatcher.match(content_type)) { getContents() } - }, [cid, size, content_type, getFileContent, file, path]) + }, [cid, size, content_type, getFileContent, file, path, bucketType]) const validRendererMimeType = content_type && @@ -270,7 +272,7 @@ const FilePreviewModal = ({ file, nextFile, previousFile, closePreview, path }: link.click() URL.revokeObjectURL(link.href) } else { - downloadFile(cid) + downloadFile(file, path, bucketType) } } @@ -426,7 +428,7 @@ const FilePreviewModal = ({ file, nextFile, previousFile, closePreview, path }: diff --git a/packages/files-ui/src/Components/Modules/SearchModule.tsx b/packages/files-ui/src/Components/Modules/SearchModule.tsx index 86170566e0..4670b84593 100644 --- a/packages/files-ui/src/Components/Modules/SearchModule.tsx +++ b/packages/files-ui/src/Components/Modules/SearchModule.tsx @@ -23,7 +23,7 @@ import { getParentPathFromFilePath } from "../../Utils/pathUtils" import { t, Trans } from "@lingui/macro" import { CSFTheme } from "../../Themes/types" -interface SearchParams { +export interface SearchParams { bucketType: BucketType bucketId: string } @@ -241,13 +241,13 @@ const SearchModule: React.FC = ({ ) const onSearchEntryClickFolder = (searchEntry: SearchEntry) => { - redirect(ROUTE_LINKS.Home(searchEntry.path)) + redirect(ROUTE_LINKS.Drive(searchEntry.path)) setSearchQuery("") setSearchActive(false) } const onSearchEntryClickFile = (searchEntry: SearchEntry) => { - redirect(ROUTE_LINKS.Home(getParentPathFromFilePath(searchEntry.path))) + redirect(ROUTE_LINKS.Drive(getParentPathFromFilePath(searchEntry.path))) setSearchQuery("") setSearchActive(false) } diff --git a/packages/files-ui/src/Components/Modules/Settings/index.tsx b/packages/files-ui/src/Components/Modules/Settings/index.tsx index 7f59e2e91b..6e4e3de285 100644 --- a/packages/files-ui/src/Components/Modules/Settings/index.tsx +++ b/packages/files-ui/src/Components/Modules/Settings/index.tsx @@ -148,7 +148,7 @@ const Settings: React.FC = () => {
redirect(ROUTE_LINKS.Home())} + homeOnClick={() => redirect(ROUTE_LINKS.Drive(""))} /> = ({ const classes = useStyles() const [isDoneDisabled, setIsDoneDisabled] = useState(true) - const { uploadFiles, currentPath } = useDrive() + const { uploadFiles } = useDrive() const UploadSchema = object().shape({ files: array().required("Please select a file to upload") @@ -93,6 +93,8 @@ const UploadFileModule: React.FC = ({ setIsDoneDisabled(filesNumber === 0) }, []) + const { currentPath } = useParams<{ currentPath: string }>() + return ( { +const DrivePage = () => { return } -export default HomePage +export default DrivePage From 3e3ebd9a39bf6814076b16aea00ffd9f76c20242 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 28 Apr 2021 17:33:50 +0200 Subject: [PATCH 06/62] URL does not accept URI safe pushes --- .../files-ui/src/Components/FilesRoutes.tsx | 4 +- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 42 +++++++------------ .../FileBrowsers/views/FileSystemItemRow.tsx | 4 +- 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/files-ui/src/Components/FilesRoutes.tsx b/packages/files-ui/src/Components/FilesRoutes.tsx index 6556c46929..e1657e5f7f 100644 --- a/packages/files-ui/src/Components/FilesRoutes.tsx +++ b/packages/files-ui/src/Components/FilesRoutes.tsx @@ -17,8 +17,8 @@ export const ROUTE_LINKS = { ChainSafe: "https://chainsafe.io/", // TODO: update link ApplyCryptography: "https://chainsafe.io/", - Drive: (currentPath: string) => `/drive${currentPath}`, - Search: (searchTerm: string) => `/search${searchTerm}`, + Drive: (rawCurrentPath: string) => `/drive${rawCurrentPath}`, + Search: (rawSearchTerm: string) => `/search${rawSearchTerm}`, Bin: "/bin", Settings: `${SETTINGS_BASE}/:path`, SettingsDefault: `${SETTINGS_BASE}`, diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index 0b951d81af..f98a61f295 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useMemo, useReducer, useState } from "react" -import { Crumb, useToaster, useHistory } from "@chainsafe/common-components" +import React, { useCallback, useEffect, useMemo, useState } from "react" +import { Crumb, useToaster, useHistory, useParams } from "@chainsafe/common-components" import { useDrive, FileSystemItem, BucketType } from "../../../Contexts/DriveContext" import { getArrayOfPaths, getPathFromArray, getPathWithFile } from "../../../Utils/pathUtils" import { IBulkOperations, IFilesBrowserModuleProps, IFilesTableBrowserProps } from "./types" @@ -29,30 +29,10 @@ const CSFFileBrowser: React.FC = ({ controls = true }: const { redirect } = useHistory() - const currentPathReducer = ( - currentPath: string, - action: - | { type: "update"; payload: string } - | { type: "refreshOnSamePath"; payload: string } - ): string => { - switch (action.type) { - case "update": { - return action.payload - } - case "refreshOnSamePath": { - // check user has not navigated to other folder - // using then catch as awaits won't work in reducer - if (action.payload === currentPath) { - refreshContents(currentPath, bucketType, false) - } - return currentPath - } - default: - return currentPath - } - } - const [currentPath, dispatchCurrentPath] = useReducer(currentPathReducer, "/") + const { rawCurrentPath } = useParams<{ rawCurrentPath: string }>() + console.log("rawCurrentPath", rawCurrentPath) + const [currentPath, setCurrentPath] = useState(rawCurrentPath ? decodeURI(rawCurrentPath) : "/") const refreshContents = useCallback( async ( path: string, @@ -90,6 +70,12 @@ const CSFFileBrowser: React.FC = ({ controls = true }: [bucketType, list] ) + useEffect(() => { + console.log("refreshing") + setCurrentPath(rawCurrentPath ? decodeURI(rawCurrentPath) : "/") + refreshContents(rawCurrentPath ? decodeURI(rawCurrentPath) : "/") + }, [refreshContents, rawCurrentPath]) + // From drive const moveFileToTrash = useCallback(async (cid: string) => { const itemToDelete = pathContents.find((i) => i.cid === cid) @@ -166,7 +152,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: text: path, onClick: () => redirect( - ROUTE_LINKS.Drive(getPathFromArray(arrayOfPaths.slice(0, index + 1))) + ROUTE_LINKS.Drive(encodeURI(encodeURI(getPathFromArray(arrayOfPaths.slice(0, index + 1))))) ) })), [arrayOfPaths, redirect]) @@ -188,9 +174,9 @@ const CSFFileBrowser: React.FC = ({ controls = true }: // refresh contents // using reducer because user may navigate to other paths // need to check currentPath and upload path is same - dispatchCurrentPath({ type: "refreshOnSamePath", payload: path }) + refreshContents(currentPath) } - }, [addToastMessage, uploadFiles]) + }, [addToastMessage, uploadFiles, currentPath, refreshContents]) const bulkOperations: IBulkOperations = useMemo(() => ({ [CONTENT_TYPES.Directory]: ["move"], diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx index 0ee80e268b..06175b6e6e 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx @@ -377,7 +377,9 @@ const FileSystemItemRow: React.FC = ({ const onFolderClick = useCallback(() => { if (!moduleRootPath) return - redirect(`${moduleRootPath}/${currentPath}${name}`) + // React route runs a decode in creating a location in the context so double encoding is needed + const newPath = `${moduleRootPath}${currentPath}${encodeURI(encodeURI(name))}` + redirect(newPath) resetSelectedFiles() }, [currentPath, name, resetSelectedFiles, redirect, moduleRootPath]) From b1543916ffd4011d10adcd875dc14e100d0ab224 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 29 Apr 2021 17:05:00 +0200 Subject: [PATCH 07/62] fix path routing --- .../files-ui/src/Components/FilesRoutes.tsx | 3 +-- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 20 ++++++++++--------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/files-ui/src/Components/FilesRoutes.tsx b/packages/files-ui/src/Components/FilesRoutes.tsx index e1657e5f7f..0bc75f2b18 100644 --- a/packages/files-ui/src/Components/FilesRoutes.tsx +++ b/packages/files-ui/src/Components/FilesRoutes.tsx @@ -45,8 +45,7 @@ const FilesRoutes = () => { redirectPath={ROUTE_LINKS.Drive("/")} /> = ({ controls = true }: } = useDrive() const { addToastMessage } = useToaster() - // const { currentPath } = useParams<{ currentPath: string }>() const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) const [pathContents, setPathContents] = useState([]) const [bucketType] = useState("csf") const { redirect } = useHistory() - const { rawCurrentPath } = useParams<{ rawCurrentPath: string }>() - console.log("rawCurrentPath", rawCurrentPath) - - const [currentPath, setCurrentPath] = useState(rawCurrentPath ? decodeURI(rawCurrentPath) : "/") + const { pathname } = useLocation() + const [currentPath, setCurrentPath] = useState(pathname.split("/").slice(1).join("/")) const refreshContents = useCallback( async ( path: string, @@ -72,9 +69,14 @@ const CSFFileBrowser: React.FC = ({ controls = true }: useEffect(() => { console.log("refreshing") - setCurrentPath(rawCurrentPath ? decodeURI(rawCurrentPath) : "/") - refreshContents(rawCurrentPath ? decodeURI(rawCurrentPath) : "/") - }, [refreshContents, rawCurrentPath]) + let drivePath = pathname.split("/").slice(2).join("/").concat("/") + if (drivePath[0] !== "/") { + drivePath = "/" + drivePath + } + + setCurrentPath(decodeURI(drivePath)) + refreshContents(decodeURI(drivePath)) + }, [refreshContents, pathname]) // From drive const moveFileToTrash = useCallback(async (cid: string) => { From 2ec9e758cf66517b422cbb491dc5f28ad8d449ed Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 29 Apr 2021 22:25:56 +0200 Subject: [PATCH 08/62] make router redirect good --- .../src/Router/ConditionalRoute.tsx | 29 ++++++++++++------- .../files-ui/src/Components/FilesRoutes.tsx | 23 +++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/common-components/src/Router/ConditionalRoute.tsx b/packages/common-components/src/Router/ConditionalRoute.tsx index ae9ca31f3d..757135b58e 100644 --- a/packages/common-components/src/Router/ConditionalRoute.tsx +++ b/packages/common-components/src/Router/ConditionalRoute.tsx @@ -1,23 +1,30 @@ import React from "react" -import { Route, Redirect } from "react-router-dom" +import { Route, Redirect, useLocation } from "react-router-dom" -interface IProps { +interface IConditionalRouteProps { component: React.ElementType - isAuthorized: boolean | undefined + isAuthorized?: boolean path: string - redirectPath?: string + // The path the user should be redirected to if the condition is not met + redirectPath: string + // This flag will ignore the redirect path and redirect the user to where they were originally going + redirectToSource?: boolean exact?: boolean } -const ConditionalRoute: React.FC = ({ +const ConditionalRoute: React.FC = ({ component: Component, isAuthorized, - redirectPath = "/403", + redirectPath, + redirectToSource, path, exact, ...rest -}) => ( - { + const { state, pathname } = useLocation() + const from = (state as any)?.from + + return { @@ -26,14 +33,14 @@ const ConditionalRoute: React.FC = ({ ) : isAuthorized === false ? ( ) : // this may be converted into loading null }} /> -) +} export default ConditionalRoute diff --git a/packages/files-ui/src/Components/FilesRoutes.tsx b/packages/files-ui/src/Components/FilesRoutes.tsx index 0bc75f2b18..5e280c781e 100644 --- a/packages/files-ui/src/Components/FilesRoutes.tsx +++ b/packages/files-ui/src/Components/FilesRoutes.tsx @@ -38,21 +38,7 @@ const FilesRoutes = () => { return ( - - { component={PurchasePlanPage} redirectPath={ROUTE_LINKS.Landing} /> + ) } From 7818934d74420cab43ffd77f88e1434d36bff328 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Thu, 29 Apr 2021 23:14:19 +0200 Subject: [PATCH 09/62] make routing persistent --- .../src/ImployApiContext/ImployApiContext.tsx | 2 +- .../files-ui/src/Components/Layouts/AppHeader.tsx | 12 ++++++++---- .../files-ui/src/Contexts/ThresholdKeyContext.tsx | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/common-contexts/src/ImployApiContext/ImployApiContext.tsx b/packages/common-contexts/src/ImployApiContext/ImployApiContext.tsx index 6f323e3fbb..2d5e29a4ec 100644 --- a/packages/common-contexts/src/ImployApiContext/ImployApiContext.tsx +++ b/packages/common-contexts/src/ImployApiContext/ImployApiContext.tsx @@ -6,7 +6,6 @@ import jwtDecode from "jwt-decode" import axios from "axios" import { decryptFile } from "../helpers" import { useLocalStorage, useSessionStorage } from "@chainsafe/browser-storage-hooks" - export { IdentityProvider as OAuthProvider } const tokenStorageKey = "csf.refreshToken" @@ -411,3 +410,4 @@ const useImployApi = () => { } export { ImployApiProvider, useImployApi } + diff --git a/packages/files-ui/src/Components/Layouts/AppHeader.tsx b/packages/files-ui/src/Components/Layouts/AppHeader.tsx index 1d4b1cf5bb..f53b8ab161 100644 --- a/packages/files-ui/src/Components/Layouts/AppHeader.tsx +++ b/packages/files-ui/src/Components/Layouts/AppHeader.tsx @@ -8,7 +8,8 @@ import { ChainsafeFilesLogo, HamburgerMenu, MenuDropdown, - PowerDownSvg + PowerDownSvg, + useHistory } from "@chainsafe/common-components" import { ROUTE_LINKS } from "../FilesRoutes" import SearchModule from "../Modules/SearchModule" @@ -161,11 +162,14 @@ const AppHeader: React.FC = ({ const { isLoggedIn, secured } = useImployApi() const { publicKey, isNewDevice, shouldInitializeAccount, logout } = useThresholdKey() const { getProfileTitle, removeUser } = useUser() + const { history } = useHistory() - const signOut = useCallback(() => { - logout() + const signOut = useCallback(async () => { + await logout() removeUser() - }, [logout, removeUser]) + history.replace("/", {}) + + }, [logout, removeUser, history]) const [searchActive, setSearchActive] = useState(false) diff --git a/packages/files-ui/src/Contexts/ThresholdKeyContext.tsx b/packages/files-ui/src/Contexts/ThresholdKeyContext.tsx index 2990f5e6b9..ab46c97422 100644 --- a/packages/files-ui/src/Contexts/ThresholdKeyContext.tsx +++ b/packages/files-ui/src/Contexts/ThresholdKeyContext.tsx @@ -743,7 +743,7 @@ const ThresholdKeyProvider = ({ children, network = "mainnet", enableLogging = f }) const serviceProvider = (tkey.serviceProvider as unknown) as DirectAuthSdk - serviceProvider.init({ skipSw: false }) + return serviceProvider.init({ skipSw: false }) .then(() => { setStatus("initialized") }) From a7e7a531e8ff2adec52ad51f44d49d25b4e94086 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Fri, 30 Apr 2021 15:57:21 +0200 Subject: [PATCH 10/62] Search working --- .../files-ui/src/Components/FilesRoutes.tsx | 2 +- .../FileBrowsers/SearchFileBrowser.tsx | 29 ++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/packages/files-ui/src/Components/FilesRoutes.tsx b/packages/files-ui/src/Components/FilesRoutes.tsx index 5e280c781e..1477f41577 100644 --- a/packages/files-ui/src/Components/FilesRoutes.tsx +++ b/packages/files-ui/src/Components/FilesRoutes.tsx @@ -18,7 +18,7 @@ export const ROUTE_LINKS = { // TODO: update link ApplyCryptography: "https://chainsafe.io/", Drive: (rawCurrentPath: string) => `/drive${rawCurrentPath}`, - Search: (rawSearchTerm: string) => `/search${rawSearchTerm}`, + Search: (rawSearchTerm: string) => `/search/${rawSearchTerm}`, Bin: "/bin", Settings: `${SETTINGS_BASE}/:path`, SettingsDefault: `${SETTINGS_BASE}`, diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx index 8ffe1640a0..5718ab3e54 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx @@ -4,21 +4,24 @@ import { IFilesBrowserModuleProps, IFilesTableBrowserProps } from "./types" import FilesTableView from "./views/FilesTable.view" import { CONTENT_TYPES } from "../../../Utils/Constants" import DragAndDrop from "../../../Contexts/DnDContext" -import { useHistory, useParams, useToaster } from "@chainsafe/common-components" +import { useHistory, useLocation, useToaster } from "@chainsafe/common-components" import { getParentPathFromFilePath } from "../../../Utils/pathUtils" import { ROUTE_LINKS } from "../../FilesRoutes" -import { useQuery } from "../../../Utils/Helpers" import { t } from "@lingui/macro" import { SearchParams } from "../SearchModule" const SearchFileBrowser: React.FC = ({ controls = false }: IFilesBrowserModuleProps) => { - const { searchTerm } = useParams<{ searchTerm: string }>() + const { pathname } = useLocation() + const [searchTerm, setSearchTerm] = useState(pathname.split("/").slice(2)[0]) + const { redirect } = useHistory() + const { listBuckets, searchFiles } = useDrive() const [bucketType] = useState("csf") const [currentSearchBucket, setCurrentSearchBucket] = useState() const { addToastMessage } = useToaster() - const getSearchResults = async (searchString: string) => { + + const getSearchResults = useCallback(async (searchString: string) => { try { if (!searchString) return [] let bucketId @@ -47,20 +50,26 @@ const SearchFileBrowser: React.FC = ({ controls = fals }) return Promise.reject(err) } - } - const { redirect } = useHistory() + }, [addToastMessage, bucketType, currentSearchBucket, listBuckets, searchFiles]) + + useEffect(() => { + console.log("refreshing") + const drivePath = pathname.split("/").slice(2)[0] + + setSearchTerm(decodeURI(drivePath)) + getSearchResults(decodeURI(drivePath)) + }, [getSearchResults, pathname]) const [loadingSearchResults, setLoadingSearchResults] = useState(true) const [searchResults, setSearchResults] = useState([]) - const querySearch = useQuery().get("search") useEffect(() => { const onSearch = async () => { - if (querySearch) { + if (searchTerm) { try { setLoadingSearchResults(true) - const results = await getSearchResults(querySearch) + const results = await getSearchResults(searchTerm) setSearchResults(results) setLoadingSearchResults(false) } catch { @@ -70,7 +79,7 @@ const SearchFileBrowser: React.FC = ({ controls = fals } onSearch() // eslint-disable-next-line - }, [querySearch]) + }, [searchTerm]) const getSearchEntry = useCallback((cid: string) => searchResults.find( From e2889a962086b12ed2205aebad12d9f6f64b120b Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Fri, 30 Apr 2021 16:11:22 +0200 Subject: [PATCH 11/62] Routing incorrect --- .../src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx | 1 + .../Modules/FileBrowsers/views/FileSystemItemRow.tsx | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index c7609162b4..f75ecf224d 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -88,6 +88,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: } try { + debugger await moveCSFObject({ path: getPathWithFile(currentPath, itemToDelete.name), new_path: getPathWithFile("/", itemToDelete.name), diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx index 06175b6e6e..ab79803600 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx @@ -377,8 +377,7 @@ const FileSystemItemRow: React.FC = ({ const onFolderClick = useCallback(() => { if (!moduleRootPath) return - // React route runs a decode in creating a location in the context so double encoding is needed - const newPath = `${moduleRootPath}${currentPath}${encodeURI(encodeURI(name))}` + const newPath = `${moduleRootPath}${currentPath}${encodeURI(name)}` redirect(newPath) resetSelectedFiles() }, [currentPath, name, resetSelectedFiles, redirect, moduleRootPath]) From 56b326c53b8f4559a21175fdea089ef266652776 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Mon, 3 May 2021 14:46:48 +0200 Subject: [PATCH 12/62] Fixed pathing --- packages/files-ui/src/Utils/pathUtils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/files-ui/src/Utils/pathUtils.ts b/packages/files-ui/src/Utils/pathUtils.ts index 5f77a6d1f4..a2fc814581 100644 --- a/packages/files-ui/src/Utils/pathUtils.ts +++ b/packages/files-ui/src/Utils/pathUtils.ts @@ -31,7 +31,11 @@ export function getPathFromArray(arrayOfPaths: string[]): string { // get path and file export function getPathWithFile(path: string, fileName: string) { - return path === "/" ? `/${fileName}` : `${path}/${fileName}` + return path === "/" + ? `/${fileName}` + : path[path.length - 1] === "/" + ? `${path}${fileName}` + : `${path}/${fileName}` } // get path and file From 6b2b1dde5c46d823498e86c46bc519ada09b1541 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Tue, 4 May 2021 13:25:10 +0200 Subject: [PATCH 13/62] fix delete --- packages/files-ui/src/Contexts/DriveContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index 347d52b7dd..b179c42908 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -500,7 +500,7 @@ const DriveProvider = ({ children }: DriveContextProps) => { } const moveCSFObject = async (body: FilesMvRequest) => { - await moveCSFObject(body) + return await imployApiClient.moveCSFObject(body) } const searchFiles = async (bucketId: string, searchString: string) => { From 1399ad528d6a979ee8b09c6209ef9a70d4ee3163 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Tue, 4 May 2021 15:06:43 +0200 Subject: [PATCH 14/62] Ready for review --- .../files-ui/src/Components/FilesRoutes.tsx | 4 +-- .../src/Components/Layouts/AppNav.tsx | 2 +- .../Modules/FileBrowsers/BinFileBrowser.tsx | 36 ++++++++++--------- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 1 - 4 files changed, 23 insertions(+), 20 deletions(-) diff --git a/packages/files-ui/src/Components/FilesRoutes.tsx b/packages/files-ui/src/Components/FilesRoutes.tsx index 1477f41577..d08cfab001 100644 --- a/packages/files-ui/src/Components/FilesRoutes.tsx +++ b/packages/files-ui/src/Components/FilesRoutes.tsx @@ -19,7 +19,7 @@ export const ROUTE_LINKS = { ApplyCryptography: "https://chainsafe.io/", Drive: (rawCurrentPath: string) => `/drive${rawCurrentPath}`, Search: (rawSearchTerm: string) => `/search/${rawSearchTerm}`, - Bin: "/bin", + Bin: (rawBinPath: string) => `/bin${rawBinPath}`, Settings: `${SETTINGS_BASE}/:path`, SettingsDefault: `${SETTINGS_BASE}`, PurchasePlan: "/purchase" @@ -59,7 +59,7 @@ const FilesRoutes = () => { /> = ({ navOpen, setNavOpen }: IAppNav) => { = ({ controls = false } const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) const [pathContents, setPathContents] = useState([]) - const [bucketType] = useState("csf") - const { currentPath } = useParams<{ currentPath: string }>() + const [bucketType] = useState("trash") + const { pathname } = useLocation() + const [currentPath, setCurrentPath] = useState(pathname.split("/").slice(1).join("/")) const refreshContents = useCallback( async ( @@ -60,10 +61,22 @@ const BinFileBrowser: React.FC = ({ controls = false } [bucketType, list, currentPath] ) + // useEffect(() => { + // refreshContents() + // // eslint-disable-next-line + // }, []) + useEffect(() => { + console.log("refreshing") + let binPath = pathname.split("/").slice(2).join("/").concat("/") + if (binPath[0] !== "/") { + binPath = "/" + binPath + } + + setCurrentPath(decodeURI(binPath)) refreshContents() - // eslint-disable-next-line - }, []) + }, [refreshContents, pathname]) + const deleteFile = useCallback(async (cid: string) => { const itemToDelete = pathContents.find((i) => i.cid === cid) @@ -146,15 +159,6 @@ const BinFileBrowser: React.FC = ({ controls = false } } } - const handleRecover = async (cid: string) => { - // TODO set loading - try { - await recoverFile(cid) - } catch { - // - } - } - const itemOperations: IFilesTableBrowserProps["itemOperations"] = useMemo(() => ({ [CONTENT_TYPES.File]: ["recover", "delete"], [CONTENT_TYPES.Directory]: ["recover", "delete"] @@ -164,10 +168,10 @@ const BinFileBrowser: React.FC = ({ controls = false } = ({ controls = true }: } try { - debugger await moveCSFObject({ path: getPathWithFile(currentPath, itemToDelete.name), new_path: getPathWithFile("/", itemToDelete.name), From 12fd9632f45ff71d476f0b7f0a851046f971d011 Mon Sep 17 00:00:00 2001 From: Ryan Noble Date: Wed, 5 May 2021 14:52:55 +0200 Subject: [PATCH 15/62] Apply suggestions from code review Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> --- .../common-components/src/Router/ConditionalRoute.tsx | 2 +- .../Components/Modules/FileBrowsers/CSFFileBrowser.tsx | 2 -- .../files-ui/src/Components/Modules/FileBrowsers/types.ts | 2 -- .../files-ui/src/Components/Modules/FilePreviewModal.tsx | 2 +- packages/files-ui/src/Components/Modules/SearchModule.tsx | 8 +------- 5 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/common-components/src/Router/ConditionalRoute.tsx b/packages/common-components/src/Router/ConditionalRoute.tsx index 757135b58e..643ce1fbf4 100644 --- a/packages/common-components/src/Router/ConditionalRoute.tsx +++ b/packages/common-components/src/Router/ConditionalRoute.tsx @@ -21,7 +21,7 @@ const ConditionalRoute: React.FC = ({ exact, ...rest }) => { - const { state, pathname } = useLocation() + const { state, pathname } = useLocation<{from?: string} | undefined>() const from = (state as any)?.from return = ({ controls = true }: list } = useDrive() const { addToastMessage } = useToaster() - const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) const [pathContents, setPathContents] = useState([]) const [bucketType] = useState("csf") - const { redirect } = useHistory() const { pathname } = useLocation() diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts index 497b02274d..d3dfb67b5b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts @@ -47,11 +47,9 @@ export interface IFilesTableBrowserProps loadingCurrentPath: boolean uploadsInProgress?: UploadProgress[] showUploadsInTable: boolean - sourceFiles: FileSystemItem[] crumbs: Crumb[] | undefined moduleRootPath: string | undefined - getPath?: (cid: string) => string isSearch?: boolean } diff --git a/packages/files-ui/src/Components/Modules/FilePreviewModal.tsx b/packages/files-ui/src/Components/Modules/FilePreviewModal.tsx index d8863f78b2..bd2b45dc11 100644 --- a/packages/files-ui/src/Components/Modules/FilePreviewModal.tsx +++ b/packages/files-ui/src/Components/Modules/FilePreviewModal.tsx @@ -206,7 +206,7 @@ const FilePreviewModal = ({ file, nextFile, previousFile, closePreview, path, bu }, file, path, - bucketType: bucketType + bucketType }) if (content) { diff --git a/packages/files-ui/src/Components/Modules/SearchModule.tsx b/packages/files-ui/src/Components/Modules/SearchModule.tsx index 4670b84593..9c7e9b731f 100644 --- a/packages/files-ui/src/Components/Modules/SearchModule.tsx +++ b/packages/files-ui/src/Components/Modules/SearchModule.tsx @@ -159,19 +159,13 @@ const SearchModule: React.FC = ({ const ref = useRef(null) const { listBuckets, searchFiles } = useDrive() const { addToastMessage } = useToaster() - const [bucketType] = useState("csf") - const [currentSearchBucket, setCurrentSearchBucket] = useState() - const getSearchResults = async (searchString: string) => { try { if (!searchString) return [] let bucketId - if ( - currentSearchBucket && - currentSearchBucket.bucketType === bucketType - ) { + if (currentSearchBucket?.bucketType === bucketType) { // we have the bucket id bucketId = currentSearchBucket.bucketId } else { From a6765f78b89245565f2cff50c6b6c3a1da17b736 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 5 May 2021 14:53:21 +0200 Subject: [PATCH 16/62] Feedback --- .../src/Components/Modules/FileBrowsers/BinFileBrowser.tsx | 5 ++--- .../src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx | 5 ++--- .../Components/Modules/FileBrowsers/SearchFileBrowser.tsx | 1 - packages/files-ui/src/Utils/pathUtils.ts | 5 +++++ 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index c9013b005f..9a9983b23c 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -8,7 +8,7 @@ import { CONTENT_TYPES } from "../../../Utils/Constants" import { IFilesTableBrowserProps } from "../../Modules/FileBrowsers/types" import { guessContentType } from "../../../Utils/contentTypeGuesser" import { useLocation, useToaster } from "@chainsafe/common-components" -import { getPathWithFile } from "../../../Utils/pathUtils" +import { extractDrivePath, getPathWithFile } from "../../../Utils/pathUtils" import { ROUTE_LINKS } from "../../FilesRoutes" const BinFileBrowser: React.FC = ({ controls = false }: IFilesBrowserModuleProps) => { @@ -67,8 +67,7 @@ const BinFileBrowser: React.FC = ({ controls = false } // }, []) useEffect(() => { - console.log("refreshing") - let binPath = pathname.split("/").slice(2).join("/").concat("/") + let binPath = extractDrivePath(pathname) if (binPath[0] !== "/") { binPath = "/" + binPath } diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index c7609162b4..b57f163afd 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react" import { Crumb, useToaster, useHistory, useLocation } from "@chainsafe/common-components" import { useDrive, FileSystemItem, BucketType } from "../../../Contexts/DriveContext" -import { getArrayOfPaths, getPathFromArray, getPathWithFile } from "../../../Utils/pathUtils" +import { extractDrivePath, getArrayOfPaths, getPathFromArray, getPathWithFile } from "../../../Utils/pathUtils" import { IBulkOperations, IFilesBrowserModuleProps, IFilesTableBrowserProps } from "./types" import FilesTableView from "./views/FilesTable.view" import { CONTENT_TYPES } from "../../../Utils/Constants" @@ -68,8 +68,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: ) useEffect(() => { - console.log("refreshing") - let drivePath = pathname.split("/").slice(2).join("/").concat("/") + let drivePath = extractDrivePath(pathname) if (drivePath[0] !== "/") { drivePath = "/" + drivePath } diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx index 5718ab3e54..29294a213b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx @@ -53,7 +53,6 @@ const SearchFileBrowser: React.FC = ({ controls = fals }, [addToastMessage, bucketType, currentSearchBucket, listBuckets, searchFiles]) useEffect(() => { - console.log("refreshing") const drivePath = pathname.split("/").slice(2)[0] setSearchTerm(decodeURI(drivePath)) diff --git a/packages/files-ui/src/Utils/pathUtils.ts b/packages/files-ui/src/Utils/pathUtils.ts index a2fc814581..3554703e36 100644 --- a/packages/files-ui/src/Utils/pathUtils.ts +++ b/packages/files-ui/src/Utils/pathUtils.ts @@ -44,3 +44,8 @@ export function getParentPathFromFilePath(filePath: string) { if (!parentPath) return "/" else return parentPath } + + +export function extractDrivePath(pathname: string) { + return pathname.split("/").slice(2).join("/").concat("/") +} \ No newline at end of file From 17f8b128eb2609a3a12574fa844413bba4adb917 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 5 May 2021 15:00:56 +0200 Subject: [PATCH 17/62] Feedback --- packages/files-ui/src/Components/Layouts/AppHeader.tsx | 9 ++++++--- .../Components/Modules/FileBrowsers/BinFileBrowser.tsx | 3 ++- .../Components/Modules/FileBrowsers/CSFFileBrowser.tsx | 1 + .../Modules/FileBrowsers/views/FileSystemItemRow.tsx | 5 ++++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/files-ui/src/Components/Layouts/AppHeader.tsx b/packages/files-ui/src/Components/Layouts/AppHeader.tsx index f53b8ab161..a00c48c137 100644 --- a/packages/files-ui/src/Components/Layouts/AppHeader.tsx +++ b/packages/files-ui/src/Components/Layouts/AppHeader.tsx @@ -165,9 +165,12 @@ const AppHeader: React.FC = ({ const { history } = useHistory() const signOut = useCallback(async () => { - await logout() - removeUser() - history.replace("/", {}) + logout() + .catch(console.error) + .finally(() => { + removeUser() + history.replace("/", {}) + }) }, [logout, removeUser, history]) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 9a9983b23c..d4efdf3401 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -55,6 +55,7 @@ const BinFileBrowser: React.FC = ({ controls = false } ) } } catch (error) { + console.error(error) showLoading && setLoadingCurrentPath(false) } }, @@ -123,7 +124,7 @@ const BinFileBrowser: React.FC = ({ controls = false } const recoverFile = async (cid: string) => { const itemToRestore = pathContents.find((i) => i.cid === cid) - if (!itemToRestore) return + if (!itemToRestore) throw "Not found" try { await moveCSFObject({ path: getPathWithFile("/", itemToRestore.name), diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index ab34f239ba..94925ab7bf 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -59,6 +59,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: ) } } catch (error) { + console.error(error) showLoading && setLoadingCurrentPath(false) } }, diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx index ab79803600..88836b0f24 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItemRow.tsx @@ -376,7 +376,10 @@ const FileSystemItemRow: React.FC = ({ } const onFolderClick = useCallback(() => { - if (!moduleRootPath) return + if (!moduleRootPath) { + console.error("Module root path not set") + return + } const newPath = `${moduleRootPath}${currentPath}${encodeURI(name)}` redirect(newPath) resetSelectedFiles() From ce04bdf65ebed8bfeb365f1fdcf717a2b1521a1c Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 5 May 2021 15:27:15 +0200 Subject: [PATCH 18/62] Feedback --- packages/files-ui/src/Contexts/DriveContext.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index b179c42908..7b6122c035 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -400,11 +400,10 @@ const DriveProvider = ({ children }: DriveContextProps) => { throw new Error("No file found.") } - const pathToUse = path try { const result = await imployApiClient.getFileContent( { - path: pathToUse, + path: path, source: { type: bucketType } @@ -503,12 +502,12 @@ const DriveProvider = ({ children }: DriveContextProps) => { return await imployApiClient.moveCSFObject(body) } - const searchFiles = async (bucketId: string, searchString: string) => { + const searchFiles = useCallback(async (bucketId: string, searchString: string) => { return await imployApiClient.searchFiles({ bucket_id: bucketId || "", query: searchString }) - } + }, [imployApiClient]) // const setPassword = async (password: string) => { // if (!masterPassword && (await validateMasterPassword(password))) { From ef239a26f2ef13d2983da7609a940de52dddc23c Mon Sep 17 00:00:00 2001 From: Ryan Noble Date: Wed, 5 May 2021 16:13:20 +0200 Subject: [PATCH 19/62] Update packages/files-ui/src/Contexts/DriveContext.tsx Co-authored-by: Michael Yankelev <12774278+FSM1@users.noreply.github.com> --- packages/files-ui/src/Contexts/DriveContext.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index 7b6122c035..aa700928e9 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -394,7 +394,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { // because the current path will not reflect the right state of the app const fileToGet = file - // TODO: move to implementations if (!fileToGet) { console.error("No file passed, and no file found for cid:", cid, "in pathContents:", path) throw new Error("No file found.") From be8e647b8607285143b45af0b8886dd0a451b836 Mon Sep 17 00:00:00 2001 From: Ryan Noble Date: Wed, 5 May 2021 16:13:41 +0200 Subject: [PATCH 20/62] Update packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx Co-authored-by: Michael Yankelev <12774278+FSM1@users.noreply.github.com> --- .../src/Components/Modules/FileBrowsers/BinFileBrowser.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index d4efdf3401..6b0397a3dc 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -41,7 +41,6 @@ const BinFileBrowser: React.FC = ({ controls = false } showLoading && setLoadingCurrentPath(false) if (newContents) { - // Remove this when the API returns dates setPathContents( newContents?.map((fcr) => ({ ...fcr, From 78f52cb4ad2448bb3eb196e6e4c58432986eae9e Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Wed, 5 May 2021 16:14:13 +0200 Subject: [PATCH 21/62] remove unused code --- packages/files-ui/src/Contexts/DriveContext.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index aa700928e9..be6d3617ec 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -508,17 +508,6 @@ const DriveProvider = ({ children }: DriveContextProps) => { }) }, [imployApiClient]) - // const setPassword = async (password: string) => { - // if (!masterPassword && (await validateMasterPassword(password))) { - // setMasterPassword(password) - // } else { - // console.log( - // "The password is already set, or an incorrect password was entered.", - // ) - // return false - // } - // } - return ( Date: Wed, 5 May 2021 16:17:49 +0200 Subject: [PATCH 22/62] Then catch --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 6b0397a3dc..434a7fc2ba 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -26,33 +26,36 @@ const BinFileBrowser: React.FC = ({ controls = false } const [currentPath, setCurrentPath] = useState(pathname.split("/").slice(1).join("/")) const refreshContents = useCallback( - async ( + ( bucketTypeParam?: BucketType, showLoading?: boolean ) => { try { showLoading && setLoadingCurrentPath(true) - const newContents = await list({ + list({ path: currentPath, source: { type: bucketTypeParam || bucketType } + }).then((newContents) => { + showLoading && setLoadingCurrentPath(false) + + if (newContents) { + setPathContents( + newContents?.map((fcr) => ({ + ...fcr, + content_type: + fcr.content_type !== "application/octet-stream" + ? fcr.content_type + : guessContentType(fcr.name), + isFolder: + fcr.content_type === "application/chainsafe-files-directory" + })) + ) + } + }).catch((error) => { + throw error }) - showLoading && setLoadingCurrentPath(false) - - if (newContents) { - setPathContents( - newContents?.map((fcr) => ({ - ...fcr, - content_type: - fcr.content_type !== "application/octet-stream" - ? fcr.content_type - : guessContentType(fcr.name), - isFolder: - fcr.content_type === "application/chainsafe-files-directory" - })) - ) - } } catch (error) { console.error(error) showLoading && setLoadingCurrentPath(false) From 911ec343330205cb89f7aeb4be948730a798881d Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 5 May 2021 16:20:08 +0200 Subject: [PATCH 23/62] Length check --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 2 +- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 39 ++++++++++--------- 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 434a7fc2ba..db8e11c959 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -40,7 +40,7 @@ const BinFileBrowser: React.FC = ({ controls = false } }).then((newContents) => { showLoading && setLoadingCurrentPath(false) - if (newContents) { + if (newContents?.length > 0) { setPathContents( newContents?.map((fcr) => ({ ...fcr, diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index 94925ab7bf..c8ad0f05fd 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -29,35 +29,38 @@ const CSFFileBrowser: React.FC = ({ controls = true }: const { pathname } = useLocation() const [currentPath, setCurrentPath] = useState(pathname.split("/").slice(1).join("/")) const refreshContents = useCallback( - async ( + ( path: string, bucketTypeParam?: BucketType, showLoading?: boolean ) => { try { showLoading && setLoadingCurrentPath(true) - const newContents = await list({ + list({ path, source: { type: bucketTypeParam || bucketType } + }).then((newContents) => { + showLoading && setLoadingCurrentPath(false) + + if (newContents?.length > 0) { + // Remove this when the API returns dates + setPathContents( + newContents?.map((fcr) => ({ + ...fcr, + content_type: + fcr.content_type !== "application/octet-stream" + ? fcr.content_type + : guessContentType(fcr.name), + isFolder: + fcr.content_type === "application/chainsafe-files-directory" + })) + ) + } + }).catch(error => { + throw error }) - showLoading && setLoadingCurrentPath(false) - - if (newContents) { - // Remove this when the API returns dates - setPathContents( - newContents?.map((fcr) => ({ - ...fcr, - content_type: - fcr.content_type !== "application/octet-stream" - ? fcr.content_type - : guessContentType(fcr.name), - isFolder: - fcr.content_type === "application/chainsafe-files-directory" - })) - ) - } } catch (error) { console.error(error) showLoading && setLoadingCurrentPath(false) From 50c6ce055e0af783680b58e8a260682c4d471813 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Wed, 5 May 2021 21:35:29 +0200 Subject: [PATCH 24/62] Interface change --- .../src/Components/Modules/FileBrowsers/BinFileBrowser.tsx | 2 +- packages/files-ui/src/Components/Modules/FileBrowsers/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index db8e11c959..91f0cb18f9 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -126,7 +126,7 @@ const BinFileBrowser: React.FC = ({ controls = false } const recoverFile = async (cid: string) => { const itemToRestore = pathContents.find((i) => i.cid === cid) - if (!itemToRestore) throw "Not found" + if (!itemToRestore) throw new Error("Not found") try { await moveCSFObject({ path: getPathWithFile("/", itemToRestore.name), diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts index d3dfb67b5b..3b1f729093 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts @@ -41,7 +41,7 @@ export interface IFilesTableBrowserProps path: string, ) => void - refreshContents?: () => Promise + refreshContents?: () => void currentPath: string bucketType: BucketType loadingCurrentPath: boolean From babef46280e7fddca5521842c5d02ed82c2d6e0d Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Thu, 6 May 2021 11:18:06 +0200 Subject: [PATCH 25/62] Fixed updating for empty --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 24 +++++++-------- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 29 +++++++++---------- 2 files changed, 24 insertions(+), 29 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 91f0cb18f9..36f225f9ea 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -40,19 +40,17 @@ const BinFileBrowser: React.FC = ({ controls = false } }).then((newContents) => { showLoading && setLoadingCurrentPath(false) - if (newContents?.length > 0) { - setPathContents( - newContents?.map((fcr) => ({ - ...fcr, - content_type: - fcr.content_type !== "application/octet-stream" - ? fcr.content_type - : guessContentType(fcr.name), - isFolder: - fcr.content_type === "application/chainsafe-files-directory" - })) - ) - } + setPathContents( + newContents.map((fcr) => ({ + ...fcr, + content_type: + fcr.content_type !== "application/octet-stream" + ? fcr.content_type + : guessContentType(fcr.name), + isFolder: + fcr.content_type === "application/chainsafe-files-directory" + })) + ) }).catch((error) => { throw error }) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index cabe9cbb09..b9439e399b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -48,20 +48,18 @@ const CSFFileBrowser: React.FC = ({ controls = true }: }).then((newContents) => { showLoading && setLoadingCurrentPath(false) - if (newContents?.length > 0) { - // Remove this when the API returns dates - setPathContents( - newContents?.map((fcr) => ({ - ...fcr, - content_type: - fcr.content_type !== "application/octet-stream" - ? fcr.content_type - : guessContentType(fcr.name), - isFolder: - fcr.content_type === "application/chainsafe-files-directory" - })) - ) - } + // Remove this when the API returns dates + setPathContents( + newContents.map((fcr) => ({ + ...fcr, + content_type: + fcr.content_type !== "application/octet-stream" + ? fcr.content_type + : guessContentType(fcr.name), + isFolder: + fcr.content_type === "application/chainsafe-files-directory" + })) + ) }).catch(error => { throw error }) @@ -79,7 +77,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: () => profile?.createdAt ? dayjs(Date.now()).diff(profile.createdAt, "day") > 7 : false - , [profile?.createdAt] + , [profile] ) useEffect(() => { @@ -136,7 +134,6 @@ const CSFFileBrowser: React.FC = ({ controls = true }: moveFileToTrash(cid) )) }, [moveFileToTrash]) - // END // Rename const handleRename = useCallback(async (path: string, newPath: string) => { From 25925895a3a36eed58ff7f28a56eab01f1f3cefb Mon Sep 17 00:00:00 2001 From: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> Date: Thu, 6 May 2021 11:20:25 +0100 Subject: [PATCH 26/62] Fix upload (#1010) * fix upload * Update packages/files-ui/src/Contexts/DriveContext.tsx --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 7 +-- .../FileBrowsers/views/FilesTable.view.tsx | 1 + .../Components/Modules/UploadFileModule.tsx | 55 ++++++++----------- .../files-ui/src/Contexts/DriveContext.tsx | 11 ++-- 4 files changed, 30 insertions(+), 44 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 36f225f9ea..663186dc5b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -12,13 +12,8 @@ import { extractDrivePath, getPathWithFile } from "../../../Utils/pathUtils" import { ROUTE_LINKS } from "../../FilesRoutes" const BinFileBrowser: React.FC = ({ controls = false }: IFilesBrowserModuleProps) => { - const { - removeCSFObjects, - moveCSFObject, - list - } = useDrive() + const { removeCSFObjects, moveCSFObject, list } = useDrive() const { addToastMessage } = useToaster() - const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) const [pathContents, setPathContents] = useState([]) const [bucketType] = useState("trash") diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx index 69581d07e4..558d588165 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx @@ -941,6 +941,7 @@ const FilesTableView = ({ setIsUploadModalOpen(false)} + currentPath={currentPath} /> interface IUploadFileModuleProps { modalOpen: boolean close: () => void + currentPath: string } -const UploadFileModule: React.FC = ({ - modalOpen, - close -}: IUploadFileModuleProps) => { +const UploadFileModule = ({ modalOpen, close, currentPath }: IUploadFileModuleProps) => { const classes = useStyles() - const [isDoneDisabled, setIsDoneDisabled] = useState(true) const { uploadFiles } = useDrive() - - const UploadSchema = object().shape({ - files: array().required("Please select a file to upload") - }) + const UploadSchema = object().shape({ files: array().required(t`Please select a file to upload`) }) const onFileNumberChange = useCallback((filesNumber: number) => { setIsDoneDisabled(filesNumber === 0) }, []) - const { currentPath } = useParams<{ currentPath: string }>() + const onSubmit = useCallback((values, helpers) => { + helpers.setSubmitting(true) + try { + uploadFiles(values.files, currentPath) + helpers.resetForm() + close() + } catch (errors) { + if (errors[0].message.includes("conflict with existing")) { + helpers.setFieldError("files", "File/Folder exists") + } else { + helpers.setFieldError("files", errors[0].message) + } + } + helpers.setSubmitting(false) + }, [close, currentPath, uploadFiles]) return ( = ({ }} > { - helpers.setSubmitting(true) - try { - uploadFiles(values.files, currentPath) - helpers.resetForm() - close() - } catch (errors) { - if (errors[0].message.includes("conflict with existing")) { - helpers.setFieldError("files", "File/Folder exists") - } else { - helpers.setFieldError("files", errors[0].message) - } - } - helpers.setSubmitting(false) - }} + onSubmit={onSubmit} >
{ const uploadFiles = useCallback(async (files: File[], path: string) => { const startUploadFile = async () => { - if (!encryptionKey) return // TODO: Add better error handling here. + if (!encryptionKey) { + console.error("No encryption key") + return + } const id = uuidv4() const uploadProgress: UploadProgress = { @@ -278,6 +278,7 @@ const DriveProvider = ({ children }: DriveContextProps) => { return result } catch (error) { + console.error(error) // setting error let errorMessage = t`Something went wrong. We couldn't upload your file` From 416d7907cf30cb76eb53f96a37047d379b7ea337 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 6 May 2021 10:21:54 +0000 Subject: [PATCH 27/62] lingui extract --- packages/files-ui/src/locales/en/messages.po | 3 +++ packages/files-ui/src/locales/fr/messages.po | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/files-ui/src/locales/en/messages.po b/packages/files-ui/src/locales/en/messages.po index 1c8d6ef1e5..18d439e136 100644 --- a/packages/files-ui/src/locales/en/messages.po +++ b/packages/files-ui/src/locales/en/messages.po @@ -376,6 +376,9 @@ msgstr "Please enter a folder name" msgid "Please provide a password" msgstr "Please provide a password" +msgid "Please select a file to upload" +msgstr "Please select a file to upload" + msgid "Preview" msgstr "Preview" diff --git a/packages/files-ui/src/locales/fr/messages.po b/packages/files-ui/src/locales/fr/messages.po index ceaf54a595..d8cd6c7745 100644 --- a/packages/files-ui/src/locales/fr/messages.po +++ b/packages/files-ui/src/locales/fr/messages.po @@ -377,6 +377,9 @@ msgstr "Nom du dossier" msgid "Please provide a password" msgstr "Merci de donner un mot de passe" +msgid "Please select a file to upload" +msgstr "" + msgid "Preview" msgstr "Aperçu" From 075c29bd43eb31ccaa77ee1484d389996a858732 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Thu, 6 May 2021 13:49:49 +0200 Subject: [PATCH 28/62] Reduced number of calls --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 19 ++++++++++--------- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 16 +++++++++++----- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 663186dc5b..00a7802a03 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -18,7 +18,7 @@ const BinFileBrowser: React.FC = ({ controls = false } const [pathContents, setPathContents] = useState([]) const [bucketType] = useState("trash") const { pathname } = useLocation() - const [currentPath, setCurrentPath] = useState(pathname.split("/").slice(1).join("/")) + const [currentPath, setCurrentPath] = useState(extractDrivePath(pathname.split("/").slice(1).join("/"))) const refreshContents = useCallback( ( @@ -57,20 +57,21 @@ const BinFileBrowser: React.FC = ({ controls = false } [bucketType, list, currentPath] ) - // useEffect(() => { - // refreshContents() - // // eslint-disable-next-line - // }, []) + useEffect(() => { + refreshContents() + // eslint-disable-next-line + }, []) useEffect(() => { let binPath = extractDrivePath(pathname) if (binPath[0] !== "/") { binPath = "/" + binPath } - - setCurrentPath(decodeURI(binPath)) - refreshContents() - }, [refreshContents, pathname]) + if (binPath !== currentPath) { + setCurrentPath(decodeURI(binPath)) + refreshContents() + } + }, [refreshContents, pathname, currentPath]) const deleteFile = useCallback(async (cid: string) => { diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index b9439e399b..e78d74f937 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -31,7 +31,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: const { redirect } = useHistory() const { pathname } = useLocation() - const [currentPath, setCurrentPath] = useState(pathname.split("/").slice(1).join("/")) + const [currentPath, setCurrentPath] = useState(extractDrivePath(pathname.split("/").slice(1).join("/"))) const refreshContents = useCallback( ( path: string, @@ -80,15 +80,21 @@ const CSFFileBrowser: React.FC = ({ controls = true }: , [profile] ) + useEffect(() => { + refreshContents(currentPath) + // eslint-disable-next-line + }, []) + useEffect(() => { let drivePath = extractDrivePath(pathname) if (drivePath[0] !== "/") { drivePath = "/" + drivePath } - - setCurrentPath(decodeURI(drivePath)) - refreshContents(decodeURI(drivePath)) - }, [refreshContents, pathname]) + if (drivePath !== currentPath) { + setCurrentPath(decodeURI(drivePath)) + refreshContents(decodeURI(drivePath)) + } + }, [refreshContents, pathname, currentPath]) // From drive const moveFileToTrash = useCallback(async (cid: string) => { From 212e8b511fa580e9bdd1e9c4c3f1235275a318b9 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Thu, 6 May 2021 17:45:24 +0200 Subject: [PATCH 29/62] Feed back --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 13 ++++++------- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 11 +++++------ 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 00a7802a03..e9f6fa3113 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -16,13 +16,12 @@ const BinFileBrowser: React.FC = ({ controls = false } const { addToastMessage } = useToaster() const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) const [pathContents, setPathContents] = useState([]) - const [bucketType] = useState("trash") + const bucketType: BucketType = "trash" const { pathname } = useLocation() const [currentPath, setCurrentPath] = useState(extractDrivePath(pathname.split("/").slice(1).join("/"))) const refreshContents = useCallback( ( - bucketTypeParam?: BucketType, showLoading?: boolean ) => { try { @@ -30,7 +29,7 @@ const BinFileBrowser: React.FC = ({ controls = false } list({ path: currentPath, source: { - type: bucketTypeParam || bucketType + type: bucketType } }).then((newContents) => { showLoading && setLoadingCurrentPath(false) @@ -58,7 +57,7 @@ const BinFileBrowser: React.FC = ({ controls = false } ) useEffect(() => { - refreshContents() + refreshContents(true) // eslint-disable-next-line }, []) @@ -69,7 +68,7 @@ const BinFileBrowser: React.FC = ({ controls = false } } if (binPath !== currentPath) { setCurrentPath(decodeURI(binPath)) - refreshContents() + refreshContents(true) } }, [refreshContents, pathname, currentPath]) @@ -89,7 +88,7 @@ const BinFileBrowser: React.FC = ({ controls = false } type: bucketType } }) - await refreshContents() + refreshContents() const message = `${ itemToDelete.isFolder ? t`Folder` : t`File` } ${t`deleted successfully`}` @@ -132,7 +131,7 @@ const BinFileBrowser: React.FC = ({ controls = false } type: "csf" } }) - await refreshContents() + refreshContents() const message = `${ itemToRestore.isFolder ? t`Folder` : t`File` diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index e78d74f937..04aaa3b5f2 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -27,7 +27,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: const { addToastMessage } = useToaster() const [loadingCurrentPath, setLoadingCurrentPath] = useState(false) const [pathContents, setPathContents] = useState([]) - const [bucketType] = useState("csf") + const bucketType: BucketType = "csf" const { redirect } = useHistory() const { pathname } = useLocation() @@ -35,7 +35,6 @@ const CSFFileBrowser: React.FC = ({ controls = true }: const refreshContents = useCallback( ( path: string, - bucketTypeParam?: BucketType, showLoading?: boolean ) => { try { @@ -43,7 +42,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: list({ path, source: { - type: bucketTypeParam || bucketType + type: bucketType } }).then((newContents) => { showLoading && setLoadingCurrentPath(false) @@ -81,7 +80,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: ) useEffect(() => { - refreshContents(currentPath) + refreshContents(currentPath, true) // eslint-disable-next-line }, []) @@ -92,7 +91,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: } if (drivePath !== currentPath) { setCurrentPath(decodeURI(drivePath)) - refreshContents(decodeURI(drivePath)) + refreshContents(decodeURI(drivePath), true) } }, [refreshContents, pathname, currentPath]) @@ -113,7 +112,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: type: "trash" } }) - await refreshContents(currentPath) + refreshContents(currentPath) const message = `${ itemToDelete.isFolder ? t`Folder` : t`File` } ${t`deleted successfully`}` From 399b2b9109be6227a612b04ffd093637141e2e81 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Fri, 7 May 2021 18:03:57 +0200 Subject: [PATCH 30/62] Changed to debug --- .../FileBrowsers/views/FileSystemItem/FileSystemItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx index 060c2252dc..fcbb0eb5f5 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx @@ -315,7 +315,7 @@ const FileSystemItemRow: React.FC = ({ const onFolderClick = useCallback(() => { if (!moduleRootPath) { - console.error("Module root path not set") + console.debug("Module root path not set") return } const newPath = `${moduleRootPath}${currentPath}${encodeURI(name)}` From 96f51b441c447c1c5b5fa79acd479f7aee7d116a Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Mon, 10 May 2021 00:47:05 +0200 Subject: [PATCH 31/62] ensure that contents are refreshed --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 5 +- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 5 +- .../Modules/FileBrowsers/MoveFileModal.tsx | 4 +- .../Components/Modules/FileBrowsers/types.ts | 2 +- .../FileBrowsers/views/FilesTable.view.tsx | 48 +++--- .../Components/Modules/UploadFileModule.tsx | 11 +- .../files-ui/src/Contexts/DriveContext.tsx | 148 +++++++++--------- 7 files changed, 117 insertions(+), 106 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index e9f6fa3113..b79496bc54 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -110,11 +110,12 @@ const BinFileBrowser: React.FC = ({ controls = false } }, [addToastMessage, bucketType, currentPath, pathContents, refreshContents, removeCSFObjects]) const deleteFiles = useCallback(async (cids: string[]) => { - return Promise.all( + await Promise.all( cids.map((cid: string) => deleteFile(cid) )) - }, [deleteFile]) + refreshContents() + }, [deleteFile, refreshContents]) const recoverFile = async (cid: string) => { diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index a8684b7fe4..feb724bf75 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -142,11 +142,12 @@ const CSFFileBrowser: React.FC = ({ controls = true }: }, [addToastMessage, currentPath, pathContents, refreshContents, moveCSFObject]) const moveFilesToTrash = useCallback(async (cids: string[]) => { - return Promise.all( + await Promise.all( cids.map((cid: string) => moveFileToTrash(cid) )) - }, [moveFileToTrash]) + await refreshContents(currentPath) + }, [moveFileToTrash, refreshContents, currentPath]) // Rename const handleRename = useCallback(async (path: string, newPath: string) => { diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx index f86700c1a9..a854aa2024 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx @@ -68,9 +68,10 @@ interface IMoveFileModuleProps { modalOpen: boolean onClose: () => void onCancel: () => void + refreshCurrentPath: () => void } -const MoveFileModule = ({ currentPath, filesToMove, modalOpen, onClose, onCancel }: IMoveFileModuleProps) => { +const MoveFileModule = ({ currentPath, filesToMove, modalOpen, onClose, onCancel, refreshCurrentPath }: IMoveFileModuleProps) => { const classes = useStyles() const { getFolderTree, moveFiles } = useDrive() const [movingFile, setMovingFile] = useState(false) @@ -126,6 +127,7 @@ const MoveFileModule = ({ currentPath, filesToMove, modalOpen, onClose, onCancel new_path: getPathWithFile(movePath, file.name) })) ) + .then(refreshCurrentPath) .then(onClose) .catch(console.error) .finally(() => setMovingFile(false)) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts index 9b83132685..06d4f9cadc 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts @@ -32,7 +32,7 @@ export interface IFilesTableBrowserProps handleRename?: (path: string, new_path: string) => Promise handleMove?: (path: string, new_path: string) => Promise downloadFile?: (cid: string) => Promise - deleteFiles?: (cid: string[]) => Promise + deleteFiles?: (cid: string[]) => Promise recoverFile?: (cid: string) => Promise viewFolder?: (cid: string) => void allowDropUpload?: boolean diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx index f99b03fca8..056d2a33e1 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx @@ -428,6 +428,7 @@ const FilesTableView = ({ handleUploadOnDrop && currentPath && handleUploadOnDrop(item.files, item.items, currentPath) + refreshContents && refreshContents() } }, collect: (monitor) => ({ @@ -935,29 +936,34 @@ const FilesTableView = ({ { refreshContents && ( - setCreateFolderModalOpen(false)} - /> + <> + setCreateFolderModalOpen(false)} + /> + setIsUploadModalOpen(false)} + refreshCurrentPath={refreshContents} + currentPath={currentPath} + /> + { + setIsMoveFileModalOpen(false) + setSelectedCids([]) + }} + onCancel={() => setIsMoveFileModalOpen(false)} + /> + ) } - setIsUploadModalOpen(false)} - currentPath={currentPath} - /> - { - setIsMoveFileModalOpen(false) - setSelectedCids([]) - }} - onCancel={() => setIsMoveFileModalOpen(false)} - /> + setFileInfoPath(undefined)} diff --git a/packages/files-ui/src/Components/Modules/UploadFileModule.tsx b/packages/files-ui/src/Components/Modules/UploadFileModule.tsx index 346d9948a9..007cf34076 100644 --- a/packages/files-ui/src/Components/Modules/UploadFileModule.tsx +++ b/packages/files-ui/src/Components/Modules/UploadFileModule.tsx @@ -72,9 +72,10 @@ interface IUploadFileModuleProps { modalOpen: boolean close: () => void currentPath: string + refreshCurrentPath: () => void } -const UploadFileModule = ({ modalOpen, close, currentPath }: IUploadFileModuleProps) => { +const UploadFileModule = ({ modalOpen, close, currentPath, refreshCurrentPath }: IUploadFileModuleProps) => { const classes = useStyles() const [isDoneDisabled, setIsDoneDisabled] = useState(true) const { uploadFiles } = useDrive() @@ -84,11 +85,13 @@ const UploadFileModule = ({ modalOpen, close, currentPath }: IUploadFileModulePr setIsDoneDisabled(filesNumber === 0) }, []) - const onSubmit = useCallback((values, helpers) => { + const onSubmit = useCallback(async (values, helpers) => { helpers.setSubmitting(true) try { - uploadFiles(values.files, currentPath) + await uploadFiles(values.files, currentPath) helpers.resetForm() + refreshCurrentPath() + debugger close() } catch (errors) { if (errors[0].message.includes("conflict with existing")) { @@ -98,7 +101,7 @@ const UploadFileModule = ({ modalOpen, close, currentPath }: IUploadFileModulePr } } helpers.setSubmitting(false) - }, [close, currentPath, uploadFiles]) + }, [close, currentPath, uploadFiles, refreshCurrentPath]) return ( void + uploadFiles: (files: File[], path: string) => Promise createFolder: (body: FilesPathRequest) => Promise renameFile: (body: FilesMvRequest) => Promise moveFile: (body: FilesMvRequest) => Promise @@ -213,89 +213,87 @@ const DriveProvider = ({ children }: DriveContextProps) => { }) const uploadFiles = useCallback(async (files: File[], path: string) => { - const startUploadFile = async () => { - if (!encryptionKey) { - console.error("No encryption key") - return - } - const id = uuidv4() - const uploadProgress: UploadProgress = { - id, - fileName: files[0].name, // TODO: Do we need this? - complete: false, - error: false, - noOfFiles: files.length, - progress: 0, - path - } - dispatchUploadsInProgress({ type: "add", payload: uploadProgress }) - try { - const filesParam = await Promise.all( - files - .filter((f) => f.size <= MAX_FILE_SIZE) - .map(async (f) => { - const fileData = await readFileAsync(f) - const encryptedData = await encryptFile(fileData, encryptionKey) - return { - data: new Blob([encryptedData], { type: f.type }), - fileName: f.name - } - }) - ) - if (filesParam.length !== files.length) { - addToastMessage({ - message: + if (!encryptionKey) { + console.error("No encryption key") + return + } + + const id = uuidv4() + const uploadProgress: UploadProgress = { + id, + fileName: files[0].name, // TODO: Do we need this? + complete: false, + error: false, + noOfFiles: files.length, + progress: 0, + path + } + dispatchUploadsInProgress({ type: "add", payload: uploadProgress }) + try { + const filesParam = await Promise.all( + files + .filter((f) => f.size <= MAX_FILE_SIZE) + .map(async (f) => { + const fileData = await readFileAsync(f) + const encryptedData = await encryptFile(fileData, encryptionKey) + return { + data: new Blob([encryptedData], { type: f.type }), + fileName: f.name + } + }) + ) + if (filesParam.length !== files.length) { + addToastMessage({ + message: "We can't encrypt files larger than 2GB. Some items will not be uploaded", - appearance: "error" + appearance: "error" + }) + } + // API call + const result = await imployApiClient.addCSFFiles( + filesParam, + path, + "", + undefined, + undefined, + (progressEvent: { loaded: number; total: number }) => { + dispatchUploadsInProgress({ + type: "progress", + payload: { + id, + progress: Math.ceil( + (progressEvent.loaded / progressEvent.total) * 100 + ) + } }) } - // API call - const result = await imployApiClient.addCSFFiles( - filesParam, - path, - "", - undefined, - undefined, - (progressEvent: { loaded: number; total: number }) => { - dispatchUploadsInProgress({ - type: "progress", - payload: { - id, - progress: Math.ceil( - (progressEvent.loaded / progressEvent.total) * 100 - ) - } - }) - } - ) + ) - // setting complete - dispatchUploadsInProgress({ type: "complete", payload: { id } }) - setTimeout(() => { - dispatchUploadsInProgress({ type: "remove", payload: { id } }) - }, REMOVE_UPLOAD_PROGRESS_DELAY) + // setting complete + dispatchUploadsInProgress({ type: "complete", payload: { id } }) + setTimeout(() => { + dispatchUploadsInProgress({ type: "remove", payload: { id } }) + }, REMOVE_UPLOAD_PROGRESS_DELAY) - return result - } catch (error) { - console.error(error) - // setting error - let errorMessage = t`Something went wrong. We couldn't upload your file` + return Promise.resolve() + } catch (error) { + console.error(error) + // setting error + let errorMessage = t`Something went wrong. We couldn't upload your file` - // we will need a method to parse server errors - if (Array.isArray(error) && error[0].message.includes("conflict")) { - errorMessage = t`A file with the same name already exists` - } - dispatchUploadsInProgress({ - type: "error", - payload: { id, errorMessage } - }) - setTimeout(() => { - dispatchUploadsInProgress({ type: "remove", payload: { id } }) - }, REMOVE_UPLOAD_PROGRESS_DELAY) + // we will need a method to parse server errors + if (Array.isArray(error) && error[0].message.includes("conflict")) { + errorMessage = t`A file with the same name already exists` } + dispatchUploadsInProgress({ + type: "error", + payload: { id, errorMessage } + }) + setTimeout(() => { + dispatchUploadsInProgress({ type: "remove", payload: { id } }) + }, REMOVE_UPLOAD_PROGRESS_DELAY) } - startUploadFile() }, [addToastMessage, encryptionKey, imployApiClient]) const createFolder = async (body: FilesPathRequest) => { From b7c9c2eb0a1ebe9fd798ee8d6946aa795e3bff78 Mon Sep 17 00:00:00 2001 From: Michael Yankelev Date: Mon, 10 May 2021 00:48:01 +0200 Subject: [PATCH 32/62] remove unused --- packages/files-ui/src/Contexts/DriveContext.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/files-ui/src/Contexts/DriveContext.tsx b/packages/files-ui/src/Contexts/DriveContext.tsx index f814f24fac..b5bcaa5908 100644 --- a/packages/files-ui/src/Contexts/DriveContext.tsx +++ b/packages/files-ui/src/Contexts/DriveContext.tsx @@ -251,7 +251,7 @@ const DriveProvider = ({ children }: DriveContextProps) => { }) } // API call - const result = await imployApiClient.addCSFFiles( + await imployApiClient.addCSFFiles( filesParam, path, "", From 058e172f54673c3f2f42362dd19fa748e5dd8733 Mon Sep 17 00:00:00 2001 From: ryry79261 Date: Mon, 10 May 2021 10:56:00 +0200 Subject: [PATCH 33/62] Removed debugger --- packages/files-ui/src/Components/Modules/UploadFileModule.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/files-ui/src/Components/Modules/UploadFileModule.tsx b/packages/files-ui/src/Components/Modules/UploadFileModule.tsx index 007cf34076..241307a576 100644 --- a/packages/files-ui/src/Components/Modules/UploadFileModule.tsx +++ b/packages/files-ui/src/Components/Modules/UploadFileModule.tsx @@ -91,7 +91,6 @@ const UploadFileModule = ({ modalOpen, close, currentPath, refreshCurrentPath }: await uploadFiles(values.files, currentPath) helpers.resetForm() refreshCurrentPath() - debugger close() } catch (errors) { if (errors[0].message.includes("conflict with existing")) { From c48240154fda812846e0d934a017f9c119a48061 Mon Sep 17 00:00:00 2001 From: Tanmoy Basak Anjan Date: Mon, 10 May 2021 23:26:27 +0600 Subject: [PATCH 34/62] Bulk operations for Bin (#1017) * bulk operations bin * lingui extract * Update packages/files-ui/src/locales/fr/messages.po Co-authored-by: GitHub Actions Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> --- .../Modules/FileBrowsers/BinFileBrowser.tsx | 20 +++++++++++--- .../Components/Modules/FileBrowsers/types.ts | 1 + .../FileBrowsers/views/FilesTable.view.tsx | 26 ++++++++++++++++++- packages/files-ui/src/locales/en/messages.po | 3 +++ packages/files-ui/src/locales/fr/messages.po | 3 +++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index b79496bc54..11c478e86b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react" import { BucketType, FileSystemItem, useDrive } from "../../../Contexts/DriveContext" -import { IFilesBrowserModuleProps } from "./types" +import { IBulkOperations, IFilesBrowserModuleProps } from "./types" import FilesTableView from "./views/FilesTable.view" import DragAndDrop from "../../../Contexts/DnDContext" import { t } from "@lingui/macro" @@ -118,7 +118,7 @@ const BinFileBrowser: React.FC = ({ controls = false } }, [deleteFile, refreshContents]) - const recoverFile = async (cid: string) => { + const recoverFile = useCallback(async (cid: string) => { const itemToRestore = pathContents.find((i) => i.cid === cid) if (!itemToRestore) throw new Error("Not found") try { @@ -153,7 +153,19 @@ const BinFileBrowser: React.FC = ({ controls = false } }) return Promise.reject() } - } + }, [addToastMessage, moveCSFObject, pathContents, refreshContents]) + + const recoverFiles = useCallback(async (cids: string[]) => { + return Promise.all( + cids.map((cid: string) => + recoverFile(cid) + )) + }, [recoverFile]) + + const bulkOperations: IBulkOperations = useMemo(() => ({ + [CONTENT_TYPES.Directory]: [], + [CONTENT_TYPES.File]: ["recover", "delete"] + }), []) const itemOperations: IFilesTableBrowserProps["itemOperations"] = useMemo(() => ({ [CONTENT_TYPES.File]: ["recover", "delete"], @@ -166,6 +178,7 @@ const BinFileBrowser: React.FC = ({ controls = false } crumbs={undefined} recoverFile={recoverFile} deleteFiles={deleteFiles} + recoverFiles={recoverFiles} currentPath={currentPath} moduleRootPath={ROUTE_LINKS.Bin("/")} refreshContents={refreshContents} @@ -176,6 +189,7 @@ const BinFileBrowser: React.FC = ({ controls = false } controls={controls} bucketType={bucketType} itemOperations={itemOperations} + bulkOperations={bulkOperations} /> ) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts index 06d4f9cadc..1bbe867a16 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/types.ts @@ -34,6 +34,7 @@ export interface IFilesTableBrowserProps downloadFile?: (cid: string) => Promise deleteFiles?: (cid: string[]) => Promise recoverFile?: (cid: string) => Promise + recoverFiles?: (cid: string[]) => Promise viewFolder?: (cid: string) => void allowDropUpload?: boolean diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx index 056d2a33e1..2a8e1dcfac 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx @@ -286,6 +286,7 @@ const FilesTableView = ({ downloadFile, deleteFiles, recoverFile, + recoverFiles, viewFolder, currentPath, refreshContents, @@ -444,6 +445,7 @@ const FilesTableView = ({ const [isMoveFileModalOpen, setIsMoveFileModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) const [isDeletingFiles, setIsDeletingFiles] = useState(false) + const [isRecoveringFiles, setIsRecoveringFiles] = useState(false) const [fileInfoPath, setFileInfoPath] = useState( undefined ) @@ -461,7 +463,8 @@ const FilesTableView = ({ "move", "preview", "rename", - "share" + "share", + "recover" ] for (let i = 0; i < selectedCids.length; i++) { const contentType = items.find((item) => item.cid === selectedCids[i]) @@ -520,6 +523,18 @@ const FilesTableView = ({ }) }, [deleteFiles, selectedCids]) + const handleRecoverFiles = useCallback(() => { + if (!recoverFiles) return + + setIsRecoveringFiles(true) + recoverFiles(selectedCids) + .catch(console.error) + .finally(() => { + setIsRecoveringFiles(false) + setSelectedCids([]) + }) + }, [recoverFiles, selectedCids]) + const getItemOperations = useCallback( (contentType: string) => { const result = Object.keys(itemOperations).reduce( @@ -695,6 +710,15 @@ const FilesTableView = ({ Move selected )} + {validBulkOps.indexOf("recover") >= 0 && ( + + )} {validBulkOps.indexOf("delete") >= 0 && ( - )} - {validBulkOps.indexOf("recover") >= 0 && ( - - )} - {validBulkOps.indexOf("delete") >= 0 && ( - - )} - - )} + +
+ {selectedCids.length > 0 && ( + <> + {validBulkOps.indexOf("move") >= 0 && ( + + )} + {validBulkOps.indexOf("recover") >= 0 && ( + + )} + {validBulkOps.indexOf("delete") >= 0 && ( + + )} + + )} +
Date: Fri, 14 May 2021 14:37:32 +0100 Subject: [PATCH 40/62] fix (#1030) Co-authored-by: Thibaut Sardan --- .../Components/Modules/LoginModule/AuthenticationFactors.tsx | 1 - packages/files-ui/src/Components/Pages/LoginPage.tsx | 3 --- 2 files changed, 4 deletions(-) diff --git a/packages/files-ui/src/Components/Modules/LoginModule/AuthenticationFactors.tsx b/packages/files-ui/src/Components/Modules/LoginModule/AuthenticationFactors.tsx index 75d26bfc2d..a79feddf2b 100644 --- a/packages/files-ui/src/Components/Modules/LoginModule/AuthenticationFactors.tsx +++ b/packages/files-ui/src/Components/Modules/LoginModule/AuthenticationFactors.tsx @@ -16,7 +16,6 @@ const useStyles = makeStyles(({ breakpoints, constants, typography, palette, zIn width: "100vw", [breakpoints.up("md")]: { maxWidth: 580, - minHeight: "64vh", padding: `${constants.generalUnit * 6.5}px ${constants.generalUnit * 5}px` }, [breakpoints.down("md")]: { diff --git a/packages/files-ui/src/Components/Pages/LoginPage.tsx b/packages/files-ui/src/Components/Pages/LoginPage.tsx index d025dc5137..60d068561e 100644 --- a/packages/files-ui/src/Components/Pages/LoginPage.tsx +++ b/packages/files-ui/src/Components/Pages/LoginPage.tsx @@ -109,9 +109,6 @@ const useStyles = makeStyles( border: `1px solid ${constants.landing.border}`, boxShadow: constants.landing.boxShadow, borderRadius: 6, - [breakpoints.up("md")]:{ - justifyContent: "space-between" - }, [breakpoints.down("md")]: { justifyContent: "center", width: "100%" From 43980ddc9b09f64896480202d866d1e54dc7ef8f Mon Sep 17 00:00:00 2001 From: Ryan Noble Date: Mon, 17 May 2021 12:27:47 +0200 Subject: [PATCH 41/62] File browser context provider (#1026) * Pulling out funcions * Pulling out funcions * provider wired * Apply suggestions from code review Co-authored-by: Priom Chowdhury * Linting * Fied linter * Update packages/files-ui/src/Contexts/FileBrowserContext.tsx Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> * Update packages/files-ui/src/Contexts/FileBrowserContext.tsx Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> * Update packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> Co-authored-by: Priom Chowdhury Co-authored-by: Michael Yankelev <12774278+FSM1@users.noreply.github.com> Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> --- .../Components/Modules/CreateFolderModule.tsx | 8 +- .../Modules/FileBrowsers/BinFileBrowser.tsx | 41 +++++----- .../Modules/FileBrowsers/CSFFileBrowser.tsx | 57 +++++++------- .../Modules/FileBrowsers/MoveFileModal.tsx | 10 ++- .../FileBrowsers/SearchFileBrowser.tsx | 37 +++++----- .../views/FileSystemItem/FileSystemItem.tsx | 10 +-- .../FileBrowsers/views/FilesTable.view.tsx | 74 +++++++------------ .../Components/Modules/UploadFileModule.tsx | 11 +-- .../src/Contexts/FileBrowserContext.tsx | 49 ++++++++++++ 9 files changed, 166 insertions(+), 131 deletions(-) create mode 100644 packages/files-ui/src/Contexts/FileBrowserContext.tsx diff --git a/packages/files-ui/src/Components/Modules/CreateFolderModule.tsx b/packages/files-ui/src/Components/Modules/CreateFolderModule.tsx index 39d9aa8106..a3d16d4e2f 100644 --- a/packages/files-ui/src/Components/Modules/CreateFolderModule.tsx +++ b/packages/files-ui/src/Components/Modules/CreateFolderModule.tsx @@ -17,6 +17,7 @@ import CustomModal from "../Elements/CustomModal" import CustomButton from "../Elements/CustomButton" import { Trans } from "@lingui/macro" import { CSFTheme } from "../../Themes/types" +import { useFileBrowser } from "../../Contexts/FileBrowserContext" const useStyles = makeStyles( ({ breakpoints, constants, typography, zIndex }: CSFTheme) => { @@ -71,18 +72,15 @@ const useStyles = makeStyles( interface ICreateFolderModuleProps { modalOpen: boolean - refreshCurrentPath: () => void close: () => void - currentPath: string } const CreateFolderModule: React.FC = ({ modalOpen, - refreshCurrentPath, - currentPath, close }: ICreateFolderModuleProps) => { const classes = useStyles() + const { currentPath, refreshContents } = useFileBrowser() const { createFolder } = useDrive() const [creatingFolder, setCreatingFolder] = useState(false) @@ -127,7 +125,7 @@ const CreateFolderModule: React.FC = ({ try { setCreatingFolder(true) await createFolder({ path: currentPath + values.name }) - refreshCurrentPath() + refreshContents && refreshContents() setCreatingFolder(false) helpers.resetForm() close() diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx index 11c478e86b..f80c997999 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/BinFileBrowser.tsx @@ -10,6 +10,7 @@ import { guessContentType } from "../../../Utils/contentTypeGuesser" import { useLocation, useToaster } from "@chainsafe/common-components" import { extractDrivePath, getPathWithFile } from "../../../Utils/pathUtils" import { ROUTE_LINKS } from "../../FilesRoutes" +import { FileBrowserContext } from "../../../Contexts/FileBrowserContext" const BinFileBrowser: React.FC = ({ controls = false }: IFilesBrowserModuleProps) => { const { removeCSFObjects, moveCSFObject, list } = useDrive() @@ -173,25 +174,27 @@ const BinFileBrowser: React.FC = ({ controls = false } }), []) return ( - - - + + + + + ) } diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx index feb724bf75..06ddbc7f0c 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/CSFFileBrowser.tsx @@ -13,8 +13,9 @@ import dayjs from "dayjs" import { useUser } from "@chainsafe/common-contexts" import { useLocalStorage } from "@chainsafe/browser-storage-hooks" import { DISMISSED_SURVEY_KEY } from "../../SurveyBanner" +import { FileBrowserContext } from "../../../Contexts/FileBrowserContext" -const CSFFileBrowser: React.FC = ({ controls = true }: IFilesBrowserModuleProps) => { +const CSFFileBrowser: React.FC = () => { const { downloadFile, renameFile, @@ -217,7 +218,7 @@ const CSFFileBrowser: React.FC = ({ controls = true }: [CONTENT_TYPES.File]: ["delete", "move"] }), []) - const ItemOperations: IFilesTableBrowserProps["itemOperations"] = useMemo(() => ({ + const itemOperations: IFilesTableBrowserProps["itemOperations"] = useMemo(() => ({ [CONTENT_TYPES.Audio]: ["preview"], [CONTENT_TYPES.MP4]: ["preview"], [CONTENT_TYPES.Image]: ["preview"], @@ -228,31 +229,33 @@ const CSFFileBrowser: React.FC = ({ controls = true }: }), []) return ( - - refreshContents(currentPath)} - deleteFiles={moveFilesToTrash} - downloadFile={handleDownload} - handleMove={handleMove} - handleRename={handleRename} - viewFolder={viewFolder} - handleUploadOnDrop={handleUploadOnDrop} - uploadsInProgress={uploadsInProgress} - loadingCurrentPath={loadingCurrentPath} - showUploadsInTable={true} - sourceFiles={pathContents} - heading = {t`My Files`} - bucketType={bucketType} - controls={controls} - allowDropUpload={true} - itemOperations={ItemOperations} - withSurvey={showSurvey && olderThanOneWeek} - /> - + refreshContents(currentPath), + deleteFiles: moveFilesToTrash, + downloadFile:handleDownload, + handleMove, + handleRename, + viewFolder, + handleUploadOnDrop, + uploadsInProgress, + loadingCurrentPath, + showUploadsInTable: true, + sourceFiles: pathContents, + heading: t`My Files`, + bucketType, + controls: true, + allowDropUpload: true, + itemOperations, + withSurvey: showSurvey && olderThanOneWeek + }}> + + + + ) } diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx index a854aa2024..3bc5319868 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx @@ -7,6 +7,7 @@ import { useDrive, DirectoryContentResponse, FileSystemItem } from "../../../Con import { Button, FolderIcon, Grid, ITreeNodeProps, ScrollbarWrapper, TreeView, Typography } from "@chainsafe/common-components" import { getPathWithFile } from "../../../Utils/pathUtils" import { CSFTheme } from "../../../Themes/types" +import { useFileBrowser } from "../../../Contexts/FileBrowserContext" const useStyles = makeStyles( ({ breakpoints, constants, palette, typography, zIndex }: CSFTheme) => { @@ -63,20 +64,19 @@ const useStyles = makeStyles( ) interface IMoveFileModuleProps { - currentPath?: string filesToMove: FileSystemItem[] modalOpen: boolean onClose: () => void onCancel: () => void - refreshCurrentPath: () => void } -const MoveFileModule = ({ currentPath, filesToMove, modalOpen, onClose, onCancel, refreshCurrentPath }: IMoveFileModuleProps) => { +const MoveFileModule = ({ filesToMove, modalOpen, onClose, onCancel }: IMoveFileModuleProps) => { const classes = useStyles() const { getFolderTree, moveFiles } = useDrive() const [movingFile, setMovingFile] = useState(false) const [movePath, setMovePath] = useState(undefined) const [folderTree, setFolderTree] = useState([]) + const { currentPath, refreshContents } = useFileBrowser() const mapFolderTree = useCallback( (folderTreeEntries: DirectoryContentResponse[]): ITreeNodeProps[] => { @@ -127,7 +127,9 @@ const MoveFileModule = ({ currentPath, filesToMove, modalOpen, onClose, onCancel new_path: getPathWithFile(movePath, file.name) })) ) - .then(refreshCurrentPath) + .then(() => { + refreshContents && refreshContents() + }) .then(onClose) .catch(console.error) .finally(() => setMovingFile(false)) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx index 29294a213b..0237f14510 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/SearchFileBrowser.tsx @@ -9,6 +9,7 @@ import { getParentPathFromFilePath } from "../../../Utils/pathUtils" import { ROUTE_LINKS } from "../../FilesRoutes" import { t } from "@lingui/macro" import { SearchParams } from "../SearchModule" +import { FileBrowserContext } from "../../../Contexts/FileBrowserContext" const SearchFileBrowser: React.FC = ({ controls = false }: IFilesBrowserModuleProps) => { const { pathname } = useLocation() @@ -116,23 +117,25 @@ const SearchFileBrowser: React.FC = ({ controls = fals }), []) return ( - - - + + + + + ) } diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx index fcbb0eb5f5..383fe579da 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx @@ -31,6 +31,7 @@ import { CSFTheme } from "../../../../../Themes/types" import FileItemTableItem from "./FileSystemTableItem" import FileItemGridItem from "./FileSystemGridItem" import { FileSystemItem } from "../../../../../Contexts/DriveContext" +import { useFileBrowser } from "../../../../../Contexts/FileBrowserContext" const useStyles = makeStyles(({ breakpoints, constants }: CSFTheme) => { return createStyles({ @@ -103,8 +104,6 @@ interface IFileSystemItemRowProps { index: number file: FileSystemItem files: FileSystemItem[] - currentPath: string - moduleRootPath?: string selected: string[] handleSelect(selected: string): void editing: string | undefined @@ -115,8 +114,6 @@ interface IFileSystemItemRowProps { deleteFile?: () => void recoverFile?: (cid: string) => void viewFolder?: (cid: string) => void - downloadFile?: (cid: string) => Promise - handleUploadOnDrop?: (files: File[], fileItems: DataTransferItemList, path: string,) => void setPreviewFileIndex: (fileIndex: number | undefined) => void moveFile?: () => void setFileInfoPath: (path: string) => void @@ -131,16 +128,12 @@ const FileSystemItemRow: React.FC = ({ selected, editing, setEditing, - currentPath, - moduleRootPath, renameSchema, handleRename, handleMove, deleteFile, recoverFile, - downloadFile, viewFolder, - handleUploadOnDrop, setPreviewFileIndex, moveFile, setFileInfoPath, @@ -148,6 +141,7 @@ const FileSystemItemRow: React.FC = ({ itemOperations, browserView }) => { + const { downloadFile, currentPath, handleUploadOnDrop, moduleRootPath } = useFileBrowser() const { cid, name, isFolder, content_type } = file let Icon if (isFolder) { diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx index 3c01937639..cdf5a480a1 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx @@ -32,7 +32,7 @@ import clsx from "clsx" import { plural, t, Trans } from "@lingui/macro" import { NativeTypes } from "react-dnd-html5-backend" import { useDrop } from "react-dnd" -import { BrowserView, FileOperation, IFilesTableBrowserProps } from "../types" +import { BrowserView, FileOperation } from "../types" import { FileSystemItem } from "../../../../Contexts/DriveContext" import FileSystemItemRow from "./FileSystemItem/FileSystemItem" import FilePreviewModal from "../../FilePreviewModal" @@ -48,6 +48,7 @@ import MimeMatcher from "../../../../Utils/MimeMatcher" import { useLanguageContext } from "../../../../Contexts/LanguageContext" import { getPathWithFile } from "../../../../Utils/pathUtils" import SurveyBanner from "../../../SurveyBanner" +import { useFileBrowser } from "../../../../Contexts/FileBrowserContext" interface IStyleProps { themeKey: string @@ -275,34 +276,33 @@ const useStyles = makeStyles( const sortFoldersFirst = (a: FileSystemItem, b: FileSystemItem) => a.isFolder && a.content_type !== b.content_type ? -1 : 1 -const FilesTableView = ({ - heading, - controls = true, - sourceFiles, - handleUploadOnDrop, - bulkOperations, - crumbs, - handleRename, - handleMove, - downloadFile, - deleteFiles, - recoverFile, - recoverFiles, - viewFolder, - currentPath, - refreshContents, - loadingCurrentPath, - uploadsInProgress, - showUploadsInTable, - allowDropUpload, - itemOperations, - getPath, - moduleRootPath, - bucketType, - isSearch, - withSurvey -}: IFilesTableBrowserProps) => { +const FilesTableView = () => { const { themeKey, desktop } = useThemeSwitcher() + + const { + heading, + controls = true, + sourceFiles, + handleUploadOnDrop, + bulkOperations, + crumbs, + handleRename, + deleteFiles, + recoverFiles, + viewFolder, + currentPath, + refreshContents, + loadingCurrentPath, + uploadsInProgress, + showUploadsInTable, + allowDropUpload, + itemOperations, + getPath, + moduleRootPath, + bucketType, + isSearch, + withSurvey + } = useFileBrowser() const classes = useStyles({ themeKey }) const [editing, setEditing] = useState() const [direction, setDirection] = useState("ascend") @@ -857,8 +857,6 @@ const FilesTableView = ({ index={index} file={file} files={files} - moduleRootPath={moduleRootPath} - currentPath={currentPath} selected={selectedCids} handleSelect={handleSelect} editing={editing} @@ -868,15 +866,11 @@ const FilesTableView = ({ handleRename && (await handleRename(path, newPath)) setEditing(undefined) }} - handleMove={handleMove} deleteFile={() => { setSelectedCids([file.cid]) setIsDeleteModalOpen(true) }} - recoverFile={recoverFile} - downloadFile={downloadFile} viewFolder={handleViewFolder} - handleUploadOnDrop={handleUploadOnDrop} setPreviewFileIndex={setPreviewFileIndex} moveFile={() => { setSelectedCids([file.cid]) @@ -903,7 +897,6 @@ const FilesTableView = ({ index={index} file={file} files={files} - currentPath={currentPath} selected={selectedCids} handleSelect={handleSelect} editing={editing} @@ -913,15 +906,10 @@ const FilesTableView = ({ handleRename && (await handleRename(path, newPath)) setEditing(undefined) }} - handleMove={handleMove} deleteFile={() => { setSelectedCids([file.cid]) setIsDeleteModalOpen(true) }} - recoverFile={recoverFile} - downloadFile={downloadFile} - viewFolder={viewFolder} - handleUploadOnDrop={handleUploadOnDrop} setPreviewFileIndex={setPreviewFileIndex} moveFile={() => { setSelectedCids([file.cid]) @@ -970,21 +958,15 @@ const FilesTableView = ({ <> setCreateFolderModalOpen(false)} /> setIsUploadModalOpen(false)} - refreshCurrentPath={refreshContents} - currentPath={currentPath} /> { setIsMoveFileModalOpen(false) setSelectedCids([]) diff --git a/packages/files-ui/src/Components/Modules/UploadFileModule.tsx b/packages/files-ui/src/Components/Modules/UploadFileModule.tsx index 241307a576..2d2de07e16 100644 --- a/packages/files-ui/src/Components/Modules/UploadFileModule.tsx +++ b/packages/files-ui/src/Components/Modules/UploadFileModule.tsx @@ -8,6 +8,7 @@ import CustomModal from "../Elements/CustomModal" import { Trans, t } from "@lingui/macro" import clsx from "clsx" import { CSFTheme } from "../../Themes/types" +import { useFileBrowser } from "../../Contexts/FileBrowserContext" const useStyles = makeStyles(({ constants, breakpoints }: CSFTheme) => createStyles({ @@ -71,14 +72,14 @@ const useStyles = makeStyles(({ constants, breakpoints }: CSFTheme) => interface IUploadFileModuleProps { modalOpen: boolean close: () => void - currentPath: string - refreshCurrentPath: () => void } -const UploadFileModule = ({ modalOpen, close, currentPath, refreshCurrentPath }: IUploadFileModuleProps) => { +const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => { const classes = useStyles() const [isDoneDisabled, setIsDoneDisabled] = useState(true) const { uploadFiles } = useDrive() + const { currentPath, refreshContents } = useFileBrowser() + const UploadSchema = object().shape({ files: array().required(t`Please select a file to upload`) }) const onFileNumberChange = useCallback((filesNumber: number) => { @@ -90,7 +91,7 @@ const UploadFileModule = ({ modalOpen, close, currentPath, refreshCurrentPath }: try { await uploadFiles(values.files, currentPath) helpers.resetForm() - refreshCurrentPath() + refreshContents && refreshContents() close() } catch (errors) { if (errors[0].message.includes("conflict with existing")) { @@ -100,7 +101,7 @@ const UploadFileModule = ({ modalOpen, close, currentPath, refreshCurrentPath }: } } helpers.setSubmitting(false) - }, [close, currentPath, uploadFiles, refreshCurrentPath]) + }, [close, currentPath, uploadFiles, refreshContents]) return ( Promise + handleMove?: (path: string, new_path: string) => Promise + downloadFile?: (cid: string) => Promise + deleteFiles?: (cid: string[]) => Promise + recoverFile?: (cid: string) => Promise + recoverFiles?: (cid: string[]) => Promise + viewFolder?: (cid: string) => void + allowDropUpload?: boolean + + handleUploadOnDrop?: ( + files: File[], + fileItems: DataTransferItemList, + path: string, + ) => void + + refreshContents?: () => void + currentPath: string + bucketType: BucketType + loadingCurrentPath: boolean + uploadsInProgress?: UploadProgress[] + showUploadsInTable: boolean + sourceFiles: FileSystemItem[] + crumbs: Crumb[] | undefined + moduleRootPath: string | undefined + getPath?: (cid: string) => string + isSearch?: boolean + withSurvey?: boolean +} + +const FileBrowserContext = React.createContext(undefined) + +const useFileBrowser = () => { + const context = useContext(FileBrowserContext) + if (context === undefined) { + throw new Error("useFileBrowserContext must be called within a FileBrowserProvider") + } + return context +} + +export { FileBrowserContext, useFileBrowser } From 4fe89d221b7c4894e95162b85df3a0ad58597180 Mon Sep 17 00:00:00 2001 From: Tanmoy Basak Anjan Date: Tue, 18 May 2021 01:20:50 +0600 Subject: [PATCH 42/62] work on selections (#1029) * ref change * ctrl selection * basic outside clickk implemented * click away implemented * move file dialog * move file dialog * cleanup * Update packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> * rename Co-authored-by: Michael Yankelev Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> --- .../common-components/src/Modal/Modal.tsx | 5 +- .../common-components/src/Table/TableCell.tsx | 4 +- .../common-theme/src/Hooks/useDoubleClick.ts | 15 +- .../Modules/FileBrowsers/MoveFileModal.tsx | 8 +- .../FileSystemItem/FileSystemGridItem.tsx | 205 ++++++++------- .../views/FileSystemItem/FileSystemItem.tsx | 96 ++++--- .../FileSystemItem/FileSystemTableItem.tsx | 240 +++++++++--------- .../FileBrowsers/views/FilesTable.view.tsx | 23 +- 8 files changed, 347 insertions(+), 249 deletions(-) diff --git a/packages/common-components/src/Modal/Modal.tsx b/packages/common-components/src/Modal/Modal.tsx index c5f71f4717..433e256807 100644 --- a/packages/common-components/src/Modal/Modal.tsx +++ b/packages/common-components/src/Modal/Modal.tsx @@ -138,6 +138,7 @@ interface IModalProps { closePosition?: "left" | "right" | "none" children?: ReactNode | ReactNode[] maxWidth?: "xs" | "sm" | "md" | "lg" | "xl" | number + onModalBodyClick?: (e: React.MouseEvent) => void } const Modal: React.FC = ({ @@ -147,7 +148,8 @@ const Modal: React.FC = ({ injectedClass, active = false, setActive, - maxWidth = "sm" + maxWidth = "sm", + onModalBodyClick }: IModalProps) => { const classes = useStyles() @@ -169,6 +171,7 @@ const Modal: React.FC = ({ setActive ? "closable" : "", active ? "active" : "closed" )} + onClick={onModalBodyClick} >
void + onClick?: (e?: React.MouseEvent) => void } const TableCell = React.forwardRef( ( @@ -46,7 +46,7 @@ const TableCell = React.forwardRef( return ( (onClick ? onClick() : null)} + onClick={onClick} className={clsx( className, classes.root, diff --git a/packages/common-theme/src/Hooks/useDoubleClick.ts b/packages/common-theme/src/Hooks/useDoubleClick.ts index 569117896e..2b25ffb283 100644 --- a/packages/common-theme/src/Hooks/useDoubleClick.ts +++ b/packages/common-theme/src/Hooks/useDoubleClick.ts @@ -1,13 +1,19 @@ +import * as React from "react" import { useCallback, useEffect, useState } from "react" -export function useDoubleClick(actionSingleClick: () => void, actionDoubleClick: () => void, delay = 250) { +export function useDoubleClick( + actionSingleClick: (e?: React.MouseEvent) => void, + actionDoubleClick: (e?: React.MouseEvent) => void, + delay = 250 +) { const [clickCount, setClickCount] = useState(0) + const [event, setEvent] = useState() useEffect(() => { const timer = setTimeout(() => { // simple click if (clickCount === 1) { - actionSingleClick && actionSingleClick() + actionSingleClick && actionSingleClick(event) } setClickCount(0) @@ -17,7 +23,7 @@ export function useDoubleClick(actionSingleClick: () => void, actionDoubleClick: // is less than the value of delay = double-click if (clickCount === 2) { setClickCount(0) - actionDoubleClick && actionDoubleClick() + actionDoubleClick && actionDoubleClick(event) } return () => clearTimeout(timer) @@ -26,7 +32,8 @@ export function useDoubleClick(actionSingleClick: () => void, actionDoubleClick: // eslint-disable-next-line react-hooks/exhaustive-deps }, [actionSingleClick, clickCount, delay]) - const onClick = useCallback(() => { + const onClick = useCallback((e?: React.MouseEvent) => { + setEvent(e) setClickCount((prev) => prev + 1) }, []) diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx index 3bc5319868..394a361908 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/MoveFileModal.tsx @@ -145,6 +145,10 @@ const MoveFileModule = ({ filesToMove, modalOpen, onClose, onCancel }: IMoveFile active={modalOpen} closePosition="none" maxWidth="sm" + onModalBodyClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} > -
+
{folderTree.length ? void - handleSelect: (selected: string) => void - onFolderOrFileClicks: () => void + onFolderOrFileClicks: (e?: React.MouseEvent) => void icon: React.ReactNode preview: ConnectDragPreview renameSchema: any @@ -135,97 +133,132 @@ interface IFileSystemTableItemProps { handleRename?: (path: string, newPath: string) => Promise currentPath: string | undefined menuItems: IMenuItem[] + resetSelectedFiles: () => void } -function FileSystemGridItem({ - isFolder, - isOverMove, - isOverUpload, - selected, - file, - editing, - attachRef, - onFolderOrFileClicks, - icon, - renameSchema, - setEditing, - handleRename, - currentPath, - menuItems -}: IFileSystemTableItemProps) { - const classes = useStyles() - const { name, cid } = file - const { desktop } = useThemeSwitcher() +const FileSystemGridItem = React.forwardRef( + ({ + isFolder, + isOverMove, + isOverUpload, + selected, + file, + editing, + onFolderOrFileClicks, + icon, + renameSchema, + setEditing, + handleRename, + currentPath, + menuItems, + resetSelectedFiles, + preview + }: IFileSystemTableItemProps, forwardedRef: any) => { + const classes = useStyles() + const { name, cid } = file + const { desktop } = useThemeSwitcher() - return ( -
+ const handleClickOutside = useCallback( + (e) => { + if (forwardedRef.current && forwardedRef.current.contains(e.target)) { + // inside click + return + } + if (e.defaultPrevented || e.isPropagationStopped) { + return + } + // outside click + resetSelectedFiles() + }, + [resetSelectedFiles, forwardedRef] + ) + + useEffect(() => { + document.addEventListener("click", handleClickOutside) + return () => { + document.removeEventListener("click", handleClickOutside) + } + }, [handleClickOutside]) + + return (
{ + e.preventDefault() + e.stopPropagation() + }} >
onFolderOrFileClicks(e)} > - {icon} -
- {editing === cid && desktop ? ( - { - handleRename && handleRename( - `${currentPath}${name}`, - `${currentPath}${values.fileName}` - ) - setEditing(undefined) - }} +
- - { - if (event.key === "Escape") { - setEditing(undefined) + {icon} +
+ {editing === cid && desktop ? ( + { + handleRename && handleRename( + `${currentPath}${name}`, + `${currentPath}${values.fileName}` + ) + setEditing(undefined) + }} + > + + { + if (event.key === "Escape") { + setEditing(undefined) + } + }} + placeholder = {isFolder + ? t`Please enter a file name` + : t`Please enter a folder name` } - }} - placeholder = {isFolder - ? t`Please enter a file name` - : t`Please enter a folder name` - } - autoFocus={editing === cid} - /> - - - ) : ( -
{name}
- )} -
-
- + autoFocus={editing === cid} + /> + + + ) : ( +
{name}
+ )} +
+
+ +
-
- ) -} + ) + } +) + +FileSystemGridItem.displayName = "FileSystemGridItem" export default FileSystemGridItem diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx index 383fe579da..07af776323 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react" +import React, { useCallback, useRef } from "react" import { FormikTextInput, Typography, @@ -105,7 +105,8 @@ interface IFileSystemItemRowProps { file: FileSystemItem files: FileSystemItem[] selected: string[] - handleSelect(selected: string): void + handleSelectCid(selectedCid: string): void + handleAddToSelectedCids(selectedCid: string): void editing: string | undefined setEditing(editing: string | undefined): void renameSchema: any @@ -122,7 +123,7 @@ interface IFileSystemItemRowProps { browserView: BrowserView } -const FileSystemItemRow: React.FC = ({ +const FileSystemItemRow = ({ file, files, selected, @@ -137,10 +138,12 @@ const FileSystemItemRow: React.FC = ({ setPreviewFileIndex, moveFile, setFileInfoPath, - handleSelect, + handleSelectCid, + handleAddToSelectedCids, itemOperations, - browserView -}) => { + browserView, + resetSelectedFiles +}: IFileSystemItemRowProps) => { const { downloadFile, currentPath, handleUploadOnDrop, moduleRootPath } = useFileBrowser() const { cid, name, isFolder, content_type } = file let Icon @@ -298,55 +301,81 @@ const FileSystemItemRow: React.FC = ({ }) }) - function attachRef(el: any) { - if (isFolder) { - dropMoveRef(el) - dropUploadRef(el) - } else { - dragMoveRef(el) - } + const fileOrFolderRef = useRef() + + if (!editing && isFolder) { + dropMoveRef(fileOrFolderRef) + dropUploadRef(fileOrFolderRef) + } + if (!editing && !isFolder) { + dragMoveRef(fileOrFolderRef) } - const onFolderClick = useCallback(() => { + const onFolderNavigation = useCallback(() => { + resetSelectedFiles() if (!moduleRootPath) { console.debug("Module root path not set") return } const newPath = `${moduleRootPath}${currentPath}${encodeURI(name)}` redirect(newPath) - }, [currentPath, name, redirect, moduleRootPath]) + }, [currentPath, name, redirect, moduleRootPath, resetSelectedFiles]) - const onFileClick = useCallback(() => { + const onFilePreview = useCallback(() => { setPreviewFileIndex(files?.indexOf(file)) }, [file, files, setPreviewFileIndex]) const onSingleClick = useCallback( - () => { handleSelect(cid) }, - [cid, handleSelect] + (e) => { + if (desktop) { + // on desktop + if (e && (e.ctrlKey || e.metaKey)) { + handleAddToSelectedCids(cid) + } else { + handleSelectCid(cid) + } + } else { + // on mobile + if (isFolder) { + onFolderNavigation() + } else { + onFilePreview() + } + } + }, + [cid, handleSelectCid, handleAddToSelectedCids, desktop, isFolder, onFolderNavigation, onFilePreview] ) - const onDoubleClick = useCallback(() => { - isFolder - ? onFolderClick() - : onFileClick() - }, [isFolder, onFileClick, onFolderClick]) + const onDoubleClick = useCallback( + () => { + if (desktop) { + // on desktop + if (isFolder) { + onFolderNavigation() + } else { + onFilePreview() + } + } else { + // on mobile + return + } + }, + [desktop, onFolderNavigation, onFilePreview, isFolder] + ) const { click } = useDoubleClick(onSingleClick, onDoubleClick) - const onFolderOrFileClicks = desktop - ? click - : () => { - isFolder - ? onFolderClick() - : onFileClick() - } + const onFolderOrFileClicks = (e?: React.MouseEvent) => { + e?.persist() + click(e) + } const itemProps = { - attachRef, + ref: fileOrFolderRef, currentPath, editing, file, - handleSelect, + handleAddToSelectedCids, handleRename, icon: , isFolder, @@ -357,7 +386,8 @@ const FileSystemItemRow: React.FC = ({ preview, renameSchema, selected, - setEditing + setEditing, + resetSelectedFiles } return ( diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx index b2c97fdd88..6c4077bcc7 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx @@ -112,9 +112,8 @@ interface IFileSystemTableItemProps { selected: string[] file: FileSystemItem editing: string | undefined - attachRef: (element: any) => void - handleSelect: (selected: string) => void - onFolderOrFileClicks: () => void + handleAddToSelectedCids: (selected: string) => void + onFolderOrFileClicks: (e?: React.MouseEvent) => void icon: React.ReactNode preview: ConnectDragPreview renameSchema: any @@ -124,129 +123,132 @@ interface IFileSystemTableItemProps { menuItems: IMenuItem[] } -function FileSystemTableItem({ - isFolder, - isOverMove, - isOverUpload, - selected, - file, - editing, - attachRef, - handleSelect, - onFolderOrFileClicks, - icon, - preview, - renameSchema, - setEditing, - handleRename, - currentPath, - menuItems -}: IFileSystemTableItemProps) { - const classes = useStyles() - const { name, cid, created_at, size } = file - const { desktop } = useThemeSwitcher() +const FileSystemTableItem = React.forwardRef( + ({ + isFolder, + isOverMove, + isOverUpload, + selected, + file, + editing, + handleAddToSelectedCids, + onFolderOrFileClicks, + icon, + preview, + renameSchema, + setEditing, + handleRename, + currentPath, + menuItems + }: IFileSystemTableItemProps, forwardedRef: any) => { + const classes = useStyles() + const { name, cid, created_at, size } = file + const { desktop } = useThemeSwitcher() - return ( - - {desktop && ( - - handleSelect(cid)} - /> - - )} - - {icon} - - !editing && onFolderOrFileClicks()} - > - {editing === cid && desktop ? ( - { - handleRename && + {desktop && ( + + handleAddToSelectedCids(cid)} + /> + + )} + onFolderOrFileClicks(e)} + > + {icon} + + !editing && onFolderOrFileClicks(e)} + > + {editing === cid && desktop ? ( + { + handleRename && handleRename( `${currentPath}${name}`, `${currentPath}${values.fileName}` ) - setEditing(undefined) - }} - > -
- { - if (event.key === "Escape") { - setEditing(undefined) + setEditing(undefined) + }} + > + + { + if (event.key === "Escape") { + setEditing(undefined) + } + }} + placeholder = {isFolder + ? t`Please enter a file name` + : t`Please enter a folder name` } - }} - placeholder = {isFolder - ? t`Please enter a file name` - : t`Please enter a folder name` - } - autoFocus={editing === cid} - /> - - -
- ) : ( - {name} + autoFocus={editing === cid} + /> + + +
+ ) : ( + {name} + )} +
+ {desktop && ( + <> + + { + created_at && dayjs.unix(created_at).format("DD MMM YYYY h:mm a") + } + + + {!isFolder && formatBytes(size)} + + )} - - {desktop && ( - <> - - { - created_at && dayjs.unix(created_at).format("DD MMM YYYY h:mm a") - } - - - {!isFolder && formatBytes(size)} - - - )} - - - -
- ) -} + + + + + ) + } +) + +FileSystemTableItem.displayName = "FileSystemTableItem" export default FileSystemTableItem \ No newline at end of file diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx index cdf5a480a1..6a2854be9b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx @@ -388,7 +388,18 @@ const FilesTableView = () => { } // Selection logic - const handleSelect = useCallback( + const handleSelectCid = useCallback( + (cid: string) => { + if (selectedCids.includes(cid)) { + setSelectedCids([]) + } else { + setSelectedCids([cid]) + } + }, + [selectedCids] + ) + + const handleAddToSelectedCids = useCallback( (cid: string) => { if (selectedCids.includes(cid)) { setSelectedCids( @@ -858,7 +869,8 @@ const FilesTableView = () => { file={file} files={files} selected={selectedCids} - handleSelect={handleSelect} + handleSelectCid={handleSelectCid} + handleAddToSelectedCids={handleAddToSelectedCids} editing={editing} setEditing={setEditing} renameSchema={renameSchema} @@ -898,7 +910,8 @@ const FilesTableView = () => { file={file} files={files} selected={selectedCids} - handleSelect={handleSelect} + handleSelectCid={handleSelectCid} + handleAddToSelectedCids={handleAddToSelectedCids} editing={editing} setEditing={setEditing} renameSchema={renameSchema} @@ -950,6 +963,10 @@ const FilesTableView = () => { acceptButtonProps={{ loading: isDeletingFiles, disabled: isDeletingFiles }} rejectButtonProps={{ disabled: isDeletingFiles }} injectedClass={{ inner: classes.confirmDeletionDialog }} + onModalBodyClick={(e) => { + e.preventDefault() + e.stopPropagation() + }} /> From b8daa3b454d02d1191eb5bceac47c924e1e79808 Mon Sep 17 00:00:00 2001 From: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> Date: Wed, 19 May 2021 12:01:52 +0100 Subject: [PATCH 43/62] Remove release drafter for now (#1038) * comment it out * remove it alltogether otherwise it still fails --- .github/release_drafter.yml | 38 ------------------------------------- 1 file changed, 38 deletions(-) delete mode 100644 .github/release_drafter.yml diff --git a/.github/release_drafter.yml b/.github/release_drafter.yml deleted file mode 100644 index 1170fa755b..0000000000 --- a/.github/release_drafter.yml +++ /dev/null @@ -1,38 +0,0 @@ -name-template: "v$RESOLVED_VERSION 🌈" -tag-template: "v$RESOLVED_VERSION" -references: - - dev -categories: - - title: "🚀 Features" - labels: - - "Type: Feature" - - "Type: Enhancement" - - title: "🐛 Bug Fixes" - labels: - - "Type: Bug" - - title: "🧰 Maintenance" - label: - - "Type: Maintenance" -change-template: "- $TITLE @$AUTHOR (#$NUMBER)" -change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks. -version-resolver: - major: - labels: - - "Release: Major" - minor: - labels: - - "Release: Minor" - patch: - labels: - - "Release: Patch" - default: patch -exclude-labels: - - "skip-changelog" -template: | - ## Changelog - - $CHANGES - - 🙏 A big thank you to all the contributors to this release: - - $CONTRIBUTORS From ac5f2fa58193642914e9a4761aed641c4fdf59e2 Mon Sep 17 00:00:00 2001 From: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> Date: Wed, 19 May 2021 12:22:12 +0100 Subject: [PATCH 44/62] Update Readme for tests (#1036) Co-authored-by: Michael Yankelev <12774278+FSM1@users.noreply.github.com> --- readme.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/readme.md b/readme.md index f1eb84cc0e..8bd5c4ee2b 100644 --- a/readme.md +++ b/readme.md @@ -45,3 +45,11 @@ You then need to add it as environment variable, depending on your OS and shell: OR - Create a `packages/files-ui/.env` based on `packages/files-ui/.env.example` - Run `yarn start:files-ui` to start the development server. + +## Run Tests + +Our tests use Cypress running against the local instance of the Files UI. The files UI needs to run **before** the test are launched. +By default the tests are run against `localhost:3000` + +- To start the tests UI run `yarn test:files-ui` +- To start all the tests like in CI run `yarn test:ci:files-ui` From 92c34ae6696f0ae30cebcf56fbd519d903b605f7 Mon Sep 17 00:00:00 2001 From: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> Date: Wed, 19 May 2021 22:59:48 +0100 Subject: [PATCH 45/62] Delete release_drafter.yml (#1039) --- .github/workflows/release_drafter.yml | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .github/workflows/release_drafter.yml diff --git a/.github/workflows/release_drafter.yml b/.github/workflows/release_drafter.yml deleted file mode 100644 index e8d766d38d..0000000000 --- a/.github/workflows/release_drafter.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: Release Drafter - -on: - push: - # branches to consider in the event; optional, defaults to all - branches: - - dev - pull_request: - # Only following types are handled by the action, but one can default to all as well - types: [opened, reopened, synchronize] -jobs: - update_release_draft: - runs-on: ubuntu-latest - steps: - # Drafts your next Release notes as Pull Requests are merged into "master" - - uses: release-drafter/release-drafter@v5 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file From fe0210538fe095c693a9b38d959acefd45a6217d Mon Sep 17 00:00:00 2001 From: Michael Yankelev <12774278+FSM1@users.noreply.github.com> Date: Thu, 20 May 2021 11:51:04 +1200 Subject: [PATCH 46/62] Bulk DND Move files (#1028) * wire up bulk dnd * fix grid view navigation * fix custom preview * add proper table and grid previews * fix icon and table row styles * remove unnecessary styles * Apply suggestions from code review Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> * Update packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> * fix row drag preview * remove unnecessary preview markup and styles * remove unused imports Co-authored-by: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> --- packages/files-ui/package.json | 4 +- .../FileBrowsers/views/DragPreviewLayer.tsx | 197 ++++++++++++++++++ .../views/FileSystemItem/FileSystemItem.tsx | 48 +++-- .../FileBrowsers/views/FilesTable.view.tsx | 21 +- packages/files-ui/src/Contexts/DnDContext.tsx | 22 +- packages/files-ui/src/Utils/pathUtils.ts | 1 - yarn.lock | 50 ++--- 7 files changed, 279 insertions(+), 64 deletions(-) create mode 100644 packages/files-ui/src/Components/Modules/FileBrowsers/views/DragPreviewLayer.tsx diff --git a/packages/files-ui/package.json b/packages/files-ui/package.json index 74413f7d1d..dbe13d718f 100644 --- a/packages/files-ui/package.json +++ b/packages/files-ui/package.json @@ -27,8 +27,8 @@ "mime-matcher": "^1.0.5", "react": "^16.14.0", "react-beforeunload": "^2.4.0", - "react-dnd": "^11.1.3", - "react-dnd-html5-backend": "^11.1.3", + "react-dnd": "14.0.2", + "react-dnd-html5-backend": "14.0.0", "react-dom": "^16.14.0", "react-h5-audio-player": "^3.5.0", "react-hotkeys-hook": "^2.4.0", diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/DragPreviewLayer.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/DragPreviewLayer.tsx new file mode 100644 index 0000000000..95722994b4 --- /dev/null +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/DragPreviewLayer.tsx @@ -0,0 +1,197 @@ +import { FolderFilledSvg, FileImageSvg, FilePdfSvg, FileTextSvg, Typography } from "@chainsafe/common-components" +import { makeStyles } from "@chainsafe/common-theme" +import clsx from "clsx" +import React from "react" +import { useDragLayer, XYCoord } from "react-dnd" +import { FileSystemItem } from "../../../../Contexts/DriveContext" +import { CSFTheme } from "../../../../Themes/types" +import { DragTypes } from "../DragConstants" +import { BrowserView } from "../types" + +const useStyles = makeStyles(({ breakpoints, constants, palette }: CSFTheme) => { + return ({ + rowItem: { + display: "flex", + height: 70, + border: "2px solid transparent" + }, + fileIcon: { + "& svg": { + width: constants.generalUnit * 2.5, + fill: constants.fileSystemItemRow.icon + }, + [breakpoints.up("md")]: { + paddingLeft: constants.generalUnit * 8.5 + }, + paddingRight: constants.generalUnit * 6 + }, + folderIcon: { + "& svg": { + fill: palette.additional.gray[9] + } + }, + filename: { + whiteSpace: "nowrap", + textOverflow: "ellipsis", + overflow: "hidden" + }, + previewDragLayer: { + position: "fixed", + pointerEvents: "none", + zIndex: 100, + left: 0, + top: 0, + bottom: 0, + right: 0 + }, + dropdownIcon: { + "& svg": { + fill: constants.fileSystemItemRow.dropdownIcon + } + }, + gridViewContainer: { + display: "flex", + flex: 1, + maxWidth: constants.generalUnit * 24 + }, + gridFolderName: { + textAlign: "center", + wordBreak: "break-all", + overflowWrap: "break-word", + padding: constants.generalUnit + }, + gridViewIconNameBox: { + display: "flex", + flexDirection: "column", + width: "100%", + cursor: "pointer" + }, + gridIcon: { + display: "flex", + justifyContent: "center", + alignItems: "center", + height: constants.generalUnit * 16, + maxWidth: constants.generalUnit * 24, + border: `1px solid ${palette.additional["gray"][6]}`, + boxShadow: constants.filesTable.gridItemShadow, + "& svg": { + width: "30%" + }, + [breakpoints.down("lg")]: { + height: constants.generalUnit * 16 + }, + [breakpoints.down("sm")]: { + height: constants.generalUnit * 16 + } + }, + menuTitleGrid: { + padding: `0 ${constants.generalUnit * 0.5}px`, + [breakpoints.down("md")]: { + padding: 0 + } + } + })}) + +const DragPreviewRowItem: React.FC<{item: FileSystemItem; icon: React.ReactNode}> = ({ + item: { name, isFolder }, + icon +}) => { + const classes = useStyles() + return ( +
+
+ {icon} +
+
+ {name} +
+
+ ) +} + +const DragPreviewGridItem: React.FC<{item: FileSystemItem; icon: React.ReactNode}> = ({ + item: { name, isFolder }, + icon +}) => { + const classes = useStyles() + return ( +
+
+
+ {icon} +
+
{name}
+
+
+ ) +} + +export const DragPreviewLayer: React.FC<{items: FileSystemItem[]; previewType: BrowserView} > = ({ items, previewType }) => { + const classes = useStyles() + const { isDragging, dragItems, itemType, currentOffset } = useDragLayer(monitor => ({ + itemType: monitor.getItemType(), + dragItems: monitor.getItem() as {ids: string[]}, + isDragging: monitor.isDragging(), + initialOffset: monitor.getInitialSourceClientOffset(), + currentOffset: monitor.getSourceClientOffset() + })) + + const getItemStyles = (currentOffset: XYCoord | null) => { + if (!currentOffset) { + return { + display: "none" + } + } + const { x, y } = currentOffset + + const transform = `translate(${x}px, ${y}px)` + return { + transform, + WebkitTransform: transform + } + } + + return (!isDragging || itemType !== DragTypes.MOVABLE_FILE) + ? null + :
+
    + {dragItems.ids.map(di => { + const previewItem = items.find(i => i.cid === di) + + if (previewItem) { + let Icon + if (previewItem.isFolder) { + Icon = FolderFilledSvg + } else if (previewItem.content_type.includes("image")) { + Icon = FileImageSvg + } else if (previewItem.content_type.includes("pdf")) { + Icon = FilePdfSvg + } else { + Icon = FileTextSvg + } + + return (previewType === "table") + ? } + key={previewItem.cid} + /> + : } + key={previewItem.cid} + /> + } else { + return null + }})} +
+
+} diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx index 07af776323..b40892d13b 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemItem.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef } from "react" +import React, { useCallback, useEffect, useRef } from "react" import { FormikTextInput, Typography, @@ -25,7 +25,7 @@ import CustomModal from "../../../../Elements/CustomModal" import { Trans } from "@lingui/macro" import { useDrag, useDrop } from "react-dnd" import { DragTypes } from "../../DragConstants" -import { NativeTypes } from "react-dnd-html5-backend" +import { getEmptyImage, NativeTypes } from "react-dnd-html5-backend" import { BrowserView, FileOperation } from "../../types" import { CSFTheme } from "../../../../../Themes/types" import FileItemTableItem from "./FileSystemTableItem" @@ -131,7 +131,6 @@ const FileSystemItemRow = ({ setEditing, renameSchema, handleRename, - handleMove, deleteFile, recoverFile, viewFolder, @@ -144,7 +143,7 @@ const FileSystemItemRow = ({ browserView, resetSelectedFiles }: IFileSystemItemRowProps) => { - const { downloadFile, currentPath, handleUploadOnDrop, moduleRootPath } = useFileBrowser() + const { downloadFile, currentPath, handleUploadOnDrop, moduleRootPath, handleMove } = useFileBrowser() const { cid, name, isFolder, content_type } = file let Icon if (isFolder) { @@ -268,22 +267,41 @@ const FileSystemItemRow = ({ (itemOperation) => allMenuItems[itemOperation] ) - const [, dragMoveRef, preview] = useDrag({ - item: { type: DragTypes.MOVABLE_FILE, payload: file } + const [, dragMoveRef, preview] = useDrag(() => + ({ type: DragTypes.MOVABLE_FILE, + item: () => { + if (selected.includes(file.cid)) { + return { ids: selected } + } else { + return { ids: [...selected, file.cid] } + } + } + }), [selected]) + + useEffect(() => { + // This gets called after every render, by default + + // Use empty image as a drag preview so browsers don't draw it + // and we can draw whatever we want on the custom drag layer instead. + preview(getEmptyImage(), { + // IE fallback: specify that we'd rather screenshot the node + // when it already knows it's being dragged so we can hide it with CSS. + captureDraggingState: true + }) }) const [{ isOverMove }, dropMoveRef] = useDrop({ accept: DragTypes.MOVABLE_FILE, canDrop: () => isFolder, - drop: async (item: { - type: typeof DragTypes.MOVABLE_FILE - payload: FileSystemItem - }) => { - handleMove && - (await handleMove( - `${currentPath}${item.payload.name}`, - `${currentPath}${name}/${item.payload.name}` - )) + drop: (item: {ids: string[]}) => { + item.ids.forEach((cid) => { + const fileToMove = files.find(f => f.cid === cid) + handleMove && fileToMove && + handleMove( + `${currentPath}${fileToMove.name}`, + `${currentPath}${name}/${fileToMove.name}` + ) + }) }, collect: (monitor) => ({ isOverMove: monitor.isOver() diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx index 6a2854be9b..3774edc756 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FilesTable.view.tsx @@ -48,6 +48,7 @@ import MimeMatcher from "../../../../Utils/MimeMatcher" import { useLanguageContext } from "../../../../Contexts/LanguageContext" import { getPathWithFile } from "../../../../Utils/pathUtils" import SurveyBanner from "../../../SurveyBanner" +import { DragPreviewLayer } from "./DragPreviewLayer" import { useFileBrowser } from "../../../../Contexts/FileBrowserContext" interface IStyleProps { @@ -311,6 +312,8 @@ const FilesTableView = () => { const [selectedCids, setSelectedCids] = useState([]) const [previewFileIndex, setPreviewFileIndex] = useState() const { selectedLocale } = useLanguageContext() + const { redirect } = useHistory() + const items: FileSystemItem[] = useMemo(() => { let temp = [] @@ -342,8 +345,6 @@ const FilesTableView = () => { : temp.sort(sortFoldersFirst) }, [sourceFiles, direction, column, selectedLocale]) - const { redirect } = useHistory() - const files = useMemo(() => items.filter((i) => !i.isFolder), [items]) const selectedFiles = useMemo( @@ -412,13 +413,13 @@ const FilesTableView = () => { [selectedCids] ) - const toggleAll = () => { + const toggleAll = useCallback(() => { if (selectedCids.length === items.length) { setSelectedCids([]) } else { setSelectedCids([...items.map((file: FileSystemItem) => file.cid)]) } - } + }, [setSelectedCids, items, selectedCids]) const invalidFilenameRegex = new RegExp("/") const renameSchema = object().shape({ @@ -466,6 +467,7 @@ const FilesTableView = () => { // Bulk operations const [validBulkOps, setValidBulkOps] = useState([]) + useEffect(() => { if (bulkOperations) { let filteredList: FileOperation[] = [ @@ -575,14 +577,17 @@ const FilesTableView = () => { setSelectedCids([]) }, []) + useEffect(() => { + setSelectedCids([]) + }, [currentPath]) + const onHideSurveyBanner = useCallback(() => { setIsSurveyBannerVisible(false) }, [setIsSurveyBannerVisible]) const handleViewFolder = useCallback((cid: string) => { - resetSelectedCids() viewFolder && viewFolder(cid) - }, [viewFolder, resetSelectedCids]) + }, [viewFolder]) return (
{ Drop to upload files
+
{crumbs && moduleRootPath ? ( { - const manager = useRef(RNDContext) - if (manager.current?.dragDropManager) { - return ( - - {children} - - ) - } - return <>{children} -} +const DragAndDrop: React.FC = ({ children }) => ( + + {children} + +) export default DragAndDrop diff --git a/packages/files-ui/src/Utils/pathUtils.ts b/packages/files-ui/src/Utils/pathUtils.ts index 3554703e36..ef4174ac5a 100644 --- a/packages/files-ui/src/Utils/pathUtils.ts +++ b/packages/files-ui/src/Utils/pathUtils.ts @@ -45,7 +45,6 @@ export function getParentPathFromFilePath(filePath: string) { else return parentPath } - export function extractDrivePath(pathname: string) { return pathname.split("/").slice(2).join("/").concat("/") } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 7cb9a92c00..49393b31e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4758,7 +4758,7 @@ resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.7.tgz#613957d900fab9ff84c8dfb24fa3eef0c2a40896" integrity sha512-2xtoL22/3Mv6a70i4+4RB7VgbDDORoWwjcqeNysojZA0R7NK17RbY5Gof/2QiFfJgX+KkWghbwJ+d/2SB8Ndzg== -"@types/hoist-non-react-statics@*", "@types/hoist-non-react-statics@^3.3.1": +"@types/hoist-non-react-statics@*": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f" integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA== @@ -9510,14 +9510,14 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" -dnd-core@^11.1.3: - version "11.1.3" - resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-11.1.3.tgz#f92099ba7245e49729d2433157031a6267afcc98" - integrity sha512-QugF55dNW+h+vzxVJ/LSJeTeUw9MCJ2cllhmVThVPEtF16ooBkxj0WBE5RB+AceFxMFo1rO6bJKXtqKl+JNnyA== +dnd-core@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/dnd-core/-/dnd-core-14.0.0.tgz#973ab3470d0a9ac5a0fa9021c4feba93ad12347d" + integrity sha512-wTDYKyjSqWuYw3ZG0GJ7k+UIfzxTNoZLjDrut37PbcPGNfwhlKYlPUqjAKUjOOv80izshUiqusaKgJPItXSevA== dependencies: "@react-dnd/asap" "^4.0.0" "@react-dnd/invariant" "^2.0.0" - redux "^4.0.4" + redux "^4.0.5" dns-equal@^1.0.0: version "1.0.0" @@ -17933,22 +17933,23 @@ react-dev-utils@^9.0.0: strip-ansi "5.2.0" text-table "0.2.0" -react-dnd-html5-backend@^11.1.3: - version "11.1.3" - resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-11.1.3.tgz#2749f04f416ec230ea193f5c1fbea2de7dffb8f7" - integrity sha512-/1FjNlJbW/ivkUxlxQd7o3trA5DE33QiRZgxent3zKme8DwF4Nbw3OFVhTRFGaYhHFNL1rZt6Rdj1D78BjnNLw== +react-dnd-html5-backend@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/react-dnd-html5-backend/-/react-dnd-html5-backend-14.0.0.tgz#28d660a2ad1e07447c34a65cd25f7de8f1657194" + integrity sha512-2wAQqRFC1hbRGmk6+dKhOXsyQQOn3cN8PSZyOUeOun9J8t3tjZ7PS2+aFu7CVu2ujMDwTJR3VTwZh8pj2kCv7g== dependencies: - dnd-core "^11.1.3" + dnd-core "14.0.0" -react-dnd@^11.1.3: - version "11.1.3" - resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-11.1.3.tgz#f9844f5699ccc55dfc81462c2c19f726e670c1af" - integrity sha512-8rtzzT8iwHgdSC89VktwhqdKKtfXaAyC4wiqp0SywpHG12TTLvfOoL6xNEIUWXwIEWu+CFfDn4GZJyynCEuHIQ== +react-dnd@14.0.2: + version "14.0.2" + resolved "https://registry.yarnpkg.com/react-dnd/-/react-dnd-14.0.2.tgz#57266baec92b887301f81fa3b77f87168d159733" + integrity sha512-JoEL78sBCg8SzjOKMlkR70GWaPORudhWuTNqJ56lb2P8Vq0eM2+er3ZrMGiSDhOmzaRPuA9SNBz46nHCrjn11A== dependencies: + "@react-dnd/invariant" "^2.0.0" "@react-dnd/shallowequal" "^2.0.0" - "@types/hoist-non-react-statics" "^3.3.1" - dnd-core "^11.1.3" - hoist-non-react-statics "^3.3.0" + dnd-core "14.0.0" + fast-deep-equal "^3.1.3" + hoist-non-react-statics "^3.3.2" react-docgen@^5.0.0: version "5.3.0" @@ -18513,13 +18514,12 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" -redux@^4.0.4: - version "4.0.5" - resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.5.tgz#4db5de5816e17891de8a80c424232d06f051d93f" - integrity sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w== +redux@^4.0.5: + version "4.1.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.0.tgz#eb049679f2f523c379f1aff345c8612f294c88d4" + integrity sha512-uI2dQN43zqLWCt6B/BMGRMY6db7TTY4qeHHfGeKb3EOhmOKjU3KdWvNLJyqaHRksv/ErdNH7cFZWg9jXtewy4g== dependencies: - loose-envify "^1.4.0" - symbol-observable "^1.2.0" + "@babel/runtime" "^7.9.2" refractor@^2.4.1: version "2.10.1" @@ -20292,7 +20292,7 @@ svgo@^1.0.0, svgo@^1.2.2: unquote "~1.1.1" util.promisify "~1.0.0" -symbol-observable@^1.1.0, symbol-observable@^1.2.0: +symbol-observable@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== From 0585117a7cbdd1f2e42a69d6ccff42260586b25d Mon Sep 17 00:00:00 2001 From: Thibaut Sardan <33178835+Tbaut@users.noreply.github.com> Date: Thu, 20 May 2021 09:58:09 +0100 Subject: [PATCH 47/62] Test file upload (#1035) * add deps * test upload modal, cancel and upload * programmatically clear CSF buckets * stray comment * passe className from FileBrowser * cleanup and make it pass * Update packages/files-ui/cypress/support/commands.ts Co-authored-by: --list <--list> --- .../cypress/fixtures/uploadedFiles/logo.png | Bin 0 -> 180586 bytes .../fixtures/uploadedFiles/text-file.txt | 1 + .../cypress/integration/file-management.ts | 26 +++++ packages/files-ui/cypress/support/commands.ts | 110 ++++++++++++++---- packages/files-ui/cypress/support/index.ts | 11 ++ packages/files-ui/cypress/tsconfig.json | 2 +- packages/files-ui/package.json | 5 +- .../FileSystemItem/FileSystemTableItem.tsx | 1 + .../FileBrowsers/views/FilesTable.view.tsx | 1 + .../Components/Modules/UploadFileModule.tsx | 9 +- yarn.lock | 21 ++-- 11 files changed, 149 insertions(+), 38 deletions(-) create mode 100644 packages/files-ui/cypress/fixtures/uploadedFiles/logo.png create mode 100644 packages/files-ui/cypress/fixtures/uploadedFiles/text-file.txt create mode 100644 packages/files-ui/cypress/integration/file-management.ts diff --git a/packages/files-ui/cypress/fixtures/uploadedFiles/logo.png b/packages/files-ui/cypress/fixtures/uploadedFiles/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ecfc70aca1df0d82ed6d99355be2b0dc24dc62d3 GIT binary patch literal 180586 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

WF-<4wEGJxyJQqc0r@ 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 { + it("can add files and cancel", () => { + cy.web3Login() + cy.get("[data-cy=upload-modal-button").click() + cy.get("[data-cy=upload-file-form] input").attachFile("../fixtures/uploadedFiles/text-file.txt") + cy.get(".scrollbar li").should("have.length", 1) + cy.get("[data-cy=upload-cancel-button").click() + cy.get("[data-cy=files-app-header").should("be.visible") + }) + + it("can add/remove files and upload", () => { + cy.web3Login({ clearCSFBucket: true }) + cy.get("[data-cy=upload-modal-button").click() + cy.get("[data-cy=upload-file-form] input").attachFile("../fixtures/uploadedFiles/text-file.txt") + cy.get(".scrollbar li").should("have.length", 1) + cy.get("[data-cy=upload-file-form] input").attachFile("../fixtures/uploadedFiles/logo.png") + cy.get(".scrollbar li").should("have.length", 2) + cy.get(".removeFileIcon").first().click() + cy.get(".scrollbar li").should("have.length", 1) + cy.get("[data-cy=upload-file-form] input").attachFile("../fixtures/uploadedFiles/text-file.txt") + cy.get(".scrollbar li").should("have.length", 2) + cy.get("[data-cy=upload-ok-button").click() + cy.get("[data-cy=files-app-header").should("be.visible") + cy.get("[data-cy=file-item-row]").should("have.length", 2) + }) +}) diff --git a/packages/files-ui/cypress/support/commands.ts b/packages/files-ui/cypress/support/commands.ts index 1e6520bc01..8d439ccb19 100644 --- a/packages/files-ui/cypress/support/commands.ts +++ b/packages/files-ui/cypress/support/commands.ts @@ -28,19 +28,78 @@ import { ethers, Wallet } from "ethers" import { testPrivateKey, testAccountPassword, localHost } from "../fixtures/loginData" import { CustomizedBridge } from "./utils/CustomBridge" +import "cypress-file-upload" export type Storage = Record[] export interface Web3LoginOptions { url?: string + apiUrlBase?: string saveBrowser?: boolean useLocalAndSessionStorage?: boolean + clearCSFBucket?: boolean } const SESSION_FILE = "cypress/fixtures/storage/sessionStorage.json" const LOCAL_FILE = "cypress/fixtures/storage/localStorage.json" +const REFRESH_TOKEN_KEY = "csf.refreshToken" -Cypress.Commands.add("web3Login", ({ saveBrowser = false, url = localHost, useLocalAndSessionStorage = true }: Web3LoginOptions = {}) => { +Cypress.Commands.add("clearCsfBucket", (apiUrlBase: string) => { + cy.window().then((win) => { + cy.request("POST", `${apiUrlBase}/user/refresh`, { "refresh": win.sessionStorage.getItem(REFRESH_TOKEN_KEY) }) + .then((res) => res.body.access_token.token) + .then((accessToken) => { + cy.request({ + method: "POST", + url: `${apiUrlBase}/files/ls`, + body: { "path": "/", "source": { "type": "csf" } }, + auth: { "bearer": accessToken } + }).then((res) => { + const toDelete = res.body.map(({ name }: { name: string }) => `/${name}`) + cy.request({ + method: "POST", + url: `${apiUrlBase}/files/rm`, + body: { "paths": toDelete, "source": { "type": "csf" } }, + auth: { "bearer": accessToken } + }).then(res => { + if(!res.isOkStatusCode){ + throw new Error(`unexpected answer when deleting files: ${JSON.stringify(res, null, 2)}`) + } + }) + }) + }) + }) +}) + +Cypress.Commands.add("saveLocalAndSession", () => { + // save local and session storage in files + cy.window().then((win) => { + const newLocal: Storage = [] + const newSession: Storage = [] + + Object.keys(win.localStorage).forEach((key) => { + newLocal.push({ key, value: win.localStorage.getItem(key) || "" }) + }) + + Object.keys(win.sessionStorage).forEach((key) => { + newSession.push({ key, value: win.sessionStorage.getItem(key) || "" }) + }) + + const newLocalString = JSON.stringify(newLocal) + const newSessionString = JSON.stringify(newSession) + + cy.writeFile(SESSION_FILE, newSessionString) + cy.writeFile(LOCAL_FILE, newLocalString) + }) +}) + +Cypress.Commands.add("web3Login", ({ + saveBrowser = false, + url = localHost, + apiUrlBase = "https://stage.imploy.site/api/v1", + useLocalAndSessionStorage = true, + clearCSFBucket = false +}: Web3LoginOptions = {}) => { let session: Storage = [] let local: Storage = [] @@ -68,7 +127,7 @@ Cypress.Commands.add("web3Login", ({ saveBrowser = false, url = localHost, useLo win.sessionStorage.clear() win.localStorage.clear() - if (useLocalAndSessionStorage){ + if (useLocalAndSessionStorage) { session.forEach(({ key, value }) => { win.sessionStorage.setItem(key, value) }) @@ -84,9 +143,9 @@ Cypress.Commands.add("web3Login", ({ saveBrowser = false, url = localHost, useLo // with nothing in localstorage (and in session storage) // the whole login flow should kick in cy.then(() => { - cy.log("Logging in", !!local.length && "there is something in session storage ---> direct login") + cy.log("Logging in", local.length > 0 && "there is something in session storage ---> direct login") - if(local.length === 0){ + if (local.length === 0) { cy.log("nothing in session storage, --> click on web3 button") cy.get("[data-cy=web3]").click() cy.get(".bn-onboard-modal-select-wallets > :nth-child(1) > .bn-onboard-custom").click() @@ -94,8 +153,8 @@ Cypress.Commands.add("web3Login", ({ saveBrowser = false, url = localHost, useLo cy.get("[data-cy=login-password-button]", { timeout: 20000 }).click() cy.get("[data-cy=login-password-input]").type(`${testAccountPassword}{enter}`) - if(saveBrowser){ - // this is taking forever for test accounts + if (saveBrowser) { + // this is taking forever for test accounts cy.get("[data-cy=save-browser-button]").click() } else { cy.get("[data-cy=do-not-save-browser-button]").click() @@ -105,25 +164,11 @@ Cypress.Commands.add("web3Login", ({ saveBrowser = false, url = localHost, useLo cy.get("[data-cy=files-app-header", { timeout: 20000 }).should("be.visible") - // save local and session storage in files - cy.window().then((win) => { - const newLocal: Array> = [] - const newSession: Array> = [] - - Object.keys(win.localStorage).forEach((key) => { - newLocal.push({ key, value: localStorage.getItem(key) || "" }) - }) - - Object.keys(win.sessionStorage).forEach((key) => { - newSession.push({ key, value: sessionStorage.getItem(key) || "" }) - }) - - const newLocalString = JSON.stringify(newLocal) - const newSessionString = JSON.stringify(newSession) + cy.saveLocalAndSession() - cy.writeFile(SESSION_FILE, newSessionString) - cy.writeFile(LOCAL_FILE, newLocalString) - }) + if (clearCSFBucket) { + cy.clearCsfBucket(apiUrlBase) + } }) // Must be declared global to be detected by typescript (allows import/export) @@ -134,14 +179,29 @@ declare global { /** * Login using Metamask to an instance of Files. * @param {String} options.url - (default: "http://localhost:3000") - what url to visit. + * @param {String} apiUrlBase - (default: "https://stage.imploy.site/api/v1") - what url to call for the api. * @param {Boolean} options.saveBrowser - (default: false) - save the browser to localstorage. * @param {Boolean} options.useLocalAndSessionStorage - (default: true) - use what could have been stored before to speedup login + * @param {Boolean} options.clearCSFBucket - (default: false) - whether any file in the csf bucket should be deleted. * @example cy.web3Login({saveBrowser: true, url: 'http://localhost:8080'}) */ web3Login: (options?: Web3LoginOptions) => Chainable + + /** + * Removed any file or folder at the root + * @param {String} apiUrlBase - what url to call for the api. + * @example cy.clearCsfBucket("https://stage.imploy.site/api/v1") + */ + clearCsfBucket: (apiUrlBase: string) => Chainable + + /** + * Save local and session storage to local files + * @example cy.saveLocalAndSession() + */ + saveLocalAndSession: () => Chainable } } } // Convert this to a module instead of script (allows import/export) -export {} +export { } diff --git a/packages/files-ui/cypress/support/index.ts b/packages/files-ui/cypress/support/index.ts index ab1d671bec..9fd31b5693 100644 --- a/packages/files-ui/cypress/support/index.ts +++ b/packages/files-ui/cypress/support/index.ts @@ -16,5 +16,16 @@ // Import commands.js using ES2015 syntax: import "./commands" +// the following gets rid of the exception "ResizeObserver loop limit exceeded" +// which someone on the internet says we can safely ignore +// source https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded +const resizeObserverLoopErrRe = /^[^(ResizeObserver loop limit exceeded)]/ +Cypress.on("uncaught:exception", (err) => { + /* returning false here prevents Cypress from failing the test */ + if (resizeObserverLoopErrRe.test(err.message)) { + return false + } +}) + // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/packages/files-ui/cypress/tsconfig.json b/packages/files-ui/cypress/tsconfig.json index 1776ddb55f..c58896952f 100644 --- a/packages/files-ui/cypress/tsconfig.json +++ b/packages/files-ui/cypress/tsconfig.json @@ -4,7 +4,7 @@ "target": "es5", "jsx": "react", "lib": ["es5", "dom"], - "types": ["cypress"] + "types": ["cypress", "cypress-file-upload"] }, "include": ["**/*.ts"] } \ No newline at end of file diff --git a/packages/files-ui/package.json b/packages/files-ui/package.json index dbe13d718f..8b583d9b3f 100644 --- a/packages/files-ui/package.json +++ b/packages/files-ui/package.json @@ -63,8 +63,9 @@ "@types/yup": "^0.29.9", "@types/zxcvbn": "^4.4.0", "babel-plugin-macros": "^2.8.0", - "cypress": "^7.2.0", - "eslint-plugin-cypress": "^2.11.2" + "cypress": "^7.3.0", + "cypress-file-upload": "^5.0.7", + "eslint-plugin-cypress": "^2.11.3" }, "scripts": { "postinstall": "yarn compile", diff --git a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx index 6c4077bcc7..51889308e5 100644 --- a/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx +++ b/packages/files-ui/src/Components/Modules/FileBrowsers/views/FileSystemItem/FileSystemTableItem.tsx @@ -147,6 +147,7 @@ const FileSystemTableItem = React.forwardRef( return ( {

*/} -
+
{ + lg={6} + > + lg={6} + >