Merge branch 'master' into 14078-obtain-configuration-data-from-discovery-endpoint
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 21 Aug 2018 12:25:29 +0000 (14:25 +0200)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 21 Aug 2018 12:25:29 +0000 (14:25 +0200)
refs #14078

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

58 files changed:
src/common/api/common-resource-service.ts
src/common/custom-theme.ts
src/common/file.ts [new file with mode: 0644]
src/common/webdav.test.ts
src/common/webdav.ts
src/components/collection-panel-files/collection-panel-files.tsx
src/components/details-attribute/details-attribute.tsx
src/components/file-upload-dialog/file-upload-dialog.tsx [new file with mode: 0644]
src/components/form-dialog/form-dialog.tsx [new file with mode: 0644]
src/components/list-item-text-icon/list-item-text-icon.tsx
src/components/move-to-dialog/move-to-dialog.tsx [new file with mode: 0644]
src/components/project-copy/project-copy.tsx [new file with mode: 0644]
src/components/tree/tree.test.tsx
src/components/tree/tree.tsx
src/index.tsx
src/models/tree.test.ts
src/models/tree.ts
src/services/collection-files-service/collection-manifest-mapper.ts
src/services/collection-service/collection-service-files-response.ts [new file with mode: 0644]
src/services/collection-service/collection-service.ts
src/services/services.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts
src/store/collections/creator/collection-creator-action.ts
src/store/collections/uploader/collection-uploader-actions.ts
src/store/data-explorer/data-explorer-reducer.test.tsx
src/store/dialog/dialog-reducer.ts
src/store/project-tree-picker/project-tree-picker-actions.ts [new file with mode: 0644]
src/store/project/project-reducer.test.ts
src/store/tree-picker/tree-picker-actions.ts
src/store/tree-picker/tree-picker-reducer.test.ts
src/store/tree-picker/tree-picker-reducer.ts
src/store/tree-picker/tree-picker.ts
src/validators/validators.tsx
src/views-components/collection-panel-files/collection-panel-files.ts
src/views-components/collection-partial-copy-dialog/collection-partial-copy-dialog.tsx [new file with mode: 0644]
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/context-menu/action-sets/collection-files-action-set.ts
src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
src/views-components/context-menu/action-sets/collection-resource-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected.tsx
src/views-components/details-panel/collection-details.tsx
src/views-components/details-panel/details-panel.tsx
src/views-components/details-panel/process-details.tsx
src/views-components/details-panel/project-details.tsx
src/views-components/dialog-create/dialog-collection-create-selected.tsx
src/views-components/form-dialog/collection-form-dialog.tsx [new file with mode: 0644]
src/views-components/move-to-dialog/move-to-dialog.tsx [new file with mode: 0644]
src/views-components/project-copy-dialog/project-copy-dialog.tsx [new file with mode: 0644]
src/views-components/project-tree-picker/project-tree-picker.tsx
src/views-components/project-tree/project-tree.test.tsx
src/views-components/rename-file-dialog/rename-file-dialog.tsx
src/views-components/tree-picker/tree-picker.ts
src/views-components/upload-collection-files-dialog/upload-collection-files-dialog.ts [new file with mode: 0644]
src/views/collection-panel/collection-panel.tsx
src/views/workbench/workbench.tsx

index caa4d760c9e08ef1c7af63bf86e90b34a03d4ebb..da5bc33cf7c08f33af05e7e5c938c6b9e0663bc4 100644 (file)
@@ -29,6 +29,12 @@ export interface Errors {
     errorToken: string;
 }
 
+export enum CommonResourceServiceError {
+    UNIQUE_VIOLATION = 'UniqueViolation',
+    UNKNOWN = 'Unknown',
+    NONE = 'None'
+}
+
 export class CommonResourceService<T extends Resource> {
 
     static mapResponseKeys = (response: any): Promise<any> =>
@@ -106,3 +112,17 @@ export class CommonResourceService<T extends Resource> {
     }
 }
 
