refs #master Merge branch 'origin/master' into 13856-upload-component
authorDaniel Kos <daniel.kos@contractors.roche.com>
Sat, 4 Aug 2018 11:57:00 +0000 (13:57 +0200)
committerDaniel Kos <daniel.kos@contractors.roche.com>
Sat, 4 Aug 2018 11:57:18 +0000 (13:57 +0200)
Arvados-DCO-1.1-Signed-off-by: Daniel Kos <daniel.kos@contractors.roche.com>

99 files changed:
package.json
src/common/api/common-resource-service.ts
src/common/api/server-api.ts [deleted file]
src/common/custom-theme.ts
src/components/collection-panel-files/collection-panel-files.tsx [new file with mode: 0644]
src/components/column-selector/column-selector.test.tsx
src/components/column-selector/column-selector.tsx
src/components/data-explorer/data-explorer.tsx
src/components/data-table/data-column.ts
src/components/file-tree/file-tree-data.ts [new file with mode: 0644]
src/components/file-tree/file-tree-item.tsx [new file with mode: 0644]
src/components/file-tree/file-tree.tsx [new file with mode: 0644]
src/components/icon/icon.tsx
src/components/list-item-text-icon/list-item-text-icon.tsx
src/components/side-panel/side-panel.tsx
src/components/tree/tree.test.tsx
src/components/tree/tree.tsx
src/index.tsx
src/models/collection-file.ts [new file with mode: 0644]
src/models/collection.ts
src/models/keep-manifest.ts [new file with mode: 0644]
src/models/project.ts
src/models/tree.test.ts [new file with mode: 0644]
src/models/tree.ts [new file with mode: 0644]
src/services/auth-service/auth-service.ts
src/services/collection-files-service/collection-files-service.ts [new file with mode: 0644]
src/services/collection-files-service/collection-manifest-mapper.test.ts [new file with mode: 0644]
src/services/collection-files-service/collection-manifest-mapper.ts [new file with mode: 0644]
src/services/collection-files-service/collection-manifest-parser.test.ts [new file with mode: 0644]
src/services/collection-files-service/collection-manifest-parser.ts [new file with mode: 0644]
src/services/collection-service/collection-service.ts [new file with mode: 0644]
src/services/services.ts
src/store/auth/auth-action.ts
src/store/auth/auth-actions.test.ts [new file with mode: 0644]
src/store/auth/auth-reducer.test.ts
src/store/auth/auth-reducer.ts
src/store/collection-panel/collection-panel-action.ts [new file with mode: 0644]
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts [new file with mode: 0644]
src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.test.ts [new file with mode: 0644]
src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts [new file with mode: 0644]
src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts [new file with mode: 0644]
src/store/collection-panel/collection-panel-reducer.ts [new file with mode: 0644]
src/store/collections/collections-reducer.ts [new file with mode: 0644]
src/store/collections/creator/collection-creator-action.ts [new file with mode: 0644]
src/store/collections/creator/collection-creator-reducer.test.ts [new file with mode: 0644]
src/store/collections/creator/collection-creator-reducer.ts [new file with mode: 0644]
src/store/collections/updator/collection-updator-action.ts [new file with mode: 0644]
src/store/collections/updator/collection-updator-reducer.ts [new file with mode: 0644]
src/store/context-menu/context-menu-reducer.ts
src/store/data-explorer/data-explorer-action.ts
src/store/data-explorer/data-explorer-middleware-service.ts [new file with mode: 0644]
src/store/data-explorer/data-explorer-middleware.test.ts [new file with mode: 0644]
src/store/data-explorer/data-explorer-middleware.ts [new file with mode: 0644]
src/store/data-explorer/data-explorer-reducer.ts
src/store/details-panel/details-panel-action.ts
src/store/dialog/dialog-actions.ts [new file with mode: 0644]
src/store/dialog/dialog-reducer.test.ts [new file with mode: 0644]
src/store/dialog/dialog-reducer.ts [new file with mode: 0644]
src/store/dialog/with-dialog.ts [new file with mode: 0644]
src/store/favorite-panel/favorite-panel-action.ts [new file with mode: 0644]
src/store/favorite-panel/favorite-panel-middleware-service.ts [new file with mode: 0644]
src/store/favorite-panel/favorite-panel-middleware.ts [deleted file]
src/store/favorites/favorites-actions.ts
src/store/navigation/navigation-action.ts
src/store/project-panel/project-panel-action.ts [new file with mode: 0644]
src/store/project-panel/project-panel-middleware-service.ts [new file with mode: 0644]
src/store/project-panel/project-panel-middleware.ts [deleted file]
src/store/project/project-action.ts
src/store/project/project-reducer.test.ts
src/store/project/project-reducer.ts
src/store/side-panel/side-panel-reducer.ts
src/store/store.ts
src/validators/create-project/create-project-validator.tsx
src/views-components/api-token/api-token.tsx
src/views-components/collection-panel-files/collection-panel-files.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/collection-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/collection-files-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/collection-files-item-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/favorite-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/action-sets/resource-action-set.ts
src/views-components/context-menu/actions/favorite-action.tsx [moved from src/views-components/context-menu/action-sets/favorite-action.tsx with 79% similarity]
src/views-components/context-menu/context-menu.tsx
src/views-components/create-collection-dialog/create-collection-dialog.tsx [new file with mode: 0644]
src/views-components/create-project-dialog/create-project-dialog.tsx
src/views-components/data-explorer/data-explorer.tsx
src/views-components/data-explorer/renderers.tsx [new file with mode: 0644]
src/views-components/dialog-create/dialog-collection-create.tsx [new file with mode: 0644]
src/views-components/dialog-create/dialog-project-create.tsx
src/views-components/dialog-update/dialog-collection-update.tsx [new file with mode: 0644]
src/views-components/remove-dialog/remove-dialog.tsx [new file with mode: 0644]
src/views-components/rename-dialog/rename-dialog.tsx [new file with mode: 0644]
src/views-components/update-collection-dialog/update-collection-dialog..tsx [new file with mode: 0644]
src/views/collection-panel/collection-panel.tsx [new file with mode: 0644]
src/views/favorite-panel/favorite-panel.tsx
src/views/project-panel/project-panel.tsx
src/views/workbench/workbench.test.tsx
src/views/workbench/workbench.tsx
yarn.lock

index fa4bd309df7ad1b6c873128d70192e0c9b97fc88..f940e54ee3e4b138ee7000daf58eb41ca5e38d67 100644 (file)
@@ -3,15 +3,17 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
-    "@material-ui/core": "1.4.0",
-    "@material-ui/icons": "1.1.0",
-    "@types/lodash": "4.14.112",
-    "@types/redux-form": "7.4.1",
+    "@material-ui/core": "1.4.2",
+    "@material-ui/icons": "2.0.0",
+    "@types/lodash": "4.14.116",
+    "@types/react-copy-to-clipboard": "4.2.5",
+    "@types/redux-form": "7.4.4",
     "axios": "0.18.0",
     "classnames": "2.2.6",
     "lodash": "4.17.10",
-    "react": "16.4.1",
-    "react-dom": "16.4.1",
+    "react": "16.4.2",
+    "react-copy-to-clipboard": "5.0.1",
+    "react-dom": "16.4.2",
     "react-redux": "5.0.7",
     "react-router": "4.3.1",
     "react-router-dom": "4.3.1",
     "@types/classnames": "^2.2.4",
     "@types/enzyme": "3.1.12",
     "@types/enzyme-adapter-react-16": "1.0.2",
-    "@types/jest": "23.3.0",
-    "@types/node": "10.5.2",
+    "@types/jest": "23.3.1",
+    "@types/node": "10.5.5",
     "@types/react": "16.4",
     "@types/react-dom": "16.0.6",
-    "@types/react-redux": "6.0.4",
+    "@types/react-redux": "6.0.6",
     "@types/react-router": "4.0.29",
-    "@types/react-router-dom": "4.2.7",
+    "@types/react-router-dom": "4.3.0",
     "@types/react-router-redux": "5.0.15",
     "@types/redux-devtools": "3.0.44",
-    "@types/redux-form": "7.4.1",
+    "@types/redux-form": "7.4.4",
     "axios-mock-adapter": "1.15.0",
     "enzyme": "3.3.0",
     "enzyme-adapter-react-16": "1.1.1",
     "jest-localstorage-mock": "2.2.0",
     "redux-devtools": "3.4.1",
     "redux-form": "7.4.2",
-    "typescript": "2.9.2"
+    "typescript": "3.0.1"
   },
   "moduleNameMapper": {
     "^~/(.*)$": "<rootDir>/src/$1"
index 3956fb7390983824a402456abc2144850b85cda2..8ad8fe916ee48e7ef7cf373c105530a224b1c1a2 100644 (file)
@@ -100,8 +100,11 @@ export class CommonResourceService<T extends Resource> {
                 }));
     }
 
-    update(uuid: string) {
-        throw new Error("Not implemented");
+    update(uuid: string, data: any) {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .put<T>(this.resourceType + uuid, data));
+        
     }
 }
 
diff --git a/src/common/api/server-api.ts b/src/common/api/server-api.ts
deleted file mode 100644 (file)
index bcd2f65..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import Axios, { AxiosInstance } from "axios";
-
-export const API_HOST = process.env.REACT_APP_ARVADOS_API_HOST;
-
-export const authClient: AxiosInstance = Axios.create();
-export const apiClient: AxiosInstance = Axios.create();
-
-export function setServerApiAuthorizationHeader(token: string) {
-    [authClient, apiClient].forEach(client => {
-        client.defaults.headers.common = {
-            Authorization: `OAuth2 ${token}`
-        };
-    });
-}
-
-export function removeServerApiAuthorizationHeader() {
-    [authClient, apiClient].forEach(client => {
-        delete client.defaults.headers.common.Authorization;
-    });
-}
-
-export const setBaseUrl = (url: string) => {
-    authClient.defaults.baseURL = url;
-    apiClient.defaults.baseURL = url + "/arvados/v1";
-};
index c85acd9089b0011ac2a819068b55d1ce5fd35688..ecad39134d1652e07d11b58d8da524fdad7cca29 100644 (file)
@@ -16,11 +16,17 @@ interface ArvadosThemeOptions extends ThemeOptions {
 }
 
 export interface ArvadosTheme extends Theme {
-    customs: any;
+    customs: {
+        colors: Colors
+    };
+}
+
+interface Colors {
+    green700: string;
+    yellow700: string;
 }
 
 const red900 = red["900"];
-const yellow700 = yellow["700"];
 const purple800 = purple["800"];
 const grey200 = grey["200"];
 const grey300 = grey["300"];
