Merge branch '14013-upload-collection-files-using-webdav'
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Mon, 20 Aug 2018 09:16:54 +0000 (11:16 +0200)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Mon, 20 Aug 2018 09:16:54 +0000 (11:16 +0200)
refs #14013

Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski@contractors.roche.com>

20 files changed:
src/common/file.ts [new file with mode: 0644]
src/common/webdav.test.ts
src/common/webdav.ts
src/components/collection-panel-files/collection-panel-files.tsx
src/components/file-upload-dialog/file-upload-dialog.tsx [new file with mode: 0644]
src/models/tree.test.ts
src/models/tree.ts
src/services/collection-files-service/collection-manifest-mapper.ts
src/services/collection-service/collection-service-files-response.ts [new file with mode: 0644]
src/services/collection-service/collection-service.ts
src/services/services.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts
src/store/collections/creator/collection-creator-action.ts
src/store/collections/uploader/collection-uploader-actions.ts
src/store/tree-picker/tree-picker-reducer.test.ts
src/views-components/collection-panel-files/collection-panel-files.ts
src/views-components/tree-picker/tree-picker.ts
src/views-components/upload-collection-files-dialog/upload-collection-files-dialog.ts [new file with mode: 0644]
src/views/workbench/workbench.tsx

diff --git a/src/common/file.ts b/src/common/file.ts
new file mode 100644 (file)
index 0000000..2311399
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const fileToArrayBuffer = (file: File) =>
+    new Promise<ArrayBuffer>((resolve, reject) => {
+        const reader = new FileReader();
+        reader.onload = () => {
+            resolve(reader.result as ArrayBuffer);
+        };
+        reader.onerror = () => {
+            reject();
+        };
+        reader.readAsArrayBuffer(file);
+    });
index d96465ba7e61a748f92f3a9b53d78b4b057266fc..c85f30e793864ecc3cb5798c44a85de75afa6d55 100644 (file)
@@ -41,15 +41,13 @@ describe('WebDAV', () => {
 
     it('PUT', async () => {
         const { open, send, load, progress, createRequest } = mockCreateRequest();
-        const onProgress = jest.fn();
         const webdav = new WebDAV(undefined, createRequest);
-        const promise = webdav.put('foo', 'Test data', { onProgress });
+        const promise = webdav.put('foo', 'Test data');
         progress();
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('PUT', 'foo');
         expect(send).toHaveBeenCalledWith('Test data');
-        expect(onProgress).toHaveBeenCalled();
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
index 57caebc839e433f29ee26e13c5163e71270241dc..27e1f22de5be8c642072d7ae42b80f9189fe524b 100644 (file)
@@ -58,8 +58,8 @@ export class WebDAV {
                 .keys(headers)
                 .forEach(key => r.setRequestHeader(key, headers[key]));
 
-            if (config.onProgress) {
-                r.addEventListener('progress', config.onProgress);
+            if (config.onUploadProgress) {
+                r.upload.addEventListener('progress', config.onUploadProgress);
             }
 
             r.addEventListener('load', () => resolve(r));
@@ -73,7 +73,7 @@ export interface WebDAVRequestConfig {
     headers?: {
         [key: string]: string;
     };
-    onProgress?: (event: ProgressEvent) => void;
+    onUploadProgress?: (event: ProgressEvent) => void;
 }
 
 interface WebDAVDefaults {
@@ -86,5 +86,5 @@ interface RequestConfig {
     url: string;
     headers?: { [key: string]: string };
     data?: any;
-    onProgress?: (event: ProgressEvent) => void;
+    onUploadProgress?: (event: ProgressEvent) => void;
 }
index 665758c3e62cbd916366045f79c1e4026a560c99..f9c18219852fdfce3a5cf0f60a4d874cf56c06f1 100644 (file)
@@ -8,10 +8,6 @@ import { FileTreeData } from '../file-tree/file-tree-data';
 import { FileTree } from '../file-tree/file-tree';
 import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, Card, Button } from '@material-ui/core';
 import { CustomizeTableIcon } from '../icon/icon';
-import { connect, DispatchProp } from "react-redux";
-import { Dispatch } from "redux";
-import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
 
 export interface CollectionPanelFilesProps {
     items: Array<TreeItem<FileTreeData>>;
@@ -40,44 +36,34 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     }
 });
 
-const renameFile = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-  services.collectionFilesService.renameTest();
-};
-
-
 export const CollectionPanelFiles =
-    connect()(
     withStyles(styles)(
-    ({ onItemMenuOpen, onOptionsMenuOpen, classes, dispatch, ...treeProps }: CollectionPanelFilesProps & DispatchProp & WithStyles<CssRules>) =>
-        <Card className={classes.root}>
-            <CardHeader
-                title="Files"
-                action={
-                    <Button onClick={
-                        () => {
-                            dispatch<any>(renameFile());
-                        }}
-                        variant='raised'
-                        color='primary'
-                        size='small'>
-                        Upload data
+        ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
+            <Card className={classes.root}>
+                <CardHeader
+                    title="Files"
+                    action={
+                        <Button onClick={onUploadDataClick}
+                            variant='raised'
+                            color='primary'
+                            size='small'>
+                            Upload data
                     </Button>
-                } />
-            <CardHeader
-                className={classes.cardSubheader}
-                action={
-                    <IconButton onClick={onOptionsMenuOpen}>
-                        <CustomizeTableIcon />
-                    </IconButton>
-                } />
-            <Grid container justify="space-between">
-                <Typography variant="caption" className={classes.nameHeader}>
-                    Name
+                    } />
+                <CardHeader
+                    className={classes.cardSubheader}
+                    action={
+                        <IconButton onClick={onOptionsMenuOpen}>
+                            <CustomizeTableIcon />
+                        </IconButton>
+                    } />
+                <Grid container justify="space-between">
+                    <Typography variant="caption" className={classes.nameHeader}>
+                        Name
                     </Typography>
-                <Typography variant="caption" className={classes.fileSizeHeader}>
-                    File size
+                    <Typography variant="caption" className={classes.fileSizeHeader}>
+                        File size
                     </Typography>
-            </Grid>
-            <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
-        </Card>)
-);
+                </Grid>
+                <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
+            </Card>);
diff --git a/src/components/file-upload-dialog/file-upload-dialog.tsx b/src/components/file-upload-dialog/file-upload-dialog.tsx
new file mode 100644 (file)
index 0000000..7810c49
--- /dev/null
@@ -0,0 +1,52 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { FileUpload } from "~/components/file-upload/file-upload";
+import { UploadFile } from '~/store/collections/uploader/collection-uploader-actions';
+import { Dialog, DialogTitle, DialogContent, DialogActions } from '@material-ui/core/';
+import { Button, CircularProgress } from '@material-ui/core';
+import { WithDialogProps } from '../../store/dialog/with-dialog';
+
+export interface FilesUploadDialogProps {
+    files: UploadFile[];
+    uploading: boolean;
+    onSubmit: () => void;
+    onChange: (files: File[]) => void;
+}
+
+export const FilesUploadDialog = (props: FilesUploadDialogProps & WithDialogProps<{}>) =>
+    <Dialog open={props.open}
+        disableBackdropClick={true}
+        disableEscapeKeyDown={true}
+        fullWidth={true}
+        maxWidth='sm'>
+        <DialogTitle>Upload data</DialogTitle>
+        <DialogContent>
+            <FileUpload
+                files={props.files}
+                disabled={props.uploading}
+                onDrop={props.onChange}
+            />
+        </DialogContent>
+        <DialogActions>
+            <Button
+                variant='flat'
+                color='primary'
+                disabled={props.uploading}
+                onClick={props.closeDialog}>
+                Cancel
+            </Button>
+            <Button
+                variant='contained'
+                color='primary'
+                type='submit'
+                onClick={props.onSubmit}
+                disabled={props.uploading}>
+                {props.uploading
+                    ? <CircularProgress size={20} />
+                    : 'Upload data'}
+            </Button>
+        </DialogActions>
+    </Dialog>;
index 708cf4045c75b73fae7650b8a3bba789a46362bc..375a012054f9bea3a26a7bd8033642b44697ea24 100644 (file)
@@ -30,7 +30,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3', parent: 'Node 2', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeAncestors('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
+        expect(Tree.getNodeAncestorsIds('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
     });
 
     it('gets node descendants', () => {
@@ -41,7 +41,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeDescendants('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+        expect(Tree.getNodeDescendantsIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
     });
 
     it('gets root descendants', () => {
@@ -52,7 +52,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeDescendants('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+        expect(Tree.getNodeDescendantsIds('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
     });
 
     it('gets node children', () => {
@@ -63,7 +63,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeChildren('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
+        expect(Tree.getNodeChildrenIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
     });
 
     it('gets root children', () => {
@@ -74,7 +74,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: '', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeChildren('')(newTree)).toEqual(['Node 1', 'Node 3']);
+        expect(Tree.getNodeChildrenIds('')(newTree)).toEqual(['Node 1', 'Node 3']);
     });
 
     it('maps tree', () => {
index 8b66e50da7961df58c5c60f0b8fd23556f749467..a5fb49cff4a3adb3945cdcfbcb1a1ec6b4ac5080 100644 (file)
@@ -6,7 +6,7 @@ export type Tree<T> = Record<string, TreeNode<T>>;
 
 export const TREE_ROOT_ID = '';
 
-export interface TreeNode<T> {
+export interface TreeNode<T = any> {
     children: string[];
     value: T;
     id: string;
@@ -21,7 +21,7 @@ export const setNode = <T>(node: TreeNode<T>) => (tree: Tree<T>): Tree<T> => {
     const [newTree] = [tree]
         .map(tree => getNode(node.id)(tree) === node
             ? tree
-            : {...tree, [node.id]: node})
+            : { ...tree, [node.id]: node })
         .map(addChild(node.parent, node.id));
     return newTree;
 };
@@ -46,25 +46,32 @@ export const setNodeValueWith = <T>(mapFn: (value: T) => T) => (id: string) => (
 };
 
 export const mapTreeValues = <T, R>(mapFn: (value: T) => R) => (tree: Tree<T>): Tree<R> =>
-    getNodeDescendants('')(tree)
+    getNodeDescendantsIds('')(tree)
         .map(id => getNode(id)(tree))
         .map(mapNodeValue(mapFn))
         .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
 
-export const mapTree = <T, R>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
-    getNodeDescendants('')(tree)
+export const mapTree = <T, R = T>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
+    getNodeDescendantsIds('')(tree)
         .map(id => getNode(id)(tree))
         .map(mapFn)
         .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
 
-export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>): string[] => {
+export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeAncestorsIds(id)(tree))(tree);
+
+
+export const getNodeAncestorsIds = (id: string) => <T>(tree: Tree<T>): string[] => {
     const node = getNode(id)(tree);
     return node && node.parent
-        ? [...getNodeAncestors(node.parent)(tree), node.parent]
+        ? [...getNodeAncestorsIds(node.parent)(tree), node.parent]
         : [];
 };
 
-export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
+export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeDescendantsIds(id, limit)(tree))(tree);
+
+export const getNodeDescendantsIds = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
     const node = getNode(id)(tree);
     const children = node ? node.children :
         id === TREE_ROOT_ID
@@ -75,12 +82,18 @@ export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tr
         .concat(limit < 1
             ? []
             : children
-                .map(id => getNodeDescendants(id, limit - 1)(tree))
+                .map(id => getNodeDescendantsIds(id, limit - 1)(tree))
                 .reduce((nodes, nodeChildren) => [...nodes, ...nodeChildren], []));
 };
 
-export const getNodeChildren = (id: string) => <T>(tree: Tree<T>): string[] =>
-    getNodeDescendants(id, 0)(tree);
+export const getNodeChildren = (id: string) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeChildrenIds(id)(tree))(tree);
+
+export const getNodeChildrenIds = (id: string) => <T>(tree: Tree<T>): string[] =>
+    getNodeDescendantsIds(id, 0)(tree);
+
+export const mapIdsToNodes = (ids: string[]) => <T>(tree: Tree<T>) =>
+    ids.map(id => getNode(id)(tree)).filter((node): node is TreeNode<T> => node !== undefined);
 
 const mapNodeValue = <T, R>(mapFn: (value: T) => R) => (node: TreeNode<T>): TreeNode<R> =>
     ({ ...node, value: mapFn(node.value) });
index c3fd43ead1d0d4c013e066f0331712710af9f38f..0c7e91deecf4aba5bc9465569c40118f9401d536 100644 (file)
@@ -4,11 +4,11 @@
 
 import { uniqBy, groupBy } from 'lodash';
 import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "~/models/keep-manifest";
-import { TreeNode, setNode, createTree, getNodeDescendants, getNodeValue } from '~/models/tree';
+import { TreeNode, setNode, createTree, getNodeDescendantsIds, getNodeValue } from '~/models/tree';
 import { CollectionFilesTree, CollectionFile, CollectionDirectory, createCollectionDirectory, createCollectionFile, CollectionFileType } from '../../models/collection-file';
 
 export const mapCollectionFilesTreeToManifest = (tree: CollectionFilesTree): KeepManifest => {
-    const values = getNodeDescendants('')(tree).map(id => getNodeValue(id)(tree));
+    const values = getNodeDescendantsIds('')(tree).map(id => getNodeValue(id)(tree));
     const files = values.filter(value => value && value.type === CollectionFileType.FILE) as CollectionFile[];
     const fileGroups = groupBy(files, file => file.path);
     return Object
diff --git a/src/services/collection-service/collection-service-files-response.ts b/src/services/collection-service/collection-service-files-response.ts
new file mode 100644 (file)
index 0000000..b8a7970
--- /dev/null
@@ -0,0 +1,54 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createCollectionFilesTree, CollectionDirectory, CollectionFile, CollectionFileType, createCollectionDirectory, createCollectionFile } from "../../models/collection-file";
+import { getTagValue } from "~/common/xml";
+import { getNodeChildren, Tree, mapTree } from '~/models/tree';
+
+export const parseFilesResponse = (document: Document) => {
+    const files = extractFilesData(document);
+    const tree = createCollectionFilesTree(files);
+    return sortFilesTree(tree);
+};
+
+export const sortFilesTree = (tree: Tree<CollectionDirectory | CollectionFile>) => {
+    return mapTree<CollectionDirectory | CollectionFile>(node => {
+        const children = getNodeChildren(node.id)(tree);
+
+        children.sort((a, b) =>
+            a.value.type !== b.value.type
+                ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
+                : a.value.name.localeCompare(b.value.name)
+        );
+        return { ...node, children: children.map(child => child.id) };
+    })(tree);
+};
+
+export const extractFilesData = (document: Document) => {
+    const collectionUrlPrefix = /\/c=[0-9a-zA-Z\-]*/;
+    return Array
+        .from(document.getElementsByTagName('D:response'))
+        .slice(1) // omit first element which is collection itself
+        .map(element => {
+            const name = getTagValue(element, 'D:displayname', '');
+            const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
+            const url = getTagValue(element, 'D:href', '');
+            const nameSuffix = `/${name || ''}`;
+            const directory = url
+                .replace(collectionUrlPrefix, '')
+                .replace(nameSuffix, '');
+
+            const data = {
+                url,
+                id: `${directory}/${name}`,
+                name,
+                path: directory,
+            };
+
+            return getTagValue(element, 'D:resourcetype', '')
+                ? createCollectionDirectory(data)
+                : createCollectionFile({ ...data, size });
+
+        });
+};
index 9feec699e52dfd07070105c75c251d96b3107541..d32434919e3d92701f5fd833422063ae295e5fb0 100644 (file)
@@ -2,43 +2,28 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import * as _ from "lodash";
 import { CommonResourceService } from "~/common/api/common-resource-service";
 import { CollectionResource } from "~/models/collection";
-import axios, { AxiosInstance } from "axios";
-import { KeepService } from "../keep-service/keep-service";
+import { AxiosInstance } from "axios";
+import { CollectionFile, CollectionDirectory } from "~/models/collection-file";
 import { WebDAV } from "~/common/webdav";
 import { AuthService } from "../auth-service/auth-service";
-import { mapTree, getNodeChildren, getNode, TreeNode } from "../../models/tree";
-import { getTagValue } from "~/common/xml";
-import { FilterBuilder } from "~/common/api/filter-builder";
-import { CollectionFile, createCollectionFile, CollectionFileType, CollectionDirectory, createCollectionDirectory } from '~/models/collection-file';
-import { parseKeepManifestText, stringifyKeepManifest } from "../collection-files-service/collection-manifest-parser";
-import { KeepManifestStream } from "~/models/keep-manifest";
-import { createCollectionFilesTree } from '~/models/collection-file';
+import { mapTreeValues } from "~/models/tree";
+import { parseFilesResponse } from "./collection-service-files-response";
+import { fileToArrayBuffer } from "~/common/file";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
 
 export class CollectionService extends CommonResourceService<CollectionResource> {
-    constructor(serverApi: AxiosInstance, private keepService: KeepService, private webdavClient: WebDAV, private authService: AuthService) {
+    constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService) {
         super(serverApi, "collections");
     }
 
     async files(uuid: string) {
         const request = await this.webdavClient.propfind(`/c=${uuid}`);
         if (request.responseXML != null) {
-            const files = this.extractFilesData(request.responseXML);
-            const tree = createCollectionFilesTree(files);
-            const sortedTree = mapTree(node => {
-                const children = getNodeChildren(node.id)(tree).map(id => getNode(id)(tree)) as TreeNode<CollectionDirectory | CollectionFile>[];
-                children.sort((a, b) =>
-                    a.value.type !== b.value.type
-                        ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
-                        : a.value.name.localeCompare(b.value.name)
-                );
-                return { ...node, children: children.map(child => child.id) };
-            })(tree);
-            return sortedTree;
+            const filesTree = parseFilesResponse(request.responseXML);
+            return mapTreeValues(this.extendFileURL)(filesTree);
         }
         return Promise.reject();
     }
@@ -47,120 +32,31 @@ export class CollectionService extends CommonResourceService<CollectionResource>
         return this.webdavClient.delete(`/c=${collectionUuid}${filePath}`);
     }
 
-    extractFilesData(document: Document) {
-        const collectionUrlPrefix = /\/c=[0-9a-zA-Z\-]*/;
-        return Array
-            .from(document.getElementsByTagName('D:response'))
-            .slice(1) // omit first element which is collection itself
-            .map(element => {
-                const name = getTagValue(element, 'D:displayname', '');
-                const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
-                const pathname = getTagValue(element, 'D:href', '');
-                const nameSuffix = `/${name || ''}`;
-                const directory = pathname
-                    .replace(collectionUrlPrefix, '')
-                    .replace(nameSuffix, '');
-                const href = this.webdavClient.defaults.baseURL + pathname + '?api_token=' + this.authService.getApiToken();
-
-                const data = {
-                    url: href,
-                    id: `${directory}/${name}`,
-                    name,
-                    path: directory,
-                };
-
-                return getTagValue(element, 'D:resourcetype', '')
-                    ? createCollectionDirectory(data)
-                    : createCollectionFile({ ...data, size });
-
-            });
-    }
-
-
-    private readFile(file: File): Promise<ArrayBuffer> {
-        return new Promise<ArrayBuffer>(resolve => {
-            const reader = new FileReader();
-            reader.onload = () => {
-                resolve(reader.result as ArrayBuffer);
-            };
-
-            reader.readAsArrayBuffer(file);
-        });
-    }
-
-    private uploadFile(keepServiceHost: string, file: File, fileId: number, onProgress?: UploadProgress): Promise<CollectionFile> {
-        return this.readFile(file).then(content => {
-            return axios.post<string>(keepServiceHost, content, {
-                headers: {
-                    'Content-Type': 'text/octet-stream'
-                },
-                onUploadProgress: (e: ProgressEvent) => {
-                    if (onProgress) {
-                        onProgress(fileId, e.loaded, e.total, Date.now());
-                    }
-                    console.log(`${e.loaded} / ${e.total}`);
-                }
-            }).then(data => createCollectionFile({
-                id: data.data,
-                name: file.name,
-                size: file.size
-            }));
-        });
+    async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress) {
+        // files have to be uploaded sequentially
+        for (let idx = 0; idx < files.length; idx++) {
+            await this.uploadFile(collectionUuid, files[idx], idx, onProgress);
+        }
     }
 
-    private async updateManifest(collectionUuid: string, files: CollectionFile[]): Promise<CollectionResource> {
-        const collection = await this.get(collectionUuid);
-        const manifest: KeepManifestStream[] = parseKeepManifestText(collection.manifestText);
-
-        files.forEach(f => {
-            let kms = manifest.find(stream => stream.name === f.path);
-            if (!kms) {
-                kms = {
-                    files: [],
-                    locators: [],
-                    name: f.path
-                };
-                manifest.push(kms);
+    private extendFileURL = (file: CollectionDirectory | CollectionFile) => ({
+        ...file,
+        url: this.webdavClient.defaults.baseURL + file.url + '?api_token=' + this.authService.getApiToken()
+    })
+
+    private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }) {
+        const fileURL = `/c=${collectionUuid}/${file.name}`;
+        const fileContent = await fileToArrayBuffer(file);
+        const requestConfig = {
+            headers: {
+                'Content-Type': 'text/octet-stream'
+            },
+            onUploadProgress: (e: ProgressEvent) => {
+                onProgress(fileId, e.loaded, e.total, Date.now());
             }
-            kms.locators.push(f.id);
-            const len = kms.files.length;
-            const nextPos = len > 0
-                ? parseInt(kms.files[len - 1].position, 10) + kms.files[len - 1].size
-                : 0;
-            kms.files.push({
-                name: f.name,
-                position: nextPos.toString(),
-                size: f.size
-            });
-        });
+        };
+        return this.webdavClient.put(fileURL, fileContent, requestConfig);
 
-        console.log(manifest);
-
-        const manifestText = stringifyKeepManifest(manifest);
-        const data = { ...collection, manifestText };
-        return this.update(collectionUuid, CommonResourceService.mapKeys(_.snakeCase)(data));
     }
 
-    uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress): Promise<CollectionResource | never> {
-        const filters = new FilterBuilder()
-            .addEqual("service_type", "proxy");
-
-        return this.keepService.list({ filters: filters.getFilters() }).then(data => {
-            if (data.items && data.items.length > 0) {
-                const serviceHost =
-                    (data.items[0].serviceSslFlag ? "https://" : "http://") +
-                    data.items[0].serviceHost +
-                    ":" + data.items[0].servicePort;
-
-                console.log("serviceHost", serviceHost);
-
-                const files$ = files.map((f, idx) => this.uploadFile(serviceHost, f, idx, onProgress));
-                return Promise.all(files$).then(values => {
-                    return this.updateManifest(collectionUuid, values);
-                });
-            } else {
-                return Promise.reject("Missing keep service host");
-            }
-        });
-    }
 }
index 61dd399206384c159d0ffe6982e704fe054a405a..1fa35fcaae2694f82dea51b9959dd54aaf8b7044 100644 (file)
@@ -30,7 +30,7 @@ export const createServices = (config: Config) => {
     const projectService = new ProjectService(apiClient);
     const linkService = new LinkService(apiClient);
     const favoriteService = new FavoriteService(linkService, groupsService);
-    const collectionService = new CollectionService(apiClient, keepService, webdavClient, authService);
+    const collectionService = new CollectionService(apiClient, webdavClient, authService);
     const tagService = new TagService(linkService);
     const collectionFilesService = new CollectionFilesService(collectionService);
 
index cedfbebef5aded305b3237e125ee80857c96361b..3d8308013e7dcda1b016017b81fa22b0b589abba 100644 (file)
@@ -9,7 +9,7 @@ import { ServiceRepository } from "~/services/services";
 import { RootState } from "../../store";
 import { snackbarActions } from "../../snackbar/snackbar-actions";
 import { dialogActions } from "../../dialog/dialog-actions";
-import { getNodeValue, getNodeDescendants } from "~/models/tree";
+import { getNodeValue, getNodeDescendants } from '~/models/tree';
 import { CollectionPanelDirectory, CollectionPanelFile } from "./collection-panel-files-state";
 
 export const collectionPanelFilesAction = unionize({
@@ -44,8 +44,7 @@ export const removeCollectionsSelectedFiles = () =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const tree = getState().collectionPanelFiles;
         const allFiles = getNodeDescendants('')(tree)
-            .map(id => getNodeValue(id)(tree))
-            .filter(file => file !== undefined) as Array<CollectionPanelDirectory | CollectionPanelFile>;
+            .map(node => node.value);
 
         const selectedDirectories = allFiles.filter(file => file.selected && file.type === CollectionFileType.DIRECTORY);
         const selectedFiles = allFiles.filter(file => file.selected && !selectedDirectories.some(dir => dir.id === file.path));
index 08b60308c42bb9d4f3dfdeb72eae23f4b45946de..dde622a7d7ca5c06ddd9287b653addd156459213 100644 (file)
@@ -4,7 +4,7 @@
 
 import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile, mergeCollectionPanelFilesStates } from "./collection-panel-files-state";
 import { CollectionPanelFilesAction, collectionPanelFilesAction } from "./collection-panel-files-actions";
-import { createTree, mapTreeValues, getNode, setNode, getNodeAncestors, getNodeDescendants, setNodeValueWith, mapTree } from "~/models/tree";
+import { createTree, mapTreeValues, getNode, setNode, getNodeAncestorsIds, getNodeDescendantsIds, setNodeValueWith, mapTree } from "~/models/tree";
 import { CollectionFileType } from "~/models/collection-file";
 
 export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => {
@@ -44,7 +44,7 @@ const toggleSelected = (id: string) => (tree: CollectionPanelFilesState) =>
 const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
     const node = getNode(id)(tree);
     if (node && node.value.type === CollectionFileType.DIRECTORY) {
-        return getNodeDescendants(id)(tree)
+        return getNodeDescendantsIds(id)(tree)
             .reduce((newTree, id) =>
                 setNodeValueWith(v => ({ ...v, selected: node.value.selected }))(id)(newTree), tree);
     }
@@ -52,7 +52,7 @@ const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
 };
 
 const toggleAncestors = (id: string) => (tree: CollectionPanelFilesState) => {
-    const ancestors = getNodeAncestors(id)(tree).reverse();
+    const ancestors = getNodeAncestorsIds(id)(tree).reverse();
     return ancestors.reduce((newTree, parent) => parent ? toggleParentNode(parent)(newTree) : newTree, tree);
 };
 
index 323ba8d8e86a844a9dca026772a9058593687aba..5243a610a28c381980b79e78a64a3c0ed8944e13 100644 (file)
@@ -8,7 +8,7 @@ import { Dispatch } from "redux";
 import { RootState } from "../../store";
 import { CollectionResource } from '~/models/collection';
 import { ServiceRepository } from "~/services/services";
-import { collectionUploaderActions } from "../uploader/collection-uploader-actions";
+import { uploadCollectionFiles } from '../uploader/collection-uploader-actions';
 import { reset } from "redux-form";
 
 export const collectionCreateActions = unionize({
@@ -17,30 +17,20 @@ export const collectionCreateActions = unionize({
     CREATE_COLLECTION: ofType<{}>(),
     CREATE_COLLECTION_SUCCESS: ofType<{}>(),
 }, {
-    tag: 'type',
-    value: 'payload'
-});
+        tag: 'type',
+        value: 'payload'
+    });
 
 export const createCollection = (collection: Partial<CollectionResource>, files: File[]) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { ownerUuid } = getState().collections.creator;
         const collectiontData = { ownerUuid, ...collection };
         dispatch(collectionCreateActions.CREATE_COLLECTION(collectiontData));
-        return services.collectionService
-            .create(collectiontData)
-            .then(collection => {
-                dispatch(collectionUploaderActions.START_UPLOAD());
-                services.collectionService.uploadFiles(collection.uuid, files,
-                    (fileId, loaded, total, currentTime) => {
-                        dispatch(collectionUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));
-                    })
-                .then(collection => {
-                    dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection));
-                    dispatch(reset('collectionCreateDialog'));
-                    dispatch(collectionUploaderActions.CLEAR_UPLOAD());
-                });
-                return collection;
-            });
+        const newCollection = await services.collectionService.create(collectiontData);
+        await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
+        dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection));
+        dispatch(reset('collectionCreateDialog'));
+        return newCollection;
     };
 
 export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
