Merge branch 'master'
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 7 Aug 2018 12:20:35 +0000 (14:20 +0200)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 7 Aug 2018 12:20:35 +0000 (14:20 +0200)
Feature #13952

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

58 files changed:
package.json
src/common/api/common-resource-service.ts
src/common/api/filter-builder.ts
src/common/custom-theme.ts
src/common/formatters.ts
src/components/collection-panel-files/collection-panel-files.tsx
src/components/details-attribute/details-attribute.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/models/link.ts
src/models/object-types.ts [new file with mode: 0644]
src/models/tag.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 [new file with mode: 0644]
src/store/collection-panel/collection-panel-action.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.test.ts
src/store/collection-panel/collection-panel-reducer.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/data-explorer/data-explorer-middleware.ts
src/store/favorite-panel/favorite-panel-middleware-service.ts
src/store/navigation/navigation-action.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/project/project-action.ts
src/validators/create-collection/create-collection-validator.tsx [new file with mode: 0644]
src/validators/validators.tsx [new file with mode: 0644]
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/create-collection-dialog/create-collection-dialog.tsx
src/views-components/create-project-dialog/create-project-dialog.tsx
src/views-components/dialog-create/dialog-collection-create.tsx
src/views-components/dialog-update/dialog-collection-update.tsx
src/views-components/project-tree-picker/project-tree-picker.tsx
src/views-components/update-collection-dialog/update-collection-dialog..tsx
src/views/collection-panel/collection-panel.tsx
src/views/collection-panel/collection-tag-form.tsx [new file with mode: 0644]
src/views/project-panel/project-panel.tsx
src/views/workbench/workbench.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 ecad39134d1652e07d11b58d8da524fdad7cca29..098e0090cc90bc896cd795169ddb32e8582fb817 100644 (file)
@@ -34,6 +34,7 @@ const grey500 = grey["500"];
 const grey600 = grey["600"];
 const grey700 = grey["700"];
 const grey900 = grey["900"];
+const rocheBlue = '#06C';
 
 const themeOptions: ArvadosThemeOptions = {
     customs: {
@@ -122,7 +123,7 @@ const themeOptions: ArvadosThemeOptions = {
     },
     palette: {
         primary: {
-            main: '#06C',
+            main: rocheBlue,
             dark: blue.A100
         }
     }
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>)
+);
index 56da6c177cba95484171c55506b67411215021eb..f9a5b05abfdb4ab6f62c31be5263cd701bbc4479 100644 (file)
@@ -6,6 +6,7 @@ import * as React from 'react';
 import Typography from '@material-ui/core/Typography';
 import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
 import { ArvadosTheme } from '../../common/custom-theme';
+import * as classnames from "classnames";
 
 type CssRules = 'attribute' | 'label' | 'value' | 'link';
 
@@ -35,19 +36,21 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 interface DetailsAttributeDataProps {
     label: string;
+    classLabel?: string;
     value?: string | number;
+    classValue?: string;
     link?: string;
     children?: React.ReactNode;
 }
 
 type DetailsAttributeProps = DetailsAttributeDataProps & WithStyles<CssRules>;
 