@@ -32,7 +38,8 @@ const grey900 = grey["900"];
 const themeOptions: ArvadosThemeOptions = {
     customs: {
         colors: {
-            green700: green["700"]
+            green700: green["700"],
+            yellow700: yellow["700"]
         }
     },
     overrides: {
@@ -74,6 +81,38 @@ const themeOptions: ArvadosThemeOptions = {
             root: {
                 fontSize: '1.25rem'
             }
+        },
+        MuiCardHeader: {
+            avatar: {
+                display: 'flex',
+                alignItems: 'center'
+            },
+            title: {
+                color: grey700,
+                fontSize: '1.25rem'
+            }
+        },
+        MuiMenuItem: {
+            root: {
+                padding: '8px 16px'
+            }
+        },
+        MuiInput: {
+            underline: {
+                '&:after': {
+                    borderBottomColor: purple800
+                },
+                '&:hover:not($disabled):not($focused):not($error):before': {
+                    borderBottom: '1px solid inherit'
+                }
+            }
+        },
+        MuiFormLabel: {
+            focused: {
+                "&$focused:not($error)": {
+                    color: purple800
+                }
+            }
         }
     },
     mixins: {
diff --git a/src/components/collection-panel-files/collection-panel-files.tsx b/src/components/collection-panel-files/collection-panel-files.tsx
new file mode 100644 (file)
index 0000000..17bbe85
--- /dev/null
@@ -0,0 +1,68 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { TreeItem, TreeItemStatus } from '../tree/tree';
+import { FileTreeData } from '../file-tree/file-tree-data';
+import { FileTree } from '../file-tree/file-tree';
+import { IconButton, Grid, Typography, StyleRulesCallback, withStyles, WithStyles, CardHeader, CardContent, Card, Button } from '@material-ui/core';
+import { CustomizeTableIcon } from '../icon/icon';
+
+export interface CollectionPanelFilesProps {
+    items: Array<TreeItem<FileTreeData>>;
+    onUploadDataClick: () => void;
+    onItemMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+    onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>) => void;
+    onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+    onCollapseToggle: (id: string, status: TreeItemStatus) => void;
+}
+
+type CssRules = 'root' | 'cardSubheader' | 'nameHeader' | 'fileSizeHeader';
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    root: {
+        paddingBottom: theme.spacing.unit
+    },
+    cardSubheader: {
+        paddingTop: 0,
+        paddingBottom: 0
+    },
+    nameHeader: {
+        marginLeft: '75px'
+    },
+    fileSizeHeader: {
+        marginRight: '65px'
+    }
+});
+
+export const CollectionPanelFiles = withStyles(styles)(
+    ({ onItemMenuOpen, onOptionsMenuOpen, classes, ...treeProps }: CollectionPanelFilesProps & WithStyles<CssRules>) =>
+        <Card className={classes.root}>
+            <CardHeader
+                title="Files"
+                action={
+                    <Button 
+                        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
+                    </Typography>
+                <Typography variant="caption" className={classes.fileSizeHeader}>
+                    File size
+                    </Typography>
+            </Grid>
+            <FileTree onMenuOpen={onItemMenuOpen} {...treeProps} />
+        </Card>);
index 01dba85c0621e1ac50718a340cb9059f8c147a2a..02265fc4fd5f2f16da793a9a5cb72081a417ca71 100644 (file)
@@ -17,7 +17,8 @@ describe("<ColumnSelector />", () => {
             {
                 name: "Column 1",
                 render: () => <span />,
-                selected: true
+                selected: true,
+                configurable: true
             },
             {
                 name: "Column 2",
@@ -42,17 +43,20 @@ describe("<ColumnSelector />", () => {
             {
                 name: "Column 1",
                 render: () => <span />,
-                selected: true
+                selected: true,
+                configurable: true
             },
             {
                 name: "Column 2",
                 render: () => <span />,
-                selected: false
+                selected: false,
+                configurable: true
             },
             {
                 name: "Column 3",
                 render: () => <span />,
-                selected: true
+                selected: true,
+                configurable: true
             }
         ];
         const columnsConfigurator = mount(<ColumnSelector columns={columns} onColumnToggle={jest.fn()} />);
@@ -67,7 +71,8 @@ describe("<ColumnSelector />", () => {
             {
                 name: "Column 1",
                 render: () => <span />,
-                selected: true
+                selected: true,
+                configurable: true
             }
         ];
         const onColumnToggle = jest.fn();
index 0f496e25cd2c396f9fa20bcabf7e3c0915e49573..f2e42dd251bfbfa07e3c926de0a24cd7291c2038 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { WithStyles, StyleRulesCallback, withStyles, IconButton, Paper, List, Checkbox, ListItemText, ListItem } from '@material-ui/core';
 import MenuIcon from "@material-ui/icons/Menu";
-import { DataColumn, isColumnConfigurable } from '../data-table/data-column';
+import { DataColumn } from '../data-table/data-column';
 import { Popover } from "../popover/popover";
 import { IconButtonProps } from '@material-ui/core/IconButton';
 import { DataColumns } from '../data-table/data-table';
@@ -33,8 +33,8 @@ export const ColumnSelector = withStyles(styles)(
         <Paper>
             <List dense>
                 {columns
-                    .filter(isColumnConfigurable)
-                    .map((column, index) => (
+                    .filter(column => column.configurable)
+                    .map((column, index) =>
                         <ListItem
                             button
                             key={index}
@@ -48,7 +48,7 @@ export const ColumnSelector = withStyles(styles)(
                                 {column.name}
                             </ListItemText>
                         </ListItem>
-                    ))}
+                    )}
             </List>
         </Paper>
     </Popover>
index 4699fd6de0c143f4871febb38b421be8e4ee737f..46d5fb50f3783c89c9852892716891c6d260732c 100644 (file)
@@ -29,7 +29,7 @@ interface DataExplorerDataProps<T> {
     columns: DataColumns<T>;
     searchValue: string;
     rowsPerPage: number;
-    rowsPerPageOptions?: number[];
+    rowsPerPageOptions: number[];
     page: number;
     onSearch: (value: string) => void;
     onRowClick: (item: T) => void;
@@ -105,8 +105,8 @@ export const DataExplorer = withStyles(styles)(
         contextMenuColumn = {
             name: "Actions",
             selected: true,
+            configurable: false,
             key: "context-actions",
-            renderHeader: () => null,
             render: this.renderContextMenuTrigger,
             width: "auto"
         };
index 96ef952493aa895e24b778b22716b024c9ade851..a5000b935bcea4b1420b14408516d03718dacb67 100644 (file)
@@ -7,12 +7,12 @@ import { DataTableFilterItem } from "../data-table-filters/data-table-filters";
 export interface DataColumn<T, F extends DataTableFilterItem = DataTableFilterItem> {
     name: string;
     selected: boolean;
-    configurable?: boolean;
+    configurable: boolean;
     key?: React.Key;
     sortDirection?: SortDirection;
     filters?: F[];
-    render: (item: T) => React.ReactElement<void>;
-    renderHeader?: () => React.ReactElement<void> | null;
+    render: (item: T) => React.ReactElement<any>;
+    renderHeader?: () => React.ReactElement<any>;
     width?: string;
 }
 
@@ -22,10 +22,6 @@ export enum SortDirection {
     NONE = "none"
 }
 
-export const isColumnConfigurable = <T>(column: DataColumn<T>) => {
-    return column.configurable === undefined || column.configurable;
-};
-
 export const toggleSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
     return column.sortDirection
         ? column.sortDirection === SortDirection.ASC
diff --git a/src/components/file-tree/file-tree-data.ts b/src/components/file-tree/file-tree-data.ts
new file mode 100644 (file)
index 0000000..4be4ace
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export interface FileTreeData {
+    name: string;
+    type: string;
+    size?: number;
+}
diff --git a/src/components/file-tree/file-tree-item.tsx b/src/components/file-tree/file-tree-item.tsx
new file mode 100644 (file)
index 0000000..5255ded
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { TreeItem } from "../tree/tree";
+import { ProjectIcon, MoreOptionsIcon, DefaultIcon, CollectionIcon } from "../icon/icon";
+import { Typography, IconButton, StyleRulesCallback, withStyles, WithStyles } from "@material-ui/core";
+import { formatFileSize } from "../../common/formatters";
+import { ListItemTextIcon } from "../list-item-text-icon/list-item-text-icon";
+import { FileTreeData } from "./file-tree-data";
+
+type CssRules = "root" | "spacer" | "sizeInfo" | "button";
+
+const fileTreeItemStyle: StyleRulesCallback<CssRules> = theme => ({
+    root: {
+        display: "flex",
+        alignItems: "center",
+        paddingRight: `${theme.spacing.unit * 1.5}px`
+    },
+    spacer: {
+        flex: "1"
+    },
+    sizeInfo: {
+        width: `${theme.spacing.unit * 8}px`
+    },
+    button: {
+        width: theme.spacing.unit * 3,
+        height: theme.spacing.unit * 3,
+        marginRight: theme.spacing.unit
+    }
+});
+
+export interface FileTreeItemProps {
+    item: TreeItem<FileTreeData>;
+    onMoreClick: (event: React.MouseEvent<any>, item: TreeItem<FileTreeData>) => void;
+}
+export const FileTreeItem = withStyles(fileTreeItemStyle)(
+    class extends React.Component<FileTreeItemProps & WithStyles<CssRules>> {
+        render() {
+            const { classes, item } = this.props;
+            return <div className={classes.root}>
+                <ListItemTextIcon
+                    icon={getIcon(item)}
+                    name={item.data.name} />
+                <div className={classes.spacer} />
+                <Typography
+                    className={classes.sizeInfo}
+                    variant="caption">{formatFileSize(item.data.size)}</Typography>
+                <IconButton
+                    className={classes.button}
+                    onClick={this.handleClick}>
+                    <MoreOptionsIcon />
+                </IconButton>
+            </div >;
+        }
+
+        handleClick = (event: React.MouseEvent<any>) => {
+            this.props.onMoreClick(event, this.props.item);
+        }
+    });
+
+const getIcon = (item: TreeItem<FileTreeData>) => {
+    switch(item.data.type){
+        case 'directory':
+            return ProjectIcon;
+        case 'file':
+            return CollectionIcon;
+        default:
+            return DefaultIcon;
+    }
+};
+
diff --git a/src/components/file-tree/file-tree.tsx b/src/components/file-tree/file-tree.tsx
new file mode 100644 (file)
index 0000000..06fc8b7
--- /dev/null
@@ -0,0 +1,51 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Tree, TreeItem, TreeItemStatus } from "../tree/tree";
+import { FileTreeData } from "./file-tree-data";
+import { FileTreeItem } from "./file-tree-item";
+
+export interface FileTreeProps {
+    items: Array<TreeItem<FileTreeData>>;
+    onMenuOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+    onSelectionToggle: (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => void;
+    onCollapseToggle: (id: string, status: TreeItemStatus) => void;
+}
+
+export class FileTree extends React.Component<FileTreeProps> {
+    render() {
+        return <Tree
+            showSelection={true}
+            items={this.props.items}
+            disableRipple={true}
+            render={this.renderItem}
+            onContextMenu={this.handleContextMenu}
+            toggleItemActive={this.handleToggleActive}
+            toggleItemOpen={this.handleToggle}
+            onSelectionChange={this.handleSelectionChange} />;
+    }
+
+    handleContextMenu = (event: React.MouseEvent<any>, item: TreeItem<FileTreeData>) => {
+        event.stopPropagation();
+        this.props.onMenuOpen(event, item);
+    }
+
+    handleToggle = (id: string, status: TreeItemStatus) => {
+        this.props.onCollapseToggle(id, status);
+    }
+
+    handleToggleActive = () => { return; };
+
+    handleSelectionChange = (event: React.MouseEvent<HTMLElement>, item: TreeItem<FileTreeData>) => {
+        event.stopPropagation();
+        this.props.onSelectionToggle(event, item);
+    }
+
+    renderItem = (item: TreeItem<FileTreeData>) =>
+        <FileTreeItem
+            item={item}
+            onMoreClick={this.handleContextMenu} />
+
+}
index e80fee8e97db18107cc186cf2819c6dfc9f0ed9c..1dc8669ecfdc927f550459819fc1f9aae1561d16 100644 (file)
@@ -11,9 +11,10 @@ import Code from '@material-ui/icons/Code';
 import ChevronLeft from '@material-ui/icons/ChevronLeft';
 import ChevronRight from '@material-ui/icons/ChevronRight';
 import Close from '@material-ui/icons/Close';
-import ContentCopy from '@material-ui/icons/ContentCopy';
+import ContentCopy from '@material-ui/icons/FileCopyOutlined';
 import CreateNewFolder from '@material-ui/icons/CreateNewFolder';
 import Delete from '@material-ui/icons/Delete';
+import DeviceHub from '@material-ui/icons/DeviceHub';
 import Edit from '@material-ui/icons/Edit';
 import Folder from '@material-ui/icons/Folder';
 import GetApp from '@material-ui/icons/GetApp';
@@ -21,6 +22,7 @@ import Help from '@material-ui/icons/Help';
 import Inbox from '@material-ui/icons/Inbox';
 import Info from '@material-ui/icons/Info';
 import Input from '@material-ui/icons/Input';
+import LibraryBooks from '@material-ui/icons/LibraryBooks';
 import Menu from '@material-ui/icons/Menu';
 import MoreVert from '@material-ui/icons/MoreVert';
 import Notifications from '@material-ui/icons/Notifications';
@@ -30,16 +32,17 @@ import PersonAdd from '@material-ui/icons/PersonAdd';
 import PlayArrow from '@material-ui/icons/PlayArrow';
 import RateReview from '@material-ui/icons/RateReview';
 import Search from '@material-ui/icons/Search';
+import SettingsApplications from '@material-ui/icons/SettingsApplications';
 import Star from '@material-ui/icons/Star';
 import StarBorder from '@material-ui/icons/StarBorder';
 
 export type IconType = React.SFC<{ className?: string }>;
 
 export const AddFavoriteIcon: IconType = (props) => <StarBorder {...props} />;
-export const AdvancedIcon: IconType = (props) => <Folder {...props} />;
+export const AdvancedIcon: IconType = (props) => <SettingsApplications {...props} />;
 export const CustomizeTableIcon: IconType = (props) => <Menu {...props} />;
 export const CopyIcon: IconType = (props) => <ContentCopy {...props} />;
-export const CollectionIcon: IconType = (props) => <Folder {...props} />;
+export const CollectionIcon: IconType = (props) => <LibraryBooks {...props} />;
 export const CloseIcon: IconType = (props) => <Close {...props} />;
 export const DefaultIcon: IconType = (props) => <RateReview {...props} />;
 export const DetailsIcon: IconType = (props) => <Info {...props} />;
@@ -57,7 +60,7 @@ export const PaginationRightArrowIcon: IconType = (props) => <ChevronRight {...p
 export const ProcessIcon: IconType = (props) => <BubbleChart {...props} />;
 export const ProjectIcon: IconType = (props) => <Folder {...props} />;
 export const ProjectsIcon: IconType = (props) => <Inbox {...props} />;
-export const ProvenanceGraphIcon: IconType = (props) => <Folder {...props} />;
+export const ProvenanceGraphIcon: IconType = (props) => <DeviceHub {...props} />;
 export const RecentIcon: IconType = (props) => <AccessTime {...props} />;
 export const RemoveIcon: IconType = (props) => <Delete {...props} />;
 export const RemoveFavoriteIcon: IconType = (props) => <Star {...props} />;
@@ -70,4 +73,4 @@ export const SidePanelRightArrowIcon: IconType = (props) => <PlayArrow {...props
 export const TrashIcon: IconType = (props) => <Delete {...props} />;
 export const UserPanelIcon: IconType = (props) => <Person {...props} />;
 export const UsedByIcon: IconType = (props) => <Folder {...props} />;
-export const WorkflowIcon: IconType = (props) => <Code {...props} />;
\ No newline at end of file
+export const WorkflowIcon: IconType = (props) => <Code {...props} />;
index 8f9d4744dd3145364d0aea2350e5e995c51af25b..6f8a2c4302e635bd9b4222801d0ddbb9ce5f82bb 100644 (file)
@@ -23,7 +23,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         color: theme.palette.primary.main,
     },
     hasMargin: {
-        marginLeft: '18px',
+        marginLeft: `${theme.spacing.unit}px`,
     },
 });
 
index de6b3c8149faa99a8cb83362a39a25dd186b2aa3..ec648e11b2e6d84922e57a54a6e57f973f381e1c 100644 (file)
@@ -34,7 +34,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toggableIconContainer: {
         color: theme.palette.grey["700"],
         height: '14px',
-        position: 'absolute'
+        width: '14px'
     },
     toggableIcon: {
         fontSize: '14px'
index 58484c37a2358a538cd6f8af2827506d0f809b92..45981d8962c7119f614b48a9ab866bceed9c65f9 100644 (file)
@@ -10,6 +10,7 @@ import ListItem from "@material-ui/core/ListItem/ListItem";
 import { Tree, TreeItem } from './tree';
 import { ProjectResource } from '../../models/project';
 import { mockProjectResource } from '../../models/test-utils';
+import { Checkbox } from '@material-ui/core';
 
 Enzyme.configure({ adapter: new Adapter() });
 
@@ -48,4 +49,51 @@ describe("Tree component", () => {
             items={[project]} />);
         expect(wrapper.find('i')).toHaveLength(1);
     });
+
+    it("should render checkbox", () => {
+        const project: TreeItem<ProjectResource> = {
+            data: mockProjectResource(),
+            id: "3",
+            open: true,
+            active: true,
+            status: 1,
+        };
+        const wrapper = mount(<Tree
+            showSelection={true}
+            render={() => <div />}
+            toggleItemOpen={jest.fn()}
+            toggleItemActive={jest.fn()}
+            onContextMenu={jest.fn()}
+            items={[project]} />);
+        expect(wrapper.find(Checkbox)).toHaveLength(1);
+    });
+
+    it("call onSelectionChanged with associated item", () => {
+        const project: TreeItem<ProjectResource> = {
+            data: mockProjectResource(),
+            id: "3",
+            open: true,
+            active: true,
+            status: 1,
+        };
+        const spy = jest.fn();
+        const onSelectionChanged = (event: any, item: TreeItem<any>) => spy(item);
+        const wrapper = mount(<Tree
+            showSelection={true}
+            render={() => <div />}
+            toggleItemOpen={jest.fn()}
+            toggleItemActive={jest.fn()}
+            onContextMenu={jest.fn()}
+            onSelectionChange={onSelectionChanged}
+            items={[project]} />);
+        wrapper.find(Checkbox).prop('onClick')();
+        expect(spy).toHaveBeenLastCalledWith({
+            data: mockProjectResource(),
+            id: "3",
+            open: true,
+            active: true,
+            status: 1,
+        });
+    });
+
 });
index 20526005a1830491a5e844bdf64d3cf8d6148d68..ea15b6b1bd8df4a8b830bbdc6aec48f6c56e7ea2 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { List, ListItem, ListItemIcon, Collapse } from "@material-ui/core";
+import { List, ListItem, ListItemIcon, Collapse, Checkbox } from "@material-ui/core";
 import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
 import { ReactElement } from "react";
 import CircularProgress from '@material-ui/core/CircularProgress';
@@ -12,12 +12,24 @@ import * as classnames from "classnames";
 import { ArvadosTheme } from '../../common/custom-theme';
 import { SidePanelRightArrowIcon } from '../icon/icon';
 
-type CssRules = 'list' | 'active' | 'loader' | 'toggableIconContainer' | 'iconClose' | 'iconOpen' | 'toggableIcon';
+type CssRules = 'list'
+    | 'listItem'
+    | 'active'
+    | 'loader'
+    | 'toggableIconContainer'
+    | 'iconClose'
+    | 'renderContainer'
+    | 'iconOpen'
+    | 'toggableIcon'
+    | 'checkbox';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     list: {
         padding: '3px 0px'
     },
+    listItem: {
+        padding: '3px 0px',
+    },
     loader: {
         position: 'absolute',
         transform: 'translate(0px)',
@@ -26,11 +38,14 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toggableIconContainer: {
         color: theme.palette.grey["700"],
         height: '14px',
-        position: 'absolute'
+        width: '14px',
     },
     toggableIcon: {
         fontSize: '14px'
     },
+    renderContainer: {
+        flex: 1
+    },
     active: {
         color: theme.palette.primary.main,
     },
@@ -40,6 +55,12 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     iconOpen: {
         transition: 'all 0.1s ease',
         transform: 'rotate(90deg)',
+    },
+    checkbox: {
+        width: theme.spacing.unit * 3,
+        height: theme.spacing.unit * 3,
+        margin: `0 ${theme.spacing.unit}px`,
+        color: theme.palette.grey["500"]
     }
 });
 
@@ -54,6 +75,7 @@ export interface TreeItem<T> {
     id: string;
     open: boolean;
     active: boolean;
+    selected?: boolean;
     status: TreeItemStatus;
     items?: Array<TreeItem<T>>;
 }
@@ -65,18 +87,22 @@ interface TreeProps<T> {
     toggleItemActive: (id: string, status: TreeItemStatus) => void;
     level?: number;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+    showSelection?: boolean;
+    onSelectionChange?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
+    disableRipple?: boolean;
 }
 
 export const Tree = withStyles(styles)(
     class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
         render(): ReactElement<any> {
             const level = this.props.level ? this.props.level : 0;
-            const { classes, render, toggleItemOpen, items, toggleItemActive, onContextMenu } = this.props;
-            const { list, loader, toggableIconContainer } = classes;
+            const { classes, render, toggleItemOpen, items, toggleItemActive, onContextMenu, disableRipple } = this.props;
+            const { list, listItem, loader, toggableIconContainer, renderContainer } = classes;
             return <List component="div" className={list}>
                 {items && items.map((it: TreeItem<T>, idx: number) =>
                     <div key={`item/${level}/${idx}`}>
-                        <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }}
+                        <ListItem button className={listItem} style={{ paddingLeft: (level + 1) * 20 }}
+                            disableRipple={disableRipple}
                             onClick={() => toggleItemActive(it.id, it.status)}
                             onContextMenu={this.handleRowContextMenu(it)}>
                             {it.status === TreeItemStatus.PENDING ?
@@ -87,17 +113,28 @@ export const Tree = withStyles(styles)(
                                     {it.status !== TreeItemStatus.INITIAL && it.items && it.items.length === 0 ? <span /> : <SidePanelRightArrowIcon />}
                                 </ListItemIcon>
                             </i>
-                            {render(it, level)}
+                            {this.props.showSelection &&
+                                <Checkbox
+                                    checked={it.selected}
+                                    className={classes.checkbox}
+                                    color="primary"
+                                    onClick={this.handleCheckboxChange(it)} />}
+                            <div className={renderContainer}>
+                                {render(it, level)}
+                            </div>
                         </ListItem>
                         {it.items && it.items.length > 0 &&
                             <Collapse in={it.open} timeout="auto" unmountOnExit>
                                 <Tree
+                                    showSelection={this.props.showSelection}
                                     items={it.items}
                                     render={render}
+                                    disableRipple={disableRipple}
                                     toggleItemOpen={toggleItemOpen}
                                     toggleItemActive={toggleItemActive}
                                     level={level + 1}
-                                    onContextMenu={onContextMenu} />
+                                    onContextMenu={onContextMenu}
+                                    onSelectionChange={this.props.onSelectionChange} />
                             </Collapse>}
                     </div>)}
             </List>;
@@ -115,5 +152,14 @@ export const Tree = withStyles(styles)(
         handleRowContextMenu = (item: TreeItem<T>) =>
             (event: React.MouseEvent<HTMLElement>) =>
                 this.props.onContextMenu(event, item)
+
+        handleCheckboxChange = (item: TreeItem<T>) => {
+            const { onSelectionChange } = this.props;
+            return onSelectionChange
+                ? (event: React.MouseEvent<HTMLElement>) => {
+                    onSelectionChange(event, item);
+                }
+                : undefined;
+        }
     }
 );
index 77d5763b478cc3884d44c644fea7de6e3929a37c..467aee08dd378915152892fbdcecb67ef4f86c86 100644 (file)
@@ -12,34 +12,39 @@ import createBrowserHistory from "history/createBrowserHistory";
 import { configureStore } from "./store/store";
 import { ConnectedRouter } from "react-router-redux";
 import { ApiToken } from "./views-components/api-token/api-token";
-import { authActions } from "./store/auth/auth-action";
-import { authService } from "./services/services";
+import { initAuth } from "./store/auth/auth-action";
+import { createServices } from "./services/services";
 import { getProjectList } from "./store/project/project-action";
 import { MuiThemeProvider } from '@material-ui/core/styles';
 import { CustomTheme } from './common/custom-theme';
 import { fetchConfig } from './common/config';
-import { setBaseUrl } from './common/api/server-api';
 import { addMenuActionSet, ContextMenuKind } from "./views-components/context-menu/context-menu";
 import { rootProjectActionSet } from "./views-components/context-menu/action-sets/root-project-action-set";
 import { projectActionSet } from "./views-components/context-menu/action-sets/project-action-set";
 import { resourceActionSet } from './views-components/context-menu/action-sets/resource-action-set';
 import { favoriteActionSet } from "./views-components/context-menu/action-sets/favorite-action-set";
+import { collectionFilesActionSet } from './views-components/context-menu/action-sets/collection-files-action-set';
+import { collectionFilesItemActionSet } from './views-components/context-menu/action-sets/collection-files-item-action-set';
+import { collectionActionSet } from './views-components/context-menu/action-sets/collection-action-set';
 
 addMenuActionSet(ContextMenuKind.ROOT_PROJECT, rootProjectActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT, projectActionSet);
 addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet);
 addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION_FILES_ITEM, collectionFilesItemActionSet);
+addMenuActionSet(ContextMenuKind.COLLECTION, collectionActionSet); 
 
 fetchConfig()
     .then(config => {
-
-        setBaseUrl(config.API_HOST);
-
         const history = createBrowserHistory();
-        const store = configureStore(history);
+        const services = createServices(config.API_HOST);
+        const store = configureStore(history, services);
+
+        store.dispatch(initAuth());
+        store.dispatch(getProjectList(services.authService.getUuid()));
 
-        store.dispatch(authActions.INIT());
-        store.dispatch<any>(getProjectList(authService.getUuid()));
+        const Token = (props: any) => <ApiToken authService={services.authService} {...props}/>;
 
         const App = () =>
             <MuiThemeProvider theme={CustomTheme}>
@@ -47,7 +52,7 @@ fetchConfig()
                     <ConnectedRouter history={history}>
                         <div>
                             <Route path="/" component={Workbench} />
-                            <Route path="/token" component={ApiToken} />
+                            <Route path="/token" component={Token} />
                         </div>
                     </ConnectedRouter>
                 </Provider>
diff --git a/src/models/collection-file.ts b/src/models/collection-file.ts
new file mode 100644 (file)
index 0000000..a140080
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Tree } from './tree';
+
+export type CollectionFilesTree = Tree<CollectionDirectory | CollectionFile>;
+
+export enum CollectionFileType {
+    DIRECTORY = 'directory',
+    FILE = 'file'
+}
+
+export interface CollectionDirectory {
+    parentId: string;
+    id: string;
+    name: string;
+    type: CollectionFileType.DIRECTORY;
+}
+
+export interface CollectionFile {
+    parentId: string;
+    id: string;
+    name: string;
+    size: number;
+    type: CollectionFileType.FILE;
+}
+
+export const createCollectionDirectory = (data: Partial<CollectionDirectory>): CollectionDirectory => ({
+    id: '',
+    name: '',
+    parentId: '',
+    type: CollectionFileType.DIRECTORY,
+    ...data
+});
+
+export const createCollectionFile = (data: Partial<CollectionFile>): CollectionFile => ({
+    id: '',
+    name: '',
+    parentId: '',
+    size: 0,
+    type: CollectionFileType.FILE,
+    ...data
+});
\ No newline at end of file
index 9cb8bb84f6d0963ac59eaf1db2d3276211ceedd8..0e96f7fd3dd6f1473f62d8c2a2e3226aa42f6507 100644 (file)
@@ -18,3 +18,7 @@ export interface CollectionResource extends Resource {
     deleteAt: string;
     isTrashed: boolean;
 }
+
+export const getCollectionUrl = (uuid: string) => {
+    return `/collections/${uuid}`;
+};
\ No newline at end of file
diff --git a/src/models/keep-manifest.ts b/src/models/keep-manifest.ts
new file mode 100644 (file)
index 0000000..6dc6445
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type KeepManifest = KeepManifestStream[];
+
+export interface KeepManifestStream {
+    name: string;
+    locators: string[];
+    files: Array<KeepManifestStreamFile>;
+}
+
+export interface KeepManifestStreamFile {
+    name: string;
+    position: string;
+    size: number;
+}
index eaf60904609974223e091bb244067e4da9b2b667..b919450774f687084a700d8b4d041772aa142669 100644 (file)
@@ -7,3 +7,7 @@ import { GroupResource, GroupClass } from "./group";
 export interface ProjectResource extends GroupResource {
     groupClass: GroupClass.PROJECT;
 }
+
+export const getProjectUrl = (uuid: string) => {
+    return `/projects/${uuid}`;
+};
diff --git a/src/models/tree.test.ts b/src/models/tree.test.ts
new file mode 100644 (file)
index 0000000..708cf40
--- /dev/null
@@ -0,0 +1,88 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as Tree from './tree';
+
+describe('Tree', () => {
+    let tree: Tree.Tree<string>;
+
+    beforeEach(() => {
+        tree = Tree.createTree();
+    });
+
+    it('sets new node', () => {
+        const newTree = Tree.setNode({ children: [], id: 'Node 1', parent: '', value: 'Value 1' })(tree);
+        expect(Tree.getNode('Node 1')(newTree)).toEqual({ children: [], id: 'Node 1', parent: '', value: 'Value 1' });
+    });
+
+    it('adds new node reference to parent children', () => {
+        const [newTree] = [tree]
+            .map(Tree.setNode({ children: [], id: 'Node 1', parent: '', value: 'Value 1' }))
+            .map(Tree.setNode({ children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 2' }));
+
+        expect(Tree.getNode('Node 1')(newTree)).toEqual({ children: ['Node 2'], id: 'Node 1', parent: '', value: 'Value 1' });
+    });
+
+    it('gets node ancestors', () => {
+        const newTree = [
+            { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+            { 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']);
+    });
+
+    it('gets node descendants', () => {
+        const newTree = [
+            { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+            { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+            { children: [], id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' },
+            { 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']);
+    });
+
+    it('gets root descendants', () => {
+        const newTree = [
+            { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+            { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+            { children: [], id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' },
+            { 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']);
+    });
+
+    it('gets node children', () => {
+        const newTree = [
+            { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+            { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+            { children: [], id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' },
+            { 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']);
+    });
+
+    it('gets root children', () => {
+        const newTree = [
+            { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+            { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 1' },
+            { children: [], id: 'Node 2.1', parent: 'Node 2', value: 'Value 1' },
+            { 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']);
+    });
+
+    it('maps tree', () => {
+        const newTree = [
+            { children: [], id: 'Node 1', parent: '', value: 'Value 1' },
+            { children: [], id: 'Node 2', parent: 'Node 1', value: 'Value 2' },
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+        const mappedTree = Tree.mapTreeValues<string, number>(value => parseInt(value.split(' ')[1], 10))(newTree);
+        expect(Tree.getNode('Node 2')(mappedTree)).toEqual({ children: [], id: 'Node 2', parent: 'Node 1', value: 2 }, );
+    });
+});
\ No newline at end of file
diff --git a/src/models/tree.ts b/src/models/tree.ts
new file mode 100644 (file)
index 0000000..8b66e50
--- /dev/null
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export type Tree<T> = Record<string, TreeNode<T>>;
+
+export const TREE_ROOT_ID = '';
+
+export interface TreeNode<T> {
+    children: string[];
+    value: T;
+    id: string;
+    parent: string;
+}
+
+export const createTree = <T>(): Tree<T> => ({});
+
+export const getNode = (id: string) => <T>(tree: Tree<T>): TreeNode<T> | undefined => tree[id];
+
+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})
+        .map(addChild(node.parent, node.id));
+    return newTree;
+};
+
+export const getNodeValue = (id: string) => <T>(tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node ? node.value : undefined;
+};
+
+export const setNodeValue = (id: string) => <T>(value: T) => (tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node
+        ? setNode(mapNodeValue(() => value)(node))(tree)
+        : tree;
+};
+
+export const setNodeValueWith = <T>(mapFn: (value: T) => T) => (id: string) => (tree: Tree<T>) => {
+    const node = getNode(id)(tree);
+    return node
+        ? setNode(mapNodeValue(mapFn)(node))(tree)
+        : tree;
+};
+
+export const mapTreeValues = <T, R>(mapFn: (value: T) => R) => (tree: Tree<T>): Tree<R> =>
+    getNodeDescendants('')(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)
+        .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[] => {
+    const node = getNode(id)(tree);
+    return node && node.parent
+        ? [...getNodeAncestors(node.parent)(tree), node.parent]
+        : [];
+};
+
+export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
+    const node = getNode(id)(tree);
+    const children = node ? node.children :
+        id === TREE_ROOT_ID
+            ? getRootNodeChildren(tree)
+            : [];
+
+    return children
+        .concat(limit < 1
+            ? []
+            : children
+                .map(id => getNodeDescendants(id, limit - 1)(tree))
+                .reduce((nodes, nodeChildren) => [...nodes, ...nodeChildren], []));
+};
+
+export const getNodeChildren = (id: string) => <T>(tree: Tree<T>): string[] =>
+    getNodeDescendants(id, 0)(tree);
+
+const mapNodeValue = <T, R>(mapFn: (value: T) => R) => (node: TreeNode<T>): TreeNode<R> =>
+    ({ ...node, value: mapFn(node.value) });
+
+const getRootNodeChildren = <T>(tree: Tree<T>) =>
+    Object
+        .keys(tree)
+        .filter(id => getNode(id)(tree)!.parent === TREE_ROOT_ID);
+
+const addChild = (parentId: string, childId: string) => <T>(tree: Tree<T>): Tree<T> => {
+    const node = getNode(parentId)(tree);
+    if (node) {
+        const children = node.children.some(id => id === childId)
+            ? node.children
+            : [...node.children, childId];
+
+        const newNode = children === node.children
+            ? node
+            : { ...node, children };
+
+        return setNode(newNode)(tree);
+    }
+    return tree;
+};
index 551d435f25d208d07a835270b4602a97b228460a..f96edc79a08acd2e1079a4785b6aafd9f31a6edb 100644 (file)
@@ -2,7 +2,6 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { API_HOST } from "../../common/api/server-api";
 import { User } from "../../models/user";
 import { AxiosInstance } from "../../../node_modules/axios";
 
@@ -25,8 +24,8 @@ export interface UserDetailsResponse {
 export class AuthService {
 
     constructor(
-        protected authClient: AxiosInstance,
-        protected apiClient: AxiosInstance) { }
+        protected apiClient: AxiosInstance,
+        protected baseUrl: string) { }
 
     public saveApiToken(token: string) {
         localStorage.setItem(API_TOKEN_KEY, token);
@@ -78,12 +77,12 @@ export class AuthService {
 
     public login() {
         const currentUrl = `${window.location.protocol}//${window.location.host}/token`;
-        window.location.assign(`${this.authClient.defaults.baseURL || ""}/login?return_to=${currentUrl}`);
+        window.location.assign(`${this.baseUrl || ""}/login?return_to=${currentUrl}`);
     }
 
     public logout() {
         const currentUrl = `${window.location.protocol}//${window.location.host}`;
-        window.location.assign(`${this.authClient.defaults.baseURL || ""}/logout?return_to=${currentUrl}`);
+        window.location.assign(`${this.baseUrl || ""}/logout?return_to=${currentUrl}`);
     }
 
     public getUserDetails = (): Promise<User> => {
diff --git a/src/services/collection-files-service/collection-files-service.ts b/src/services/collection-files-service/collection-files-service.ts
new file mode 100644 (file)
index 0000000..96c9e99
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionService } from "../collection-service/collection-service";
+import { parseKeepManifestText } from "./collection-manifest-parser";
+import { mapManifestToCollectionFilesTree } from "./collection-manifest-mapper";
+
+export class CollectionFilesService {
+    
+    constructor(private collectionService: CollectionService) { }
+
+    getFiles(collectionUuid: string) {
+        return this.collectionService
+            .get(collectionUuid)
+            .then(collection =>
+                mapManifestToCollectionFilesTree(
+                    parseKeepManifestText(
+                        collection.manifestText
+                    )
+                )
+            );
+    }
+
+}
\ No newline at end of file
diff --git a/src/services/collection-files-service/collection-manifest-mapper.test.ts b/src/services/collection-files-service/collection-manifest-mapper.test.ts
new file mode 100644 (file)
index 0000000..ad8f872
--- /dev/null
@@ -0,0 +1,59 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { parseKeepManifestText } from "./collection-manifest-parser";
+import { mapManifestToFiles, mapManifestToDirectories } from "./collection-manifest-mapper";
+
+test('mapManifestToFiles', () => {
+    const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n./c d41d8cd98f00b204e9800998ecf8427e+0 0:0:d`;
+    const manifest = parseKeepManifestText(manifestText);
+    const files = mapManifestToFiles(manifest);
+    expect(files).toEqual([{
+        parentId: '',
+        id: '/a',
+        name: 'a',
+        size: 0,
+        type: 'file'
+    }, {
+        parentId: '',
+        id: '/b',
+        name: 'b',
+        size: 0,
+        type: 'file'
+    }, {
+        parentId: '',
+        id: '/output.txt',
+        name: 'output.txt',
+        size: 33,
+        type: 'file'
+    }, {
+        parentId: '/c',
+        id: '/c/d',
+        name: 'd',
+        size: 0,
+        type: 'file'
+    },]);
+});
+
+test('mapManifestToDirectories', () => {
+    const manifestText = `./c/user/results 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n`;
+    const manifest = parseKeepManifestText(manifestText);
+    const directories = mapManifestToDirectories(manifest);
+    expect(directories).toEqual([{
+        parentId: "",
+        id: '/c',
+        name: 'c',
+        type: 'directory'
+    }, {
+        parentId: '/c',
+        id: '/c/user',
+        name: 'user',
+        type: 'directory'
+    }, {
+        parentId: '/c/user',
+        id: '/c/user/results',
+        name: 'results',
+        type: 'directory'
+    },]);
+});
\ No newline at end of file
diff --git a/src/services/collection-files-service/collection-manifest-mapper.ts b/src/services/collection-files-service/collection-manifest-mapper.ts
new file mode 100644 (file)
index 0000000..c2a8ae8
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { uniqBy } from 'lodash';
+import { KeepManifestStream, KeepManifestStreamFile, KeepManifest } from "../../models/keep-manifest";
+import { TreeNode, setNode, createTree } from '../../models/tree';
+import { CollectionFilesTree, CollectionFile, CollectionDirectory, createCollectionDirectory, createCollectionFile } from '../../models/collection-file';
+
+export const mapManifestToCollectionFilesTree = (manifest: KeepManifest): CollectionFilesTree =>
+    manifestToCollectionFiles(manifest)
+        .map(mapCollectionFileToTreeNode)
+        .reduce((tree, node) => setNode(node)(tree), createTree<CollectionFile>());
+
+
+export const mapCollectionFileToTreeNode = (file: CollectionFile): TreeNode<CollectionFile> => ({
+    children: [],
+    id: file.id,
+    parent: file.parentId,
+    value: file
+});
+
+export const manifestToCollectionFiles = (manifest: KeepManifest): Array<CollectionDirectory | CollectionFile> => ([
+    ...mapManifestToDirectories(manifest),
+    ...mapManifestToFiles(manifest)
+]);
+
+export const mapManifestToDirectories = (manifest: KeepManifest): CollectionDirectory[] =>
+    uniqBy(
+        manifest
+            .map(mapStreamDirectory)
+            .map(splitDirectory)
+            .reduce((all, splitted) => ([...all, ...splitted]), []),
+        directory => directory.id);
+
+export const mapManifestToFiles = (manifest: KeepManifest): CollectionFile[] =>
+    manifest
+        .map(stream => stream.files.map(mapStreamFile(stream)))
+        .reduce((all, current) => ([...all, ...current]), []);
+
+const splitDirectory = (directory: CollectionDirectory): CollectionDirectory[] => {
+    return directory.name
+        .split('/')
+        .slice(1)
+        .map(mapPathComponentToDirectory);
+};
+
+const mapPathComponentToDirectory = (component: string, index: number, components: string[]): CollectionDirectory =>
+    createCollectionDirectory({
+        parentId: index === 0 ? '' : joinPathComponents(components, index),
+        id: joinPathComponents(components, index + 1),
+        name: component,
+    });
+
+const joinPathComponents = (components: string[], index: number) =>
+    `/${components.slice(0, index).join('/')}`;
+
+const mapStreamDirectory = (stream: KeepManifestStream): CollectionDirectory =>
+    createCollectionDirectory({
+        parentId: '',
+        id: stream.name,
+        name: stream.name,
+    });
+
+const mapStreamFile = (stream: KeepManifestStream) =>
+    (file: KeepManifestStreamFile): CollectionFile =>
+        createCollectionFile({
+            parentId: stream.name,
+            id: `${stream.name}/${file.name}`,
+            name: file.name,
+            size: file.size,
+        });
+
diff --git a/src/services/collection-files-service/collection-manifest-parser.test.ts b/src/services/collection-files-service/collection-manifest-parser.test.ts
new file mode 100644 (file)
index 0000000..eddc9c6
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { parseKeepManifestText, parseKeepManifestStream } from "./collection-manifest-parser";
+
+describe('parseKeepManifestText', () => {
+    it('should parse text into streams', () => {
+        const manifestText = `. 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt\n./c d41d8cd98f00b204e9800998ecf8427e+0 0:0:d\n`;
+        const manifest = parseKeepManifestText(manifestText);
+        expect(manifest[0].name).toBe('');
+        expect(manifest[1].name).toBe('/c');
+        expect(manifest.length).toBe(2);
+    });
+});
+
+describe('parseKeepManifestStream', () => {
+    const streamText = './c 930625b054ce894ac40596c3f5a0d947+33 0:0:a 0:0:b 0:33:output.txt';
+    const stream = parseKeepManifestStream(streamText);
+
+    it('should parse stream name', () => {
+        expect(stream.name).toBe('/c');
+    });
+    it('should parse stream locators', () => {
+        expect(stream.locators).toEqual(['930625b054ce894ac40596c3f5a0d947+33']);
+    });
+    it('should parse stream files', () => {
+        expect(stream.files).toEqual([
+            {name: 'a', position: '0', size: 0},
+            {name: 'b', position: '0', size: 0},
+            {name: 'output.txt', position: '0', size: 33},
+        ]);
+    });
+});
\ No newline at end of file
diff --git a/src/services/collection-files-service/collection-manifest-parser.ts b/src/services/collection-files-service/collection-manifest-parser.ts
new file mode 100644 (file)
index 0000000..df334d4
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { KeepManifestStream, KeepManifestStreamFile } from "../../models/keep-manifest";
+
+/**
+ * Documentation [http://doc.arvados.org/api/storage.html](http://doc.arvados.org/api/storage.html)
+ */
+export const parseKeepManifestText = (text: string) =>
+    text
+        .split(/\n/)
+        .filter(streamText => streamText.length > 0)
+        .map(parseKeepManifestStream);
+
+/**
+ * Documentation [http://doc.arvados.org/api/storage.html](http://doc.arvados.org/api/storage.html)
+ */
+export const parseKeepManifestStream = (stream: string): KeepManifestStream => {
+    const tokens = stream.split(' ');
+    return {
+        name: streamName(tokens),
+        locators: locators(tokens),
+        files: files(tokens)
+    };
+};
+
+const FILE_LOCATOR_REGEXP = /^([0-9a-f]{32})\+([0-9]+)(\+[A-Z][-A-Za-z0-9@_]*)*$/;
+
+const FILE_REGEXP = /([0-9]+):([0-9]+):(.*)/;
+
+const streamName = (tokens: string[]) => tokens[0].slice(1);
+
+const locators = (tokens: string[]) => tokens.filter(isFileLocator);
+
+const files = (tokens: string[]) => tokens.filter(isFile).map(parseFile);
+
+const isFileLocator = (token: string) => FILE_LOCATOR_REGEXP.test(token);
+
+const isFile = (token: string) => FILE_REGEXP.test(token);
+
+const parseFile = (token: string): KeepManifestStreamFile => {
+    const match = FILE_REGEXP.exec(token);
+    const [position, size, name] = match!.slice(1);
+    return { name, position, size: parseInt(size, 10) };
+};
\ No newline at end of file
diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts
new file mode 100644 (file)
index 0000000..bb63771
--- /dev/null
@@ -0,0 +1,13 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CommonResourceService } from "../../common/api/common-resource-service";
+import { CollectionResource } from "../../models/collection";
+import { AxiosInstance } from "axios";
+
+export class CollectionService extends CommonResourceService<CollectionResource> {
+    constructor(serverApi: AxiosInstance) {
+        super(serverApi, "collections");
+    }
+}
\ No newline at end of file
index a08ed3cb3de4bbb0a89caa858cd5c5b3163c3551..9e1adbf6e4e20d8a1f59637ae4a02f356905dc18 100644 (file)
@@ -4,13 +4,46 @@
 
 import { AuthService } from "./auth-service/auth-service";
 import { GroupsService } from "./groups-service/groups-service";
-import { authClient, apiClient } from "../common/api/server-api";
 import { ProjectService } from "./project-service/project-service";
 import { LinkService } from "./link-service/link-service";
 import { FavoriteService } from "./favorite-service/favorite-service";
+import { AxiosInstance } from "axios";
+import { CollectionService } from "./collection-service/collection-service";
+import Axios from "axios";
+import { CollectionFilesService } from "./collection-files-service/collection-files-service";
 
-export const authService = new AuthService(authClient, apiClient);
-export const groupsService = new GroupsService(apiClient);
-export const projectService = new ProjectService(apiClient);
-export const linkService = new LinkService(apiClient);
-export const favoriteService = new FavoriteService(linkService, groupsService);
+export interface ServiceRepository {
+    apiClient: AxiosInstance;
+
+    authService: AuthService;
+    groupsService: GroupsService;
+    projectService: ProjectService;
+    linkService: LinkService;
+    favoriteService: FavoriteService;
+    collectionService: CollectionService;
+    collectionFilesService: CollectionFilesService;
+}
+
+export const createServices = (baseUrl: string): ServiceRepository => {
+    const apiClient = Axios.create();
+    apiClient.defaults.baseURL = `${baseUrl}/arvados/v1`;
+
+    const authService = new AuthService(apiClient, baseUrl);
+    const groupsService = new GroupsService(apiClient);
+    const projectService = new ProjectService(apiClient);
+    const linkService = new LinkService(apiClient);
+    const favoriteService = new FavoriteService(linkService, groupsService);
+    const collectionService = new CollectionService(apiClient);
+    const collectionFilesService = new CollectionFilesService(collectionService);
+
+    return {
+        apiClient,
+        authService,
+        groupsService,
+        projectService,
+        linkService,
+        favoriteService,
+        collectionService,
+        collectionFilesService
+    };
+};
index e9930a02836da5d66c7e062813a5fd292784f973..6b81c31796a41dce91cce146f560978cd5510d56 100644 (file)
@@ -4,14 +4,16 @@
 
 import { ofType, default as unionize, UnionOf } from "unionize";
 import { Dispatch } from "redux";
-import { authService } from "../../services/services";
 import { User } from "../../models/user";
+import { RootState } from "../store";
+import { ServiceRepository } from "../../services/services";
+import { AxiosInstance } from "axios";
 
 export const authActions = unionize({
     SAVE_API_TOKEN: ofType<string>(),
     LOGIN: {},
     LOGOUT: {},
-    INIT: {},
+    INIT: ofType<{ user: User, token: string }>(),
     USER_DETAILS_REQUEST: {},
     USER_DETAILS_SUCCESS: ofType<User>()
 }, {
@@ -19,11 +21,52 @@ export const authActions = unionize({
     value: 'payload'
 });
 
-export const getUserDetails = () => (dispatch: Dispatch): Promise<User> => {
+function setAuthorizationHeader(client: AxiosInstance, token: string) {
+    client.defaults.headers.common = {
+        Authorization: `OAuth2 ${token}`
+    };
+}
+
+function removeAuthorizationHeader(client: AxiosInstance) {
+    delete client.defaults.headers.common.Authorization;
+}
+
+export const initAuth = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const user = services.authService.getUser();
+    const token = services.authService.getApiToken();
+    if (token) {
+        setAuthorizationHeader(services.apiClient, token);
+    }
+    if (token && user) {
+        dispatch(authActions.INIT({ user, token }));
+    }
+};
+
+export const saveApiToken = (token: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    services.authService.saveApiToken(token);
+    setAuthorizationHeader(services.apiClient, token);
+    dispatch(authActions.SAVE_API_TOKEN(token));
+};
+
+export const login = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    services.authService.login();
+    dispatch(authActions.LOGIN());
+};
+
+export const logout = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    services.authService.removeApiToken();
+    services.authService.removeUser();
+    removeAuthorizationHeader(services.apiClient);
+    services.authService.logout();
+    dispatch(authActions.LOGOUT());
+};
+
+export const getUserDetails = () => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<User> => {
     dispatch(authActions.USER_DETAILS_REQUEST());
-    return authService.getUserDetails().then(details => {
-        dispatch(authActions.USER_DETAILS_SUCCESS(details));
-        return details;
+    return services.authService.getUserDetails().then(user => {
+        services.authService.saveUser(user);
+        dispatch(authActions.USER_DETAILS_SUCCESS(user));
+        return user;
     });
 };
 
diff --git a/src/store/auth/auth-actions.test.ts b/src/store/auth/auth-actions.test.ts
new file mode 100644 (file)
index 0000000..1ded88e
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { authReducer, AuthState } from "./auth-reducer";
+import { AuthAction, initAuth } from "./auth-action";
+import {
+    API_TOKEN_KEY,
+    USER_EMAIL_KEY,
+    USER_FIRST_NAME_KEY,
+    USER_LAST_NAME_KEY,
+    USER_OWNER_UUID_KEY,
+    USER_UUID_KEY
+} from "../../services/auth-service/auth-service";
+
+import 'jest-localstorage-mock';
+import { createServices } from "../../services/services";
+import { configureStore, RootStore } from "../store";
+import createBrowserHistory from "history/createBrowserHistory";
+
+describe('auth-actions', () => {
+    let reducer: (state: AuthState | undefined, action: AuthAction) => any;
+    let store: RootStore;
+
+    beforeEach(() => {
+        store = configureStore(createBrowserHistory(), createServices("/arvados/v1"));
+        localStorage.clear();
+        reducer = authReducer(createServices("/arvados/v1"));
+    });
+
+    it('should initialise state with user and api token from local storage', () => {
+
+        localStorage.setItem(API_TOKEN_KEY, "token");
+        localStorage.setItem(USER_EMAIL_KEY, "test@test.com");
+        localStorage.setItem(USER_FIRST_NAME_KEY, "John");
+        localStorage.setItem(USER_LAST_NAME_KEY, "Doe");
+        localStorage.setItem(USER_UUID_KEY, "uuid");
+        localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid");
+
+        store.dispatch(initAuth());
+
+        expect(store.getState().auth).toEqual({
+            apiToken: "token",
+            user: {
+                email: "test@test.com",
+                firstName: "John",
+                lastName: "Doe",
+                uuid: "uuid",
+                ownerUuid: "ownerUuid"
+            }
+        });
+    });
+
+    // TODO: Add remaining action tests
+    /*
+    it('should fire external url to login', () => {
+        const initialState = undefined;
+        window.location.assign = jest.fn();
+        reducer(initialState, authActions.LOGIN());
+        expect(window.location.assign).toBeCalledWith(
+            `/login?return_to=${window.location.protocol}//${window.location.host}/token`
+        );
+    });
+
+    it('should fire external url to logout', () => {
+        const initialState = undefined;
+        window.location.assign = jest.fn();
+        reducer(initialState, authActions.LOGOUT());
+        expect(window.location.assign).toBeCalledWith(
+            `/logout?return_to=${location.protocol}//${location.host}`
+        );
+    });
+    */
+});
index 778b500d364b87fe5478b939833af69ff24ca628..0e05263d4301a7b3d94f0408670da9a9cc019fd5 100644 (file)
@@ -2,66 +2,44 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { authReducer } from "./auth-reducer";
-import { authActions } from "./auth-action";
-import {
-    API_TOKEN_KEY,
-    USER_EMAIL_KEY,
-    USER_FIRST_NAME_KEY,
-    USER_LAST_NAME_KEY,
-    USER_OWNER_UUID_KEY,
-    USER_UUID_KEY
-} from "../../services/auth-service/auth-service";
+import { authReducer, AuthState } from "./auth-reducer";
+import { AuthAction, authActions } from "./auth-action";
 
 import 'jest-localstorage-mock';
+import { createServices } from "../../services/services";
 
 describe('auth-reducer', () => {
+    let reducer: (state: AuthState | undefined, action: AuthAction) => any;
+
     beforeAll(() => {
         localStorage.clear();
+        reducer = authReducer(createServices("/arvados/v1"));
     });
 
-    it('should return default state on initialisation', () => {
-        const initialState = undefined;
-        const state = authReducer(initialState, authActions.INIT());
-        expect(state).toEqual({
-            apiToken: undefined,
-            user: undefined
-        });
-    });
-
-    it('should read user and api token from local storage on init if they are there', () => {
+    it('should correctly initialise state', () => {
         const initialState = undefined;
-
-        localStorage.setItem(API_TOKEN_KEY, "token");
-        localStorage.setItem(USER_EMAIL_KEY, "test@test.com");
-        localStorage.setItem(USER_FIRST_NAME_KEY, "John");
-        localStorage.setItem(USER_LAST_NAME_KEY, "Doe");
-        localStorage.setItem(USER_UUID_KEY, "uuid");
-        localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid");
-
-        const state = authReducer(initialState, authActions.INIT());
+        const user = {
+            email: "test@test.com",
+            firstName: "John",
+            lastName: "Doe",
+            uuid: "uuid",
+            ownerUuid: "ownerUuid"
+        };
+        const state = reducer(initialState, authActions.INIT({user, token: "token"}));
         expect(state).toEqual({
             apiToken: "token",
-            user: {
-                email: "test@test.com",
-                firstName: "John",
-                lastName: "Doe",
-                uuid: "uuid",
-                ownerUuid: "ownerUuid"
-            }
+            user
         });
     });
 
-    it('should store token in local storage', () => {
+    it('should save api token', () => {
         const initialState = undefined;
 
-        const state = authReducer(initialState, authActions.SAVE_API_TOKEN("token"));
+        const state = reducer(initialState, authActions.SAVE_API_TOKEN("token"));
         expect(state).toEqual({
             apiToken: "token",
             user: undefined
         });
-
-        expect(localStorage.getItem(API_TOKEN_KEY)).toBe("token");
     });
 
     it('should set user details on success fetch', () => {
@@ -75,7 +53,7 @@ describe('auth-reducer', () => {
             ownerUuid: "ownerUuid"
         };
 
-        const state = authReducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
+        const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
         expect(state).toEqual({
             apiToken: undefined,
             user: {
@@ -86,25 +64,5 @@ describe('auth-reducer', () => {
                 ownerUuid: "ownerUuid",
             }
         });
-
-        expect(localStorage.getItem(API_TOKEN_KEY)).toBe("token");
-    });
-
-    it('should fire external url to login', () => {
-        const initialState = undefined;
-        window.location.assign = jest.fn();
-        authReducer(initialState, authActions.LOGIN());
-        expect(window.location.assign).toBeCalledWith(
-            `/login?return_to=${window.location.protocol}//${window.location.host}/token`
-        );
-    });
-
-    it('should fire external url to logout', () => {
-        const initialState = undefined;
-        window.location.assign = jest.fn();
-        authReducer(initialState, authActions.LOGOUT());
-        expect(window.location.assign).toBeCalledWith(
-            `/logout?return_to=${location.protocol}//${location.host}`
-        );
     });
 });
index 366385d50b506de529139f3f75151762f6a6285a..1546212b08fe846834bb84d61cf098dedf2c40c3 100644 (file)
@@ -4,42 +4,28 @@
 
 import { authActions, AuthAction } from "./auth-action";
 import { User } from "../../models/user";
-import { authService } from "../../services/services";
-import { removeServerApiAuthorizationHeader, setServerApiAuthorizationHeader } from "../../common/api/server-api";
+import { ServiceRepository } from "../../services/services";
 
 export interface AuthState {
     user?: User;
     apiToken?: string;
 }
 
-export const authReducer = (state: AuthState = {}, action: AuthAction) => {
+export const authReducer = (services: ServiceRepository) => (state: AuthState = {}, action: AuthAction) => {
     return authActions.match(action, {
         SAVE_API_TOKEN: (token: string) => {
-            authService.saveApiToken(token);
-            setServerApiAuthorizationHeader(token);
             return {...state, apiToken: token};
         },
-        INIT: () => {
-            const user = authService.getUser();
-            const token = authService.getApiToken();
-            if (token) {
-                setServerApiAuthorizationHeader(token);
-            }
-            return {user, apiToken: token};
+        INIT: ({ user, token }) => {
+            return { user, apiToken: token };
         },
         LOGIN: () => {
-            authService.login();
             return state;
         },
         LOGOUT: () => {
-            authService.removeApiToken();
-            authService.removeUser();
-            removeServerApiAuthorizationHeader();
-            authService.logout();
             return {...state, apiToken: undefined};
         },
         USER_DETAILS_SUCCESS: (user: User) => {
-            authService.saveUser(user);
             return {...state, user};
         },
         default: () => state
diff --git a/src/store/collection-panel/collection-panel-action.ts b/src/store/collection-panel/collection-panel-action.ts
new file mode 100644 (file)
index 0000000..ee95590
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { unionize, ofType, UnionOf } from "unionize";
+import { Dispatch } from "redux";
+import { ResourceKind } from "../../models/resource";
+import { CollectionResource } from "../../models/collection";
+import { collectionPanelFilesAction } from "./collection-panel-files/collection-panel-files-actions";
+import { createTree } from "../../models/tree";
+import { RootState } from "../store";
+import { ServiceRepository } from "../../services/services";
+
+export const collectionPanelActions = unionize({
+    LOAD_COLLECTION: ofType<{ uuid: string, kind: ResourceKind }>(),
+    LOAD_COLLECTION_SUCCESS: ofType<{ item: CollectionResource }>()
+}, { tag: 'type', value: 'payload' });
+
+export type CollectionPanelAction = UnionOf<typeof collectionPanelActions>;
+
+export const loadCollection = (uuid: string, kind: ResourceKind) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(collectionPanelActions.LOAD_COLLECTION({ uuid, kind }));
+        dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES({ files: createTree() }));
+        return services.collectionService
+            .get(uuid)
+            .then(item => {
+                dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item }));
+                return services.collectionFilesService.getFiles(item.uuid);
+            })
+            .then(files => {
+                dispatch(collectionPanelFilesAction.SET_COLLECTION_FILES(files));
+            });
+    };
+
+
+
diff --git a/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
new file mode 100644 (file)
index 0000000..463d49c
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { CollectionFilesTree } from "../../../models/collection-file";
+
+export const collectionPanelFilesAction = unionize({
+    SET_COLLECTION_FILES: ofType<CollectionFilesTree>(),
+    TOGGLE_COLLECTION_FILE_COLLAPSE: ofType<{ id: string }>(),
+    TOGGLE_COLLECTION_FILE_SELECTION: ofType<{ id: string }>(),
+    SELECT_ALL_COLLECTION_FILES: ofType<{}>(),
+    UNSELECT_ALL_COLLECTION_FILES: ofType<{}>(),
+}, { tag: 'type', value: 'payload' });
+
+export type CollectionPanelFilesAction = UnionOf<typeof collectionPanelFilesAction>;
\ No newline at end of file
diff --git a/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.test.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.test.ts
new file mode 100644 (file)
index 0000000..1a6bb7d
--- /dev/null
@@ -0,0 +1,116 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionPanelFilesReducer } from "./collection-panel-files-reducer";
+import { collectionPanelFilesAction } from "./collection-panel-files-actions";
+import { CollectionFile, CollectionDirectory, createCollectionFile, createCollectionDirectory } from "../../../models/collection-file";
+import { createTree, setNode, getNodeValue, mapTreeValues, Tree } from "../../../models/tree";
+import { CollectionPanelFile, CollectionPanelDirectory } from "./collection-panel-files-state";
+
+describe('CollectionPanelFilesReducer', () => {
+
+    const files: Array<CollectionFile | CollectionDirectory> = [
+        createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', parentId: '' }),
+        createCollectionDirectory({ id: 'Directory 2', name: 'Directory 2', parentId: 'Directory 1' }),
+        createCollectionDirectory({ id: 'Directory 3', name: 'Directory 3', parentId: '' }),
+        createCollectionDirectory({ id: 'Directory 4', name: 'Directory 4', parentId: 'Directory 3' }),
+        createCollectionFile({ id: 'file1.txt', name: 'file1.txt', parentId: 'Directory 2' }),
+        createCollectionFile({ id: 'file2.txt', name: 'file2.txt', parentId: 'Directory 2' }),
+        createCollectionFile({ id: 'file3.txt', name: 'file3.txt', parentId: 'Directory 3' }),
+        createCollectionFile({ id: 'file4.txt', name: 'file4.txt', parentId: 'Directory 3' }),
+        createCollectionFile({ id: 'file5.txt', name: 'file5.txt', parentId: 'Directory 4' }),
+    ];
+
+    const collectionFilesTree = files.reduce((tree, file) => setNode({
+        children: [],
+        id: file.id,
+        parent: file.parentId,
+        value: file
+    })(tree), createTree<CollectionFile | CollectionDirectory>());
+
+    const collectionPanelFilesTree = collectionPanelFilesReducer(
+        createTree<CollectionPanelFile | CollectionPanelDirectory>(),
+        collectionPanelFilesAction.SET_COLLECTION_FILES(collectionFilesTree));
+
+    it('SET_COLLECTION_FILES', () => {
+        expect(getNodeValue('Directory 1')(collectionPanelFilesTree)).toEqual({
+            ...createCollectionDirectory({ id: 'Directory 1', name: 'Directory 1', parentId: '' }),
+            collapsed: true,
+            selected: false
+        });
+    });
+
+    it('TOGGLE_COLLECTION_FILE_COLLAPSE', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id: 'Directory 3' }));
+
+        const value = getNodeValue('Directory 3')(newTree)! as CollectionPanelDirectory;
+        expect(value.collapsed).toBe(false);
+    });
+
+    it('TOGGLE_COLLECTION_FILE_SELECTION', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 3' }));
+
+        const value = getNodeValue('Directory 3')(newTree);
+        expect(value!.selected).toBe(true);
+    });
+
+    it('TOGGLE_COLLECTION_FILE_SELECTION ancestors', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' }));
+
+        const value = getNodeValue('Directory 1')(newTree);
+        expect(value!.selected).toBe(true);
+    });
+
+    it('TOGGLE_COLLECTION_FILE_SELECTION descendants', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' }));
+        expect(getNodeValue('file1.txt')(newTree)!.selected).toBe(true);
+        expect(getNodeValue('file2.txt')(newTree)!.selected).toBe(true);
+    });
+
+    it('TOGGLE_COLLECTION_FILE_SELECTION unselect ancestors', () => {
+        const [newTree] = [collectionPanelFilesTree]
+            .map(tree => collectionPanelFilesReducer(
+                tree,
+                collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'Directory 2' })))
+            .map(tree => collectionPanelFilesReducer(
+                tree,
+                collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: 'file1.txt' })));
+
+        expect(getNodeValue('Directory 2')(newTree)!.selected).toBe(false);
+    });
+
+    it('SELECT_ALL_COLLECTION_FILES', () => {
+        const newTree = collectionPanelFilesReducer(
+            collectionPanelFilesTree,
+            collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+
+        mapTreeValues((v: CollectionPanelFile | CollectionPanelDirectory) => {
+            expect(v.selected).toEqual(true);
+            return v;
+        })(newTree);
+    });
+
+    it('SELECT_ALL_COLLECTION_FILES', () => {
+        const [newTree] = [collectionPanelFilesTree]
+            .map(tree => collectionPanelFilesReducer(
+                tree,
+                collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES()))
+            .map(tree => collectionPanelFilesReducer(
+                tree,
+                collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES()));
+
+        mapTreeValues((v: CollectionPanelFile | CollectionPanelDirectory) => {
+            expect(v.selected).toEqual(false);
+            return v;
+        })(newTree);
+    });
+});
diff --git a/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-reducer.ts
new file mode 100644 (file)
index 0000000..ca518f0
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionPanelFilesState, CollectionPanelFile, CollectionPanelDirectory, mapCollectionFileToCollectionPanelFile } 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 { CollectionFileType } from "../../../models/collection-file";
+
+export const collectionPanelFilesReducer = (state: CollectionPanelFilesState = createTree(), action: CollectionPanelFilesAction) => {
+    return collectionPanelFilesAction.match(action, {
+        SET_COLLECTION_FILES: files =>
+            mapTree(mapCollectionFileToCollectionPanelFile)(files),
+
+        TOGGLE_COLLECTION_FILE_COLLAPSE: data =>
+            toggleCollapse(data.id)(state),
+
+        TOGGLE_COLLECTION_FILE_SELECTION: data => [state]
+            .map(toggleSelected(data.id))
+            .map(toggleAncestors(data.id))
+            .map(toggleDescendants(data.id))[0],
+
+        SELECT_ALL_COLLECTION_FILES: () =>
+            mapTreeValues(v => ({ ...v, selected: true }))(state),
+
+        UNSELECT_ALL_COLLECTION_FILES: () =>
+            mapTreeValues(v => ({ ...v, selected: false }))(state),
+
+        default: () => state
+    }) as CollectionPanelFilesState;
+};
+
+const toggleCollapse = (id: string) => (tree: CollectionPanelFilesState) =>
+    setNodeValueWith((v: CollectionPanelDirectory | CollectionPanelFile) =>
+        v.type === CollectionFileType.DIRECTORY
+            ? { ...v, collapsed: !v.collapsed }
+            : v)(id)(tree);
+
+
+const toggleSelected = (id: string) => (tree: CollectionPanelFilesState) =>
+    setNodeValueWith((v: CollectionPanelDirectory | CollectionPanelFile) => ({ ...v, selected: !v.selected }))(id)(tree);
+
+
+const toggleDescendants = (id: string) => (tree: CollectionPanelFilesState) => {
+    const node = getNode(id)(tree);
+    if (node && node.value.type === CollectionFileType.DIRECTORY) {
+        return getNodeDescendants(id)(tree)
+            .reduce((newTree, id) =>
+                setNodeValueWith(v => ({ ...v, selected: node.value.selected }))(id)(newTree), tree);
+    }
+    return tree;
+};
+
+const toggleAncestors = (id: string) => (tree: CollectionPanelFilesState) => {
+    const ancestors = getNodeAncestors(id)(tree).reverse();
+    return ancestors.reduce((newTree, parent) => parent ? toggleParentNode(parent)(newTree) : newTree, tree);
+};
+
+const toggleParentNode = (id: string) => (tree: CollectionPanelFilesState) => {
+    const node = getNode(id)(tree);
+    if (node) {
+        const parentNode = getNode(node.id)(tree);
+        if (parentNode) {
+            const selected = parentNode.children
+                .map(id => getNode(id)(tree))
+                .every(node => node !== undefined && node.value.selected);
+            return setNodeValueWith(v => ({ ...v, selected }))(parentNode.id)(tree);
+        }
+        return setNode(node)(tree);
+    }
+    return tree;
+};
+
+
diff --git a/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts b/src/store/collection-panel/collection-panel-files/collection-panel-files-state.ts
new file mode 100644 (file)
index 0000000..d6f2fa4
--- /dev/null
@@ -0,0 +1,26 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionFile, CollectionDirectory, CollectionFileType } from '../../../models/collection-file';
+import { Tree, TreeNode } from '../../../models/tree';
+
+export type CollectionPanelFilesState = Tree<CollectionPanelDirectory | CollectionPanelFile>;
+
+export interface CollectionPanelDirectory extends CollectionDirectory {
+    collapsed: boolean;
+    selected: boolean;
+}
+
+export interface CollectionPanelFile extends CollectionFile {
+    selected: boolean;
+}
+
+export const mapCollectionFileToCollectionPanelFile = (node: TreeNode<CollectionDirectory | CollectionFile>): TreeNode<CollectionPanelDirectory | CollectionPanelFile> => {
+    return {
+        ...node,
+        value: node.value.type === CollectionFileType.DIRECTORY
+            ? { ...node.value, selected: false, collapsed: true }
+            : { ...node.value, selected: false }
+    };
+};
\ No newline at end of file
diff --git a/src/store/collection-panel/collection-panel-reducer.ts b/src/store/collection-panel/collection-panel-reducer.ts
new file mode 100644 (file)
index 0000000..0dd233e
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionPanelActions, CollectionPanelAction } from "./collection-panel-action";
+import { CollectionResource } from "../../models/collection";
+
+export interface CollectionPanelState {
+    item: CollectionResource | null;
+}
+
+const initialState = {
+    item: null
+};
+
+export const collectionPanelReducer = (state: CollectionPanelState = initialState, action: CollectionPanelAction) =>
+    collectionPanelActions.match(action, {
+        default: () => state,
+        LOAD_COLLECTION: () => state,
+        LOAD_COLLECTION_SUCCESS: ({ item }) => ({ ...state, item }),
+    });
diff --git a/src/store/collections/collections-reducer.ts b/src/store/collections/collections-reducer.ts
new file mode 100644 (file)
index 0000000..966cf29
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { combineReducers } from 'redux';
+import * as creator from "./creator/collection-creator-reducer";
+import * as updator from "./updator/collection-updator-reducer";
+
+export type CollectionsState = {
+    creator: creator.CollectionCreatorState;
+    updator: updator.CollectionUpdatorState;
+};
+
+export const collectionsReducer = combineReducers({
+    creator: creator.collectionCreationReducer,
+    updator: updator.collectionCreationReducer
+});
\ No newline at end of file
diff --git a/src/store/collections/creator/collection-creator-action.ts b/src/store/collections/creator/collection-creator-action.ts
new file mode 100644 (file)
index 0000000..2f2b838
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { Dispatch } from "redux";
+
+import { RootState } from "../../store";
+import { CollectionResource } from '../../../models/collection';
+import { ServiceRepository } from "../../../services/services";
+
+export const collectionCreateActions = unionize({
+    OPEN_COLLECTION_CREATOR: ofType<{ ownerUuid: string }>(),
+    CLOSE_COLLECTION_CREATOR: ofType<{}>(),
+    CREATE_COLLECTION: ofType<{}>(),
+    CREATE_COLLECTION_SUCCESS: ofType<{}>(),
+}, {
+        tag: 'type',
+        value: 'payload'
+    });
+
+export const createCollection = (collection: Partial<CollectionResource>) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { ownerUuid } = getState().collections.creator;
+        const collectiontData = { ownerUuid, ...collection };
+        dispatch(collectionCreateActions.CREATE_COLLECTION(collectiontData));
+        return services.collectionService
+            .create(collectiontData)
+            .then(collection => dispatch(collectionCreateActions.CREATE_COLLECTION_SUCCESS(collection)));
+    };
+
+export type CollectionCreateAction = UnionOf<typeof collectionCreateActions>;
diff --git a/src/store/collections/creator/collection-creator-reducer.test.ts b/src/store/collections/creator/collection-creator-reducer.test.ts
new file mode 100644 (file)
index 0000000..fde58c4
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionCreationReducer } from "./collection-creator-reducer";
+import { collectionCreateActions } from "./collection-creator-action";
+
+describe('collection-reducer', () => {
+
+    it('should open collection creator dialog', () => {
+        const initialState = { opened: false, ownerUuid: "" };
+        const collection = { opened: true, ownerUuid: "" };
+
+        const state = collectionCreationReducer(initialState, collectionCreateActions.OPEN_COLLECTION_CREATOR(initialState));
+        expect(state).toEqual(collection);
+    });
+
+    it('should close collection creator dialog', () => {
+        const initialState = { opened: true, ownerUuid: "" };
+        const collection = { opened: false, ownerUuid: "" };
+
+        const state = collectionCreationReducer(initialState, collectionCreateActions.CLOSE_COLLECTION_CREATOR());
+        expect(state).toEqual(collection);
+    });
+
+    it('should reset collection creator dialog props', () => {
+        const initialState = { opened: true, ownerUuid: "test" };
+        const collection = { opened: false, ownerUuid: "" };
+
+        const state = collectionCreationReducer(initialState, collectionCreateActions.CREATE_COLLECTION_SUCCESS());
+        expect(state).toEqual(collection);
+    });
+});
\ No newline at end of file
diff --git a/src/store/collections/creator/collection-creator-reducer.ts b/src/store/collections/creator/collection-creator-reducer.ts
new file mode 100644 (file)
index 0000000..1a3cb0d
--- /dev/null
@@ -0,0 +1,32 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionCreateActions, CollectionCreateAction } from './collection-creator-action';
+
+export type CollectionCreatorState = CollectionCreator;
+
+interface CollectionCreator {
+    opened: boolean;
+    ownerUuid: string;
+}
+
+const updateCreator = (state: CollectionCreatorState, creator?: Partial<CollectionCreator>) => ({
+    ...state,
+    ...creator
+});
+
+const initialState: CollectionCreatorState = {
+    opened: false,
+    ownerUuid: ''
+};
+
+export const collectionCreationReducer = (state: CollectionCreatorState = initialState, action: CollectionCreateAction) => {
+    return collectionCreateActions.match(action, {
+        OPEN_COLLECTION_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true }),
+        CLOSE_COLLECTION_CREATOR: () => updateCreator(state, { opened: false }),
+        CREATE_COLLECTION: () => updateCreator(state),
+        CREATE_COLLECTION_SUCCESS: () => updateCreator(state, { opened: false, ownerUuid: "" }),
+        default: () => state
+    });
+};
diff --git a/src/store/collections/updator/collection-updator-action.ts b/src/store/collections/updator/collection-updator-action.ts
new file mode 100644 (file)
index 0000000..e12bfe5
--- /dev/null
@@ -0,0 +1,47 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+import { Dispatch } from "redux";
+
+import { RootState } from "../../store";
+import { ServiceRepository } from "../../../services/services";
+import { CollectionResource } from '../../../models/collection';
+import { initialize } from 'redux-form';
+import { collectionPanelActions } from "../../collection-panel/collection-panel-action";
+
+export const collectionUpdatorActions = unionize({
+    OPEN_COLLECTION_UPDATOR: ofType<{ uuid: string }>(),
+    CLOSE_COLLECTION_UPDATOR: ofType<{}>(),
+    UPDATE_COLLECTION_SUCCESS: ofType<{}>(),
+}, {
+        tag: 'type',
+        value: 'payload'
+    });
+
+
+export const COLLECTION_FORM_NAME = 'collectionEditDialog';
+    
+export const openUpdator = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(collectionUpdatorActions.OPEN_COLLECTION_UPDATOR({ uuid }));
+        const item = getState().collectionPanel.item;
+        if(item) {
+            dispatch(initialize(COLLECTION_FORM_NAME, { name: item.name, description: item.description }));
+        }
+    };
+
+export const updateCollection = (collection: Partial<CollectionResource>) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { uuid } = getState().collections.updator;
+        return services.collectionService
+            .update(uuid, collection)
+            .then(collection => {
+                    dispatch(collectionPanelActions.LOAD_COLLECTION_SUCCESS({ item: collection as CollectionResource }));
+                    dispatch(collectionUpdatorActions.UPDATE_COLLECTION_SUCCESS());
+                }
+            );
+    };
+
+export type CollectionUpdatorAction = UnionOf<typeof collectionUpdatorActions>;
\ No newline at end of file
diff --git a/src/store/collections/updator/collection-updator-reducer.ts b/src/store/collections/updator/collection-updator-reducer.ts
new file mode 100644 (file)
index 0000000..b9d0250
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionUpdatorActions, CollectionUpdatorAction } from './collection-updator-action';
+
+export type CollectionUpdatorState = CollectionUpdator;
+
+interface CollectionUpdator {
+    opened: boolean;
+    uuid: string;
+}
+
+const updateCollection = (state: CollectionUpdatorState, updator?: Partial<CollectionUpdator>) => ({
+    ...state,
+    ...updator
+});
+
+const initialState: CollectionUpdatorState = {
+    opened: false,
+    uuid: ''
+};
+
+export const collectionCreationReducer = (state: CollectionUpdatorState = initialState, action: CollectionUpdatorAction) => {
+    return collectionUpdatorActions.match(action, {
+        OPEN_COLLECTION_UPDATOR: ({ uuid }) => updateCollection(state, { uuid, opened: true }),
+        CLOSE_COLLECTION_UPDATOR: () => updateCollection(state, { opened: false }),
+        UPDATE_COLLECTION_SUCCESS: () => updateCollection(state, { opened: false, uuid: "" }),
+        default: () => state
+    });
+};
index 7ce2b3e75449a705a1627cc4b62457eebc561d8b..8b51478d2bf980680be65249fab495ae5f7e9509 100644 (file)
@@ -2,7 +2,6 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ResourceKind } from "../../models/resource";
 import { contextMenuActions, ContextMenuAction } from "./context-menu-actions";
 
 export interface ContextMenuState {
index 053f4194ec8ddab68b3583bf346f21ea39f470ad..6dd7af92856a909b968fa328e0b9011df457a073 100644 (file)
@@ -20,3 +20,26 @@ export const dataExplorerActions = unionize({
 }, { tag: "type", value: "payload" });
 
 export type DataExplorerAction = UnionOf<typeof dataExplorerActions>;
+
+export const bindDataExplorerActions = (id: string) => ({
+    RESET_PAGINATION: () =>
+        dataExplorerActions.RESET_PAGINATION({ id }),
+    REQUEST_ITEMS: () =>
+        dataExplorerActions.REQUEST_ITEMS({ id }),
+    SET_COLUMNS: (payload: { columns: DataColumns<any> }) =>
+        dataExplorerActions.SET_COLUMNS({ ...payload, id }),
+    SET_FILTERS: (payload: { columnName: string, filters: DataTableFilterItem[] }) =>
+        dataExplorerActions.SET_FILTERS({ ...payload, id }),
+    SET_ITEMS: (payload: { items: any[], page: number, rowsPerPage: number, itemsAvailable: number }) =>
+        dataExplorerActions.SET_ITEMS({ ...payload, id }),
+    SET_PAGE: (payload: { page: number }) =>
+        dataExplorerActions.SET_PAGE({ ...payload, id }),
+    SET_ROWS_PER_PAGE: (payload: { rowsPerPage: number }) =>
+        dataExplorerActions.SET_ROWS_PER_PAGE({ ...payload, id }),
+    TOGGLE_COLUMN: (payload: { columnName: string }) =>
+        dataExplorerActions.TOGGLE_COLUMN({ ...payload, id }),
+    TOGGLE_SORT: (payload: { columnName: string }) =>
+        dataExplorerActions.TOGGLE_SORT({ ...payload, id }),
+    SET_SEARCH_VALUE: (payload: { searchValue: string }) =>
+        dataExplorerActions.SET_SEARCH_VALUE({ ...payload, id }),
+});
diff --git a/src/store/data-explorer/data-explorer-middleware-service.ts b/src/store/data-explorer/data-explorer-middleware-service.ts
new file mode 100644 (file)
index 0000000..14be4ea
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from "redux";
+import { RootState } from "../store";
+
+export abstract class DataExplorerMiddlewareService {
+    protected readonly id: string;
+
+    protected constructor(id: string) {
+        this.id = id;
+    }
+
+    public getId() {
+        return this.id;
+    }
+
+    abstract requestItems(api: MiddlewareAPI<Dispatch, RootState>): void;
+}
diff --git a/src/store/data-explorer/data-explorer-middleware.test.ts b/src/store/data-explorer/data-explorer-middleware.test.ts
new file mode 100644 (file)
index 0000000..6b8297b
--- /dev/null
@@ -0,0 +1,210 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataExplorerMiddlewareService } from "./data-explorer-middleware-service";
+import { dataExplorerMiddleware } from "./data-explorer-middleware";
+import { MiddlewareAPI } from "redux";
+import { DataColumns } from "../../components/data-table/data-table";
+import { dataExplorerActions } from "./data-explorer-action";
+
+
+describe("DataExplorerMiddleware", () => {
+    
+    it("handles only actions that are identified by service id", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [{
+                name: "Column",
+                selected: true,
+                configurable: false,
+                render: jest.fn()
+            }],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_PAGE({ id: "OtherId", page: 0 }));
+        middleware(dataExplorerActions.SET_PAGE({ id: "ServiceId", page: 0 }));
+        middleware(dataExplorerActions.SET_PAGE({ id: "OtherId", page: 0 }));
+        expect(api.dispatch).toHaveBeenCalledWith(dataExplorerActions.REQUEST_ITEMS({ id: "ServiceId" }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles REQUEST_ITEMS action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [{
+                name: "Column",
+                selected: true,
+                configurable: false,
+                render: jest.fn()
+            }],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.REQUEST_ITEMS({ id: "ServiceId" }));
+        expect(config.requestItems).toHaveBeenCalled();
+    });
+
+    it("handles SET_PAGE action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_PAGE({ id: service.getId(), page: 0 }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles SET_ROWS_PER_PAGE action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_ROWS_PER_PAGE({ id: service.getId(), rowsPerPage: 0 }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles SET_FILTERS action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_FILTERS({ id: service.getId(), columnName: "", filters: [] }));
+        expect(api.dispatch).toHaveBeenCalledTimes(2);
+    });
+
+    it("handles SET_ROWS_PER_PAGE action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_ROWS_PER_PAGE({ id: service.getId(), rowsPerPage: 0 }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles TOGGLE_SORT action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.TOGGLE_SORT({ id: service.getId(), columnName: "" }));
+        expect(api.dispatch).toHaveBeenCalledTimes(1);
+    });
+
+    it("handles SET_SEARCH_VALUE action", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_SEARCH_VALUE({ id: service.getId(), searchValue: "" }));
+        expect(api.dispatch).toHaveBeenCalledTimes(2);
+    });
+
+    it("forwards other actions", () => {
+        const config = {
+            id: "ServiceId",
+            columns: [],
+            requestItems: jest.fn(),
+            setApi: jest.fn()
+        };
+        const service = new ServiceMock(config);
+        const api = {
+            getState: jest.fn(),
+            dispatch: jest.fn()
+        };
+        const next = jest.fn();
+        const middleware = dataExplorerMiddleware(service)(api)(next);
+        middleware(dataExplorerActions.SET_COLUMNS({ id: service.getId(), columns: [] }));
+        middleware(dataExplorerActions.SET_ITEMS({ id: service.getId(), items: [], rowsPerPage: 0, itemsAvailable: 0, page: 0 }));
+        middleware(dataExplorerActions.TOGGLE_COLUMN({ id: service.getId(), columnName: "" }));
+        expect(api.dispatch).toHaveBeenCalledTimes(0);
+        expect(next).toHaveBeenCalledTimes(3);
+    });
+
+});
+
+class ServiceMock extends DataExplorerMiddlewareService {
+    constructor(private config: {
+        id: string,
+        columns: DataColumns<any>,
+        requestItems: (api: MiddlewareAPI) => void
+    }) {
+        super(config.id);
+    }
+
+    getColumns() {
+        return this.config.columns;
+    }
+
+    requestItems(api: MiddlewareAPI) {
+        this.config.requestItems(api);
+    }
+}
diff --git a/src/store/data-explorer/data-explorer-middleware.ts b/src/store/data-explorer/data-explorer-middleware.ts
new file mode 100644 (file)
index 0000000..146867c
--- /dev/null
@@ -0,0 +1,44 @@
+
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Middleware } from "redux";
+import { dataExplorerActions, bindDataExplorerActions } from "./data-explorer-action";
+import { DataExplorerMiddlewareService } from "./data-explorer-middleware-service";
+
+export const dataExplorerMiddleware = (service: DataExplorerMiddlewareService): Middleware => api => next => {
+    const handleAction = <T extends { id: string }>(handler: (data: T) => void) =>
+        (data: T) => {
+            if (data.id === service.getId()) {
+                handler(data);
+            }
+        };
+    const actions = bindDataExplorerActions(service.getId());
+
+    return action => {
+        dataExplorerActions.match(action, {
+            SET_PAGE: handleAction(() => {
+                api.dispatch(actions.REQUEST_ITEMS());
+            }),
+            SET_ROWS_PER_PAGE: handleAction(() => {
+                api.dispatch(actions.REQUEST_ITEMS());
+            }),
+            SET_FILTERS: handleAction(() => {
+                api.dispatch(actions.RESET_PAGINATION());
+                api.dispatch(actions.REQUEST_ITEMS());
+            }),
+            TOGGLE_SORT: handleAction(() => {
+                api.dispatch(actions.REQUEST_ITEMS());
+            }),
+            SET_SEARCH_VALUE: handleAction(() => {
+                api.dispatch(actions.RESET_PAGINATION());
+                api.dispatch(actions.REQUEST_ITEMS());
+            }),
+            REQUEST_ITEMS: handleAction(() => {
+                service.requestItems(api);
+            }),
+            default: () => next(action)
+        });
+    };
+};
index c112454b94f31451100d047d1e5f46f489e28e30..1fde652d07b3941bfa9b02207352101a72a4ba4a 100644 (file)
@@ -7,13 +7,13 @@ import { dataExplorerActions, DataExplorerAction } from "./data-explorer-action"
 import { DataTableFilterItem } from "../../components/data-table-filters/data-table-filters";
 import { DataColumns } from "../../components/data-table/data-table";
 
-interface DataExplorer {
+export interface DataExplorer {
     columns: DataColumns<any>;
     items: any[];
     itemsAvailable: number;
     page: number;
     rowsPerPage: number;
-    rowsPerPageOptions?: number[];
+    rowsPerPageOptions: number[];
     searchValue: string;
 }
 
@@ -27,7 +27,7 @@ export const initialDataExplorer: DataExplorer = {
     searchValue: ""
 };
 
-export type DataExplorerState = Record<string, DataExplorer | undefined>;
+export type DataExplorerState = Record<string, DataExplorer>;
 
 export const dataExplorerReducer = (state: DataExplorerState = {}, action: DataExplorerAction) =>
     dataExplorerActions.match(action, {
index 03212b9fc915bacfbc08f22d367d393b1f26b14b..c4acf5aa9b3710fac0a3f61a905132651f8709da 100644 (file)
@@ -3,10 +3,10 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "unionize";
-import { CommonResourceService } from "../../common/api/common-resource-service";
 import { Dispatch } from "redux";
-import { apiClient } from "../../common/api/server-api";
 import { Resource, ResourceKind } from "../../models/resource";
+import { RootState } from "../store";
+import { ServiceRepository } from "../../services/services";
 
 export const detailsPanelActions = unionize({
     TOGGLE_DETAILS_PANEL: ofType<{}>(),
@@ -17,23 +17,20 @@ export const detailsPanelActions = unionize({
 export type DetailsPanelAction = UnionOf<typeof detailsPanelActions>;
 
 export const loadDetails = (uuid: string, kind: ResourceKind) =>
-    (dispatch: Dispatch) => {
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(detailsPanelActions.LOAD_DETAILS({ uuid, kind }));
-        getService(kind)
-            .get(uuid)
-            .then(project => {
-                dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item: project }));
-            });
+        const item = await getService(services, kind).get(uuid);
+        dispatch(detailsPanelActions.LOAD_DETAILS_SUCCESS({ item }));
     };
 
-const getService = (kind: ResourceKind) => {
+const getService = (services: ServiceRepository, kind: ResourceKind) => {
     switch (kind) {
         case ResourceKind.PROJECT:
-            return new CommonResourceService(apiClient, "groups");
+            return services.projectService;
         case ResourceKind.COLLECTION:
-            return new CommonResourceService(apiClient, "collections");
+            return services.collectionService;
         default:
-            return new CommonResourceService(apiClient, "");
+            return services.projectService;
     }
 };
 
diff --git a/src/store/dialog/dialog-actions.ts b/src/store/dialog/dialog-actions.ts
new file mode 100644 (file)
index 0000000..df4418f
--- /dev/null
@@ -0,0 +1,15 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { default as unionize, ofType, UnionOf } from "unionize";
+
+export const dialogActions = unionize({
+    OPEN_DIALOG: ofType<{ id: string, data: any }>(),
+    CLOSE_DIALOG: ofType<{ id: string }>()
+}, {
+        tag: 'type',
+        value: 'payload'
+    });
+
+export type DialogAction = UnionOf<typeof dialogActions>;
diff --git a/src/store/dialog/dialog-reducer.test.ts b/src/store/dialog/dialog-reducer.test.ts
new file mode 100644 (file)
index 0000000..f0bf9be
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { dialogReducer } from "./dialog-reducer";
+import { dialogActions } from "./dialog-actions";
+
+describe('DialogReducer', () => {
+    it('OPEN_DIALOG', () => {
+        const id = 'test id';
+        const data = 'test data';
+        const state = dialogReducer({}, dialogActions.OPEN_DIALOG({ id, data }));
+        expect(state[id]).toEqual({ open: true, data });
+    });
+
+    it('CLOSE_DIALOG', () => {
+        const id = 'test id';
+        const state = dialogReducer({}, dialogActions.CLOSE_DIALOG({ id }));
+        expect(state[id]).toEqual({ open: false });
+    });
+    
+    it('CLOSE_DIALOG persist data', () => {
+        const id = 'test id';
+        const [newState] = [{}]
+            .map(state => dialogReducer(state, dialogActions.OPEN_DIALOG({ id, data: 'test data' })))
+            .map(state => dialogReducer(state, dialogActions.CLOSE_DIALOG({ id })));
+        
+        expect(newState[id]).toEqual({ open: false, data: 'test data' });
+    });
+});
diff --git a/src/store/dialog/dialog-reducer.ts b/src/store/dialog/dialog-reducer.ts
new file mode 100644 (file)
index 0000000..e49f65d
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DialogAction, dialogActions } from "./dialog-actions";
+
+export type DialogState = Record<string, Dialog>;
+
+export interface Dialog {
+    open: boolean;
+    data?: any;
+}
+
+export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
+    dialogActions.match(action, {
+        OPEN_DIALOG: ({ id, data }) => ({ ...state, [id]: { open: true, data } }),
+        CLOSE_DIALOG: ({ id }) => ({ 
+            ...state, 
+            [id]: state[id] ? { ...state[id], open: false } : { open: false } }),
+        default: () => state,
+    });
+
diff --git a/src/store/dialog/with-dialog.ts b/src/store/dialog/with-dialog.ts
new file mode 100644 (file)
index 0000000..42ae73e
--- /dev/null
@@ -0,0 +1,33 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { connect } from 'react-redux';
+import { DialogState } from './dialog-reducer';
+import { Dispatch } from 'redux';
+import { dialogActions } from './dialog-actions';
+
+export type WithDialog<T> = {
+    open: boolean;
+    data?: T;
+};
+
+export type WithDialogActions = {
+    closeDialog: () => void;
+};
+
+export const withDialog = (id: string) =>
+    <T>(component: React.ComponentType<WithDialog<T> & WithDialogActions>) =>
+        connect(mapStateToProps(id), mapDispatchToProps(id))(component);
+
+export const mapStateToProps = (id: string) => <T>(state: { dialog: DialogState }): WithDialog<T> => {
+    const dialog = state.dialog[id];
+    return dialog ? dialog : { open: false };
+};
+
+export const mapDispatchToProps = (id: string) => (dispatch: Dispatch): WithDialogActions => ({
+    closeDialog: () => {
+        dispatch(dialogActions.CLOSE_DIALOG({ id }));
+    }
+});
\ No newline at end of file
diff --git a/src/store/favorite-panel/favorite-panel-action.ts b/src/store/favorite-panel/favorite-panel-action.ts
new file mode 100644 (file)
index 0000000..aa1ec8d
--- /dev/null
@@ -0,0 +1,8 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
+
+export const FAVORITE_PANEL_ID = "favoritePanel";
+export const favoritePanelActions = bindDataExplorerActions(FAVORITE_PANEL_ID);
diff --git a/src/store/favorite-panel/favorite-panel-middleware-service.ts b/src/store/favorite-panel/favorite-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..62d9ae2
--- /dev/null
@@ -0,0 +1,70 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataExplorerMiddlewareService } from "../data-explorer/data-explorer-middleware-service";
+import { FavoritePanelFilter, FavoritePanelColumnNames } from "../../views/favorite-panel/favorite-panel";
+import { RootState } from "../store";
+import { DataColumns } from "../../components/data-table/data-table";
+import { FavoritePanelItem, resourceToDataItem } from "../../views/favorite-panel/favorite-panel-item";
+import { FavoriteOrderBuilder } from "../../services/favorite-service/favorite-order-builder";
+import { ServiceRepository } from "../../services/services";
+import { SortDirection } from "../../components/data-table/data-column";
+import { FilterBuilder } from "../../common/api/filter-builder";
+import { LinkResource } from "../../models/link";
+import { checkPresenceInFavorites } from "../favorites/favorites-actions";
+import { favoritePanelActions } from "./favorite-panel-action";
+import { Dispatch, MiddlewareAPI } from "redux";
+
+export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const dataExplorer = api.getState().dataExplorer[this.getId()];
+        const columns = dataExplorer.columns as DataColumns<FavoritePanelItem, FavoritePanelFilter>;
+        const sortColumn = dataExplorer.columns.find(
+            ({ sortDirection }) => sortDirection !== undefined && sortDirection !== "none"
+        );
+        const typeFilters = getColumnFilters(columns, FavoritePanelColumnNames.TYPE);
+        const order = FavoriteOrderBuilder.create();
+        if (typeFilters.length > 0) {
+            this.services.favoriteService
+                .list(this.services.authService.getUuid()!, {
+                    limit: dataExplorer.rowsPerPage,
+                    offset: dataExplorer.page * dataExplorer.rowsPerPage,
+                    order: sortColumn!.name === FavoritePanelColumnNames.NAME
+                        ? sortColumn!.sortDirection === SortDirection.ASC
+                            ? order.addDesc("name")
+                            : order.addAsc("name")
+                        : order,
+                    filters: FilterBuilder
+                        .create<LinkResource>()
+                        .addIsA("headUuid", typeFilters.map(filter => filter.type))
+                        .addILike("name", dataExplorer.searchValue)
+                })
+                .then(response => {
+                    api.dispatch(favoritePanelActions.SET_ITEMS({
+                        items: response.items.map(resourceToDataItem),
+                        itemsAvailable: response.itemsAvailable,
+                        page: Math.floor(response.offset / response.limit),
+                        rowsPerPage: response.limit
+                    }));
+                    api.dispatch<any>(checkPresenceInFavorites(response.items.map(item => item.uuid)));
+                });
+        } else {
+            api.dispatch(favoritePanelActions.SET_ITEMS({
+                items: [],
+                itemsAvailable: 0,
+                page: 0,
+                rowsPerPage: dataExplorer.rowsPerPage
+            }));
+        }
+    }
+}
+
+const getColumnFilters = (columns: DataColumns<FavoritePanelItem, FavoritePanelFilter>, columnName: string) => {
+    const column = columns.find(c => c.name === columnName);
+    return column && column.filters ? column.filters.filter(f => f.selected) : [];
+};
diff --git a/src/store/favorite-panel/favorite-panel-middleware.ts b/src/store/favorite-panel/favorite-panel-middleware.ts
deleted file mode 100644 (file)
index 548a117..0000000
+++ /dev/null
@@ -1,125 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { Middleware } from "redux";
-import { dataExplorerActions } from "../data-explorer/data-explorer-action";
-import { favoriteService } from "../../services/services";
-import { RootState } from "../store";
-import { getDataExplorer } from "../data-explorer/data-explorer-reducer";
-import { FilterBuilder } from "../../common/api/filter-builder";
-import { DataColumns } from "../../components/data-table/data-table";
-import {
-    columns,
-    FAVORITE_PANEL_ID,
-    FavoritePanelColumnNames,
-    FavoritePanelFilter
-} from "../../views/favorite-panel/favorite-panel";
-import { FavoritePanelItem, resourceToDataItem } from "../../views/favorite-panel/favorite-panel-item";
-import { LinkResource } from "../../models/link";
-import { checkPresenceInFavorites } from "../favorites/favorites-actions";
-import { OrderBuilder } from "../../common/api/order-builder";
-import { SortDirection } from "../../components/data-table/data-column";
-import { GroupContentsResource, GroupContentsResourcePrefix } from "../../services/groups-service/groups-service";
-import { FavoriteOrderBuilder } from "../../services/favorite-service/favorite-order-builder";
-
-export const favoritePanelMiddleware: Middleware = store => next => {
-    next(dataExplorerActions.SET_COLUMNS({ id: FAVORITE_PANEL_ID, columns }));
-
-    return action => {
-
-        const handlePanelAction = <T extends { id: string }>(handler: (data: T) => void) =>
-            (data: T) => {
-                next(action);
-                if (data.id === FAVORITE_PANEL_ID) {
-                    handler(data);
-                }
-            };
-
-        dataExplorerActions.match(action, {
-            SET_PAGE: handlePanelAction(() => {
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID }));
-            }),
-            SET_ROWS_PER_PAGE: handlePanelAction(() => {
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID }));
-            }),
-            SET_FILTERS: handlePanelAction(() => {
-                store.dispatch(dataExplorerActions.RESET_PAGINATION({ id: FAVORITE_PANEL_ID }));
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID }));
-            }),
-            TOGGLE_SORT: handlePanelAction(() => {
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID }));
-            }),
-            SET_SEARCH_VALUE: handlePanelAction(() => {
-                store.dispatch(dataExplorerActions.RESET_PAGINATION({ id: FAVORITE_PANEL_ID }));
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID }));
-            }),
-            REQUEST_ITEMS: handlePanelAction(() => {
-                const state = store.getState() as RootState;
-                const dataExplorer = getDataExplorer(state.dataExplorer, FAVORITE_PANEL_ID);
-                const columns = dataExplorer.columns as DataColumns<FavoritePanelItem, FavoritePanelFilter>;
-                const sortColumn = dataExplorer.columns.find(({ sortDirection }) => Boolean(sortDirection && sortDirection !== "none"));
-                const typeFilters = getColumnFilters(columns, FavoritePanelColumnNames.TYPE);
-                const order = FavoriteOrderBuilder.create();
-                if (typeFilters.length > 0) {
-                    favoriteService
-                        .list(state.projects.currentItemId, {
-                            limit: dataExplorer.rowsPerPage,
-                            offset: dataExplorer.page * dataExplorer.rowsPerPage,
-                            order: sortColumn!.name === FavoritePanelColumnNames.NAME
-                                ? sortColumn!.sortDirection === SortDirection.ASC
-                                    ? order.addDesc("name")
-                                    : order.addAsc("name")
-                                : order,
-                            filters: FilterBuilder
-                                .create<LinkResource>()
-                                .addIsA("headUuid", typeFilters.map(filter => filter.type))
-                                .addILike("name", dataExplorer.searchValue)
-                        })
-                        .then(response => {
-                            store.dispatch(dataExplorerActions.SET_ITEMS({
-                                id: FAVORITE_PANEL_ID,
-                                items: response.items.map(resourceToDataItem),
-                                itemsAvailable: response.itemsAvailable,
-                                page: Math.floor(response.offset / response.limit),
-                                rowsPerPage: response.limit
-                            }));
-                            store.dispatch<any>(checkPresenceInFavorites(response.items.map(item => item.uuid)));
-                        });
-                } else {
-                    store.dispatch(dataExplorerActions.SET_ITEMS({
-                        id: FAVORITE_PANEL_ID,
-                        items: [],
-                        itemsAvailable: 0,
-                        page: 0,
-                        rowsPerPage: dataExplorer.rowsPerPage
-                    }));
-                }
-            }),
-            default: () => next(action)
-        });
-    };
-};
-
-const getOrder = (direction: SortDirection) => {
-    const order = OrderBuilder.create<LinkResource>();
-    const addRule = (builder: OrderBuilder<GroupContentsResource | LinkResource>, direction: SortDirection) =>
-        direction === SortDirection.ASC
-            ? builder.addAsc("name")
-            : builder.addDesc("name");
-
-    return [
-        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.COLLECTION),
-        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROCESS),
-        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROJECT)
-    ].reduce((acc, b) =>
-        acc.concat(addRule(b, direction)), addRule(OrderBuilder.create(), direction));
-};
-
-const getColumnFilters = (columns: DataColumns<FavoritePanelItem, FavoritePanelFilter>, columnName: string) => {
-    const column = columns.find(c => c.name === columnName);
-    return column && column.filters ? column.filters.filter(f => f.selected) : [];
-};
-
-
-
index eb4f649025d3b03d27b3c342b410519c2f074734..38229dff8390424fd26686da2158d6f59b22ceea 100644 (file)
@@ -4,10 +4,10 @@
 
 import { unionize, ofType, UnionOf } from "unionize";
 import { Dispatch } from "redux";
