refs #13856 Merge branch 'origin/13856-upload-component'
authorDaniel Kos <daniel.kos@contractors.roche.com>
Tue, 7 Aug 2018 11:34:15 +0000 (13:34 +0200)
committerDaniel Kos <daniel.kos@contractors.roche.com>
Tue, 7 Aug 2018 11:34:15 +0000 (13:34 +0200)
# Conflicts:
# src/services/services.ts
# src/views-components/dialog-create/dialog-collection-create.tsx
# src/views-components/dialog-update/dialog-collection-update.tsx

Arvados-DCO-1.1-Signed-off-by: Daniel Kos <daniel.kos@contractors.roche.com>

41 files changed:
package.json
src/common/api/common-resource-service.ts
src/common/api/filter-builder.ts
src/common/formatters.ts
src/components/collection-panel-files/collection-panel-files.tsx
src/components/file-upload/file-upload.tsx [new file with mode: 0644]
src/components/icon/icon.tsx
src/models/collection-file.ts
src/models/keep.ts [new file with mode: 0644]
src/services/collection-files-service/collection-files-service.ts
src/services/collection-files-service/collection-manifest-mapper.test.ts
src/services/collection-files-service/collection-manifest-mapper.ts
src/services/collection-files-service/collection-manifest-parser.test.ts
src/services/collection-files-service/collection-manifest-parser.ts
src/services/collection-service/collection-service.ts
src/services/favorite-service/favorite-service.test.ts
src/services/favorite-service/favorite-service.ts
src/services/keep-service/keep-service.ts [new file with mode: 0644]
src/services/project-service/project-service.test.ts
src/services/project-service/project-service.ts
src/services/services.ts
src/services/tag-service/tag-service.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.test.ts
src/store/collections/collections-reducer.ts
src/store/collections/creator/collection-creator-action.ts
src/store/collections/creator/collection-creator-reducer.test.ts
src/store/collections/creator/collection-creator-reducer.ts
src/store/collections/updater/collection-updater-action.ts [moved from src/store/collections/updator/collection-updator-action.ts with 72% similarity]
src/store/collections/updater/collection-updater-reducer.ts [new file with mode: 0644]
src/store/collections/updator/collection-updator-reducer.ts [deleted file]
src/store/collections/uploader/collection-uploader-actions.ts [new file with mode: 0644]
src/store/collections/uploader/collection-uploader-reducer.ts [new file with mode: 0644]
src/store/favorite-panel/favorite-panel-middleware-service.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/project/project-action.ts
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/create-collection-dialog/create-collection-dialog.tsx
src/views-components/dialog-create/dialog-collection-create.tsx
src/views-components/dialog-update/dialog-collection-update.tsx
src/views-components/update-collection-dialog/update-collection-dialog..tsx
yarn.lock

index f940e54ee3e4b138ee7000daf58eb41ca5e38d67..cd8a9c4d241356df33f60894c8ca23f2bf0fc1e4 100644 (file)
@@ -7,6 +7,7 @@
     "@material-ui/icons": "2.0.0",
     "@types/lodash": "4.14.116",
     "@types/react-copy-to-clipboard": "4.2.5",
+    "@types/react-dropzone": "4.2.1",
     "@types/redux-form": "7.4.4",
     "axios": "0.18.0",
     "classnames": "2.2.6",
@@ -14,6 +15,7 @@
     "react": "16.4.2",
     "react-copy-to-clipboard": "5.0.1",
     "react-dom": "16.4.2",
+    "react-dropzone": "4.2.13",
     "react-redux": "5.0.7",
     "react-router": "4.3.1",
     "react-router-dom": "4.3.1",
index 8ad8fe916ee48e7ef7cf373c105530a224b1c1a2..a836246b2e23fd50dde96eebe613f8dd14c7820f 100644 (file)
@@ -68,10 +68,10 @@ export class CommonResourceService<T extends Resource> {
         this.resourceType = '/' + resourceType + '/';
     }
 
-    create(data: Partial<T>) {
+    create(data?: Partial<T> | any) {
         return CommonResourceService.defaultResponse(
             this.serverApi
-                .post<T>(this.resourceType, CommonResourceService.mapKeys(_.snakeCase)(data)));
+                .post<T>(this.resourceType, data && CommonResourceService.mapKeys(_.snakeCase)(data)));
     }
 
     delete(uuid: string): Promise<T> {
@@ -104,7 +104,7 @@ export class CommonResourceService<T extends Resource> {
         return CommonResourceService.defaultResponse(
             this.serverApi
                 .put<T>(this.resourceType + uuid, data));
-        
+
     }
 }
 
index 28ad060f46b1e825c47ff0f0e04a6faf318c3603..e5aab3ac72825c37d0b503287bf07968eb93dc1d 100644 (file)
@@ -3,38 +3,37 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as _ from "lodash";
-import { Resource } from "../../models/resource";
 
