Merge branch 'master' into 14452-my-account
authorPawel Kromplewski <pawel.kromplewski@contractors.roche.com>
Mon, 3 Dec 2018 07:29:41 +0000 (08:29 +0100)
committerPawel Kromplewski <pawel.kromplewski@contractors.roche.com>
Mon, 3 Dec 2018 07:29:41 +0000 (08:29 +0100)
refs #14452

Arvados-DCO-1.1-Signed-off-by: Pawel Kromplewski <pawel.kromplewski@contractors.roche.com>

85 files changed:
README.md
package.json
src/common/config.ts
src/components/autocomplete/autocomplete.tsx
src/components/data-explorer/data-explorer.tsx
src/components/data-table-filters/data-table-filters-popover.tsx [new file with mode: 0644]
src/components/data-table-filters/data-table-filters-tree.tsx [new file with mode: 0644]
src/components/data-table-filters/data-table-filters.tsx
src/components/data-table/data-column.ts
src/components/data-table/data-table.test.tsx
src/components/data-table/data-table.tsx
src/components/icon/icon.tsx
src/components/progress-button/progress-button.tsx [new file with mode: 0644]
src/components/tree/tree.tsx
src/index.tsx
src/models/collection.ts
src/models/link.ts
src/models/resource.ts
src/models/tree.ts
src/models/user.ts
src/models/virtual-machines.ts [new file with mode: 0644]
src/models/vocabulary.ts [new file with mode: 0644]
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/services/api/filter-builder.test.ts
src/services/api/filter-builder.ts
src/services/auth-service/auth-service.ts
src/services/services.ts
src/services/virtual-machines-service/virtual-machines-service.ts [new file with mode: 0644]
src/services/vocabulary-service/vocabulary-service.ts [new file with mode: 0644]
src/store/advanced-tab/advanced-tab.ts
src/store/auth/auth-actions.test.ts
src/store/auth/auth-reducer.test.ts
src/store/context-menu/context-menu-actions.ts
src/store/current-token-dialog/current-token-dialog-actions.tsx
src/store/data-explorer/data-explorer-action.ts
src/store/data-explorer/data-explorer-middleware-service.ts
src/store/data-explorer/data-explorer-middleware.test.ts
src/store/data-explorer/data-explorer-reducer.ts
src/store/favorite-panel/favorite-panel-middleware-service.ts
src/store/navigation/navigation-action.ts
src/store/processes/process-copy-actions.ts
src/store/processes/process-move-actions.ts
src/store/processes/process-update-actions.ts
src/store/project-panel/project-panel-action.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/resource-type-filters/resource-type-filters.test.ts [new file with mode: 0644]
src/store/resource-type-filters/resource-type-filters.ts [new file with mode: 0644]
src/store/search-results-panel/search-results-panel-actions.ts
src/store/sharing-dialog/sharing-dialog-actions.ts
src/store/side-panel-tree/side-panel-tree-actions.ts
src/store/store.ts
src/store/trash-panel/trash-panel-middleware-service.ts
src/store/virtual-machines/virtual-machines-actions.ts [new file with mode: 0644]
src/store/virtual-machines/virtual-machines-reducer.ts [new file with mode: 0644]
src/store/vocabulary/vocabulary-actions.ts [new file with mode: 0644]
src/store/vocabulary/vocabulary-selctors.ts [new file with mode: 0644]
src/store/workbench/workbench-actions.ts
src/store/workflow-panel/workflow-panel-actions.ts
src/views-components/advanced-tab-dialog/metadataTab.tsx
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/action-sets/repository-action-set.ts
src/views-components/context-menu/action-sets/ssh-key-action-set.ts
src/views-components/context-menu/action-sets/trashed-collection-action-set.ts
src/views-components/current-token-dialog/current-token-dialog.tsx
src/views-components/data-explorer/data-explorer.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views-components/main-content-bar/main-content-bar.tsx
src/views-components/project-properties-dialog/project-properties-form.tsx
src/views-components/resource-properties-form/property-field-common.tsx [new file with mode: 0644]
src/views-components/resource-properties-form/property-key-field.tsx [new file with mode: 0644]
src/views-components/resource-properties-form/property-value-field.tsx [new file with mode: 0644]
src/views-components/resource-properties-form/resource-properties-form.tsx [new file with mode: 0644]
src/views-components/side-panel-tree/side-panel-tree.tsx
src/views/collection-panel/collection-tag-form.tsx
src/views/favorite-panel/favorite-panel.tsx
src/views/project-panel/project-panel.tsx
src/views/run-process-panel/run-process-panel.tsx
src/views/search-results-panel/search-results-panel-view.tsx
src/views/trash-panel/trash-panel.tsx
src/views/virtual-machine-panel/virtual-machine-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx
src/views/workflow-panel/workflow-panel-view.tsx
src/views/workflow-panel/workflow-panel.tsx

index 998d424662ac4cb69fb75b89904d9955fe5bc25d..ea9bc02fc1f0f038b016f5fd8f5ade2ab38ad2f9 100644 (file)
--- a/README.md
+++ b/README.md
@@ -40,7 +40,8 @@ The app will fetch runtime configuration when starting. By default it will try t
 Currently this configuration schema is supported:
 ```
 {
-    "API_HOST": "string"
+    "API_HOST": "string",
+    "VOCABULARY_URL": "string"
 }
 ```
 
index 89458046ae9707d97571397fdfb7e37a54f3d9d4..64a24dcafcef48a5e245f487d214371c209bd636 100644 (file)
@@ -47,7 +47,9 @@
   "scripts": {
     "start": "react-scripts-ts start",
     "build": "REACT_APP_BUILD_NUMBER=$BUILD_NUMBER REACT_APP_GIT_COMMIT=$GIT_COMMIT react-scripts-ts build",
+    "build-local": "react-scripts-ts build",
     "test": "CI=true react-scripts-ts test --env=jsdom",
+    "test-local": "react-scripts-ts test --env=jsdom",
     "eject": "react-scripts-ts eject",
     "lint": "tslint src/** -t verbose",
     "build-css": "node-sass-chokidar src/ -o src/",
index 1ab73294b01bc9c742c8b52d17bb1c418d080dc9..c74277e42cc1ff78a9499e688f9f3bc171ca2835 100644 (file)
@@ -49,6 +49,7 @@ export interface Config {
     version: string;
     websocketUrl: string;
     workbenchUrl: string;
+    vocabularyUrl: string;
 }
 
 export const fetchConfig = () => {
@@ -58,7 +59,10 @@ export const fetchConfig = () => {
         .catch(() => Promise.resolve(getDefaultConfig()))
         .then(config => Axios
             .get<Config>(getDiscoveryURL(config.API_HOST))
-            .then(response => ({ config: response.data, apiHost: config.API_HOST })));
+            .then(response => ({ 
+                config: {...response.data, vocabularyUrl: config.VOCABULARY_URL }, 
+                apiHost: config.API_HOST, 
+            })));
 
 };
 
@@ -105,15 +109,18 @@ export const mockConfig = (config: Partial<Config>): Config => ({
     version: '',
     websocketUrl: '',
     workbenchUrl: '',
+    vocabularyUrl: '',
     ...config
 });
 
 interface ConfigJSON {
     API_HOST: string;
+    VOCABULARY_URL: string;
 }
 
 const getDefaultConfig = (): ConfigJSON => ({
     API_HOST: process.env.REACT_APP_ARVADOS_API_HOST || "",
+    VOCABULARY_URL: "",
 });
 
 const getDiscoveryURL = (apiHost: string) => `${window.location.protocol}//${apiHost}/discovery/v1/apis/arvados/v1/rest`;
index 85704c357ca127ff240ba5a08173166c1c9054e4..7da4ba4a30845a85713439624c8f70fcb052c99a 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List } from '@material-ui/core';
+import { Input as MuiInput, Chip as MuiChip, Popper as MuiPopper, Paper, FormControl, InputLabel, StyleRulesCallback, withStyles, RootRef, ListItemText, ListItem, List, FormHelperText } from '@material-ui/core';
 import { PopperProps } from '@material-ui/core/Popper';
 import { WithStyles } from '@material-ui/core/styles';
 import { noop } from 'lodash';
