Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow the upload of empty folder on drop #2164

Merged
merged 16 commits into from
Jun 10, 2022
Merged
33 changes: 28 additions & 5 deletions packages/common-components/src/FileInput/FileInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Button } from "../Button"
import { Typography } from "../Typography"
import { PlusIcon, CrossIcon } from "../Icons"
import { ScrollbarWrapper } from "../ScrollbarWrapper"
import { getFilesAndEmptyDirFromDataTransferItems } from "../utils"

const useStyles = makeStyles(({ constants, palette, overrides }: ITheme) =>
createStyles({
Expand Down Expand Up @@ -118,6 +119,7 @@ interface IFileInputProps extends DropzoneOptions {
error?: string
}
onFileNumberChange?: (filesNumber: number) => void
onEmptyFolderPathsChange?: (emptyFolderPaths: string[]) => void
moreFilesLabel: string
testId?: string
}
Expand Down Expand Up @@ -162,6 +164,7 @@ const FileInput = ({
maxFileSize,
classNames,
onFileNumberChange,
onEmptyFolderPathsChange,
moreFilesLabel,
testId,
...props
Expand All @@ -179,6 +182,13 @@ const FileInput = ({
}
}, [onFileNumberChange, value])

useEffect(() => {
// reset the field on load
helpers.setValue([])
// needed to avoid an infinite loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])

const onDrop = useCallback(
async (acceptedFiles: FileWithPath[], fileRejections: FileRejection[]) => {
const filtered = acceptedFiles.filter((file) =>
Expand Down Expand Up @@ -223,16 +233,29 @@ const FileInput = ({

const { getRootProps, getInputProps, open } = useDropzone({
onDrop,
...dropZoneProps
...dropZoneProps,
getFilesFromEvent
})

async function getFilesFromEvent(event: any) {
// this is a drag n drop
// we support folder upload and empty folders
if(event.dataTransfer){
const res = await getFilesAndEmptyDirFromDataTransferItems(event.dataTransfer.items)
onEmptyFolderPathsChange && res.emptyDirPaths?.length && onEmptyFolderPathsChange(res.emptyDirPaths)
return res.files as File[] || []
} else {
// this is a file list using the input
return event.target.files as File[]
}
}

const removeItem = useCallback((i: number) => {
let items = value

Array.isArray(items)
? items.splice(i, 1)
: items = null

helpers.setValue(items)
}, [helpers, value])

Expand Down Expand Up @@ -261,21 +284,21 @@ const FileInput = ({
data-testid={`list-${testId}`}
className={clsx(classes.root, classNames?.filelist)}
>
<ScrollbarWrapper className={clsx("scrollbar")}>
<ScrollbarWrapper className="scrollbar">
<ul>
{Array.isArray(value)
? value.map((file, i) => (
<FileItem
key={i}
index={i}
fullPath={file.path || ""}
fullPath={`${file.path || ""}${file.name}`}
removeItem={removeItem}
closeIconClassName={classNames?.closeIcon}
itemClassName={classNames?.item}
/>
))
: <FileItem
fullPath={value.path || ""}
fullPath={`${value.path || ""}${value.name}`}
removeItem={removeItem}
closeIconClassName={classNames?.closeIcon}
itemClassName={classNames?.item}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
export type FileWithPath = File & {
path: string
}

interface FilesAndDirFromDataTransferItems {
files?: FileWithPath[]
emptyDirPaths?: string[]
}

const mergeResults = (prevRes: FilesAndDirFromDataTransferItems, newRes: FilesAndDirFromDataTransferItems) => {
return {
files: [...(prevRes.files || []), ...(newRes.files || [])],
emptyDirPaths: [...(prevRes.emptyDirPaths || []), ...(newRes.emptyDirPaths || [])]
} as FilesAndDirFromDataTransferItems
}

const readFile = (entry: FileEntry, path = ""): Promise<FileWithPath> => {
return new Promise((resolve, reject) => {
entry.file((file: File) => {
Object.defineProperty(file, "path", {
value: path
})
resolve(file as FileWithPath)
}, (err: Error) => {
reject(err)
})
})
}


const dirReadEntries = (dirReader: DirectoryReader, path: string): Promise<FilesAndDirFromDataTransferItems> => {
return new Promise((resolve, reject) => {
dirReader.readEntries(async (entries: FileSystemEntry[]) => {
let res: FilesAndDirFromDataTransferItems = {}
for (const entry of entries) {
const newRes = await getFilesAndEmptyDirFromEntry(entry, path)
res = mergeResults(res, newRes)
}
resolve(res)
}, (err: Error) => {
reject(err)
})
})
}

const readDir = async (entry: DirectoryEntry, path: string) => {
const dirReader = entry.createReader()
const newPath = path + entry.name + "/"
let newFilesOrFolders: FilesAndDirFromDataTransferItems = {}
let res: FilesAndDirFromDataTransferItems = {}
do {
newFilesOrFolders = await dirReadEntries(dirReader, newPath)

res = mergeResults(res, newFilesOrFolders)
} while (newFilesOrFolders?.files && newFilesOrFolders.files.length > 0)

return res
}

const getFilesAndEmptyDirFromEntry = async (entry: FileSystemEntry, path = ""): Promise<FilesAndDirFromDataTransferItems> => {
if (entry.isFile) {
const file = await readFile(entry as FileEntry, path)
return { files: [file] }
}

if (entry.isDirectory) {
// read files inside this dir
const res = await readDir(entry as DirectoryEntry, path)

if(!res?.files || res.files.length === 0){
// this is an empty dir
return { emptyDirPaths: [`${path}${entry.name}`] }
}

return res
}

return {}
}

export const getFilesAndEmptyDirFromDataTransferItems =
async (dataTransferItems: DataTransferItemList): Promise<FilesAndDirFromDataTransferItems> => {
let result: FilesAndDirFromDataTransferItems = { files: [], emptyDirPaths: [] }
const entries: (FileSystemEntry | null)[] = []

// Pull out all entries before reading them
for (let i = 0, ii = dataTransferItems.length; i < ii; i++) {
entries.push(dataTransferItems[i].webkitGetAsEntry())
}

// Recursively read through all entries
for (const entry of entries) {
if (entry) {
const newRes = await getFilesAndEmptyDirFromEntry(entry)
result = mergeResults(result, newRes)
}
}

return result
}
1 change: 1 addition & 0 deletions packages/common-components/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./formDataHelper"
export * from "./stringUtils"
export * from "./getFilesAndEmptyDirFromDataTransferItems"
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { Crumb, useToasts, useHistory, useLocation } from "@chainsafe/common-components"
import { Crumb, useToasts, useHistory, useLocation, getFilesAndEmptyDirFromDataTransferItems } from "@chainsafe/common-components"
import { useFiles, FileSystemItem } from "../../../Contexts/FilesContext"
import {
getArrayOfPaths,
Expand All @@ -23,7 +23,6 @@ import { useUser } from "../../../Contexts/UserContext"
import { DISMISSED_SURVEY_KEY } from "../../SurveyBanner"
import { FileBrowserContext } from "../../../Contexts/FileBrowserContext"
import { parseFileContentResponse } from "../../../Utils/Helpers"
import getFilesFromDataTransferItems from "../../../Utils/getFilesFromDataTransferItems"
import { Helmet } from "react-helmet-async"

const CSFFileBrowser: React.FC<IFileBrowserModuleProps> = () => {
Expand Down Expand Up @@ -184,9 +183,21 @@ const CSFFileBrowser: React.FC<IFileBrowserModuleProps> = () => {
})
return
}
const flattenedFiles = await getFilesFromDataTransferItems(fileItems)
await uploadFiles(bucket, flattenedFiles, path)
}, [bucket, accountRestricted, storageSummary, addToast, uploadFiles])

const flattenedFiles = await getFilesAndEmptyDirFromDataTransferItems(fileItems)
flattenedFiles.files?.length && await uploadFiles(bucket, flattenedFiles.files, path)

//create empty dir
if(flattenedFiles?.emptyDirPaths?.length){
const allDirs = flattenedFiles.emptyDirPaths.map((folderPath) =>
filesApiClient.addBucketDirectory(bucket.id, { path: getPathWithFile(currentPath, folderPath) })
)

Promise.all(allDirs)
.then(() => refreshContents(true))
.catch(console.error)
}
}, [bucket, accountRestricted, storageSummary, uploadFiles, refreshContents, addToast, filesApiClient, currentPath])

const viewFolder = useCallback((cid: string) => {
const fileSystemItem = pathContents.find(f => f.cid === cid)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { useToasts, useHistory, useLocation, Crumb, Typography, ExclamationCircleIcon, Loading } from "@chainsafe/common-components"
import {
useToasts,
useHistory,
useLocation,
Crumb,
Typography,
ExclamationCircleIcon,
Loading,
getFilesAndEmptyDirFromDataTransferItems
} from "@chainsafe/common-components"
import {
getArrayOfPaths,
getURISafePathFromArray,
Expand All @@ -22,7 +31,6 @@ import DragAndDrop from "../../../Contexts/DnDContext"
import FilesList from "./views/FilesList"
import { createStyles, makeStyles } from "@chainsafe/common-theme"
import { CSFTheme } from "../../../Themes/types"
import getFilesFromDataTransferItems from "../../../Utils/getFilesFromDataTransferItems"
import { Helmet } from "react-helmet-async"

const useStyles = makeStyles(({ constants, palette }: CSFTheme) =>
Expand Down Expand Up @@ -224,9 +232,21 @@ const SharedFileBrowser = () => {
})
return
}
const flattenedFiles = await getFilesFromDataTransferItems(fileItems)
await uploadFiles(bucket, flattenedFiles, path)
}, [bucket, accountRestricted, storageSummary, addToast, uploadFiles])

const flattenedFiles = await getFilesAndEmptyDirFromDataTransferItems(fileItems)
flattenedFiles.files?.length && await uploadFiles(bucket, flattenedFiles.files, path)

//create empty dir
if(flattenedFiles?.emptyDirPaths?.length){
const allDirs = flattenedFiles.emptyDirPaths.map((folderPath) =>
filesApiClient.addBucketDirectory(bucket.id, { path: getPathWithFile(currentPath, folderPath) })
)

Tbaut marked this conversation as resolved.
Show resolved Hide resolved
Promise.all(allDirs)
.then(() => refreshContents(true))
.catch(console.error)
}
}, [bucket, accountRestricted, storageSummary, uploadFiles, addToast, filesApiClient, currentPath, refreshContents])

const bulkOperations: IBulkOperations = useMemo(() => ({
[CONTENT_TYPES.Directory]: ["download", "move", "delete", "share"],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Button, FileInput } from "@chainsafe/common-components"
import { useFiles } from "../../../Contexts/FilesContext"
import { createStyles, makeStyles } from "@chainsafe/common-theme"
import React, { useCallback, useState } from "react"

import React, { useCallback, useState, useEffect } from "react"
import { Form, useFormik, FormikProvider } from "formik"
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"
import { getPathWithFile } from "../../../Utils/pathUtils"
import { useFilesApi } from "../../../Contexts/FilesApiContext"

const useStyles = makeStyles(({ constants, breakpoints }: CSFTheme) =>
createStyles({
Expand Down Expand Up @@ -83,8 +86,15 @@ interface UploadedFiles {
const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => {
const classes = useStyles()
const [isDoneDisabled, setIsDoneDisabled] = useState(true)
const { currentPath, refreshContents, bucket } = useFileBrowser()
const { uploadFiles, storageSummary } = useFiles()
const { currentPath, refreshContents, bucket } = useFileBrowser()
const { storageSummary, uploadFiles } = useFiles()
const { filesApiClient } = useFilesApi()
const [emptyFolders, setEmptyFolders] = useState<string[]>([])
const [isTouched, setIsTouched] = useState(false)

useEffect(() => {
setEmptyFolders([])
}, [])

const onFileNumberChange = useCallback((filesNumber: number) => {
setIsDoneDisabled(filesNumber === 0)
Expand All @@ -96,18 +106,28 @@ const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => {
helpers.setSubmitting(true)
try {
close()
await uploadFiles(bucket, values.files, currentPath)
await values.files.length && uploadFiles(bucket, values.files, currentPath)

//create empty dir
if(emptyFolders.length){
const allDirs = emptyFolders.map((folderPath) =>
filesApiClient.addBucketDirectory(bucket.id, { path: getPathWithFile(currentPath, folderPath) })
)

Tbaut marked this conversation as resolved.
Show resolved Hide resolved
await Promise.all(allDirs)
.catch(console.error)
}
refreshContents && refreshContents()
helpers.resetForm()
} catch (error: any) {
console.error(error)
}
helpers.setSubmitting(false)
}, [bucket, close, refreshContents, uploadFiles, currentPath])
}, [bucket, close, uploadFiles, currentPath, emptyFolders, refreshContents, filesApiClient])

const onFormikValidate = useCallback(({ files }: UploadedFiles) => {

if (files.length === 0) {
if (files.length === 0 && isTouched) {
return { files: t`Please select a file to upload` }
}

Expand All @@ -117,14 +137,19 @@ const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => {
if(uploadSize > availableStorage)
return { files: t`Upload size exceeds plan capacity` }
},
[storageSummary])
[storageSummary, isTouched])

const formik = useFormik({
initialValues: { files: [] },
validate: onFormikValidate,
enableReinitialize: true,
onSubmit
})

useEffect(() => {
setIsTouched(formik.dirty)
}, [formik])

return (
<CustomModal
active={modalOpen}
Expand All @@ -151,6 +176,7 @@ const UploadFileModule = ({ modalOpen, close }: IUploadFileModuleProps) => {
maxSize={2 * 1024 ** 3}
name="files"
onFileNumberChange={onFileNumberChange}
onEmptyFolderPathsChange={setEmptyFolders}
testId="fileUpload"
/>
<footer className={classes.footer}>
Expand Down
Loading