+export const getCommonResourceServiceError = (errorResponse: any) => {
+    if ('errors' in errorResponse && 'errorToken' in errorResponse) {
+        const error = errorResponse.errors.join('');
+        switch (true) {
+            case /UniqueViolation/.test(error):
+                return CommonResourceServiceError.UNIQUE_VIOLATION;
+            default:
+                return CommonResourceServiceError.UNKNOWN;
+        }
+    }
+    return CommonResourceServiceError.NONE;
+};
+
+
index 098e0090cc90bc896cd795169ddb32e8582fb817..2b0c58918f11270ef786e7d5a99d06f8bd61e001 100644 (file)
@@ -99,6 +99,9 @@ const themeOptions: ArvadosThemeOptions = {
             }
         },
         MuiInput: {
+            root: {
+                fontSize: '0.875rem'
+            },
             underline: {
                 '&:after': {
                     borderBottomColor: purple800
@@ -109,6 +112,9 @@ const themeOptions: ArvadosThemeOptions = {
             }
         },
         MuiFormLabel: {
+            root: {
+                fontSize: '0.875rem'
+            },
             focused: {
                 "&$focused:not($error)": {
                     color: purple800
diff --git a/src/common/file.ts b/src/common/file.ts
new file mode 100644 (file)
index 0000000..2311399
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const fileToArrayBuffer = (file: File) =>
+    new Promise<ArrayBuffer>((resolve, reject) => {
+        const reader = new FileReader();
+        reader.onload = () => {
+            resolve(reader.result as ArrayBuffer);
+        };
+        reader.onerror = () => {
+            reject();
+        };
+        reader.readAsArrayBuffer(file);
+    });
index d96465ba7e61a748f92f3a9b53d78b4b057266fc..c85f30e793864ecc3cb5798c44a85de75afa6d55 100644 (file)
@@ -41,15 +41,13 @@ describe('WebDAV', () => {
 
     it('PUT', async () => {
         const { open, send, load, progress, createRequest } = mockCreateRequest();
-        const onProgress = jest.fn();
         const webdav = new WebDAV(undefined, createRequest);
-        const promise = webdav.put('foo', 'Test data', { onProgress });
+        const promise = webdav.put('foo', 'Test data');
         progress();
         load();
         const request = await promise;
         expect(open).toHaveBeenCalledWith('PUT', 'foo');
         expect(send).toHaveBeenCalledWith('Test data');
-        expect(onProgress).toHaveBeenCalled();
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
index 57caebc839e433f29ee26e13c5163e71270241dc..27e1f22de5be8c642072d7ae42b80f9189fe524b 100644 (file)
@@ -58,8 +58,8 @@ export class WebDAV {
                 .keys(headers)
                 .forEach(key => r.setRequestHeader(key, headers[key]));
 
-            if (config.onProgress) {
-                r.addEventListener('progress', config.onProgress);
+            if (config.onUploadProgress) {
+                r.upload.addEventListener('progress', config.onUploadProgress);
             }
 
             r.addEventListener('load', () => resolve(r));
@@ -73,7 +73,7 @@ export interface WebDAVRequestConfig {
     headers?: {
         [key: string]: string;
     };
-    onProgress?: (event: ProgressEvent) => void;
+    onUploadProgress?: (event: ProgressEvent) => void;
 }
 
 interface WebDAVDefaults {
@@ -86,5 +86,5 @@ interface RequestConfig {
     url: string;
     headers?: { [key: string]: string };
     data?: any;
-    onProgress?: (event: ProgressEvent) => void;
+    onUploadProgress?: (event: ProgressEvent) => void;
 }
index 665758c3e62cbd916366045f79c1e4026a560c99..f9c18219852fdfce3a5cf0f60a4d874cf56c06f1 100644 (file)
@@ -8,10 +8,6 @@ import { FileTreeData } from '../file-tree/file-tree-data';
 import { FileTree } from '../file-tree/file-tree';
 import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, Card, Button } from '@material-ui/core';
 import { CustomizeTableIcon } from '../icon/icon';
-import { connect, DispatchProp } from "react-redux";
-import { Dispatch } from "redux";
-import { RootState } from "~/store/store";
-import { ServiceRepository } from "~/services/services";
 
 export interface CollectionPanelFilesProps {
     items: Array<TreeItem<FileTreeData>>;
@@ -40,44 +36,34 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     }
 });
 
-const renameFile = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-  services.collectionFilesService.renameTest();
-};
-
-
 export const CollectionPanelFiles =
-    connect()(
     withStyles(styles)(
-    ({ onItemMenuOpen, onOptionsMenuOpen, classes, dispatch, ...treeProps }: CollectionPanelFilesProps & DispatchProp & WithStyles<CssRules>) =>
-        <Card className={classes.root}>
-            <CardHeader
-                title="Files"
-                action={
-                    <Button onClick={
-                        () => {
-                            dispatch<any>(renameFile());
-                        }}
-                        variant='raised'
-                        color='primary'
-                        size='small'>
-                        Upload data
+        ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
+            <Card className={classes.root}>
+                <CardHeader
+                    title="Files"
+                    action={
+                        <Button onClick={onUploadDataClick}
+                            variant='raised'
+                            color='primary'
+                            size='small'>
+                            Upload data
                     </Button>
-                } />
-            <CardHeader
-                className={classes.cardSubheader}
-                action={
-                    <IconButton onClick={onOptionsMenuOpen}>
-                        <CustomizeTableIcon />
-                    </IconButton>
-                } />
-            <Grid container justify="space-between">
-                <Typography variant="caption" className={classes.nameHeader}>
-                    Name
+                    } />
+                <CardHeader
+                    className={classes.cardSubheader}
+                    action={
+                        <IconButton onClick={onOptionsMenuOpen}>
+                            <CustomizeTableIcon />
+                        </IconButton>
+                    } />
+                <Grid container justify="space-between">
+                    <Typography variant="caption" className={classes.nameHeader}>
+                        Name
                     </Typography>
-                <Typography variant="caption" className={classes.fileSizeHeader}>
-                    File size
+                    <Typography variant="caption" className={classes.fileSizeHeader}>
+                        File size
                     </Typography>
-            </Grid>
-            <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
-        </Card>)
-);
+                </Grid>
+                <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
+            </Card>);
index 3888b04b67d596ea84f3e11cbfeba30999adbc03..32ab167182c28170660a42e1f0507c40f35256ce 100644 (file)
@@ -8,7 +8,7 @@ import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/st
 import { ArvadosTheme } from '~/common/custom-theme';
 import * as classnames from "classnames";
 
-type CssRules = 'attribute' | 'label' | 'value' | 'link';
+type CssRules = 'attribute' | 'label' | 'value' | 'lowercaseValue' | 'link';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     attribute: {
@@ -26,6 +26,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         alignItems: 'flex-start',
         textTransform: 'capitalize'
     },
+    lowercaseValue: {
+        textTransform: 'lowercase'
+    },
     link: {
         width: '60%',
         color: theme.palette.primary.main,
@@ -39,6 +42,7 @@ interface DetailsAttributeDataProps {
     classLabel?: string;
     value?: string | number;
     classValue?: string;
+    lowercaseValue?: boolean;
     link?: string;
     children?: React.ReactNode;
 }
@@ -46,12 +50,12 @@ interface DetailsAttributeDataProps {
 type DetailsAttributeProps = DetailsAttributeDataProps & WithStyles<CssRules>;
 
 export const DetailsAttribute = withStyles(styles)(
-    ({ label, link, value, children, classes, classLabel, classValue }: DetailsAttributeProps) =>
+    ({ label, link, value, children, classes, classLabel, classValue, lowercaseValue }: DetailsAttributeProps) =>
         <Typography component="div" className={classes.attribute}>
             <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={classnames([classes.value, classValue])}>
+                : <Typography component="span" className={classnames([classes.value, classValue, { [classes.lowercaseValue]: lowercaseValue }])}>
                     {value}
                     {children}
                 </Typography> }
diff --git a/src/components/file-upload-dialog/file-upload-dialog.tsx b/src/components/file-upload-dialog/file-upload-dialog.tsx
new file mode 100644 (file)
index 0000000..7810c49
--- /dev/null
@@ -0,0 +1,52 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { FileUpload } from "~/components/file-upload/file-upload";
+import { UploadFile } from '~/store/collections/uploader/collection-uploader-actions';
+import { Dialog, DialogTitle, DialogContent, DialogActions } from '@material-ui/core/';
+import { Button, CircularProgress } from '@material-ui/core';
+import { WithDialogProps } from '../../store/dialog/with-dialog';
+
+export interface FilesUploadDialogProps {
+    files: UploadFile[];
+    uploading: boolean;
+    onSubmit: () => void;
+    onChange: (files: File[]) => void;
+}
+
+export const FilesUploadDialog = (props: FilesUploadDialogProps & WithDialogProps<{}>) =>
+    <Dialog open={props.open}
+        disableBackdropClick={true}
+        disableEscapeKeyDown={true}
+        fullWidth={true}
+        maxWidth='sm'>
+        <DialogTitle>Upload data</DialogTitle>
+        <DialogContent>
+            <FileUpload
+                files={props.files}
+                disabled={props.uploading}
+                onDrop={props.onChange}
+            />
+        </DialogContent>
+        <DialogActions>
+            <Button
+                variant='flat'
+                color='primary'
+                disabled={props.uploading}
+                onClick={props.closeDialog}>
+                Cancel
+            </Button>
+            <Button
+                variant='contained'
+                color='primary'
+                type='submit'
+                onClick={props.onSubmit}
+                disabled={props.uploading}>
+                {props.uploading
+                    ? <CircularProgress size={20} />
+                    : 'Upload data'}
+            </Button>
+        </DialogActions>
+    </Dialog>;
diff --git a/src/components/form-dialog/form-dialog.tsx b/src/components/form-dialog/form-dialog.tsx
new file mode 100644 (file)
index 0000000..dee8924
--- /dev/null
@@ -0,0 +1,81 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { Dialog, DialogActions, DialogContent, DialogTitle } from '@material-ui/core/';
+import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+
+type CssRules = "button" | "lastButton" | "formContainer" | "dialogTitle" | "progressIndicator" | "dialogActions";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+    lastButton: {
+        marginLeft: theme.spacing.unit,
+        marginRight: "20px",
+    },
+    formContainer: {
+        display: "flex",
+        flexDirection: "column",
+        marginTop: "20px",
+    },
+    dialogTitle: {
+        paddingBottom: "0"
+    },
+    progressIndicator: {
+        position: "absolute",
+        minWidth: "20px",
+    },
+    dialogActions: {
+        marginBottom: "24px"
+    }
+});
+
+interface DialogProjectProps {
+    cancelLabel?: string;
+    dialogTitle: string;
+    formFields: React.ComponentType<InjectedFormProps<any> & WithDialogProps<any>>;
+    submitLabel?: string;
+}
+
+export const FormDialog = withStyles(styles)((props: DialogProjectProps & WithDialogProps<{}> & InjectedFormProps<any> & WithStyles<CssRules>) =>
+    <Dialog
+        open={props.open}
+        onClose={props.closeDialog}
+        disableBackdropClick={props.submitting}
+        disableEscapeKeyDown={props.submitting}
+        fullWidth>
+        <form>
+            <DialogTitle className={props.classes.dialogTitle}>
+                {props.dialogTitle}
+            </DialogTitle>
+            <DialogContent className={props.classes.formContainer}>
+                <props.formFields {...props} />
+            </DialogContent>
+            <DialogActions className={props.classes.dialogActions}>
+                <Button
+                    onClick={props.closeDialog}
+                    className={props.classes.button}
+                    color="primary"
+                    disabled={props.submitting}>
+                    {props.cancelLabel || 'Cancel'}
+                </Button>
+                <Button
+                    onClick={props.handleSubmit}
+                    className={props.classes.lastButton}
+                    color="primary"
+                    disabled={props.invalid || props.submitting || props.pristine}
+                    variant="contained">
+                    {props.submitLabel || 'Submit'}
+                    {props.submitting && <CircularProgress size={20} className={props.classes.progressIndicator} />}
+                </Button>
+            </DialogActions>
+        </form>
+    </Dialog>
+);
+
+
index b34c6ab5f9cab8784e525b6356963f4fad4b800c..e7f63eafc0c1f7e9423e148daad623d008580623 100644 (file)
@@ -17,7 +17,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         alignItems: 'center'
     },
     listItemText: {
-        fontWeight: 700
+        fontWeight: 400
     },
     active: {
         color: theme.palette.primary.main,
diff --git a/src/components/move-to-dialog/move-to-dialog.tsx b/src/components/move-to-dialog/move-to-dialog.tsx
new file mode 100644 (file)
index 0000000..2bfc2c3
--- /dev/null
@@ -0,0 +1,48 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field, InjectedFormProps, WrappedFieldProps } from "redux-form";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, CircularProgress } from "@material-ui/core";
+
+import { WithDialogProps } from "~/store/dialog/with-dialog";
+import { ProjectTreePicker } from "~/views-components/project-tree-picker/project-tree-picker";
+import { MOVE_TO_VALIDATION } from "~/validators/validators";
+
+export const MoveToDialog = (props: WithDialogProps<string> & InjectedFormProps<{ name: string }>) =>
+    <form>
+        <Dialog open={props.open}
+            disableBackdropClick={true}
+            disableEscapeKeyDown={true}>
+            <DialogTitle>Move to</DialogTitle>
+            <DialogContent>
+                <Field
+                    name="projectUuid"
+                    component={Picker}
+                    validate={MOVE_TO_VALIDATION} />
+            </DialogContent>
+            <DialogActions>
+                <Button
+                    variant='flat'
+                    color='primary'
+                    disabled={props.submitting}
+                    onClick={props.closeDialog}>
+                    Cancel
+                    </Button>
+                <Button
+                    variant='contained'
+                    color='primary'
+                    type='submit'
+                    onClick={props.handleSubmit}
+                    disabled={props.pristine || props.invalid || props.submitting}>
+                    {props.submitting ? <CircularProgress size={20} /> : 'Move'}
+                </Button>
+            </DialogActions>
+        </Dialog>
+    </form>;
+
+const Picker = (props: WrappedFieldProps) =>
+    <div style={{ width: '400px', height: '144px', display: 'flex', flexDirection: 'column' }}>
+       <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} /> 
+    </div>;
\ No newline at end of file
diff --git a/src/components/project-copy/project-copy.tsx b/src/components/project-copy/project-copy.tsx
new file mode 100644 (file)
index 0000000..34c8e6d
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import * as React from "react";
+import { Field, InjectedFormProps, WrappedFieldProps } from "redux-form";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, CircularProgress } from "@material-ui/core";
+import { WithDialogProps } from "~/store/dialog/with-dialog";
+import { ProjectTreePicker } from "~/views-components/project-tree-picker/project-tree-picker";
+import { MAKE_A_COPY_VALIDATION, COPY_NAME_VALIDATION } from "~/validators/validators";
+import { TextField } from '~/components/text-field/text-field';
+
+export interface CopyFormData {
+    name: string;
+    projectUuid: string;
+    uuid: string;
+}
+
+export const ProjectCopy = (props: WithDialogProps<string> & InjectedFormProps<CopyFormData>) =>
+    <form>
+        <Dialog open={props.open}
+            disableBackdropClick={true}
+            disableEscapeKeyDown={true}>
+            <DialogTitle>Make a copy</DialogTitle>
+            <DialogContent>
+                <Field
+                    name="name"
+                    component={TextField}
+                    validate={COPY_NAME_VALIDATION}
+                    label="Enter a new name for the copy" />
+                <Field
+                    name="projectUuid"
+                    component={Picker}
+                    validate={MAKE_A_COPY_VALIDATION} />
+            </DialogContent>
+            <DialogActions>
+                <Button
+                    variant='flat'
+                    color='primary'
+                    disabled={props.submitting}
+                    onClick={props.closeDialog}>
+                    Cancel
+                    </Button>
+                <Button
+                    variant='contained'
+                    color='primary'
+                    type='submit'
+                    onClick={props.handleSubmit}
+                    disabled={props.pristine || props.invalid || props.submitting}>
+                    {props.submitting && <CircularProgress size={20} style={{position: 'absolute'}}/>}
+                    Copy
+                </Button>                
+            </DialogActions>
+        </Dialog>
+    </form>;
+const Picker = (props: WrappedFieldProps) =>
+    <div style={{ width: '400px', height: '144px', display: 'flex', flexDirection: 'column' }}>
+        <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} />
+    </div>; 
\ No newline at end of file
index 45981d8962c7119f614b48a9ab866bceed9c65f9..50d1368abedc0776b1b81b4e2fe88013f4f7c134 100644 (file)
@@ -7,7 +7,7 @@ import * as Enzyme from 'enzyme';
 import * as Adapter from 'enzyme-adapter-react-16';
 import ListItem from "@material-ui/core/ListItem/ListItem";
 
-import { Tree, TreeItem } from './tree';
+import { Tree, TreeItem, TreeItemStatus } from './tree';
 import { ProjectResource } from '../../models/project';
 import { mockProjectResource } from '../../models/test-utils';
 import { Checkbox } from '@material-ui/core';
@@ -22,7 +22,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED
         };
         const wrapper = mount(<Tree
             render={project => <div />}
@@ -39,7 +39,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED,
         };
         const wrapper = mount(<Tree
             render={project => <div />}
@@ -56,7 +56,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED
         };
         const wrapper = mount(<Tree
             showSelection={true}
@@ -74,7 +74,7 @@ describe("Tree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1,
+            status: TreeItemStatus.LOADED,
         };
         const spy = jest.fn();
         const onSelectionChanged = (event: any, item: TreeItem<any>) => spy(item);
index 3e8cf904cfaae2274d1273bb81f1f77048b34c2a..8d657f8dd63dabff570abafaa831d8411a9d1eab 100644 (file)
@@ -65,9 +65,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 });
 
 export enum TreeItemStatus {
-    INITIAL,
-    PENDING,
-    LOADED
+    INITIAL = 'initial',
+    PENDING = 'pending',
+    LOADED = 'loaded'
 }
 
 export interface TreeItem<T> {
@@ -110,7 +110,7 @@ export const Tree = withStyles(styles)(
                             <i onClick={() => this.props.toggleItemOpen(it.id, it.status)}
                                 className={toggableIconContainer}>
                                 <ListItemIcon className={this.getToggableIconClassNames(it.open, it.active)}>
-                                    {it.status !== TreeItemStatus.INITIAL && it.items && it.items.length === 0 ? <span /> : <SidePanelRightArrowIcon />}
+                                    {this.getProperArrowAnimation(it.status, it.items!)}
                                 </ListItemIcon>
                             </i>
                             {this.props.showSelection &&
@@ -140,6 +140,16 @@ export const Tree = withStyles(styles)(
             </List>;
         }
 
+        getProperArrowAnimation = (status: string, items: Array<TreeItem<T>>) => {
+            return this.isSidePanelIconNotNeeded(status, items) ? <span /> : <SidePanelRightArrowIcon />;
+        }
+
+        isSidePanelIconNotNeeded = (status: string, items: Array<TreeItem<T>>) => {
+            return status === TreeItemStatus.PENDING ||
+                (status === TreeItemStatus.LOADED && !items) ||
+                (status === TreeItemStatus.LOADED && items && items.length === 0);
+        }
+
         getToggableIconClassNames = (isOpen?: boolean, isActive?: boolean) => {
             const { iconOpen, iconClose, active, toggableIcon } = this.props.classes;
             return classnames(toggableIcon, {
index 443e76f3e62a42597d804aefd779b32b3f65ffa9..b368d3f88f9cdf560f9f6c5a6b3340c23cf88d2a 100644 (file)
@@ -52,7 +52,7 @@ fetchConfig()
         const store = configureStore(history, services);
 
         store.dispatch(initAuth());
-        store.dispatch(getProjectList(services.authService.getUuid()));
+        store.dispatch(getProjectList(services.authService.getUuid()));  
 
         const TokenComponent = (props: any) => <ApiToken authService={services.authService} {...props}/>;
         const WorkbenchComponent = (props: any) => <Workbench authService={services.authService} buildInfo={buildInfo} {...props}/>;
index 708cf4045c75b73fae7650b8a3bba789a46362bc..375a012054f9bea3a26a7bd8033642b44697ea24 100644 (file)
@@ -30,7 +30,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3', parent: 'Node 2', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeAncestors('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
+        expect(Tree.getNodeAncestorsIds('Node 3')(newTree)).toEqual(['Node 1', 'Node 2']);
     });
 
     it('gets node descendants', () => {
@@ -41,7 +41,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeDescendants('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+        expect(Tree.getNodeDescendantsIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
     });
 
     it('gets root descendants', () => {
@@ -52,7 +52,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeDescendants('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
+        expect(Tree.getNodeDescendantsIds('')(newTree)).toEqual(['Node 1', 'Node 2', 'Node 3', 'Node 2.1', 'Node 3.1']);
     });
 
     it('gets node children', () => {
@@ -63,7 +63,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: 'Node 1', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeChildren('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
+        expect(Tree.getNodeChildrenIds('Node 1')(newTree)).toEqual(['Node 2', 'Node 3']);
     });
 
     it('gets root children', () => {
@@ -74,7 +74,7 @@ describe('Tree', () => {
             { children: [], id: 'Node 3', parent: '', value: 'Value 1' },
             { children: [], id: 'Node 3.1', parent: 'Node 3', value: 'Value 1' }
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
-        expect(Tree.getNodeChildren('')(newTree)).toEqual(['Node 1', 'Node 3']);
+        expect(Tree.getNodeChildrenIds('')(newTree)).toEqual(['Node 1', 'Node 3']);
     });
 
     it('maps tree', () => {
index 8b66e50da7961df58c5c60f0b8fd23556f749467..a5fb49cff4a3adb3945cdcfbcb1a1ec6b4ac5080 100644 (file)
@@ -6,7 +6,7 @@ export type Tree<T> = Record<string, TreeNode<T>>;
 
 export const TREE_ROOT_ID = '';
 
-export interface TreeNode<T> {
+export interface TreeNode<T = any> {
     children: string[];
     value: T;
     id: string;
@@ -21,7 +21,7 @@ export const setNode = <T>(node: TreeNode<T>) => (tree: Tree<T>): Tree<T> => {
     const [newTree] = [tree]
         .map(tree => getNode(node.id)(tree) === node
             ? tree
-            : {...tree, [node.id]: node})
+            : { ...tree, [node.id]: node })
         .map(addChild(node.parent, node.id));
     return newTree;
 };
@@ -46,25 +46,32 @@ export const setNodeValueWith = <T>(mapFn: (value: T) => T) => (id: string) => (
 };
 
 export const mapTreeValues = <T, R>(mapFn: (value: T) => R) => (tree: Tree<T>): Tree<R> =>
-    getNodeDescendants('')(tree)
+    getNodeDescendantsIds('')(tree)
         .map(id => getNode(id)(tree))
         .map(mapNodeValue(mapFn))
         .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
 
-export const mapTree = <T, R>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
-    getNodeDescendants('')(tree)
+export const mapTree = <T, R = T>(mapFn: (node: TreeNode<T>) => TreeNode<R>) => (tree: Tree<T>): Tree<R> =>
+    getNodeDescendantsIds('')(tree)
         .map(id => getNode(id)(tree))
         .map(mapFn)
         .reduce((newTree, node) => setNode(node)(newTree), createTree<R>());
 
-export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>): string[] => {
+export const getNodeAncestors = (id: string) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeAncestorsIds(id)(tree))(tree);
+
+
+export const getNodeAncestorsIds = (id: string) => <T>(tree: Tree<T>): string[] => {
     const node = getNode(id)(tree);
     return node && node.parent
-        ? [...getNodeAncestors(node.parent)(tree), node.parent]
+        ? [...getNodeAncestorsIds(node.parent)(tree), node.parent]
         : [];
 };
 
-export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
+export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeDescendantsIds(id, limit)(tree))(tree);
+
+export const getNodeDescendantsIds = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
     const node = getNode(id)(tree);
     const children = node ? node.children :
         id === TREE_ROOT_ID
@@ -75,12 +82,18 @@ export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tr
         .concat(limit < 1
             ? []
             : children
-                .map(id => getNodeDescendants(id, limit - 1)(tree))
+                .map(id => getNodeDescendantsIds(id, limit - 1)(tree))
                 .reduce((nodes, nodeChildren) => [...nodes, ...nodeChildren], []));
 };
 
-export const getNodeChildren = (id: string) => <T>(tree: Tree<T>): string[] =>
-    getNodeDescendants(id, 0)(tree);
+export const getNodeChildren = (id: string) => <T>(tree: Tree<T>) =>
+    mapIdsToNodes(getNodeChildrenIds(id)(tree))(tree);
+
+export const getNodeChildrenIds = (id: string) => <T>(tree: Tree<T>): string[] =>
+    getNodeDescendantsIds(id, 0)(tree);
+
+export const mapIdsToNodes = (ids: string[]) => <T>(tree: Tree<T>) =>
+    ids.map(id => getNode(id)(tree)).filter((node): node is TreeNode<T> => node !== undefined);
 
 const mapNodeValue = <T, R>(mapFn: (value: T) => R) => (node: TreeNode<T>): TreeNode<R> =>
     ({ ...node, value: mapFn(node.value) });
index c3fd43ead1d0d4c013e066f0331712710af9f38f..0c7e91deecf4aba5bc9465569c40118f9401d536 100644 (file)
@@ -4,11 +4,11 @@
 
 import { uniqBy, groupBy } from 'lodash';
 import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "~/models/keep-manifest";
-import { TreeNode, setNode, createTree, getNodeDescendants, getNodeValue } from '~/models/tree';
+import { TreeNode, setNode, createTree, getNodeDescendantsIds, getNodeValue } from '~/models/tree';
 import { CollectionFilesTree, CollectionFile, CollectionDirectory, createCollectionDirectory, createCollectionFile, CollectionFileType } from '../../models/collection-file';
 
 export const mapCollectionFilesTreeToManifest = (tree: CollectionFilesTree): KeepManifest => {
-    const values = getNodeDescendants('')(tree).map(id => getNodeValue(id)(tree));
+    const values = getNodeDescendantsIds('')(tree).map(id => getNodeValue(id)(tree));
     const files = values.filter(value => value && value.type === CollectionFileType.FILE) as CollectionFile[];
     const fileGroups = groupBy(files, file => file.path);
     return Object
diff --git a/src/services/collection-service/collection-service-files-response.ts b/src/services/collection-service/collection-service-files-response.ts
new file mode 100644 (file)
index 0000000..b8a7970
--- /dev/null
@@ -0,0 +1,54 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { createCollectionFilesTree, CollectionDirectory, CollectionFile, CollectionFileType, createCollectionDirectory, createCollectionFile } from "../../models/collection-file";
+import { getTagValue } from "~/common/xml";
+import { getNodeChildren, Tree, mapTree } from '~/models/tree';
+
+export const parseFilesResponse = (document: Document) => {
+    const files = extractFilesData(document);
+    const tree = createCollectionFilesTree(files);
+    return sortFilesTree(tree);
+};
+
+export const sortFilesTree = (tree: Tree<CollectionDirectory | CollectionFile>) => {
+    return mapTree<CollectionDirectory | CollectionFile>(node => {
+        const children = getNodeChildren(node.id)(tree);
+
+        children.sort((a, b) =>
+            a.value.type !== b.value.type
+                ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
+                : a.value.name.localeCompare(b.value.name)
+        );
+        return { ...node, children: children.map(child => child.id) };
+    })(tree);
+};
+
+export const extractFilesData = (document: Document) => {
+    const collectionUrlPrefix = /\/c=[0-9a-zA-Z\-]*/;
+    return Array
+        .from(document.getElementsByTagName('D:response'))
+        .slice(1) // omit first element which is collection itself
+        .map(element => {
+            const name = getTagValue(element, 'D:displayname', '');
+            const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
+            const url = getTagValue(element, 'D:href', '');
+            const nameSuffix = `/${name || ''}`;
+            const directory = url
+                .replace(collectionUrlPrefix, '')
+                .replace(nameSuffix, '');
+
+            const data = {
+                url,
+                id: `${directory}/${name}`,
+                name,
+                path: directory,
+            };
+
+            return getTagValue(element, 'D:resourcetype', '')
+                ? createCollectionDirectory(data)
+                : createCollectionFile({ ...data, size });
+
+        });
+};
index 9feec699e52dfd07070105c75c251d96b3107541..1c62ec5a68b471b89e646e6d698d99b423a421b8 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import * as _ from "lodash";
 import { CommonResourceService } from "~/common/api/common-resource-service";
 import { CollectionResource } from "~/models/collection";
-import axios, { AxiosInstance } from "axios";
-import { KeepService } from "../keep-service/keep-service";
+import { AxiosInstance } from "axios";
+import { CollectionFile, CollectionDirectory } from "~/models/collection-file";
 import { WebDAV } from "~/common/webdav";
 import { AuthService } from "../auth-service/auth-service";
-import { mapTree, getNodeChildren, getNode, TreeNode } from "../../models/tree";
-import { getTagValue } from "~/common/xml";
-import { FilterBuilder } from "~/common/api/filter-builder";
-import { CollectionFile, createCollectionFile, CollectionFileType, CollectionDirectory, createCollectionDirectory } from '~/models/collection-file';
-import { parseKeepManifestText, stringifyKeepManifest } from "../collection-files-service/collection-manifest-parser";
-import { KeepManifestStream } from "~/models/keep-manifest";
-import { createCollectionFilesTree } from '~/models/collection-file';
+import { mapTreeValues } from "~/models/tree";
+import { parseFilesResponse } from "./collection-service-files-response";
+import { fileToArrayBuffer } from "~/common/file";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
 
 export class CollectionService extends CommonResourceService<CollectionResource> {
-    constructor(serverApi: AxiosInstance, private keepService: KeepService, private webdavClient: WebDAV, private authService: AuthService) {
+    constructor(serverApi: AxiosInstance, private webdavClient: WebDAV, private authService: AuthService) {
         super(serverApi, "collections");
     }
 
     async files(uuid: string) {
         const request = await this.webdavClient.propfind(`/c=${uuid}`);
         if (request.responseXML != null) {
-            const files = this.extractFilesData(request.responseXML);
-            const tree = createCollectionFilesTree(files);
-            const sortedTree = mapTree(node => {
-                const children = getNodeChildren(node.id)(tree).map(id => getNode(id)(tree)) as TreeNode<CollectionDirectory | CollectionFile>[];
-                children.sort((a, b) =>
-                    a.value.type !== b.value.type
-                        ? a.value.type === CollectionFileType.DIRECTORY ? -1 : 1
-                        : a.value.name.localeCompare(b.value.name)
-                );
-                return { ...node, children: children.map(child => child.id) };
-            })(tree);
-            return sortedTree;
+            const filesTree = parseFilesResponse(request.responseXML);
+            return mapTreeValues(this.extendFileURL)(filesTree);
         }
         return Promise.reject();
     }
 
-    async deleteFile(collectionUuid: string, filePath: string) {
-        return this.webdavClient.delete(`/c=${collectionUuid}${filePath}`);
-    }
-
-    extractFilesData(document: Document) {
-        const collectionUrlPrefix = /\/c=[0-9a-zA-Z\-]*/;
-        return Array
-            .from(document.getElementsByTagName('D:response'))
-            .slice(1) // omit first element which is collection itself
-            .map(element => {
-                const name = getTagValue(element, 'D:displayname', '');
-                const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
-                const pathname = getTagValue(element, 'D:href', '');
-                const nameSuffix = `/${name || ''}`;
-                const directory = pathname
-                    .replace(collectionUrlPrefix, '')
-                    .replace(nameSuffix, '');
-                const href = this.webdavClient.defaults.baseURL + pathname + '?api_token=' + this.authService.getApiToken();
-
-                const data = {
-                    url: href,
-                    id: `${directory}/${name}`,
-                    name,
-                    path: directory,
-                };
-
-                return getTagValue(element, 'D:resourcetype', '')
-                    ? createCollectionDirectory(data)
-                    : createCollectionFile({ ...data, size });
-
-            });
+    async deleteFiles(collectionUuid: string, filePaths: string[]) {
+        for (const path of filePaths) {
+            await this.webdavClient.delete(`/c=${collectionUuid}${path}`);
+        }
     }
 
-
-    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);
-        });
+    async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress) {
+        // files have to be uploaded sequentially
+        for (let idx = 0; idx < files.length; idx++) {
+            await this.uploadFile(collectionUuid, files[idx], idx, onProgress);
+        }
     }
 
-    private 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
-            }));
-        });
+    moveFile(collectionUuid: string, oldPath: string, newPath: string) {
+        return this.webdavClient.move(
+            `/c=${collectionUuid}${oldPath}`,
+            `/c=${collectionUuid}${encodeURI(newPath)}`
+        );
     }
 
-    private async updateManifest(collectionUuid: string, files: CollectionFile[]): Promise<CollectionResource> {
-        const collection = await this.get(collectionUuid);
-        const manifest: KeepManifestStream[] = parseKeepManifestText(collection.manifestText);
-
-        files.forEach(f => {
-            let kms = manifest.find(stream => stream.name === f.path);
-            if (!kms) {
-                kms = {
-                    files: [],
-                    locators: [],
-                    name: f.path
-                };
-                manifest.push(kms);
+    private extendFileURL = (file: CollectionDirectory | CollectionFile) => ({
+        ...file,
+        url: this.webdavClient.defaults.baseURL + file.url + '?api_token=' + this.authService.getApiToken()
+    })
+
+    private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }) {
+        const fileURL = `/c=${collectionUuid}/${file.name}`;
+        const fileContent = await fileToArrayBuffer(file);
+        const requestConfig = {
+            headers: {
+                'Content-Type': 'text/octet-stream'
+            },
+            onUploadProgress: (e: ProgressEvent) => {
+                onProgress(fileId, e.loaded, e.total, Date.now());
             }
-            kms.locators.push(f.id);
-            const len = kms.files.length;
-            const nextPos = len > 0
-                ? parseInt(kms.files[len - 1].position, 10) + kms.files[len - 1].size
-                : 0;
-            kms.files.push({
-                name: f.name,
-                position: nextPos.toString(),
-                size: f.size
-            });
-        });
+        };
+        return this.webdavClient.put(fileURL, fileContent, requestConfig);
 
-        console.log(manifest);
-
-        const manifestText = stringifyKeepManifest(manifest);
-        const data = { ...collection, manifestText };
-        return this.update(collectionUuid, CommonResourceService.mapKeys(_.snakeCase)(data));
     }
 
-    uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress): Promise<CollectionResource | never> {
-        const filters = new FilterBuilder()
-            .addEqual("service_type", "proxy");
-
-        return this.keepService.list({ filters: filters.getFilters() }).then(data => {
-            if (data.items && data.items.length > 0) {
-                const serviceHost =
-                    (data.items[0].serviceSslFlag ? "https://" : "http://") +
-                    data.items[0].serviceHost +
-                    ":" + data.items[0].servicePort;
-
-                console.log("serviceHost", serviceHost);
-
-                const files$ = files.map((f, idx) => this.uploadFile(serviceHost, f, idx, onProgress));
-                return Promise.all(files$).then(values => {
-                    return this.updateManifest(collectionUuid, values);
-                });
-            } else {
-                return Promise.reject("Missing keep service host");
-            }
-        });
-    }
 }
index 8ddc3f477403be59e73d981bdcbac9f8a2fab499..183deefb4548393dfc677e7b29ce61a0b1c8f016 100644 (file)
@@ -30,7 +30,7 @@ export const createServices = (config: Config) => {
     const projectService = new ProjectService(apiClient);
     const linkService = new LinkService(apiClient);
     const favoriteService = new FavoriteService(linkService, groupsService);
-    const collectionService = new CollectionService(apiClient, keepService, webdavClient, authService);
+    const collectionService = new CollectionService(apiClient, webdavClient, authService);
     const tagService = new TagService(linkService);
     const collectionFilesService = new CollectionFilesService(collectionService);
 
index cedfbebef5aded305b3237e125ee80857c96361b..97abfef09f2822c51912097f7643be5a68bc293b 100644 (file)
@@ -8,9 +8,13 @@ import { CollectionFilesTree, CollectionFileType } from "~/models/collection-fil
 import { ServiceRepository } from "~/services/services";
 import { RootState } from "../../store";
 import { snackbarActions } from "../../snackbar/snackbar-actions";
-import { dialogActions } from "../../dialog/dialog-actions";
-import { getNodeValue, getNodeDescendants } from "~/models/tree";
-import { CollectionPanelDirectory, CollectionPanelFile } from "./collection-panel-files-state";
+import { dialogActions } from '../../dialog/dialog-actions';
+import { getNodeValue } from "~/models/tree";
+import { filterCollectionFilesBySelection } from './collection-panel-files-state';
+import { startSubmit, initialize, stopSubmit, reset } from 'redux-form';
+import { getCommonResourceServiceError, CommonResourceServiceError } from "~/common/api/common-resource-service";
+import { getDialog } from "~/store/dialog/dialog-reducer";
+import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
 
 export const collectionPanelFilesAction = unionize({
     SET_COLLECTION_FILES: ofType<CollectionFilesTree>(),
@@ -30,30 +34,23 @@ export const loadCollectionFiles = (uuid: string) =>
 
 export const removeCollectionFiles = (filePaths: string[]) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { item } = getState().collectionPanel;
-        if (item) {
+        const currentCollection = getState().collectionPanel.item;
+        if (currentCollection) {
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
-            const promises = filePaths.map(filePath => services.collectionService.deleteFile(item.uuid, filePath));
-            await Promise.all(promises);
-            dispatch<any>(loadCollectionFiles(item.uuid));
+            await services.collectionService.deleteFiles(currentCollection.uuid, filePaths);
+            dispatch<any>(loadCollectionFiles(currentCollection.uuid));
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
         }
     };
 
 export const removeCollectionsSelectedFiles = () =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        const tree = getState().collectionPanelFiles;
-        const allFiles = getNodeDescendants('')(tree)
-            .map(id => getNodeValue(id)(tree))
-            .filter(file => file !== undefined) as Array<CollectionPanelDirectory | CollectionPanelFile>;
-
-        const selectedDirectories = allFiles.filter(file => file.selected && file.type === CollectionFileType.DIRECTORY);
-        const selectedFiles = allFiles.filter(file => file.selected && !selectedDirectories.some(dir => dir.id === file.path));
-        const paths = [...selectedDirectories, ...selectedFiles].map(file => file.id);
+        const paths = filterCollectionFilesBySelection(getState().collectionPanelFiles, true).map(file => file.id);
         dispatch<any>(removeCollectionFiles(paths));
     };
 
 export const FILE_REMOVE_DIALOG = 'fileRemoveDialog';
+
 export const openFileRemoveDialog = (filePath: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const file = getNodeValue(filePath)(getState().collectionPanelFiles);
@@ -78,6 +75,7 @@ export const openFileRemoveDialog = (filePath: string) =>
     };
 
 export const MULTIPLE_FILES_REMOVE_DIALOG = 'multipleFilesRemoveDialog';
+
 export const openMultipleFilesRemoveDialog = () =>
     dialogActions.OPEN_DIALOG({
         id: MULTIPLE_FILES_REMOVE_DIALOG,
@@ -87,3 +85,92 @@ export const openMultipleFilesRemoveDialog = () =>
             confirmButtonLabel: 'Remove'
         }
     });
+
+export const COLLECTION_PARTIAL_COPY = 'COLLECTION_PARTIAL_COPY';
+
+export interface CollectionPartialCopyFormData {
+    name: string;
+    description: string;
+    projectUuid: string;
+}
+
+export const openCollectionPartialCopyDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const currentCollection = getState().collectionPanel.item;
+        if (currentCollection) {
+            const initialData = {
+                name: currentCollection.name,
+                description: currentCollection.description,
+                projectUuid: ''
+            };
+            dispatch(initialize(COLLECTION_PARTIAL_COPY, initialData));
+            dispatch<any>(resetPickerProjectTree());
+            dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY, data: {} }));
+        }
+    };
+
+export const doCollectionPartialCopy = ({ name, description, projectUuid }: CollectionPartialCopyFormData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(COLLECTION_PARTIAL_COPY));
+        const state = getState();
+        const currentCollection = state.collectionPanel.item;
+        if (currentCollection) {
+            try {
+                const collection = await services.collectionService.get(currentCollection.uuid);
+                const collectionCopy = {
+                    ...collection,
+                    name,
+                    description,
+                    ownerUuid: projectUuid,
+                    uuid: undefined
+                };
+                const newCollection = await services.collectionService.create(collectionCopy);
+                const paths = filterCollectionFilesBySelection(state.collectionPanelFiles, false).map(file => file.id);
+                await services.collectionService.deleteFiles(newCollection.uuid, paths);
+                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'New collection created.', hideDuration: 2000 }));
+            } catch (e) {
+                const error = getCommonResourceServiceError(e);
+                if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+                    dispatch(stopSubmit(COLLECTION_PARTIAL_COPY, { name: 'Collection with this name already exists.' }));
+                } else if (error === CommonResourceServiceError.UNKNOWN) {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not create a copy of collection', hideDuration: 2000 }));
+                } else {
+                    dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY }));
+                    dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Collection has been copied but may contain incorrect files.', hideDuration: 2000 }));
+                }
+            }
+        }
+    };
+
+export const RENAME_FILE_DIALOG = 'renameFileDialog';
+export interface RenameFileDialogData {
+    name: string;
+    id: string;
+}
+
+export const openRenameFileDialog = (data: RenameFileDialogData) =>
+    (dispatch: Dispatch) => {
+        dispatch(reset(RENAME_FILE_DIALOG));
+        dispatch(dialogActions.OPEN_DIALOG({ id: RENAME_FILE_DIALOG, data }));
+    };
+
+export const renameFile = (newName: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const dialog = getDialog<RenameFileDialogData>(getState().dialog, RENAME_FILE_DIALOG);
+        const currentCollection = getState().collectionPanel.item;
+        if (dialog && currentCollection) {
+            dispatch(startSubmit(RENAME_FILE_DIALOG));
+            const oldPath = dialog.data.id;
+            const newPath = dialog.data.id.replace(dialog.data.name, newName);
+            try {
+                await services.collectionService.moveFile(currentCollection.uuid, oldPath, newPath);
+                dispatch<any>(loadCollectionFiles(currentCollection.uuid));
+                dispatch(dialogActions.CLOSE_DIALOG({ id: RENAME_FILE_DIALOG }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'File name changed.', hideDuration: 2000 }));
+            } catch (e) {
+                dispatch(stopSubmit(RENAME_FILE_DIALOG, { name: 'Could not rename the file' }));
+            }
+        }
+    };
index 08b60308c42bb9d4f3dfdeb72eae23f4b45946de..57961538708c900b56631e47e33639a90d66a559 100644 (file)
@@ -2,9 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile, mergeCollectionPanelFilesStates } from "./collection-panel-files-state";
+import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile, mergeCollectionPanelFilesStates } from './collection-panel-files-state';
 import { CollectionPanelFilesAction, collectionPanelFilesAction } from "./collection-panel-files-actions";