-export const DetailsAttribute = withStyles(styles)(({ label, link, value, children, classes }: DetailsAttributeProps) =>
+export const DetailsAttribute = withStyles(styles)(({ label, link, value, children, classes, classLabel, classValue }: DetailsAttributeProps) =>
     <Typography component="div" className={classes.attribute}>
-        <Typography component="span" className={classes.label}>{label}</Typography>
+        <Typography component="span" className={classnames([classes.label, classLabel])}>{label}</Typography>
         { link
             ? <a href={link} className={classes.link} target='_blank'>{value}</a>
-            : <Typography component="span" className={classes.value}>
+            : <Typography component="span" className={classnames([classes.value, classValue])}>
                 {value}
                 {children}
             </Typography> }
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 868652809c6485b3a1d90e96e6d19d088f46f0df..da9dfd030b39217f7649d6d896f9cfd3ad639a5e 100644 (file)
@@ -13,5 +13,6 @@ export interface LinkResource extends Resource {
 }
 
 export enum LinkClass {
-    STAR = 'star'
+    STAR = 'star',
+    TAG = 'tag'
 }
\ No newline at end of file
diff --git a/src/models/object-types.ts b/src/models/object-types.ts
new file mode 100644 (file)
index 0000000..f0f17e0
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+const USER_UUID_REGEX = /.*tpzed.*/;
+const GROUP_UUID_REGEX = /.*-j7d0g-.*/;
+
+export enum ObjectTypes {
+    USER = "User",
+    GROUP = "Group",
+    UNKNOWN = "Unknown"
+}
+
+export const getUuidObjectType = (uuid: string) => {
+    switch (true) {
+        case USER_UUID_REGEX.test(uuid):
+            return ObjectTypes.USER;
+        case GROUP_UUID_REGEX.test(uuid):
+            return ObjectTypes.GROUP;
+        default:
+            return ObjectTypes.UNKNOWN;
+    }
+};
\ No newline at end of file
diff --git a/src/models/tag.ts b/src/models/tag.ts
new file mode 100644 (file)
index 0000000..9c229af
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkResource } from "./link";
+
+export interface TagResource extends LinkResource {
+    tailUuid: TagTailType;
+    properties: TagProperty;
+}
+
+export interface TagProperty {
+    key: string;
+    value: string;
+}
+
+export enum TagTailType {
+    COLLECTION = 'Collection',
+    JOB = 'Job'
+}
\ No newline at end of file
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 9e1adbf6e4e20d8a1f59637ae4a02f356905dc18..e77b5d3aa89b18a5fa8d4e1f3b2de2826f93f523 100644 (file)
@@ -9,17 +9,21 @@ import { LinkService } from "./link-service/link-service";
 import { FavoriteService } from "./favorite-service/favorite-service";
 import { AxiosInstance } from "axios";
 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;
     favoriteService: FavoriteService;
+    tagService: TagService;
     collectionService: CollectionService;
     collectionFilesService: CollectionFilesService;
 }
@@ -29,21 +33,25 @@ 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,
         favoriteService,
         collectionService,
+        tagService,
         collectionFilesService
     };
 };
diff --git a/src/services/tag-service/tag-service.ts b/src/services/tag-service/tag-service.ts
new file mode 100644 (file)
index 0000000..084603e
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { LinkService } from "../link-service/link-service";
+import { LinkClass } from "../../models/link";
+import { FilterBuilder } from "../../common/api/filter-builder";
+import { TagTailType, TagResource } from "../../models/tag";
+import { OrderBuilder } from "../../common/api/order-builder";
+
+export class TagService {
+
+    constructor(private linkService: LinkService) { }
+
+    create(uuid: string, data: { key: string; value: string } ) {
+        return this.linkService
+            .create({
+                headUuid: uuid,
+                tailUuid: TagTailType.COLLECTION,
+                linkClass: LinkClass.TAG,
+                name: '',
+                properties: data
+            })
+            .then(tag => tag as TagResource );
+    }
+
+    list(uuid: string) {
+        const filters = FilterBuilder
+            .create()
+            .addEqual("headUuid", uuid)
+            .addEqual("tailUuid", TagTailType.COLLECTION)
+            .addEqual("linkClass", LinkClass.TAG);
+
+        const order = OrderBuilder
+            .create<TagResource>()
+            .addAsc('createdAt');
+
+        return this.linkService
+            .list({ filters, order })
+            .then(results => {
+                return results.items.map((tag => tag as TagResource ));
+            });
+    }
+
+}
index ee95590c064446d3d614c2bc2b9640f293bf8aba..f2774f6fb384f2d72256e57a93bd9cda92d71493 100644 (file)
@@ -10,14 +10,24 @@ import { collectionPanelFilesAction } from "./collection-panel-files/collection-
 import { createTree } from "../../models/tree";
 import { RootState } from "../store";
 import { ServiceRepository } from "../../services/services";
+import { TagResource, TagProperty } from "../../models/tag";
+import { snackbarActions } from "../snackbar/snackbar-actions";
 
 export const collectionPanelActions = unionize({
     LOAD_COLLECTION: ofType<{ uuid: string, kind: ResourceKind }>(),
-    LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>()
+    LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>(),
+    LOAD_COLLECTION_TAGS: ofType<{ uuid: string }>(),
+    LOAD_COLLECTION_TAGS_SUCCESS: ofType<{ tags: TagResource[] }>(),
+    CREATE_COLLECTION_TAG: ofType<{ data: any }>(),
+    CREATE_COLLECTION_TAG_SUCCESS: ofType<{ tag: TagResource }>(),
+    DELETE_COLLECTION_TAG: ofType<{ uuid: string }>(),
+    DELETE_COLLECTION_TAG_SUCCESS: ofType<{ uuid: string }>()
 }, { tag: 'type', value: 'payload' });
 
 export type CollectionPanelAction = UnionOf<typeof collectionPanelActions>;
 