@@ -13,6 +13,8 @@ export interface AutocompleteProps<Item, Suggestion> {
     value: string;
     items: Item[];
     suggestions?: Suggestion[];
+    error?: boolean;
+    helperText?: string;
     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
@@ -38,9 +40,10 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
     render() {
         return (
             <RootRef rootRef={this.containerRef}>
-                <FormControl fullWidth>
+                <FormControl fullWidth error={this.props.error}>
                     {this.renderLabel()}
                     {this.renderInput()}
+                    {this.renderHelperText()}
                     {this.renderSuggestions()}
                 </FormControl>
             </RootRef>
@@ -64,12 +67,16 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
         />;
     }
 
+    renderHelperText(){
+        return <FormHelperText>{this.props.helperText}</FormHelperText>;
+    }
+
     renderSuggestions() {
         const { suggestions = [] } = this.props;
         return (
             <Popper
                 open={this.state.suggestionsOpen && suggestions.length > 0}
-                anchorEl={this.containerRef.current}>
+                anchorEl={this.inputRef.current}>
                 <Paper onMouseDown={this.preventBlur}>
                     <List dense style={{ width: this.getSuggestionsWidth() }}>
                         {suggestions.map(
index f863ba13617adcc50c6ded0aac032445787079eb..cb979c7bd216b31d7e7d6760c08da9471a159472 100644 (file)
@@ -7,9 +7,10 @@ import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, Table
 import { ColumnSelector } from "../column-selector/column-selector";
 import { DataTable, DataColumns } from "../data-table/data-table";
 import { DataColumn, SortDirection } from "../data-table/data-column";
-import { DataTableFilterItem } from '../data-table-filters/data-table-filters';
 import { SearchInput } from '../search-input/search-input';
 import { ArvadosTheme } from "~/common/custom-theme";
+import { createTree } from '~/models/tree';
+import { DataTableFilters } from '../data-table-filters/data-table-filters-tree';
 import { MoreOptionsIcon } from '~/components/icon/icon';
 
 type CssRules = 'searchBox' | "toolbar" | "footer" | "root" | 'moreOptionsButton';
@@ -53,7 +54,7 @@ interface DataExplorerActionProps<T> {
     onColumnToggle: (column: DataColumn<T>) => void;
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
     onSortToggle: (column: DataColumn<T>) => void;
-    onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<T>) => void;
+    onFiltersChange: (filters: DataTableFilters, column: DataColumn<T>) => void;
     onChangePage: (page: number) => void;
     onChangeRowsPerPage: (rowsPerPage: number) => void;
     extractKey?: (item: T) => React.Key;
@@ -137,7 +138,7 @@ export const DataExplorer = withStyles(styles)(
             selected: true,
             configurable: false,
             sortDirection: SortDirection.NONE,
-            filters: [],
+            filters: createTree(),
             key: "context-actions",
             render: this.renderContextMenuTrigger
         };
diff --git a/src/components/data-table-filters/data-table-filters-popover.tsx b/src/components/data-table-filters/data-table-filters-popover.tsx
new file mode 100644 (file)
index 0000000..b79d36b
--- /dev/null
@@ -0,0 +1,165 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import {
+    WithStyles,
+    withStyles,
+    ButtonBase,
+    StyleRulesCallback,
+    Theme,
+    Popover,
+    Button,
+    Card,
+    CardActions,
+    Typography,
+    CardContent,
+    Tooltip
+} from "@material-ui/core";
+import * as classnames from "classnames";
+import { DefaultTransformOrigin } from "~/components/popover/helpers";
+import { createTree } from '~/models/tree';
+import { DataTableFilters, DataTableFiltersTree } from "./data-table-filters-tree";
+import { getNodeDescendants } from '~/models/tree';
+
+export type CssRules = "root" | "icon" | "active" | "checkbox";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    root: {
+        cursor: "pointer",
+        display: "inline-flex",
+        justifyContent: "flex-start",
+        flexDirection: "inherit",
+        alignItems: "center",
+        "&:hover": {
+            color: theme.palette.text.primary,
+        },
+        "&:focus": {
+            color: theme.palette.text.primary,
+        },
+    },
+    active: {
+        color: theme.palette.text.primary,
+        '& $icon': {
+            opacity: 1,
+        },
+    },
+    icon: {
+        marginRight: 4,
+        marginLeft: 4,
+        opacity: 0.7,
+        userSelect: "none",
+        width: 16
+    },
+    checkbox: {
+        width: 24,
+        height: 24
+    }
+});
+
+export interface DataTableFilterProps {
+    name: string;
+    filters: DataTableFilters;
+    onChange?: (filters: DataTableFilters) => void;
+}
+
+interface DataTableFilterState {
+    anchorEl?: HTMLElement;
+    filters: DataTableFilters;
+    prevFilters: DataTableFilters;
+}
+
+export const DataTableFiltersPopover = withStyles(styles)(
+    class extends React.Component<DataTableFilterProps & WithStyles<CssRules>, DataTableFilterState> {
+        state: DataTableFilterState = {
+            anchorEl: undefined,
+            filters: createTree(),
+            prevFilters: createTree(),
+        };
+        icon = React.createRef<HTMLElement>();
+
+        render() {
+            const { name, classes, children } = this.props;
+            const isActive = getNodeDescendants('')(this.state.filters).some(f => f.selected);
+            return <>
+                <Tooltip title='Filters'>
+                    <ButtonBase
+                        className={classnames([classes.root, { [classes.active]: isActive }])}
+                        component="span"
+                        onClick={this.open}
+                        disableRipple>
+                        {children}
+                        <i className={classnames(["fas fa-filter", classes.icon])}
+                            data-fa-transform="shrink-3"
+                            ref={this.icon} />
+                    </ButtonBase>
+                </Tooltip>
+                <Popover
+                    anchorEl={this.state.anchorEl}
+                    open={!!this.state.anchorEl}
+                    anchorOrigin={DefaultTransformOrigin}
+                    transformOrigin={DefaultTransformOrigin}
+                    onClose={this.cancel}>
+                    <Card>
+                        <CardContent>
+                            <Typography variant="caption">
+                                {name}
+                            </Typography>
+                        </CardContent>
+                        <DataTableFiltersTree
+                            filters={this.state.filters}
+                            onChange={filters => this.setState({ filters })} />
+                        <CardActions>
+                            <Button
+                                color="primary"
+                                variant="raised"
+                                size="small"
+                                onClick={this.submit}>
+                                Ok
+                            </Button>
+                            <Button
+                                color="primary"
+                                variant="outlined"
+                                size="small"
+                                onClick={this.cancel}>
+                                Cancel
+                            </Button>
+                        </CardActions >
+                    </Card>
+                </Popover>
+            </>;
+        }
+
+        static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState {
+            return props.filters !== state.prevFilters
+                ? { ...state, filters: props.filters, prevFilters: props.filters }
+                : state;
+        }
+
+        open = () => {
+            this.setState({ anchorEl: this.icon.current || undefined });
+        }
+
+        submit = () => {
+            const { onChange } = this.props;
+            if (onChange) {
+                onChange(this.state.filters);
+            }
+            this.setState({ anchorEl: undefined });
+        }
+
+        cancel = () => {
+            this.setState(prev => ({
+                ...prev,
+                filters: prev.prevFilters,
+                anchorEl: undefined
+            }));
+        }
+
+        setFilters = (filters: DataTableFilters) => {
+            this.setState({ filters });
+        }
+
+    }
+);
diff --git a/src/components/data-table-filters/data-table-filters-tree.tsx b/src/components/data-table-filters/data-table-filters-tree.tsx
new file mode 100644 (file)
index 0000000..dcc4f0e
--- /dev/null
@@ -0,0 +1,74 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Tree, toggleNodeSelection, getNode, initTreeNode, getNodeChildrenIds } from '~/models/tree';
+import { Tree as TreeComponent, TreeItem, TreeItemStatus } from '~/components/tree/tree';
+import { noop, map } from "lodash/fp";
+import { toggleNodeCollapse } from '~/models/tree';
+import { countNodes, countChildren } from '~/models/tree';
+
+export interface DataTableFilterItem {
+    name: string;
+}
+
+export type DataTableFilters = Tree<DataTableFilterItem>;
+
+export interface DataTableFilterProps {
+    filters: DataTableFilters;
+    onChange?: (filters: DataTableFilters) => void;
+}
+
+export class DataTableFiltersTree extends React.Component<DataTableFilterProps> {
+
+    render() {
+        const { filters } = this.props;
+        const hasSubfilters = countNodes(filters) !== countChildren('')(filters);
+        return <TreeComponent
+            levelIndentation={hasSubfilters ? 20 : 0}
+            itemRightPadding={20}
+            items={filtersToTree(filters)}
+            render={renderItem}
+            showSelection
+            disableRipple
+            onContextMenu={noop}
+            toggleItemActive={noop}
+            toggleItemOpen={this.toggleOpen}
+            toggleItemSelection={this.toggleFilter}
+        />;
+    }
+
+    toggleFilter = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
+        const { onChange = noop } = this.props;
+        onChange(toggleNodeSelection(item.id)(this.props.filters));
+    }
+
+    toggleOpen = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
+        const { onChange = noop } = this.props;
+        onChange(toggleNodeCollapse(item.id)(this.props.filters));
+    }
+}
+
+const renderItem = (item: TreeItem<DataTableFilterItem>) =>
+    <span>{item.data.name}</span>;
+
+const filterToTreeItem = (filters: DataTableFilters) =>
+    (id: string): TreeItem<any> => {
+        const node = getNode(id)(filters) || initTreeNode({ id: '', value: 'InvalidNode' });
+        const items = getNodeChildrenIds(node.id)(filters)
+            .map(filterToTreeItem(filters));
+
+        return {
+            active: node.active,
+            data: node.value,
+            id: node.id,
+            items: items.length > 0 ? items : undefined,
+            open: node.expanded,
+            selected: node.selected,
+            status: TreeItemStatus.LOADED,
+        };
+    };
+
+const filtersToTree = (filters: DataTableFilters): TreeItem<DataTableFilterItem>[] =>
+    map(filterToTreeItem(filters), getNodeChildrenIds('')(filters));
index 11607e4b22d108277f4f2dd8c32028ec88bcb36c..7033d369e7dcdb0f1daa0e9168fac1427719db3c 100644 (file)
@@ -23,6 +23,10 @@ import {
 } from "@material-ui/core";
 import * as classnames from "classnames";
 import { DefaultTransformOrigin } from "../popover/helpers";
+import { createTree, initTreeNode, mapTree } from '~/models/tree';
+import { DataTableFilters as DataTableFiltersModel, DataTableFiltersTree } from "./data-table-filters-tree";
+import { pipe } from 'lodash/fp';
+import { setNode } from '~/models/tree';
 
 export type CssRules = "root" | "icon" | "active" | "checkbox";
 
@@ -74,14 +78,27 @@ interface DataTableFilterState {
     anchorEl?: HTMLElement;
     filters: DataTableFilterItem[];
     prevFilters: DataTableFilterItem[];
+    filtersTree: DataTableFiltersModel;
 }
 
+const filters: DataTableFiltersModel = pipe(
+    createTree,
+    setNode(initTreeNode({ id: 'Project', value: { name: 'Project' } })),
+    setNode(initTreeNode({ id: 'Process', value: { name: 'Process' } })),
+    setNode(initTreeNode({ id: 'Data collection', value: { name: 'Data collection' } })),
+    setNode(initTreeNode({ id: 'General', parent: 'Data collection', value: { name: 'General' } })),
+    setNode(initTreeNode({ id: 'Output', parent: 'Data collection', value: { name: 'Output' } })),
+    setNode(initTreeNode({ id: 'Log', parent: 'Data collection', value: { name: 'Log' } })),
+    mapTree(node => ({...node, selected: true})),
+)();
+
 export const DataTableFilters = withStyles(styles)(
     class extends React.Component<DataTableFilterProps & WithStyles<CssRules>, DataTableFilterState> {
         state: DataTableFilterState = {
             anchorEl: undefined,
             filters: [],
-            prevFilters: []
+            prevFilters: [],
+            filtersTree: filters,
         };
         icon = React.createRef<HTMLElement>();
 
@@ -128,6 +145,9 @@ export const DataTableFilters = withStyles(styles)(
                                 </ListItem>
                             )}
                         </List>
+                        <DataTableFiltersTree
+                            filters={this.state.filtersTree}
+                            onChange={filtersTree => this.setState({ filtersTree })} />
                         <CardActions>
                             <Button
                                 color="primary"
index a5f95506a816da0decfd4754705ea4cb8c0cddc6..28e93beed0cebadc1fbaa54324cc2729a3a86ed5 100644 (file)
@@ -3,15 +3,16 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
-import { DataTableFilterItem } from "../data-table-filters/data-table-filters";
+import { DataTableFilters } from "../data-table-filters/data-table-filters-tree";
+import { createTree } from '~/models/tree';
 
-export interface DataColumn<T, F extends DataTableFilterItem = DataTableFilterItem> {
+export interface DataColumn<T> {
     key?: React.Key;
     name: string;
     selected: boolean;
     configurable: boolean;
     sortDirection?: SortDirection;
-    filters: F[];
+    filters: DataTableFilters;
     render: (item: T) => React.ReactElement<any>;
     renderHeader?: () => React.ReactElement<any>;
 }
@@ -34,13 +35,13 @@ export const resetSortDirection = <T>(column: DataColumn<T>): DataColumn<T> => {
     return column.sortDirection ? { ...column, sortDirection: SortDirection.NONE } : column;
 };
 
-export const createDataColumn = <T, F extends DataTableFilterItem>(dataColumn: Partial<DataColumn<T, F>>): DataColumn<T, F> => ({
+export const createDataColumn = <T>(dataColumn: Partial<DataColumn<T>>): DataColumn<T> => ({
     key: '',
     name: '',
     selected: true,
     configurable: true,
     sortDirection: SortDirection.NONE,
-    filters: [],
+    filters: createTree(),
     render: () => React.createElement('span'),
     ...dataColumn,
 });
index c2f5d4acc1a85f10e6565ee164942e8fda7bc833..d0b83b969695d00dfc4e31170e0d072f112e9055 100644 (file)
@@ -4,12 +4,15 @@
 
 import * as React from "react";
 import { mount, configure } from "enzyme";
+import { pipe } from 'lodash/fp';
 import { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core";
 import * as Adapter from "enzyme-adapter-react-16";
 import { DataTable, DataColumns } from "./data-table";
-import { DataTableFilters } from "../data-table-filters/data-table-filters";
+import { DataTableFilters } from "~/components/data-table-filters/data-table-filters";
 import { SortDirection, createDataColumn } from "./data-column";
-import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
+import { DataTableFiltersPopover } from '~/components/data-table-filters/data-table-filters-popover';
+import { createTree, setNode, initTreeNode } from '~/models/tree';
+import { DataTableFilterItem } from "~/components/data-table-filters/data-table-filters-tree";
 
 configure({ adapter: new Adapter() });
 
@@ -139,12 +142,12 @@ describe("<DataTable />", () => {
     it("passes sorting props to <TableSortLabel />", () => {
         const columns: DataColumns<string> = [
             createDataColumn({
-            name: "Column 1",
-            sortDirection: SortDirection.ASC,
-            selected: true,
-            configurable: true,
-            render: (item) => <Typography>{item}</Typography>
-        })];
+                name: "Column 1",
+                sortDirection: SortDirection.ASC,
+                selected: true,
+                configurable: true,
+                render: (item) => <Typography>{item}</Typography>
+            })];
         const onSortToggle = jest.fn();
         const dataTable = mount(<DataTable
             columns={columns}
@@ -180,13 +183,17 @@ describe("<DataTable />", () => {
         expect(dataTable.find(DataTableFilters)).toHaveLength(0);
     });
 
-    it("passes filter props to <DataTableFilter />", () => {
+    it("passes filter props to <DataTableFiltersPopover />", () => {
+        const filters = pipe(
+            () => createTree<DataTableFilterItem>(),
+            setNode(initTreeNode({ id: 'filter', value: { name: 'filter' } }))
+        );
         const columns: DataColumns<string> = [{
             name: "Column 1",
             sortDirection: SortDirection.ASC,
             selected: true,
             configurable: true,
-            filters: [{ name: "Filter 1", selected: true }],
+            filters: filters(),
             render: (item) => <Typography>{item}</Typography>
         }];
         const onFiltersChange = jest.fn();
@@ -198,8 +205,8 @@ describe("<DataTable />", () => {
             onRowDoubleClick={jest.fn()}
             onSortToggle={jest.fn()}
             onContextMenu={jest.fn()} />);
-        expect(dataTable.find(DataTableFilters).prop("filters")).toBe(columns[0].filters);
-        dataTable.find(DataTableFilters).prop("onChange")([]);
+        expect(dataTable.find(DataTableFiltersPopover).prop("filters")).toBe(columns[0].filters);
+        dataTable.find(DataTableFiltersPopover).prop("onChange")([]);
         expect(onFiltersChange).toHaveBeenCalledWith([], columns[0]);
     });
 
index 25d81c62fa7dc89a8135c8a718c5dc02800d22a6..d9157a6a5d0e14851f141309c8a89f96af818c50 100644 (file)
@@ -5,10 +5,12 @@
 import * as React from 'react';
 import { Table, TableBody, TableRow, TableCell, TableHead, TableSortLabel, StyleRulesCallback, Theme, WithStyles, withStyles } from '@material-ui/core';
 import { DataColumn, SortDirection } from './data-column';
-import { DataTableFilters, DataTableFilterItem } from "../data-table-filters/data-table-filters";
 import { DataTableDefaultView } from '../data-table-default-view/data-table-default-view';
+import { DataTableFilters } from '../data-table-filters/data-table-filters-tree';
+import { DataTableFiltersPopover } from '../data-table-filters/data-table-filters-popover';
+import { countNodes } from '~/models/tree';
 
-export type DataColumns<T, F extends DataTableFilterItem = DataTableFilterItem> = Array<DataColumn<T, F>>;
+export type DataColumns<T> = Array<DataColumn<T>>;
 
 export interface DataTableDataProps<T> {
     items: T[];
@@ -17,7 +19,7 @@ export interface DataTableDataProps<T> {
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
     onRowDoubleClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
     onSortToggle: (column: DataColumn<T>) => void;
-    onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<T>) => void;
+    onFiltersChange: (filters: DataTableFilters, column: DataColumn<T>) => void;
     extractKey?: (item: T) => React.Key;
     working?: boolean;
     defaultView?: React.ReactNode;
@@ -81,15 +83,15 @@ export const DataTable = withStyles(styles)(
             return <TableCell key={key || index}>
                 {renderHeader ?
                     renderHeader() :
-                    filters.length > 0
-                        ? <DataTableFilters
+                    countNodes(filters) > 0
+                        ? <DataTableFiltersPopover
                             name={`${name} filters`}
                             onChange={filters =>
                                 onFiltersChange &&
                                 onFiltersChange(filters, column)}
                             filters={filters}>
                             {name}
-                        </DataTableFilters>
+                        </DataTableFiltersPopover>
                         : sortDirection
                             ? <TableSortLabel
                                 active={sortDirection !== SortDirection.NONE}
index a0f58be4e8df41dd0890a639a839f3d7c8d7a2ac..8049686f3d749c56db2bab6a55b99d63c975a65e 100644 (file)
@@ -3,7 +3,6 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import AccessTime from '@material-ui/icons/AccessTime';
 import Add from '@material-ui/icons/Add';
 import ArrowBack from '@material-ui/icons/ArrowBack';
 import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
@@ -90,7 +89,6 @@ 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) => <DeviceHub {...props} />;
-export const RecentIcon: IconType = (props) => <AccessTime {...props} />;
 export const RemoveIcon: IconType = (props) => <Delete {...props} />;
 export const RemoveFavoriteIcon: IconType = (props) => <Star {...props} />;
 export const RenameIcon: IconType = (props) => <Edit {...props} />;
diff --git a/src/components/progress-button/progress-button.tsx b/src/components/progress-button/progress-button.tsx
new file mode 100644 (file)
index 0000000..14286dd
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import Button, { ButtonProps } from '@material-ui/core/Button';
+import { CircularProgress, withStyles } from '@material-ui/core';
+import { CircularProgressProps } from '@material-ui/core/CircularProgress';
+
+interface ProgressButtonProps extends ButtonProps {
+    loading?: boolean;
+    progressProps?: CircularProgressProps;
+}
+
+export const ProgressButton = ({ loading, progressProps, children, disabled, ...props }: ProgressButtonProps) =>
+    <Button {...props} disabled={disabled || loading}>
+        {children}
+        {loading && <Progress {...progressProps} size={getProgressSize(props.size)} />}
+    </Button>;
+
+const Progress = withStyles({
+    root: {
+        position: 'absolute',
+    },
+})(CircularProgress);
+
+const getProgressSize = (size?: 'small' | 'medium' | 'large') => {
+    switch (size) {
+        case 'small':
+            return 16;
+        case 'large':
+            return 24;
+        default:
+            return 20;
+    }
+};
index 4cbefbd2b0eb0cbb1a0b9cc8d5927cc66a05e90c..c64a722139b3d4c5a9690e1ee0120e673c52cf7f 100644 (file)
@@ -88,6 +88,8 @@ export interface TreeProps<T> {
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
     render: (item: TreeItem<T>, level?: number) => ReactElement<{}>;
     showSelection?: boolean | ((item: TreeItem<T>) => boolean);
+    levelIndentation?: number;
+    itemRightPadding?: number;
     toggleItemActive: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
     toggleItemOpen: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
     toggleItemSelection?: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
@@ -103,10 +105,16 @@ export const Tree = withStyles(styles)(
                 ? this.props.showSelection
                 : () => this.props.showSelection ? true : false;
 
+            const { levelIndentation = 20, itemRightPadding = 20 } = this.props;
+
             return <List component="div" className={list}>
                 {items && items.map((it: TreeItem<T>, idx: number) =>
                     <div key={`item/${level}/${idx}`}>
-                        <ListItem button className={listItem} style={{ paddingLeft: (level + 1) * 20 }}
+                        <ListItem button className={listItem} 
+                            style={{ 
+                                paddingLeft: (level + 1) * levelIndentation,
+                                paddingRight: itemRightPadding,
+                            }}
                             disableRipple={disableRipple}
                             onClick={event => toggleItemActive(event, it)}
                             onContextMenu={this.handleRowContextMenu(it)}>
index 88fd2298bbc5a1936f23fb372e0220432b3c32f1..801a56a1d382a3ac589af3bb732a085190567b1c 100644 (file)
@@ -50,6 +50,7 @@ import HTML5Backend from 'react-dnd-html5-backend';
 import { initAdvanceFormProjectsTree } from '~/store/search-bar/search-bar-actions';
 import { repositoryActionSet } from '~/views-components/context-menu/action-sets/repository-action-set';
 import { sshKeyActionSet } from '~/views-components/context-menu/action-sets/ssh-key-action-set';
+import { loadVocabulary } from '~/store/vocabulary/vocabulary-actions';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -88,6 +89,7 @@ fetchConfig()
         store.dispatch(setBuildInfo());
         store.dispatch(setCurrentTokenDialogApiHost(apiHost));
         store.dispatch(setUuidPrefix(config.uuidPrefix));
+        store.dispatch(loadVocabulary);
 
         const TokenComponent = (props: any) => <ApiToken authService={services.authService} {...props} />;
         const MainPanelComponent = (props: any) => <MainPanel {...props} />;
@@ -304,3 +306,4 @@ const createSampleProcess = ({ containerRequestService }: ServiceRepository) =>
     });
 };
 
+// force build comment #1
index 53c6230143b9a78c8881d8b92baeb5e4ea305c1d..2b16ea2523a4de9d4cb4a776e402fc6ce2e7168e 100644 (file)
@@ -22,3 +22,9 @@ export interface CollectionResource extends TrashableResource {
 export const getCollectionUrl = (uuid: string) => {
     return `/collections/${uuid}`;
 };
+
+export enum CollectionType {
+    GENERAL = 'nil',
+    OUTPUT = 'output',
+    LOG = 'log',
+}
index 9d1711d8316c3e327d1f8e2edf1b59cd267a852c..baaff658a205f0cc5427e4a7ff796ebd93b64255 100644 (file)
@@ -3,13 +3,14 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Resource } from "./resource";
+import { TagProperty } from "~/models/tag";
 
 export interface LinkResource extends Resource {
     headUuid: string;
     tailUuid: string;
     linkClass: string;
     name: string;
-    properties: {};
+    properties: TagProperty;
 }
 
 export enum LinkClass {
index 5fa617974b173741e69e3b017c7778223a606cc9..7e2127b2813dad8076881b8fc69a03bb82184633 100644 (file)
@@ -31,6 +31,7 @@ export enum ResourceKind {
     REPOSITORY = "arvados#repository",
     SSH_KEY = "arvados#authorizedKeys",
     USER = "arvados#user",
+    VIRTUAL_MACHINE = "arvados#virtualMachine",
     WORKFLOW = "arvados#workflow",
     NONE = "arvados#none"
 }
@@ -43,7 +44,9 @@ export enum ResourceObjectType {
     LOG = '57u5n',
     REPOSITORY = 's0uqq',
     USER = 'tpzed',
+    VIRTUAL_MACHINE = '2x53u',
     WORKFLOW = '7fd4e',
+    SSH_KEY = 'fngyi'
 }
 
 export const RESOURCE_UUID_PATTERN = '.{5}-.{5}-.{15}';
@@ -76,8 +79,12 @@ export const extractUuidKind = (uuid: string = '') => {
             return ResourceKind.LOG;
         case ResourceObjectType.WORKFLOW:
             return ResourceKind.WORKFLOW;
+        case ResourceObjectType.VIRTUAL_MACHINE:
+            return ResourceKind.VIRTUAL_MACHINE;
         case ResourceObjectType.REPOSITORY:
             return ResourceKind.REPOSITORY;
+        case ResourceObjectType.SSH_KEY:
+            return ResourceKind.SSH_KEY;
         default:
             return undefined;
     }
index fe52a97b0fcdd3806579318d968f2efc8206f364..bec2f758a478335e6ef437afe6592dde144ce729 100644 (file)
@@ -95,6 +95,12 @@ export const getNodeAncestorsIds = (id: string) => <T>(tree: Tree<T>): string[]
 export const getNodeDescendants = (id: string, limit = Infinity) => <T>(tree: Tree<T>) =>
     mapIdsToNodes(getNodeDescendantsIds(id, limit)(tree))(tree);
 
+export const countNodes = <T>(tree: Tree<T>) =>
+    getNodeDescendantsIds('')(tree).length;
+
+export const countChildren = (id: string) => <T>(tree: Tree<T>) =>
+    getNodeChildren('')(tree).length;
+
 export const getNodeDescendantsIds = (id: string, limit = Infinity) => <T>(tree: Tree<T>): string[] => {
     const node = getNode(id)(tree);
     const children = node ? node.children :
@@ -120,19 +126,19 @@ export const mapIdsToNodes = (ids: string[]) => <T>(tree: Tree<T>) =>
     ids.map(id => getNode(id)(tree)).filter((node): node is TreeNode<T> => node !== undefined);
 
 export const activateNode = (id: string) => <T>(tree: Tree<T>) =>
-    mapTree(node => node.id === id ? { ...node, active: true } : { ...node, active: false })(tree);
+    mapTree((node: TreeNode<T>) => node.id === id ? { ...node, active: true } : { ...node, active: false })(tree);
 
 export const deactivateNode = <T>(tree: Tree<T>) =>
-    mapTree(node => node.active ? { ...node, active: false } : node)(tree);
+    mapTree((node: TreeNode<T>) => node.active ? { ...node, active: false } : node)(tree);
 
 export const expandNode = (...ids: string[]) => <T>(tree: Tree<T>) =>
-    mapTree(node => ids.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree);
+    mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree);
 
 export const collapseNode = (...ids: string[]) => <T>(tree: Tree<T>) =>
-    mapTree(node => ids.some(id => id === node.id) ? { ...node, expanded: false } : node)(tree);
+    mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: false } : node)(tree);
 
 export const toggleNodeCollapse = (...ids: string[]) => <T>(tree: Tree<T>) =>
-    mapTree(node => ids.some(id => id === node.id) ? { ...node, expanded: !node.expanded } : node)(tree);
+    mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: !node.expanded } : node)(tree);
 
 export const setNodeStatus = (id: string) => (status: TreeNodeStatus) => <T>(tree: Tree<T>) => {
     const node = getNode(id)(tree);
@@ -175,6 +181,10 @@ export const deselectNodes = (id: string | string[]) => <T>(tree: Tree<T>) => {
     return ids.reduce((tree, id) => deselectNode(id)(tree), tree);
 };
 
+export const getSelectedNodes = <T>(tree: Tree<T>) =>
+    getNodeDescendants('')(tree)
+        .filter(node => node.selected);
+
 export const initTreeNode = <T>(data: Pick<TreeNode<T>, 'id' | 'value'> & { parent?: string }): TreeNode<T> => ({
     children: [],
     active: false,
index eed135a21cf816133dc14deb4a656cce21953553..dfb418892ef363192b73c5e8f6983c887910603b 100644 (file)
@@ -22,6 +22,7 @@ export interface User {
     ownerUuid: string;
     identityUrl: string;
     prefs: userPrefs;
+    isAdmin: boolean;
 }
 
 export const getUserFullname = (user?: User) => {
diff --git a/src/models/virtual-machines.ts b/src/models/virtual-machines.ts
new file mode 100644 (file)
index 0000000..0652c35
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "~/models/resource";
+
+export interface VirtualMachinesResource extends Resource {
+    hostname: string;
+}
+
+export interface VirtualMachinesLoginsResource {
+    hostname: string;
+    username: string;
+    public_key: string;
+    user_uuid: string;
+    virtual_machine_uuid: string;
+    authorized_key_uuid: string;
+}
\ No newline at end of file
diff --git a/src/models/vocabulary.ts b/src/models/vocabulary.ts
new file mode 100644 (file)
index 0000000..ea23ad2
--- /dev/null
@@ -0,0 +1,24 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { isObject, has, every } from 'lodash/fp';
+
+export interface Vocabulary {
+    strict: boolean;
+    tags: Record<string, Tag>;
+}
+
+export interface Tag {
+    strict?: boolean;
+    values?: string[];
+}
+
+const VOCABULARY_VALIDATORS = [
+    isObject,
+    has('strict'),
+    has('tags'),
+];
+
+export const isVocabulary = (value: any) =>
+    every(validator => validator(value), VOCABULARY_VALIDATORS);
\ No newline at end of file
index f2aed2db48f0411b7ba3ebcffeb2ad60483d6169..1f3bd93fbae297f3461a1a7393280b7df80f9baa 100644 (file)
@@ -6,6 +6,8 @@ import { History, Location } from 'history';
 import { RootStore } from '~/store/store';
 import { matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute, matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute, matchSearchResultsRoute, matchSshKeysRoute, matchRepositoriesRoute, matchMyAccountRoute } from './routes';
 import { loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog, loadSshKeys, loadRepositories, loadMyAccount } from '~/store/workbench/workbench-actions';
+import { matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute, matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute, matchSearchResultsRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute } from './routes';
+import { loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog, loadSshKeys, loadRepositories, loadVirtualMachines } from '~/store/workbench/workbench-actions';
 import { navigateToRootProject } from '~/store/navigation/navigation-action';
 import { loadSharedWithMe, loadRunProcess, loadWorkflow, loadSearchResults } from '~//store/workbench/workbench-actions';
 
@@ -27,6 +29,7 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const searchResultsMatch = matchSearchResultsRoute(pathname);
     const sharedWithMeMatch = matchSharedWithMeRoute(pathname);
     const runProcessMatch = matchRunProcessRoute(pathname);
+    const virtualMachineMatch = matchVirtualMachineRoute(pathname);
     const workflowMatch = matchWorkflowRoute(pathname);
     const sshKeysMatch = matchSshKeysRoute(pathname);
     const myAccountMatch = matchMyAccountRoute(pathname);
@@ -53,6 +56,8 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(loadWorkflow);
     } else if (searchResultsMatch) {
         store.dispatch(loadSearchResults);
+    } else if (virtualMachineMatch) {
+        store.dispatch(loadVirtualMachines);
     } else if(repositoryMatch) {
         store.dispatch(loadRepositories);
     } else if (sshKeysMatch) {
index 988b4cb4a8427eba202d287d8a678e42095c1b96..a27b4274c707002a57b5a358d5aecbb9e03c6385 100644 (file)
@@ -19,6 +19,7 @@ export const Routes = {
     REPOSITORIES: '/repositories',
     SHARED_WITH_ME: '/shared-with-me',
     RUN_PROCESS: '/run-process',
+    VIRTUAL_MACHINES: '/virtual-machines',
     WORKFLOWS: '/workflows',
     SEARCH_RESULTS: '/search-results',
     SSH_KEYS: `/ssh-keys`,
@@ -80,6 +81,9 @@ export const matchWorkflowRoute = (route: string) =>
 export const matchSearchResultsRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.SEARCH_RESULTS });
 
+export const matchVirtualMachineRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.VIRTUAL_MACHINES });
+    
 export const matchRepositoriesRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.REPOSITORIES });
     
index 5f646de5f72911af6708ae78b80e2155ffe8a1a1..2ddd6bf0d337a658019192d6f5878bd838a08e46 100644 (file)
@@ -60,6 +60,12 @@ describe("FilterBuilder", () => {
         ).toEqual(`["etag","in",["etagValue1","etagValue2"]]`);
     });
 
+    it("should add 'not in' rule for set", () => {
+        expect(
+            filters.addNotIn("etag", ["etagValue1", "etagValue2"]).getFilters()
+        ).toEqual(`["etag","not in",["etagValue1","etagValue2"]]`);
+    });
+
     it("should add multiple rules", () => {
         expect(
             filters
@@ -73,6 +79,6 @@ describe("FilterBuilder", () => {
         expect(new FilterBuilder()
             .addIn("etag", ["etagValue1", "etagValue2"], "myPrefix")
             .getFilters())
-            .toEqual(`["my_prefix.etag","in",["etagValue1","etagValue2"]]`);
+            .toEqual(`["myPrefix.etag","in",["etagValue1","etagValue2"]]`);
     });
 });
index e36765ba5b5a6a145f7bae29529238438fb13420..1ebf488636115c7dfb2cd9bcbf420d79ee82fa1b 100644 (file)
@@ -31,6 +31,10 @@ export class FilterBuilder {
         return this.addCondition(field, "in", value, "", "", resourcePrefix);
     }
 
+    public addNotIn(field: string, value?: string | string[], resourcePrefix?: string) {
+        return this.addCondition(field, "not in", value, "", "", resourcePrefix);
+    }
+
     public addGt(field: string, value?: string, resourcePrefix?: string) {
         return this.addCondition(field, ">", value, "", "", resourcePrefix);
     }
@@ -62,7 +66,7 @@ export class FilterBuilder {
             }
 
             const resPrefix = resourcePrefix
-                ? _.snakeCase(resourcePrefix) + "."
+                ? resourcePrefix + "."
                 : "";
 
             this.filters += `${this.filters ? "," : ""}["${resPrefix}${_.snakeCase(field)}","${cond}",${value}]`;
index d4e81e42ee76c88bec04814e70953cd7bcbfa335..6faaf99ee7a37109a93021cd9cafeb31c4fa57a3 100644 (file)
@@ -13,6 +13,7 @@ export const USER_FIRST_NAME_KEY = 'userFirstName';
 export const USER_LAST_NAME_KEY = 'userLastName';
 export const USER_UUID_KEY = 'userUuid';
 export const USER_OWNER_UUID_KEY = 'userOwnerUuid';
+export const USER_IS_ADMIN = 'isAdmin';
 export const USER_IDENTITY_URL = 'identityUrl';
 export const USER_PREFS = 'prefs';
 
@@ -54,16 +55,22 @@ export class AuthService {
         return localStorage.getItem(USER_OWNER_UUID_KEY) || undefined;
     }
 
+    public getIsAdmin(): boolean {
+        return !!localStorage.getItem(USER_IS_ADMIN);
+    }
+
     public getUser(): User | undefined {
         const email = localStorage.getItem(USER_EMAIL_KEY);
         const firstName = localStorage.getItem(USER_FIRST_NAME_KEY);
         const lastName = localStorage.getItem(USER_LAST_NAME_KEY);
-        const uuid = localStorage.getItem(USER_UUID_KEY);
-        const ownerUuid = localStorage.getItem(USER_OWNER_UUID_KEY);
+        const uuid = this.getUuid();
+        const ownerUuid = this.getOwnerUuid();
+        const isAdmin = this.getIsAdmin();
         const identityUrl = localStorage.getItem(USER_IDENTITY_URL);
         const prefs = JSON.parse(localStorage.getItem(USER_PREFS) || '{"profile": {}}');
+
         return email && firstName && lastName && uuid && ownerUuid && identityUrl && prefs
-            ? { email, firstName, lastName, uuid, ownerUuid, identityUrl, prefs }
+            ? { email, firstName, lastName, uuid, ownerUuid, isAdmin, identityUrl, prefs }
             : undefined;
     }
 
@@ -73,6 +80,7 @@ export class AuthService {
         localStorage.setItem(USER_LAST_NAME_KEY, user.lastName);
         localStorage.setItem(USER_UUID_KEY, user.uuid);
         localStorage.setItem(USER_OWNER_UUID_KEY, user.ownerUuid);
+        localStorage.setItem(USER_IS_ADMIN, JSON.stringify(user.isAdmin));
         localStorage.setItem(USER_IDENTITY_URL, user.identityUrl);
         localStorage.setItem(USER_PREFS, JSON.stringify(user.prefs));
     }
@@ -83,6 +91,7 @@ export class AuthService {
         localStorage.removeItem(USER_LAST_NAME_KEY);
         localStorage.removeItem(USER_UUID_KEY);
         localStorage.removeItem(USER_OWNER_UUID_KEY);
+        localStorage.removeItem(USER_IS_ADMIN);
         localStorage.removeItem(USER_IDENTITY_URL);
         localStorage.removeItem(USER_PREFS);
     }
@@ -112,6 +121,7 @@ export class AuthService {
                     lastName: resp.data.last_name,
                     uuid: resp.data.uuid,
                     ownerUuid: resp.data.owner_uuid,
+                    isAdmin: resp.data.is_admin,
                     identityUrl: resp.data.identity_url,
                     prefs
                 };
index 308505c5abdbf1ef625b1ff49e038096dad829d5..b24b1d99a181a8086e9da06bd41684cd5969a907 100644 (file)
@@ -24,8 +24,10 @@ import { ApiActions } from "~/services/api/api-actions";
 import { WorkflowService } from "~/services/workflow-service/workflow-service";
 import { SearchService } from '~/services/search-service/search-service';
 import { PermissionService } from "~/services/permission-service/permission-service";
+import { VirtualMachinesService } from "~/services/virtual-machines-service/virtual-machines-service";
 import { RepositoriesService } from '~/services/repositories-service/repositories-service';
 import { AuthorizedKeysService } from '~/services/authorized-keys-service/authorized-keys-service';
+import { VocabularyService } from '~/services/vocabulary-service/vocabulary-service';
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -47,6 +49,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const projectService = new ProjectService(apiClient, actions);
     const repositoriesService = new RepositoriesService(apiClient, actions);
     const userService = new UserService(apiClient, actions);
+    const virtualMachineService = new VirtualMachinesService(apiClient, actions);
     const workflowService = new WorkflowService(apiClient, actions);
 
     const ancestorsService = new AncestorService(groupsService, userService);
@@ -56,6 +59,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const favoriteService = new FavoriteService(linkService, groupsService);
     const tagService = new TagService(linkService);
     const searchService = new SearchService();
+    const vocabularyService = new VocabularyService(config.vocabularyUrl);
 
     return {
         ancestorsService,
@@ -77,8 +81,10 @@ export const createServices = (config: Config, actions: ApiActions) => {
         searchService,
         tagService,
         userService,
+        virtualMachineService,
         webdavClient,
         workflowService,
+        vocabularyService,
     };
 };
 
diff --git a/src/services/virtual-machines-service/virtual-machines-service.ts b/src/services/virtual-machines-service/virtual-machines-service.ts
new file mode 100644 (file)
index 0000000..c54eff4
--- /dev/null
@@ -0,0 +1,38 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { CommonResourceService } from "~/services/common-service/common-resource-service";
+import { VirtualMachinesResource } from '~/models/virtual-machines';
+import { ApiActions } from '~/services/api/api-actions';
+
+export class VirtualMachinesService extends CommonResourceService<VirtualMachinesResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "virtual_machines", actions);
+    }
+
+    getRequestedDate(): string {
+        return localStorage.getItem('requestedDate') || '';
+    }
+
+    saveRequestedDate(date: string) {
+        localStorage.setItem('requestedDate', date);
+    }
+
+    logins(uuid: string) {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .get(`virtual_machines/${uuid}/logins`),
+            this.actions
+        );
+    }
+
+    getAllLogins() {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .get('virtual_machines/get_all_logins'),
+            this.actions
+        );
+    }
+}
\ No newline at end of file
diff --git a/src/services/vocabulary-service/vocabulary-service.ts b/src/services/vocabulary-service/vocabulary-service.ts
new file mode 100644 (file)
index 0000000..57bdd7c
--- /dev/null
@@ -0,0 +1,18 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import Axios from 'axios';
+import { Vocabulary } from '~/models/vocabulary';
+
+export class VocabularyService {
+    constructor(
+        private url: string
+    ) { }
+
+    getVocabulary() {
+        return Axios
+            .get<Vocabulary>(this.url)
+            .then(response => response.data);
+    }
+}
index 6ad8af22a67ac8295d32793c276137fd2e6a4d98..b3c5164c5561e8d3e8104b6a4d700e9c904a21df 100644 (file)
@@ -2,9 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { Dispatch } from 'redux';
 import { dialogActions } from '~/store/dialog/dialog-actions';
 import { RootState } from '~/store/store';
-import { Dispatch } from 'redux';
 import { ResourceKind, extractUuidKind } from '~/models/resource';
 import { getResource } from '~/store/resources/resources';
 import { GroupContentsResourcePrefix } from '~/services/groups-service/groups-service';
@@ -15,13 +15,14 @@ import { ProjectResource } from '~/models/project';
 import { ServiceRepository } from '~/services/services';
 import { FilterBuilder } from '~/services/api/filter-builder';
 import { RepositoryResource } from '~/models/repositories';
+import { SshKeyResource } from '~/models/ssh-key';
 
 export const ADVANCED_TAB_DIALOG = 'advancedTabDialog';
 
-export interface AdvancedTabDialogData {
+interface AdvancedTabDialogData {
     apiResponse: any;
     metadata: any;
-    uuid: string;
+    user: string;
     pythonHeader: string;
     pythonExample: string;
     cliGetHeader: string;
@@ -52,41 +53,65 @@ enum RepositoryData {
     CREATED_AT = 'created_at'
 }
 
+enum SshKeyData {
+    SSH_KEY = 'authorized_keys',
+    CREATED_AT = 'created_at'
+}
+
+type AdvanceResourceKind = CollectionData | ProcessData | ProjectData | RepositoryData | SshKeyData;
+type AdvanceResourcePrefix = GroupContentsResourcePrefix | 'repositories' | 'authorized_keys';
+
 export const openAdvancedTabDialog = (uuid: string, index?: number) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const { resources } = getState();
         const kind = extractUuidKind(uuid);
-        const data = getResource<any>(uuid)(resources);
-        const repositoryData = getState().repositories.items[index!];
-        if (data || repositoryData) {
-            if (data) {
-                const user = await services.userService.get(data.ownerUuid);
-                const metadata = await services.linkService.list({
-                    filters: new FilterBuilder()
-                        .addEqual('headUuid', uuid)
-                        .getFilters()
-                });
-                if (kind === ResourceKind.COLLECTION) {
-                    const dataCollection: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, collectionApiResponse, data, CollectionData.COLLECTION, GroupContentsResourcePrefix.COLLECTION, CollectionData.STORAGE_CLASSES_CONFIRMED, data.storageClassesConfirmed);
-                    dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataCollection }));
-                } else if (kind === ResourceKind.PROCESS) {
-                    const dataProcess: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, containerRequestApiResponse, data, ProcessData.CONTAINER_REQUEST, GroupContentsResourcePrefix.PROCESS, ProcessData.OUTPUT_NAME, data.outputName);
-                    dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataProcess }));
-                } else if (kind === ResourceKind.PROJECT) {
-                    const dataProject: AdvancedTabDialogData = advancedTabData(uuid, metadata, user, groupRequestApiResponse, data, ProjectData.GROUP, GroupContentsResourcePrefix.PROJECT, ProjectData.DELETE_AT, data.deleteAt);
-                    dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataProject }));
-                }
-
-            } else if (kind === ResourceKind.REPOSITORY) {
-                const dataRepository: AdvancedTabDialogData = advancedTabData(uuid, '', '', repositoryApiResponse, repositoryData, RepositoryData.REPOSITORY, 'repositories', RepositoryData.CREATED_AT, repositoryData.createdAt);
-                dispatch(dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data: dataRepository }));
-            }
-        } else {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not open advanced tab for this resource.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+        switch (kind) {
+            case ResourceKind.COLLECTION:
+                const { data: dataCollection, metadata: metaCollection, user: userCollection } = await dispatch<any>(getDataForAdvancedTab(uuid));
+                const advanceDataCollection: AdvancedTabDialogData = advancedTabData(uuid, metaCollection, userCollection, collectionApiResponse, dataCollection, CollectionData.COLLECTION, GroupContentsResourcePrefix.COLLECTION, CollectionData.STORAGE_CLASSES_CONFIRMED, dataCollection.storageClassesConfirmed);
+                dispatch<any>(initAdvancedTabDialog(advanceDataCollection));
+                break;
+            case ResourceKind.PROCESS:
+                const { data: dataProcess, metadata: metaProcess, user: userProcess } = await dispatch<any>(getDataForAdvancedTab(uuid));
+                const advancedDataProcess: AdvancedTabDialogData = advancedTabData(uuid, metaProcess, userProcess, containerRequestApiResponse, dataProcess, ProcessData.CONTAINER_REQUEST, GroupContentsResourcePrefix.PROCESS, ProcessData.OUTPUT_NAME, dataProcess.outputName);
+                dispatch<any>(initAdvancedTabDialog(advancedDataProcess));
+                break;
+            case ResourceKind.PROJECT:
+                const { data: dataProject, metadata: metaProject, user: userProject } = await dispatch<any>(getDataForAdvancedTab(uuid));
+                const advanceDataProject: AdvancedTabDialogData = advancedTabData(uuid, metaProject, userProject, groupRequestApiResponse, dataProject, ProjectData.GROUP, GroupContentsResourcePrefix.PROJECT, ProjectData.DELETE_AT, dataProject.deleteAt);
+                dispatch<any>(initAdvancedTabDialog(advanceDataProject));
+                break;
+            case ResourceKind.REPOSITORY:
+                const dataRepository = getState().repositories.items[index!];
+                const advanceDataRepository: AdvancedTabDialogData = advancedTabData(uuid, '', '', repositoryApiResponse, dataRepository, RepositoryData.REPOSITORY, 'repositories', RepositoryData.CREATED_AT, dataRepository.createdAt);
+                dispatch<any>(initAdvancedTabDialog(advanceDataRepository));
+                break;
+            case ResourceKind.SSH_KEY:
+                const dataSshKey = getState().auth.sshKeys[index!];
+                const advanceDataSshKey: AdvancedTabDialogData = advancedTabData(uuid, '', '', sshKeyApiResponse, dataSshKey, SshKeyData.SSH_KEY, 'authorized_keys', SshKeyData.CREATED_AT, dataSshKey.createdAt);
+                dispatch<any>(initAdvancedTabDialog(advanceDataSshKey));
+                break;
+            default:
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not open advanced tab for this resource.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
-const advancedTabData = (uuid: string, metadata: any, user: any, apiResponseKind: any, data: any, resourceKind: CollectionData | ProcessData | ProjectData | RepositoryData, resourcePrefix: GroupContentsResourcePrefix | 'repositories', resourceKindProperty: CollectionData | ProcessData | ProjectData | RepositoryData, property: any) => {
+const getDataForAdvancedTab = (uuid: string) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<any>(uuid)(resources);
+        const metadata = await services.linkService.list({
+            filters: new FilterBuilder()
+                .addEqual('headUuid', uuid)
+                .getFilters()
+        });
+        const user = metadata.itemsAvailable && await services.userService.get(metadata.items[0].tailUuid || '');
+        return { data, metadata, user };
+    };
+
+const initAdvancedTabDialog = (data: AdvancedTabDialogData) => dialogActions.OPEN_DIALOG({ id: ADVANCED_TAB_DIALOG, data });
+
+const advancedTabData = (uuid: string, metadata: any, user: any, apiResponseKind: any, data: any, resourceKind: AdvanceResourceKind, 
+    resourcePrefix: AdvanceResourcePrefix, resourceKindProperty: AdvanceResourceKind, property: any) => {
     return {
         uuid,
         user,
@@ -109,7 +134,7 @@ const pythonHeader = (resourceKind: string) =>
 const pythonExample = (uuid: string, resourcePrefix: string) => {
     const pythonExample = `import arvados
 
- x = arvados.api().${resourcePrefix}().get(uuid='${uuid}').execute()`;
+x = arvados.api().${resourcePrefix}().get(uuid='${uuid}').execute()`;
 
     return pythonExample;
 };
@@ -119,7 +144,7 @@ const cliGetHeader = (resourceKind: string) =>
 
 const cliGetExample = (uuid: string, resourceKind: string) => {
     const cliGetExample = `arv ${resourceKind} get \\
- --uuid ${uuid}`;
 --uuid ${uuid}`;
 
     return cliGetExample;
 };
@@ -128,9 +153,9 @@ const cliUpdateHeader = (resourceKind: string, resourceName: string) =>
     `An example arv command to update the "${resourceName}" attribute for the current ${resourceKind}:`;
 
 const cliUpdateExample = (uuid: string, resourceKind: string, resource: string | string[], resourceName: string) => {
-    const CLIUpdateCollectionExample = `arv ${resourceKind} update \\ 
- --uuid ${uuid} \\
- --${resourceKind} '{"${resourceName}":${resource}}'`;
+    const CLIUpdateCollectionExample = `arv ${resourceKind} update \\
 --uuid ${uuid} \\
 --${resourceKind} '{"${resourceName}":${resource}}'`;
 
     return CLIUpdateCollectionExample;
 };
@@ -140,10 +165,10 @@ const curlHeader = (resourceKind: string, resource: string) =>
 
 const curlExample = (uuid: string, resourcePrefix: string, resource: string | string[], resourceKind: string, resourceName: string) => {
     const curlExample = `curl -X PUT \\
- -H "Authorization: OAuth2 $ARVADOS_API_TOKEN" \\
- --data-urlencode ${resourceKind}@/dev/stdin \\
- https://$ARVADOS_API_HOST/arvados/v1/${resourcePrefix}/${uuid} \\
- <<EOF
 -H "Authorization: OAuth2 $ARVADOS_API_TOKEN" \\
 --data-urlencode ${resourceKind}@/dev/stdin \\
 https://$ARVADOS_API_HOST/arvados/v1/${resourcePrefix}/${uuid} \\
 <<EOF
 {
   "${resourceName}": ${resource}
 }
@@ -253,5 +278,19 @@ const repositoryApiResponse = (apiResponse: RepositoryResource) => {
 "name": ${stringify(name)},
 "created_at": "${createdAt}"`;
 
+    return response;
+};
+
+const sshKeyApiResponse = (apiResponse: SshKeyResource) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, authorizedUserUuid, expiresAt } = apiResponse;
+    const response = `"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"authorized_user_uuid": "${authorizedUserUuid}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"name": ${stringify(name)},
+"created_at": "${createdAt}",
+"expires_at": "${expiresAt}"`;
     return response;
 };
\ No newline at end of file
index a1cd7f4f776776831956deae615b0ae0ed30878c..3d6913a67d9a3f4514725af7fc371739028e8911 100644 (file)
@@ -10,7 +10,8 @@ import {
     USER_FIRST_NAME_KEY,
     USER_LAST_NAME_KEY,
     USER_OWNER_UUID_KEY,
-    USER_UUID_KEY
+    USER_UUID_KEY,
+    USER_IS_ADMIN
 } from "~/services/auth-service/auth-service";
 
 import 'jest-localstorage-mock';
@@ -42,17 +43,20 @@ describe('auth-actions', () => {
         localStorage.setItem(USER_LAST_NAME_KEY, "Doe");
         localStorage.setItem(USER_UUID_KEY, "uuid");
         localStorage.setItem(USER_OWNER_UUID_KEY, "ownerUuid");
+        localStorage.setItem(USER_IS_ADMIN, JSON.stringify(false));
 
         store.dispatch(initAuth());
 
         expect(store.getState().auth).toEqual({
             apiToken: "token",
+            sshKeys: [],
             user: {
                 email: "test@test.com",
                 firstName: "John",
                 lastName: "Doe",
                 uuid: "uuid",
-                ownerUuid: "ownerUuid"
+                ownerUuid: "ownerUuid",
+                isAdmin: false
             }
         });
     });
index 592191caec493503bcce69174c78f81fdedefee4..b9f768f1cb0a42a9653d119cc577e516b21407ba 100644 (file)
@@ -31,12 +31,14 @@ describe('auth-reducer', () => {
             uuid: "uuid",
             ownerUuid: "ownerUuid",
             identityUrl: "identityUrl",
-            prefs: {}
+            prefs: {},
+            isAdmin: false
         };
         const state = reducer(initialState, authActions.INIT({ user, token: "token" }));
         expect(state).toEqual({
             apiToken: "token",
-            user
+            user,
+            sshKeys: []
         });
     });
 
@@ -61,7 +63,8 @@ describe('auth-reducer', () => {
             uuid: "uuid",
             ownerUuid: "ownerUuid",
             identityUrl: "identityUrl",
-            prefs: {}
+            prefs: {},
+            isAdmin: false
         };
 
         const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
@@ -74,6 +77,7 @@ describe('auth-reducer', () => {
                 lastName: "Doe",
                 uuid: "uuid",
                 ownerUuid: "ownerUuid",
+                isAdmin: false
             }
         });
     });