-import { favoriteService } from "../../services/services";
 import { RootState } from "../store";
 import { checkFavorite } from "./favorites-reducer";
 import { snackbarActions } from "../snackbar/snackbar-actions";
+import { ServiceRepository } from "../../services/services";
 
 export const favoritesActions = unionize({
     TOGGLE_FAVORITE: ofType<{ resourceUuid: string }>(),
@@ -18,14 +18,14 @@ export const favoritesActions = unionize({
 export type FavoritesAction = UnionOf<typeof favoritesActions>;
 
 export const toggleFavorite = (resource: { uuid: string; name: string }) =>
-    (dispatch: Dispatch, getState: () => RootState): Promise<any> => {
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
         const userUuid = getState().auth.user!.uuid;
         dispatch(favoritesActions.TOGGLE_FAVORITE({ resourceUuid: resource.uuid }));
         dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Working..." }));
         const isFavorite = checkFavorite(resource.uuid, getState().favorites);
         const promise: any = isFavorite
-            ? favoriteService.delete({ userUuid, resourceUuid: resource.uuid })
-            : favoriteService.create({ userUuid, resource });
+            ? services.favoriteService.delete({ userUuid, resourceUuid: resource.uuid })
+            : services.favoriteService.create({ userUuid, resource });
 
         return promise
             .then(() => {
@@ -41,12 +41,12 @@ export const toggleFavorite = (resource: { uuid: string; name: string }) =>
     };
 
 export const checkPresenceInFavorites = (resourceUuids: string[]) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const userUuid = getState().auth.user!.uuid;
         dispatch(favoritesActions.CHECK_PRESENCE_IN_FAVORITES(resourceUuids));
-        favoriteService
+        services.favoriteService
             .checkPresenceInFavorites(userUuid, resourceUuids)
-            .then(results => {
+            .then((results: any) => {
                 dispatch(favoritesActions.UPDATE_FAVORITES(results));
             });
     };
index 52086231742d8d9f37925c11cd41e95ef4395603..ffb0f7acf6f5fc3e69b1c0295936f6ee8c4eacc5 100644 (file)
@@ -7,15 +7,16 @@ import { projectActions, getProjectList } from "../project/project-action";
 import { push } from "react-router-redux";
 import { TreeItemStatus } from "../../components/tree/tree";
 import { findTreeItem } from "../project/project-reducer";
-import { dataExplorerActions } from "../data-explorer/data-explorer-action";
-import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
 import { RootState } from "../store";
 import { Resource, ResourceKind } from "../../models/resource";
+import { projectPanelActions } from "../project-panel/project-panel-action";
+import { getCollectionUrl } from "../../models/collection";
+import { getProjectUrl } from "../../models/project";
 
 export const getResourceUrl = <T extends Resource>(resource: T): string => {
     switch (resource.kind) {
-        case ResourceKind.PROJECT: return `/projects/${resource.uuid}`;
-        case ResourceKind.COLLECTION: return `/collections/${resource.uuid}`;
+        case ResourceKind.PROJECT: return getProjectUrl(resource.uuid);
+        case ResourceKind.COLLECTION: return getCollectionUrl(resource.uuid);
         default: return resource.href;
     }
 };
@@ -51,8 +52,8 @@ export const setProjectItem = (itemId: string, itemMode: ItemMode) =>
                     if (itemMode === ItemMode.OPEN || itemMode === ItemMode.BOTH) {
                         dispatch(projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(treeItem.data.uuid));
                     }
-                    dispatch(dataExplorerActions.RESET_PAGINATION({id: PROJECT_PANEL_ID}));
-                    dispatch(dataExplorerActions.REQUEST_ITEMS({id: PROJECT_PANEL_ID}));
+                    dispatch(projectPanelActions.RESET_PAGINATION());
+                    dispatch(projectPanelActions.REQUEST_ITEMS());
                 }));
 
         }
diff --git a/src/store/project-panel/project-panel-action.ts b/src/store/project-panel/project-panel-action.ts
new file mode 100644 (file)
index 0000000..33cedd7
--- /dev/null
@@ -0,0 +1,8 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
+
+export const PROJECT_PANEL_ID = "projectPanel";
+export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID);
diff --git a/src/store/project-panel/project-panel-middleware-service.ts b/src/store/project-panel/project-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..8d3f06a
--- /dev/null
@@ -0,0 +1,96 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { DataExplorerMiddlewareService } from "../data-explorer/data-explorer-middleware-service";
+import { ProjectPanelColumnNames, ProjectPanelFilter } from "../../views/project-panel/project-panel";
+import { RootState } from "../store";
+import { DataColumns } from "../../components/data-table/data-table";
+import { ServiceRepository } from "../../services/services";
+import { ProjectPanelItem, resourceToDataItem } from "../../views/project-panel/project-panel-item";
+import { SortDirection } from "../../components/data-table/data-column";
+import { OrderBuilder } from "../../common/api/order-builder";
+import { FilterBuilder } from "../../common/api/filter-builder";
+import { ProcessResource } from "../../models/process";
+import { GroupContentsResourcePrefix, GroupContentsResource } from "../../services/groups-service/groups-service";
+import { checkPresenceInFavorites } from "../favorites/favorites-actions";
+import { projectPanelActions } from "./project-panel-action";
+import { Dispatch, MiddlewareAPI } from "redux";
+
+export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+        const state = api.getState();
+        const dataExplorer = state.dataExplorer[this.getId()];
+        const columns = dataExplorer.columns as DataColumns<ProjectPanelItem, ProjectPanelFilter>;
+        const typeFilters = getColumnFilters(columns, ProjectPanelColumnNames.TYPE);
+        const statusFilters = getColumnFilters(columns, ProjectPanelColumnNames.STATUS);
+        const sortColumn = dataExplorer.columns.find(({ sortDirection }) => Boolean(sortDirection && sortDirection !== "none"));
+        const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC ? SortDirection.ASC : SortDirection.DESC;
+        if (typeFilters.length > 0) {
+            this.services.groupsService
+                .contents(state.projects.currentItemId, {
+                    limit: dataExplorer.rowsPerPage,
+                    offset: dataExplorer.page * dataExplorer.rowsPerPage,
+                    order: sortColumn
+                        ? sortColumn.name === ProjectPanelColumnNames.NAME
+                            ? getOrder("name", sortDirection)
+                            : getOrder("createdAt", sortDirection)
+                        : OrderBuilder.create(),
+                    filters: FilterBuilder
+                        .create()
+                        .concat(FilterBuilder
+                            .create()
+                            .addIsA("uuid", typeFilters.map(f => f.type)))
+                        .concat(FilterBuilder
+                            .create<ProcessResource>(GroupContentsResourcePrefix.PROCESS)
+                            .addIn("state", statusFilters.map(f => f.type)))
+                        .concat(getSearchFilter(dataExplorer.searchValue))
+                })
+                .then(response => {
+                    api.dispatch(projectPanelActions.SET_ITEMS({
+                        items: response.items.map(resourceToDataItem),
+                        itemsAvailable: response.itemsAvailable,
+                        page: Math.floor(response.offset / response.limit),
+                        rowsPerPage: response.limit
+                    }));
+                    api.dispatch<any>(checkPresenceInFavorites(response.items.map(item => item.uuid)));
+                });
+        } else {
+            api.dispatch(projectPanelActions.SET_ITEMS({
+                items: [],
+                itemsAvailable: 0,
+                page: 0,
+                rowsPerPage: dataExplorer.rowsPerPage
+            }));
+        }
+    }
+}
+
+const getColumnFilters = (columns: DataColumns<ProjectPanelItem, ProjectPanelFilter>, columnName: string) => {
+    const column = columns.find(c => c.name === columnName);
+    return column && column.filters ? column.filters.filter(f => f.selected) : [];
+};
+
+const getOrder = (attribute: "name" | "createdAt", direction: SortDirection) =>
+    [
+        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.COLLECTION),
+        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROCESS),
+        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROJECT)
+    ].reduce((acc, b) =>
+        acc.concat(direction === SortDirection.ASC
+            ? b.addAsc(attribute)
+            : b.addDesc(attribute)), OrderBuilder.create());
+
+const getSearchFilter = (searchValue: string) =>
+    searchValue
+        ? [
+            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.COLLECTION),
+            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROCESS),
+            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROJECT)]
+            .reduce((acc, b) =>
+                acc.concat(b.addILike("name", searchValue)), FilterBuilder.create())
+        : FilterBuilder.create();
diff --git a/src/store/project-panel/project-panel-middleware.ts b/src/store/project-panel/project-panel-middleware.ts
deleted file mode 100644 (file)
index b7ab03c..0000000
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import { Middleware } from "redux";
-import { dataExplorerActions } from "../data-explorer/data-explorer-action";
-import { PROJECT_PANEL_ID, columns, ProjectPanelFilter, ProjectPanelColumnNames } from "../../views/project-panel/project-panel";
-import { groupsService } from "../../services/services";
-import { RootState } from "../store";
-import { getDataExplorer } from "../data-explorer/data-explorer-reducer";
-import { resourceToDataItem, ProjectPanelItem } from "../../views/project-panel/project-panel-item";
-import { FilterBuilder } from "../../common/api/filter-builder";
-import { DataColumns } from "../../components/data-table/data-table";
-import { ProcessResource } from "../../models/process";
-import { OrderBuilder } from "../../common/api/order-builder";
-import { GroupContentsResource, GroupContentsResourcePrefix } from "../../services/groups-service/groups-service";
-import { SortDirection } from "../../components/data-table/data-column";
-import { checkPresenceInFavorites } from "../favorites/favorites-actions";
-
-export const projectPanelMiddleware: Middleware = store => next => {
-    next(dataExplorerActions.SET_COLUMNS({ id: PROJECT_PANEL_ID, columns }));
-
-    return action => {
-
-        const handleProjectPanelAction = <T extends { id: string }>(handler: (data: T) => void) =>
-            (data: T) => {
-                next(action);
-                if (data.id === PROJECT_PANEL_ID) {
-                    handler(data);
-                }
-            };
-
-        dataExplorerActions.match(action, {
-            SET_PAGE: handleProjectPanelAction(() => {
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
-            }),
-            SET_ROWS_PER_PAGE: handleProjectPanelAction(() => {
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
-            }),
-            SET_FILTERS: handleProjectPanelAction(() => {
-                store.dispatch(dataExplorerActions.RESET_PAGINATION({ id: PROJECT_PANEL_ID }));
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
-            }),
-            TOGGLE_SORT: handleProjectPanelAction(() => {
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
-            }),
-            SET_SEARCH_VALUE: handleProjectPanelAction(() => {
-                store.dispatch(dataExplorerActions.RESET_PAGINATION({ id: PROJECT_PANEL_ID }));
-                store.dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
-            }),
-            REQUEST_ITEMS: handleProjectPanelAction(() => {
-                const state = store.getState() as RootState;
-                const dataExplorer = getDataExplorer(state.dataExplorer, PROJECT_PANEL_ID);
-                const columns = dataExplorer.columns as DataColumns<ProjectPanelItem, ProjectPanelFilter>;
-                const typeFilters = getColumnFilters(columns, ProjectPanelColumnNames.TYPE);
-                const statusFilters = getColumnFilters(columns, ProjectPanelColumnNames.STATUS);
-                const sortColumn = dataExplorer.columns.find(({ sortDirection }) => Boolean(sortDirection && sortDirection !== "none"));
-                const sortDirection = sortColumn && sortColumn.sortDirection === SortDirection.ASC ? SortDirection.ASC : SortDirection.DESC;
-                if (typeFilters.length > 0) {
-                    groupsService
-                        .contents(state.projects.currentItemId, {
-                            limit: dataExplorer.rowsPerPage,
-                            offset: dataExplorer.page * dataExplorer.rowsPerPage,
-                            order: sortColumn
-                                ? sortColumn.name === ProjectPanelColumnNames.NAME
-                                    ? getOrder("name", sortDirection)
-                                    : getOrder("createdAt", sortDirection)
-                                : OrderBuilder.create(),
-                            filters: FilterBuilder
-                                .create()
-                                .concat(FilterBuilder
-                                    .create()
-                                    .addIsA("uuid", typeFilters.map(f => f.type)))
-                                .concat(FilterBuilder
-                                    .create<ProcessResource>(GroupContentsResourcePrefix.PROCESS)
-                                    .addIn("state", statusFilters.map(f => f.type)))
-                                .concat(getSearchFilter(dataExplorer.searchValue))
-                        })
-                        .then(response => {
-                            store.dispatch(dataExplorerActions.SET_ITEMS({
-                                id: PROJECT_PANEL_ID,
-                                items: response.items.map(resourceToDataItem),
-                                itemsAvailable: response.itemsAvailable,
-                                page: Math.floor(response.offset / response.limit),
-                                rowsPerPage: response.limit
-                            }));
-                            store.dispatch<any>(checkPresenceInFavorites(response.items.map(item => item.uuid)));
-                        });
-                } else {
-                    store.dispatch(dataExplorerActions.SET_ITEMS({
-                        id: PROJECT_PANEL_ID,
-                        items: [],
-                        itemsAvailable: 0,
-                        page: 0,
-                        rowsPerPage: dataExplorer.rowsPerPage
-                    }));
-                }
-            }),
-            default: () => next(action)
-        });
-    };
-};
-
-const getColumnFilters = (columns: DataColumns<ProjectPanelItem, ProjectPanelFilter>, columnName: string) => {
-    const column = columns.find(c => c.name === columnName);
-    return column && column.filters ? column.filters.filter(f => f.selected) : [];
-};
-
-const getOrder = (attribute: "name" | "createdAt", direction: SortDirection) =>
-    [
-        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.COLLECTION),
-        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROCESS),
-        OrderBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROJECT)
-    ].reduce((acc, b) =>
-        acc.concat(direction === SortDirection.ASC
-            ? b.addAsc(attribute)
-            : b.addDesc(attribute)), OrderBuilder.create());
-
-const getSearchFilter = (searchValue: string) =>
-    searchValue
-        ? [
-            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.COLLECTION),
-            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROCESS),
-            FilterBuilder.create<GroupContentsResource>(GroupContentsResourcePrefix.PROJECT)]
-            .reduce((acc, b) =>
-                acc.concat(b.addILike("name", searchValue)), FilterBuilder.create())
-        : FilterBuilder.create();
-
-
index cf38456109be0b25625214773f771c5eabc51713..2f9963bf9b3510f8b726475ba2fa35eee7864f58 100644 (file)
@@ -4,11 +4,11 @@
 import { default as unionize, ofType, UnionOf } from "unionize";
 
 import { ProjectResource } from "../../models/project";