+export const COLLECTION_TAG_FORM_NAME = 'collectionTagForm';
+
 export const loadCollection = (uuid: string, kind: ResourceKind) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid, kind }));
@@ -33,5 +43,43 @@ export const loadCollection = (uuid: string, kind: ResourceKind) =>
             });
     };
 
+export const loadCollectionTags = (uuid: string) => 
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(collectionPanelActions.LOAD_COLLECTION_TAGS({ uuid }));
+        return services.tagService
+            .list(uuid)
+            .then(tags => {
+                dispatch(collectionPanelActions.LOAD_COLLECTION_TAGS_SUCCESS({ tags }));
+            });
+    };
+
 
+export const createCollectionTag = (data: TagProperty) => 
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(collectionPanelActions.CREATE_COLLECTION_TAG({ data }));
+        const item = getState().collectionPanel.item;
+        const uuid = item ? item.uuid : '';
+        return services.tagService
+            .create(uuid, data)
+            .then(tag => {
+                dispatch(collectionPanelActions.CREATE_COLLECTION_TAG_SUCCESS({ tag }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Tag has been successfully added.",
+                    hideDuration: 2000
+                }));
+            });
+    };
 
+export const deleteCollectionTag = (uuid: string) => 
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(collectionPanelActions.DELETE_COLLECTION_TAG({ uuid }));
+        return services.linkService
+            .delete(uuid)
+            .then(tag => {
+                dispatch(collectionPanelActions.DELETE_COLLECTION_TAG_SUCCESS({ uuid: tag.uuid }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Tag has been successfully deleted.",
+                    hideDuration: 2000
+                }));
+            });
+    };
\ 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 0dd233eaf0c4c444d4f7fc50f8eb218e3aa50d16..44b778980bafa68eade5afb259880b5a7e7bf1e2 100644 (file)
@@ -4,18 +4,23 @@
 
 import { collectionPanelActions, CollectionPanelAction } from "./collection-panel-action";
 import { CollectionResource } from "../../models/collection";
