frontend/src/js/components/Uploads/UploadFiles.tsx (292 lines of code) (raw):

import React, { useReducer, useState } from 'react'; import uuid from 'uuid/v4'; import MdFileUpload from 'react-icons/lib/md/file-upload'; import Modal from '../UtilComponents/Modal'; import FilePicker from './FilePicker'; import FileList from './FileList'; import { Button, Form, Progress } from 'semantic-ui-react'; import { getUploadTarget, WorkspaceTarget } from './UploadTarget'; import { getCollection } from '../../actions/collections/getCollection'; import { uploadFileWithNewIngestion } from '../../services/CollectionsApi'; import history from '../../util/history'; import { displayRelativePath } from '../../util/workspaceUtils'; import { Collection } from '../../types/Collection'; import { addFolderToWorkspace } from '../../services/WorkspaceApi'; import sortBy from 'lodash/sortBy'; import filesize from 'filesize'; import { Workspace, WorkspaceEntry } from '../../types/Workspaces'; import { isTreeLeaf, isTreeNode, TreeEntry, TreeNode } from '../../types/Tree'; import { getWorkspace } from '../../actions/workspaces/getWorkspace'; import { useDispatch } from 'react-redux'; import { AppActionType } from '../../types/redux/GiantActions'; const MAX_FILE_UPLOAD_SIZE_MBYTES = 250 // should correspond to http.parser.maxDiskBuffer in application.conf const MAX_FILE_UPLOAD_SIZE_BYTES = MAX_FILE_UPLOAD_SIZE_MBYTES*1024*1024 type InProgressFileUploadState = { description: 'uploading', loadedBytes?: number, totalBytes: number }; type FailedFileUploadState = { description: 'failed', failureReason?: string } type FileUploadState = { description: 'pending' } | InProgressFileUploadState | { description: 'uploaded' } | FailedFileUploadState; export function isFailedFileUploadState(uploadState: FileUploadState): uploadState is FailedFileUploadState { return uploadState.description === "failed" } export type WorkspaceUploadMetadata = { workspaceId: string, workspaceName: string, parentNodeId: string } export type UploadFile = { file: File, state: FileUploadState }; type Props = { username: string, workspace: Workspace, collections?: Collection[], getResource: typeof getCollection | typeof getWorkspace, focusedWorkspaceEntry: TreeEntry<WorkspaceEntry> | null, expandedNodes?: TreeNode<WorkspaceEntry>[] } type State = { // TODO MRB: explore whether this really needs to be a map compared to // an object or a straight-forward array of files files: Map<string, UploadFile>, target?: WorkspaceTarget } export type Action = { type: "Reset", state: State } | { type: "Set_Target", target: WorkspaceTarget } | { type: "Add_Files", files: Map<string, File> } | { type: "Remove_Path", path: string } | { type: "Set_Upload_State", file: string, state: FileUploadState }; function reducer(state: State, action: Action): State { switch(action.type) { case "Reset": return action.state; case "Set_Target": return { ...state, target: action.target }; case "Add_Files": { for(const [path, file] of action.files) { state.files.set(path, { file, state: { description: 'pending' }}); } // Create a new object to make React re-render return { ...state }; } case "Remove_Path": { for(const [key] of state.files) { if(key.startsWith(action.path)) { state.files.delete(key); } } // Create a new object to make React re-render return { ...state }; } case "Set_Upload_State": { const existing = state.files.get(action.file); if(existing) { state.files.set(action.file, { ...existing, state: action.state }); } // Create a new object to make React re-render return { ...state }; } } } async function createIntermediateDirectories(workspaceId: string, workspaceParentId: string, directories: string[], mutableCache: Map<string, string>): Promise<string> { let parent = workspaceParentId; let parentName = ""; for(const part of directories) { const partName = parentName === "" ? part : parentName + "/" + part; let idFromCache = mutableCache.get(partName); if(!idFromCache) { const { id } = (await addFolderToWorkspace(workspaceId, parent, part) as { id: string }); mutableCache.set(partName, id); idFromCache = id; } parentName = partName; parent = idFromCache; } return parent; } async function buildWorkspaceUploadMetadata(path: string, target: WorkspaceTarget, mutableFolderCache: Map<string, string>): Promise<WorkspaceUploadMetadata> { const workspaceId = target.workspace.id; const parentId = target.workspaceEntry.id; const parts = path.split("/"); const parentDirectoryNames = parts.slice(0, parts.length - 1); const parentNodeId = await createIntermediateDirectories(workspaceId, parentId, parentDirectoryNames, mutableFolderCache); return { workspaceId, workspaceName: target.workspace.name, parentNodeId }; } async function uploadFiles(target: WorkspaceTarget, files: Map<string, UploadFile>, dispatch: React.Dispatch<Action>): Promise<void> { const uploadId = uuid(); let workspaceFolderCache: Map<string, string> = new Map(); async function nextFile(target: WorkspaceTarget, files: Array<[string, UploadFile]>) { const [path, { file }] = files[0]; const state: FileUploadState = { description: 'uploading', totalBytes: file.size }; dispatch({ type: 'Set_Upload_State', file: path, state }); if (file.size > MAX_FILE_UPLOAD_SIZE_BYTES) { console.error(`Error uploading ${path}: file is too large (limit ${MAX_FILE_UPLOAD_SIZE_MBYTES}MB`) dispatch({ type: "Set_Upload_State", file: path, state: { description: 'failed', failureReason: `File too large (limit ${MAX_FILE_UPLOAD_SIZE_MBYTES}MB)` }}); } else { try { function onProgress(loadedBytes: number, totalBytes: number) { const state: FileUploadState = { description: 'uploading', loadedBytes, totalBytes }; dispatch({ type: 'Set_Upload_State', file: path, state }); } const metadata = await buildWorkspaceUploadMetadata(path, target, workspaceFolderCache); await uploadFileWithNewIngestion(target.collectionUri, target.ingestionName, uploadId, file, path, metadata, onProgress); dispatch({ type: "Set_Upload_State", file: path, state: { description: 'uploaded' }}); } catch(e) { console.error(`Error uploading ${path}: ${e}`); dispatch({ type: "Set_Upload_State", file: path, state: { description: 'failed' }}); } } if(files.length > 1) { await nextFile(target, files.slice(1)); } } const sortedFiles = sortBy(Array.from(files.entries()), ([key]) => key); await nextFile(target, sortedFiles); } function getCurrentlyUploading(state: State): { file: string, uploadState: InProgressFileUploadState } | undefined { for(const [, upload] of state.files) { if(upload.state.description === 'uploading') { return { file: upload.file.name, uploadState: upload.state } } } return undefined; } function FileUploadProgressBar({ file, uploadState: { loadedBytes, totalBytes } }: { file: string, uploadState: InProgressFileUploadState }) { if(loadedBytes && loadedBytes !== totalBytes) { return <Progress value={loadedBytes} total={totalBytes}> Uploading {file}: {`${filesize(loadedBytes)} of ${filesize(totalBytes)}`} </Progress>; } else { /* Indicate activity (via a nice sparkly animation) */ return <Progress active value={1} total={1}>Processing {file}</Progress>; } } export default function UploadFiles(props: Props) { let target: WorkspaceTarget | undefined = undefined; const appDispatch = useDispatch(); const [state, dispatch] = useReducer(reducer, { files: new Map() }); const currentUpload = getCurrentlyUploading(state); const isUploading = currentUpload !== undefined; const completeCount = [...state.files.values()].filter(({ state }) => state.description === 'uploaded').length; const isComplete = [...state.files.values()].some(({ state }) => state.description === 'uploaded'); const isEditDisabled = isUploading || isComplete; function dismissAndResetModal() { setOpen(false); dispatch({ type: "Reset", state: { files: new Map(), target: undefined } }); } const [open, setOpen] = useState(false); const [focusedWorkspaceFolder, setFocusedWorkspaceFolder] = useState<TreeNode<WorkspaceEntry> | null>(null); async function onSubmit() { const { username, workspace, collections, getResource } = props; if (!collections) { throw new Error('No collections when submitting upload dialog'); } try { target = await getUploadTarget(username, workspace, collections, focusedWorkspaceFolder); if (isComplete) { dismissAndResetModal(); history.push(`/workspaces/${target.workspace.id}`); } else { await uploadFiles(target, state.files, dispatch).then(() => { getResource(workspace.id); }); } } catch(error) { appDispatch({ type: AppActionType.APP_SHOW_ERROR, message: `Error uploading files ${error}`, error: error, }); } } function onDismiss() { setOpen(false); setFocusedWorkspaceFolder(null); dispatch({ type: "Reset", state: { files: new Map(), target: undefined } }); } function onClick() { setOpen(true); if (props.focusedWorkspaceEntry) { const { focusedWorkspaceEntry } = props; // Set the folder we are focused on. If we are focused on a leaf, then // the focused leaf is the parent of that folder. if (isTreeNode(focusedWorkspaceEntry)) { setFocusedWorkspaceFolder(focusedWorkspaceEntry); } else if (isTreeLeaf(focusedWorkspaceEntry)) { const rootNodeId = props.workspace.rootNode.id; const parentId = focusedWorkspaceEntry.data.maybeParentId; if (parentId && parentId !== rootNodeId) { const focusedUploadFolder = props.expandedNodes && props.expandedNodes.find(node => node.id === parentId); if (focusedUploadFolder) { setFocusedWorkspaceFolder(focusedUploadFolder); } } } } } const focusedWorkspaceRelativePath = (focusedWorkspaceFolder && props.workspace.rootNode) ? displayRelativePath(props.workspace.rootNode, focusedWorkspaceFolder.id) : null; return ( <React.Fragment> <button className='btn file-upload__button' onClick={onClick} > <MdFileUpload className='file-upload__icon'/> Upload to workspace </button> <Modal isOpen={open} dismiss={onDismiss} isDismissable={!isUploading}> <Form onSubmit={onSubmit}> <h2 className='form__title'>Upload Files (limit {MAX_FILE_UPLOAD_SIZE_MBYTES}MB per file)</h2> { focusedWorkspaceRelativePath ? <div className='form__row'>Uploading to folder {focusedWorkspaceRelativePath}</div> : false} <Form.Field> <FilePicker disabled={isEditDisabled} onAddFiles={(files) => { dispatch({ type: "Add_Files", files }) }} /> </Form.Field> <Form.Field> <FileList files={state.files} disabled={isEditDisabled} removeByPath={(path: string) => { dispatch({ type: "Remove_Path", path }) }} /> </Form.Field> {currentUpload ? <FileUploadProgressBar file={currentUpload.file} uploadState={currentUpload.uploadState} /> : false} { !isComplete ? <Button type="submit" primary disabled={state.files.size === 0 || isUploading} loading={isUploading} > {'Upload'} </Button> : false } {isComplete && !isUploading ? <Button type="button" onClick={() => dismissAndResetModal()} > Close </Button> : false} {isUploading ? <span>Uploaded {completeCount}/{state.files.size}</span> : false} </Form> </Modal> </React.Fragment> ); }