index 0a6b5a8241a3360bd29ccaf185b3e432e6c76fee..5631a5e85cf9880b99fde0afd22f339a55ee734f 100644 (file)
@@ -132,10 +132,10 @@ export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, pro
     (dispatch: Dispatch, getState: () => RootState) => {
         const resource = {
             uuid: process.containerRequest.uuid,
-            ownerUuid: '',
+            ownerUuid: process.containerRequest.ownerUuid,
             kind: ResourceKind.PROCESS,
-            name: '',
-            description: '',
+            name: process.containerRequest.name,
+            description: process.containerRequest.description,
             menuKind: ContextMenuKind.PROCESS
         };
         dispatch<any>(openContextMenu(event, resource));
index 030b18e21835eaa94c14392c131560a3da93c481..fe8186b7c9c4eb21abb7006a68b09de64855863a 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { dialogActions } from "~/store/dialog/dialog-actions";
-import { getProperty } from '../properties/properties';
+import { getProperty } from '~/store/properties/properties';
 import { propertiesActions } from '~/store/properties/properties-actions';
 import { RootState } from '~/store/store';
 
index a58d20edb17eea49270caf32a5cc959bef7ecb82..7797ae6cefc27759d3f994b0d7f14f9225b5f193 100644 (file)
@@ -3,14 +3,14 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "~/common/unionize";
-import { DataTableFilterItem } from "~/components/data-table-filters/data-table-filters";
 import { DataColumns } from "~/components/data-table/data-table";
+import { DataTableFilters } from '~/components/data-table-filters/data-table-filters-tree';
 
 export const dataExplorerActions = unionize({
     RESET_PAGINATION: ofType<{ id: string }>(),
     REQUEST_ITEMS: ofType<{ id: string }>(),
     SET_COLUMNS: ofType<{ id: string, columns: DataColumns<any> }>(),
-    SET_FILTERS: ofType<{ id: string, columnName: string, filters: DataTableFilterItem[] }>(),
+    SET_FILTERS: ofType<{ id: string, columnName: string, filters: DataTableFilters }>(),
     SET_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(),
     SET_PAGE: ofType<{ id: string, page: number }>(),
     SET_ROWS_PER_PAGE: ofType<{ id: string, rowsPerPage: number }>(),
@@ -28,7 +28,7 @@ export const bindDataExplorerActions = (id: string) => ({
         dataExplorerActions.REQUEST_ITEMS({ id }),
     SET_COLUMNS: (payload: { columns: DataColumns<any> }) =>
         dataExplorerActions.SET_COLUMNS({ ...payload, id }),
-    SET_FILTERS: (payload: { columnName: string, filters: DataTableFilterItem[] }) =>
+    SET_FILTERS: (payload: { columnName: string, filters: DataTableFilters }) =>
         dataExplorerActions.SET_FILTERS({ ...payload, id }),
     SET_ITEMS: (payload: { items: any[], page: number, rowsPerPage: number, itemsAvailable: number }) =>
         dataExplorerActions.SET_ITEMS({ ...payload, id }),
index 934af7be94ca04ebe369a7911403c09810592684..80ab514cb41440b9a3356f62ed6379a4ec08896c 100644 (file)
@@ -5,9 +5,10 @@
 import { Dispatch, MiddlewareAPI } from "redux";
 import { RootState } from "../store";
 import { DataColumns } from "~/components/data-table/data-table";
-import { DataTableFilterItem } from "~/components/data-table-filters/data-table-filters";
 import { DataExplorer } from './data-explorer-reducer';
 import { ListResults } from '~/services/common-service/common-resource-service';
+import { createTree } from "~/models/tree";
+import { DataTableFilters } from "~/components/data-table-filters/data-table-filters-tree";
 
 export abstract class DataExplorerMiddlewareService {
     protected readonly id: string;
@@ -20,20 +21,19 @@ export abstract class DataExplorerMiddlewareService {
         return this.id;
     }
 
-    public getColumnFilters<T, F extends DataTableFilterItem>(columns: DataColumns<T, F>, columnName: string): F[] {
-        const column = columns.find(c => c.name === columnName);
-        return column ? column.filters.filter(f => f.selected) : [];
+    public getColumnFilters<T>(columns: DataColumns<T>, columnName: string): DataTableFilters {
+        return getDataExplorerColumnFilters(columns, columnName);
     }
 
     abstract requestItems(api: MiddlewareAPI<Dispatch, RootState>): void;
 }
 
-export const getDataExplorerColumnFilters = <T, F extends DataTableFilterItem>(columns: DataColumns<T, F>, columnName: string): F[] => {
+export const getDataExplorerColumnFilters = <T>(columns: DataColumns<T>, columnName: string): DataTableFilters => {
     const column = columns.find(c => c.name === columnName);
-    return column ? column.filters.filter(f => f.selected) : [];
+    return column ? column.filters : createTree();
 };
 
-export const dataExplorerToListParams = <R>(dataExplorer: DataExplorer) => ({
+export const dataExplorerToListParams = (dataExplorer: DataExplorer) => ({
     limit: dataExplorer.rowsPerPage,
     offset: dataExplorer.page * dataExplorer.rowsPerPage,
 });
index 814d5855c0b6a45b0ee0224d73e382f13b561560..00931bf8e3c95cd5d98404ac74a14b136253536a 100644 (file)
@@ -8,6 +8,8 @@ import { MiddlewareAPI } from "redux";
 import { DataColumns } from "~/components/data-table/data-table";
 import { dataExplorerActions } from "./data-explorer-action";
 import { SortDirection } from "~/components/data-table/data-column";
+import { createTree } from '~/models/tree';
+import { DataTableFilterItem } from "~/components/data-table-filters/data-table-filters-tree";
 
 
 describe("DataExplorerMiddleware", () => {
@@ -20,7 +22,7 @@ describe("DataExplorerMiddleware", () => {
                 selected: true,
                 configurable: false,
                 sortDirection: SortDirection.NONE,
-                filters: [],
+                filters: createTree<DataTableFilterItem>(),
                 render: jest.fn()
             }],
             requestItems: jest.fn(),
@@ -48,7 +50,7 @@ describe("DataExplorerMiddleware", () => {
                 selected: true,
                 configurable: false,
                 sortDirection: SortDirection.NONE,
-                filters: [],
+                filters: createTree<DataTableFilterItem>(),
                 render: jest.fn()
             }],
             requestItems: jest.fn(),
@@ -115,7 +117,7 @@ describe("DataExplorerMiddleware", () => {
         };
         const next = jest.fn();
         const middleware = dataExplorerMiddleware(service)(api)(next);
-        middleware(dataExplorerActions.SET_FILTERS({ id: service.getId(), columnName: "", filters: [] }));
+        middleware(dataExplorerActions.SET_FILTERS({ id: service.getId(), columnName: "", filters: createTree() }));
         expect(api.dispatch).toHaveBeenCalledTimes(2);
     });
 
index 1657ab70ed7bc1fbe13bc4c691f38d41084f45c2..613bf278edd81d67ad73cc84402ab991aa8353a7 100644 (file)
@@ -4,8 +4,8 @@
 
 import { DataColumn, toggleSortDirection, resetSortDirection, SortDirection } from "~/components/data-table/data-column";
 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";
+import { DataTableFilters } from "~/components/data-table-filters/data-table-filters-tree";
 
 export interface DataExplorer {
     columns: DataColumns<any>;
@@ -103,7 +103,7 @@ const toggleColumn = (columnName: string) =>
         ? { ...column, selected: !column.selected }
         : column;
 
-const setFilters = (columnName: string, filters: DataTableFilterItem[]) =>
+const setFilters = (columnName: string, filters: DataTableFilters) =>
     (column: DataColumn<any>) => column.name === columnName
         ? { ...column, filters }
         : column;
index d7d54dedb03ca5500cb0a5da269cbe7ed6c7fc18..87f49f34f281221da6bb3ea7e33df5b3852d520d 100644 (file)
@@ -2,8 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { DataExplorerMiddlewareService } from "../data-explorer/data-explorer-middleware-service";
-import { FavoritePanelColumnNames, FavoritePanelFilter } from "~/views/favorite-panel/favorite-panel";
+import { DataExplorerMiddlewareService } from "~/store/data-explorer/data-explorer-middleware-service";
+import { FavoritePanelColumnNames } from "~/views/favorite-panel/favorite-panel";
 import { RootState } from "../store";
 import { DataColumns } from "~/components/data-table/data-table";
 import { ServiceRepository } from "~/services/services";
@@ -21,6 +21,8 @@ import { progressIndicatorActions } from '~/store/progress-indicator/progress-in
 import { getDataExplorer } from "~/store/data-explorer/data-explorer-reducer";
 import { loadMissingProcessesInformation } from "~/store/project-panel/project-panel-middleware-service";
 import { getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
+import { getDataExplorerColumnFilters } from '~/store/data-explorer/data-explorer-middleware-service';
+import { serializeSimpleObjectTypeFilters } from '../resource-type-filters/resource-type-filters';
 
 export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -32,9 +34,10 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic
         if (!dataExplorer) {
             api.dispatch(favoritesPanelDataExplorerIsNotSet());
         } else {
-            const columns = dataExplorer.columns as DataColumns<string, FavoritePanelFilter>;
+            const columns = dataExplorer.columns as DataColumns<string>;
             const sortColumn = getSortColumn(dataExplorer);
-            const typeFilters = this.getColumnFilters(columns, FavoritePanelColumnNames.TYPE);
+            const typeFilters = serializeSimpleObjectTypeFilters(getDataExplorerColumnFilters(columns, FavoritePanelColumnNames.TYPE));
+
 
             const linkOrder = new OrderBuilder<LinkResource>();
             const contentOrder = new OrderBuilder<GroupContentsResource>();
@@ -59,9 +62,10 @@ export class FavoritePanelMiddlewareService extends DataExplorerMiddlewareServic
                         linkOrder: linkOrder.getOrder(),
                         contentOrder: contentOrder.getOrder(),
                         filters: new FilterBuilder()
-                            .addIsA("headUuid", typeFilters.map(filter => filter.type))
                             .addILike("name", dataExplorer.searchValue)
-                            .getFilters()
+                            .addIsA("headUuid", typeFilters)
+                            .getFilters(),
+
                     });
                 api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
                 api.dispatch(resourcesActions.SET_RESOURCES(response.items));
index 0b6713bf04cb0d685b59e85f0327bb04a2256c56..80a7f2136eee707a5c87e320d9b74722a4e1ff20 100644 (file)
@@ -62,6 +62,8 @@ export const navigateToRunProcess = push(Routes.RUN_PROCESS);
 
 export const navigateToSearchResults = push(Routes.SEARCH_RESULTS);
 
+export const navigateToVirtualMachines = push(Routes.VIRTUAL_MACHINES);
+
 export const navigateToRepositories = push(Routes.REPOSITORIES);
 
 export const navigateToSshKeys= push(Routes.SSH_KEYS);
index cd3fe21c28abb97d96334aa2c562202ba8202560..01387852107c3224bb6c348aca9b05189d4441c4 100644 (file)
@@ -9,7 +9,7 @@ import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
-import { getProcess, ProcessStatus, getProcessStatus } from '~/store/processes/process';
+import { getProcess } from '~/store/processes/process';
 import { snackbarActions } from '~/store/snackbar/snackbar-actions';
 import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 
@@ -19,16 +19,11 @@ export const openCopyProcessDialog = (resource: { name: string, uuid: string })
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getProcess(resource.uuid)(getState().resources);
         if (process) {
-            const processStatus = getProcessStatus(process);
-            if (processStatus === ProcessStatus.DRAFT) {
-                dispatch<any>(resetPickerProjectTree());
-                dispatch<any>(initProjectsTreePicker(PROCESS_COPY_FORM_NAME));
-                const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, uuid: resource.uuid, ownerUuid: '' };
-                dispatch<any>(initialize(PROCESS_COPY_FORM_NAME, initialData));
-                dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_COPY_FORM_NAME, data: {} }));
-            } else {
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'You can copy only draft processes.', hideDuration: 2000 }));
-            }
+            dispatch<any>(resetPickerProjectTree());
+            dispatch<any>(initProjectsTreePicker(PROCESS_COPY_FORM_NAME));
+            const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, uuid: resource.uuid, ownerUuid: '' };
+            dispatch<any>(initialize(PROCESS_COPY_FORM_NAME, initialData));
+            dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_COPY_FORM_NAME, data: {} }));
         } else {
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process not found', hideDuration: 2000 }));
         }