index f6b6bfa758b4c4ab5418a9f43912a4f2423b5ccf..0fa55d836cd9510d1840ffb450b9e57801a4a34b 100644 (file)
@@ -3,6 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0\r
 \r
 import { default as unionize, ofType, UnionOf } from "unionize";\r
+import { Dispatch } from 'redux';\r
+import { RootState } from '~/store/store';\r
+import { ServiceRepository } from '~/services/services';\r
+import { dialogActions } from '~/store/dialog/dialog-actions';\r
+import { loadCollectionFiles } from '../../collection-panel/collection-panel-files/collection-panel-files-actions';\r
+import { snackbarActions } from "~/store/snackbar/snackbar-actions";\r
 \r
 export interface UploadFile {\r
     id: number;\r
@@ -26,3 +32,35 @@ export const collectionUploaderActions = unionize({
 });\r
 \r
 export type CollectionUploaderAction = UnionOf<typeof collectionUploaderActions>;\r
+\r
+export const uploadCollectionFiles = (collectionUuid: string) =>\r
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {\r
+        dispatch(collectionUploaderActions.START_UPLOAD());\r
+        const files = getState().collections.uploader.map(file => file.file);\r
+        await services.collectionService.uploadFiles(collectionUuid, files, handleUploadProgress(dispatch));\r
+        dispatch(collectionUploaderActions.CLEAR_UPLOAD());\r
+    };\r
+\r
+\r
+export const uploadCurrentCollectionFiles = () =>\r
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {\r
+        const currentCollection = getState().collectionPanel.item;\r
+        if (currentCollection) {\r
+            await dispatch<any>(uploadCollectionFiles(currentCollection.uuid));\r
+            dispatch<any>(loadCollectionFiles(currentCollection.uuid));\r
+            dispatch(closeUploadCollectionFilesDialog());\r
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Data has been uploaded.', hideDuration: 2000 }));\r
+        }\r
+    };\r
+\r
+export const UPLOAD_COLLECTION_FILES_DIALOG = 'uploadCollectionFilesDialog';\r
+export const openUploadCollectionFilesDialog = () => (dispatch: Dispatch) => {\r
+    dispatch(collectionUploaderActions.CLEAR_UPLOAD());\r
+    dispatch<any>(dialogActions.OPEN_DIALOG({ id: UPLOAD_COLLECTION_FILES_DIALOG, data: {} }));\r
+};\r
+\r
+export const closeUploadCollectionFilesDialog = () => dialogActions.CLOSE_DIALOG({ id: UPLOAD_COLLECTION_FILES_DIALOG });\r
+\r
+const handleUploadProgress = (dispatch: Dispatch) => (fileId: number, loaded: number, total: number, currentTime: number) => {\r
+    dispatch(collectionUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));\r
+};
\ No newline at end of file
index 3248cb2efba7f06af804c0b4f6a169de02170a67..b092d5ac2a89c80ed29b0d525bacee3790e24821 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createTree, getNodeValue, getNodeChildren } from "~/models/tree";
+import { createTree, getNodeValue, getNodeChildrenIds } from "~/models/tree";
 import { TreePickerNode, createTreePickerNode } from "./tree-picker";
 import { treePickerReducer } from "./tree-picker-reducer";
 import { treePickerActions } from "./tree-picker-actions";