-import { createTree, mapTreeValues, getNode, setNode, getNodeAncestors, getNodeDescendants, setNodeValueWith, mapTree } from "~/models/tree";
+import { createTree, mapTreeValues, getNode, setNode, getNodeAncestorsIds, getNodeDescendantsIds, setNodeValueWith, mapTree } from "~/models/tree";
 import { CollectionFileType } from "~/models/collection-file";
 
 export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => {
@@ -44,7 +44,7 @@ const toggleSelected = (id: string) => (tree: CollectionPanelFilesState) =>
 const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
     const node = getNode(id)(tree);
     if (node && node.value.type === CollectionFileType.DIRECTORY) {
-        return getNodeDescendants(id)(tree)
+        return getNodeDescendantsIds(id)(tree)
             .reduce((newTree, id) =>
                 setNodeValueWith(v => ({ ...v, selected: node.value.selected }))(id)(newTree), tree);
     }
@@ -52,7 +52,7 @@ const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
 };
 
 const toggleAncestors = (id: string) => (tree: CollectionPanelFilesState) => {
-    const ancestors = getNodeAncestors(id)(tree).reverse();
+    const ancestors = getNodeAncestorsIds(id)(tree).reverse();
     return ancestors.reduce((newTree, parent) => parent ? toggleParentNode(parent)(newTree) : newTree, tree);
 };
 