@@ -39,9 +34,8 @@ export const copyProcess = (resource: CopyFormDialogData) =>
         dispatch(startSubmit(PROCESS_COPY_FORM_NAME));
         try {
             const process = await services.containerRequestService.get(resource.uuid);
-            const uuidKey = 'uuid';
-            delete process[uuidKey];
-            await services.containerRequestService.create({ ...process, ownerUuid: resource.ownerUuid, name: resource.name });
+            const { kind, containerImage, outputPath, outputName, containerCountMax, command, properties, requestingContainerUuid, mounts, runtimeConstraints, schedulingParameters, environment, cwd, outputTtl, priority, expiresAt, useExisting, filters } = process;
+            await services.containerRequestService.create({ command, containerImage, outputPath, ownerUuid: resource.ownerUuid, name: resource.name, kind, outputName, containerCountMax, properties, requestingContainerUuid, mounts, runtimeConstraints, schedulingParameters, environment, cwd, outputTtl, priority, expiresAt, useExisting, filters });
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
             return process;
         } catch (e) {
index edba5a8574e16814c6a23988d85a1d4657d431b5..7e65bcca0bd221c8eea7bd293b77f42895ee26f5 100644 (file)
@@ -12,7 +12,7 @@ import { snackbarActions } from '~/store/snackbar/snackbar-actions';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
 import { projectPanelActions } from '~/store/project-panel/project-panel-action';
-import { getProcess, getProcessStatus, ProcessStatus } from '~/store/processes/process';
+import { getProcess } from '~/store/processes/process';
 import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 
 export const PROCESS_MOVE_FORM_NAME = 'processMoveFormName';
@@ -21,15 +21,10 @@ export const openMoveProcessDialog = (resource: { name: string, uuid: string })
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getProcess(resource.uuid)(getState().resources);
         if (process) {
-            const processStatus = getProcessStatus(process);
-            if (processStatus === ProcessStatus.DRAFT) {
-                dispatch<any>(resetPickerProjectTree());
-                dispatch<any>(initProjectsTreePicker(PROCESS_MOVE_FORM_NAME));
-                dispatch(initialize(PROCESS_MOVE_FORM_NAME, resource));
-                dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_MOVE_FORM_NAME, data: {} }));
-            } else {
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'You can move only draft processes.', hideDuration: 2000 }));
-            }
+            dispatch<any>(resetPickerProjectTree());
+            dispatch<any>(initProjectsTreePicker(PROCESS_MOVE_FORM_NAME));
+            dispatch(initialize(PROCESS_MOVE_FORM_NAME, resource));
+            dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_MOVE_FORM_NAME, data: {} }));
         } else {
             dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process not found', hideDuration: 2000 }));
         }
@@ -40,7 +35,7 @@ export const moveProcess = (resource: MoveToFormDialogData) =>
         dispatch(startSubmit(PROCESS_MOVE_FORM_NAME));
         try {
             const process = await services.containerRequestService.get(resource.uuid);
-            await services.containerRequestService.update(resource.uuid, { ...process, ownerUuid: resource.ownerUuid });
+            await services.containerRequestService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
             return process;
@@ -48,8 +43,6 @@ export const moveProcess = (resource: MoveToFormDialogData) =>
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
                 dispatch(stopSubmit(PROCESS_MOVE_FORM_NAME, { ownerUuid: 'A process with the same name already exists in the target project.' }));
-            } else if (error === CommonResourceServiceError.MODIFYING_CONTAINER_REQUEST_FINAL_STATE) {
-                dispatch(stopSubmit(PROCESS_MOVE_FORM_NAME, { ownerUuid: 'You can move only draft processes.' }));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the process.', hideDuration: 2000 }));
index 92cf032f37ae57562960d61074802bf6c35d23a6..2063f11362ced7bb2b04e0327dc3743432bc9b48 100644 (file)
@@ -34,8 +34,7 @@ export const updateProcess = (resource: ProcessUpdateFormDialogData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(PROCESS_UPDATE_FORM_NAME));
         try {
-            const process = await services.containerRequestService.get(resource.uuid);
-            const updatedProcess = await services.containerRequestService.update(resource.uuid, { ...process, name: resource.name });
+            const updatedProcess = await services.containerRequestService.update(resource.uuid, { name: resource.name });
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
             return updatedProcess;
@@ -43,8 +42,6 @@ export const updateProcess = (resource: ProcessUpdateFormDialogData) =>
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
                 dispatch(stopSubmit(PROCESS_UPDATE_FORM_NAME, { name: 'Process with the same name already exists.' }));
-            } else if (error === CommonResourceServiceError.MODIFYING_CONTAINER_REQUEST_FINAL_STATE) {
-                dispatch(stopSubmit(PROCESS_UPDATE_FORM_NAME, { name: 'You cannot modified in "Final" state.' }));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not update the process.', hideDuration: 2000 }));
index ef720923b317c0c0c25a4724b63988c0a6c52c1b..21598fad1c6072eb7de02ac7d64215af1f42bfa9 100644 (file)
@@ -2,10 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { bindDataExplorerActions } from "../data-explorer/data-explorer-action";
-import { propertiesActions } from "~/store/properties/properties-actions";
 import { Dispatch } from 'redux';
-import { ServiceRepository } from "~/services/services";
+import { bindDataExplorerActions } from "~/store/data-explorer/data-explorer-action";
+import { propertiesActions } from "~/store/properties/properties-actions";
 import { RootState } from '~/store/store';
 import { getProperty } from "~/store/properties/properties";
 
@@ -15,7 +14,7 @@ export const IS_PROJECT_PANEL_TRASHED = 'isProjectPanelTrashed';
 export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID);
 
 export const openProjectPanel = (projectUuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    (dispatch: Dispatch) => {
         dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid }));
         dispatch(projectPanelActions.REQUEST_ITEMS());
     };