@@ -31,7 +31,7 @@ describe('TreePickerReducer', () => {
         const tree = createTree<TreePickerNode>();
         const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
         const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [subNode] }));
-        expect(getNodeChildren('')(newTree)).toEqual(['1.1']);
+        expect(getNodeChildrenIds('')(newTree)).toEqual(['1.1']);
     });
 
     it('LOAD_TREE_PICKER_NODE_SUCCESS', () => {
@@ -41,7 +41,7 @@ describe('TreePickerReducer', () => {
         const [newTree] = [tree]
             .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
             .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode] })));
-        expect(getNodeChildren('1')(newTree)).toEqual(['1.1']);
+        expect(getNodeChildrenIds('1')(newTree)).toEqual(['1.1']);
         expect(getNodeValue('1')(newTree)).toEqual({
             ...createTreePickerNode({ id: '1', value: '1' }),
             status: TreeItemStatus.LOADED
index ae9b53e33034355dcafd0f48013f9ad761c78656..3e99e10a709bca515d695c4ed734023bc229748a 100644 (file)
@@ -12,8 +12,9 @@ import { Dispatch } from "redux";
 import { collectionPanelFilesAction } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions";
 import { contextMenuActions } from "~/store/context-menu/context-menu-actions";
 import { ContextMenuKind } from "../context-menu/context-menu";
-import { Tree, getNodeChildren, getNode } from "~/models/tree";
+import { Tree, getNodeChildrenIds, getNode } from "~/models/tree";
 import { CollectionFileType } from "~/models/collection-file";
+import { openUploadCollectionFilesDialog } from '~/store/collections/uploader/collection-uploader-actions';
 
 const memoizedMapStateToProps = () => {
     let prevState: CollectionPanelFilesState;
@@ -22,7 +23,7 @@ const memoizedMapStateToProps = () => {
     return (state: RootState): Pick<CollectionPanelFilesProps, "items"> => {
         if (prevState !== state.collectionPanelFiles) {
             prevState = state.collectionPanelFiles;
-            prevTree = getNodeChildren('')(state.collectionPanelFiles)
+            prevTree = getNodeChildrenIds('')(state.collectionPanelFiles)
                 .map(collectionItemToTreeItem(state.collectionPanelFiles));
         }
         return {
@@ -32,7 +33,9 @@ const memoizedMapStateToProps = () => {
 };
 
 const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
-    onUploadDataClick: () => { return; },
+    onUploadDataClick: () => {
+        dispatch<any>(openUploadCollectionFilesDialog());
+    },
     onCollapseToggle: (id) => {
         dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
     },
@@ -77,7 +80,7 @@ const collectionItemToTreeItem = (tree: Tree<CollectionPanelDirectory | Collecti
                 type: node.value.type
             },
             id: node.id,
-            items: getNodeChildren(node.id)(tree)
+            items: getNodeChildrenIds(node.id)(tree)
                 .map(collectionItemToTreeItem(tree)),
             open: node.value.type === CollectionFileType.DIRECTORY ? !node.value.collapsed : false,
             selected: node.value.selected,
index 09a07443f26b9097ddcccfa9ded1b95d2dea5d85..ba9ccb916efd38d955badedae7e6a7418871d354 100644 (file)
@@ -6,7 +6,7 @@ import { connect } from "react-redux";
 import { Tree, TreeProps, TreeItem } from "~/components/tree/tree";
 import { RootState } from "~/store/store";
 import { TreePicker as TTreePicker, TreePickerNode, createTreePickerNode } from "~/store/tree-picker/tree-picker";
-import { getNodeValue, getNodeChildren } from "~/models/tree";
+import { getNodeValue, getNodeChildrenIds } from "~/models/tree";
 
 const memoizedMapStateToProps = () => {
     let prevState: TTreePicker;
@@ -15,7 +15,7 @@ const memoizedMapStateToProps = () => {
     return (state: RootState): Pick<TreeProps<any>, 'items'> => {
         if (prevState !== state.treePicker) {
             prevState = state.treePicker;
-            prevTree = getNodeChildren('')(state.treePicker)
+            prevTree = getNodeChildrenIds('')(state.treePicker)
                 .map(treePickerToTreeItems(state.treePicker));
         }
         return {
@@ -33,7 +33,7 @@ export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)
 const treePickerToTreeItems = (tree: TTreePicker) =>
     (id: string): TreeItem<any> => {
         const node: TreePickerNode = getNodeValue(id)(tree) || createTreePickerNode({ id: '', value: 'InvalidNode' });
-        const items = getNodeChildren(node.id)(tree)
+        const items = getNodeChildrenIds(node.id)(tree)
             .map(treePickerToTreeItems(tree));
         return {
             active: node.selected,
diff --git a/src/views-components/upload-collection-files-dialog/upload-collection-files-dialog.ts b/src/views-components/upload-collection-files-dialog/upload-collection-files-dialog.ts
new file mode 100644 (file)
index 0000000..1f3a50e
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Dispatch, compose } from "redux";
+import { withDialog } from '~/store/dialog/with-dialog';
+import { FilesUploadDialog } from '~/components/file-upload-dialog/file-upload-dialog';
+import { RootState } from '../../store/store';
+import { uploadCurrentCollectionFiles, UPLOAD_COLLECTION_FILES_DIALOG, collectionUploaderActions } from '~/store/collections/uploader/collection-uploader-actions';
+
+const mapStateToProps = (state: RootState) => ({
+    files: state.collections.uploader,
+    uploading: state.collections.uploader.some(file => file.loaded < file.total)
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onSubmit: () => {
+        dispatch<any>(uploadCurrentCollectionFiles());
+    },
+    onChange: (files: File[]) => {
+        dispatch(collectionUploaderActions.SET_UPLOAD_FILES(files));
+    }
+});
+
+export const UploadCollectionFilesDialog = compose(
+    withDialog(UPLOAD_COLLECTION_FILES_DIALOG),
+    connect(mapStateToProps, mapDispatchToProps)
+)(FilesUploadDialog);
\ No newline at end of file
index a2d61d5cd17a209351fa5bf3f228479df5087092..f23a9784ad4846ea647cb3350d0b149f0c3f2756 100644 (file)
@@ -49,6 +49,7 @@ import { MultipleFilesRemoveDialog } from '~/views-components/file-remove-dialog
 import { DialogCollectionCreateWithSelectedFile } from '~/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected';
 import { COLLECTION_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-collection-create';
 import { PROJECT_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-project-create';
+import { UploadCollectionFilesDialog } from '~/views-components/upload-collection-files-dialog/upload-collection-files-dialog';
 
 const DRAWER_WITDH = 240;
 const APP_BAR_HEIGHT = 100;
@@ -229,7 +230,7 @@ export const Workbench = withStyles(styles)(
                         <main className={classes.contentWrapper}>
                             <div className={classes.content}>
                                 <Switch>
-                                    <Route path='/' exact render={() => <Redirect to={`/projects/${this.props.authService.getUuid()}`}  />} />
+                                    <Route path='/' exact render={() => <Redirect to={`/projects/${this.props.authService.getUuid()}`} />} />
                                     <Route path="/projects/:id" render={this.renderProjectPanel} />
                                     <Route path="/favorites" render={this.renderFavoritePanel} />
                                     <Route path="/collections/:id" render={this.renderCollectionPanel} />
@@ -246,6 +247,7 @@ export const Workbench = withStyles(styles)(
                         <FileRemoveDialog />
                         <MultipleFilesRemoveDialog />
                         <UpdateCollectionDialog />
+                        <UploadCollectionFilesDialog />
                         <UpdateProjectDialog />
                         <CurrentTokenDialog
                             currentToken={this.props.currentToken}