-import { projectService } from "../../services/services";
 import { Dispatch } from "redux";
 import { FilterBuilder } from "../../common/api/filter-builder";
 import { RootState } from "../store";
 import { checkPresenceInFavorites } from "../favorites/favorites-actions";
+import { ServiceRepository } from "../../services/services";
 
 export const projectActions = unionize({
     OPEN_PROJECT_CREATOR: ofType<{ ownerUuid: string }>(),
@@ -26,9 +26,9 @@ export const projectActions = unionize({
         value: 'payload'
     });
 
-export const getProjectList = (parentUuid: string = '') => (dispatch: Dispatch, getState: () => RootState) => {
+export const getProjectList = (parentUuid: string = '') => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     dispatch(projectActions.PROJECTS_REQUEST(parentUuid));
-    return projectService.list({
+    return services.projectService.list({
         filters: FilterBuilder
             .create<ProjectResource>()
             .addEqual("ownerUuid", parentUuid)
@@ -40,11 +40,11 @@ export const getProjectList = (parentUuid: string = '') => (dispatch: Dispatch,
 };
 
 export const createProject = (project: Partial<ProjectResource>) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { ownerUuid } = getState().projects.creator;
         const projectData = { ownerUuid, ...project };
         dispatch(projectActions.CREATE_PROJECT(projectData));
-        return projectService
+        return services.projectService
             .create(projectData)
             .then(project => dispatch(projectActions.CREATE_PROJECT_SUCCESS(project)));
     };
index 0fc28960b6652a154bc3ffccc552be3cc9de18f8..92274b3d281efba4886f8aff65687e4d6625e426 100644 (file)
@@ -35,7 +35,6 @@ describe('project-reducer', () => {
             creator: {
                 opened: false,
                 ownerUuid: "",
-                pending: false
             }
         });
     });
@@ -50,7 +49,7 @@ describe('project-reducer', () => {
                 status: TreeItemStatus.PENDING
             }],
             currentItemId: "1",
-            creator: { opened: false, pending: false, ownerUuid: "" },
+            creator: { opened: false, ownerUuid: "" },
         };
         const project = {
             items: [{
@@ -61,7 +60,7 @@ describe('project-reducer', () => {
                 status: TreeItemStatus.PENDING
             }],
             currentItemId: "",
-            creator: { opened: false, pending: false, ownerUuid: "" },
+            creator: { opened: false, ownerUuid: "" },
         };
 
         const state = projectsReducer(initialState, projectActions.RESET_PROJECT_TREE_ACTIVITY(initialState.items[0].id));
@@ -78,7 +77,7 @@ describe('project-reducer', () => {
                 status: TreeItemStatus.PENDING
             }],
             currentItemId: "1",
-            creator: { opened: false, pending: false, ownerUuid: "" }
+            creator: { opened: false, ownerUuid: "" }
         };
         const project = {
             items: [{
@@ -89,7 +88,7 @@ describe('project-reducer', () => {
                 status: TreeItemStatus.PENDING,
             }],
             currentItemId: "1",
-            creator: { opened: false, pending: false, ownerUuid: "" },
+            creator: { opened: false, ownerUuid: "" },
         };
 
         const state = projectsReducer(initialState, projectActions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(initialState.items[0].id));
@@ -107,7 +106,7 @@ describe('project-reducer', () => {
                 status: TreeItemStatus.PENDING,
             }],
             currentItemId: "1",
-            creator: { opened: false, pending: false, ownerUuid: "" }
+            creator: { opened: false, ownerUuid: "" }
         };
         const project = {
             items: [{
@@ -118,7 +117,7 @@ describe('project-reducer', () => {
                 status: TreeItemStatus.PENDING,
             }],
             currentItemId: "1",
-            creator: { opened: false, pending: false, ownerUuid: "" },
+            creator: { opened: false, ownerUuid: "" },
         };
 
         const state = projectsReducer(initialState, projectActions.TOGGLE_PROJECT_TREE_ITEM_OPEN(initialState.items[0].id));
index 45e0e19527253ea03e39603a673e8e477232c484..f5af23ab3a3f6e5501bcd115ab0bf994b79ab0e1 100644 (file)
@@ -16,7 +16,6 @@ export type ProjectState = {
 
 interface ProjectCreator {
     opened: boolean;
-    pending: boolean;
     ownerUuid: string;
     error?: string;
 }
@@ -106,7 +105,6 @@ const initialState: ProjectState = {
     currentItemId: "",
     creator: {
         opened: false,
-        pending: false,
         ownerUuid: ""
     }
 };
@@ -114,7 +112,7 @@ const initialState: ProjectState = {
 
 export const projectsReducer = (state: ProjectState = initialState, action: ProjectAction) => {
     return projectActions.match(action, {
-        OPEN_PROJECT_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true, pending: false }),
+        OPEN_PROJECT_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true }),
         CLOSE_PROJECT_CREATOR: () => updateCreator(state, { opened: false }),
         CREATE_PROJECT: () => updateCreator(state, { error: undefined }),
         CREATE_PROJECT_SUCCESS: () => updateCreator(state, { opened: false, ownerUuid: "" }),
index 9d56102b149719e74454a69b7d3bf1d917ab7d8c..cae42ac38456f2d4d7b8835865dcf04367941dd6 100644 (file)
@@ -6,10 +6,9 @@ import * as _ from "lodash";
 import { sidePanelActions, SidePanelAction } from './side-panel-action';
 import { SidePanelItem } from '../../components/side-panel/side-panel';
 import { ProjectsIcon, ShareMeIcon, WorkflowIcon, RecentIcon, FavoriteIcon, TrashIcon } from "../../components/icon/icon";
-import { dataExplorerActions } from "../data-explorer/data-explorer-action";
 import { Dispatch } from "redux";
-import { FAVORITE_PANEL_ID } from "../../views/favorite-panel/favorite-panel";
 import { push } from "react-router-redux";
+import { favoritePanelActions } from "../favorite-panel/favorite-panel-action";
 
 export type SidePanelState = SidePanelItem[];
 
@@ -84,8 +83,8 @@ export const sidePanelData = [
         active: false,
         activeAction: (dispatch: Dispatch) => {
             dispatch(push("/favorites"));
-            dispatch(dataExplorerActions.RESET_PAGINATION({id: FAVORITE_PANEL_ID}));
-            dispatch(dataExplorerActions.REQUEST_ITEMS({id: FAVORITE_PANEL_ID}));
+            dispatch(favoritePanelActions.RESET_PAGINATION());
+            dispatch(favoritePanelActions.REQUEST_ITEMS());
         }
     },
     {
index ae07744219d8f6d7b2db8cac751ca375dd0e68f7..aeb6a09cd388af3e559a41142590adcfc31234c1 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createStore, applyMiddleware, compose, Middleware, combineReducers } from 'redux';
+import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from 'redux';
 import { routerMiddleware, routerReducer, RouterState } from "react-router-redux";
 import thunkMiddleware from 'redux-thunk';
 import { History } from "history";
@@ -11,13 +11,22 @@ import { projectsReducer, ProjectState } from "./project/project-reducer";
 import { sidePanelReducer, SidePanelState } from './side-panel/side-panel-reducer';
 import { authReducer, AuthState } from "./auth/auth-reducer";
 import { dataExplorerReducer, DataExplorerState } from './data-explorer/data-explorer-reducer';
-import { projectPanelMiddleware } from './project-panel/project-panel-middleware';
 import { detailsPanelReducer, DetailsPanelState } from './details-panel/details-panel-reducer';
 import { contextMenuReducer, ContextMenuState } from './context-menu/context-menu-reducer';
-import { favoritePanelMiddleware } from "./favorite-panel/favorite-panel-middleware";
 import { reducer as formReducer } from 'redux-form';
 import { FavoritesState, favoritesReducer } from './favorites/favorites-reducer';
 import { snackbarReducer, SnackbarState } from './snackbar/snackbar-reducer';
+import { CollectionPanelFilesState } from './collection-panel/collection-panel-files/collection-panel-files-state';
+import { collectionPanelFilesReducer } from './collection-panel/collection-panel-files/collection-panel-files-reducer';
+import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware";
+import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action";
+import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action";
+import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service";
+import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service";
+import { CollectionPanelState, collectionPanelReducer } from './collection-panel/collection-panel-reducer';
+import { DialogState, dialogReducer } from './dialog/dialog-reducer';
+import { CollectionsState, collectionsReducer } from './collections/collections-reducer';
+import { ServiceRepository } from "../services/services";
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
@@ -27,33 +36,49 @@ const composeEnhancers =
 export interface RootState {
     auth: AuthState;
     projects: ProjectState;
+    collections: CollectionsState;
     router: RouterState;
     dataExplorer: DataExplorerState;
     sidePanel: SidePanelState;
+    collectionPanel: CollectionPanelState;
     detailsPanel: DetailsPanelState;
     contextMenu: ContextMenuState;
     favorites: FavoritesState;
     snackbar: SnackbarState;
+    collectionPanelFiles: CollectionPanelFilesState;
+    dialog: DialogState;
 }
 
-const rootReducer = combineReducers({
-    auth: authReducer,
-    projects: projectsReducer,
-    router: routerReducer,
-    dataExplorer: dataExplorerReducer,
-    sidePanel: sidePanelReducer,
-    detailsPanel: detailsPanelReducer,
-    contextMenu: contextMenuReducer,
-    form: formReducer,
-    favorites: favoritesReducer,
-    snackbar: snackbarReducer,
-});
-
-
-export function configureStore(history: History) {
+export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
+
+export function configureStore(history: History, services: ServiceRepository): RootStore {
+    const rootReducer = combineReducers({
+        auth: authReducer(services),
+        projects: projectsReducer,
+        collections: collectionsReducer,
+        router: routerReducer,
+        dataExplorer: dataExplorerReducer,
+        sidePanel: sidePanelReducer,
+        collectionPanel: collectionPanelReducer,
+        detailsPanel: detailsPanelReducer,
+        contextMenu: contextMenuReducer,
+        form: formReducer,
+        favorites: favoritesReducer,
+        snackbar: snackbarReducer,
+        collectionPanelFiles: collectionPanelFilesReducer,
+        dialog: dialogReducer
+    });
+
+    const projectPanelMiddleware = dataExplorerMiddleware(
+        new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID)
+    );
+    const favoritePanelMiddleware = dataExplorerMiddleware(
+        new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID)
+    );
+
     const middlewares: Middleware[] = [
         routerMiddleware(history),
-        thunkMiddleware,
+        thunkMiddleware.withExtraArgument(services),
         projectPanelMiddleware,
         favoritePanelMiddleware
     ];
index 928efdd2205e439c82fbd936e0e476e13f046139..527043d9d8567024c60546091f6b4103dcdbe684 100644 (file)
@@ -7,3 +7,5 @@ import { maxLength } from '../max-length';
 
 export const PROJECT_NAME_VALIDATION = [require, maxLength(255)];
 export const PROJECT_DESCRIPTION_VALIDATION = [maxLength(255)];
+export const COLLECTION_NAME_VALIDATION = [require, maxLength(255)];
+export const COLLECTION_DESCRIPTION_VALIDATION = [maxLength(255)];
index 1d017ccdffe754ab0fa7ca1dc2777b5fcd985c61..0ae41c657e34b925acb909084b848b9bb4cce31c 100644 (file)
@@ -5,12 +5,13 @@
 import { Redirect, RouteProps } from "react-router";
 import * as React from "react";
 import { connect, DispatchProp } from "react-redux";
-import { authActions, getUserDetails } from "../../store/auth/auth-action";
-import { authService } from "../../services/services";
+import { getUserDetails, saveApiToken } from "../../store/auth/auth-action";
 import { getProjectList } from "../../store/project/project-action";
 import { getUrlParameter } from "../../common/url";
+import { AuthService } from "../../services/auth-service/auth-service";
 
 interface ApiTokenProps {
+    authService: AuthService;
 }
 
 export const ApiToken = connect()(
@@ -18,9 +19,9 @@ export const ApiToken = connect()(
         componentDidMount() {
             const search = this.props.location ? this.props.location.search : "";
             const apiToken = getUrlParameter(search, 'api_token');
-            this.props.dispatch(authActions.SAVE_API_TOKEN(apiToken));
+            this.props.dispatch(saveApiToken(apiToken));
             this.props.dispatch<any>(getUserDetails()).then(() => {
-                const rootUuid = authService.getRootUuid();
+                const rootUuid = this.props.authService.getRootUuid();
                 this.props.dispatch(getProjectList(rootUuid));
             });
         }
diff --git a/src/views-components/collection-panel-files/collection-panel-files.ts b/src/views-components/collection-panel-files/collection-panel-files.ts
new file mode 100644 (file)
index 0000000..0009fc0
--- /dev/null
@@ -0,0 +1,86 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { CollectionPanelFiles as Component, CollectionPanelFilesProps } from "../../components/collection-panel-files/collection-panel-files";
+import { RootState } from "../../store/store";
+import { TreeItemStatus, TreeItem } from "../../components/tree/tree";
+import { CollectionPanelFilesState, CollectionPanelDirectory, CollectionPanelFile } from "../../store/collection-panel/collection-panel-files/collection-panel-files-state";
+import { FileTreeData } from "../../components/file-tree/file-tree-data";
+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 { CollectionFileType } from "../../models/collection-file";
+
+const memoizedMapStateToProps = () => {
+    let prevState: CollectionPanelFilesState;
+    let prevTree: Array<TreeItem<FileTreeData>>;
+
+    return (state: RootState): Pick<CollectionPanelFilesProps, "items"> => {
+        if (prevState !== state.collectionPanelFiles) {
+            prevState = state.collectionPanelFiles;
+            prevTree = getNodeChildren('')(state.collectionPanelFiles)
+                .map(collectionItemToTreeItem(state.collectionPanelFiles));
+        }
+        return {
+            items: prevTree
+        };
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
+    onUploadDataClick: () => { return; },
+    onCollapseToggle: (id) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
+    },
+    onSelectionToggle: (event, item) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id }));
+    },
+    onItemMenuOpen: (event, item) => {
+        event.preventDefault();
+        dispatch(contextMenuActions.OPEN_CONTEXT_MENU({
+            position: { x: event.clientX, y: event.clientY },
+            resource: { kind: ContextMenuKind.COLLECTION_FILES_ITEM, name: item.data.name, uuid: item.id }
+        }));
+    },
+    onOptionsMenuOpen: (event) =>
+        dispatch(contextMenuActions.OPEN_CONTEXT_MENU({
+            position: { x: event.clientX, y: event.clientY },
+            resource: { kind: ContextMenuKind.COLLECTION_FILES, name: '', uuid: '' }
+        }))
+});
+
+
+export const CollectionPanelFiles = connect(memoizedMapStateToProps(), mapDispatchToProps)(Component);
+
+const collectionItemToTreeItem = (tree: Tree<CollectionPanelDirectory | CollectionPanelFile>) =>
+    (id: string): TreeItem<FileTreeData> => {
+        const node = getNode(id)(tree) || {
+            id: '',
+            children: [],
+            parent: '',
+            value: {
+                name: 'Invalid node',
+                type: CollectionFileType.DIRECTORY,
+                selected: false,
+                collapsed: true
+            }
+        };
+        return {
+            active: false,
+            data: {
+                name: node.value.name,
+                size: node.value.type === CollectionFileType.FILE ? node.value.size : undefined,
+                type: node.value.type
+            },
+            id: node.id,
+            items: getNodeChildren(node.id)(tree)
+                .map(collectionItemToTreeItem(tree)),
+            open: node.value.type === CollectionFileType.DIRECTORY ? !node.value.collapsed : false,
+            selected: node.value.selected,
+            status: TreeItemStatus.LOADED
+        };
+    };
diff --git a/src/views-components/context-menu/action-sets/collection-action-set.ts b/src/views-components/context-menu/action-sets/collection-action-set.ts
new file mode 100644 (file)
index 0000000..566f8f1
--- /dev/null
@@ -0,0 +1,77 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ToggleFavoriteAction } from "../actions/favorite-action";
+import { toggleFavorite } from "../../../store/favorites/favorites-actions";
+import { RenameIcon, ShareIcon, MoveToIcon, CopyIcon, DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RemoveIcon } from "../../../components/icon/icon";
+import { openUpdator } from "../../../store/collections/updator/collection-updator-action";
+import { favoritePanelActions } from "../../../store/favorite-panel/favorite-panel-action";
+
+export const collectionActionSet: ContextMenuActionSet = [[
+    {
+        icon: RenameIcon,
+        name: "Edit collection",
+        execute: (dispatch, resource) => {
+            dispatch<any>(openUpdator(resource.uuid));
+        }
+    },
+    {
+        icon: ShareIcon,
+        name: "Share",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: MoveToIcon,
+        name: "Move to",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        component: ToggleFavoriteAction,
+        execute: (dispatch, resource) => {
+            dispatch<any>(toggleFavorite(resource)).then(() => {
+                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());           
+            });
+        }
+    },
+    {
+        icon: CopyIcon,
+        name: "Copy to project",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: DetailsIcon,
+        name: "View details",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: ProvenanceGraphIcon,
+        name: "Provenance graph",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: AdvancedIcon,
+        name: "Advanced",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    },
+    {
+        icon: RemoveIcon,
+        name: "Remove",
+        execute: (dispatch, resource) => {
+            // add code
+        }
+    }
+]];
diff --git a/src/views-components/context-menu/action-sets/collection-files-action-set.ts b/src/views-components/context-menu/action-sets/collection-files-action-set.ts
new file mode 100644 (file)
index 0000000..9396b9e
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import { collectionPanelFilesAction } from "../../../store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { openRemoveDialog } from "../../remove-dialog/remove-dialog";
+
+
+export const collectionFilesActionSet: ContextMenuActionSet = [[{
+    name: "Select all",
+    execute: (dispatch) => {
+        dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES());
+    }
+},{
+    name: "Unselect all",
+    execute: (dispatch) => {
+        dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES());
+    }
+},{
+    name: "Remove selected",
+    execute: (dispatch, resource) => {
+        dispatch(openRemoveDialog('selected files'));
+    }
+},{
+    name: "Download selected",
+    execute: (dispatch, resource) => {
+        return;
+    }
+},{
+    name: "Create a new collection with selected",
+    execute: (dispatch, resource) => {
+        return;
+    }
+}]];
diff --git a/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts b/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts
new file mode 100644 (file)
index 0000000..0b623ec
--- /dev/null
@@ -0,0 +1,29 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "../context-menu-action-set";
+import { RenameIcon, DownloadIcon, RemoveIcon } from "../../../components/icon/icon";
+import { openRemoveDialog } from "../../remove-dialog/remove-dialog";
+import { openRenameDialog } from "../../rename-dialog/rename-dialog";
+
+
+export const collectionFilesItemActionSet: ContextMenuActionSet = [[{
+    name: "Rename",
+    icon: RenameIcon,
+    execute: (dispatch, resource) => {
+        dispatch(openRenameDialog('the item'));
+    }
+},{
+    name: "Download",
+    icon: DownloadIcon,
+    execute: (dispatch, resource) => {
+        return;
+    }
+},{
+    name: "Remove",
+    icon: RemoveIcon,
+    execute: (dispatch, resource) => {
+        dispatch(openRemoveDialog('selected file'));
+    }
+}]];
index 58cf6cce40ea4c04a7a8e4ad537cae2853f72abf..72c72fa9cb0c6f44eecc548399877eedccfb06ce 100644 (file)
@@ -3,16 +3,15 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { ToggleFavoriteAction } from "./favorite-action";
+import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "../../../store/favorites/favorites-actions";
-import { dataExplorerActions } from "../../../store/data-explorer/data-explorer-action";
-import { FAVORITE_PANEL_ID } from "../../../views/favorite-panel/favorite-panel";
+import { favoritePanelActions } from "../../../store/favorite-panel/favorite-panel-action";
 
 export const favoriteActionSet: ContextMenuActionSet = [[{
     component: ToggleFavoriteAction,
     execute: (dispatch, resource) => {
         dispatch<any>(toggleFavorite(resource)).then(() => {
-            dispatch<any>(dataExplorerActions.REQUEST_ITEMS({ id : FAVORITE_PANEL_ID }));
+            dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
         });
     }
 }]];
index efcc429c49af2c53a64424545b60c0b92c6eb017..df298e4b1f8c1c472bae5fdc7c2b8d5ba8f464ad 100644 (file)
@@ -5,10 +5,9 @@
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { projectActions } from "../../../store/project/project-action";
 import { NewProjectIcon } from "../../../components/icon/icon";
-import { ToggleFavoriteAction } from "./favorite-action";
+import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "../../../store/favorites/favorites-actions";
-import { dataExplorerActions } from "../../../store/data-explorer/data-explorer-action";
-import { FAVORITE_PANEL_ID } from "../../../views/favorite-panel/favorite-panel";
+import { favoritePanelActions } from "../../../store/favorite-panel/favorite-panel-action";
 
 export const projectActionSet: ContextMenuActionSet = [[{
     icon: NewProjectIcon,
@@ -20,7 +19,7 @@ export const projectActionSet: ContextMenuActionSet = [[{
     component: ToggleFavoriteAction,
     execute: (dispatch, resource) => {
         dispatch<any>(toggleFavorite(resource)).then(() => {
-            dispatch<any>(dataExplorerActions.REQUEST_ITEMS({ id : FAVORITE_PANEL_ID }));
+            dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
         });
     }
 }]];
index 891528367bca93f119412f1195d4a92808115a8a..9585a865e7405bed40b0725654bf0d9edd4fda69 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { ToggleFavoriteAction } from "./favorite-action";
+import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "../../../store/favorites/favorites-actions";
 
 export const resourceActionSet: ContextMenuActionSet = [[{
similarity index 79%
rename from src/views-components/context-menu/action-sets/favorite-action.tsx
rename to src/views-components/context-menu/actions/favorite-action.tsx
index a4cf4e3bba646a439ba307ea5783466149e91973..55fe8cfdba19efe1c42b088e7ccc87471aaea97f 100644 (file)
@@ -4,12 +4,12 @@
 
 import * as React from "react";
 import { ListItemIcon, ListItemText } from "@material-ui/core";
-import { FavoriteIcon, AddFavoriteIcon, RemoveFavoriteIcon } from "../../../components/icon/icon";
+import { AddFavoriteIcon, RemoveFavoriteIcon } from "../../../components/icon/icon";
 import { connect } from "react-redux";
 import { RootState } from "../../../store/store";
 
 const mapStateToProps = (state: RootState) => ({
-    isFavorite: state.contextMenu.resource && state.favorites[state.contextMenu.resource.uuid] === true
+    isFavorite: state.contextMenu.resource !== undefined && state.favorites[state.contextMenu.resource.uuid] === true
 });
 
 export const ToggleFavoriteAction = connect(mapStateToProps)((props: { isFavorite: boolean }) =>
index 5b1049c13ed0e52c51409b594244a46d234638c0..8b00893703839c5bbd9fe64d932348ff08474f4b 100644 (file)
@@ -59,5 +59,8 @@ export enum ContextMenuKind {
     ROOT_PROJECT = "RootProject",
     PROJECT = "Project",
     RESOURCE = "Resource",
-    FAVORITE = "Favorite"
+    FAVORITE = "Favorite",
+    COLLECTION_FILES = "CollectionFiles",
+    COLLECTION_FILES_ITEM = "CollectionFilesItem",
+    COLLECTION = 'Collection'
 }
diff --git a/src/views-components/create-collection-dialog/create-collection-dialog.tsx b/src/views-components/create-collection-dialog/create-collection-dialog.tsx
new file mode 100644 (file)
index 0000000..0ba2b22
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
+import { SubmissionError } from "redux-form";
+
+import { RootState } from "../../store/store";
+import { DialogCollectionCreate } from "../dialog-create/dialog-collection-create";
+import { collectionCreateActions, createCollection } from "../../store/collections/creator/collection-creator-action";
+import { dataExplorerActions } from "../../store/data-explorer/data-explorer-action";
+import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
+import { snackbarActions } from "../../store/snackbar/snackbar-actions";
+
+const mapStateToProps = (state: RootState) => ({
+    open: state.collections.creator.opened
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    handleClose: () => {
+        dispatch(collectionCreateActions.CLOSE_COLLECTION_CREATOR());
+    },
+    onSubmit: (data: { name: string, description: string }) => {
+        return dispatch<any>(addCollection(data))
+            .catch((e: any) => {
+                throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Collection with this name already exists." : "" });
+            });
+    }
+});
+
+const addCollection = (data: { name: string, description: string }) =>
+    (dispatch: Dispatch) => {
+        return dispatch<any>(createCollection(data)).then(() => {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Collection has been successfully created.",
+                hideDuration: 2000
+            }));
+            dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
+        });
+    };
+
+export const CreateCollectionDialog = connect(mapStateToProps, mapDispatchToProps)(DialogCollectionCreate);
+
index cf5b24f0945316771ca52bdbb2b441a7423fa606..1a521890d71be9ac684cb57335dee9d935e29526 100644 (file)
@@ -9,8 +9,8 @@ import { SubmissionError } from "redux-form";
 import { RootState } from "../../store/store";
 import { DialogProjectCreate } from "../dialog-create/dialog-project-create";
 import { projectActions, createProject, getProjectList } from "../../store/project/project-action";
-import { dataExplorerActions } from "../../store/data-explorer/data-explorer-action";
-import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
+import { projectPanelActions } from "../../store/project-panel/project-panel-action";
+import { snackbarActions } from "../../store/snackbar/snackbar-actions";
 
 const mapStateToProps = (state: RootState) => ({
     open: state.projects.creator.opened
@@ -20,7 +20,11 @@ const addProject = (data: { name: string, description: string }) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const { ownerUuid } = getState().projects.creator;
         return dispatch<any>(createProject(data)).then(() => {
-            dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Created a new project",
+                hideDuration: 2000
+            }));
+            dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch<any>(getProjectList(ownerUuid));
         });
     };
index 2645504c612200b27dd3a2f7b778b85ba8f9174e..e13e8af0ec44934369a0ae5db0fdcb6b46987b2f 100644 (file)
@@ -10,9 +10,11 @@ import { Dispatch } from "redux";
 import { dataExplorerActions } from "../../store/data-explorer/data-explorer-action";
 import { DataColumn } from "../../components/data-table/data-column";
 import { DataTableFilterItem } from "../../components/data-table-filters/data-table-filters";
+import { DataColumns } from "../../components/data-table/data-table";
 
 interface Props {
     id: string;
+    columns: DataColumns<any>;
     onRowClick: (item: any) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
     onRowDoubleClick: (item: any) => void;
@@ -22,37 +24,46 @@ interface Props {
 const mapStateToProps = (state: RootState, { id }: Props) =>
     getDataExplorer(state.dataExplorer, id);
 
-const mapDispatchToProps = (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({
-    onSearch: (searchValue: string) => {
-        dispatch(dataExplorerActions.SET_SEARCH_VALUE({ id, searchValue }));
-    },
+const mapDispatchToProps = () => {
+    let prevColumns: DataColumns<any>;
+    return (dispatch: Dispatch, { id, columns, onRowClick, onRowDoubleClick, onContextMenu }: Props) => {
+        if (columns !== prevColumns) {
+            prevColumns = columns;
+            dispatch(dataExplorerActions.SET_COLUMNS({ id, columns }));
+        }
+        return {
+            onSearch: (searchValue: string) => {
+                dispatch(dataExplorerActions.SET_SEARCH_VALUE({ id, searchValue }));
+            },
 
-    onColumnToggle: (column: DataColumn<any>) => {
-        dispatch(dataExplorerActions.TOGGLE_COLUMN({ id, columnName: column.name }));
-    },
+            onColumnToggle: (column: DataColumn<any>) => {
+                dispatch(dataExplorerActions.TOGGLE_COLUMN({ id, columnName: column.name }));
+            },
 
-    onSortToggle: (column: DataColumn<any>) => {
-        dispatch(dataExplorerActions.TOGGLE_SORT({ id, columnName: column.name }));
-    },
+            onSortToggle: (column: DataColumn<any>) => {
+                dispatch(dataExplorerActions.TOGGLE_SORT({ id, columnName: column.name }));
+            },
 
-    onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<any>) => {
-        dispatch(dataExplorerActions.SET_FILTERS({ id, columnName: column.name, filters }));
-    },
+            onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<any>) => {
+                dispatch(dataExplorerActions.SET_FILTERS({ id, columnName: column.name, filters }));
+            },
 
-    onChangePage: (page: number) => {
-        dispatch(dataExplorerActions.SET_PAGE({ id, page }));
-    },
+            onChangePage: (page: number) => {
+                dispatch(dataExplorerActions.SET_PAGE({ id, page }));
+            },
 
-    onChangeRowsPerPage: (rowsPerPage: number) => {
-        dispatch(dataExplorerActions.SET_ROWS_PER_PAGE({ id, rowsPerPage }));
-    },
+            onChangeRowsPerPage: (rowsPerPage: number) => {
+                dispatch(dataExplorerActions.SET_ROWS_PER_PAGE({ id, rowsPerPage }));
+            },
 
-    onRowClick,
+            onRowClick,
 
-    onRowDoubleClick,
+            onRowDoubleClick,
 
-    onContextMenu,
-});
+            onContextMenu,
+        };
+    };
+};
 
-export const DataExplorer = connect(mapStateToProps, mapDispatchToProps)(DataExplorerComponent);
+export const DataExplorer = connect(mapStateToProps, mapDispatchToProps())(DataExplorerComponent);
 
diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx
new file mode 100644 (file)
index 0000000..2b99f02
--- /dev/null
@@ -0,0 +1,67 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Grid, Typography } from '@material-ui/core';
+import { FavoriteStar } from '../favorite-star/favorite-star';
+import { ResourceKind } from '../../models/resource';
+import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon } from '../../components/icon/icon';
+import { formatDate, formatFileSize } from '../../common/formatters';
+import { resourceLabel } from '../../common/labels';
+
+
+export const renderName = (item: {name: string; uuid: string, kind: string}) =>
+    <Grid container alignItems="center" wrap="nowrap" spacing={16}>
+        <Grid item>
+            {renderIcon(item)}
+        </Grid>
+        <Grid item>
+            <Typography color="primary">
+                {item.name}
+            </Typography>
+        </Grid>
+        <Grid item>
+            <Typography variant="caption">
+                <FavoriteStar resourceUuid={item.uuid} />
+            </Typography>
+        </Grid>
+    </Grid>;
+
+
+export const renderIcon = (item: {kind: string}) => {
+    switch (item.kind) {
+        case ResourceKind.PROJECT:
+            return <ProjectIcon />;
+        case ResourceKind.COLLECTION:
+            return <CollectionIcon />;
+        case ResourceKind.PROCESS:
+            return <ProcessIcon />;
+        default:
+            return <DefaultIcon />;
+    }
+};
+
+export const renderDate = (date: string) => {
+    return <Typography noWrap>{formatDate(date)}</Typography>;
+};
+
+export const renderFileSize = (fileSize?: number) =>
+    <Typography noWrap>
+        {formatFileSize(fileSize)}
+    </Typography>;
+
+export const renderOwner = (owner: string) =>
+    <Typography noWrap color="primary" >
+        {owner}
+    </Typography>;
+
+export const renderType = (type: string) =>
+    <Typography noWrap>
+        {resourceLabel(type)}
+    </Typography>;
+
+export const renderStatus = (item: {status?: string}) =>
+    <Typography noWrap align="center" >
+        {item.status || "-"}
+    </Typography>;
\ No newline at end of file
diff --git a/src/views-components/dialog-create/dialog-collection-create.tsx b/src/views-components/dialog-create/dialog-collection-create.tsx
new file mode 100644 (file)
index 0000000..3e3b74a
--- /dev/null
@@ -0,0 +1,123 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { reduxForm, Field } from 'redux-form';
+import { compose } from 'redux';
+import TextField from '@material-ui/core/TextField';
+import Dialog from '@material-ui/core/Dialog';
+import DialogActions from '@material-ui/core/DialogActions';
+import DialogContent from '@material-ui/core/DialogContent';
+import DialogTitle from '@material-ui/core/DialogTitle';
+import { Button, StyleRulesCallback, WithStyles, withStyles, CircularProgress } from '@material-ui/core';
+
+import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '../../validators/create-project/create-project-validator';
+
+type CssRules = "button" | "lastButton" | "formContainer" | "textField" | "createProgress" | "dialogActions";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+    button: {
+        marginLeft: theme.spacing.unit
+    },
+    lastButton: {
+        marginLeft: theme.spacing.unit,
+        marginRight: "20px",
+    },
+    formContainer: {
+        display: "flex",
+        flexDirection: "column",
+    },
+    textField: {
+        marginBottom: theme.spacing.unit * 3
+    },
+    createProgress: {
+        position: "absolute",
+        minWidth: "20px",
+        right: "110px"
+    },
+    dialogActions: {
+        marginBottom: theme.spacing.unit * 3
+    }
+});
+interface DialogCollectionCreateProps {
+    open: boolean;
+    handleClose: () => void;
+    onSubmit: (data: { name: string, description: string }) => void;
+    handleSubmit: any;
+    submitting: boolean;
+    invalid: boolean;
+    pristine: boolean;
+}
+
+interface TextFieldProps {
+    label: string;
+    floatinglabeltext: string;
+    className?: string;
+    input?: string;
+    meta?: any;
+}
+
+export const DialogCollectionCreate = compose(
+    reduxForm({ form: 'collectionCreateDialog' }),
+    withStyles(styles))(
+    class DialogCollectionCreate extends React.Component<DialogCollectionCreateProps & WithStyles<CssRules>> {
+        render() {
+            const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine } = this.props;
+
+            return (
+                <Dialog
+                    open={open}
+                    onClose={handleClose}
+                    fullWidth={true}
+                    maxWidth='sm'
+                    disableBackdropClick={true}
+                    disableEscapeKeyDown={true}>
+                    <form onSubmit={handleSubmit((data: any) => onSubmit(data))}>
+                        <DialogTitle id="form-dialog-title">Create a collection</DialogTitle>
+                        <DialogContent className={classes.formContainer}>
+                            <Field name="name"
+                                    disabled={submitting}
+                                    component={this.renderTextField}
+                                    floatinglabeltext="Collection Name"
+                                    validate={COLLECTION_NAME_VALIDATION}
+                                    className={classes.textField}
+                                    label="Collection Name"/>
+                            <Field name="description"
+                                    disabled={submitting}
+                                    component={this.renderTextField}
+                                    floatinglabeltext="Description - optional"
+                                    validate={COLLECTION_DESCRIPTION_VALIDATION}
+                                    className={classes.textField}
+                                    label="Description - optional"/>
+                        </DialogContent>
+                        <DialogActions className={classes.dialogActions}>
+                            <Button onClick={handleClose} className={classes.button} color="primary"
+                                    disabled={submitting}>CANCEL</Button>
+                            <Button type="submit"
+                                    className={classes.lastButton}
+                                    color="primary"
+                                    disabled={invalid || submitting || pristine}
+                                    variant="contained">
+                                CREATE A COLLECTION
+                            </Button>
+                            {submitting && <CircularProgress size={20} className={classes.createProgress}/>}
+                        </DialogActions>
+                    </form>
+                </Dialog>
+            );
+        }
+
+        renderTextField = ({ input, label, meta: { touched, error }, ...custom }: TextFieldProps) => (
+            <TextField
+                helperText={touched && error}
+                label={label}
+                className={this.props.classes.textField}
+                error={touched && !!error}
+                autoComplete='off'
+                {...input}
+                {...custom}
+            />
+        )
+    }
+);
index 592efc1a388a65f63a3471a2edfaa8a8eb674929..acfe3973b2d0dfdb69826f586fb568795c0f65a0 100644 (file)
@@ -54,6 +54,8 @@ interface DialogProjectProps {
     onSubmit: (data: { name: string, description: string }) => void;
     handleSubmit: any;
     submitting: boolean;
+    invalid: boolean;
+    pristine: boolean;
 }
 
 interface TextFieldProps {
@@ -69,7 +71,7 @@ export const DialogProjectCreate = compose(
     withStyles(styles))(
     class DialogProjectCreate extends React.Component<DialogProjectProps & WithStyles<CssRules>> {
         render() {
-            const { classes, open, handleClose, handleSubmit, onSubmit, submitting } = this.props;
+            const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine } = this.props;
 
             return (
                 <Dialog
@@ -101,7 +103,7 @@ export const DialogProjectCreate = compose(
                                 <Button type="submit"
                                         className={classes.lastButton}
                                         color="primary"
-                                        disabled={submitting}
+                                        disabled={invalid || submitting || pristine}
                                         variant="contained">
                                     CREATE A PROJECT
                                 </Button>
diff --git a/src/views-components/dialog-update/dialog-collection-update.tsx b/src/views-components/dialog-update/dialog-collection-update.tsx
new file mode 100644 (file)
index 0000000..80a82b2
--- /dev/null
@@ -0,0 +1,131 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { reduxForm, Field } from 'redux-form';
+import { compose } from 'redux';
+import { ArvadosTheme } from '../../common/custom-theme';
+import { Dialog, DialogActions, DialogContent, DialogTitle, TextField, StyleRulesCallback, withStyles, WithStyles, Button, CircularProgress } from '../../../node_modules/@material-ui/core';
+import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION } from '../../validators/create-project/create-project-validator';
+import { COLLECTION_FORM_NAME } from '../../store/collections/updator/collection-updator-action';
+
+type CssRules = 'content' | 'actions' | 'textField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    content: {
+        display: 'flex',
+        flexDirection: 'column'
+    },
+    actions: {
+        margin: 0,
+        padding: `${theme.spacing.unit}px ${theme.spacing.unit * 3 - theme.spacing.unit / 2}px 
+                ${theme.spacing.unit * 3}px ${theme.spacing.unit * 3}px`
+    },
+    textField: {
+        marginBottom: theme.spacing.unit * 3
+    },
+    buttonWrapper: {
+        position: 'relative'
+    },
+    saveButton: {
+        boxShadow: 'none'
+    },
+    circularProgress: {
+        position: 'absolute',
+        top: 0,
+        bottom: 0,
+        left: 0,
+        right: 0,
+        margin: 'auto'
+    }
+});
+
+interface DialogCollectionDataProps {
+    open: boolean;
+    handleSubmit: any;
+    submitting: boolean;
+    invalid: boolean;
+    pristine: boolean;
+}
+
+interface DialogCollectionAction {
+    handleClose: () => void;
+    onSubmit: (data: { name: string, description: string }) => void;
+}
+
+type DialogCollectionProps = DialogCollectionDataProps & DialogCollectionAction & WithStyles<CssRules>;
+
+interface TextFieldProps {
+    label: string;
+    floatinglabeltext: string;
+    className?: string;
+    input?: string;
+    meta?: any;
+}
+
+export const DialogCollectionUpdate = compose(
+    reduxForm({ form: COLLECTION_FORM_NAME }),
+    withStyles(styles))(
+
+        class DialogCollectionUpdate extends React.Component<DialogCollectionProps> {
+
+            render() {
+                const { classes, open, handleClose, handleSubmit, onSubmit, submitting, invalid, pristine } = this.props;
+                return (
+                    <Dialog open={open}
+                        onClose={handleClose}
+                        fullWidth={true}
+                        maxWidth='sm'
+                        disableBackdropClick={true}
+                        disableEscapeKeyDown={true}>
+
+                        <form onSubmit={handleSubmit((data: any) => onSubmit(data))}>
+                            <DialogTitle>Edit Collection</DialogTitle>
+                            <DialogContent className={classes.content}>
+                                <Field name="name"
+                                    disabled={submitting}
+                                    component={this.renderTextField}
+                                    floatinglabeltext="Collection Name"
+                                    validate={COLLECTION_NAME_VALIDATION}
+                                    className={classes.textField}
+                                    label="Collection Name" />
+                                <Field name="description"
+                                    disabled={submitting}
+                                    component={this.renderTextField}
+                                    floatinglabeltext="Description - optional"
+                                    validate={COLLECTION_DESCRIPTION_VALIDATION}
+                                    className={classes.textField}
+                                    label="Description - optional" />
+                            </DialogContent>
+                            <DialogActions className={classes.actions}>
+                                <Button onClick={handleClose} color="primary"
+                                    disabled={submitting}>CANCEL</Button>
+                                <div className={classes.buttonWrapper}>
+                                    <Button type="submit" className={classes.saveButton}
+                                        color="primary"
+                                        disabled={invalid || submitting || pristine}
+                                        variant="contained">
+                                        SAVE
+                                    </Button>
+                                    {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
+                                </div>
+                            </DialogActions>
+                        </form>
+                    </Dialog>
+                );
+            }
+
+            renderTextField = ({ input, label, meta: { touched, error }, ...custom }: TextFieldProps) => (
+                <TextField
+                    helperText={touched && error}
+                    label={label}
+                    className={this.props.classes.textField}
+                    error={touched && !!error}
+                    autoComplete='off'
+                    {...input}
+                    {...custom}
+                />
+            )
+        }
+    );
\ No newline at end of file
diff --git a/src/views-components/remove-dialog/remove-dialog.tsx b/src/views-components/remove-dialog/remove-dialog.tsx
new file mode 100644 (file)
index 0000000..f08727f
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from "@material-ui/core";
+import { withDialog } from "../../store/dialog/with-dialog";
+import { dialogActions } from "../../store/dialog/dialog-actions";
+
+export const REMOVE_DIALOG = 'removeCollectionFilesDialog';
+
+export const RemoveDialog = withDialog(REMOVE_DIALOG)(
+    (props) =>
+        <Dialog open={props.open}>
+            <DialogTitle>{`Removing ${props.data}`}</DialogTitle>
+            <DialogContent>
+                {`Are you sure you want to remove ${props.data}?`}
+            </DialogContent>
+            <DialogActions>
+                <Button
+                    variant='flat'
+                    color='primary'
+                    onClick={props.closeDialog}>
+                    Cancel
+                </Button>
+                <Button variant='raised' color='primary'>
+                    Remove
+                </Button>
+            </DialogActions>
+        </Dialog>
+);
+
+export const openRemoveDialog = (removedDataName: string) =>
+    dialogActions.OPEN_DIALOG({ id: REMOVE_DIALOG, data: removedDataName });
diff --git a/src/views-components/rename-dialog/rename-dialog.tsx b/src/views-components/rename-dialog/rename-dialog.tsx
new file mode 100644 (file)
index 0000000..8fe38cb
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, TextField, Typography } from "@material-ui/core";
+import { withDialog } from "../../store/dialog/with-dialog";
+import { dialogActions } from "../../store/dialog/dialog-actions";
+
+export const RENAME_DIALOG = 'nameDialog';
+
+export const RenameDialog = withDialog(RENAME_DIALOG)(
+    (props) =>
+        <Dialog open={props.open}>
+            <DialogTitle>{`Rename`}</DialogTitle>
+            <DialogContent>
+                <Typography variant='body1' gutterBottom>
+                    {`Please, enter a new name for ${props.data}`}
+                </Typography>
+                <TextField fullWidth={true} placeholder='New name' />
+            </DialogContent>
+            <DialogActions>
+                <Button
+                    variant='flat'
+                    color='primary'
+                    onClick={props.closeDialog}>
+                    Cancel
+                </Button>
+                <Button variant='raised' color='primary'>
+                    Ok
+                </Button>
+            </DialogActions>
+        </Dialog>
+);
+
+export const openRenameDialog = (originalName: string, ) =>
+    dialogActions.OPEN_DIALOG({ id: RENAME_DIALOG, data: originalName });
diff --git a/src/views-components/update-collection-dialog/update-collection-dialog..tsx b/src/views-components/update-collection-dialog/update-collection-dialog..tsx
new file mode 100644 (file)
index 0000000..a91277e
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
+import { SubmissionError } from "redux-form";
+import { RootState } from "../../store/store";
+import { snackbarActions } from "../../store/snackbar/snackbar-actions";
+import { collectionUpdatorActions, updateCollection } from "../../store/collections/updator/collection-updator-action";
+import { dataExplorerActions } from "../../store/data-explorer/data-explorer-action";
+import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
+import { DialogCollectionUpdate } from "../dialog-update/dialog-collection-update";
+
+const mapStateToProps = (state: RootState) => ({
+    open: state.collections.updator.opened
+});
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    handleClose: () => {
+        dispatch(collectionUpdatorActions.CLOSE_COLLECTION_UPDATOR());
+    },
+    onSubmit: (data: { name: string, description: string }) => {
+        return dispatch<any>(editCollection(data))
+            .catch((e: any) => {
+                if(e.errors) {
+                    throw new SubmissionError({ name: e.errors.join("").includes("UniqueViolation") ? "Collection with this name already exists." : "" });
+                }
+            });
+    }
+});
+
+const editCollection = (data: { name: string, description: string }) =>
+    (dispatch: Dispatch) => {
+        return dispatch<any>(updateCollection(data)).then(() => {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Collection has been successfully updated.",
+                hideDuration: 2000
+            }));
+            dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
+        });
+    };
+
+export const UpdateCollectionDialog = connect(mapStateToProps, mapDispatchToProps)(DialogCollectionUpdate);
\ No newline at end of file
diff --git a/src/views/collection-panel/collection-panel.tsx b/src/views/collection-panel/collection-panel.tsx
new file mode 100644 (file)
index 0000000..43423a6
--- /dev/null
@@ -0,0 +1,113 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import {
+    StyleRulesCallback, WithStyles, withStyles, Card,
+    CardHeader, IconButton, CardContent, Grid, Chip
+} from '@material-ui/core';
+import { connect } from 'react-redux';
+import { RouteComponentProps } from 'react-router';
+import { ArvadosTheme } from '../../common/custom-theme';
+import { RootState } from '../../store/store';
+import { MoreOptionsIcon, CollectionIcon, CopyIcon } from '../../components/icon/icon';
+import { DetailsAttribute } from '../../components/details-attribute/details-attribute';
+import { CollectionResource } from '../../models/collection';
+import { CollectionPanelFiles } from '../../views-components/collection-panel-files/collection-panel-files';
+import * as CopyToClipboard from 'react-copy-to-clipboard';
+
+type CssRules = 'card' | 'iconHeader' | 'tag' | 'copyIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        marginBottom: '20px'
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.yellow700
+    },
+    tag: {
+        marginRight: theme.spacing.unit
+    },
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        fontSize: '1.125rem',
+        cursor: 'pointer'
+    }
+});
+
+interface CollectionPanelDataProps {
+    item: CollectionResource;
+}
+
+interface CollectionPanelActionProps {
+    onItemRouteChange: (collectionId: string) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: CollectionResource) => void;
+}
+
+type CollectionPanelProps = CollectionPanelDataProps & CollectionPanelActionProps
+                            & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+
+
+export const CollectionPanel = withStyles(styles)(
+    connect((state: RootState) => ({ item: state.collectionPanel.item! }))(
+        class extends React.Component<CollectionPanelProps> {
+
+            render() {
+                const { classes, item, onContextMenu } = this.props;
+                return <div>
+                        <Card className={classes.card}>
+                            <CardHeader
+                                avatar={ <CollectionIcon className={classes.iconHeader} /> }
+                                action={
+                                    <IconButton
+                                        aria-label="More options"
+                                        onClick={event => onContextMenu(event, item)}>
+                                        <MoreOptionsIcon />
+                                    </IconButton>
+                                }
+                                title={item && item.name }
+                                subheader={item && item.description} />
+                            <CardContent>
+                                <Grid container direction="column">
+                                    <Grid item xs={6}>
+                                    <DetailsAttribute label='Collection UUID' value={item && item.uuid}>
+                                        <CopyToClipboard text={item && item.uuid}>
+                                            <CopyIcon className={classes.copyIcon} />
+                                        </CopyToClipboard>
+                                    </DetailsAttribute>
+                                    <DetailsAttribute label='Content size' value='54 MB' />
+                                    <DetailsAttribute label='Owner' value={item && item.ownerUuid} />
+                                    </Grid>
+                                </Grid>
+                            </CardContent>
+                        </Card>
+
+                        <Card className={classes.card}>
+                            <CardHeader title="Tags" />
+                            <CardContent>
+                                <Grid container direction="column">
+                                    <Grid item xs={4}>
+                                        <Chip label="Tag 1" className={classes.tag}/>
+                                        <Chip label="Tag 2" className={classes.tag}/>
+                                        <Chip label="Tag 3" className={classes.tag}/>
+                                    </Grid>
+                                </Grid>
+                            </CardContent>
+                        </Card>
+                        <div className={classes.card}>
+                            <CollectionPanelFiles/>
+                        </div>
+                    </div>;
+            }
+
+            componentWillReceiveProps({ match, item, onItemRouteChange }: CollectionPanelProps) {
+                if (!item || match.params.id !== item.uuid) {
+                    onItemRouteChange(match.params.id);
+                }
+            }
+
+        }
+    )
+);
index 36a3410763129b043c95c2d55af45a39814f0a15..f99afecb4cde7823d015d67ee278c14dab870b5b 100644 (file)
@@ -4,8 +4,7 @@
 
 import * as React from 'react';
 import { FavoritePanelItem } from './favorite-panel-item';