index 458444f3b943b053af7debd291858d4720cb66bc..36672e99ac19003ede9f9ade9240d870de496d14 100644 (file)
@@ -14,7 +14,7 @@ import { DataColumns } from "~/components/data-table/data-table";
 import { ServiceRepository } from "~/services/services";
 import { SortDirection } from "~/components/data-table/data-column";
 import { OrderBuilder, OrderDirection } from "~/services/api/order-builder";
-import { FilterBuilder } from "~/services/api/filter-builder";
+import { FilterBuilder, joinFilters } from "~/services/api/filter-builder";
 import { GroupContentsResource, GroupContentsResourcePrefix } from "~/services/groups-service/groups-service";
 import { updateFavorites } from "../favorites/favorites-actions";
 import { PROJECT_PANEL_CURRENT_UUID, IS_PROJECT_PANEL_TRASHED, projectPanelActions } from './project-panel-action';
@@ -32,6 +32,7 @@ import { getResource } from "~/store/resources/resources";
 import { CollectionResource } from "~/models/collection";
 import { resourcesDataActions } from "~/store/resources-data/resources-data-actions";
 import { getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
+import { serializeResourceTypeFilters } from '../resource-type-filters/resource-type-filters';
 
 export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -115,15 +116,22 @@ export const getParams = (dataExplorer: DataExplorer, isProjectTrashed: boolean)
 });
 
 export const getFilters = (dataExplorer: DataExplorer) => {
-    const columns = dataExplorer.columns as DataColumns<string, ProjectPanelFilter>;
-    const typeFilters = getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE);
-    const statusFilters = getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.STATUS);
-    return new FilterBuilder()
-        .addIsA("uuid", typeFilters.map(f => f.type))
+    const columns = dataExplorer.columns as DataColumns<string>;
+    const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
+
+    // TODO: Extract group contents name filter
+    const nameFilters = new FilterBuilder()
         .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION)
         .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROCESS)
         .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT)
         .getFilters();
+
+    return joinFilters(
+        typeFilters,
+        nameFilters,
+    );
+    // TODO: Restore process status filters
+    // const statusFilters = getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.STATUS);
 };
 
 export const getOrder = (dataExplorer: DataExplorer) => {
diff --git a/src/store/resource-type-filters/resource-type-filters.test.ts b/src/store/resource-type-filters/resource-type-filters.test.ts
new file mode 100644 (file)
index 0000000..02f017e
--- /dev/null
@@ -0,0 +1,50 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getInitialResourceTypeFilters, serializeResourceTypeFilters, ObjectTypeFilter, CollectionTypeFilter } from './resource-type-filters';
+import { ResourceKind } from '~/models/resource';
+import { deselectNode } from '~/models/tree';
+import { pipe } from 'lodash/fp';
+
+describe("serializeResourceTypeFilters", () => {
+    it("should serialize all filters", () => {
+        const filters = getInitialResourceTypeFilters();
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.PROCESS}","${ResourceKind.COLLECTION}"]]`);
+    });
+
+    it("should serialize all but collection filters", () => {
+        const filters = deselectNode(ObjectTypeFilter.COLLECTION)(getInitialResourceTypeFilters());
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.PROCESS}"]]`);
+    });
+
+    it("should serialize output collections and projects", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION),
+            deselectNode(CollectionTypeFilter.LOG_COLLECTION),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.COLLECTION}"]],["collections.properties.type","in",["output"]]`);
+    });
+
+    it("should serialize general and log collections", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.PROJECT),
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(CollectionTypeFilter.OUTPUT_COLLECTION)
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.COLLECTION}"]],["collections.properties.type","not in",["output"]]`);
+    });
+});
diff --git a/src/store/resource-type-filters/resource-type-filters.ts b/src/store/resource-type-filters/resource-type-filters.ts
new file mode 100644 (file)
index 0000000..78777be
--- /dev/null
@@ -0,0 +1,141 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { difference, pipe, values, includes, __ } from 'lodash/fp';
+import { createTree, setNode, TreeNodeStatus, TreeNode, Tree } from '~/models/tree';
+import { DataTableFilterItem, DataTableFilters } from '~/components/data-table-filters/data-table-filters-tree';
+import { ResourceKind } from '~/models/resource';
+import { FilterBuilder } from '~/services/api/filter-builder';
+import { getSelectedNodes } from '~/models/tree';
+import { CollectionType } from '~/models/collection';
+import { GroupContentsResourcePrefix } from '~/services/groups-service/groups-service';
+
+export enum ObjectTypeFilter {
+    PROJECT = 'Project',
+    PROCESS = 'Process',
+    COLLECTION = 'Data Collection',
+}
+
+export enum CollectionTypeFilter {
+    GENERAL_COLLECTION = 'General',
+    OUTPUT_COLLECTION = 'Output',
+    LOG_COLLECTION = 'Log',
+}
+
+const initFilter = (name: string, parent = '') =>
+    setNode<DataTableFilterItem>({
+        id: name,
+        value: { name },
+        parent,
+        children: [],
+        active: false,
+        selected: true,
+        expanded: false,
+        status: TreeNodeStatus.LOADED,
+    });
+
+export const getSimpleObjectTypeFilters = pipe(
+    (): DataTableFilters => createTree<DataTableFilterItem>(),
+    initFilter(ObjectTypeFilter.PROJECT),
+    initFilter(ObjectTypeFilter.PROCESS),
+    initFilter(ObjectTypeFilter.COLLECTION),
+);
+
+export const getInitialResourceTypeFilters = pipe(
+    (): DataTableFilters => createTree<DataTableFilterItem>(),
+    initFilter(ObjectTypeFilter.PROJECT),
+    initFilter(ObjectTypeFilter.PROCESS),
+    initFilter(ObjectTypeFilter.COLLECTION),
+    initFilter(CollectionTypeFilter.GENERAL_COLLECTION, ObjectTypeFilter.COLLECTION),
+    initFilter(CollectionTypeFilter.OUTPUT_COLLECTION, ObjectTypeFilter.COLLECTION),
+    initFilter(CollectionTypeFilter.LOG_COLLECTION, ObjectTypeFilter.COLLECTION),
+);
+
+
+const createFiltersBuilder = (filters: DataTableFilters) =>
+    ({ fb: new FilterBuilder(), selectedFilters: getSelectedNodes(filters) });
+
+const getMatchingFilters = (values: string[], filters: TreeNode<DataTableFilterItem>[]) =>
+    filters
+        .map(f => f.id)
+        .filter(includes(__, values));
+
+const objectTypeToResourceKind = (type: ObjectTypeFilter) => {
+    switch (type) {
+        case ObjectTypeFilter.PROJECT:
+            return ResourceKind.PROJECT;
+        case ObjectTypeFilter.PROCESS:
+            return ResourceKind.PROCESS;
+        case ObjectTypeFilter.COLLECTION:
+            return ResourceKind.COLLECTION;
+    }
+};
+
+const serializeObjectTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => {
+    const collectionFilters = getMatchingFilters(values(CollectionTypeFilter), selectedFilters);
+    const typeFilters = pipe(
+        () => new Set(getMatchingFilters(values(ObjectTypeFilter), selectedFilters)),
+        set => collectionFilters.length > 0
+            ? set.add(ObjectTypeFilter.COLLECTION)
+            : set,
+        set => Array.from(set)
+    )();
+
+    return {
+        fb: typeFilters.length > 0
+            ? fb.addIsA('uuid', typeFilters.map(objectTypeToResourceKind))
+            : fb,
+        selectedFilters,
+    };
+};
+
+const collectionTypeToPropertyValue = (type: CollectionTypeFilter) => {
+    switch (type) {
+        case CollectionTypeFilter.GENERAL_COLLECTION:
+            return CollectionType.GENERAL;
+        case CollectionTypeFilter.OUTPUT_COLLECTION:
+            return CollectionType.OUTPUT;
+        case CollectionTypeFilter.LOG_COLLECTION:
+            return CollectionType.LOG;
+    }
+};
+
+const serializeCollectionTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
+    () => getMatchingFilters(values(CollectionTypeFilter), selectedFilters),
+    filters => filters.map(collectionTypeToPropertyValue),
+    mappedFilters => ({
+        fb: buildCollectiomTypeFilters({ fb, filters: mappedFilters }),
+        selectedFilters
+    })
+)();
+
+const COLLECTION_TYPES = values(CollectionType);
+
+const NON_GENERAL_COLLECTION_TYPES = difference(COLLECTION_TYPES, [CollectionType.GENERAL]);
+
+const COLLECTION_PROPERTIES_PREFIX = `${GroupContentsResourcePrefix.COLLECTION}.properties`;
+
+const buildCollectiomTypeFilters = ({ fb, filters }: { fb: FilterBuilder, filters: CollectionType[] }) => {
+    switch (true) {
+        case filters.length === 0 || filters.length === COLLECTION_TYPES.length:
+            return fb;
+        case includes(CollectionType.GENERAL, filters):
+            return fb.addNotIn('type', difference(NON_GENERAL_COLLECTION_TYPES, filters), COLLECTION_PROPERTIES_PREFIX);
+        default:
+            return fb.addIn('type', filters, COLLECTION_PROPERTIES_PREFIX);
+    }
+};
+
+export const serializeResourceTypeFilters = pipe(
+    createFiltersBuilder,
+    serializeObjectTypeFilters,
+    serializeCollectionTypeFilters,
+    ({ fb }) => fb.getFilters(),
+);
+
+export const serializeSimpleObjectTypeFilters = (filters: Tree<DataTableFilterItem>) => {
+    return getSelectedNodes(filters)
+        .map(f => f.id)
+        .map(objectTypeToResourceKind);
+};
index 05da5b3e5787e36c1a9b97f128e11f4982994575..f7dc5d458f7fde7eb3af468253d4d816c5e5761f 100644 (file)
@@ -6,11 +6,13 @@ import { Dispatch } from 'redux';
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action';
+import { setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
 
 export const SEARCH_RESULTS_PANEL_ID = "searchResultsPanel";
 export const searchResultsPanelActions = bindDataExplorerActions(SEARCH_RESULTS_PANEL_ID);
 
 export const loadSearchResultsPanel = () =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(setBreadcrumbs([{ label: 'Search results' }]));
         dispatch(searchResultsPanelActions.REQUEST_ITEMS());
     };
\ No newline at end of file
index 0e3c76b28ef08f4c0c2a251d421cbc8e00d1aab4..37de6f8c2e9ab19e8a1c28c05ad4a20a8fb7013c 100644 (file)
@@ -56,13 +56,18 @@ export const sendSharingInvitations = async (dispatch: Dispatch) => {
 const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
 
     const dialog = getDialog<string>(getState().dialog, SHARING_DIALOG_NAME);
-
     if (dialog) {
         dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
-        const { items } = await permissionService.listResourcePermissions(dialog.data);
-        dispatch<any>(initializePublicAccessForm(items));
-        await dispatch<any>(initializeManagementForm(items));
-        dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+        try {
+            const { items } = await permissionService.listResourcePermissions(dialog.data);
+            dispatch<any>(initializePublicAccessForm(items));
+            await dispatch<any>(initializeManagementForm(items));
+            dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'You do not have access to share this item', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: SHARING_DIALOG_NAME }));
+            dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+        }
     }
 };
 
index 562f709614009d135b9a8be131986d625dc4be51..09009930d05062276548ab9f33f282d2340074b7 100644 (file)
@@ -20,7 +20,6 @@ export enum SidePanelTreeCategory {
     PROJECTS = 'Projects',
     SHARED_WITH_ME = 'Shared with me',
     WORKFLOWS = 'Workflows',
-    RECENT_OPEN = 'Recently open',
     FAVORITES = 'Favorites',
     TRASH = 'Trash'
 }
@@ -44,7 +43,6 @@ export const getSidePanelTreeBranch = (uuid: string) => (treePicker: TreePicker)
 
 const SIDE_PANEL_CATEGORIES = [
     SidePanelTreeCategory.WORKFLOWS,
-    SidePanelTreeCategory.RECENT_OPEN,
     SidePanelTreeCategory.FAVORITES,
     SidePanelTreeCategory.TRASH,
 ];
index 5e648c99ef9efadd32dffba96a493cd8dde2c397..4ab0918e60fb0ac6a636d03e4f95d0d9d3c6e468 100644 (file)
@@ -43,6 +43,7 @@ import { searchBarReducer } from './search-bar/search-bar-reducer';
 import { SEARCH_RESULTS_PANEL_ID } from '~/store/search-results-panel/search-results-panel-actions';
 import { SearchResultsMiddlewareService } from './search-results-panel/search-results-middleware-service';
 import { resourcesDataReducer } from "~/store/resources-data/resources-data-reducer";
+import { virtualMachinesReducer } from "~/store/virtual-machines/virtual-machines-reducer";
 import { repositoriesReducer } from '~/store/repositories/repositories-reducer';
 
 const composeEnhancers =
@@ -113,5 +114,6 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({
     runProcessPanel: runProcessPanelReducer,
     appInfo: appInfoReducer,
     searchBar: searchBarReducer,
+    virtualMachines: virtualMachinesReducer,
     repositories: repositoriesReducer
 });
index 9afc57b49f74e5808f7254f9d6b3362c74b03ee8..f52421a1d6581581cdf97f9613d848d6b3f74e15 100644 (file)
@@ -23,6 +23,9 @@ import { snackbarActions, SnackbarKind } from "~/store/snackbar/snackbar-actions
 import { updateResources } from "~/store/resources/resources-actions";
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
 import { getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
+import { serializeResourceTypeFilters } from '~/store//resource-type-filters/resource-type-filters';
+import { getDataExplorerColumnFilters } from '~/store/data-explorer/data-explorer-middleware-service';
+import { joinFilters } from '../../services/api/filter-builder';
 
 export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -31,9 +34,22 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
 
     async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
         const dataExplorer = api.getState().dataExplorer[this.getId()];
-        const columns = dataExplorer.columns as DataColumns<string, TrashPanelFilter>;
+        const columns = dataExplorer.columns as DataColumns<string>;
         const sortColumn = getSortColumn(dataExplorer);
-        const typeFilters = this.getColumnFilters(columns, TrashPanelColumnNames.TYPE);
+
+        const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
+
+        const otherFilters = new FilterBuilder()
+            .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION)
+            .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROCESS)
+            .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT)
+            .addEqual("is_trashed", true)
+            .getFilters();
+
+        const filters = joinFilters(
+            typeFilters,
+            otherFilters,
+        );
 
         const order = new OrderBuilder<ProjectResource>();
 
@@ -55,12 +71,7 @@ export class TrashPanelMiddlewareService extends DataExplorerMiddlewareService {
                 .contents(userUuid, {
                     ...dataExplorerToListParams(dataExplorer),
                     order: order.getOrder(),
-                    filters: new FilterBuilder()
-                        .addIsA("uuid", typeFilters.map(f => f.type))
-                        .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.COLLECTION)
-                        .addILike("name", dataExplorer.searchValue, GroupContentsResourcePrefix.PROJECT)
-                        .addEqual("is_trashed", true)
-                        .getFilters(),
+                    filters,
                     recursive: true,
                     includeTrash: true
                 });
diff --git a/src/store/virtual-machines/virtual-machines-actions.ts b/src/store/virtual-machines/virtual-machines-actions.ts
new file mode 100644 (file)
index 0000000..9bd7988
--- /dev/null
@@ -0,0 +1,65 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { RootState } from '~/store/store';
+import { ServiceRepository } from "~/services/services";
+import { navigateToVirtualMachines } from "../navigation/navigation-action";
+import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action';
+import { formatDate } from "~/common/formatters";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
+import { VirtualMachinesLoginsResource } from '~/models/virtual-machines';
+import { FilterBuilder } from "~/services/api/filter-builder";
+import { ListResults } from "~/services/common-service/common-resource-service";
+
+export const virtualMachinesActions = unionize({
+    SET_REQUESTED_DATE: ofType<string>(),
+    SET_VIRTUAL_MACHINES: ofType<ListResults<any>>(),
+    SET_LOGINS: ofType<VirtualMachinesLoginsResource[]>(),
+    SET_LINKS: ofType<ListResults<any>>()
+});
+
+export type VirtualMachineActions = UnionOf<typeof virtualMachinesActions>;
+
+export const VIRTUAL_MACHINES_PANEL = 'virtualMachinesPanel';
+
+export const openVirtualMachines = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateToVirtualMachines);
+    };
+
+const loadRequestedDate = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const date = services.virtualMachineService.getRequestedDate();
+        dispatch(virtualMachinesActions.SET_REQUESTED_DATE(date));
+    };
+
+
+export const loadVirtualMachinesData = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(loadRequestedDate());
+        const virtualMachines = await services.virtualMachineService.list();
+        const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid);
+        const links = await services.linkService.list({
+            filters: new FilterBuilder()
+                .addIn("headUuid", virtualMachinesUuids)
+                .getFilters()
+        });
+        dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
+        dispatch(virtualMachinesActions.SET_LINKS(links));
+    };
+
+export const saveRequestedDate = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const date = formatDate((new Date).toISOString());
+        services.virtualMachineService.saveRequestedDate(date);
+        dispatch<any>(loadRequestedDate());
+    };
+
+const virtualMachinesBindedActions = bindDataExplorerActions(VIRTUAL_MACHINES_PANEL);
+
+export const loadVirtualMachinesPanel = () =>
+    (dispatch: Dispatch) => {
+        dispatch(virtualMachinesBindedActions.REQUEST_ITEMS());
+    };
diff --git a/src/store/virtual-machines/virtual-machines-reducer.ts b/src/store/virtual-machines/virtual-machines-reducer.ts
new file mode 100644 (file)
index 0000000..fa28417
--- /dev/null
@@ -0,0 +1,42 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { virtualMachinesActions, VirtualMachineActions } from '~/store/virtual-machines/virtual-machines-actions';
+import { ListResults } from '~/services/common-service/common-resource-service';
+import { VirtualMachinesLoginsResource } from '~/models/virtual-machines';
+
+interface VirtualMachines {
+    date: string;
+    virtualMachines: ListResults<any>;
+    logins: VirtualMachinesLoginsResource[];
+    links: ListResults<any>;
+}
+
+const initialState: VirtualMachines = {
+    date: '',
+    virtualMachines: {
+        kind: '',
+        offset: 0,
+        limit: 0,
+        itemsAvailable: 0,
+        items: []
+    },
+    logins: [],
+    links: {
+        kind: '',
+        offset: 0,
+        limit: 0,
+        itemsAvailable: 0,
+        items: []
+    }
+};
+
+export const virtualMachinesReducer = (state = initialState, action: VirtualMachineActions): VirtualMachines =>
+    virtualMachinesActions.match(action, {
+        SET_REQUESTED_DATE: date => ({ ...state, date }),
+        SET_VIRTUAL_MACHINES: virtualMachines => ({ ...state, virtualMachines }),
+        SET_LOGINS: logins => ({ ...state, logins }),
+        SET_LINKS: links => ({ ...state, links }),
+        default: () => state
+    });
diff --git a/src/store/vocabulary/vocabulary-actions.ts b/src/store/vocabulary/vocabulary-actions.ts
new file mode 100644 (file)
index 0000000..799cffa
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { ServiceRepository } from '~/services/services';
+import { propertiesActions } from '~/store/properties/properties-actions';
+import { VOCABULARY_PROPERTY_NAME, DEFAULT_VOCABULARY } from './vocabulary-selctors';
+import { isVocabulary } from '~/models/vocabulary';
+
+export const loadVocabulary = async (dispatch: Dispatch, _: {}, { vocabularyService }: ServiceRepository) => {
+    const vocabulary = await vocabularyService.getVocabulary();
+
+    dispatch(propertiesActions.SET_PROPERTY({
+        key: VOCABULARY_PROPERTY_NAME,
+        value: isVocabulary(vocabulary)
+            ? vocabulary
+            : DEFAULT_VOCABULARY,
+    }));
+};
diff --git a/src/store/vocabulary/vocabulary-selctors.ts b/src/store/vocabulary/vocabulary-selctors.ts
new file mode 100644 (file)
index 0000000..d317cb4
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PropertiesState, getProperty } from '~/store/properties/properties';
+import { Vocabulary } from '~/models/vocabulary';
+
+export const VOCABULARY_PROPERTY_NAME = 'vocabulary';
+
+export const DEFAULT_VOCABULARY: Vocabulary = {
+    strict: false,
+    tags: {},
+};
+
+export const getVocabulary = (state: PropertiesState) =>
+    getProperty<Vocabulary>(VOCABULARY_PROPERTY_NAME)(state) || DEFAULT_VOCABULARY;
index dc54e4b861c1c235e888e5fea1f2d2ee547e8d21..091a8ccc7c9583c41d97bd4067816eadb491528e 100644 (file)
@@ -55,6 +55,7 @@ import { collectionPanelActions } from "~/store/collection-panel/collection-pane
 import { CollectionResource } from "~/models/collection";
 import { searchResultsPanelActions, loadSearchResultsPanel } from '~/store/search-results-panel/search-results-panel-actions';
 import { searchResultsPanelColumns } from '~/views/search-results-panel/search-results-panel-view';