+import { TagResource } from "../../models/tag";
 
 export interface CollectionPanelState {
     item: CollectionResource | null;
+    tags: TagResource[];
 }
 
 const initialState = {
-    item: null
+    item: null,
+    tags: []
 };
 
 export const collectionPanelReducer = (state: CollectionPanelState = initialState, action: CollectionPanelAction) =>
     collectionPanelActions.match(action, {
         default: () => state,
-        LOAD_COLLECTION: () => state,
         LOAD_COLLECTION_SUCCESS: ({ item }) => ({ ...state, item }),
+        LOAD_COLLECTION_TAGS_SUCCESS: ({ tags }) => ({...state, tags }),
+        CREATE_COLLECTION_TAG_SUCCESS: ({ tag }) => ({...state, tags: [...state.tags, tag] }),
+        DELETE_COLLECTION_TAG_SUCCESS: ({ uuid }) => ({...state, tags: state.tags.filter(tag => tag.uuid !== uuid) })
     });
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 146867c3492702d62a9f518daf5449a7b2e3a679..3491862d49ce2f81d2156dbafeffdb42e0f62291 100644 (file)
@@ -8,15 +8,16 @@ import { dataExplorerActions, bindDataExplorerActions } from "./data-explorer-ac
 import { DataExplorerMiddlewareService } from "./data-explorer-middleware-service";
 
 export const dataExplorerMiddleware = (service: DataExplorerMiddlewareService): Middleware => api => next => {
-    const handleAction = <T extends { id: string }>(handler: (data: T) => void) =>
-        (data: T) => {
-            if (data.id === service.getId()) {
-                handler(data);
-            }
-        };
     const actions = bindDataExplorerActions(service.getId());
 
     return action => {
+        const handleAction = <T extends { id: string }>(handler: (data: T) => void) =>
+            (data: T) => {
+                next(action);
+                if (data.id === service.getId()) {
+                    handler(data);
+                }
+            };
         dataExplorerActions.match(action, {
             SET_PAGE: handleAction(() => {
                 api.dispatch(actions.REQUEST_ITEMS());
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 ffb0f7acf6f5fc3e69b1c0295936f6ee8c4eacc5..defddadaada04a4b313b46121976353a16ea9a34 100644 (file)
@@ -11,7 +11,12 @@ import { RootState } from "../store";
 import { Resource, ResourceKind } from "../../models/resource";
 import { projectPanelActions } from "../project-panel/project-panel-action";
 import { getCollectionUrl } from "../../models/collection";
-import { getProjectUrl } from "../../models/project";
+import { getProjectUrl, ProjectResource } from "../../models/project";
+import { ProjectService } from "../../services/project-service/project-service";
+import { ServiceRepository } from "../../services/services";
+import { sidePanelActions } from "../side-panel/side-panel-action";
+import { SidePanelIdentifiers } from "../side-panel/side-panel-reducer";
+import { getUuidObjectType, ObjectTypes } from "../../models/object-types";
 
 export const getResourceUrl = <T extends Resource>(resource: T): string => {
     switch (resource.kind) {
@@ -59,3 +64,32 @@ export const setProjectItem = (itemId: string, itemMode: ItemMode) =>
         }
     };
 
+export const restoreBranch = (itemId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const ancestors = await loadProjectAncestors(itemId, services.projectService);
+        const uuids = ancestors.map(ancestor => ancestor.uuid);
+        await loadBranch(uuids, dispatch);
+        dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_OPEN(SidePanelIdentifiers.PROJECTS));
+        dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_ACTIVE(SidePanelIdentifiers.PROJECTS));
+        uuids.forEach(uuid => {
+            dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(uuid));
+        });
+    };
+
+export const loadProjectAncestors = async (uuid: string, projectService: ProjectService): Promise<Array<ProjectResource>> => {
+    if (getUuidObjectType(uuid) === ObjectTypes.USER) {
+        return [];
+    } else {
+        const currentProject = await projectService.get(uuid);
+        const ancestors = await loadProjectAncestors(currentProject.ownerUuid, projectService);
+        return [...ancestors, currentProject];
+    }
+};
+
+const loadBranch = async (uuids: string[], dispatch: Dispatch): Promise<any> => {
+    const [uuid, ...rest] = uuids;
+    if (uuid) {
+        await dispatch<any>(getProjectList(uuid));
+        return loadBranch(rest, dispatch);
+    }
+};
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 }));
diff --git a/src/validators/create-collection/create-collection-validator.tsx b/src/validators/create-collection/create-collection-validator.tsx
new file mode 100644 (file)
index 0000000..2d8e1f5
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { require } from '../require';
+import { maxLength } from '../max-length';
+
+export const COLLECTION_NAME_VALIDATION = [require, maxLength(255)];
+export const COLLECTION_DESCRIPTION_VALIDATION = [maxLength(255)];
\ No newline at end of file
diff --git a/src/validators/validators.tsx b/src/validators/validators.tsx
new file mode 100644 (file)
index 0000000..fdeb8fa
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { require } from './require';
+import { maxLength } from './max-length';
+
+export const TAG_KEY_VALIDATION = [require, maxLength(255)];
+export const TAG_VALUE_VALIDATION = [require, maxLength(255)];
\ No newline at end of file
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 1a521890d71be9ac684cb57335dee9d935e29526..aa0dc7bc89a49ea3584806556d268435a05e737d 100644 (file)
@@ -21,7 +21,7 @@ const addProject = (data: { name: string, description: string }) =>
         const { ownerUuid } = getState().projects.creator;
         return dispatch<any>(createProject(data)).then(() => {
             dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: "Created a new project",
+                message: "Project has been successfully created.",
                 hideDuration: 2000
             }));
             dispatch(projectPanelActions.REQUEST_ITEMS());
index c599b22d7828ba6e53a6ec038f3f185f76d06e38..0686904ab3403aba2a60ae1f5599a129acbeb77d 100644 (file)
@@ -9,7 +9,11 @@ import { TextField } from '../../components/text-field/text-field';
 import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core/';
 import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
 
-import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '../../validators/create-project/create-project-validator';
+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";
 
@@ -37,23 +41,30 @@ 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[];
 }
 
 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}