-export class FilterBuilder<T extends Resource = Resource> {
-    static create<T extends Resource = Resource>(resourcePrefix = "") {
-        return new FilterBuilder<T>(resourcePrefix);
+export class FilterBuilder {
+    static create(resourcePrefix = "") {
+        return new FilterBuilder(resourcePrefix);
     }
 
     constructor(
         private resourcePrefix = "",
         private filters = "") { }
 
-    public addEqual(field: keyof T, value?: string) {
+    public addEqual(field: string, value?: string) {
         return this.addCondition(field, "=", value);
     }
 
-    public addLike(field: keyof T, value?: string) {
+    public addLike(field: string, value?: string) {
         return this.addCondition(field, "like", value, "%", "%");
     }
 
-    public addILike(field: keyof T, value?: string) {
+    public addILike(field: string, value?: string) {
         return this.addCondition(field, "ilike", value, "%", "%");
     }
 
-    public addIsA(field: keyof T, value?: string | string[]) {
+    public addIsA(field: string, value?: string | string[]) {
         return this.addCondition(field, "is_a", value);
     }
 
-    public addIn(field: keyof T, value?: string | string[]) {
+    public addIn(field: string, value?: string | string[]) {
         return this.addCondition(field, "in", value);
     }
 
-    public concat<O extends Resource>(filterBuilder: FilterBuilder<O>) {
+    public concat(filterBuilder: FilterBuilder) {
         return new FilterBuilder(this.resourcePrefix, this.filters + (this.filters && filterBuilder.filters ? "," : "") + filterBuilder.getFilters());
     }
 
@@ -46,7 +45,7 @@ export class FilterBuilder<T extends Resource = Resource> {
         return "[" + this.filters + "]";
     }
 
-    private addCondition(field: keyof T, cond: string, value?: string | string[], prefix: string = "", postfix: string = "") {
+    private addCondition(field: string, cond: string, value?: string | string[], prefix: string = "", postfix: string = "") {
         if (value) {
             value = typeof value === "string"
                 ? `"${prefix}${value}${postfix}"`
index 38ef0223e957f1b134d1dfed10c23341af145fbc..49e0690515e868a4b1145c79ccbd2ede9d813314 100644 (file)
@@ -19,6 +19,18 @@ export const formatFileSize = (size?: number) => {
     return "";
 };
 
+export const formatProgress = (loaded: number, total: number) => {
+    const progress = loaded >= 0 && total > 0 ? loaded * 100 / total : 0;
+    return `${progress.toFixed(2)}%`;
+};
+
+export function formatUploadSpeed(prevLoaded: number, loaded: number, prevTime: number, currentTime: number) {
+    const speed = loaded > prevLoaded && currentTime > prevTime
+        ? (loaded - prevLoaded) / (currentTime - prevTime)
+        : 0;
+    return `${(speed / 1000).toFixed(2)} KB/s`;
+}
+
 const FILE_SIZES = [
     {
         base: 1000000000000,
@@ -40,4 +52,4 @@ const FILE_SIZES = [
         base: 1,
         unit: "B"
     }
-];
\ No newline at end of file
+];
index 17bbe85e0d7f1748ee23ec692d7d501e13e7cafc..afe9e8517cc4027d5e90a4fca4d6007a560ea5d1 100644 (file)
@@ -8,6 +8,10 @@ import { FileTreeData } from '../file-tree/file-tree-data';
 import { FileTree } from '../file-tree/file-tree';
 import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, CardContent, 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>>;
@@ -36,14 +40,24 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     }
 });
 
-export const CollectionPanelFiles = withStyles(styles)(
-    ({ onItemMenuOpen, onOptionsMenuOpen, classes, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
+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 
-                        variant='raised' 
+                    <Button onClick={
+                        () => {
+                            dispatch<any>(renameFile());
+                        }}
+                        variant='raised'
                         color='primary'
                         size='small'>
                         Upload data
@@ -65,4 +79,5 @@ export const CollectionPanelFiles = withStyles(styles)(
                     </Typography>
             </Grid>
             <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
-        </Card>);
+        </Card>)
+);
diff --git a/src/components/file-upload/file-upload.tsx b/src/components/file-upload/file-upload.tsx
new file mode 100644 (file)
index 0000000..ec4fdc2
--- /dev/null
@@ -0,0 +1,84 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+    Grid,
+    StyleRulesCallback,
+    Table, TableBody, TableCell, TableHead, TableRow,
+    Typography,
+    WithStyles
+} from '@material-ui/core';
+import { withStyles } from '@material-ui/core';
+import Dropzone from 'react-dropzone';
+import { CloudUploadIcon } from "../icon/icon";
+import { formatFileSize, formatProgress, formatUploadSpeed } from "../../common/formatters";
+import { UploadFile } from "../../store/collections/uploader/collection-uploader-actions";
+
+type CssRules = "root" | "dropzone" | "container" | "uploadIcon";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    root: {
+    },
+    dropzone: {
+        width: "100%",
+        height: "200px",
+        overflow: "auto",
+        border: "1px dashed black",
+        borderRadius: "5px"
+    },
+    container: {
+        height: "100%"
+    },
+    uploadIcon: {
+        verticalAlign: "middle"
+    }
+});
+
+interface FileUploadProps {
+    files: UploadFile[];
+    disabled: boolean;
+    onDrop: (files: File[]) => void;
+}
+
+export const FileUpload = withStyles(styles)(
+    ({ classes, files, disabled, onDrop }: FileUploadProps & WithStyles<CssRules>) =>
+    <Grid container direction={"column"}>
+        <Typography variant={"subheading"}>
+            Upload data
+        </Typography>
+        <Dropzone className={classes.dropzone} onDrop={files => onDrop(files)} disabled={disabled}>
+            {files.length === 0 &&
+            <Grid container justify="center" alignItems="center" className={classes.container}>
+                <Grid item component={"span"}>
+                    <Typography variant={"subheading"}>
+                        <CloudUploadIcon className={classes.uploadIcon}/> Drag and drop data or click to browse
+                    </Typography>
+                </Grid>
+            </Grid>}
+            {files.length > 0 &&
+                <Table style={{width: "100%"}}>
+                    <TableHead>
+                        <TableRow>
+                            <TableCell>File name</TableCell>
+                            <TableCell>File size</TableCell>
+                            <TableCell>Upload speed</TableCell>
+                            <TableCell>Upload progress</TableCell>
+                        </TableRow>
+                    </TableHead>
+                    <TableBody>
+                    {files.map(f =>
+                        <TableRow key={f.id}>
+                            <TableCell>{f.file.name}</TableCell>
+                            <TableCell>{formatFileSize(f.file.size)}</TableCell>
+                            <TableCell>{formatUploadSpeed(f.prevLoaded, f.loaded, f.prevTime, f.currentTime)}</TableCell>
+                            <TableCell>{formatProgress(f.loaded, f.total)}</TableCell>
+                        </TableRow>
+                    )}
+                    </TableBody>
+                </Table>
+            }
+        </Dropzone>
+    </Grid>
+);
index 1dc8669ecfdc927f550459819fc1f9aae1561d16..0f0442a53ebd1b7be4b485694b45e86e3a99be7a 100644 (file)
@@ -7,6 +7,7 @@ import AccessTime from '@material-ui/icons/AccessTime';
 import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
 import BubbleChart from '@material-ui/icons/BubbleChart';
 import Cached from '@material-ui/icons/Cached';
+import CloudUpload from '@material-ui/icons/CloudUpload';
 import Code from '@material-ui/icons/Code';
 import ChevronLeft from '@material-ui/icons/ChevronLeft';
 import ChevronRight from '@material-ui/icons/ChevronRight';
@@ -44,6 +45,7 @@ export const CustomizeTableIcon: IconType = (props) => <Menu {...props} />;
 export const CopyIcon: IconType = (props) => <ContentCopy {...props} />;
 export const CollectionIcon: IconType = (props) => <LibraryBooks {...props} />;
 export const CloseIcon: IconType = (props) => <Close {...props} />;
+export const CloudUploadIcon: IconType = (props) => <CloudUpload {...props} />;
 export const DefaultIcon: IconType = (props) => <RateReview {...props} />;
 export const DetailsIcon: IconType = (props) => <Info {...props} />;
 export const DownloadIcon: IconType = (props) => <GetApp {...props} />;
index a1400806c4affb2ed2e5120b6b2f218faf9f4de8..a4b656c51397c80bbd0319b73bf7f18d6d12323b 100644 (file)
@@ -12,24 +12,28 @@ export enum CollectionFileType {
 }
 
 export interface CollectionDirectory {
-    parentId: string;
+    path: string;
     id: string;
     name: string;
     type: CollectionFileType.DIRECTORY;
 }
 
 export interface CollectionFile {
-    parentId: string;
+    path: string;
     id: string;
     name: string;
     size: number;
     type: CollectionFileType.FILE;
 }
 
+export interface CollectionUploadFile {
+    name: string;
+}
+
 export const createCollectionDirectory = (data: Partial<CollectionDirectory>): CollectionDirectory => ({
     id: '',
     name: '',
-    parentId: '',
+    path: '',
     type: CollectionFileType.DIRECTORY,
     ...data
 });
@@ -37,8 +41,8 @@ export const createCollectionDirectory = (data: Partial<CollectionDirectory>): C
 export const createCollectionFile = (data: Partial<CollectionFile>): CollectionFile => ({
     id: '',
     name: '',
-    parentId: '',
+    path: '',
     size: 0,
     type: CollectionFileType.FILE,
     ...data
-});
\ No newline at end of file
+});
diff --git a/src/models/keep.ts b/src/models/keep.ts
new file mode 100644 (file)
index 0000000..f6b5ef2
--- /dev/null
@@ -0,0 +1,12 @@
+// Copyright (C) The Arvados Authors. All rights reserved.\r
+//\r
+// SPDX-License-Identifier: AGPL-3.0\r
+\r
+import { Resource } from "./resource";\r
+\r
+export interface KeepResource extends Resource {\r
+    serviceHost: string;\r
+    servicePort: number;\r
+    serviceSslFlag: boolean;\r
+    serviceType: string;\r
+}\r
index 96c9e9905820e927efed0e856d8a27cee3e66703..5e6891c8ed417254f451cd2e640bdf80ca510222 100644 (file)
@@ -3,11 +3,13 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { CollectionService } from "../collection-service/collection-service";
-import { parseKeepManifestText } from "./collection-manifest-parser";
+import { parseKeepManifestText, stringifyKeepManifest } from "./collection-manifest-parser";
 import { mapManifestToCollectionFilesTree } from "./collection-manifest-mapper";
+import { CommonResourceService } from "../../common/api/common-resource-service";
+import * as _ from "lodash";
 
 export class CollectionFilesService {
-    
+
     constructor(private collectionService: CollectionService) { }
 
     getFiles(collectionUuid: string) {
@@ -22,4 +24,45 @@ export class CollectionFilesService {
             );
     }
 
-}
\ No newline at end of file
+    async renameFile(collectionUuid: string, file: { name: string, path: string }, newName: string) {
+        const collection = await this.collectionService.get(collectionUuid);
+        const manifest = parseKeepManifestText(collection.manifestText);
+        const updatedManifest = manifest.map(
+            stream => stream.name === file.path
+                ? {
+                    ...stream,
+                    files: stream.files.map(
+                        f => f.name === file.name
+                            ? { ...f, name: newName }
+                            : f
+                    )
+                }
+                : stream
+        );
+        const manifestText = stringifyKeepManifest(updatedManifest);
+        const data = { ...collection, manifestText };
+        return this.collectionService.update(collectionUuid, CommonResourceService.mapKeys(_.snakeCase)(data));
+    }
+
+    async deleteFile(collectionUuid: string, file: { name: string, path: string }) {
+        const collection = await this.collectionService.get(collectionUuid);
+        const manifest = parseKeepManifestText(collection.manifestText);
+        const updatedManifest = manifest.map(stream =>
+            stream.name === file.path
+                ? {
+                    ...stream,
+                    files: stream.files.filter(f => f.name !== file.name)
+                }
+                : stream
+        );
+        const manifestText = stringifyKeepManifest(updatedManifest);
+        return this.collectionService.update(collectionUuid, { manifestText });
+    }
+
+    renameTest() {
+        const u = this.renameFile('qr1hi-4zz18-n0sx074erl4p0ph', {
+            name: 'extracted2.txt.png',
+            path: ''
+        }, 'extracted-new.txt.png');
+    }
+}
index ad8f8727eba264747c30325af94be8cd3cad17c7..f08ea7bd1dc6a30e5a51895848efedf211492f9a 100644 (file)
@@ -3,32 +3,32 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { parseKeepManifestText } from "./collection-manifest-parser";
-import { mapManifestToFiles, mapManifestToDirectories } from "./collection-manifest-mapper";
+import { mapManifestToFiles, mapManifestToDirectories, mapManifestToCollectionFilesTree, mapCollectionFilesTreeToManifest } from "./collection-manifest-mapper";
 
 test('mapManifestToFiles', () => {
     const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n./c d41d8cd98f00b204e9800998ecf8427e+0 0:0:d`;
     const manifest = parseKeepManifestText(manifestText);
     const files = mapManifestToFiles(manifest);
     expect(files).toEqual([{
-        parentId: '',
+        path: '',
         id: '/a',
         name: 'a',
         size: 0,
         type: 'file'
     }, {
-        parentId: '',
+        path: '',
         id: '/b',
         name: 'b',
         size: 0,
         type: 'file'
     }, {
-        parentId: '',
+        path: '',
         id: '/output.txt',
         name: 'output.txt',
         size: 33,
         type: 'file'
     }, {
-        parentId: '/c',
+        path: '/c',
         id: '/c/d',
         name: 'd',
         size: 0,
@@ -41,19 +41,51 @@ test('mapManifestToDirectories', () => {
     const manifest = parseKeepManifestText(manifestText);
     const directories = mapManifestToDirectories(manifest);
     expect(directories).toEqual([{
-        parentId: "",
+        path: "",
         id: '/c',
         name: 'c',
         type: 'directory'
     }, {
-        parentId: '/c',
+        path: '/c',
         id: '/c/user',
         name: 'user',
         type: 'directory'
     }, {
-        parentId: '/c/user',
+        path: '/c/user',
         id: '/c/user/results',
         name: 'results',
         type: 'directory'
     },]);
+});
+
+test('mapCollectionFilesTreeToManifest', () => {
+    const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:22:test.txt\n./c/user/results 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n`;
+    const tree = mapManifestToCollectionFilesTree(parseKeepManifestText(manifestText));
+    const manifest = mapCollectionFilesTreeToManifest(tree);
+    expect(manifest).toEqual([{
+        name: '',
+        locators: [],
+        files: [{
+            name: 'test.txt',
+            position: '',
+            size: 22
+        },],
+    }, {
+        name: '/c/user/results',
+        locators: [],
+        files: [{
+            name: 'a',
+            position: '',
+            size: 0
+        }, {
+            name: 'b',
+            position: '',
+            size: 0
+        }, {
+            name: 'output.txt',
+            position: '',
+            size: 33
+        },],
+    },]);
+
 });
\ No newline at end of file
index c2a8ae8e138050789cfde7fb6af258699cb8e3dc..860308179c87927ade63dc67aff8386321225b0a 100644 (file)
@@ -2,10 +2,23 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { uniqBy } from 'lodash';
+import { uniqBy, groupBy } from 'lodash';
 import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "../../models/keep-manifest";
-import { TreeNode, setNode, createTree } from '../../models/tree';
-import { CollectionFilesTree, CollectionFile, CollectionDirectory, createCollectionDirectory, createCollectionFile } from '../../models/collection-file';
+import { TreeNode, setNode, createTree, getNodeDescendants, 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 files = values.filter(value => value && value.type === CollectionFileType.FILE) as CollectionFile[];
+    const fileGroups = groupBy(files, file => file.path);
+    return Object
+        .keys(fileGroups)
+        .map(dirName => ({
+            name: dirName,
+            locators: [],
+            files: fileGroups[dirName].map(mapCollectionFile)
+        }));
+};
 
 export const mapManifestToCollectionFilesTree = (manifest: KeepManifest): CollectionFilesTree =>
     manifestToCollectionFiles(manifest)
@@ -16,7 +29,7 @@ export const mapManifestToCollectionFilesTree = (manifest: KeepManifest): Collec
 export const mapCollectionFileToTreeNode = (file: CollectionFile): TreeNode<CollectionFile> => ({
     children: [],
     id: file.id,
-    parent: file.parentId,
+    parent: file.path,
     value: file
 });
 
@@ -47,7 +60,7 @@ const splitDirectory = (directory: CollectionDirectory): CollectionDirectory[] =
 
 const mapPathComponentToDirectory = (component: string, index: number, components: string[]): CollectionDirectory =>
     createCollectionDirectory({
-        parentId: index === 0 ? '' : joinPathComponents(components, index),
+        path: index === 0 ? '' : joinPathComponents(components, index),
         id: joinPathComponents(components, index + 1),
         name: component,
     });
@@ -55,9 +68,15 @@ const mapPathComponentToDirectory = (component: string, index: number, component
 const joinPathComponents = (components: string[], index: number) =>
     `/${components.slice(0, index).join('/')}`;
 
+const mapCollectionFile = (file: CollectionFile): KeepManifestStreamFile => ({
+    name: file.name,
+    position: '',
+    size: file.size
+});
+
 const mapStreamDirectory = (stream: KeepManifestStream): CollectionDirectory =>
     createCollectionDirectory({
-        parentId: '',
+        path: '',
         id: stream.name,
         name: stream.name,
     });
@@ -65,7 +84,7 @@ const mapStreamDirectory = (stream: KeepManifestStream): CollectionDirectory =>
 const mapStreamFile = (stream: KeepManifestStream) =>
     (file: KeepManifestStreamFile): CollectionFile =>
         createCollectionFile({
-            parentId: stream.name,
+            path: stream.name,
             id: `${stream.name}/${file.name}`,
             name: file.name,
             size: file.size,
index eddc9c622607f86c31a2f48f5422a278f31e0fc1..09525d860cdc38e552cc4d377297ac5d3e2b201b 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { parseKeepManifestText, parseKeepManifestStream } from "./collection-manifest-parser";
+import { parseKeepManifestText, parseKeepManifestStream, stringifyKeepManifest } from "./collection-manifest-parser";
 
 describe('parseKeepManifestText', () => {
     it('should parse text into streams', () => {
@@ -26,9 +26,15 @@ describe('parseKeepManifestStream', () => {
     });
     it('should parse stream files', () => {
         expect(stream.files).toEqual([
-            {name: 'a', position: '0', size: 0},
-            {name: 'b', position: '0', size: 0},
-            {name: 'output.txt', position: '0', size: 33},
+            { name: 'a', position: '0', size: 0 },
+            { name: 'b', position: '0', size: 0 },
+            { name: 'output.txt', position: '0', size: 33 },
         ]);
     });
+});
+
+test('stringifyKeepManifest', () => {
+    const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:22:test.txt\n./c/user/results 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n`;
+    const manifest = parseKeepManifestText(manifestText);
+    expect(stringifyKeepManifest(manifest)).toEqual(`. 930625b054ce894ac40596c3f5a0d947+33 0:22:test.txt\n./c/user/results 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n`);
 });
\ No newline at end of file
index df334d49fe846c09734b8b7310161ed86ebd3c66..b0fc55a49ba0ec48017c26383272a82914a72b10 100644 (file)
@@ -2,12 +2,12 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { KeepManifestStream, KeepManifestStreamFile } from "../../models/keep-manifest";
+import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "../../models/keep-manifest";
 
 /**
  * Documentation [http://doc.arvados.org/api/storage.html](http://doc.arvados.org/api/storage.html)
  */
-export const parseKeepManifestText = (text: string) =>
+export const parseKeepManifestText: (text: string) => KeepManifestStream[] = (text: string) =>
     text
         .split(/\n/)
         .filter(streamText => streamText.length > 0)
@@ -25,6 +25,12 @@ export const parseKeepManifestStream = (stream: string): KeepManifestStream => {
     };
 };
 
+export const stringifyKeepManifest = (manifest: KeepManifest) =>
+    manifest.map(stringifyKeepManifestStream).join('');
+
+export const stringifyKeepManifestStream = (stream: KeepManifestStream) =>
+    `.${stream.name} ${stream.locators.join(' ')} ${stream.files.map(stringifyFile).join(' ')}\n`;
+
 const FILE_LOCATOR_REGEXP = /^([0-9a-f]{32})\+([0-9]+)(\+[A-Z][-A-Za-z0-9@_]*)*$/;
 
 const FILE_REGEXP = /([0-9]+):([0-9]+):(.*)/;
@@ -43,4 +49,7 @@ const parseFile = (token: string): KeepManifestStreamFile => {
     const match = FILE_REGEXP.exec(token);
     const [position, size, name] = match!.slice(1);
     return { name, position, size: parseInt(size, 10) };
-};
\ No newline at end of file
+};
+
+const stringifyFile = (file: KeepManifestStreamFile) =>
+    `${file.position}:${file.size}:${file.name}`;
index bb63771d543db7c63f2a0af29a858381b318236b..4d750362e3972c339d95ca27f18aff7d3bed82e0 100644 (file)
 
 import { CommonResourceService } from "../../common/api/common-resource-service";
 import { CollectionResource } from "../../models/collection";
-import { AxiosInstance } from "axios";
+import axios, { AxiosInstance } from "axios";
+import { KeepService } from "../keep-service/keep-service";
+import { FilterBuilder } from "../../common/api/filter-builder";
+import { CollectionFile, createCollectionFile } from "../../models/collection-file";
+import { parseKeepManifestText, stringifyKeepManifest } from "../collection-files-service/collection-manifest-parser";
+import * as _ from "lodash";
+import { KeepManifestStream } from "../../models/keep-manifest";
+
+export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
 
 export class CollectionService extends CommonResourceService<CollectionResource> {
-    constructor(serverApi: AxiosInstance) {
+    constructor(serverApi: AxiosInstance, private keepService: KeepService) {
         super(serverApi, "collections");
     }
-}
\ No newline at end of file
+
+    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
+            }));
+        });
+    }
+
+    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);
+            }
+            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
+            });
+        });
+
+        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 = FilterBuilder.create()
+            .addEqual("service_type", "proxy");
+
+        return this.keepService.list({ filters }).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 e3a3bdfd4ea0ec4aef4f29f7105921112e2afb5f..3bc959df353e09b4a1bf3e0b3477390c4c74aac0 100644 (file)
@@ -39,7 +39,7 @@ describe("FavoriteService", () => {
     it("unmarks resource as favorite", async () => {
         const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ uuid: "linkUuid" }] }));
         const filters = FilterBuilder
-            .create<LinkResource>()
+            .create()
             .addEqual('tailUuid', "userUuid")
             .addEqual('headUuid', "resourceUuid")
             .addEqual('linkClass', LinkClass.STAR);
@@ -57,11 +57,11 @@ describe("FavoriteService", () => {
     it("lists favorite resources", async () => {
         const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ headUuid: "headUuid" }] }));
         const listFilters = FilterBuilder
-            .create<LinkResource>()
+            .create()
             .addEqual('tailUuid', "userUuid")
             .addEqual('linkClass', LinkClass.STAR);
         const contents = jest.fn().mockReturnValue(Promise.resolve({ items: [{ uuid: "resourceUuid" }] }));
-        const contentFilters = FilterBuilder.create<GroupContentsResource>().addIn('uuid', ["headUuid"]);
+        const contentFilters = FilterBuilder.create().addIn('uuid', ["headUuid"]);
         linkService.list = list;
         groupService.contents = contents;
         const favoriteService = new FavoriteService(linkService, groupService);
@@ -77,7 +77,7 @@ describe("FavoriteService", () => {
     it("checks if resources are present in favorites", async () => {
         const list = jest.fn().mockReturnValue(Promise.resolve({ items: [{ headUuid: "foo" }] }));
         const listFilters = FilterBuilder
-            .create<LinkResource>()
+            .create()
             .addIn("headUuid", ["foo", "oof"])
             .addEqual("tailUuid", "userUuid")
             .addEqual("linkClass", LinkClass.STAR);
index 8da6eec309458bc4d924727366366bed932d09ac..35dbbaf7ba1151ce5db0618c06142917095c73ad 100644 (file)
@@ -13,7 +13,7 @@ import { OrderBuilder } from "../../common/api/order-builder";
 export interface FavoriteListArguments {
     limit?: number;
     offset?: number;
-    filters?: FilterBuilder<LinkResource>;
+    filters?: FilterBuilder;
     order?: FavoriteOrderBuilder;
 }
 
@@ -37,7 +37,7 @@ export class FavoriteService {
         return this.linkService
             .list({
                 filters: FilterBuilder
-                    .create<LinkResource>()
+                    .create()
                     .addEqual('tailUuid', data.userUuid)
                     .addEqual('headUuid', data.resourceUuid)
                     .addEqual('linkClass', LinkClass.STAR)
@@ -48,7 +48,7 @@ export class FavoriteService {
 
     list(userUuid: string, { filters, limit, offset, order }: FavoriteListArguments = {}): Promise<ListResults<GroupContentsResource>> {
         const listFilter = FilterBuilder
-            .create<LinkResource>()
+            .create()
             .addEqual('tailUuid', userUuid)
             .addEqual('linkClass', LinkClass.STAR);
 
@@ -65,7 +65,7 @@ export class FavoriteService {
                     limit,
                     offset,
                     order: order ? order.getContentOrder() : OrderBuilder.create<GroupContentsResource>(),
-                    filters: FilterBuilder.create<GroupContentsResource>().addIn('uuid', uuids),
+                    filters: FilterBuilder.create().addIn('uuid', uuids),
                     recursive: true
                 });
             });
@@ -75,7 +75,7 @@ export class FavoriteService {
         return this.linkService
             .list({
                 filters: FilterBuilder
-                    .create<LinkResource>()
+                    .create()
                     .addIn("headUuid", resourceUuids)
                     .addEqual("tailUuid", userUuid)
                     .addEqual("linkClass", LinkClass.STAR)
diff --git a/src/services/keep-service/keep-service.ts b/src/services/keep-service/keep-service.ts
new file mode 100644 (file)
index 0000000..188e45a
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.\r
+//\r
+// SPDX-License-Identifier: AGPL-3.0\r
+\r
+import { CommonResourceService } from "../../common/api/common-resource-service";\r
+import { AxiosInstance } from "axios";\r
+import { KeepResource } from "../../models/keep";\r
+\r
+export class KeepService extends CommonResourceService<KeepResource> {\r
+    constructor(serverApi: AxiosInstance) {\r
+        super(serverApi, "keep_services");\r
+    }\r
+}\r
index f915c2dfb7939653fba3ac54a3533fd340d4bb4f..eb7ea7438dd54776778515b6d51e2c463f1e8339 100644 (file)
@@ -28,7 +28,7 @@ describe("CommonResourceService", () => {
         expect(axiosInstance.get).toHaveBeenCalledWith("/groups/", {
             params: {
                 filters: FilterBuilder
-                    .create<ProjectResource>()
+                    .create()
                     .addEqual("groupClass", "project")
                     .serialize()
             }
index 31a2ddd29cef813ea7f991d30db440c66934d506..13c60ad3140168b499243f50a36c73c3681e7367 100644 (file)
@@ -29,7 +29,7 @@ export class ProjectService extends GroupsService<ProjectResource> {
                 ? filters
                 : FilterBuilder.create())
             .concat(FilterBuilder
-                .create<ProjectResource>()
+                .create()
                 .addEqual("groupClass", GroupClass.PROJECT));
     }
 }
index 87c668f2ae4ca3ff3e9b36e0f6bb16d3c51a96e8..e77b5d3aa89b18a5fa8d4e1f3b2de2826f93f523 100644 (file)
@@ -12,11 +12,13 @@ import { CollectionService } from "./collection-service/collection-service";
 import { TagService } from "./tag-service/tag-service";
 import Axios from "axios";
 import { CollectionFilesService } from "./collection-files-service/collection-files-service";
+import { KeepService } from "./keep-service/keep-service";
 
 export interface ServiceRepository {
     apiClient: AxiosInstance;
 
     authService: AuthService;
+    keepService: KeepService;
     groupsService: GroupsService;
     projectService: ProjectService;
     linkService: LinkService;
@@ -31,17 +33,19 @@ export const createServices = (baseUrl: string): ServiceRepository => {
     apiClient.defaults.baseURL = `${baseUrl}/arvados/v1`;
 
     const authService = new AuthService(apiClient, baseUrl);
+    const keepService = new KeepService(apiClient);
     const groupsService = new GroupsService(apiClient);
     const projectService = new ProjectService(apiClient);
     const linkService = new LinkService(apiClient);
     const favoriteService = new FavoriteService(linkService, groupsService);
-    const collectionService = new CollectionService(apiClient);
+    const collectionService = new CollectionService(apiClient, keepService);
     const tagService = new TagService(linkService);
     const collectionFilesService = new CollectionFilesService(collectionService);
 
     return {
         apiClient,
         authService,
+        keepService,
         groupsService,
         projectService,
         linkService,
index c3019a650e313b7b6f44bb19290199f0234972ff..084603eb0833a9f64211598acc899e2d96a95288 100644 (file)
@@ -26,7 +26,7 @@ export class TagService {
 
     list(uuid: string) {
         const filters = FilterBuilder
-            .create<TagResource>()
+            .create()
             .addEqual("headUuid", uuid)
             .addEqual("tailUuid", TagTailType.COLLECTION)
             .addEqual("linkClass", LinkClass.TAG);
@@ -42,4 +42,4 @@ export class TagService {
             });
     }
 
-}
\ No newline at end of file
+}
index 1a6bb7d7964447873e389f8e51567bd1c4f83e42..94b71ffb249d729751b9b8e9486d9ce19ca75b8f 100644 (file)
@@ -11,21 +11,21 @@ import { CollectionPanelFile, CollectionPanelDirectory } from "./collection-pane
 describe('CollectionPanelFilesReducer', () => {
 
     const files: Array<CollectionFile | CollectionDirectory> = [
-        createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', parentId: '' }),
-        createCollectionDirectory({ id: 'Directory 2', name: 'Directory 2', parentId: 'Directory 1' }),
-        createCollectionDirectory({ id: 'Directory 3', name: 'Directory 3', parentId: '' }),
-        createCollectionDirectory({ id: 'Directory 4', name: 'Directory 4', parentId: 'Directory 3' }),
-        createCollectionFile({ id: 'file1.txt', name: 'file1.txt', parentId: 'Directory 2' }),
-        createCollectionFile({ id: 'file2.txt', name: 'file2.txt', parentId: 'Directory 2' }),
-        createCollectionFile({ id: 'file3.txt', name: 'file3.txt', parentId: 'Directory 3' }),
-        createCollectionFile({ id: 'file4.txt', name: 'file4.txt', parentId: 'Directory 3' }),
-        createCollectionFile({ id: 'file5.txt', name: 'file5.txt', parentId: 'Directory 4' }),
+        createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', path: '' }),
+        createCollectionDirectory({ id: 'Directory 2', name: 'Directory 2', path: 'Directory 1' }),
+        createCollectionDirectory({ id: 'Directory 3', name: 'Directory 3', path: '' }),
+        createCollectionDirectory({ id: 'Directory 4', name: 'Directory 4', path: 'Directory 3' }),
+        createCollectionFile({ id: 'file1.txt', name: 'file1.txt', path: 'Directory 2' }),
+        createCollectionFile({ id: 'file2.txt', name: 'file2.txt', path: 'Directory 2' }),
+        createCollectionFile({ id: 'file3.txt', name: 'file3.txt', path: 'Directory 3' }),
+        createCollectionFile({ id: 'file4.txt', name: 'file4.txt', path: 'Directory 3' }),
+        createCollectionFile({ id: 'file5.txt', name: 'file5.txt', path: 'Directory 4' }),
     ];
 
     const collectionFilesTree = files.reduce((tree, file) => setNode({
         children: [],
         id: file.id,
-        parent: file.parentId,
+        parent: file.path,
         value: file
     })(tree), createTree<CollectionFile | CollectionDirectory>());
 
@@ -35,7 +35,7 @@ describe('CollectionPanelFilesReducer', () => {
 
     it('SET_COLLECTION_FILES', () => {
         expect(getNodeValue('Directory 1')(collectionPanelFilesTree)).toEqual({
-            ...createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', parentId: '' }),
+            ...createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', path: '' }),
             collapsed: true,
             selected: false
         });
index 966cf29d515a90ed4d3d8d1f7bfe4742c0855a6e..b2ee4550662a99b4531bb6071d42ab80907f93e1 100644 (file)
@@ -3,15 +3,18 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { combineReducers } from 'redux';
-import * as creator from "./creator/collection-creator-reducer";
-import * as updator from "./updator/collection-updator-reducer";
+import { collectionCreatorReducer, CollectionCreatorState } from "./creator/collection-creator-reducer";
+import { collectionUpdaterReducer, CollectionUpdaterState } from "./updater/collection-updater-reducer";
+import { collectionUploaderReducer, CollectionUploaderState } from "./uploader/collection-uploader-reducer";
 
 export type CollectionsState = {
-    creator: creator.CollectionCreatorState;
-    updator: updator.CollectionUpdatorState;
+    creator: CollectionCreatorState;
+    updater: CollectionUpdaterState;
+    uploader: CollectionUploaderState
 };
 
 export const collectionsReducer = combineReducers({
-    creator: creator.collectionCreationReducer,
-    updator: updator.collectionCreationReducer
-});
\ No newline at end of file
+    creator: collectionCreatorReducer,
+    updater: collectionUpdaterReducer,
+    uploader: collectionUploaderReducer
+});
index 2f2b83850e5f226aeb9e3a857e1c5f937aa5e3e8..d0a66b4cc0f629cf60dc935beee2cf3c42aadfbd 100644 (file)
@@ -8,6 +8,8 @@ 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 { reset } from "redux-form";
 
 export const collectionCreateActions = unionize({
     OPEN_COLLECTION_CREATOR: ofType<{ ownerUuid: string }>(),
@@ -15,18 +17,30 @@ 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>) =>
+export const createCollection = (collection: Partial<CollectionResource>, files: File[]) =>
     (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(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection)));
+            .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;
+            });
     };
 
 export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
index fde58c433602aea0725ce09cb59f3006483bc4b2..5aa9dcfe5a86f6c6cc51f6bf987f1ad42bacb6d0 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { collectionCreationReducer } from "./collection-creator-reducer";
+import { collectionCreatorReducer } from "./collection-creator-reducer";
 import { collectionCreateActions } from "./collection-creator-action";
 
 describe('collection-reducer', () => {
@@ -11,7 +11,7 @@ describe('collection-reducer', () => {
         const initialState = { opened: false, ownerUuid: "" };
         const collection = { opened: true, ownerUuid: "" };
 
-        const state = collectionCreationReducer(initialState, collectionCreateActions.OPEN_COLLECTION_CREATOR(initialState));
+        const state = collectionCreatorReducer(initialState, collectionCreateActions.OPEN_COLLECTION_CREATOR(initialState));
         expect(state).toEqual(collection);
     });
 
@@ -19,7 +19,7 @@ describe('collection-reducer', () => {
         const initialState = { opened: true, ownerUuid: "" };
         const collection = { opened: false, ownerUuid: "" };
 
-        const state = collectionCreationReducer(initialState, collectionCreateActions.CLOSE_COLLECTION_CREATOR());
+        const state = collectionCreatorReducer(initialState, collectionCreateActions.CLOSE_COLLECTION_CREATOR());
         expect(state).toEqual(collection);
     });
 
@@ -27,7 +27,7 @@ describe('collection-reducer', () => {
         const initialState = { opened: true, ownerUuid: "test" };
         const collection = { opened: false, ownerUuid: "" };
 
-        const state = collectionCreationReducer(initialState, collectionCreateActions.CREATE_COLLECTION_SUCCESS());
+        const state = collectionCreatorReducer(initialState, collectionCreateActions.CREATE_COLLECTION_SUCCESS());
         expect(state).toEqual(collection);
     });
-});
\ No newline at end of file
+});
index 1a3cb0d2ea5f1082de69e0c796321df6295f6d07..72c999d0d198a588766e5abf9cbccaa7608446d2 100644 (file)
@@ -4,14 +4,12 @@
 
 import { collectionCreateActions, CollectionCreateAction } from './collection-creator-action';
 
-export type CollectionCreatorState = CollectionCreator;
-
-interface CollectionCreator {
+export interface CollectionCreatorState {
     opened: boolean;
     ownerUuid: string;
 }
 
-const updateCreator = (state: CollectionCreatorState, creator?: Partial<CollectionCreator>) => ({
+const updateCreator = (state: CollectionCreatorState, creator?: Partial<CollectionCreatorState>) => ({
     ...state,
     ...creator
 });
@@ -21,7 +19,7 @@ const initialState: CollectionCreatorState = {
     ownerUuid: ''
 };
 
-export const collectionCreationReducer = (state: CollectionCreatorState = initialState, action: CollectionCreateAction) => {
+export const collectionCreatorReducer = (state: CollectionCreatorState = initialState, action: CollectionCreateAction) => {
     return collectionCreateActions.match(action, {
         OPEN_COLLECTION_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true }),
         CLOSE_COLLECTION_CREATOR: () => updateCreator(state, { opened: false }),
similarity index 72%
rename from src/store/collections/updator/collection-updator-action.ts
rename to src/store/collections/updater/collection-updater-action.ts
index e12bfe548c01e69c874dc001c5533d398013a1b0..a7a354ad1c31940b733711c7dd27a92a6296ffca 100644 (file)
@@ -11,21 +11,21 @@ import { CollectionResource } from '../../../models/collection';
 import { initialize } from 'redux-form';
 import { collectionPanelActions } from "../../collection-panel/collection-panel-action";
 
-export const collectionUpdatorActions = unionize({
-    OPEN_COLLECTION_UPDATOR: ofType<{ uuid: string }>(),
-    CLOSE_COLLECTION_UPDATOR: ofType<{}>(),
+export const collectionUpdaterActions = unionize({
+    OPEN_COLLECTION_UPDATER: ofType<{ uuid: string }>(),
+    CLOSE_COLLECTION_UPDATER: ofType<{}>(),
     UPDATE_COLLECTION_SUCCESS: ofType<{}>(),
 }, {
-        tag: 'type',
-        value: 'payload'
-    });
+    tag: 'type',
+    value: 'payload'
+});
 
 
 export const COLLECTION_FORM_NAME = 'collectionEditDialog';
-    
-export const openUpdator = (uuid: string) =>
+
+export const openUpdater = (uuid: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch(collectionUpdatorActions.OPEN_COLLECTION_UPDATOR({ uuid }));
+        dispatch(collectionUpdaterActions.OPEN_COLLECTION_UPDATER({ uuid }));
         const item = getState().collectionPanel.item;
         if(item) {
             dispatch(initialize(COLLECTION_FORM_NAME, { name: item.name, description: item.description }));
@@ -34,14 +34,14 @@ export const openUpdator = (uuid: string) =>
 
 export const updateCollection = (collection: Partial<CollectionResource>) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { uuid } = getState().collections.updator;
+        const { uuid } = getState().collections.updater;
         return services.collectionService
             .update(uuid, collection)
             .then(collection => {
                     dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: collection as CollectionResource }));
-                    dispatch(collectionUpdatorActions.UPDATE_COLLECTION_SUCCESS());
+                    dispatch(collectionUpdaterActions.UPDATE_COLLECTION_SUCCESS());
                 }
             );
     };
 
-export type CollectionUpdatorAction = UnionOf<typeof collectionUpdatorActions>;
\ No newline at end of file
+export type CollectionUpdaterAction = UnionOf<typeof collectionUpdaterActions>;
diff --git a/src/store/collections/updater/collection-updater-reducer.ts b/src/store/collections/updater/collection-updater-reducer.ts
new file mode 100644 (file)
index 0000000..432aa27
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionUpdaterActions, CollectionUpdaterAction } from './collection-updater-action';
+
+export interface CollectionUpdaterState {
+    opened: boolean;
+    uuid: string;
+}
+
+const updateCollection = (state: CollectionUpdaterState, updater?: Partial<CollectionUpdaterState>) => ({
+    ...state,
+    ...updater
+});
+
+const initialState: CollectionUpdaterState = {
+    opened: false,
+    uuid: ''
+};
+
+export const collectionUpdaterReducer = (state: CollectionUpdaterState = initialState, action: CollectionUpdaterAction) => {
+    return collectionUpdaterActions.match(action, {
+        OPEN_COLLECTION_UPDATER: ({ uuid }) => updateCollection(state, { uuid, opened: true }),
+        CLOSE_COLLECTION_UPDATER: () => updateCollection(state, { opened: false }),
+        UPDATE_COLLECTION_SUCCESS: () => updateCollection(state, { opened: false, uuid: "" }),
+        default: () => state
+    });
+};
diff --git a/src/store/collections/updator/collection-updator-reducer.ts b/src/store/collections/updator/collection-updator-reducer.ts
deleted file mode 100644 (file)
index b9d0250..0000000
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { collectionUpdatorActions, CollectionUpdatorAction } from './collection-updator-action';
-
-export type CollectionUpdatorState = CollectionUpdator;
-
-interface CollectionUpdator {
-    opened: boolean;
-    uuid: string;
-}
-
-const updateCollection = (state: CollectionUpdatorState, updator?: Partial<CollectionUpdator>) => ({
-    ...state,
-    ...updator
-});
-
-const initialState: CollectionUpdatorState = {
-    opened: false,
-    uuid: ''
-};
-
-export const collectionCreationReducer = (state: CollectionUpdatorState = initialState, action: CollectionUpdatorAction) => {
-    return collectionUpdatorActions.match(action, {
-        OPEN_COLLECTION_UPDATOR: ({ uuid }) => updateCollection(state, { uuid, opened: true }),
-        CLOSE_COLLECTION_UPDATOR: () => updateCollection(state, { opened: false }),
-        UPDATE_COLLECTION_SUCCESS: () => updateCollection(state, { opened: false, uuid: "" }),
-        default: () => state
-    });
-};
diff --git a/src/store/collections/uploader/collection-uploader-actions.ts b/src/store/collections/uploader/collection-uploader-actions.ts
new file mode 100644 (file)
index 0000000..f6b6bfa
--- /dev/null
@@ -0,0 +1,28 @@
+// Copyright (C) The Arvados Authors. All rights reserved.\r
+//\r
+// SPDX-License-Identifier: AGPL-3.0\r
+\r
+import { default as unionize, ofType, UnionOf } from "unionize";\r
+\r
+export interface UploadFile {\r
+    id: number;\r
+    file: File;\r
+    prevLoaded: number;\r
+    loaded: number;\r
+    total: number;\r
+    startTime: number;\r
+    prevTime: number;\r
+    currentTime: number;\r
+}\r
+\r
+export const collectionUploaderActions = unionize({\r
+    SET_UPLOAD_FILES: ofType<File[]>(),\r
+    START_UPLOAD: ofType(),\r
+    SET_UPLOAD_PROGRESS: ofType<{ fileId: number, loaded: number, total: number, currentTime: number }>(),\r
+    CLEAR_UPLOAD: ofType()\r
+}, {\r
+    tag: 'type',\r
+    value: 'payload'\r
+});\r
+\r
+export type CollectionUploaderAction = UnionOf<typeof collectionUploaderActions>;\r
diff --git a/src/store/collections/uploader/collection-uploader-reducer.ts b/src/store/collections/uploader/collection-uploader-reducer.ts
new file mode 100644 (file)
index 0000000..79a8a62
--- /dev/null
@@ -0,0 +1,39 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionUploaderAction, collectionUploaderActions, UploadFile } from "./collection-uploader-actions";
+
+export type CollectionUploaderState = UploadFile[];
+
+const initialState: CollectionUploaderState = [];
+
+export const collectionUploaderReducer = (state: CollectionUploaderState = initialState, action: CollectionUploaderAction) => {
+    return collectionUploaderActions.match(action, {
+        SET_UPLOAD_FILES: files => files.map((f, idx) => ({
+            id: idx,
+            file: f,
+            prevLoaded: 0,
+            loaded: 0,
+            total: 0,
+            startTime: 0,
+            prevTime: 0,
+            currentTime: 0
+        })),
+        START_UPLOAD: () => {
+            const startTime = Date.now();
+            return state.map(f => ({...f, startTime, prevTime: startTime}));
+        },
+        SET_UPLOAD_PROGRESS: ({ fileId, loaded, total, currentTime }) =>
+            state.map(f => f.id === fileId ? {
+                ...f,
+                prevLoaded: f.loaded,
+                loaded,
+                total,
+                prevTime: f.currentTime,
+                currentTime
+            } : f),
+        CLEAR_UPLOAD: () => [],
+        default: () => state
+    });
+};
index 62d9ae2ab6330632f7a063f50989793eb3149a3c..be4b645cdea2424fdb522a6efa9fef6afc6d495e 100644 (file)
@@ -40,7 +40,7 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic
                             : order.addAsc("name")
                         : order,
                     filters: FilterBuilder
-                        .create<LinkResource>()
+                        .create()
                         .addIsA("headUuid", typeFilters.map(filter => filter.type))
                         .addILike("name", dataExplorer.searchValue)
                 })
index 8d3f06a59b3732637a9c2c73997ecbb8f8956151..fd893a3c98c669eb499db444da0ecd3fb35af776 100644 (file)
@@ -46,7 +46,7 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService
                             .create()
                             .addIsA("uuid", typeFilters.map(f => f.type)))
                         .concat(FilterBuilder
-                            .create<ProcessResource>(GroupContentsResourcePrefix.PROCESS)
+                            .create(GroupContentsResourcePrefix.PROCESS)
                             .addIn("state", statusFilters.map(f => f.type)))
                         .concat(getSearchFilter(dataExplorer.searchValue))
                 })
@@ -88,9 +88,9 @@ const getOrder = (attribute: "name" | "createdAt", direction: SortDirection) =>
 const getSearchFilter = (searchValue: string) =>
     searchValue
         ? [
-            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.COLLECTION),
-            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROCESS),
-            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROJECT)]
+            FilterBuilder.create(GroupContentsResourcePrefix.COLLECTION),
+            FilterBuilder.create(GroupContentsResourcePrefix.PROCESS),
+            FilterBuilder.create(GroupContentsResourcePrefix.PROJECT)]
             .reduce((acc, b) =>
                 acc.concat(b.addILike("name", searchValue)), FilterBuilder.create())
         : FilterBuilder.create();
index 2f9963bf9b3510f8b726475ba2fa35eee7864f58..5caf012148f5bc9506ed281078dbd592d01acf9a 100644 (file)
@@ -22,15 +22,15 @@ export const projectActions = unionize({
     TOGGLE_PROJECT_TREE_ITEM_ACTIVE: ofType<string>(),
     RESET_PROJECT_TREE_ACTIVITY: ofType<string>()
 }, {
-        tag: 'type',
-        value: 'payload'
-    });
+    tag: 'type',
+    value: 'payload'
+});
 
 export const getProjectList = (parentUuid: string = '') => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     dispatch(projectActions.PROJECTS_REQUEST(parentUuid));
     return services.projectService.list({
         filters: FilterBuilder
-            .create<ProjectResource>()
+            .create()
             .addEqual("ownerUuid", parentUuid)
     }).then(({ items: projects }) => {
         dispatch(projectActions.PROJECTS_SUCCESS({ projects, parentItemId: parentUuid }));
index 566f8f12e41c85cef26b7b945544b8c050811996..513cb5fc08647a8c419f38d5a8f6afc8822679a1 100644 (file)
@@ -6,7 +6,7 @@ import { ContextMenuActionSet } from "../context-menu-action-set";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "../../../store/favorites/favorites-actions";
 import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RemoveIcon } from "../../../components/icon/icon";
-import { openUpdator } from "../../../store/collections/updator/collection-updator-action";
+import { openUpdater } from "../../../store/collections/updater/collection-updater-action";
 import { favoritePanelActions } from "../../../store/favorite-panel/favorite-panel-action";
 
 export const collectionActionSet: ContextMenuActionSet = [[
@@ -14,7 +14,7 @@ export const collectionActionSet: ContextMenuActionSet = [[
         icon: RenameIcon,
         name: "Edit collection",
         execute: (dispatch, resource) => {
-            dispatch<any>(openUpdator(resource.uuid));
+            dispatch<any>(openUpdater(resource.uuid));
         }
     },
     {
@@ -35,7 +35,7 @@ export const collectionActionSet: ContextMenuActionSet = [[
         component: ToggleFavoriteAction,
         execute: (dispatch, resource) => {
             dispatch<any>(toggleFavorite(resource)).then(() => {
-                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());           
+                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
             });
         }
     },
index 0ba2b22add6e33179a04cba44b8e5e3228be6310..3f8cc07c248295354281eacc6ef9b5558c4ad40e 100644 (file)
@@ -9,9 +9,8 @@ import { SubmissionError } from "redux-form";
 import { RootState } from "../../store/store";
 import { DialogCollectionCreate } from "../dialog-create/dialog-collection-create";
 import { collectionCreateActions, createCollection } from "../../store/collections/creator/collection-creator-action";
-import { dataExplorerActions } from "../../store/data-explorer/data-explorer-action";
-import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
 import { snackbarActions } from "../../store/snackbar/snackbar-actions";
+import { UploadFile } from "../../store/collections/uploader/collection-uploader-actions";
 
 const mapStateToProps = (state: RootState) => ({
     open: state.collections.creator.opened
@@ -21,22 +20,21 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
     handleClose: () => {
         dispatch(collectionCreateActions.CLOSE_COLLECTION_CREATOR());
     },
-    onSubmit: (data: { name: string, description: string }) => {
-        return dispatch<any>(addCollection(data))
+    onSubmit: (data: { name: string, description: string }, files: UploadFile[]) => {
+        return dispatch<any>(addCollection(data, files.map(f => f.file)))
             .catch((e: any) => {
                 throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Collection with this name already exists." : "" });
             });
     }
 });
 
-const addCollection = (data: { name: string, description: string }) =>
+const addCollection = (data: { name: string, description: string }, files: File[]) =>
     (dispatch: Dispatch) => {
-        return dispatch<any>(createCollection(data)).then(() => {
+        return dispatch<any>(createCollection(data, files)).then(() => {
             dispatch(snackbarActions.OPEN_SNACKBAR({
                 message: "Collection has been successfully created.",
                 hideDuration: 2000
             }));
-            dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
         });
     };
 
index 874ce138c76e456ff798e3d63edb88a4d3aa79e2..804aae11f72f3e85f69eab01660b121d8a789b5f 100644 (file)
@@ -13,6 +13,10 @@ import DialogTitle from '@material-ui/core/DialogTitle';
 import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
 
 import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '../../validators/create-collection/create-collection-validator';
+import { FileUpload } from "../../components/file-upload/file-upload";
+import { connect, DispatchProp } from "react-redux";
+import { RootState } from "../../store/store";
+import { collectionUploaderActions, UploadFile } from "../../store/collections/uploader/collection-uploader-actions";
 
 type CssRules = "button" | "lastButton" | "formContainer" | "textField" | "createProgress" | "dialogActions";
 
@@ -40,14 +44,16 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
         marginBottom: theme.spacing.unit * 3
     }
 });
+
 interface DialogCollectionCreateProps {
     open: boolean;
     handleClose: () => void;
-    onSubmit: (data: { name: string, description: string }) => void;
+    onSubmit: (data: { name: string, description: string }, files: UploadFile[]) => void;
     handleSubmit: any;
     submitting: boolean;
     invalid: boolean;
     pristine: boolean;
+    files: UploadFile[];
 }
 
 interface TextFieldProps {
@@ -59,12 +65,17 @@ interface TextFieldProps {
 }
 
 export const DialogCollectionCreate = compose(
+    connect((state: RootState) => ({
+        files: state.collections.uploader
+    })),
     reduxForm({ form: 'collectionCreateDialog' }),
     withStyles(styles))(
-    class DialogCollectionCreate extends React.Component<DialogCollectionCreateProps & WithStyles<CssRules>> {
+    class DialogCollectionCreate extends React.Component<DialogCollectionCreateProps & DispatchProp & WithStyles<CssRules>> {
         render() {
-            const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine } = this.props;
-
+            const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine, files } = this.props;
+            const busy = submitting || files.reduce(
+                (prev, curr) => prev + (curr.loaded > 0 && curr.loaded < curr.total ? 1 : 0), 0
+            ) > 0;
             return (
                 <Dialog
                     open={open}
@@ -73,7 +84,7 @@ export const DialogCollectionCreate = compose(
                     maxWidth='sm'
                     disableBackdropClick={true}
                     disableEscapeKeyDown={true}>
-                    <form onSubmit={handleSubmit((data: any) => onSubmit(data))}>
+                    <form onSubmit={handleSubmit((data: any) => onSubmit(data, files))}>
                         <DialogTitle id="form-dialog-title">Create a collection</DialogTitle>
                         <DialogContent className={classes.formContainer}>
                             <Field name="name"
@@ -90,18 +101,22 @@ export const DialogCollectionCreate = compose(
                                     validate={COLLECTION_DESCRIPTION_VALIDATION}
                                     className={classes.textField}
                                     label="Description - optional"/>
+                            <FileUpload
+                                files={files}
+                                disabled={busy}
+                                onDrop={files => this.props.dispatch(collectionUploaderActions.SET_UPLOAD_FILES(files))}/>
                         </DialogContent>
                         <DialogActions className={classes.dialogActions}>
                             <Button onClick={handleClose} className={classes.button} color="primary"
-                                    disabled={submitting}>CANCEL</Button>
+                                    disabled={busy}>CANCEL</Button>
                             <Button type="submit"
                                     className={classes.lastButton}
                                     color="primary"
-                                    disabled={invalid || submitting || pristine}
+                                    disabled={invalid || busy || pristine}
                                     variant="contained">
                                 CREATE A COLLECTION
                             </Button>
-                            {submitting && <CircularProgress size={20} className={classes.createProgress}/>}
+                            {busy && <CircularProgress size={20} className={classes.createProgress}/>}
                         </DialogActions>
                     </form>
                 </Dialog>
index 08eee418bdc1f2bf016c0e575e0d751b3d96137c..febe1124b2f9f00ca1087086322eb5f1bfcb9ce8 100644 (file)
@@ -8,7 +8,7 @@ import { compose } from 'redux';
 import { ArvadosTheme } from '../../common/custom-theme';
 import { Dialog, DialogActions, DialogContent, DialogTitle, TextField, StyleRulesCallback, withStyles, WithStyles, Button, CircularProgress } from '../../../node_modules/@material-ui/core';
 import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '../../validators/create-collection/create-collection-validator';
-import { COLLECTION_FORM_NAME } from '../../store/collections/updator/collection-updator-action';
+import { COLLECTION_FORM_NAME } from '../../store/collections/updater/collection-updater-action';
 
 type CssRules = 'content' | 'actions' | 'textField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
 
@@ -128,4 +128,4 @@ export const DialogCollectionUpdate = compose(
                 />
             )
         }
-    );
\ No newline at end of file
+    );
index a91277e996d6fbdecac7617597f6f5c80df48dcc..1374ac41bad7feb8f827b1055d63e5c56b50ff80 100644 (file)
@@ -7,18 +7,18 @@ import { Dispatch } from "redux";
 import { SubmissionError } from "redux-form";
 import { RootState } from "../../store/store";
 import { snackbarActions } from "../../store/snackbar/snackbar-actions";
-import { collectionUpdatorActions, updateCollection } from "../../store/collections/updator/collection-updator-action";
+import { collectionUpdaterActions, updateCollection } from "../../store/collections/updater/collection-updater-action";
 import { dataExplorerActions } from "../../store/data-explorer/data-explorer-action";
 import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
 import { DialogCollectionUpdate } from "../dialog-update/dialog-collection-update";
 
 const mapStateToProps = (state: RootState) => ({
-    open: state.collections.updator.opened
+    open: state.collections.updater.opened
 });
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     handleClose: () => {
-        dispatch(collectionUpdatorActions.CLOSE_COLLECTION_UPDATOR());
+        dispatch(collectionUpdaterActions.CLOSE_COLLECTION_UPDATER());
     },
     onSubmit: (data: { name: string, description: string }) => {
         return dispatch<any>(editCollection(data))
@@ -41,4 +41,4 @@ const editCollection = (data: { name: string, description: string }) =>
         });
     };
 
-export const UpdateCollectionDialog = connect(mapStateToProps, mapDispatchToProps)(DialogCollectionUpdate);
\ No newline at end of file
+export const UpdateCollectionDialog = connect(mapStateToProps, mapDispatchToProps)(DialogCollectionUpdate);
index ee49986a76142a6638e0bc529c858221153c6e3b..700f4ad2b107eaa3d5ce8f1728a448d64f157343 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
     "@types/node" "*"
     "@types/react" "*"
 
+"@types/react-dropzone@4.2.1":
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/@types/react-dropzone/-/react-dropzone-4.2.1.tgz#4a973b63a8a227e263ff4eece053f643220f28fc"
+  dependencies:
+    "@types/react" "*"
+
 "@types/react-redux@6.0.6":
   version "6.0.6"
   resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-6.0.6.tgz#87f1d0a6ea901b93fcaf95fa57641ff64079d277"
@@ -465,6 +471,12 @@ atob@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.1.tgz#ae2d5a729477f289d60dd7f96a6314a22dd6c22a"
 
+attr-accept@^1.0.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52"
+  dependencies:
+    core-js "^2.5.0"
+
 autoprefixer@7.1.6:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-7.1.6.tgz#fb933039f74af74a83e71225ce78d9fd58ba84d7"
@@ -6126,6 +6138,13 @@ react-dom@16.4.2:
     object-assign "^4.1.1"
     prop-types "^15.6.0"
 
+react-dropzone@4.2.13:
+  version "4.2.13"
+  resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-4.2.13.tgz#31393c079b4e5ddcc176c095cebc3545d1248b9d"
+  dependencies:
+    attr-accept "^1.0.3"
+    prop-types "^15.5.7"
+
 react-error-overlay@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-4.0.0.tgz#d198408a85b4070937a98667f500c832f86bd5d4"