index 35b81d2e121e134b1b10f8aad6223a0267da9765..9d5b06cea6b9c94f74e5fadbebd022b5b6366178 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Tree, TreeNode, mapTreeValues, getNodeValue } from '~/models/tree';
+import { Tree, TreeNode, mapTreeValues, getNodeValue, getNodeDescendants } from '~/models/tree';
 import { CollectionFile, CollectionDirectory, CollectionFileType } from '~/models/collection-file';
 
 export type CollectionPanelFilesState = Tree<CollectionPanelDirectory | CollectionPanelFile>;
@@ -34,4 +34,12 @@ export const mergeCollectionPanelFilesStates = (oldState: CollectionPanelFilesSt
                 : { ...value, selected: oldValue.selected }
             : value;
     })(newState);
-}; 
+};
+
+export const filterCollectionFilesBySelection = (tree: CollectionPanelFilesState, selected: boolean) => {
+    const allFiles = getNodeDescendants('')(tree).map(node => node.value);
+
+    const selectedDirectories = allFiles.filter(file => file.selected === selected && file.type === CollectionFileType.DIRECTORY);
+    const selectedFiles = allFiles.filter(file => file.selected === selected && !selectedDirectories.some(dir => dir.id === file.path));
+    return [...selectedDirectories, ...selectedFiles];
+};
index 323ba8d8e86a844a9dca026772a9058593687aba..8c35ffa8336bd30a5eae76815ec305835c352171 100644 (file)
@@ -8,7 +8,7 @@ import { Dispatch } from "redux";
 import { RootState } from "../../store";
 import { CollectionResource } from '~/models/collection';
 import { ServiceRepository } from "~/services/services";
-import { collectionUploaderActions } from "../uploader/collection-uploader-actions";
+import { uploadCollectionFiles } from '../uploader/collection-uploader-actions';
 import { reset } from "redux-form";
 
 export const collectionCreateActions = unionize({
@@ -17,30 +17,21 @@ export const collectionCreateActions = unionize({
     CREATE_COLLECTION: ofType<{}>(),
     CREATE_COLLECTION_SUCCESS: ofType<{}>(),
 }, {
-    tag: 'type',
-    value: 'payload'
-});
+        tag: 'type',
+        value: 'payload'
+    });
+
+export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
 
 export const createCollection = (collection: Partial<CollectionResource>, files: File[]) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { ownerUuid } = getState().collections.creator;
         const collectiontData = { ownerUuid, ...collection };
         dispatch(collectionCreateActions.CREATE_COLLECTION(collectiontData));
-        return services.collectionService
-            .create(collectiontData)
-            .then(collection => {
-                dispatch(collectionUploaderActions.START_UPLOAD());
-                services.collectionService.uploadFiles(collection.uuid, files,
-                    (fileId, loaded, total, currentTime) => {
-                        dispatch(collectionUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));
-                    })
-                .then(collection => {
-                    dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection));
-                    dispatch(reset('collectionCreateDialog'));
-                    dispatch(collectionUploaderActions.CLEAR_UPLOAD());
-                });
-                return collection;
-            });
+        const newCollection = await services.collectionService.create(collectiontData);
+        await dispatch<any>(uploadCollectionFiles(newCollection.uuid));
+        dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection));
+        dispatch(reset('collectionCreateDialog'));
+        return newCollection;
     };
 
-export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
index f6b6bfa758b4c4ab5418a9f43912a4f2423b5ccf..0fa55d836cd9510d1840ffb450b9e57801a4a34b 100644 (file)
@@ -3,6 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0\r
 \r
 import { default as unionize, ofType, UnionOf } from "unionize";\r