@@ -62,7 +73,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"
@@ -77,18 +88,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 80a82b27fd97bb27a65dd954603e1cb7c2a965d8..febe1124b2f9f00ca1087086322eb5f1bfcb9ce8 100644 (file)
@@ -7,8 +7,8 @@ import { reduxForm, Field } from 'redux-form';
 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-project/create-project-validator';
-import { COLLECTION_FORM_NAME } from '../../store/collections/updator/collection-updator-action';
+import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '../../validators/create-collection/create-collection-validator';
+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 6effd86d56b6eb9d224dfd8cfcbc6a7a25808bf1..1c343a2dde1d8a4acf364d95f6f0a451508f5fee 100644 (file)
@@ -49,7 +49,7 @@ export const loadProjectTreePickerProjects = (id: string) =>
         const ownerUuid = id.length === 0 ? services.authService.getUuid() || '' : id;
 
         const filters = FilterBuilder
-            .create<ProjectResource>()
+            .create()
             .addEqual('ownerUuid', ownerUuid);
 
         const { items } = await services.projectService.list({ filters });
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 43423a62f2c66a56d759cf304ad122e98d2adcf8..489d28473b0803fe3887380c850186f05c707680 100644 (file)
@@ -7,7 +7,7 @@ import {
     StyleRulesCallback, WithStyles, withStyles, Card,
     CardHeader, IconButton, CardContent, Grid, Chip
 } from '@material-ui/core';
-import { connect } from 'react-redux';
+import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
 import { ArvadosTheme } from '../../common/custom-theme';
 import { RootState } from '../../store/store';
@@ -16,29 +16,38 @@ import { DetailsAttribute } from '../../components/details-attribute/details-att
 import { CollectionResource } from '../../models/collection';
 import { CollectionPanelFiles } from '../../views-components/collection-panel-files/collection-panel-files';
 import * as CopyToClipboard from 'react-copy-to-clipboard';
+import { TagResource } from '../../models/tag';
+import { CollectionTagForm } from './collection-tag-form';
+import { deleteCollectionTag } from '../../store/collection-panel/collection-panel-action';
 
-type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon';
+type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'value';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
-        marginBottom: '20px'
+        marginBottom: theme.spacing.unit * 2
     },
     iconHeader: {
         fontSize: '1.875rem',
         color: theme.customs.colors.yellow700
     },
     tag: {
-        marginRight: theme.spacing.unit
+        marginRight: theme.spacing.unit,
+        marginBottom: theme.spacing.unit
     },
     copyIcon: {
         marginLeft: theme.spacing.unit,
         fontSize: '1.125rem',
+        color: theme.palette.grey["500"],
         cursor: 'pointer'
+    },
+    value: {
+        textTransform: 'none'
     }
 });
 
 interface CollectionPanelDataProps {
     item: CollectionResource;
+    tags: TagResource[];
 }
 
 interface CollectionPanelActionProps {
@@ -46,16 +55,19 @@ interface CollectionPanelActionProps {
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: CollectionResource) => void;
 }
 