+import { loadVirtualMachinesPanel } from '~/store/virtual-machines/virtual-machines-actions';
 import { loadRepositoriesPanel } from '~/store/repositories/repositories-actions';
 
 export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
@@ -393,6 +394,12 @@ export const loadSearchResults = handleFirstTimeLoad(
         await dispatch(loadSearchResultsPanel());
     });
 
+export const loadVirtualMachines = handleFirstTimeLoad(
+    async (dispatch: Dispatch<any>) => {
+        await dispatch(loadVirtualMachinesPanel());
+        dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }]));
+    });
+    
 export const loadRepositories = handleFirstTimeLoad(
     async (dispatch: Dispatch<any>) => {
         await dispatch(loadRepositoriesPanel());
index da0da54ddb46e8de55d9d7743145b5a49fb29da9..3d51cbb8dbff61db38620569ff19d6087b78287a 100644 (file)
@@ -9,7 +9,9 @@ import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-act
 import { propertiesActions } from '~/store/properties/properties-actions';
 import { getResource } from '../resources/resources';
 import { getProperty } from '~/store/properties/properties';
-import { WorkflowResource } from '../../models/workflow';
+import { WorkflowResource } from '~/models/workflow';
+import { navigateToRunProcess } from '~/store/navigation/navigation-action';
+import { goToStep, runProcessPanelActions } from '~/store/run-process-panel/run-process-panel-actions';
 
 export const WORKFLOW_PANEL_ID = "workflowPanel";
 const UUID_PREFIX_PROPERTY_NAME = 'uuidPrefix';
@@ -17,8 +19,10 @@ const WORKFLOW_PANEL_DETAILS_UUID = 'workflowPanelDetailsUuid';
 export const workflowPanelActions = bindDataExplorerActions(WORKFLOW_PANEL_ID);
 
 export const loadWorkflowPanel = () =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(workflowPanelActions.REQUEST_ITEMS());
+        const response = await services.workflowService.list();
+        dispatch(runProcessPanelActions.SET_WORKFLOWS(response.items));
     };
 
 export const setUuidPrefix = (uuidPrefix: string) =>
@@ -28,6 +32,17 @@ export const getUuidPrefix = (state: RootState) => {
     return state.properties.uuidPrefix;
 };
 
+export const openRunProcess = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {  
+        const workflows = getState().runProcessPanel.searchWorkflows;
+        const workflow = workflows.find(workflow => workflow.uuid === uuid);
+        dispatch<any>(navigateToRunProcess);
+        dispatch(runProcessPanelActions.RESET_RUN_PROCESS_PANEL()); 
+        dispatch<any>(goToStep(1));
+        dispatch(runProcessPanelActions.SET_STEP_CHANGED(true));
+        dispatch(runProcessPanelActions.SET_SELECTED_WORKFLOW(workflow!));       
+    };
+
 export const getPublicUserUuid = (state: RootState) => {
     const prefix = getProperty<string>(UUID_PREFIX_PROPERTY_NAME)(state.properties);
     return `${prefix}-tpzed-anonymouspublic`;
index 6250a7ad643c5cd48aba090a26a36312586f5572..bcf277c0b1d963d238e63e6fc2a14085ce676397 100644 (file)
@@ -47,7 +47,7 @@ export const MetadataTab = withStyles(styles)((props: MetadataProps & WithStyles
                     <TableCell className={props.classes.cell}>{it.uuid}</TableCell>
                     <TableCell className={props.classes.cell}>{it.linkClass}</TableCell>
                     <TableCell className={props.classes.cell}>{it.name}</TableCell>
-                    <TableCell className={props.classes.cell}>{props.user ? `User: ${props.user.firstName} ${props.user.lastName}` : it.tailUuid}</TableCell>
+                    <TableCell className={props.classes.cell}>{props.user && `User: ${props.user.firstName} ${props.user.lastName}`}</TableCell>
                     <TableCell className={props.classes.cell}>{it.headUuid === props.uuid ? 'this' : it.headUuid}</TableCell>
                     <TableCell className={props.classes.cell}>{JSON.stringify(it.properties)}</TableCell>
                 </TableRow>
index 9b8ced5663037596646b1d1598ebd8eb6bc74d80..8c81e3bd1996633ff39eca407e3547a6de0f3cf3 100644 (file)
@@ -12,7 +12,6 @@ import { openProjectCreateDialog } from '~/store/projects/project-create-actions
 import { openProjectUpdateDialog } from '~/store/projects/project-update-actions';
 import { ToggleTrashAction } from "~/views-components/context-menu/actions/trash-action";
 import { toggleProjectTrashed } from "~/store/trash/trash-actions";
-import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
 import { ShareIcon } from '~/components/icon/icon';
 import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
@@ -61,13 +60,13 @@ export const projectActionSet: ContextMenuActionSet = [[
             dispatch<any>(openMoveProjectDialog(resource));
         }
     },
-    {
-        icon: CopyIcon,
-        name: "Copy to project",
-        execute: (dispatch, resource) => {
-            // add code
-        }
-    },
+    // {
+    //     icon: CopyIcon,
+    //     name: "Copy to project",
+    //     execute: (dispatch, resource) => {
+    //         // add code
+    //     }
+    // },
     {
         icon: DetailsIcon,
         name: "View details",
index cf7fb883cfeb0bab89c0364085627e1ec2ceb1e9..22f6bee135c71f6b11fb6faf21b4af1fe27ed4f6 100644 (file)
@@ -23,8 +23,8 @@ export const repositoryActionSet: ContextMenuActionSet = [[{
 }, {
     name: "Advanced",
     icon: AdvancedIcon,
-    execute: (dispatch, { uuid, index }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid, index));
+    execute: (dispatch, resource) => {
+        dispatch<any>(openAdvancedTabDialog(resource.uuid, resource.index));
     }
 }, {
     name: "Remove",
index 3fa2f16fa4c05401b002dcfa949e7ea20274bb7b..6e86b2bc17a2044d05e3ab3eb320e0cbc8fb0e0c 100644 (file)
@@ -5,6 +5,7 @@
 import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set";
 import { AdvancedIcon, RemoveIcon, AttributesIcon } from "~/components/icon/icon";
 import { openSshKeyRemoveDialog, openSshKeyAttributesDialog } from '~/store/auth/auth-action';
+import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab';
 
 export const sshKeyActionSet: ContextMenuActionSet = [[{
     name: "Attributes",
@@ -16,7 +17,7 @@ export const sshKeyActionSet: ContextMenuActionSet = [[{
     name: "Advanced",
     icon: AdvancedIcon,
     execute: (dispatch, { uuid, index }) => {
-        // ToDo
+        dispatch<any>(openAdvancedTabDialog(uuid, index));
     }
 }, {
     name: "Remove",
index cefef345f0a0df2b842a764a0ae37ce0b1a5d25e..ea0d1aaf6fdb32d0a9063e3d19a14e0108767a49 100644 (file)
@@ -5,7 +5,6 @@
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RestoreFromTrashIcon } from '~/components/icon/icon';
 import { toggleCollectionTrashed } from "~/store/trash/trash-actions";
-import { detailsPanelActions } from "~/store/details-panel/details-panel-action";
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
 import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
index 503206a6f5be37d0c166d5b0ceaa3df7e14ec015..934be54d37f15336d5e6e5b5a89a66002bdf4b34 100644 (file)
@@ -3,12 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { Dialog, DialogActions, DialogTitle, DialogContent, WithStyles, withStyles, StyleRulesCallback, Button, Typography, Paper } from '@material-ui/core';
+import { Dialog, DialogActions, DialogTitle, DialogContent, WithStyles, withStyles, StyleRulesCallback, Button, Typography } from '@material-ui/core';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { withDialog } from '~/store/dialog/with-dialog';
 import { WithDialogProps } from '~/store/dialog/with-dialog';
 import { connect } from 'react-redux';
-import { CurrentTokenDialogData, getCurrentTokenDialogData } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { CurrentTokenDialogData, getCurrentTokenDialogData, CURRENT_TOKEN_DIALOG_NAME } from '~/store/current-token-dialog/current-token-dialog-actions';
 import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
 
 type CssRules = 'link' | 'paper' | 'button';
@@ -36,7 +36,7 @@ type CurrentTokenProps = CurrentTokenDialogData & WithDialogProps<{}> & WithStyl
 export const CurrentTokenDialog =
     withStyles(styles)(
     connect(getCurrentTokenDialogData)(
-    withDialog('currentTokenDialog')(
+    withDialog(CURRENT_TOKEN_DIALOG_NAME)(
     class extends React.Component<CurrentTokenProps> {
         render() {
             const { classes, open, closeDialog, ...data } = this.props;
index 59555707d11a2e509d886abf978b3ab4a2972580..710d202dfe25997c66dcde62f44ead0d9e469b10 100644 (file)
@@ -9,8 +9,8 @@ import { getDataExplorer } from "~/store/data-explorer/data-explorer-reducer";
 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";
+import { DataTableFilters } from '~/components/data-table-filters/data-table-filters-tree';
 
 interface Props {
     id: string;
@@ -44,7 +44,7 @@ const mapDispatchToProps = () => {
             dispatch(dataExplorerActions.TOGGLE_SORT({ id, columnName: column.name }));
         },
 
-        onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<any>) => {
+        onFiltersChange: (filters: DataTableFilters, column: DataColumn<any>) => {
             dispatch(dataExplorerActions.SET_FILTERS({ id, columnName: column.name, filters }));
         },
 
index 87ba73ff0ef43b2fa9b8de60c69f0c47ad8727fd..a032b3ed5a22945e952d20767afd924fb931d4bd 100644 (file)
@@ -18,8 +18,7 @@ import { ArvadosTheme } from '~/common/custom-theme';
 import { compose, Dispatch } from 'redux';
 import { WorkflowResource } from '~/models/workflow';
 import { ResourceStatus } from '~/views/workflow-panel/workflow-panel-view';
-import { getUuidPrefix } from '~/store/workflow-panel/workflow-panel-actions';
-import { CollectionResource } from "~/models/collection";
+import { getUuidPrefix, openRunProcess } from '~/store/workflow-panel/workflow-panel-actions';
 import { getResourceData } from "~/store/resources-data/resources-data";
 import { openSharingDialog } from '~/store/sharing-dialog/sharing-dialog-actions';
 
@@ -87,12 +86,11 @@ const getPublicUuid = (uuidPrefix: string) => {
     return `${uuidPrefix}-tpzed-anonymouspublic`;
 };
 
-// ToDo: share onClick
 export const resourceShare = (dispatch: Dispatch, uuidPrefix: string, ownerUuid?: string, uuid?: string) => {
     const isPublic = ownerUuid === getPublicUuid(uuidPrefix);
     return (
         <div>
-            { isPublic && uuid &&
+            {!isPublic && uuid &&
                 <Tooltip title="Share">
                     <IconButton onClick={() => dispatch<any>(openSharingDialog(uuid))}>
                         <ShareIcon />
@@ -115,6 +113,28 @@ export const ResourceShare = connect(
     })((props: { ownerUuid?: string, uuidPrefix: string, uuid?: string } & DispatchProp<any>) =>
         resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid));
 
+export const resourceRunProcess = (dispatch: Dispatch, uuid: string) => {
+    return (
+        <div>
+            {uuid &&
+                <Tooltip title="Run process">
+                    <IconButton onClick={() => dispatch<any>(openRunProcess(uuid))}>
+                        <ProcessIcon />
+                    </IconButton>
+                </Tooltip>}
+        </div>
+    );
+};
+
+export const ResourceRunProcess = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<WorkflowResource>(props.uuid)(state.resources);
+        return {
+            uuid: resource ? resource.uuid : ''
+        };
+    })((props: { uuid: string } & DispatchProp<any>) =>
+        resourceRunProcess(props.dispatch, props.uuid));
+
 export const renderWorkflowStatus = (uuidPrefix: string, ownerUuid?: string) => {
     if (ownerUuid === getPublicUuid(uuidPrefix)) {
         return renderStatus(ResourceStatus.PUBLIC);
index b93bfcfbedecf90de608d3acb2eb90ab62d26c7e..0aedee9001c4bde86a49950502e3622feae5b624 100644 (file)
@@ -13,6 +13,7 @@ import { RootState } from "~/store/store";
 import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
 import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
 import { navigateToSshKeys, navigateToMyAccount } from '~/store/navigation/navigation-action';
+import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
 
 interface AccountMenuProps {
     user?: User;
@@ -32,6 +33,7 @@ export const AccountMenu = connect(mapStateToProps)(
                 <MenuItem>
                     {getUserFullname(user)}
                 </MenuItem>
+                <MenuItem onClick={() => dispatch(openVirtualMachines())}>Virtual Machines</MenuItem>
                 <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
                 <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToSshKeys)}>Ssh Keys</MenuItem>
index 741a7e00f68481b07c24895cbcd5ecf05b8f8290..6b84bde2b6143a6e00abd80e6a17068eb27be66c 100644 (file)
@@ -8,7 +8,7 @@ import { DetailsIcon } from "~/components/icon/icon";
 import { Breadcrumbs } from "~/views-components/breadcrumbs/breadcrumbs";
 import { connect } from 'react-redux';
 import { RootState } from '~/store/store';
-import { matchWorkflowRoute, matchSshKeysRoute, matchRepositoriesRoute } from '~/routes/routes';
+import { matchWorkflowRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute } from '~/routes/routes';
 import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
 interface MainContentBarProps {
@@ -22,6 +22,12 @@ const isWorkflowPath = ({ router }: RootState) => {
     return !!match;
 };
 
+const isVirtualMachinePath = ({ router }: RootState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchVirtualMachineRoute(pathname);
+    return !!match;
+};
+
 const isRepositoriesPath = ({ router }: RootState) => {
     const pathname = router.location ? router.location.pathname : '';
     const match = matchRepositoriesRoute(pathname);
@@ -35,7 +41,7 @@ const isSshKeysPath = ({ router }: RootState) => {
 };
 
 export const MainContentBar = connect((state: RootState) => ({
-    buttonVisible: !isWorkflowPath(state) && !isSshKeysPath(state) && !isRepositoriesPath(state)
+    buttonVisible: !isWorkflowPath(state) && !isSshKeysPath(state) && !isRepositoriesPath(state) && !isVirtualMachinePath(state)
 }), {
         onDetailsPanelToggle: toggleDetailsPanel
     })((props: MainContentBarProps) =>
index 82ae0406954331e8a96c2742754232e3c3238e31..90c8c080d98b2edb522b9a22d52aec9158e83630 100644 (file)
@@ -2,94 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import * as React from 'react';
-import { reduxForm, Field, reset } from 'redux-form';
-import { compose, Dispatch } from 'redux';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { StyleRulesCallback, withStyles, WithStyles, Button, CircularProgress } from '@material-ui/core';
-import { TagProperty } from '~/models/tag';
-import { TextField } from '~/components/text-field/text-field';
-import { TAG_VALUE_VALIDATION, TAG_KEY_VALIDATION } from '~/validators/validators';
+import { reduxForm, reset } from 'redux-form';
 import { PROJECT_PROPERTIES_FORM_NAME, createProjectProperty } from '~/store/details-panel/details-panel-action';
+import { ResourcePropertiesForm, ResourcePropertiesFormData } from '~/views-components/resource-properties-form/resource-properties-form';
+import { withStyles } from '@material-ui/core';
 
-type CssRules = 'root' | 'keyField' | 'valueField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
+const Form = withStyles(({ spacing }) => ({ container: { marginBottom: spacing.unit * 2 } }))(ResourcePropertiesForm);
 
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    root: {
-        width: '100%',
-        display: 'flex'
-    },
-    keyField: {
-        width: '40%',
-        marginRight: theme.spacing.unit * 3
-    },
-    valueField: {
-        width: '40%',
-        marginRight: theme.spacing.unit * 3
-    },
-    buttonWrapper: {
-        paddingTop: '14px',
-        position: 'relative',
-    },
-    saveButton: {
-        boxShadow: 'none'
-    },
-    circularProgress: {
-        position: 'absolute',
-        top: -9,
-        bottom: 0,
-        left: 0,
-        right: 0,
-        margin: 'auto'
+export const ProjectPropertiesForm = reduxForm<ResourcePropertiesFormData>({
+    form: PROJECT_PROPERTIES_FORM_NAME,
+    onSubmit: (data, dispatch) => {
+        dispatch<any>(createProjectProperty(data));
+        dispatch(reset(PROJECT_PROPERTIES_FORM_NAME));
     }
-});
-
-interface ProjectPropertiesFormDataProps {
-    submitting: boolean;
-    invalid: boolean;
-    pristine: boolean;
-}
-
-interface ProjectPropertiesFormActionProps {
-    handleSubmit: any;
-}
-
-type ProjectPropertiesFormProps = ProjectPropertiesFormDataProps & ProjectPropertiesFormActionProps & WithStyles<CssRules>;
-
-export const ProjectPropertiesForm = compose(
-    reduxForm({
-        form: PROJECT_PROPERTIES_FORM_NAME,
-        onSubmit: (data: TagProperty, dispatch: Dispatch) => {
-            dispatch<any>(createProjectProperty(data));
-            dispatch(reset(PROJECT_PROPERTIES_FORM_NAME));
-        }
-    }),
-    withStyles(styles))(
-        ({ classes, submitting, pristine, invalid, handleSubmit }: ProjectPropertiesFormProps) => 
-            <form onSubmit={handleSubmit} className={classes.root}>
-                <div className={classes.keyField}>
-                    <Field name="key"
-                        disabled={submitting}
-                        component={TextField}
-                        validate={TAG_KEY_VALIDATION}
-                        label="Key" />
-                </div>
-                <div className={classes.valueField}>
-                    <Field name="value"
-                        disabled={submitting}
-                        component={TextField}
-                        validate={TAG_VALUE_VALIDATION}
-                        label="Value" />
-                </div>
-                <div className={classes.buttonWrapper}>
-                    <Button type="submit" className={classes.saveButton}
-                        color="primary"
-                        size='small'
-                        disabled={invalid || submitting || pristine}
-                        variant="contained">
-                        ADD
-                    </Button>
-                    {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
-                </div>
-            </form>
-        );
+})(Form);
diff --git a/src/views-components/resource-properties-form/property-field-common.tsx b/src/views-components/resource-properties-form/property-field-common.tsx
new file mode 100644 (file)
index 0000000..028c46b
--- /dev/null
@@ -0,0 +1,45 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from 'react-redux';
+import { WrappedFieldMetaProps, WrappedFieldInputProps, WrappedFieldProps } from 'redux-form';
+import { identity } from 'lodash';
+import { Vocabulary } from '~/models/vocabulary';
+import { RootState } from '~/store/store';
+import { getVocabulary } from '~/store/vocabulary/vocabulary-selctors';
+
+export interface VocabularyProp {
+    vocabulary: Vocabulary;
+}
+
+export const mapStateToProps = (state: RootState): VocabularyProp => ({
+    vocabulary: getVocabulary(state.properties),
+});
+
+export const connectVocabulary = connect(mapStateToProps);
+
+export const ITEMS_PLACEHOLDER: string[] = [];
+
+export const hasError = ({ touched, invalid }: WrappedFieldMetaProps) =>
+    touched && invalid;
+
+export const getErrorMsg = (meta: WrappedFieldMetaProps) =>
+    hasError(meta)
+        ? meta.error
+        : '';
+
+export const handleBlur = ({ onBlur, value }: WrappedFieldInputProps) =>
+    () =>
+        onBlur(value);
+
+export const buildProps = ({ input, meta }: WrappedFieldProps) => ({
+    value: input.value,
+    onChange: input.onChange,
+    onBlur: handleBlur(input),
+    items: ITEMS_PLACEHOLDER,
+    onSelect: input.onChange,
+    renderSuggestion: identity,
+    error: hasError(meta),
+    helperText: getErrorMsg(meta),
+});
diff --git a/src/views-components/resource-properties-form/property-key-field.tsx b/src/views-components/resource-properties-form/property-key-field.tsx
new file mode 100644 (file)
index 0000000..e6708a3
--- /dev/null
@@ -0,0 +1,46 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WrappedFieldProps, Field } from 'redux-form';
+import { memoize } from 'lodash';
+import { Autocomplete } from '~/components/autocomplete/autocomplete';
+import { Vocabulary } from '~/models/vocabulary';
+import { connectVocabulary, VocabularyProp, buildProps } from '~/views-components/resource-properties-form/property-field-common';
+import { TAG_KEY_VALIDATION } from '~/validators/validators';
+
+export const PROPERTY_KEY_FIELD_NAME = 'key';
+
+export const PropertyKeyField = connectVocabulary(
+    ({ vocabulary }: VocabularyProp) =>
+        <Field
+            name={PROPERTY_KEY_FIELD_NAME}
+            component={PropertyKeyInput}
+            vocabulary={vocabulary}
+            validate={getValidation(vocabulary)} />);
+
+const PropertyKeyInput = ({ vocabulary, ...props }: WrappedFieldProps & VocabularyProp) =>
+    <Autocomplete
+        label='Key'
+        suggestions={getSuggestions(props.input.value, vocabulary)}
+        {...buildProps(props)}
+    />;
+
+const getValidation = memoize(
+    (vocabulary: Vocabulary) =>
+        vocabulary.strict
+            ? [...TAG_KEY_VALIDATION, matchTags(vocabulary)]
+            : TAG_KEY_VALIDATION);
+
+const matchTags = (vocabulary: Vocabulary) =>
+    (value: string) =>
+        getTagsList(vocabulary).find(tag => tag.includes(value))
+            ? undefined
+            : 'Incorrect key';
+
+const getSuggestions = (value: string, vocabulary: Vocabulary) =>
+    getTagsList(vocabulary).filter(tag => tag.includes(value) && tag !== value);
+
+const getTagsList = ({ tags }: Vocabulary) =>
+    Object.keys(tags);
diff --git a/src/views-components/resource-properties-form/property-value-field.tsx b/src/views-components/resource-properties-form/property-value-field.tsx
new file mode 100644 (file)
index 0000000..db2db3f
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WrappedFieldProps, Field, formValues } from 'redux-form';
+import { compose } from 'redux';
+import { Autocomplete } from '~/components/autocomplete/autocomplete';
+import { Vocabulary } from '~/models/vocabulary';
+import { PROPERTY_KEY_FIELD_NAME } from '~/views-components/resource-properties-form/property-key-field';
+import { VocabularyProp, connectVocabulary, buildProps } from '~/views-components/resource-properties-form/property-field-common';
+import { TAG_VALUE_VALIDATION } from '~/validators/validators';
+
+interface PropertyKeyProp {
+    propertyKey: string;
+}
+
+type PropertyValueFieldProps = VocabularyProp & PropertyKeyProp;
+
+export const PROPERTY_VALUE_FIELD_NAME = 'value';
+
+export const PropertyValueField = compose(
+    connectVocabulary,
+    formValues({ propertyKey: PROPERTY_KEY_FIELD_NAME })
+)(
+    (props: PropertyValueFieldProps) =>
+        <Field
+            name={PROPERTY_VALUE_FIELD_NAME}
+            component={PropertyValueInput}
+            validate={getValidation(props)}
+            {...props} />);
+
+const PropertyValueInput = ({ vocabulary, propertyKey, ...props }: WrappedFieldProps & PropertyValueFieldProps) =>
+    <Autocomplete
+        label='Value'
+        suggestions={getSuggestions(props.input.value, propertyKey, vocabulary)}
+        {...buildProps(props)}
+    />;
+
+const getValidation = (props: PropertyValueFieldProps) =>
+    isStrictTag(props.propertyKey, props.vocabulary)
+        ? [...TAG_VALUE_VALIDATION, matchTagValues(props)]
+        : TAG_VALUE_VALIDATION;
+
+const matchTagValues = ({ vocabulary, propertyKey }: PropertyValueFieldProps) =>
+    (value: string) =>
+        getTagValues(propertyKey, vocabulary).find(v => v.includes(value))
+            ? undefined
+            : 'Incorrect value';
+
+const getSuggestions = (value: string, tagName: string, vocabulary: Vocabulary) =>
+    getTagValues(tagName, vocabulary).filter(v => v.includes(value) && v !== value);
+
+const isStrictTag = (tagName: string, vocabulary: Vocabulary) => {
+    const tag = vocabulary.tags[tagName];
+    return tag ? tag.strict : false;
+};
+
+const getTagValues = (tagName: string, vocabulary: Vocabulary) => {
+    const tag = vocabulary.tags[tagName];
+    return tag && tag.values ? tag.values : [];
+};
diff --git a/src/views-components/resource-properties-form/resource-properties-form.tsx b/src/views-components/resource-properties-form/resource-properties-form.tsx
new file mode 100644 (file)
index 0000000..a62b3d1
--- /dev/null
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { InjectedFormProps } from 'redux-form';
+import { Grid, withStyles, WithStyles } from '@material-ui/core';
+import { PropertyKeyField, PROPERTY_KEY_FIELD_NAME } from './property-key-field';
+import { PropertyValueField, PROPERTY_VALUE_FIELD_NAME } from './property-value-field';
+import { ProgressButton } from '~/components/progress-button/progress-button';
+import { GridClassKey } from '@material-ui/core/Grid';
+
+export interface ResourcePropertiesFormData {
+    [PROPERTY_KEY_FIELD_NAME]: string;
+    [PROPERTY_VALUE_FIELD_NAME]: string;
+}
+
+export type ResourcePropertiesFormProps = InjectedFormProps<ResourcePropertiesFormData> & WithStyles<GridClassKey>;
+
+export const ResourcePropertiesForm = ({ handleSubmit, submitting, invalid, classes }: ResourcePropertiesFormProps ) =>
+    <form onSubmit={handleSubmit}>
+        <Grid container spacing={16} classes={classes}>
+            <Grid item xs>
+                <PropertyKeyField />
+            </Grid>
+            <Grid item xs>
+                <PropertyValueField />
+            </Grid>
+            <Grid item xs>
+                <Button
+                    disabled={invalid}
+                    loading={submitting}
+                    color='primary'
+                    variant='contained'
+                    type='submit'>
+                    Add
+                </Button>
+            </Grid>
+        </Grid>
+    </form>;
+
+const Button = withStyles(theme => ({
+    root: { marginTop: theme.spacing.unit }
+}))(ProgressButton);
index 33ee97f95fd7e99bc5b79ba93d23ef2f7f989b2e..dd5005c35557f1fdf769450fd79f2337a70f522b 100644 (file)
@@ -10,7 +10,7 @@ import { TreeItem } from "~/components/tree/tree";
 import { ProjectResource } from "~/models/project";
 import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon";
 import { ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon } from '~/components/icon/icon';
-import { RecentIcon, WorkflowIcon } from '~/components/icon/icon';
+import { WorkflowIcon } from '~/components/icon/icon';
 import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
 import { openSidePanelContextMenu } from '~/store/context-menu/context-menu-actions';
 import { noop } from 'lodash';
@@ -59,8 +59,6 @@ const getSidePanelIcon = (category: string) => {
             return FavoriteIcon;
         case SidePanelTreeCategory.PROJECTS:
             return ProjectsIcon;
-        case SidePanelTreeCategory.RECENT_OPEN:
-            return RecentIcon;
         case SidePanelTreeCategory.SHARED_WITH_ME:
             return ShareMeIcon;
         case SidePanelTreeCategory.TRASH:
index 9aa881286580eefd0302c0c12f3f926fdabef6d3..fd4f0880a2abf9007d0a1e8d0db94704d20d16e2 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import * as React from 'react';
-import { reduxForm, Field, reset } from 'redux-form';
-import { compose, Dispatch } from 'redux';
-import { ArvadosTheme } from '~/common/custom-theme';
-import { StyleRulesCallback, withStyles, WithStyles, Button, CircularProgress, Grid, Typography } from '@material-ui/core';
-import { TagProperty } from '~/models/tag';
-import { TextField } from '~/components/text-field/text-field';
+import { reduxForm, reset } from 'redux-form';
 import { createCollectionTag, COLLECTION_TAG_FORM_NAME } from '~/store/collection-panel/collection-panel-action';
-import { TAG_VALUE_VALIDATION, TAG_KEY_VALIDATION } from '~/validators/validators';
+import { ResourcePropertiesForm, ResourcePropertiesFormData } from '~/views-components/resource-properties-form/resource-properties-form';
+import { withStyles } from '@material-ui/core';
 
-type CssRules = 'root' | 'keyField' | 'valueField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
+const Form = withStyles(({ spacing }) => ({ container: { marginBottom: spacing.unit * 2 } }))(ResourcePropertiesForm);
 
-const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-    root: {
-        width: '100%',
-        display: 'flex'
-    },
-    keyField: {
-        width: '25%',
-        marginRight: theme.spacing.unit * 3
-    },
-    valueField: {
-        width: '40%',
-        marginRight: theme.spacing.unit * 3
-    },
-    buttonWrapper: {
-        paddingTop: '14px',
-        position: 'relative',
-    },
-    saveButton: {
-        boxShadow: 'none'
-    },
-    circularProgress: {
-        position: 'absolute',
-        top: -9,
-        bottom: 0,
-        left: 0,
-        right: 0,
-        margin: 'auto'
+export const CollectionTagForm = reduxForm<ResourcePropertiesFormData>({
+    form: COLLECTION_TAG_FORM_NAME,
+    onSubmit: (data, dispatch) => {
+        dispatch<any>(createCollectionTag(data));
+        dispatch(reset(COLLECTION_TAG_FORM_NAME));
     }
-});
-
-interface CollectionTagFormDataProps {
-    submitting: boolean;
-    invalid: boolean;
-    pristine: boolean;
-}
-
-interface CollectionTagFormActionProps {
-    handleSubmit: any;
-}
-
-type CollectionTagFormProps = CollectionTagFormDataProps & CollectionTagFormActionProps & WithStyles<CssRules>;
-
-export const CollectionTagForm = compose(
-    reduxForm({
-        form: COLLECTION_TAG_FORM_NAME,
-        onSubmit: (data: TagProperty, dispatch: Dispatch) => {
-            dispatch<any>(createCollectionTag(data));
-            dispatch(reset(COLLECTION_TAG_FORM_NAME));
-        }
-    }),
-    withStyles(styles))(
-
-        class CollectionTagForm extends React.Component<CollectionTagFormProps> {
-
-            render() {
-                const { classes, submitting, pristine, invalid, handleSubmit } = this.props;
-                return (
-                    <form onSubmit={handleSubmit} className={classes.root}>
-                        <div className={classes.keyField}>
-                            <Field name="key"
-                                disabled={submitting}
-                                component={TextField}
-                                validate={TAG_KEY_VALIDATION}
-                                label="Key" />
-                        </div>
-                        <div className={classes.valueField}>
-                            <Field name="value"
-                                disabled={submitting}
-                                component={TextField}
-                                validate={TAG_VALUE_VALIDATION}
-                                label="Value" />
-                        </div>
-                        <div className={classes.buttonWrapper}>
-                            <Button type="submit" className={classes.saveButton}
-                                color="primary"
-                                size='small'
-                                disabled={invalid || submitting || pristine}
-                                variant="contained">
-                                ADD
-                            </Button>
-                            {submitting && <CircularProgress size={20} className={classes.circularProgress} />}
-                        </div>
-                    </form>
-                );
-            }
-        }
-
-    );
+})(Form);
index 33c901cb784e19e51b03051cfaa640d1facd571b..4682d3fc299bf6cdc6b3e8e24768a8fdc5f5f161 100644 (file)
@@ -11,7 +11,6 @@ import { RouteComponentProps } from 'react-router';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
 import { SortDirection } from '~/components/data-table/data-column';
 import { ResourceKind } from '~/models/resource';
-import { resourceLabel } from '~/common/labels';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { FAVORITE_PANEL_ID } from "~/store/favorite-panel/favorite-panel-action";
 import {
@@ -28,9 +27,11 @@ import { openContextMenu, resourceKindToContextMenuKind } from '~/store/context-
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
 import { navigateTo } from '~/store/navigation/navigation-action';
 import { ContainerRequestState } from "~/models/container-request";
-import { FavoritesState } from '../../store/favorites/favorites-reducer';
+import { FavoritesState } from '~/store/favorites/favorites-reducer';
 import { RootState } from '~/store/store';
 import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
+import { createTree } from '~/models/tree';
+import { getSimpleObjectTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
 
 type CssRules = "toolbar" | "button";
 
@@ -57,57 +58,41 @@ export interface FavoritePanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const favoritePanelColumns: DataColumns<string, FavoritePanelFilter> = [
+export const favoritePanelColumns: DataColumns<string> = [
     {
         name: FavoritePanelColumnNames.NAME,
         selected: true,
         configurable: true,
         sortDirection: SortDirection.ASC,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
     {
         name: "Status",
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ProcessStatus uuid={uuid} />
     },
     {
         name: FavoritePanelColumnNames.TYPE,
         selected: true,
         configurable: true,
-        filters: [
-            {
-                name: resourceLabel(ResourceKind.COLLECTION),
-                selected: true,
-                type: ResourceKind.COLLECTION
-            },
-            {
-                name: resourceLabel(ResourceKind.PROCESS),
-                selected: true,
-                type: ResourceKind.PROCESS
-            },
-            {
-                name: resourceLabel(ResourceKind.PROJECT),
-                selected: true,
-                type: ResourceKind.PROJECT
-            }
-        ],
+        filters: getSimpleObjectTypeFilters(),
         render: uuid => <ResourceType uuid={uuid} />
     },
     {
         name: FavoritePanelColumnNames.OWNER,
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceOwner uuid={uuid} />
     },
     {
         name: FavoritePanelColumnNames.FILE_SIZE,
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceFileSize uuid={uuid} />
     },
     {
@@ -115,7 +100,7 @@ export const favoritePanelColumns: DataColumns<string, FavoritePanelFilter> = [
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceLastModifiedDate uuid={uuid} />
     }
 ];
index 1221d0d1fba4df866500dc7ed4669a7fe8f58f38..b8811476668fea67ef2abc23422759af88176086 100644 (file)
@@ -3,16 +3,18 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { DataExplorer } from "~/views-components/data-explorer/data-explorer";
+import withStyles from "@material-ui/core/styles/withStyles";
 import { DispatchProp, connect } from 'react-redux';
-import { DataColumns } from '~/components/data-table/data-table';
 import { RouteComponentProps } from 'react-router';
+import { StyleRulesCallback, WithStyles } from "@material-ui/core";
+
+import { DataExplorer } from "~/views-components/data-explorer/data-explorer";
+import { DataColumns } from '~/components/data-table/data-table';
 import { RootState } from '~/store/store';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
 import { ContainerRequestState } from '~/models/container-request';
 import { SortDirection } from '~/components/data-table/data-column';
 import { ResourceKind, Resource } from '~/models/resource';
-import { resourceLabel } from '~/common/labels';
 import { ResourceFileSize, ResourceLastModifiedDate, ProcessStatus, ResourceType, ResourceOwner } from '~/views-components/data-explorer/renderers';
 import { ProjectIcon } from '~/components/icon/icon';
 import { ResourceName } from '~/views-components/data-explorer/renderers';
@@ -24,9 +26,9 @@ import { navigateTo } from '~/store/navigation/navigation-action';
 import { getProperty } from '~/store/properties/properties';
 import { PROJECT_PANEL_CURRENT_UUID } from '~/store/project-panel/project-panel-action';
 import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
-import { StyleRulesCallback, WithStyles } from "@material-ui/core";
 import { ArvadosTheme } from "~/common/custom-theme";
-import withStyles from "@material-ui/core/styles/withStyles";
+import { createTree } from '~/models/tree';
+import { getInitialResourceTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
 
 type CssRules = 'root' | "button";
 
@@ -54,57 +56,41 @@ export interface ProjectPanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const projectPanelColumns: DataColumns<string, ProjectPanelFilter> = [
+export const projectPanelColumns: DataColumns<string> = [
     {
         name: ProjectPanelColumnNames.NAME,
         selected: true,
         configurable: true,
         sortDirection: SortDirection.ASC,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
     {
         name: "Status",
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ProcessStatus uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.TYPE,
         selected: true,
         configurable: true,
-        filters: [
-            {
-                name: resourceLabel(ResourceKind.COLLECTION),
-                selected: true,
-                type: ResourceKind.COLLECTION
-            },
-            {
-                name: resourceLabel(ResourceKind.PROCESS),
-                selected: true,
-                type: ResourceKind.PROCESS
-            },
-            {
-                name: resourceLabel(ResourceKind.PROJECT),
-                selected: true,
-                type: ResourceKind.PROJECT
-            }
-        ],
+        filters: getInitialResourceTypeFilters(),
         render: uuid => <ResourceType uuid={uuid} />
     },
     {
         name: ProjectPanelColumnNames.OWNER,
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceOwner uuid={uuid} />
     },
     {
         name: ProjectPanelColumnNames.FILE_SIZE,
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceFileSize uuid={uuid} />
     },
     {
@@ -112,13 +98,18 @@ export const projectPanelColumns: DataColumns<string, ProjectPanelFilter> = [
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceLastModifiedDate uuid={uuid} />
     }
 ];
 
 export const PROJECT_PANEL_ID = "projectPanel";
 
+const DEFAUL_VIEW_MESSAGES = [
+    'Your project is empty.',
+    'Please create a project or create a collection and upload a data.',
+];
+
 interface ProjectPanelDataProps {
     currentItemId: string;
     resources: ResourcesState;
@@ -145,11 +136,8 @@ export const ProjectPanel = withStyles(styles)(
                         dataTableDefaultView={
                             <DataTableDefaultView
                                 icon={ProjectIcon}
-                                messages={[
-                                    'Your project is empty.',
-                                    'Please create a project or create a collection and upload a data.'
-                                ]}/>
-                        }/>
+                                messages={DEFAUL_VIEW_MESSAGES} />
+                        } />
                 </div>;
             }
 
index c8411ad719e68a05175512e5b5da08fff0c50b92..c5b95c3b07e2bcae58204ba7d559fe6e7c883a79 100644 (file)
@@ -6,7 +6,7 @@ import { Dispatch } from 'redux';
 import { connect } from 'react-redux';
 import { RootState } from '~/store/store';
 import { RunProcessPanelRootDataProps, RunProcessPanelRootActionProps, RunProcessPanelRoot } from '~/views/run-process-panel/run-process-panel-root';
-import { goToStep, setWorkflow, runProcess, searchWorkflows, openSetWorkflowDialog } from '~/store/run-process-panel/run-process-panel-actions';
+import { goToStep, runProcess, searchWorkflows, openSetWorkflowDialog } from '~/store/run-process-panel/run-process-panel-actions';
 import { WorkflowResource } from '~/models/workflow';
 
 const mapStateToProps = ({ runProcessPanel }: RootState): RunProcessPanelRootDataProps => {
index 009b2abef409b23d432220eeb30cdb9680250768..ea658ee72573c1e6554a6053b4e75ae96c474295 100644 (file)
@@ -20,7 +20,9 @@ import {
     ResourceOwner,
     ResourceType
 } from '~/views-components/data-explorer/renderers';
-
+import { createTree } from '~/models/tree';
+import { getInitialResourceTypeFilters } from '../../store/resource-type-filters/resource-type-filters';
+// TODO: code clean up
 export enum SearchResultsPanelColumnNames {
     NAME = "Name",
     PROJECT = "Project",
@@ -48,64 +50,48 @@ export interface WorkflowPanelFilter extends DataTableFilterItem {
     type: ResourceKind | ContainerRequestState;
 }
 
-export const searchResultsPanelColumns: DataColumns<string, WorkflowPanelFilter> = [
+export const searchResultsPanelColumns: DataColumns<string> = [
     {
         name: SearchResultsPanelColumnNames.NAME,
         selected: true,
         configurable: true,
         sortDirection: SortDirection.ASC,
-        filters: [],
+        filters: createTree(),
         render: (uuid: string) => <ResourceName uuid={uuid} />
     },
     {
         name: SearchResultsPanelColumnNames.PROJECT,
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceFileSize uuid={uuid} />
     },
     {
         name: SearchResultsPanelColumnNames.STATUS,
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ProcessStatus uuid={uuid} />
     },
     {
         name: SearchResultsPanelColumnNames.TYPE,
         selected: true,
         configurable: true,
-        filters: [
-            {
-                name: resourceLabel(ResourceKind.COLLECTION),
-                selected: true,
-                type: ResourceKind.COLLECTION
-            },
-            {
-                name: resourceLabel(ResourceKind.PROCESS),
-                selected: true,
-                type: ResourceKind.PROCESS
-            },
-            {
-                name: resourceLabel(ResourceKind.PROJECT),
-                selected: true,
-                type: ResourceKind.PROJECT
-            }
-        ],
+        filters: getInitialResourceTypeFilters(),
         render: (uuid: string) => <ResourceType uuid={uuid} />,
     },
     {
         name: SearchResultsPanelColumnNames.OWNER,
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceOwner uuid={uuid} />
     },
     {
         name: SearchResultsPanelColumnNames.FILE_SIZE,
         selected: true,
         configurable: true,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceFileSize uuid={uuid} />
     },
     {
@@ -113,7 +99,7 @@ export const searchResultsPanelColumns: DataColumns<string, WorkflowPanelFilter>
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceLastModifiedDate uuid={uuid} />
     }
 ];
index a0cf3e4fd40f2add68c0603c9d1e2760d3010c24..ae12425e1516a1dde5e05b31624a2537b97c5704 100644 (file)
@@ -33,7 +33,9 @@ import { ContextMenuKind } from "~/views-components/context-menu/context-menu";
 import { Dispatch } from "redux";
 import { PanelDefaultView } from '~/components/panel-default-view/panel-default-view';
 import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
-
+import { createTree } from '~/models/tree';
+import { getInitialResourceTypeFilters } from '../../store/resource-type-filters/resource-type-filters';
+// TODO: code clean up
 type CssRules = "toolbar" | "button";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
@@ -79,13 +81,13 @@ export const ResourceRestore =
         </Tooltip>
     );
 
-export const trashPanelColumns: DataColumns<string, TrashPanelFilter> = [
+export const trashPanelColumns: DataColumns<string> = [
     {
         name: TrashPanelColumnNames.NAME,
         selected: true,
         configurable: true,
         sortDirection: SortDirection.ASC,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceName uuid={uuid} />
     },
     {
@@ -93,18 +95,7 @@ export const trashPanelColumns: DataColumns<string, TrashPanelFilter> = [
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
-        filters: [
-            {
-                name: resourceLabel(ResourceKind.COLLECTION),
-                selected: true,
-                type: ResourceKind.COLLECTION
-            },
-            {
-                name: resourceLabel(ResourceKind.PROJECT),
-                selected: true,
-                type: ResourceKind.PROJECT
-            }
-        ],
+        filters: getInitialResourceTypeFilters(),
         render: uuid => <ResourceType uuid={uuid} />,
     },
     {
@@ -112,7 +103,7 @@ export const trashPanelColumns: DataColumns<string, TrashPanelFilter> = [
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceFileSize uuid={uuid} />
     },
     {
@@ -120,7 +111,7 @@ export const trashPanelColumns: DataColumns<string, TrashPanelFilter> = [
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceTrashDate uuid={uuid} />
     },
     {
@@ -128,7 +119,7 @@ export const trashPanelColumns: DataColumns<string, TrashPanelFilter> = [
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceDeleteDate uuid={uuid} />
     },
     {
@@ -136,7 +127,7 @@ export const trashPanelColumns: DataColumns<string, TrashPanelFilter> = [
         selected: true,
         configurable: false,
         sortDirection: SortDirection.NONE,
-        filters: [],
+        filters: createTree(),
         render: uuid => <ResourceRestore uuid={uuid} />
     }
 ];
diff --git a/src/views/virtual-machine-panel/virtual-machine-panel.tsx b/src/views/virtual-machine-panel/virtual-machine-panel.tsx
new file mode 100644 (file)
index 0000000..c94c3a7
--- /dev/null
@@ -0,0 +1,200 @@
+// 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 { Grid, Typography, Button, Card, CardContent, TableBody, TableCell, TableHead, TableRow, Table, Tooltip } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
+import { Link } from 'react-router-dom';
+import { Dispatch, compose } from 'redux';
+import { saveRequestedDate, loadVirtualMachinesData } from '~/store/virtual-machines/virtual-machines-actions';
+import { RootState } from '~/store/store';
+import { ListResults } from '~/services/common-service/common-resource-service';
+import { HelpIcon } from '~/components/icon/icon';
+import { VirtualMachinesLoginsResource, VirtualMachinesResource } from '~/models/virtual-machines';
+import { Routes } from '~/routes/routes';
+
+type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    button: {
+        marginTop: theme.spacing.unit,
+        marginBottom: theme.spacing.unit
+    },
+    codeSnippet: {
+        borderRadius: theme.spacing.unit * 0.5,
+        border: '1px solid',
+        borderColor: theme.palette.grey["400"],
+    },
+    link: {
+        textDecoration: 'none',
+        color: theme.palette.primary.main,
+        "&:hover": {
+            color: theme.palette.primary.dark,
+            transition: 'all 0.5s ease'
+        }
+    },
+    linkIcon: {
+        textDecoration: 'none',
+        color: theme.palette.grey["500"],
+        textAlign: 'right',
+        "&:hover": {
+            color: theme.palette.common.black,
+            transition: 'all 0.5s ease'
+        }
+    },
+    rightAlign: {
+        textAlign: "right"
+    },
+    cardWithoutMachines: {
+        display: 'flex'
+    },
+    icon: {
+        textAlign: "right",
+        marginTop: theme.spacing.unit
+    }
+});
+
+const mapStateToProps = ({ virtualMachines }: RootState) => {
+    return {
+        requestedDate: virtualMachines.date,
+        ...virtualMachines
+    };
+};
+
+const mapDispatchToProps = {
+    saveRequestedDate,
+    loadVirtualMachinesData
+};
+
+interface VirtualMachinesPanelDataProps {
+    requestedDate: string;
+    virtualMachines: ListResults<any>;
+    logins: VirtualMachinesLoginsResource[];
+    links: ListResults<any>;
+}
+
+interface VirtualMachinesPanelActionProps {
+    saveRequestedDate: () => void;
+    loadVirtualMachinesData: () => string;
+}
+
+type VirtualMachineProps = VirtualMachinesPanelActionProps & VirtualMachinesPanelDataProps & WithStyles<CssRules>;
+
+export const VirtualMachinePanel = compose(
+    withStyles(styles),
+    connect(mapStateToProps, mapDispatchToProps))(
+        class extends React.Component<VirtualMachineProps> {
+            componentDidMount() {
+                this.props.loadVirtualMachinesData();
+            }
+
+            render() {
+                const { virtualMachines, links } = this.props;
+                return (
+                    <Grid container spacing={16}>
+                        {virtualMachines.itemsAvailable === 0 && <CardContentWithNoVirtualMachines {...this.props} />}
+                        {virtualMachines.itemsAvailable > 0 && links.itemsAvailable > 0 && <CardContentWithVirtualMachines {...this.props} />}
+                        {<CardSSHSection {...this.props} />}
+                    </Grid>
+                );
+            }
+        }
+    );
+
+const CardContentWithNoVirtualMachines = (props: VirtualMachineProps) =>
+    <Grid item xs={12}>
+        <Card>
+            <CardContent className={props.classes.cardWithoutMachines}>
+                <Grid item xs={6}>
+                    <Typography variant="body2">
+                        You do not have access to any virtual machines. Some Arvados features require using the command line. You may request access to a hosted virtual machine with the command line shell.
+                    </Typography>
+                </Grid>
+                <Grid item xs={6} className={props.classes.rightAlign}>
+                    <Button variant="contained" color="primary" className={props.classes.button} onClick={props.saveRequestedDate}>
+                        SEND REQUEST FOR SHELL ACCESS
+                    </Button>
+                    {props.requestedDate &&
+                        <Typography variant="body1">
+                            A request for shell access was sent on {props.requestedDate}
+                        </Typography>}
+                </Grid>
+            </CardContent>
+        </Card>
+    </Grid>;
+
+const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
+    <Grid item xs={12}>
+        <Card>
+            <CardContent>
+                <div className={props.classes.rightAlign}>
+                    <Button variant="contained" color="primary" className={props.classes.button} onClick={props.saveRequestedDate}>
+                        SEND REQUEST FOR SHELL ACCESS
+                    </Button>
+                    {props.requestedDate &&
+                        <Typography variant="body1">
+                            A request for shell access was sent on {props.requestedDate}
+                        </Typography>}
+                </div>
+                <div className={props.classes.icon}>
+                    <a href="https://doc.arvados.org/user/getting_started/vm-login-with-webshell.html" target="_blank" className={props.classes.linkIcon}>
+                        <Tooltip title="Access VM using webshell">
+                            <HelpIcon />
+                        </Tooltip>
+                    </a>
+                </div>
+                <Table>
+                    <TableHead>
+                        <TableRow>
+                            <TableCell>Host name</TableCell>
+                            <TableCell>Login name</TableCell>
+                            <TableCell>Command line</TableCell>
+                            <TableCell>Web shell</TableCell>
+                        </TableRow>
+                    </TableHead>
+                    <TableBody>
+                        {props.virtualMachines.items.map((it, index) =>
+                            <TableRow key={index}>
+                                <TableCell>{it.hostname}</TableCell>
+                                <TableCell>{getUsername(props.links, it)}</TableCell>
+                                <TableCell>ssh {getUsername(props.links, it)}@shell.arvados</TableCell>
+                                <TableCell>
+                                    <a href={`https://workbench.c97qk.arvadosapi.com${it.href}/webshell/${getUsername(props.links, it)}`} target="_blank" className={props.classes.link}>
+                                        Log in as {getUsername(props.links, it)}
+                                    </a>
+                                </TableCell>
+                            </TableRow>
+                        )}
+                    </TableBody>
+                </Table>
+            </CardContent>
+        </Card>
+    </Grid>;
+
+const getUsername = (links: ListResults<any>, virtualMachine: VirtualMachinesResource) => {
+    const link = links.items.find((item: any) => item.headUuid === virtualMachine.uuid);
+    return link.properties.username || undefined;
+};
+
+const CardSSHSection = (props: VirtualMachineProps) =>
+    <Grid item xs={12}>
+        <Card>
+            <CardContent>
+                <Typography variant="body2">
+                    In order to access virtual machines using SSH, <Link to={Routes.SSH_KEYS} className={props.classes.link}>add an SSH key to your account</Link> and add a section like this to your SSH configuration file ( ~/.ssh/config):
+                </Typography>
+                <DefaultCodeSnippet
+                    className={props.classes.codeSnippet}
+                    lines={[textSSH]} />
+            </CardContent>
+        </Card>
+    </Grid>;
+
+const textSSH = `Host *.arvados
+    TCPKeepAlive yes
+    ServerAliveInterval 60
+    ProxyCommand ssh -p2222 turnout@switchyard.api.ardev.roche.com -x -a $SSH_PROXY_FLAGS %h`;
\ No newline at end of file
index 8542e6ca36a278a54e59c395ea63c4900e6c9ce0..2cff4317e5576470c44e22ff910a043302a4aa3f 100644 (file)
@@ -49,6 +49,7 @@ import { MyAccountPanel } from '~/views/my-account-panel/my-account-panel';
 import { SharingDialog } from '~/views-components/sharing-dialog/sharing-dialog';
 import { AdvancedTabDialog } from '~/views-components/advanced-tab-dialog/advanced-tab-dialog';
 import { ProcessInputDialog } from '~/views-components/process-input-dialog/process-input-dialog';
+import { VirtualMachinePanel } from '~/views/virtual-machine-panel/virtual-machine-panel';
 import { ProjectPropertiesDialog } from '~/views-components/project-properties-dialog/project-properties-dialog';
 import { RepositoriesPanel } from '~/views/repositories-panel/repositories-panel';
 import { RepositoriesSampleGitDialog } from '~/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog';
@@ -128,6 +129,7 @@ export const WorkbenchPanel =
                                 <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
                                 <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
                                 <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
+                                <Route path={Routes.VIRTUAL_MACHINES} component={VirtualMachinePanel} />
                                 <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
                                 <Route path={Routes.SSH_KEYS} component={SshKeyPanel} />
                                 <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
index 18a254bd36afcc678bab48f41f87a59b460d4e14..da8a0c4bd303d5de5a66c86d6b4fb1adb8a6861c 100644 (file)
@@ -11,14 +11,16 @@ import {
     ResourceLastModifiedDate,
     RosurceWorkflowName,
     ResourceWorkflowStatus,
-    ResourceShare
+    ResourceShare,
+    ResourceRunProcess
 } from "~/views-components/data-explorer/renderers";
 import { SortDirection } from '~/components/data-table/data-column';
 import { DataColumns } from '~/components/data-table/data-table';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
 import { Grid, Paper } from '@material-ui/core';
 import { WorkflowDetailsCard } from './workflow-description-card';
-import { WorkflowResource } from '../../models/workflow';
+import { WorkflowResource } from '~/models/workflow';
+import { createTree } from '~/models/tree';
 
 export enum WorkflowPanelColumnNames {
     NAME = "Name",
@@ -61,36 +63,38 @@ const resourceStatus = (type: string) => {
     }
 };
 
-export const workflowPanelColumns: DataColumns<string, WorkflowPanelFilter> = [
+export const workflowPanelColumns: DataColumns<string> = [
     {
         name: WorkflowPanelColumnNames.NAME,
         selected: true,
         configurable: true,
         sortDirection: SortDirection.ASC,
-        filters: [],
+        filters: createTree(),
         render: (uuid: string) => <RosurceWorkflowName uuid={uuid} />
     },
     {
         name: WorkflowPanelColumnNames.AUTHORISATION,
         selected: true,
         configurable: true,
-        filters: [
-            {
-                name: resourceStatus(ResourceStatus.PUBLIC),
-                selected: true,
-                type: ResourceStatus.PUBLIC
-            },
-            {
-                name: resourceStatus(ResourceStatus.PRIVATE),
-                selected: true,
-                type: ResourceStatus.PRIVATE
-            },
-            {
-                name: resourceStatus(ResourceStatus.SHARED),
-                selected: true,
-                type: ResourceStatus.SHARED
-            }
-        ],
+        filters: createTree(),
+        // TODO: restore filters
+        // filters: [
+        //     {
+        //         name: resourceStatus(ResourceStatus.PUBLIC),
+        //         selected: true,
+        //         type: ResourceStatus.PUBLIC
+        //     },
+        //     {
+        //         name: resourceStatus(ResourceStatus.PRIVATE),
+        //         selected: true,
+        //         type: ResourceStatus.PRIVATE
+        //     },
+        //     {
+        //         name: resourceStatus(ResourceStatus.SHARED),
+        //         selected: true,
+        //         type: ResourceStatus.SHARED
+        //     }
+        // ],
         render: (uuid: string) => <ResourceWorkflowStatus uuid={uuid} />,
     },
     {
@@ -98,20 +102,27 @@ export const workflowPanelColumns: DataColumns<string, WorkflowPanelFilter> = [
         selected: true,
         configurable: true,
         sortDirection: SortDirection.NONE,
-        filters: [],
+        filters: createTree(),
         render: (uuid: string) => <ResourceLastModifiedDate uuid={uuid} />
     },
     {
         name: '',
         selected: true,
         configurable: false,
-        filters: [],
+        filters: createTree(),
         render: (uuid: string) => <ResourceShare uuid={uuid} />
+    },
+    {
+        name: '',
+        selected: true,
+        configurable: false,
+        filters: createTree(),
+        render: (uuid: string) => <ResourceRunProcess uuid={uuid} />
     }
 ];
 
 export const WorkflowPanelView = (props: WorkflowPanelProps) => {
-    return <Grid container spacing={16} style={{minHeight: '500px'}}>
+    return <Grid container spacing={16} style={{ minHeight: '500px' }}>
         <Grid item xs={6}>
             <DataExplorer
                 id={WORKFLOW_PANEL_ID}
index c59dc492f8b573dedcbfb63e0ca1c2ccfcb6cf86..99dc9a76889eb2166fc3c1d37b97495212dc2aae 100644 (file)
@@ -15,7 +15,6 @@ const mapStateToProps = (state: RootState): WorkflowPanelDataProps => ({
 });
 
 const mapDispatchToProps = (dispatch: Dispatch): WorfklowPanelActionProps => ({
-
     handleRowDoubleClick: (uuid: string) => {
         dispatch<any>(navigateTo(uuid));
     },