+import { Dispatch } from 'redux';\r
+import { RootState } from '~/store/store';\r
+import { ServiceRepository } from '~/services/services';\r
+import { dialogActions } from '~/store/dialog/dialog-actions';\r
+import { loadCollectionFiles } from '../../collection-panel/collection-panel-files/collection-panel-files-actions';\r
+import { snackbarActions } from "~/store/snackbar/snackbar-actions";\r
 \r
 export interface UploadFile {\r
     id: number;\r
@@ -26,3 +32,35 @@ export const collectionUploaderActions = unionize({
 });\r
 \r
 export type CollectionUploaderAction = UnionOf<typeof collectionUploaderActions>;\r
+\r
+export const uploadCollectionFiles = (collectionUuid: string) =>\r
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {\r
+        dispatch(collectionUploaderActions.START_UPLOAD());\r
+        const files = getState().collections.uploader.map(file => file.file);\r
+        await services.collectionService.uploadFiles(collectionUuid, files, handleUploadProgress(dispatch));\r
+        dispatch(collectionUploaderActions.CLEAR_UPLOAD());\r
+    };\r
+\r
+\r
+export const uploadCurrentCollectionFiles = () =>\r
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {\r
+        const currentCollection = getState().collectionPanel.item;\r
+        if (currentCollection) {\r
+            await dispatch<any>(uploadCollectionFiles(currentCollection.uuid));\r
+            dispatch<any>(loadCollectionFiles(currentCollection.uuid));\r
+            dispatch(closeUploadCollectionFilesDialog());\r
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Data has been uploaded.', hideDuration: 2000 }));\r
+        }\r
+    };\r
+\r
+export const UPLOAD_COLLECTION_FILES_DIALOG = 'uploadCollectionFilesDialog';\r
+export const openUploadCollectionFilesDialog = () => (dispatch: Dispatch) => {\r
+    dispatch(collectionUploaderActions.CLEAR_UPLOAD());\r
+    dispatch<any>(dialogActions.OPEN_DIALOG({ id: UPLOAD_COLLECTION_FILES_DIALOG, data: {} }));\r
+};\r
+\r
+export const closeUploadCollectionFilesDialog = () => dialogActions.CLOSE_DIALOG({ id: UPLOAD_COLLECTION_FILES_DIALOG });\r
+\r
+const handleUploadProgress = (dispatch: Dispatch) => (fileId: number, loaded: number, total: number, currentTime: number) => {\r
+    dispatch(collectionUploaderActions.SET_UPLOAD_PROGRESS({ fileId, loaded, total, currentTime }));\r
+};
\ No newline at end of file
index 0bc44ba85bf06195bb34ef88500ec807c4b99e54..d26d768a0ecd089447d587a08190ef4ebe24a45f 100644 (file)
@@ -29,8 +29,8 @@ describe('data-explorer-reducer', () => {
             filters: [],
             render: jest.fn(),
             selected: true,
-            configurable: true,
-            sortDirection: SortDirection.ASC
+            sortDirection: SortDirection.ASC,
+            configurable: true
         }, {
             name: "Column 2",
             filters: [],
index 34d38fdf4ea6e2d39dc28814a2f2a1dcee5e9733..48f8ee8a1e6caac149fb07b8c94a10a46a3f2377 100644 (file)
@@ -4,11 +4,11 @@
 
 import { DialogAction, dialogActions } from "./dialog-actions";
 
-export type DialogState = Record<string, Dialog>;
+export type DialogState = Record<string, Dialog<any>>;
 
-export interface Dialog {
+export interface Dialog <T> {
     open: boolean;
-    data: any;
+    data: T;
 }
 
 export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
@@ -20,3 +20,5 @@ export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
         default: () => state,
     });
 
+export const getDialog = <T>(state: DialogState, id: string) => 
+    state[id] ? state[id] as Dialog<T> : undefined;
diff --git a/src/store/project-tree-picker/project-tree-picker-actions.ts b/src/store/project-tree-picker/project-tree-picker-actions.ts
new file mode 100644 (file)
index 0000000..86d9a18
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from "~/store/store";
+import { ServiceRepository } from "~/services/services";
+import { TreePickerId, receiveTreePickerData } from "~/views-components/project-tree-picker/project-tree-picker";
+import { mockProjectResource } from "~/models/test-utils";
+import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
+
+export const resetPickerProjectTree = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.PROJECTS}));
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.SHARED_WITH_ME}));
+    dispatch<any>(treePickerActions.RESET_TREE_PICKER({pickerId: TreePickerId.FAVORITES}));
+
+    dispatch<any>(initPickerProjectTree());
+};
+
+export const initPickerProjectTree = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const uuid = services.authService.getUuid();
+
+    dispatch<any>(getPickerTreeProjects(uuid));
+    dispatch<any>(getSharedWithMeProjectsPickerTree(uuid));
+    dispatch<any>(getFavoritesProjectsPickerTree(uuid));
+};
+
+const getPickerTreeProjects = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.PROJECTS);
+};
+
+const getSharedWithMeProjectsPickerTree = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.SHARED_WITH_ME);
+};
+
+const getFavoritesProjectsPickerTree = (uuid: string = '') => {
+    return getProjectsPickerTree(uuid, TreePickerId.FAVORITES);
+};
+
+const getProjectsPickerTree = (uuid: string, kind: string) => {
+    return receiveTreePickerData(
+        '',
+        [mockProjectResource({ uuid, name: kind })],
+        kind
+    );
+};
\ No newline at end of file
index bb60e396946a588f8d93a1c1f7e6803461ba82eb..8cd3121eecb871c8d9fa538bcfdb6983cf1322f6 100644 (file)
@@ -21,7 +21,7 @@ describe('project-reducer', () => {
                 id: "1",
                 items: [],
                 data: mockProjectResource({ uuid: "1" }),
-                status: 0
+                status: TreeItemStatus.INITIAL
             }, {
                 active: false,
                 open: false,
index e3bebe1c858f6e9a6ef7017de35b6162e72a745d..34f1303717e0097a723c4851af8e94481f4f213d 100644 (file)
@@ -3,13 +3,15 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { default as unionize, ofType, UnionOf } from "unionize";
+
 import { TreePickerNode } from "./tree-picker";
 
 export const treePickerActions = unionize({
-    LOAD_TREE_PICKER_NODE: ofType<{ id: string }>(),
-    LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreePickerNode> }>(),
-    TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string }>(),
-    TOGGLE_TREE_PICKER_NODE_SELECT: ofType<{ id: string }>()
+    LOAD_TREE_PICKER_NODE: ofType<{ nodeId: string, pickerId: string }>(),
+    LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ nodeId: string, nodes: Array<TreePickerNode>, pickerId: string }>(),
+    TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ nodeId: string, pickerId: string }>(),
+    TOGGLE_TREE_PICKER_NODE_SELECT: ofType<{ nodeId: string, pickerId: string }>(),
+    RESET_TREE_PICKER: ofType<{ pickerId: string }>()
 }, {
         tag: 'type',
         value: 'payload'
index 3248cb2efba7f06af804c0b4f6a169de02170a67..e09d12d777a485199325da74c146d76f6da375e6 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createTree, getNodeValue, getNodeChildren } from "~/models/tree";
+import { createTree, getNodeValue, getNodeChildrenIds } from "~/models/tree";
 import { TreePickerNode, createTreePickerNode } from "./tree-picker";
 import { treePickerReducer } from "./tree-picker-reducer";
 import { treePickerActions } from "./tree-picker-actions";
@@ -11,89 +11,95 @@ import { TreeItemStatus } from "~/components/tree/tree";
 describe('TreePickerReducer', () => {
     it('LOAD_TREE_PICKER_NODE - initial state', () => {
         const tree = createTree<TreePickerNode>();
-        const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1' }));
-        expect(newTree).toEqual(tree);
+        const newState = treePickerReducer({}, treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: '1', pickerId: "projects" }));
+        expect(newState).toEqual({ 'projects': tree });
     });
 
     it('LOAD_TREE_PICKER_NODE', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: '1', pickerId: "projects" })));
+
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             status: TreeItemStatus.PENDING
         });
     });
 
     it('LOAD_TREE_PICKER_NODE_SUCCESS - initial state', () => {
-        const tree = createTree<TreePickerNode>();
-        const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
-        const newTree = treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [subNode] }));
-        expect(getNodeChildren('')(newTree)).toEqual(['1.1']);
+        const subNode = createTreePickerNode({ nodeId: '1.1', value: '1.1' });
+        const newState = treePickerReducer({}, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [subNode], pickerId: "projects" }));
+        expect(getNodeChildrenIds('')(newState.projects)).toEqual(['1.1']);
     });
 
     it('LOAD_TREE_PICKER_NODE_SUCCESS', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const subNode = createTreePickerNode({ id: '1.1', value: '1.1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode] })));
-        expect(getNodeChildren('1')(newTree)).toEqual(['1.1']);
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const subNode = createTreePickerNode({ nodeId: '1.1', value: '1.1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '1', nodes: [subNode], pickerId: "projects" })));
+        expect(getNodeChildrenIds('1')(newState.projects)).toEqual(['1.1']);
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             status: TreeItemStatus.LOADED
         });
     });
 
     it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - collapsed', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })));
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             collapsed: false
         });
     });
 
     it('TOGGLE_TREE_PICKER_NODE_COLLAPSE - expanded', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId: '1', pickerId: "projects" })));
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             collapsed: true
         });
     });
 
     it('TOGGLE_TREE_PICKER_NODE_SELECT - selected', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })));
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             selected: true
         });
     });
 
     it('TOGGLE_TREE_PICKER_NODE_SELECT - not selected', () => {
-        const tree = createTree<TreePickerNode>();
-        const node = createTreePickerNode({ id: '1', value: '1' });
-        const [newTree] = [tree]
-            .map(tree => treePickerReducer(tree, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node] })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })))
-            .map(tree => treePickerReducer(tree, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id: '1' })));
-        expect(getNodeValue('1')(newTree)).toEqual({
-            ...createTreePickerNode({ id: '1', value: '1' }),
+        const node = createTreePickerNode({ nodeId: '1', value: '1' });
+        const [newState] = [{
+            projects: createTree<TreePickerNode>()
+        }]
+            .map(state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ nodeId: '', nodes: [node], pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })))
+            .map(state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '1', pickerId: "projects" })));
+        expect(getNodeValue('1')(newState.projects)).toEqual({
+            ...createTreePickerNode({ nodeId: '1', value: '1' }),
             selected: false
         });
     });
index 8d61714cc9f744929587dd7f96f613ba18120f67..e7173d22ac73832403cb486c345f41b6f106946b 100644 (file)
@@ -2,28 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createTree, setNodeValueWith, TreeNode, setNode, mapTreeValues } from "~/models/tree";
+import { createTree, setNodeValueWith, TreeNode, setNode, mapTreeValues, Tree } from "~/models/tree";
 import { TreePicker, TreePickerNode } from "./tree-picker";
 import { treePickerActions, TreePickerAction } from "./tree-picker-actions";
 import { TreeItemStatus } from "~/components/tree/tree";
+import { compose } from "redux";
 
-export const treePickerReducer = (state: TreePicker = createTree(), action: TreePickerAction) =>
+export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) =>
     treePickerActions.match(action, {
-        LOAD_TREE_PICKER_NODE: ({ id }) =>
-            setNodeValueWith(setPending)(id)(state),
-        LOAD_TREE_PICKER_NODE_SUCCESS: ({ id, nodes }) => {
-            const [newState] = [state]
-                .map(receiveNodes(nodes)(id))
-                .map(setNodeValueWith(setLoaded)(id));
-            return newState;
-        },
-        TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ id }) =>
-            setNodeValueWith(toggleCollapse)(id)(state),
-        TOGGLE_TREE_PICKER_NODE_SELECT: ({ id }) =>
-            mapTreeValues(toggleSelect(id))(state),
+        LOAD_TREE_PICKER_NODE: ({ nodeId, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, setNodeValueWith(setPending)(nodeId)),
+        LOAD_TREE_PICKER_NODE_SUCCESS: ({ nodeId, nodes, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, compose(receiveNodes(nodes)(nodeId), setNodeValueWith(setLoaded)(nodeId))),
+        TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ nodeId, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, setNodeValueWith(toggleCollapse)(nodeId)),
+        TOGGLE_TREE_PICKER_NODE_SELECT: ({ nodeId, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, mapTreeValues(toggleSelect(nodeId))),
+        RESET_TREE_PICKER: ({ pickerId }) => 
+            updateOrCreatePicker(state, pickerId, createTree),
         default: () => state
     });
 
+const updateOrCreatePicker = (state: TreePicker, pickerId: string, func: (value: Tree<TreePickerNode>) => Tree<TreePickerNode>) => {
+    const picker = state[pickerId] || createTree();
+    const updatedPicker = func(picker);
+    return { ...state, [pickerId]: updatedPicker };
+};
+
 const setPending = (value: TreePickerNode): TreePickerNode =>
     ({ ...value, status: TreeItemStatus.PENDING });
 
@@ -33,12 +38,12 @@ const setLoaded = (value: TreePickerNode): TreePickerNode =>
 const toggleCollapse = (value: TreePickerNode): TreePickerNode =>
     ({ ...value, collapsed: !value.collapsed });
 
-const toggleSelect = (id: string) => (value: TreePickerNode): TreePickerNode =>
-    value.id === id
+const toggleSelect = (nodeId: string) => (value: TreePickerNode): TreePickerNode =>
+    value.nodeId === nodeId
         ? ({ ...value, selected: !value.selected })
         : ({ ...value, selected: false });
 