-import { Grid, Typography, Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
-import { formatDate, formatFileSize } from '../../common/formatters';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { DataExplorer } from "../../views-components/data-explorer/data-explorer";
 import { DispatchProp, connect } from 'react-redux';
 import { DataColumns } from '../../components/data-table/data-table';
@@ -16,9 +15,9 @@ import { ContainerRequestState } from '../../models/container-request';
 import { SortDirection } from '../../components/data-table/data-column';
 import { ResourceKind } from '../../models/resource';
 import { resourceLabel } from '../../common/labels';
-import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon } from '../../components/icon/icon';
 import { ArvadosTheme } from '../../common/custom-theme';
-import { FavoriteStar } from "../../views-components/favorite-star/favorite-star";
+import { renderName, renderStatus, renderType, renderOwner, renderFileSize, renderDate } from '../../views-components/data-explorer/renderers';
+import { FAVORITE_PANEL_ID } from "../../store/favorite-panel/favorite-panel-action";
 
 type CssRules = "toolbar" | "button";
 
@@ -32,61 +31,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
 });
 
-const renderName = (item: FavoritePanelItem) =>
-    <Grid container alignItems="center" wrap="nowrap" spacing={16}>
-        <Grid item>
-            {renderIcon(item)}
-        </Grid>
-        <Grid item>
-            <Typography color="primary">
-                {item.name}
-            </Typography>
-        </Grid>
-        <Grid item>
-            <Typography variant="caption">
-                <FavoriteStar resourceUuid={item.uuid} />
-            </Typography>
-        </Grid>
-    </Grid>;
-
-
-const renderIcon = (item: FavoritePanelItem) => {
-    switch (item.kind) {
-        case ResourceKind.PROJECT:
-            return <ProjectIcon />;
-        case ResourceKind.COLLECTION:
-            return <CollectionIcon />;
-        case ResourceKind.PROCESS:
-            return <ProcessIcon />;
-        default:
-            return <DefaultIcon />;
-    }
-};
-
-const renderDate = (date: string) => {
-    return <Typography noWrap>{formatDate(date)}</Typography>;
-};
-
-const renderFileSize = (fileSize?: number) =>
-    <Typography noWrap>
-        {formatFileSize(fileSize)}
-    </Typography>;
-
-const renderOwner = (owner: string) =>
-    <Typography noWrap color="primary" >
-        {owner}
-    </Typography>;
-
-const renderType = (type: string) =>
-    <Typography noWrap>
-        {resourceLabel(type)}
-    </Typography>;
-
-const renderStatus = (item: FavoritePanelItem) =>
-    <Typography noWrap align="center" >
-        {item.status || "-"}
-    </Typography>;
-
 export enum FavoritePanelColumnNames {
     NAME = "Name",
     STATUS = "Status",
@@ -104,6 +48,7 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
     {
         name: FavoritePanelColumnNames.NAME,
         selected: true,
+        configurable: true,
         sortDirection: SortDirection.ASC,
         render: renderName,
         width: "450px"
@@ -111,6 +56,7 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
     {
         name: "Status",
         selected: true,
+        configurable: true,
         filters: [
             {
                 name: ContainerRequestState.COMMITTED,
@@ -134,6 +80,7 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
     {
         name: FavoritePanelColumnNames.TYPE,
         selected: true,
+        configurable: true,
         filters: [
             {
                 name: resourceLabel(ResourceKind.COLLECTION),
@@ -157,26 +104,27 @@ export const columns: DataColumns<FavoritePanelItem, FavoritePanelFilter> = [
     {
         name: FavoritePanelColumnNames.OWNER,
         selected: true,
+        configurable: true,
         render: item => renderOwner(item.owner),
         width: "200px"
     },
     {
         name: FavoritePanelColumnNames.FILE_SIZE,
         selected: true,
+        configurable: true,
         render: item => renderFileSize(item.fileSize),
         width: "50px"
     },
     {
         name: FavoritePanelColumnNames.LAST_MODIFIED,
         selected: true,
+        configurable: true,
         sortDirection: SortDirection.NONE,
         render: item => renderDate(item.lastModified),
         width: "150px"
     }
 ];
 
-export const FAVORITE_PANEL_ID = "favoritePanel";
-
 interface FavoritePanelDataProps {
     currentItemId: string;
 }
@@ -198,6 +146,7 @@ export const FavoritePanel = withStyles(styles)(
             render() {
                 return <DataExplorer
                     id={FAVORITE_PANEL_ID}
+                    columns={columns}
                     onRowClick={this.props.onItemClick}
                     onRowDoubleClick={this.props.onItemDoubleClick}
                     onContextMenu={this.props.onContextMenu}
index 17b0fd723db9f07f2c5c05d7e905aa6b6823bb71..5c3fb2b0421837c43cf2ae721de6306c50358bd3 100644 (file)
@@ -4,8 +4,7 @@
 
 import * as React from 'react';
 import { ProjectPanelItem } from './project-panel-item';
-import { Grid, Typography, Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
-import { formatDate, formatFileSize } from '../../common/formatters';
+import { Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { DataExplorer } from "../../views-components/data-explorer/data-explorer";
 import { DispatchProp, connect } from 'react-redux';
 import { DataColumns } from '../../components/data-table/data-table';
@@ -16,9 +15,8 @@ import { ContainerRequestState } from '../../models/container-request';
 import { SortDirection } from '../../components/data-table/data-column';
 import { ResourceKind } from '../../models/resource';
 import { resourceLabel } from '../../common/labels';
-import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon, FavoriteIcon } from '../../components/icon/icon';
 import { ArvadosTheme } from '../../common/custom-theme';
-import { FavoriteStar } from '../../views-components/favorite-star/favorite-star';
+import { renderName, renderStatus, renderType, renderOwner, renderFileSize, renderDate } from '../../views-components/data-explorer/renderers';
 
 type CssRules = "toolbar" | "button";
 
@@ -32,61 +30,6 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
 });
 
-const renderName = (item: ProjectPanelItem) =>
-    <Grid container alignItems="center" wrap="nowrap" spacing={16}>
-        <Grid item>
-            {renderIcon(item)}
-        </Grid>
-        <Grid item>
-            <Typography color="primary">
-                {item.name}
-            </Typography>
-        </Grid>
-        <Grid item>
-            <Typography variant="caption">
-                <FavoriteStar resourceUuid={item.uuid} />
-            </Typography>
-        </Grid>
-    </Grid>;
-
-
-const renderIcon = (item: ProjectPanelItem) => {
-    switch (item.kind) {
-        case ResourceKind.PROJECT:
-            return <ProjectIcon />;
-        case ResourceKind.COLLECTION:
-            return <CollectionIcon />;
-        case ResourceKind.PROCESS:
-            return <ProcessIcon />;
-        default:
-            return <DefaultIcon />;
-    }
-};
-
-const renderDate = (date: string) => {
-    return <Typography noWrap>{formatDate(date)}</Typography>;
-};
-
-const renderFileSize = (fileSize?: number) =>
-    <Typography noWrap>
-        {formatFileSize(fileSize)}
-    </Typography>;
-
-const renderOwner = (owner: string) =>
-    <Typography noWrap color="primary" >
-        {owner}
-    </Typography>;
-
-const renderType = (type: string) =>
-    <Typography noWrap>
-        {resourceLabel(type)}
-    </Typography>;
-
-const renderStatus = (item: ProjectPanelItem) =>
-    <Typography noWrap align="center" >
-        {item.status || "-"}
-    </Typography>;
-
 export enum ProjectPanelColumnNames {
     NAME = "Name",
     STATUS = "Status",
@@ -104,6 +47,7 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
     {
         name: ProjectPanelColumnNames.NAME,
         selected: true,
+        configurable: true,
         sortDirection: SortDirection.ASC,
         render: renderName,
         width: "450px"
@@ -111,6 +55,7 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
     {
         name: "Status",
         selected: true,
+        configurable: true,
         filters: [
             {
                 name: ContainerRequestState.COMMITTED,
@@ -134,6 +79,7 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
     {
         name: ProjectPanelColumnNames.TYPE,
         selected: true,
+        configurable: true,
         filters: [
             {
                 name: resourceLabel(ResourceKind.COLLECTION),
@@ -157,18 +103,21 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [
     {
         name: ProjectPanelColumnNames.OWNER,
         selected: true,
+        configurable: true,
         render: item => renderOwner(item.owner),
         width: "200px"
     },
     {
         name: ProjectPanelColumnNames.FILE_SIZE,
         selected: true,
+        configurable: true,
         render: item => renderFileSize(item.fileSize),
         width: "50px"
     },
     {
         name: ProjectPanelColumnNames.LAST_MODIFIED,
         selected: true,
+        configurable: true,
         sortDirection: SortDirection.NONE,
         render: item => renderDate(item.lastModified),
         width: "150px"
@@ -184,7 +133,8 @@ interface ProjectPanelDataProps {
 interface ProjectPanelActionProps {
     onItemClick: (item: ProjectPanelItem) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: ProjectPanelItem) => void;
-    onDialogOpen: (ownerUuid: string) => void;
+    onProjectCreationDialogOpen: (ownerUuid: string) => void;
+    onCollectionCreationDialogOpen: (ownerUuid: string) => void;
     onItemDoubleClick: (item: ProjectPanelItem) => void;
     onItemRouteChange: (itemId: string) => void;
 }
@@ -199,7 +149,7 @@ export const ProjectPanel = withStyles(styles)(
                 const { classes } = this.props;
                 return <div>
                     <div className={classes.toolbar}>
-                        <Button color="primary" variant="raised" className={classes.button}>
+                        <Button color="primary" onClick={this.handleNewCollectionClick} variant="raised" className={classes.button}>
                             Create a collection
                         </Button>
                         <Button color="primary" variant="raised" className={classes.button}>
@@ -211,6 +161,7 @@ export const ProjectPanel = withStyles(styles)(
                     </div>
                     <DataExplorer
                         id={PROJECT_PANEL_ID}
+                        columns={columns}
                         onRowClick={this.props.onItemClick}
                         onRowDoubleClick={this.props.onItemDoubleClick}
                         onContextMenu={this.props.onContextMenu}
@@ -219,7 +170,11 @@ export const ProjectPanel = withStyles(styles)(
             }
 
             handleNewProjectClick = () => {
-                this.props.onDialogOpen(this.props.currentItemId);
+                this.props.onProjectCreationDialogOpen(this.props.currentItemId);
+            }
+
+            handleNewCollectionClick = () => {
+                this.props.onCollectionCreationDialogOpen(this.props.currentItemId);
             }
             componentWillReceiveProps({ match, currentItemId, onItemRouteChange }: ProjectPanelProps) {
                 if (match.params.id !== currentItemId) {
@@ -228,4 +183,4 @@ export const ProjectPanel = withStyles(styles)(
             }
         }
     )
-);
\ No newline at end of file
+);
index 538b8e780f352489a4c0e15e6bf5493e2f0ee96d..8e0b353f6558f26200de817d8e9edb3586361a0d 100644 (file)
@@ -11,12 +11,13 @@ import createBrowserHistory from "history/createBrowserHistory";
 import { ConnectedRouter } from "react-router-redux";
 import { MuiThemeProvider } from '@material-ui/core/styles';
 import { CustomTheme } from '../../common/custom-theme';
+import { createServices } from "../../services/services";
 
 const history = createBrowserHistory();
 
 it('renders without crashing', () => {
     const div = document.createElement('div');
-    const store = configureStore(createBrowserHistory());
+    const store = configureStore(createBrowserHistory(), createServices("/arvados/v1"));
     ReactDOM.render(
         <MuiThemeProvider theme={CustomTheme}>
             <Provider store={store}>
index 3637528d49f8f8355dafe291a7b06e9155381e09..3611d7b10fb12210f9573f9ebf2df41476d62693 100644 (file)
@@ -7,7 +7,7 @@ import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/st
 import Drawer from '@material-ui/core/Drawer';
 import { connect, DispatchProp } from "react-redux";
 import { Route, Switch, RouteComponentProps } from "react-router";
-import { authActions } from "../../store/auth/auth-action";
+import { login, logout } from "../../store/auth/auth-action";
 import { User } from "../../models/user";
 import { RootState } from "../../store/store";
 import { MainAppBar, MainAppBarActionProps, MainAppBarMenuItem } from '../../views-components/main-app-bar/main-app-bar';
@@ -20,25 +20,34 @@ import { sidePanelActions } from '../../store/side-panel/side-panel-action';
 import { SidePanel, SidePanelItem } from '../../components/side-panel/side-panel';
 import { ItemMode, setProjectItem } from "../../store/navigation/navigation-action";
 import { projectActions } from "../../store/project/project-action";
+import { collectionCreateActions } from '../../store/collections/creator/collection-creator-action';
 import { ProjectPanel } from "../project-panel/project-panel";
 import { DetailsPanel } from '../../views-components/details-panel/details-panel';
 import { ArvadosTheme } from '../../common/custom-theme';
 import { CreateProjectDialog } from "../../views-components/create-project-dialog/create-project-dialog";
-import { authService } from '../../services/services';
 
 import { detailsPanelActions, loadDetails } from "../../store/details-panel/details-panel-action";
 import { contextMenuActions } from "../../store/context-menu/context-menu-actions";
-import { sidePanelData, SidePanelIdentifiers } from '../../store/side-panel/side-panel-reducer';
+import { SidePanelIdentifiers } from '../../store/side-panel/side-panel-reducer';
 import { ProjectResource } from '../../models/project';
 import { ResourceKind } from '../../models/resource';
 import { ContextMenu, ContextMenuKind } from "../../views-components/context-menu/context-menu";
-import { FavoritePanel, FAVORITE_PANEL_ID } from "../favorite-panel/favorite-panel";
+import { FavoritePanel } from "../favorite-panel/favorite-panel";
 import { CurrentTokenDialog } from '../../views-components/current-token-dialog/current-token-dialog';
 import { dataExplorerActions } from '../../store/data-explorer/data-explorer-action';
 import { Snackbar } from '../../views-components/snackbar/snackbar';
+import { favoritePanelActions } from '../../store/favorite-panel/favorite-panel-action';
+import { CreateCollectionDialog } from '../../views-components/create-collection-dialog/create-collection-dialog';
+import { CollectionPanel } from '../collection-panel/collection-panel';
+import { loadCollection } from '../../store/collection-panel/collection-panel-action';
+import { getCollectionUrl } from '../../models/collection';
+import { RemoveDialog } from '../../views-components/remove-dialog/remove-dialog';
+import { RenameDialog } from '../../views-components/rename-dialog/rename-dialog';
+import { UpdateCollectionDialog } from '../../views-components/update-collection-dialog/update-collection-dialog.';
+import { AuthService } from "../../services/auth-service/auth-service";
 
-const drawerWidth = 240;
-const appBarHeight = 100;
+const DRAWER_WITDH = 240;
+const APP_BAR_HEIGHT = 100;
 
 type CssRules = 'root' | 'appBar' | 'drawerPaper' | 'content' | 'contentWrapper' | 'toolbar';
 
@@ -59,7 +68,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     drawerPaper: {
         position: 'relative',
-        width: drawerWidth,
+        width: DRAWER_WITDH,
         display: 'flex',
         flexDirection: 'column',
     },
@@ -68,7 +77,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         display: "flex",
         flexGrow: 1,
         minWidth: 0,
-        paddingTop: appBarHeight
+        paddingTop: APP_BAR_HEIGHT
     },
     content: {
         padding: `${theme.spacing.unit}px ${theme.spacing.unit * 3}px`,
@@ -86,10 +95,14 @@ interface WorkbenchDataProps {
     sidePanelItems: SidePanelItem[];
 }
 
+interface WorkbenchServiceProps {
+    authService: AuthService;
+}
+
 interface WorkbenchActionProps {
 }
 
-type WorkbenchProps = WorkbenchDataProps & WorkbenchActionProps & DispatchProp & WithStyles<CssRules>;
+type WorkbenchProps = WorkbenchDataProps & WorkbenchServiceProps & WorkbenchActionProps & DispatchProp<any> & WithStyles<CssRules>;
 
 interface NavBreadcrumb extends Breadcrumb {
     itemId: string;
@@ -135,7 +148,7 @@ export const Workbench = withStyles(styles)(
                         },
                         {
                             label: "Logout",
-                            action: () => this.props.dispatch(authActions.LOGOUT())
+                            action: () => this.props.dispatch(logout())
                         },
                         {
                             label: "My account",
@@ -151,7 +164,7 @@ export const Workbench = withStyles(styles)(
                     anonymousMenu: [
                         {
                             label: "Sign in",
-                            action: () => this.props.dispatch(authActions.LOGIN())
+                            action: () => this.props.dispatch(login())
                         }
                     ]
                 }
@@ -188,22 +201,22 @@ export const Workbench = withStyles(styles)(
                                     toggleActive={this.toggleSidePanelActive}
                                     sidePanelItems={this.props.sidePanelItems}
                                     onContextMenu={(event) => this.openContextMenu(event, {
-                                        uuid: authService.getUuid() || "",
+                                        uuid: this.props.authService.getUuid() || "",
                                         name: "",
                                         kind: ContextMenuKind.ROOT_PROJECT
                                     })}>
                                     <ProjectTree
                                         projects={this.props.projects}
-                                        toggleOpen={itemId => this.props.dispatch<any>(setProjectItem(itemId, ItemMode.OPEN))}
+                                        toggleOpen={itemId => this.props.dispatch(setProjectItem(itemId, ItemMode.OPEN))}
                                         onContextMenu={(event, item) => this.openContextMenu(event, {
                                             uuid: item.data.uuid,
                                             name: item.data.name,
                                             kind: ContextMenuKind.PROJECT
                                         })}
                                         toggleActive={itemId => {
-                                            this.props.dispatch<any>(setProjectItem(itemId, ItemMode.ACTIVE));
-                                            this.props.dispatch<any>(loadDetails(itemId, ResourceKind.PROJECT));
-                                            this.props.dispatch<any>(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_ACTIVE(SidePanelIdentifiers.PROJECTS));
+                                            this.props.dispatch(setProjectItem(itemId, ItemMode.ACTIVE));
+                                            this.props.dispatch(loadDetails(itemId, ResourceKind.PROJECT));
+                                            this.props.dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_ACTIVE(SidePanelIdentifiers.PROJECTS));
                                         }} />
                                 </SidePanel>
                             </Drawer>}
@@ -212,6 +225,7 @@ export const Workbench = withStyles(styles)(
                                 <Switch>
                                     <Route path="/projects/:id" render={this.renderProjectPanel} />
                                     <Route path="/favorites" render={this.renderFavoritePanel} />
+                                    <Route path="/collections/:id" render={this.renderCollectionPanel} />
                                 </Switch>
                             </div>
                             {user && <DetailsPanel />}
@@ -219,6 +233,10 @@ export const Workbench = withStyles(styles)(
                         <ContextMenu />
                         <Snackbar />
                         <CreateProjectDialog />
+                        <CreateCollectionDialog />
+                        <RemoveDialog />
+                        <RenameDialog />
+                        <UpdateCollectionDialog />
                         <CurrentTokenDialog
                             currentToken={this.props.currentToken}
                             open={this.state.isCurrentTokenDialogOpen}
@@ -227,9 +245,21 @@ export const Workbench = withStyles(styles)(
                 );
             }
 
+            renderCollectionPanel = (props: RouteComponentProps<{ id: string }>) => <CollectionPanel
+                onItemRouteChange={(collectionId) => this.props.dispatch<any>(loadCollection(collectionId, ResourceKind.COLLECTION))}
+                onContextMenu={(event, item) => {
+                    this.openContextMenu(event, {
+                        uuid: item.uuid,
+                        name: item.name,
+                        kind: ContextMenuKind.COLLECTION
+                    });
+                }}
+                {...props} />
+
             renderProjectPanel = (props: RouteComponentProps<{ id: string }>) => <ProjectPanel
-                onItemRouteChange={itemId => this.props.dispatch<any>(setProjectItem(itemId, ItemMode.ACTIVE))}
+                onItemRouteChange={itemId => this.props.dispatch(setProjectItem(itemId, ItemMode.ACTIVE))}
                 onContextMenu={(event, item) => {
+
                     const kind = item.kind === ResourceKind.PROJECT ? ContextMenuKind.PROJECT : ContextMenuKind.RESOURCE;
                     this.openContextMenu(event, {
                         uuid: item.uuid,
@@ -237,18 +267,26 @@ export const Workbench = withStyles(styles)(
                         kind
                     });
                 }}
-                onDialogOpen={this.handleCreationDialogOpen}
+                onProjectCreationDialogOpen={this.handleProjectCreationDialogOpen}
+                onCollectionCreationDialogOpen={this.handleCollectionCreationDialogOpen}
                 onItemClick={item => {
-                    this.props.dispatch<any>(loadDetails(item.uuid, item.kind as ResourceKind));
+                    this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind));
                 }}
                 onItemDoubleClick={item => {
-                    this.props.dispatch<any>(setProjectItem(item.uuid, ItemMode.ACTIVE));
-                    this.props.dispatch<any>(loadDetails(item.uuid, ResourceKind.PROJECT));
+                    switch (item.kind) {
+                        case ResourceKind.COLLECTION:
+                            this.props.dispatch(loadCollection(item.uuid, item.kind as ResourceKind));
+                            this.props.dispatch(push(getCollectionUrl(item.uuid)));
+                        default: 
+                            this.props.dispatch(setProjectItem(item.uuid, ItemMode.ACTIVE));
+                            this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind));
+                    }
+
                 }}
                 {...props} />
 
             renderFavoritePanel = (props: RouteComponentProps<{ id: string }>) => <FavoritePanel
-                onItemRouteChange={() => this.props.dispatch<any>(dataExplorerActions.REQUEST_ITEMS({ id: FAVORITE_PANEL_ID }))}
+                onItemRouteChange={() => this.props.dispatch(favoritePanelActions.REQUEST_ITEMS())}
                 onContextMenu={(event, item) => {
                     const kind = item.kind === ResourceKind.PROJECT ? ContextMenuKind.PROJECT : ContextMenuKind.RESOURCE;
                     this.openContextMenu(event, {
@@ -257,21 +295,28 @@ export const Workbench = withStyles(styles)(
                         kind,
                     });
                 }}
-                onDialogOpen={this.handleCreationDialogOpen}
+                onDialogOpen={this.handleProjectCreationDialogOpen}
                 onItemClick={item => {
-                    this.props.dispatch<any>(loadDetails(item.uuid, item.kind as ResourceKind));
+                    this.props.dispatch(loadDetails(item.uuid, item.kind as ResourceKind));
                 }}
                 onItemDoubleClick={item => {
-                    this.props.dispatch<any>(loadDetails(item.uuid, ResourceKind.PROJECT));
-                    this.props.dispatch<any>(setProjectItem(item.uuid, ItemMode.ACTIVE));
-                    this.props.dispatch<any>(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_ACTIVE(SidePanelIdentifiers.PROJECTS));
+                    switch (item.kind) {
+                        case ResourceKind.COLLECTION:
+                            this.props.dispatch(loadCollection(item.uuid, item.kind as ResourceKind));
+                            this.props.dispatch(push(getCollectionUrl(item.uuid)));
+                        default:
+                            this.props.dispatch(loadDetails(item.uuid, ResourceKind.PROJECT));
+                            this.props.dispatch(setProjectItem(item.uuid, ItemMode.ACTIVE));
+                            this.props.dispatch(sidePanelActions.TOGGLE_SIDE_PANEL_ITEM_ACTIVE(SidePanelIdentifiers.PROJECTS));
+                    }
+
                 }}
                 {...props} />
 
             mainAppBarActions: MainAppBarActionProps = {
                 onBreadcrumbClick: ({ itemId }: NavBreadcrumb) => {
-                    this.props.dispatch<any>(setProjectItem(itemId, ItemMode.BOTH));
-                    this.props.dispatch<any>(loadDetails(itemId, ResourceKind.PROJECT));
+                    this.props.dispatch(setProjectItem(itemId, ItemMode.BOTH));
+                    this.props.dispatch(loadDetails(itemId, ResourceKind.PROJECT));
                 },
                 onSearch: searchText => {
                     this.setState({ searchText });
@@ -303,10 +348,14 @@ export const Workbench = withStyles(styles)(
                 }
             }
 
-            handleCreationDialogOpen = (itemUuid: string) => {
+            handleProjectCreationDialogOpen = (itemUuid: string) => {
                 this.props.dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: itemUuid }));
             }
 
+            handleCollectionCreationDialogOpen = (itemUuid: string) => {
+                this.props.dispatch(collectionCreateActions.OPEN_COLLECTION_CREATOR({ ownerUuid: itemUuid }));
+            }
+
             openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: { name: string; uuid: string; kind: ContextMenuKind; }) => {
                 event.preventDefault();
                 this.props.dispatch(
index 1eed5d2fda1e5adb4dff764d193c58d4e664a708..ee49986a76142a6638e0bc529c858221153c6e3b 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -23,9 +23,9 @@
     core-js "^2.5.7"
     regenerator-runtime "^0.12.0"
 
-"@material-ui/core@1.4.0":
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-1.4.0.tgz#e535fef84576b096c46e1fb7d6c4c61895155fd3"
+"@material-ui/core@1.4.2":
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/@material-ui/core/-/core-1.4.2.tgz#8a1282e985d4922a4d2b4f7e287d8a716a2fc108"
   dependencies:
     "@babel/runtime" "^7.0.0-beta.42"
     "@types/jss" "^9.5.3"
     jss-vendor-prefixer "^7.0.0"
     keycode "^2.1.9"
     normalize-scroll-left "^0.1.2"
-    popper.js "^1.0.0"
+    popper.js "^1.14.1"
     prop-types "^15.6.0"
     react-event-listener "^0.6.0"
     react-jss "^8.1.0"
     react-transition-group "^2.2.1"
     recompose "^0.27.0"
-    scroll "^2.0.3"
     warning "^4.0.1"
 
-"@material-ui/icons@1.1.0":
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-1.1.0.tgz#4d025df7b0ba6ace8d6710079ed76013a4d26595"
+"@material-ui/icons@2.0.0":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@material-ui/icons/-/icons-2.0.0.tgz#f2c4e80d0cb4bbbd433127781da67d93393535f8"
   dependencies:
-    recompose "^0.26.0 || ^0.27.0"
+    "@babel/runtime" "^7.0.0-beta.42"
+    recompose "^0.27.0"
 
 "@types/cheerio@*":
   version "0.22.8"
@@ -87,9 +87,9 @@
   version "4.6.2"
   resolved "https://registry.yarnpkg.com/@types/history/-/history-4.6.2.tgz#12cfaba693ba20f114ed5765467ff25fdf67ddb0"
 
-"@types/jest@23.3.0":
-  version "23.3.0"
-  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.0.tgz#5dd70033b616a6228042244ebd992f6426808810"
+"@types/jest@23.3.1":
+  version "23.3.1"
+  resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.1.tgz#a4319aedb071d478e6f407d1c4578ec8156829cf"
 
 "@types/jss@^9.5.3":
   version "9.5.4"
     csstype "^2.0.0"
     indefinite-observable "^1.0.1"
 
-"@types/lodash@4.14.112":
-  version "4.14.112"
-  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.112.tgz#4a8d8e5716b97a1ac01fe1931ad1e4cba719de5a"
+"@types/lodash@4.14.116":
+  version "4.14.116"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.116.tgz#5ccf215653e3e8c786a58390751033a9adca0eb9"
 
-"@types/node@*", "@types/node@10.5.2":
+"@types/node@*":
   version "10.5.2"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.2.tgz#f19f05314d5421fe37e74153254201a7bf00a707"
 
+"@types/node@10.5.5":
+  version "10.5.5"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.5.5.tgz#8e84d24e896cd77b0d4f73df274027e3149ec2ba"
+
+"@types/react-copy-to-clipboard@4.2.5":
+  version "4.2.5"
+  resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-4.2.5.tgz#bda288b4256288676019b75ca86f1714bbd206d4"
+  dependencies:
+    "@types/react" "*"
+
 "@types/react-dom@16.0.6":
   version "16.0.6"
   resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.6.tgz#f1a65a4e7be8ed5d123f8b3b9eacc913e35a1a3c"
     "@types/node" "*"
     "@types/react" "*"
 
-"@types/react-redux@6.0.4":
-  version "6.0.4"
-  resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-6.0.4.tgz#c1cfce0a0bd88983c75dbf393576f8dc59181586"
+"@types/react-redux@6.0.6":
+  version "6.0.6"
+  resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-6.0.6.tgz#87f1d0a6ea901b93fcaf95fa57641ff64079d277"
   dependencies:
     "@types/react" "*"
     redux "^4.0.0"
 
-"@types/react-router-dom@4.2.7":
-  version "4.2.7"
-  resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-4.2.7.tgz#9d36bfe175f916dd8d7b6b0237feed6cce376b4c"
+"@types/react-router-dom@4.3.0":
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-4.3.0.tgz#c91796d02deb3a5b24bc1c5db4a255df0d18b8b5"
   dependencies:
     "@types/history" "*"
     "@types/react" "*"
     "@types/react" "*"
     redux "^3.6.0"
 
-"@types/redux-form@7.4.1":
-  version "7.4.1"
-  resolved "https://registry.yarnpkg.com/@types/redux-form/-/redux-form-7.4.1.tgz#df84bbda5f06e4d517210797c3cfdc573c3bda36"
+"@types/redux-form@7.4.4":
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/@types/redux-form/-/redux-form-7.4.4.tgz#2cf62b8eb1dc1b1df95b6b25c2763db196e5c190"
   dependencies:
     "@types/react" "*"
     redux "^3.6.0 || ^4.0.0"
@@ -1817,6 +1827,12 @@ copy-descriptor@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
 
+copy-to-clipboard@^3:
+  version "3.0.8"
+  resolved "https://registry.yarnpkg.com/copy-to-clipboard/-/copy-to-clipboard-3.0.8.tgz#f4e82f4a8830dce4666b7eb8ded0c9bcc313aba9"
+  dependencies:
+    toggle-selection "^1.0.3"
+
 core-js@^1.0.0:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636"
@@ -2272,10 +2288,6 @@ dom-urls@^1.1.0:
   dependencies:
     urijs "^1.16.1"
 
-dom-walk@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/dom-walk/-/dom-walk-0.1.1.tgz#672226dc74c8f799ad35307df936aba11acd6018"
-
 domain-browser@^1.1.1:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda"
@@ -3162,13 +3174,6 @@ global-prefix@^1.0.1:
     is-windows "^1.0.1"
     which "^1.2.14"
 
-global@~4.3.0:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/global/-/global-4.3.2.tgz#e76989268a6c74c38908b1305b10fc0e394e9d0f"
-  dependencies:
-    min-document "^2.19.0"
-    process "~0.5.1"
-
 globals@^9.18.0:
   version "9.18.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a"
@@ -4873,12 +4878,6 @@ mimic-fn@^1.0.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
 
-min-document@^2.19.0:
-  version "2.19.0"
-  resolved "https://registry.yarnpkg.com/min-document/-/min-document-2.19.0.tgz#7bd282e3f5842ed295bb748cdd9f1ffa2c824685"
-  dependencies:
-    dom-walk "^0.1.0"
-
 minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
@@ -5577,9 +5576,9 @@ pn@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
 
-popper.js@^1.0.0:
-  version "1.14.3"
-  resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.3.tgz#1438f98d046acf7b4d78cd502bf418ac64d4f095"
+popper.js@^1.14.1:
+  version "1.14.4"
+  resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.14.4.tgz#8eec1d8ff02a5a3a152dd43414a15c7b79fd69b6"
 
 portfinder@^1.0.9:
   version "1.0.13"
@@ -5918,10 +5917,6 @@ process@^0.11.10:
   version "0.11.10"
   resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
 
-process@~0.5.1:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf"
-
 promise-inflight@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
@@ -6038,12 +6033,6 @@ raf@3.4.0, raf@^3.4.0:
   dependencies:
     performance-now "^2.1.0"
 
-rafl@~1.2.1:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/rafl/-/rafl-1.2.2.tgz#fe930f758211020d47e38815f5196a8be4150740"
-  dependencies:
-    global "~4.3.0"
-
 railroad-diagrams@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e"
@@ -6098,6 +6087,13 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.7:
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
+react-copy-to-clipboard@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#8eae107bb400be73132ed3b6a7b4fb156090208e"
+  dependencies:
+    copy-to-clipboard "^3"
+    prop-types "^15.5.8"
+
 react-dev-utils@^5.0.1:
   version "5.0.1"
   resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-5.0.1.tgz#1f396e161fe44b595db1b186a40067289bf06613"
@@ -6121,9 +6117,9 @@ react-dev-utils@^5.0.1:
     strip-ansi "3.0.1"
     text-table "0.2.0"
 
-react-dom@16.4.1:
-  version "16.4.1"
-  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.4.1.tgz#7f8b0223b3a5fbe205116c56deb85de32685dad6"
+react-dom@16.4.2:
+  version "16.4.2"
+  resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.4.2.tgz#4afed569689f2c561d2b8da0b819669c38a0bda4"
   dependencies:
     fbjs "^0.8.16"
     loose-envify "^1.1.0"
@@ -6273,9 +6269,9 @@ react-transition-group@^2.2.1:
     prop-types "^15.6.2"
     react-lifecycles-compat "^3.0.4"
 
-react@16.4.1:
-  version "16.4.1"
-  resolved "https://registry.yarnpkg.com/react/-/react-16.4.1.tgz#de51ba5764b5dbcd1f9079037b862bd26b82fe32"
+react@16.4.2:
+  version "16.4.2"
+  resolved "https://registry.yarnpkg.com/react/-/react-16.4.2.tgz#2cd90154e3a9d9dd8da2991149fdca3c260e129f"
   dependencies:
     fbjs "^0.8.16"
     loose-envify "^1.1.0"
@@ -6348,7 +6344,7 @@ realpath-native@^1.0.0:
   dependencies:
     util.promisify "^1.0.0"
 
-"recompose@^0.26.0 || ^0.27.0", recompose@^0.27.0:
+recompose@^0.27.0:
   version "0.27.1"
   resolved "https://registry.yarnpkg.com/recompose/-/recompose-0.27.1.tgz#1a49e931f183634516633bbb4f4edbfd3f38a7ba"
   dependencies:
@@ -6747,12 +6743,6 @@ schema-utils@^0.4.5:
     ajv "^6.1.0"
     ajv-keywords "^3.1.0"
 
-scroll@^2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/scroll/-/scroll-2.0.3.tgz#0951b785544205fd17753bc3d294738ba16fc2ab"
-  dependencies:
-    rafl "~1.2.1"
-
 select-hose@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
@@ -7406,6 +7396,10 @@ to-regex@^3.0.1, to-regex@^3.0.2:
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
+toggle-selection@^1.0.3:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
+
 toposort@^1.0.0:
   version "1.0.7"
   resolved "https://registry.yarnpkg.com/toposort/-/toposort-1.0.7.tgz#2e68442d9f64ec720b8cc89e6443ac6caa950029"
@@ -7545,9 +7539,9 @@ typedarray@^0.0.6:
   version "0.0.6"
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
 
-typescript@2.9.2:
-  version "2.9.2"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-2.9.2.tgz#1cbf61d05d6b96269244eb6a3bce4bd914e0f00c"
+typescript@3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.1.tgz#43738f29585d3a87575520a4b93ab6026ef11fdb"
 
 ua-parser-js@^0.7.18:
   version "0.7.18"