-type CollectionPanelProps = CollectionPanelDataProps & CollectionPanelActionProps
+type CollectionPanelProps = CollectionPanelDataProps & CollectionPanelActionProps & DispatchProp
                             & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
 
 export const CollectionPanel = withStyles(styles)(
-    connect((state: RootState) => ({ item: state.collectionPanel.item! }))(
-        class extends React.Component<CollectionPanelProps> {
+    connect((state: RootState) => ({ 
+        item: state.collectionPanel.item, 
+        tags: state.collectionPanel.tags 
+    }))(
+        class extends React.Component<CollectionPanelProps> { 
 
             render() {
-                const { classes, item, onContextMenu } = this.props;
+                const { classes, item, tags, onContextMenu } = this.props;
                 return <div>
                         <Card className={classes.card}>
                             <CardHeader
@@ -72,13 +84,16 @@ export const CollectionPanel = withStyles(styles)(
                             <CardContent>
                                 <Grid container direction="column">
                                     <Grid item xs={6}>
-                                    <DetailsAttribute label='Collection UUID' value={item && item.uuid}>
+                                    <DetailsAttribute classValue={classes.value} 
+                                            label='Collection UUID' 
+                                            value={item && item.uuid}>
                                         <CopyToClipboard text={item && item.uuid}>
                                             <CopyIcon className={classes.copyIcon} />
                                         </CopyToClipboard>
                                     </DetailsAttribute>
+                                    <DetailsAttribute label='Number of files' value='14' />
                                     <DetailsAttribute label='Content size' value='54 MB' />
-                                    <DetailsAttribute label='Owner' value={item && item.ownerUuid} />
+                                    <DetailsAttribute classValue={classes.value} label='Owner' value={item && item.ownerUuid} />
                                     </Grid>
                                 </Grid>
                             </CardContent>
@@ -88,10 +103,15 @@ export const CollectionPanel = withStyles(styles)(
                             <CardHeader title="Tags" />
                             <CardContent>
                                 <Grid container direction="column">
-                                    <Grid item xs={4}>
-                                        <Chip label="Tag 1" className={classes.tag}/>
-                                        <Chip label="Tag 2" className={classes.tag}/>
-                                        <Chip label="Tag 3" className={classes.tag}/>
+                                    <Grid item xs={12}><CollectionTagForm /></Grid>
+                                    <Grid item xs={12}>
+                                        {
+                                            tags.map(tag => {
+                                                return <Chip key={tag.etag} className={classes.tag}
+                                                    onDelete={this.handleDelete(tag.uuid)}
+                                                    label={renderTagLabel(tag)}  />;
+                                            })
+                                        }
                                     </Grid>
                                 </Grid>
                             </CardContent>
@@ -102,6 +122,10 @@ export const CollectionPanel = withStyles(styles)(
                     </div>;
             }
 
+            handleDelete = (uuid: string) => () => {
+                this.props.dispatch<any>(deleteCollectionTag(uuid));
+            }
+
             componentWillReceiveProps({ match, item, onItemRouteChange }: CollectionPanelProps) {
                 if (!item || match.params.id !== item.uuid) {
                     onItemRouteChange(match.params.id);
@@ -111,3 +135,8 @@ export const CollectionPanel = withStyles(styles)(
         }
     )
 );
+
+const renderTagLabel = (tag: TagResource) => {
+    const { properties } = tag;
+    return `${properties.key}: ${properties.value}`;
+};
diff --git a/src/views/collection-panel/collection-tag-form.tsx b/src/views/collection-panel/collection-tag-form.tsx
new file mode 100644 (file)
index 0000000..89cf880
--- /dev/null
@@ -0,0 +1,118 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { reduxForm, Field, reset } from 'redux-form';
+import { compose, Dispatch } from 'redux';
+import { ArvadosTheme } from '../../common/custom-theme';
+import { StyleRulesCallback, withStyles, WithStyles, TextField, Button, CircularProgress } from '@material-ui/core';
+import { TagProperty } from '../../models/tag';
+import { createCollectionTag, COLLECTION_TAG_FORM_NAME } from '../../store/collection-panel/collection-panel-action';
+import { TAG_VALUE_VALIDATION, TAG_KEY_VALIDATION } from '../../validators/validators';
+
+type CssRules = 'form' | 'textField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    form: {
+        marginBottom: theme.spacing.unit * 4 
+    },
+    textField: {
+        marginRight: theme.spacing.unit
+    },
+    buttonWrapper: {
+        position: 'relative',
+        display: 'inline-block'
+    },
+    saveButton: {
+        boxShadow: 'none'
+    },
+    circularProgress: {
+        position: 'absolute',
+        top: 0,
+        bottom: 0,
+        left: 0,
+        right: 0,
+        margin: 'auto'
+    }
+});
+
+interface CollectionTagFormDataProps {
+    submitting: boolean;
+    invalid: boolean;
+    pristine: boolean;
+}
+
+interface CollectionTagFormActionProps {
+    handleSubmit: any;
+}
+
+interface TextFieldProps {
+    label: string;
+    floatinglabeltext: string;
+    className?: string;
+    input?: string;
+    meta?: any;
+}
+
+type CollectionTagFormProps = CollectionTagFormDataProps & CollectionTagFormActionProps & WithStyles<CssRules>;
+
+export const CollectionTagForm = compose(
+    reduxForm({ 
+        form: COLLECTION_TAG_FORM_NAME, 
+        onSubmit: (data: TagProperty, dispatch: Dispatch) => {
+            dispatch<any>(createCollectionTag(data));
+            dispatch(reset(COLLECTION_TAG_FORM_NAME));
+        } 
+    }),
+    withStyles(styles))(
+        
+    class CollectionTagForm extends React.Component<CollectionTagFormProps> {
+
+            render() {
+                const { classes, submitting, pristine, invalid, handleSubmit } = this.props;
+                return (
+                    <form className={classes.form} onSubmit={handleSubmit}>
+                        <Field name="key"
+                            disabled={submitting}
+                            component={this.renderTextField}
+                            floatinglabeltext="Key"
+                            validate={TAG_KEY_VALIDATION}
+                            className={classes.textField}
+                            label="Key" />
+                        <Field name="value"
+                            disabled={submitting}
+                            component={this.renderTextField}
+                            floatinglabeltext="Value"
+                            validate={TAG_VALUE_VALIDATION}
+                            className={classes.textField}
+                            label="Value" />
+                        <div className={classes.buttonWrapper}>
+                            <Button type="submit" className={classes.saveButton}
+                                color="primary"
+                                size='small'
+                                disabled={invalid || submitting || pristine}
+                                variant="contained">
+                                ADD
+                            </Button>
+                            {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
+                        </div>
+                    </form>
+                );
+            }
+
+            renderTextField = ({ input, label, meta: { touched, error }, ...custom }: TextFieldProps) => (
+                <TextField
+                    helperText={touched && error}
+                    label={label}
+                    className={this.props.classes.textField}
+                    error={touched && !!error}
+                    autoComplete='off'
+                    {...input}
+                    {...custom}
+                />
+            )
+
+        }
+
+    );
\ No newline at end of file
index 5c3fb2b0421837c43cf2ae721de6306c50358bd3..0cd75ca3f8f5ae5e14fd5b853d48a624bd45c0cc 100644 (file)
@@ -17,6 +17,7 @@ import { ResourceKind } from '../../models/resource';
 import { resourceLabel } from '../../common/labels';
 import { ArvadosTheme } from '../../common/custom-theme';
 import { renderName, renderStatus, renderType, renderOwner, renderFileSize, renderDate } from '../../views-components/data-explorer/renderers';
+import { restoreBranch } from '../../store/navigation/navigation-action';
 
 type CssRules = "toolbar" | "button";
 
@@ -176,11 +177,18 @@ export const ProjectPanel = withStyles(styles)(
             handleNewCollectionClick = () => {
                 this.props.onCollectionCreationDialogOpen(this.props.currentItemId);
             }
+
             componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: ProjectPanelProps) {
                 if (match.params.id !== currentItemId) {
                     onItemRouteChange(match.params.id);
                 }
             }
+
+            componentDidMount() {
+                if (this.props.match.params.id && this.props.currentItemId === '') {
+                    this.props.dispatch<any>(restoreBranch(this.props.match.params.id));
+                }
+            }
         }
     )
 );
index 147c0a8ae24cc6cddc6d59d6a08f96b90bee35f8..a8552eef824053bf7c197bc85d18758045a77bdd 100644 (file)
@@ -38,7 +38,7 @@ import { Snackbar } from '../../views-components/snackbar/snackbar';
 import { favoritePanelActions } from '../../store/favorite-panel/favorite-panel-action';
 import { CreateCollectionDialog } from '../../views-components/create-collection-dialog/create-collection-dialog';
 import { CollectionPanel } from '../collection-panel/collection-panel';
-import { loadCollection } from '../../store/collection-panel/collection-panel-action';
+import { loadCollection, loadCollectionTags } from '../../store/collection-panel/collection-panel-action';
 import { getCollectionUrl } from '../../models/collection';
 import { UpdateCollectionDialog } from '../../views-components/update-collection-dialog/update-collection-dialog.';
 import { AuthService } from "../../services/auth-service/auth-service";
@@ -248,8 +248,11 @@ export const Workbench = withStyles(styles)(
                 );
             }
 
-            renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel
-                onItemRouteChange={(collectionId) => this.props.dispatch<any>(loadCollection(collectionId, ResourceKind.COLLECTION))}
+            renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel 
+                onItemRouteChange={(collectionId) => {
+                    this.props.dispatch<any>(loadCollection(collectionId, ResourceKind.COLLECTION));
+                    this.props.dispatch<any>(loadCollectionTags(collectionId));
+                }}
                 onContextMenu={(event, item) => {
                     this.openContextMenu(event, {
                         uuid: item.uuid,
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"