-const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (state: TreePicker) =>
+const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (state: Tree<TreePickerNode>) =>
     nodes.reduce((tree, node) =>
         setNode(
             createTreeNode(parent)(node)
@@ -46,7 +51,7 @@ const receiveNodes = (nodes: Array<TreePickerNode>) => (parent: string) => (stat
 
 const createTreeNode = (parent: string) => (node: TreePickerNode): TreeNode<TreePickerNode> => ({
     children: [],
-    id: node.id,
+    id: node.nodeId,
     parent,
     value: node
 });
index e19ce3a7a48c0d162c1fb462c00ddb26d8b30c57..c815ad4f900468ed74f2bd0b33d062e5e377f219 100644 (file)
@@ -5,17 +5,17 @@
 import { Tree } from "~/models/tree";
 import { TreeItemStatus } from "~/components/tree/tree";
 
-export type TreePicker = Tree<TreePickerNode>;
+export type TreePicker = { [key: string]: Tree<TreePickerNode> };
 
 export interface TreePickerNode {
-    id: string;
+    nodeId: string;
     value: any;
     selected: boolean;
     collapsed: boolean;
     status: TreeItemStatus;
 }
 
-export const createTreePickerNode = (data: {id: string, value: any}) => ({
+export const createTreePickerNode = (data: { nodeId: string, value: any }) => ({
     ...data,
     selected: false,
     collapsed: true,
index edd07822942ace10ac40ae68e79b9222422dbe55..106c74da76f054a5dc8af4d615c4f316592fb623 100644 (file)
@@ -13,4 +13,9 @@ export const PROJECT_DESCRIPTION_VALIDATION = [maxLength(255)];
 
 export const COLLECTION_NAME_VALIDATION = [require, maxLength(255)];
 export const COLLECTION_DESCRIPTION_VALIDATION = [maxLength(255)];
-export const COLLECTION_PROJECT_VALIDATION = [require];
\ No newline at end of file
+export const COLLECTION_PROJECT_VALIDATION = [require];
+
+export const COPY_NAME_VALIDATION = [require, maxLength(255)];
+export const MAKE_A_COPY_VALIDATION = [require, maxLength(255)];
+
+export const MOVE_TO_VALIDATION = [require];
index ae9b53e33034355dcafd0f48013f9ad761c78656..3e99e10a709bca515d695c4ed734023bc229748a 100644 (file)
@@ -12,8 +12,9 @@ import { Dispatch } from "redux";
 import { collectionPanelFilesAction } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions";
 import { contextMenuActions } from "~/store/context-menu/context-menu-actions";
 import { ContextMenuKind } from "../context-menu/context-menu";
-import { Tree, getNodeChildren, getNode } from "~/models/tree";
+import { Tree, getNodeChildrenIds, getNode } from "~/models/tree";
 import { CollectionFileType } from "~/models/collection-file";
+import { openUploadCollectionFilesDialog } from '~/store/collections/uploader/collection-uploader-actions';
 
 const memoizedMapStateToProps = () => {
     let prevState: CollectionPanelFilesState;
@@ -22,7 +23,7 @@ const memoizedMapStateToProps = () => {
     return (state: RootState): Pick<CollectionPanelFilesProps, "items"> => {
         if (prevState !== state.collectionPanelFiles) {
             prevState = state.collectionPanelFiles;
-            prevTree = getNodeChildren('')(state.collectionPanelFiles)
+            prevTree = getNodeChildrenIds('')(state.collectionPanelFiles)
                 .map(collectionItemToTreeItem(state.collectionPanelFiles));
         }
         return {
@@ -32,7 +33,9 @@ const memoizedMapStateToProps = () => {
 };
 
 const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
-    onUploadDataClick: () => { return; },
+    onUploadDataClick: () => {
+        dispatch<any>(openUploadCollectionFilesDialog());
+    },
     onCollapseToggle: (id) => {
         dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
     },
@@ -77,7 +80,7 @@ const collectionItemToTreeItem = (tree: Tree<CollectionPanelDirectory | Collecti
                 type: node.value.type
             },
             id: node.id,
-            items: getNodeChildren(node.id)(tree)
+            items: getNodeChildrenIds(node.id)(tree)
                 .map(collectionItemToTreeItem(tree)),
             open: node.value.type === CollectionFileType.DIRECTORY ? !node.value.collapsed : false,
             selected: node.value.selected,
diff --git a/src/views-components/collection-partial-copy-dialog/collection-partial-copy-dialog.tsx b/src/views-components/collection-partial-copy-dialog/collection-partial-copy-dialog.tsx
new file mode 100644 (file)
index 0000000..e230470
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { compose } from "redux";
+import { reduxForm, InjectedFormProps } from 'redux-form';
+import { withDialog, WithDialogProps } from '~/store/dialog/with-dialog';
+import { CollectionPartialCopyFields } from '../form-dialog/collection-form-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { COLLECTION_PARTIAL_COPY, doCollectionPartialCopy, CollectionPartialCopyFormData } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
+
+export const CollectionPartialCopyDialog = compose(
+    withDialog(COLLECTION_PARTIAL_COPY),
+    reduxForm({
+        form: COLLECTION_PARTIAL_COPY,
+        onSubmit: (data: CollectionPartialCopyFormData, dispatch) => {
+            dispatch(doCollectionPartialCopy(data));
+        }
+    }))((props: WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyFormData>) =>
+        <FormDialog
+            dialogTitle='Create a collection'
+            formFields={CollectionPartialCopyFields}
+            submitLabel='Create a collection'
+            {...props}
+        />);
index 4561f9d308879b981c8a9a72dc32c29dc701ddcc..7d49e34c03a59078642ed2f68ebeeb3d02db016d 100644 (file)
@@ -8,6 +8,8 @@ import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RemoveIcon } from "~/components/icon/icon";
 import { openUpdater } from "~/store/collections/updater/collection-updater-action";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { openProjectCopyDialog } from "~/views-components/project-copy-dialog/project-copy-dialog";
+import { openMoveToDialog } from "../../move-to-dialog/move-to-dialog";
 
 export const collectionActionSet: ContextMenuActionSet = [[
     {
@@ -27,9 +29,7 @@ export const collectionActionSet: ContextMenuActionSet = [[
     {
         icon: MoveToIcon,
         name: "Move to",
-        execute: (dispatch, resource) => {
-            // add code
-        }
+        execute: dispatch => dispatch<any>(openMoveToDialog())
     },
     {
         component: ToggleFavoriteAction,
@@ -43,7 +43,7 @@ export const collectionActionSet: ContextMenuActionSet = [[
         icon: CopyIcon,
         name: "Copy to project",
         execute: (dispatch, resource) => {
-            // add code
+            dispatch<any>(openProjectCopyDialog({name: resource.name, projectUuid: resource.uuid}));
         }
     },
     {
index 653da011f7ed03153cfd3a360c192bf0b36d146c..965109cadcaeaae12b4add5f788416175ab8822e 100644 (file)
@@ -4,8 +4,7 @@
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { collectionPanelFilesAction, openMultipleFilesRemoveDialog } from "~/store/collection-panel/collection-panel-files/collection-panel-files-actions";
-import { createCollectionWithSelected } from "~/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected";
-
+import { openCollectionPartialCopyDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
 
 export const collectionFilesActionSet: ContextMenuActionSet = [[{
     name: "Select all",
@@ -30,6 +29,6 @@ export const collectionFilesActionSet: ContextMenuActionSet = [[{
 }, {
     name: "Create a new collection with selected",
     execute: (dispatch) => {
-        dispatch<any>(createCollectionWithSelected());
+        dispatch<any>(openCollectionPartialCopyDialog());
     }
 }]];
index a3bfa0b95cb30736ae8c995fcfa00e3deb463b0f..b55648917329723d6cec917b8e73b53725695673 100644 (file)
@@ -3,17 +3,16 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { RenameIcon, DownloadIcon, RemoveIcon } from "~/components/icon/icon";
-import { openRenameFileDialog } from "../../rename-file-dialog/rename-file-dialog";
+import { RenameIcon, RemoveIcon } from "~/components/icon/icon";
 import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
-import { openFileRemoveDialog } from "../../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
 
 
 export const collectionFilesItemActionSet: ContextMenuActionSet = [[{
     name: "Rename",
     icon: RenameIcon,
     execute: (dispatch, resource) => {
-        dispatch<any>(openRenameFileDialog(resource.name));
+        dispatch<any>(openRenameFileDialog({ name: resource.name, id: resource.uuid }));
     }
 }, {
     component: DownloadCollectionFileAction,
index 7d8364bd70ea22b4c29bb69c5c11b95699f6765b..056ea7b5128b38c8e480cabe87e5aa8d083c5f24 100644 (file)
@@ -8,6 +8,8 @@ import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, RemoveIcon } from "~/components/icon/icon";
 import { openUpdater } from "~/store/collections/updater/collection-updater-action";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { openProjectCopyDialog } from "~/views-components/project-copy-dialog/project-copy-dialog";
+import { openMoveToDialog } from "~/views-components/move-to-dialog/move-to-dialog";
 
 export const collectionResourceActionSet: ContextMenuActionSet = [[
     {
@@ -27,9 +29,7 @@ export const collectionResourceActionSet: ContextMenuActionSet = [[
     {
         icon: MoveToIcon,
         name: "Move to",
-        execute: (dispatch, resource) => {
-            // add code
-        }
+        execute: dispatch => dispatch<any>(openMoveToDialog())
     },
     {
         component: ToggleFavoriteAction,
@@ -43,8 +43,8 @@ export const collectionResourceActionSet: ContextMenuActionSet = [[
         icon: CopyIcon,
         name: "Copy to project",
         execute: (dispatch, resource) => {
-            // add code
-        }
+            dispatch<any>(openProjectCopyDialog({name: resource.name, projectUuid: resource.uuid}));
+        },
     },
     {
         icon: DetailsIcon,
index 1b000c88fcee77ec2c39a844d3476001fca725a7..e5db66db3ade1b81e96ba64f422c5631abf5b7d9 100644 (file)
@@ -6,11 +6,13 @@ import { reset, initialize } from "redux-form";
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { projectActions, PROJECT_FORM_NAME } from "~/store/project/project-action";
-import { NewProjectIcon, RenameIcon } from "~/components/icon/icon";
+import { NewProjectIcon, RenameIcon, CopyIcon, MoveToIcon } from "~/components/icon/icon";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "~/store/favorites/favorites-actions";
 import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-action";
+import { openMoveToDialog } from "../../move-to-dialog/move-to-dialog";
 import { PROJECT_CREATE_DIALOG } from "../../dialog-create/dialog-project-create";
+import { openProjectCopyDialog } from "~/views-components/project-copy-dialog/project-copy-dialog";
 
 export const projectActionSet: ContextMenuActionSet = [[
     {
@@ -36,5 +38,17 @@ export const projectActionSet: ContextMenuActionSet = [[
                 dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
             });
         }
+    },
+    {
+        icon: MoveToIcon,
+        name: "Move to",
+        execute: dispatch => dispatch<any>(openMoveToDialog())       
+    },
+    {
+        icon: CopyIcon,
+        name: "Copy to project",
+        execute: (dispatch, resource) => {
+            dispatch<any>(openProjectCopyDialog({name: resource.name, projectUuid: resource.uuid}));
+        }
     }
 ]];
index 46bc724d7704fdaa067640f8a17675774788d4c7..fb5f094b68ffad5e9c458aa60f4d1355b78a46c5 100644 (file)
@@ -7,14 +7,14 @@ import { reduxForm, reset, startSubmit, stopSubmit } from "redux-form";
 import { withDialog } from "~/store/dialog/with-dialog";
 import { dialogActions } from "~/store/dialog/dialog-actions";
 import { DialogCollectionCreateWithSelected } from "../dialog-create/dialog-collection-create-selected";
-import { loadProjectTreePickerProjects } from "../project-tree-picker/project-tree-picker";
+import { resetPickerProjectTree } from "~/store/project-tree-picker/project-tree-picker-actions";
 
 export const DIALOG_COLLECTION_CREATE_WITH_SELECTED = 'dialogCollectionCreateWithSelected';
 
 export const createCollectionWithSelected = () =>
     (dispatch: Dispatch) => {
         dispatch(reset(DIALOG_COLLECTION_CREATE_WITH_SELECTED));
-        dispatch<any>(loadProjectTreePickerProjects(''));
+        dispatch<any>(resetPickerProjectTree());
         dispatch(dialogActions.OPEN_DIALOG({ id: DIALOG_COLLECTION_CREATE_WITH_SELECTED, data: {} }));
     };
 
index c41e0b80ee1616af2b97f20d3d5c71e2311b0288..721418ef5463c5f043cae43c4a71b4b94db4242d 100644 (file)
@@ -21,7 +21,7 @@ export class CollectionDetails extends DetailsData<CollectionResource> {
         return <div>
             <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.COLLECTION)} />
             <DetailsAttribute label='Size' value='---' />
-            <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+            <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
             <DetailsAttribute label='Last modified' value={formatDate(this.item.modifiedAt)} />
             <DetailsAttribute label='Created at' value={formatDate(this.item.createdAt)} />
             {/* Links but we dont have view */}
index a298d670ee70de01e14ab58b03c9410aaccd4e69..70c026d3d3385440dad0035ac6d657b2387c1f4e 100644 (file)
@@ -21,7 +21,7 @@ import { EmptyDetails } from "./empty-details";
 import { DetailsData } from "./details-data";
 import { DetailsResource } from "~/models/details";
 
-type CssRules = 'drawerPaper' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'tabContainer';
+type CssRules = 'drawerPaper' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'headerTitle' | 'tabContainer';
 
 const drawerWidth = 320;
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
@@ -45,7 +45,10 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         textAlign: 'center'
     },
     headerIcon: {
-        fontSize: "34px"
+        fontSize: '2.125rem'
+    },
+    headerTitle: {
+        wordBreak: 'break-all'
     },
     tabContainer: {
         padding: theme.spacing.unit * 3
@@ -114,7 +117,7 @@ export const DetailsPanel = withStyles(styles)(
                                         {item.getIcon(classes.headerIcon)}
                                     </Grid>
                                     <Grid item xs={8}>
-                                        <Typography variant="title">
+                                        <Typography variant="title" className={classes.headerTitle}>
                                             {item.getTitle()}
                                         </Typography>
                                     </Grid>
index dee6e8b0b9a20db979ca64cddda58b268c2535d1..cec01b966d91ecd5eb26dcda42779c579915dc49 100644 (file)
@@ -21,7 +21,7 @@ export class ProcessDetails extends DetailsData<ProcessResource> {
         return <div>
             <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROCESS)} />
             <DetailsAttribute label='Size' value='---' />
-            <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+            <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
 
             {/* Missing attr */}
             <DetailsAttribute label='Status' value={this.item.state} />
index 154f0a2c906e908660d05d5d437c65ad7890911a..1e65ec834bf8e48f75230bea8505d843eda82a63 100644 (file)
@@ -22,7 +22,7 @@ export class ProjectDetails extends DetailsData<ProjectResource> {
             <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROJECT)} />
             {/* Missing attr */}
             <DetailsAttribute label='Size' value='---' />
-            <DetailsAttribute label='Owner' value={this.item.ownerUuid} />
+            <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
             <DetailsAttribute label='Last modified' value={formatDate(this.item.modifiedAt)} />
             <DetailsAttribute label='Created at' value={formatDate(this.item.createdAt)} />
             {/* Missing attr */}
index af2536df9512a66b89a1d013a5345c6b579bb690..ad684d780b008a274ce9a99de8f44f13d267da6c 100644 (file)
@@ -48,9 +48,7 @@ export const DialogCollectionCreateWithSelected = (props: WithDialogProps<string
                     type='submit'
                     onClick={props.handleSubmit}
                     disabled={props.pristine || props.invalid || props.submitting}>
-                    {props.submitting
-                        ? <CircularProgress size={20} />
-                        : 'Create a collection'}
+                    {props.submitting ? <CircularProgress size={20} /> : 'Create a collection'}
                 </Button>
             </DialogActions>
         </Dialog>
diff --git a/src/views-components/form-dialog/collection-form-dialog.tsx b/src/views-components/form-dialog/collection-form-dialog.tsx
new file mode 100644 (file)
index 0000000..d5f1d85
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field, WrappedFieldProps } from "redux-form";
+import { TextField } from "~/components/text-field/text-field";
+import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION, COLLECTION_PROJECT_VALIDATION } from "~/validators/validators";
+import { ProjectTreePicker } from "../project-tree-picker/project-tree-picker";
+
+export const CollectionPartialCopyFields = () => <div style={{ display: 'flex' }}>
+    <div>
+        <CollectionNameField />
+        <CollectionDescriptionField />
+    </div>
+    <CollectionProjectPickerField />
+</div>;
+
+export const CollectionNameField = () =>
+    <Field
+        name='name'
+        component={TextField}
+        validate={COLLECTION_NAME_VALIDATION}
+        label="Collection Name" />;
+
+export const CollectionDescriptionField = () =>
+    <Field
+        name='description'
+        component={TextField}
+        validate={COLLECTION_DESCRIPTION_VALIDATION}
+        label="Description - optional" />;
+
+export const CollectionProjectPickerField = () =>
+    <Field
+        name="projectUuid"
+        component={ProjectPicker}
+        validate={COLLECTION_PROJECT_VALIDATION} />;
+
+const ProjectPicker = (props: WrappedFieldProps) =>
+    <div style={{ width: '400px', height: '144px', display: 'flex', flexDirection: 'column' }}>
+        <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} />
+    </div>;
diff --git a/src/views-components/move-to-dialog/move-to-dialog.tsx b/src/views-components/move-to-dialog/move-to-dialog.tsx
new file mode 100644 (file)
index 0000000..dbc402b
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, compose } from "redux";
+import { withDialog } from "../../store/dialog/with-dialog";
+import { dialogActions } from "../../store/dialog/dialog-actions";
+import { MoveToDialog } from "../../components/move-to-dialog/move-to-dialog";
+import { reduxForm, startSubmit, stopSubmit } from "redux-form";
+import { resetPickerProjectTree } from "~/store/project-tree-picker/project-tree-picker-actions";
+
+export const MOVE_TO_DIALOG = 'moveToDialog';
+
+export const openMoveToDialog = () =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(resetPickerProjectTree());
+        dispatch(dialogActions.OPEN_DIALOG({ id: MOVE_TO_DIALOG, data: {} }));
+    };
+
+export const MoveToProjectDialog = compose(
+    withDialog(MOVE_TO_DIALOG),
+    reduxForm({
+        form: MOVE_TO_DIALOG,
+        onSubmit: (data, dispatch) => {
+            dispatch(startSubmit(MOVE_TO_DIALOG));
+            setTimeout(() => dispatch(stopSubmit(MOVE_TO_DIALOG, { name: 'Invalid path' })), 2000);
+        }
+    })
+)(MoveToDialog);
diff --git a/src/views-components/project-copy-dialog/project-copy-dialog.tsx b/src/views-components/project-copy-dialog/project-copy-dialog.tsx
new file mode 100644 (file)
index 0000000..dedf507
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { Dispatch, compose } from "redux";
+import { withDialog } from "~/store/dialog/with-dialog";
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { ProjectCopy, CopyFormData } from "~/components/project-copy/project-copy";
+import { reduxForm, startSubmit, stopSubmit, initialize } from 'redux-form';
+import { resetPickerProjectTree } from "~/store/project-tree-picker/project-tree-picker-actions";
+
+export const PROJECT_COPY_DIALOG = 'projectCopy';
+export const openProjectCopyDialog = (data: { projectUuid: string, name: string }) =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(resetPickerProjectTree());
+        const initialData: CopyFormData = { name: `Copy of: ${data.name}`, projectUuid: '', uuid: data.projectUuid };
+        dispatch<any>(initialize(PROJECT_COPY_DIALOG, initialData));
+        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_COPY_DIALOG, data: {} }));
+    };
+
+export const ProjectCopyDialog = compose(
+    withDialog(PROJECT_COPY_DIALOG),
+    reduxForm({
+        form: PROJECT_COPY_DIALOG,
+        onSubmit: (data, dispatch) => {
+            dispatch(startSubmit(PROJECT_COPY_DIALOG));
+            setTimeout(() => dispatch(stopSubmit(PROJECT_COPY_DIALOG, { name: 'Invalid path' })), 2000);
+        }
+    })
+)(ProjectCopy);
\ No newline at end of file
index 9143c47a2da5efe25b4d983015946706a8487c7f..cc27806bbe8e4bf4c19f428567355e3ad51bf414 100644 (file)
@@ -6,47 +6,76 @@ import * as React from "react";
 import { Dispatch } from "redux";
 import { connect } from "react-redux";
 import { Typography } from "@material-ui/core";
-import { TreePicker } from "../tree-picker/tree-picker";
-import { TreeProps, TreeItem, TreeItemStatus } from "~/components/tree/tree";
+import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
+import { TreeItem, TreeItemStatus } from "~/components/tree/tree";
 import { ProjectResource } from "~/models/project";
 import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
 import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon";
-import { ProjectIcon } from "~/components/icon/icon";
+import { ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon } from "~/components/icon/icon";
 import { createTreePickerNode } from "~/store/tree-picker/tree-picker";
 import { RootState } from "~/store/store";
 import { ServiceRepository } from "~/services/services";
 import { FilterBuilder } from "~/common/api/filter-builder";
 
-type ProjectTreePickerProps = Pick<TreeProps<ProjectResource>, 'toggleItemActive' | 'toggleItemOpen'>;
+type ProjectTreePickerProps = Pick<TreePickerProps, 'toggleItemActive' | 'toggleItemOpen'>;
 
-const mapDispatchToProps = (dispatch: Dispatch, props: {onChange: (projectUuid: string) => void}): ProjectTreePickerProps => ({
-    toggleItemActive: id => {
-        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ id }));
-        props.onChange(id);
+const mapDispatchToProps = (dispatch: Dispatch, props: { onChange: (projectUuid: string) => void }): ProjectTreePickerProps => ({
+    toggleItemActive: (nodeId, status, pickerId) => {
+        getNotSelectedTreePickerKind(pickerId)
+            .forEach(pickerId => dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId: '', pickerId })));
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECT({ nodeId, pickerId }));
+
+        props.onChange(nodeId);
     },
-    toggleItemOpen: (id, status) => {
-        status === TreeItemStatus.INITIAL
-            ? dispatch<any>(loadProjectTreePickerProjects(id))
-            : dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id }));
+    toggleItemOpen: (nodeId, status, pickerId) => {
+        dispatch<any>(toggleItemOpen(nodeId, status, pickerId));
     }
 });
 
+const toggleItemOpen = (nodeId: string, status: TreeItemStatus, pickerId: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        if (status === TreeItemStatus.INITIAL) {
+            if (pickerId === TreePickerId.PROJECTS) {
+                dispatch<any>(loadProjectTreePickerProjects(nodeId));
+            } else if (pickerId === TreePickerId.FAVORITES) {
+                dispatch<any>(loadFavoriteTreePickerProjects(nodeId === services.authService.getUuid() ? '' : nodeId));
+            } else {
+                // TODO: load sharedWithMe
+            }
+        } else {
+            dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId }));
+        }
+    };
+
+const getNotSelectedTreePickerKind = (pickerId: string) => {
+    return [TreePickerId.PROJECTS, TreePickerId.FAVORITES, TreePickerId.SHARED_WITH_ME].filter(nodeId => nodeId !== pickerId);
+};
+
+export enum TreePickerId {
+    PROJECTS = 'Projects',
+    SHARED_WITH_ME = 'Shared with me',
+    FAVORITES = 'Favorites'
+}
+
 export const ProjectTreePicker = connect(undefined, mapDispatchToProps)((props: ProjectTreePickerProps) =>
-    <div style={{display: 'flex', flexDirection: 'column'}}>
-        <Typography variant='caption' style={{flexShrink: 0}}>
+    <div style={{ display: 'flex', flexDirection: 'column' }}>
+        <Typography variant='caption' style={{ flexShrink: 0 }}>
             Select a project
         </Typography>
-        <div style={{flexGrow: 1, overflow: 'auto'}}>
-            <TreePicker {...props} render={renderTreeItem} />
+        <div style={{ flexGrow: 1, overflow: 'auto' }}>
+            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.PROJECTS} />
+            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.SHARED_WITH_ME} />
+            <TreePicker {...props} render={renderTreeItem} pickerId={TreePickerId.FAVORITES} />
         </div>
     </div>);
 
+
 // TODO: move action creator to store directory
-export const loadProjectTreePickerProjects = (id: string) =>
+export const loadProjectTreePickerProjects = (nodeId: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id }));
+        dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId, pickerId: TreePickerId.PROJECTS }));
 
-        const ownerUuid = id.length === 0 ? services.authService.getUuid() || '' : id;
+        const ownerUuid = nodeId.length === 0 ? services.authService.getUuid() || '' : nodeId;
 
         const filters = new FilterBuilder()
             .addEqual('ownerUuid', ownerUuid)
@@ -54,22 +83,62 @@ export const loadProjectTreePickerProjects = (id: string) =>
 
         const { items } = await services.projectService.list({ filters });
 
-        dispatch<any>(receiveProjectTreePickerData(id, items));
+        dispatch<any>(receiveTreePickerData(nodeId, items, TreePickerId.PROJECTS));
+    };
+
+export const loadFavoriteTreePickerProjects = (nodeId: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const parentId = services.authService.getUuid() || '';
+
+        if (nodeId === '') {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId: parentId, pickerId: TreePickerId.FAVORITES }));
+            const { items } = await services.favoriteService.list(parentId);
+
+            dispatch<any>(receiveTreePickerData(parentId, items as ProjectResource[], TreePickerId.FAVORITES));
+        } else {
+            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ nodeId, pickerId: TreePickerId.FAVORITES }));
+            const filters = new FilterBuilder()
+                .addEqual('ownerUuid', nodeId)
+                .getFilters();
+
+            const { items } = await services.projectService.list({ filters });
+
+            dispatch<any>(receiveTreePickerData(nodeId, items, TreePickerId.FAVORITES));
+        }
+
     };
 
+const getProjectPickerIcon = (item: TreeItem<ProjectResource>) => {
+    switch (item.data.name) {
+        case TreePickerId.FAVORITES:
+            return FavoriteIcon;
+        case TreePickerId.PROJECTS:
+            return ProjectsIcon;
+        case TreePickerId.SHARED_WITH_ME:
+            return ShareMeIcon;
+        default:
+            return ProjectIcon;
+    }
+};
+
 const renderTreeItem = (item: TreeItem<ProjectResource>) =>
     <ListItemTextIcon
-        icon={ProjectIcon}
+        icon={getProjectPickerIcon(item)}
         name={item.data.name}
         isActive={item.active}
         hasMargin={true} />;
 
+
 // TODO: move action creator to store directory
-const receiveProjectTreePickerData = (id: string, projects: ProjectResource[]) =>
+export const receiveTreePickerData = (nodeId: string, projects: ProjectResource[], pickerId: string) =>
     (dispatch: Dispatch) => {
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
-            id,
-            nodes: projects.map(project => createTreePickerNode({ id: project.uuid, value: project }))
+            nodeId,
+            nodes: projects.map(project => createTreePickerNode({ nodeId: project.uuid, value: project })),
+            pickerId,
         }));
-        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id }));
+
+        dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ nodeId, pickerId }));
     };
+
+
index 140119e17e6849ad57d397fa02f362b2f1d7111f..18efdaf88d6d235d66a9547ec9fd6afe18805b9d 100644 (file)
@@ -11,9 +11,9 @@ import { Collapse } from '@material-ui/core';
 import CircularProgress from '@material-ui/core/CircularProgress';
 
 import { ProjectTree } from './project-tree';
-import { TreeItem } from '~/components/tree/tree';
-import { ProjectResource } from '~/models/project';
-import { mockProjectResource } from '~/models/test-utils';
+import { TreeItem, TreeItemStatus } from '../../components/tree/tree';
+import { ProjectResource } from '../../models/project';
+import { mockProjectResource } from '../../models/test-utils';
 
 Enzyme.configure({ adapter: new Adapter() });
 
@@ -25,7 +25,7 @@ describe("ProjectTree component", () => {
             id: "3",
             open: true,
             active: true,
-            status: 1
+            status: TreeItemStatus.PENDING
         };
         const wrapper = mount(<ProjectTree
             projects={[project]}
@@ -43,14 +43,14 @@ describe("ProjectTree component", () => {
                 id: "3",
                 open: true,
                 active: true,
-                status: 2,
+                status: TreeItemStatus.LOADED,
                 items: [
                     {
                         data: mockProjectResource(),
                         id: "3",
                         open: true,
                         active: true,
-                        status: 1
+                        status: TreeItemStatus.PENDING
                     }
                 ]
             }
@@ -70,7 +70,7 @@ describe("ProjectTree component", () => {
             id: "3",
             open: false,
             active: true,
-            status: 1
+            status: TreeItemStatus.PENDING
         };
         const wrapper = mount(<ProjectTree
             projects={[project]}
index 37028f9db5adc5b4cffe7cfffa25b1d969a7fcee..20116fcda747c0f322223b48fc6d83b67a024762 100644 (file)
@@ -2,27 +2,37 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from "redux";
-import { reduxForm, reset, startSubmit, stopSubmit } from "redux-form";
-import { withDialog } from "~/store/dialog/with-dialog";
-import { dialogActions } from "~/store/dialog/dialog-actions";
-import { RenameDialog } from "~/components/rename-dialog/rename-dialog";
+import * as React from 'react';
+import { compose } from 'redux';
+import { reduxForm, reset, startSubmit, stopSubmit, InjectedFormProps, Field } from 'redux-form';
+import { withDialog, WithDialogProps } from '~/store/dialog/with-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { DialogContentText } from '@material-ui/core';
+import { TextField } from '~/components/text-field/text-field';
+import { RENAME_FILE_DIALOG, RenameFileDialogData, renameFile } from '~/store/collection-panel/collection-panel-files/collection-panel-files-actions';
 
-export const RENAME_FILE_DIALOG = 'renameFileDialog';
-
-export const openRenameFileDialog = (originalName: string) =>
-    (dispatch: Dispatch) => {
-        dispatch(reset(RENAME_FILE_DIALOG));
-        dispatch(dialogActions.OPEN_DIALOG({ id: RENAME_FILE_DIALOG, data: originalName }));
-    };
-
-export const [RenameFileDialog] = [RenameDialog]
-    .map(withDialog(RENAME_FILE_DIALOG))
-    .map(reduxForm({
+export const RenameFileDialog = compose(
+    withDialog(RENAME_FILE_DIALOG),
+    reduxForm({
         form: RENAME_FILE_DIALOG,
-        onSubmit: (data, dispatch) => {
-            dispatch(startSubmit(RENAME_FILE_DIALOG));
-            // TODO: call collection file renaming action here
-            setTimeout(() => dispatch(stopSubmit(RENAME_FILE_DIALOG, { name: 'Invalid name' })), 2000);
+        onSubmit: (data: { name: string }, dispatch) => {
+            dispatch<any>(renameFile(data.name));
         }
-    }));
+    })
+)((props: WithDialogProps<RenameFileDialogData> & InjectedFormProps<{ name: string }>) =>
+    <FormDialog
+        dialogTitle='Rename'
+        formFields={RenameDialogFormFields}
+        submitLabel='Ok'
+        {...props}
+    />);
+
+const RenameDialogFormFields = (props: WithDialogProps<RenameFileDialogData>) => <>
+    <DialogContentText>
+        {`Please, enter a new name for ${props.data.name}`}
+    </DialogContentText>
+    <Field
+        name='name'
+        component={TextField}
+    />
+</>;
index 09a07443f26b9097ddcccfa9ded1b95d2dea5d85..b90f2e420656dd1365b37869c65779830726a589 100644 (file)
@@ -3,42 +3,43 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { connect } from "react-redux";
-import { Tree, TreeProps, TreeItem } from "~/components/tree/tree";
+import { Tree, TreeProps, TreeItem, TreeItemStatus } from "~/components/tree/tree";
 import { RootState } from "~/store/store";
-import { TreePicker as TTreePicker, TreePickerNode, createTreePickerNode } from "~/store/tree-picker/tree-picker";
-import { getNodeValue, getNodeChildren } from "~/models/tree";
+import { createTreePickerNode, TreePickerNode } from "~/store/tree-picker/tree-picker";
+import { getNodeValue, getNodeChildrenIds, Tree as Ttree, createTree } from "~/models/tree";
+import { Dispatch } from "redux";
 
-const memoizedMapStateToProps = () => {
-    let prevState: TTreePicker;
-    let prevTree: Array<TreeItem<any>>;
+export interface TreePickerProps {
+    pickerId: string;
+    toggleItemOpen: (nodeId: string, status: TreeItemStatus, pickerId: string) => void;
+    toggleItemActive: (nodeId: string, status: TreeItemStatus, pickerId: string) => void;
+}
 
-    return (state: RootState): Pick<TreeProps<any>, 'items'> => {
-        if (prevState !== state.treePicker) {
-            prevState = state.treePicker;
-            prevTree = getNodeChildren('')(state.treePicker)
-                .map(treePickerToTreeItems(state.treePicker));
-        }
-        return {
-            items: prevTree
-        };
+const mapStateToProps = (state: RootState, props: TreePickerProps): Pick<TreeProps<any>, 'items'> => {
+    const tree = state.treePicker[props.pickerId] || createTree();
+    return {
+        items: getNodeChildrenIds('')(tree)
+            .map(treePickerToTreeItems(tree))
     };
 };
 
-const mapDispatchToProps = (): Pick<TreeProps<any>, 'onContextMenu'> => ({
+const mapDispatchToProps = (dispatch: Dispatch, props: TreePickerProps): Pick<TreeProps<any>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive'> => ({
     onContextMenu: () => { return; },
+    toggleItemActive: (id, status) => props.toggleItemActive(id, status, props.pickerId),
+    toggleItemOpen: (id, status) => props.toggleItemOpen(id, status, props.pickerId)
 });
 
-export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)(Tree);
+export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree);
 
-const treePickerToTreeItems = (tree: TTreePicker) =>
+const treePickerToTreeItems = (tree: Ttree<TreePickerNode>) =>
     (id: string): TreeItem<any> => {
-        const node: TreePickerNode = getNodeValue(id)(tree) || createTreePickerNode({ id: '', value: 'InvalidNode' });
-        const items = getNodeChildren(node.id)(tree)
+        const node: TreePickerNode = getNodeValue(id)(tree) || createTreePickerNode({ nodeId: '', value: 'InvalidNode' });
+        const items = getNodeChildrenIds(node.nodeId)(tree)
             .map(treePickerToTreeItems(tree));
         return {
             active: node.selected,
             data: node.value,
-            id: node.id,
+            id: node.nodeId,
             items: items.length > 0 ? items : undefined,
             open: !node.collapsed,
             status: node.status
diff --git a/src/views-components/upload-collection-files-dialog/upload-collection-files-dialog.ts b/src/views-components/upload-collection-files-dialog/upload-collection-files-dialog.ts
new file mode 100644 (file)
index 0000000..1f3a50e
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Dispatch, compose } from "redux";
+import { withDialog } from '~/store/dialog/with-dialog';
+import { FilesUploadDialog } from '~/components/file-upload-dialog/file-upload-dialog';
+import { RootState } from '../../store/store';
+import { uploadCurrentCollectionFiles, UPLOAD_COLLECTION_FILES_DIALOG, collectionUploaderActions } from '~/store/collections/uploader/collection-uploader-actions';
+
+const mapStateToProps = (state: RootState) => ({
+    files: state.collections.uploader,
+    uploading: state.collections.uploader.some(file => file.loaded < file.total)
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    onSubmit: () => {
+        dispatch<any>(uploadCurrentCollectionFiles());
+    },
+    onChange: (files: File[]) => {
+        dispatch(collectionUploaderActions.SET_UPLOAD_FILES(files));
+    }
+});
+
+export const UploadCollectionFilesDialog = compose(
+    withDialog(UPLOAD_COLLECTION_FILES_DIALOG),
+    connect(mapStateToProps, mapDispatchToProps)
+)(FilesUploadDialog);
\ No newline at end of file
index 374cb95159483f5d4896fcbd539fddfc61931ea3..559d4a9a86072ee3bc0f0750b94166bf72017d43 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import {
     StyleRulesCallback, WithStyles, withStyles, Card,
-    CardHeader, IconButton, CardContent, Grid, Chip
+    CardHeader, IconButton, CardContent, Grid, Chip, Tooltip
 } from '@material-ui/core';
 import { connect, DispatchProp } from "react-redux";
 import { RouteComponentProps } from 'react-router';
@@ -19,8 +19,9 @@ 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';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
 
-type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'value';
+type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon' | 'label' | 'value';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
@@ -40,8 +41,12 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         color: theme.palette.grey["500"],
         cursor: 'pointer'
     },
+    label: {
+        fontSize: '0.875rem'
+    },
     value: {
-        textTransform: 'none'
+        textTransform: 'none',
+        fontSize: '0.875rem'
     }
 });
 
@@ -84,16 +89,21 @@ export const CollectionPanel = withStyles(styles)(
                             <CardContent>
                                 <Grid container direction="column">
                                     <Grid item xs={6}>
-                                    <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 classValue={classes.value} label='Owner' value={item && item.ownerUuid} />
+                                        <DetailsAttribute classLabel={classes.label} classValue={classes.value}
+                                                label='Collection UUID'
+                                                value={item && item.uuid}>
+                                            <Tooltip title="Copy uuid">
+                                                <CopyToClipboard text={item && item.uuid} onCopy={() => this.onCopy() }>
+                                                    <CopyIcon className={classes.copyIcon} />
+                                                </CopyToClipboard>
+                                            </Tooltip>
+                                        </DetailsAttribute>
+                                        <DetailsAttribute classLabel={classes.label} classValue={classes.value} 
+                                            label='Number of files' value='14' />
+                                        <DetailsAttribute classLabel={classes.label} classValue={classes.value} 
+                                            label='Content size' value='54 MB' />
+                                        <DetailsAttribute classLabel={classes.label} classValue={classes.value} 
+                                            label='Owner' value={item && item.ownerUuid} />
                                     </Grid>
                                 </Grid>
                             </CardContent>
@@ -126,6 +136,13 @@ export const CollectionPanel = withStyles(styles)(
                 this.props.dispatch<any>(deleteCollectionTag(uuid));
             }
 
+            onCopy = () => {
+                this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: "Uuid has been copied",
+                    hideDuration: 2000
+                }));
+            }
+
             componentWillReceiveProps({ match, item, onItemRouteChange }: CollectionPanelProps) {
                 if (!item || match.params.id !== item.uuid) {
                     onItemRouteChange(match.params.id);
index a2d61d5cd17a209351fa5bf3f228479df5087092..f5f62a284553e44e2f48a6ce1e41636b9348edee 100644 (file)
@@ -47,8 +47,12 @@ import { RenameFileDialog } from '~/views-components/rename-file-dialog/rename-f
 import { FileRemoveDialog } from '~/views-components/file-remove-dialog/file-remove-dialog';
 import { MultipleFilesRemoveDialog } from '~/views-components/file-remove-dialog/multiple-files-remove-dialog';
 import { DialogCollectionCreateWithSelectedFile } from '~/views-components/create-collection-dialog-with-selected/create-collection-dialog-with-selected';
+import { MoveToProjectDialog } from '../../views-components/move-to-dialog/move-to-dialog';
 import { COLLECTION_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-collection-create';
 import { PROJECT_CREATE_DIALOG } from '~/views-components/dialog-create/dialog-project-create';
+import { UploadCollectionFilesDialog } from '~/views-components/upload-collection-files-dialog/upload-collection-files-dialog';
+import { ProjectCopyDialog } from '~/views-components/project-copy-dialog/project-copy-dialog';
+import { CollectionPartialCopyDialog } from '../../views-components/collection-partial-copy-dialog/collection-partial-copy-dialog';
 
 const DRAWER_WITDH = 240;
 const APP_BAR_HEIGHT = 100;
@@ -229,7 +233,7 @@ export const Workbench = withStyles(styles)(
                         <main className={classes.contentWrapper}>
                             <div className={classes.content}>
                                 <Switch>
-                                    <Route path='/' exact render={() => <Redirect to={`/projects/${this.props.authService.getUuid()}`}  />} />
+                                    <Route path='/' exact render={() => <Redirect to={`/projects/${this.props.authService.getUuid()}`} />} />
                                     <Route path="/projects/:id" render={this.renderProjectPanel} />
                                     <Route path="/favorites" render={this.renderFavoritePanel} />
                                     <Route path="/collections/:id" render={this.renderCollectionPanel} />
@@ -242,10 +246,14 @@ export const Workbench = withStyles(styles)(
                         <CreateProjectDialog />
                         <CreateCollectionDialog />
                         <RenameFileDialog />
+                        <CollectionPartialCopyDialog />
+                        <MoveToProjectDialog />
                         <DialogCollectionCreateWithSelectedFile />
                         <FileRemoveDialog />
+                        <ProjectCopyDialog />
                         <MultipleFilesRemoveDialog />
                         <UpdateCollectionDialog />
+                        <UploadCollectionFilesDialog />
                         <UpdateProjectDialog />
                         <CurrentTokenDialog
                             currentToken={this.props.currentToken}