Resolve merge conflicts
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 10 Jul 2018 13:20:15 +0000 (15:20 +0200)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Tue, 10 Jul 2018 13:20:15 +0000 (15:20 +0200)
Feature #13694

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

20 files changed:
src/components/breadcrumbs/breadcrumbs.test.tsx
src/components/breadcrumbs/breadcrumbs.tsx
src/components/context-menu/context-menu.tsx
src/components/data-explorer/data-explorer.test.tsx
src/components/data-explorer/data-explorer.tsx
src/components/data-table/data-table.test.tsx
src/components/data-table/data-table.tsx
src/components/side-panel/side-panel.tsx
src/components/tree/tree.tsx
src/store/project/project-action.ts
src/store/project/project-reducer.test.ts
src/store/project/project-reducer.ts
src/utils/dialog-validator.tsx [new file with mode: 0644]
src/views-components/create-project-dialog/create-project-dialog.tsx [new file with mode: 0644]
src/views-components/data-explorer/data-explorer.tsx
src/views-components/dialog-create/dialog-project-create.tsx [new file with mode: 0644]
src/views-components/main-app-bar/main-app-bar.tsx
src/views-components/project-tree/project-tree.tsx
src/views/project-panel/project-panel.tsx
src/views/workbench/workbench.tsx

index b525554a2c8031d80464c5d2f070b55cfbf9b57c..ef3f8887976384922004830e2c5c6b2d8953edce 100644 (file)
@@ -24,7 +24,7 @@ describe("<Breadcrumbs />", () => {
         const items = [
             { label: 'breadcrumb 1' }
         ];
-        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />);
         expect(breadcrumbs.find(Button)).toHaveLength(1);
         expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0);
     });
@@ -34,7 +34,7 @@ describe("<Breadcrumbs />", () => {
             { label: 'breadcrumb 1' },
             { label: 'breadcrumb 2' }
         ];
-        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />);
         expect(breadcrumbs.find(Button)).toHaveLength(2);
         expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1);
     });
@@ -44,7 +44,7 @@ describe("<Breadcrumbs />", () => {
             { label: 'breadcrumb 1' },
             { label: 'breadcrumb 2' }
         ];
-        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} />);
+        const breadcrumbs = mount(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />);
         breadcrumbs.find(Button).at(1).simulate('click');
         expect(onClick).toBeCalledWith(items[1]);
     });
index 41f71981e57eea29d54064f5888c78961f283f89..4868e137f9f6b11065bdd2dbfaf3d3ff4ac642da 100644 (file)
@@ -14,9 +14,10 @@ export interface Breadcrumb {
 interface BreadcrumbsProps {
     items: Breadcrumb[];
     onClick: (breadcrumb: Breadcrumb) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
 }
 
-const Breadcrumbs: React.SFC<BreadcrumbsProps & WithStyles<CssRules>> = ({ classes, onClick, items }) => {
+const Breadcrumbs: React.SFC<BreadcrumbsProps & WithStyles<CssRules>> = ({ classes, onClick, onContextMenu, items }) => {
     return <Grid container alignItems="center" wrap="nowrap">
         {
             items.map((item, index) => {
@@ -28,12 +29,11 @@ const Breadcrumbs: React.SFC<BreadcrumbsProps & WithStyles<CssRules>> = ({ class
                                 color="inherit"
                                 className={isLastItem ? classes.currentItem : classes.item}
                                 onClick={() => onClick(item)}
-                            >
+                                onContextMenu={event => onContextMenu(event, item)}>
                                 <Typography
                                     noWrap
                                     color="inherit"
-                                    className={classes.label}
-                                >
+                                    className={classes.label}>
                                     {item.label}
                                 </Typography>
                             </Button>
index 6ac1207b9b3fb8875a9b524bd44d21b5a4ce3fc3..c892ba2616dda6480de47d2c3767596636267917 100644 (file)
@@ -8,6 +8,7 @@ import { DefaultTransformOrigin } from "../popover/helpers";
 export interface ContextMenuAction {
     name: string;
     icon: string;
+    openCreateDialog?: boolean;
 }
 
 export type ContextMenuActionGroup = ContextMenuAction[];
index 33899c00c79aa242c902c7c4d6af7cc11c741900..97b1bec602a4330fd2c4eff29ddd8dde6be59ce6 100644 (file)
@@ -22,8 +22,6 @@ describe("<DataExplorer />", () => {
         const onContextAction = jest.fn();
         const dataExplorer = mount(<DataExplorer
             {...mockDataExplorerProps()}
-            contextActions={[]}
-            onContextAction={onContextAction}
             items={[{ key: "1", name: "item 1" }] as MockItem[]}
             columns={[{ name: "Column 1", render: jest.fn(), selected: true }]} />);
         expect(dataExplorer.find(ContextMenu).prop("actions")).toEqual([]);
@@ -54,7 +52,6 @@ describe("<DataExplorer />", () => {
             {...mockDataExplorerProps()}
             columns={columns}
             onColumnToggle={onColumnToggle}
-            contextActions={[]}
             items={[{ key: "1", name: "item 1" }] as MockItem[]} />);
         expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns);
         dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns");
@@ -125,7 +122,7 @@ const mockDataExplorerProps = () => ({
     onSortToggle: jest.fn(),
     onRowClick: jest.fn(),
     onColumnToggle: jest.fn(),
-    onContextAction: jest.fn(),
     onChangePage: jest.fn(),
-    onChangeRowsPerPage: jest.fn()
+    onChangeRowsPerPage: jest.fn(),
+    onContextMenu: jest.fn()
 });
\ No newline at end of file
index 9b099acb2c34c5c6743ef212137519d6822be4a0..9085b1f1f0bce76fdc24f32ec8115b610803ae01 100644 (file)
@@ -5,10 +5,8 @@
 import * as React from 'react';
 import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, Theme, WithStyles, TablePagination, IconButton } from '@material-ui/core';
 import MoreVertIcon from "@material-ui/icons/MoreVert";
-import ContextMenu, { ContextMenuActionGroup, ContextMenuAction } from "../../components/context-menu/context-menu";
 import ColumnSelector from "../../components/column-selector/column-selector";
 import DataTable, { DataColumns, DataItem } from "../../components/data-table/data-table";
-import { mockAnchorFromMouseEvent } from "../../components/popover/helpers";
 import { DataColumn } from "../../components/data-table/data-column";
 import { DataTableFilterItem } from '../../components/data-table-filters/data-table-filters';
 import SearchInput from '../search-input/search-input';
@@ -17,7 +15,6 @@ interface DataExplorerProps<T> {
     items: T[];
     itemsAvailable: number;
     columns: DataColumns<T>;
-    contextActions: ContextMenuActionGroup[];
     searchValue: string;
     rowsPerPage: number;
     rowsPerPageOptions?: number[];
@@ -26,32 +23,17 @@ interface DataExplorerProps<T> {
     onRowClick: (item: T) => void;
     onRowDoubleClick: (item: T) => void;
     onColumnToggle: (column: DataColumn<T>) => void;
-    onContextAction: (action: ContextMenuAction, item: T) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
     onSortToggle: (column: DataColumn<T>) => void;
     onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<T>) => void;
     onChangePage: (page: number) => void;
     onChangeRowsPerPage: (rowsPerPage: number) => void;
 }
 
-interface DataExplorerState<T> {
-    contextMenu: {
-        anchorEl?: HTMLElement;
-        item?: T;
-    };
-}
-
-class DataExplorer<T extends DataItem> extends React.Component<DataExplorerProps<T> & WithStyles<CssRules>, DataExplorerState<T>> {
-    state: DataExplorerState<T> = {
-        contextMenu: {}
-    };
+class DataExplorer<T extends DataItem> extends React.Component<DataExplorerProps<T> & WithStyles<CssRules>> {
 
     render() {
         return <Paper>
-            <ContextMenu
-                anchorEl={this.state.contextMenu.anchorEl}
-                actions={this.props.contextActions}
-                onActionClick={this.callAction}
-                onClose={this.closeContextMenu} />
             <Toolbar className={this.props.classes.toolbar}>
                 <Grid container justify="space-between" wrap="nowrap" alignItems="center">
                     <div className={this.props.classes.searchBox}>
@@ -68,8 +50,8 @@ class DataExplorer<T extends DataItem> extends React.Component<DataExplorerProps
                 columns={[...this.props.columns, this.contextMenuColumn]}
                 items={this.props.items}
                 onRowClick={(_, item: T) => this.props.onRowClick(item)}
+                onContextMenu={this.props.onContextMenu}
                 onRowDoubleClick={(_, item: T) => this.props.onRowDoubleClick(item)}
-                onRowContextMenu={this.openContextMenu}
                 onFiltersChange={this.props.onFiltersChange}
                 onSortToggle={this.props.onSortToggle} />
             <Toolbar>
@@ -89,29 +71,6 @@ class DataExplorer<T extends DataItem> extends React.Component<DataExplorerProps
         </Paper>;
     }
 
-    openContextMenu = (event: React.MouseEvent<HTMLElement>, item: T) => {
-        event.preventDefault();
-        event.stopPropagation();
-        this.setState({
-            contextMenu: {
-                anchorEl: mockAnchorFromMouseEvent(event),
-                item
-            }
-        });
-    }
-
-    closeContextMenu = () => {
-        this.setState({ contextMenu: {} });
-    }
-
-    callAction = (action: ContextMenuAction) => {
-        const { item } = this.state.contextMenu;
-        this.closeContextMenu();
-        if (item) {
-            this.props.onContextAction(action, item);
-        }
-    }
-
     changePage = (event: React.MouseEvent<HTMLButtonElement> | null, page: number) => {
         this.props.onChangePage(page);
     }
@@ -122,21 +81,11 @@ class DataExplorer<T extends DataItem> extends React.Component<DataExplorerProps
 
     renderContextMenuTrigger = (item: T) =>
         <Grid container justify="flex-end">
-            <IconButton onClick={event => this.openContextMenuTrigger(event, item)}>
+            <IconButton onClick={event => this.props.onContextMenu(event, item)}>
                 <MoreVertIcon />
             </IconButton>
         </Grid>
 
-    openContextMenuTrigger = (event: React.MouseEvent<HTMLElement>, item: T) => {
-        event.preventDefault();
-        this.setState({
-            contextMenu: {
-                anchorEl: event.currentTarget,
-                item
-            }
-        });
-    }
-
     contextMenuColumn = {
         name: "Actions",
         selected: true,
index 6dbccb5e7d662222119103ff6155b57ffbcb6ae8..2ee350724a341510c8dac855ac6d5ac332c53230 100644 (file)
@@ -40,7 +40,7 @@ describe("<DataTable />", () => {
             items={[{ key: "1", name: "item 1" }] as MockItem[]}
             onFiltersChange={jest.fn()}
             onRowClick={jest.fn()}
-            onRowContextMenu={jest.fn()}
+            onContextMenu={jest.fn()}
             onSortToggle={jest.fn()} />);
         expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2);
     });
@@ -58,7 +58,7 @@ describe("<DataTable />", () => {
             items={[{ key: "1", name: "item 1" }] as MockItem[]}
             onFiltersChange={jest.fn()}
             onRowClick={jest.fn()}
-            onRowContextMenu={jest.fn()}
+            onContextMenu={jest.fn()}
             onSortToggle={jest.fn()} />);
         expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1");
     });
@@ -77,7 +77,7 @@ describe("<DataTable />", () => {
             items={[{ key: "1", name: "item 1" }] as MockItem[]}
             onFiltersChange={jest.fn()}
             onRowClick={jest.fn()}
-            onRowContextMenu={jest.fn()}
+            onContextMenu={jest.fn()}
             onSortToggle={jest.fn()} />);
         expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header");
     });
@@ -96,7 +96,7 @@ describe("<DataTable />", () => {
             items={[{ key: "1", name: "item 1" }] as MockItem[]}
             onFiltersChange={jest.fn()}
             onRowClick={jest.fn()}
-            onRowContextMenu={jest.fn()}
+            onContextMenu={jest.fn()}
             onSortToggle={jest.fn()} />);
         expect(dataTable.find(TableHead).find(TableCell).key()).toBe("column-1-key");
         expect(dataTable.find(TableBody).find(TableCell).key()).toBe("column-1-key");
@@ -120,7 +120,7 @@ describe("<DataTable />", () => {
             items={[{ key: "1", name: "item 1" }] as MockItem[]}
             onFiltersChange={jest.fn()}
             onRowClick={jest.fn()}
-            onRowContextMenu={jest.fn()}
+            onContextMenu={jest.fn()}
             onSortToggle={jest.fn()} />);
         expect(dataTable.find(TableBody).find(Typography).text()).toBe("item 1");
         expect(dataTable.find(TableBody).find(Button).text()).toBe("item 1");
@@ -139,7 +139,7 @@ describe("<DataTable />", () => {
             items={[{ key: "1", name: "item 1" }] as MockItem[]}
             onFiltersChange={jest.fn()}
             onRowClick={jest.fn()}
-            onRowContextMenu={jest.fn()}
+            onContextMenu={jest.fn()}
             onSortToggle={onSortToggle} />);
         expect(dataTable.find(TableSortLabel).prop("active")).toBeTruthy();
         dataTable.find(TableSortLabel).at(0).simulate("click");
@@ -160,8 +160,8 @@ describe("<DataTable />", () => {
             items={[{ key: "1", name: "item 1" }] as MockItem[]}
             onFiltersChange={onFiltersChange}
             onRowClick={jest.fn()}
-            onRowContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
+            onSortToggle={jest.fn()}
+            onContextMenu={jest.fn()} />);
         expect(dataTable.find(DataTableFilters).prop("filters")).toBe(columns[0].filters);
         dataTable.find(DataTableFilters).prop("onChange")([]);
         expect(onFiltersChange).toHaveBeenCalledWith([], columns[0]);
index c657e1167171b665c05f129ac81c096a3c1a12b1..8f4ffc6fab73b49055333ce28b0302752062a627 100644 (file)
@@ -15,8 +15,8 @@ export interface DataTableProps<T> {
     items: T[];
     columns: DataColumns<T>;
     onRowClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: T) => void;
     onRowDoubleClick: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
-    onRowContextMenu: (event: React.MouseEvent<HTMLTableRowElement>, item: T) => void;
     onSortToggle: (column: DataColumn<T>) => void;
     onFiltersChange: (filters: DataTableFilterItem[], column: DataColumn<T>) => void;
 }
@@ -24,7 +24,8 @@ export interface DataTableProps<T> {
 class DataTable<T extends DataItem> extends React.Component<DataTableProps<T> & WithStyles<CssRules>> {
     render() {
         const { items, classes } = this.props;
-        return <div className={classes.tableContainer}>
+        return <div
+            className={classes.tableContainer}>
             <Table>
                 <TableHead>
                     <TableRow>
@@ -41,7 +42,7 @@ class DataTable<T extends DataItem> extends React.Component<DataTableProps<T> &
     renderHeadCell = (column: DataColumn<T>, index: number) => {
         const { name, key, renderHeader, filters, sortDirection } = column;
         const { onSortToggle, onFiltersChange } = this.props;
-        return <TableCell key={key || index} style={{width: column.width, minWidth: column.width}}>
+        return <TableCell key={key || index} style={{ width: column.width, minWidth: column.width }}>
             {renderHeader ?
                 renderHeader() :
                 filters
@@ -69,13 +70,13 @@ class DataTable<T extends DataItem> extends React.Component<DataTableProps<T> &
     }
 
     renderBodyRow = (item: T, index: number) => {
-        const { onRowClick, onRowDoubleClick, onRowContextMenu } = this.props;
+        const { onRowClick, onRowDoubleClick, onContextMenu } = this.props;
         return <TableRow
             hover
             key={item.key}
             onClick={event => onRowClick && onRowClick(event, item)}
-            onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item) }
-            onContextMenu={event => onRowContextMenu && onRowContextMenu(event, item)}>
+            onContextMenu={this.handleRowContextMenu(item)}
+            onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item) }>
             {this.mapVisibleColumns((column, index) => (
                 <TableCell key={column.key || index}>
                     {column.render(item)}
@@ -88,6 +89,10 @@ class DataTable<T extends DataItem> extends React.Component<DataTableProps<T> &
         return this.props.columns.filter(column => column.selected).map(fn);
     }
 
+    handleRowContextMenu = (item: T) =>
+        (event: React.MouseEvent<HTMLElement>) =>
+            this.props.onContextMenu(event, item)
+
 }
 
 type CssRules = "tableBody" | "tableContainer" | "noItemsInfo";
index cc191e808df6bd4770f9f60df1888cb0e7ad1671..a7783fb256c611f99fac80803b781c9884b74f8c 100644 (file)
@@ -27,6 +27,7 @@ interface SidePanelProps {
     toggleOpen: (id: string) => void;
     toggleActive: (id: string) => void;
     sidePanelItems: SidePanelItem[];
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: SidePanelItem) => void;
 }
 
 class SidePanel extends React.Component<SidePanelProps & WithStyles<CssRules>> {
@@ -38,7 +39,7 @@ class SidePanel extends React.Component<SidePanelProps & WithStyles<CssRules>> {
                 <List>
                     {sidePanelItems.map(it => (
                         <span key={it.name}>
-                            <ListItem button className={list} onClick={() => toggleActive(it.id)}>
+                            <ListItem button className={list} onClick={() => toggleActive(it.id)} onContextMenu={this.handleRowContextMenu(it)}>
                                 <span className={row}>
                                     {it.openAble ? <i onClick={() => toggleOpen(it.id)} className={`${it.active ? activeArrow : inactiveArrow} 
                                         ${it.open ? `fas fa-caret-down ${arrowTransition}` : `fas fa-caret-down ${arrowRotate}`}`} /> : null}
@@ -58,6 +59,11 @@ class SidePanel extends React.Component<SidePanelProps & WithStyles<CssRules>> {
             </div>
         );
     }
+
+    handleRowContextMenu = (item: SidePanelItem) =>
+        (event: React.MouseEvent<HTMLElement>) =>
+            item.openAble ? this.props.onContextMenu(event, item) : null
+
 }
 
 type CssRules = 'active' | 'listItemText' | 'row' | 'leftSidePanelContainer' | 'list' | 'icon' | 'projectIconMargin' |
index 2c19a831ecd1154bfe7de3746787a6b3d6641eb3..8de9bda5970ffe7d495f94f24b51229aacf9c1df 100644 (file)
@@ -32,25 +32,18 @@ interface TreeProps<T> {
     toggleItemOpen: (id: string, status: TreeItemStatus) => void;
     toggleItemActive: (id: string, status: TreeItemStatus) => void;
     level?: number;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>) => void;
 }
 
 class Tree<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
-    renderArrow(status: TreeItemStatus, arrowClass: string, open: boolean, id: string) {
-        const { arrowTransition, arrowVisibility, arrowRotate } = this.props.classes;
-        return <i onClick={() => this.props.toggleItemOpen(id, status)}
-            className={`
-                    ${arrowClass} 
-                    ${status === TreeItemStatus.Pending ? arrowVisibility : ''} 
-                    ${open ? `fas fa-caret-down ${arrowTransition}` : `fas fa-caret-down ${arrowRotate}`}`} />;
-    }
     render(): ReactElement<any> {
         const level = this.props.level ? this.props.level : 0;
-        const { classes, render, toggleItemOpen, items, toggleItemActive } = this.props;
+        const { classes, render, toggleItemOpen, items, toggleItemActive, onContextMenu } = this.props;
         const { list, inactiveArrow, activeArrow, loader } = classes;
         return <List component="div" className={list}>
             {items && items.map((it: TreeItem<T>, idx: number) =>
                 <div key={`item/${level}/${idx}`}>
-                    <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }} onClick={() => toggleItemActive(it.id, it.status)}>
+                    <ListItem button className={list} style={{ paddingLeft: (level + 1) * 20 }} onClick={() => toggleItemActive(it.id, it.status)} onContextMenu={this.handleRowContextMenu(it)}>
                         {it.status === TreeItemStatus.Pending ? <CircularProgress size={10} className={loader} /> : null}
                         {it.toggled && it.items && it.items.length === 0 ? null : this.renderArrow(it.status, it.active ? activeArrow : inactiveArrow, it.open, it.id)}
                         {render(it, level)}
@@ -62,11 +55,24 @@ class Tree<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
                                 render={render}
                                 toggleItemOpen={toggleItemOpen}
                                 toggleItemActive={toggleItemActive}
-                                level={level + 1} />
+                                level={level + 1}
+                                onContextMenu={onContextMenu} />
                         </Collapse>}
                 </div>)}
         </List>;
     }
+    renderArrow(status: TreeItemStatus, arrowClass: string, open: boolean, id: string) {
+        const { arrowTransition, arrowVisibility, arrowRotate } = this.props.classes;
+        return <i onClick={() => this.props.toggleItemOpen(id, status)}
+            className={`
+                    ${arrowClass} 
+                    ${status === TreeItemStatus.Pending ? arrowVisibility : ''} 
+                    ${open ? `fas fa-caret-down ${arrowTransition}` : `fas fa-caret-down ${arrowRotate}`}`} />;
+    }
+
+    handleRowContextMenu = (item: TreeItem<T>) =>
+        (event: React.MouseEvent<HTMLElement>) =>
+            this.props.onContextMenu(event, item)
 }
 
 type CssRules = 'list' | 'activeArrow' | 'inactiveArrow' | 'arrowRotate' | 'arrowTransition' | 'loader' | 'arrowVisibility';
index 516934969310fa7c5331d49719b190c5fc8d990b..c1a002f98a760d109c03bdbd022eeb36b7a94a10 100644 (file)
@@ -8,9 +8,15 @@ import { projectService } from "../../services/services";
 import { Dispatch } from "redux";
 import { getResourceKind } from "../../models/resource";
 import FilterBuilder from "../../common/api/filter-builder";
+import { ThunkAction } from "../../../node_modules/redux-thunk";
+import { RootState } from "../store";
 
 const actions = unionize({
-    CREATE_PROJECT: ofType<Project>(),
+    OPEN_PROJECT_CREATOR: ofType<{ ownerUuid: string }>(),
+    CLOSE_PROJECT_CREATOR: ofType<{}>(),
+    CREATE_PROJECT: ofType<Partial<ProjectResource>>(),
+    CREATE_PROJECT_SUCCESS: ofType<ProjectResource>(),
+    CREATE_PROJECT_ERROR: ofType<string>(),
     REMOVE_PROJECT: ofType<string>(),
     PROJECTS_REQUEST: ofType<string>(),
     PROJECTS_SUCCESS: ofType<{ projects: Project[], parentItemId?: string }>(),
@@ -38,5 +44,16 @@ export const getProjectList = (parentUuid: string = '') => (dispatch: Dispatch)
     });
 };
 
+export const createProject = (project: Partial<ProjectResource>) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const { ownerUuid } = getState().projects.creator;
+        const projectData = { ownerUuid, ...project };
+        dispatch(actions.CREATE_PROJECT(projectData));
+        return projectService
+            .create(projectData)
+            .then(project => dispatch(actions.CREATE_PROJECT_SUCCESS(project)))
+            .catch(() => dispatch(actions.CREATE_PROJECT_ERROR("Could not create a project")));
+    };
+
 export type ProjectAction = UnionOf<typeof actions>;
 export default actions;
index c80f18c82f1ef92a6032cbd60ce46687d0b7cd8f..0862142d48b264e5bd84706d5e420dff06d07d9d 100644 (file)
@@ -76,7 +76,8 @@ describe('project-reducer', () => {
                 active: true,
                 status: 1
             }],
-            currentItemId: "1"
+            currentItemId: "1",
+            creator: { opened: false, pending: false, ownerUuid: "" },
         };
         const project = {
             items: [{
@@ -118,7 +119,8 @@ describe('project-reducer', () => {
                 active: false,
                 status: 1
             }],
-            currentItemId: "1"
+            currentItemId: "1",
+            creator: { opened: false, pending: false, ownerUuid: "" }
         };
         const project = {
             items: [{
@@ -137,7 +139,8 @@ describe('project-reducer', () => {
                 status: 1,
                 toggled: true
             }],
-            currentItemId: "1"
+            currentItemId: "1",
+            creator: { opened: false, pending: false, ownerUuid: "" },
         };
 
         const state = projectsReducer(initialState, actions.TOGGLE_PROJECT_TREE_ITEM_ACTIVE(initialState.items[0].id));
@@ -163,7 +166,8 @@ describe('project-reducer', () => {
                 status: 1,
                 toggled: false,
             }],
-            currentItemId: "1"
+            currentItemId: "1",
+            creator: { opened: false, pending: false, ownerUuid: "" }
         };
         const project = {
             items: [{
@@ -182,7 +186,8 @@ describe('project-reducer', () => {
                 status: 1,
                 toggled: true
             }],
-            currentItemId: "1"
+            currentItemId: "1",
+            creator: { opened: false, pending: false, ownerUuid: "" },
         };
 
         const state = projectsReducer(initialState, actions.TOGGLE_PROJECT_TREE_ITEM_OPEN(initialState.items[0].id));
index efef80992189c39a2d748a477cc972f3b1bf696b..8ee9e9f916be5e95792d4fc7119b13e7b803cee3 100644 (file)
@@ -10,9 +10,16 @@ import { TreeItem, TreeItemStatus } from "../../components/tree/tree";
 
 export type ProjectState = {
     items: Array<TreeItem<Project>>,
-    currentItemId: string
+    currentItemId: string,
+    creator: ProjectCreator
 };
 
+interface ProjectCreator {
+    opened: boolean;
+    pending: boolean;
+    ownerUuid: string;
+}
+
 export function findTreeItem<T>(tree: Array<TreeItem<T>>, itemId: string): TreeItem<T> | undefined {
     let item;
     for (const t of tree) {
@@ -40,12 +47,12 @@ export function getActiveTreeItem<T>(tree: Array<TreeItem<T>>): TreeItem<T> | un
 }
 
 export function getTreePath<T>(tree: Array<TreeItem<T>>, itemId: string): Array<TreeItem<T>> {
-    for (const item of tree){
-        if(item.id === itemId){
+    for (const item of tree) {
+        if (item.id === itemId) {
             return [item];
         } else {
             const branch = getTreePath(item.items || [], itemId);
-            if(branch.length > 0){
+            if (branch.length > 0) {
                 return [item, ...branch];
             }
         }
@@ -85,20 +92,32 @@ function updateProjectTree(tree: Array<TreeItem<Project>>, projects: Project[],
     return items;
 }
 
-const projectsReducer = (state: ProjectState = { items: [], currentItemId: "" }, action: ProjectAction) => {
+const updateCreator = (state: ProjectState, creator: Partial<ProjectCreator>) => ({
+    ...state,
+    creator: {
+        ...state.creator,
+        ...creator
+    }
+});
+
+const initialState: ProjectState = {
+    items: [],
+    currentItemId: "",
+    creator: {
+        opened: false,
+        pending: false,
+        ownerUuid: ""
+    }
+};
+
+
+const projectsReducer = (state: ProjectState = initialState, action: ProjectAction) => {
     return actions.match(action, {
-        CREATE_PROJECT: project => ({
-            ...state,
-            items: state.items.concat({
-                id: project.uuid,
-                open: false,
-                active: false,
-                status: TreeItemStatus.Loaded,
-                toggled: false,
-                items: [],
-                data: project
-            })
-        }),
+        OPEN_PROJECT_CREATOR: ({ ownerUuid }) => updateCreator(state, { ownerUuid, opened: true, pending: false }),
+        CLOSE_PROJECT_CREATOR: () => updateCreator(state, { opened: false }),
+        CREATE_PROJECT: () => updateCreator(state, { opened: false, pending: true }),
+        CREATE_PROJECT_SUCCESS: () => updateCreator(state, { ownerUuid: "", pending: false }),
+        CREATE_PROJECT_ERROR: () => updateCreator(state, { ownerUuid: "", pending: false }),
         REMOVE_PROJECT: () => state,
         PROJECTS_REQUEST: itemId => {
             const items = _.cloneDeep(state.items);
@@ -123,6 +142,7 @@ const projectsReducer = (state: ProjectState = { items: [], currentItemId: "" },
                 item.open = !item.open;
             }
             return {
+                ...state,
                 items,
                 currentItemId: itemId
             };
@@ -136,6 +156,7 @@ const projectsReducer = (state: ProjectState = { items: [], currentItemId: "" },
                 item.active = true;
             }
             return {
+                ...state,
                 items,
                 currentItemId: itemId
             };
@@ -144,6 +165,7 @@ const projectsReducer = (state: ProjectState = { items: [], currentItemId: "" },
             const items = _.cloneDeep(state.items);
             resetTreeActivity(items);
             return {
+                ...state,
                 items,
                 currentItemId: ""
             };
diff --git a/src/utils/dialog-validator.tsx b/src/utils/dialog-validator.tsx
new file mode 100644 (file)
index 0000000..1d1a921
--- /dev/null
@@ -0,0 +1,72 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+
+type ValidatorProps = {
+  value: string,
+  onChange: (isValid: boolean | string) => void;
+  render: (hasError: boolean) => React.ReactElement<any>;
+  isRequired: boolean;
+};
+
+interface ValidatorState {
+  isPatternValid: boolean;
+  isLengthValid: boolean;
+}
+
+const nameRegEx = /^[a-zA-Z0-9-_ ]+$/;
+const maxInputLength = 60;
+
+class Validator extends React.Component<ValidatorProps & WithStyles<CssRules>> {
+  state: ValidatorState = {
+    isPatternValid: true,
+    isLengthValid: true
+  };
+
+  componentWillReceiveProps(nextProps: ValidatorProps) {
+    const { value } = nextProps;
+
+    if (this.props.value !== value) {
+      this.setState({
+        isPatternValid: value.match(nameRegEx),
+        isLengthValid: value.length < maxInputLength
+      }, () => this.onChange());
+    }
+  }
+
+  onChange() {
+    const { value, onChange, isRequired } = this.props;
+    const { isPatternValid, isLengthValid } = this.state;
+    const isValid = value && isPatternValid && isLengthValid && (isRequired || (!isRequired && value.length > 0));
+
+    onChange(isValid);
+  }
+
+  render() {
+    const { classes, isRequired, value } = this.props;
+    const { isPatternValid, isLengthValid } = this.state;
+
+    return (
+      <span>
+        {this.props.render(!(isPatternValid && isLengthValid) && (isRequired || (!isRequired && value.length > 0)))}
+        {!isPatternValid && (isRequired || (!isRequired && value.length > 0)) ? <span className={classes.formInputError}>This field allow only alphanumeric characters, dashes, spaces and underscores.<br /></span> : null}
+        {!isLengthValid ? <span className={classes.formInputError}>This field should have max 60 characters.</span> : null}
+      </span>
+    );
+  }
+}
+
+type CssRules = "formInputError";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+  formInputError: {
+    color: "#ff0000",
+    marginLeft: "5px",
+    fontSize: "11px",
+  }
+});
+
+export default withStyles(styles)(Validator);
\ No newline at end of file
diff --git a/src/views-components/create-project-dialog/create-project-dialog.tsx b/src/views-components/create-project-dialog/create-project-dialog.tsx
new file mode 100644 (file)
index 0000000..701ceee
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import { Dispatch } from "../../../node_modules/redux";
+import { RootState } from "../../store/store";
+import DialogProjectCreate from "../dialog-create/dialog-project-create";
+import actions, { createProject, getProjectList } from "../../store/project/project-action";
+import dataExplorerActions from "../../store/data-explorer/data-explorer-action";
+import { PROJECT_PANEL_ID } from "../../views/project-panel/project-panel";
+
+const mapStateToProps = (state: RootState) => ({
+    open: state.projects.creator.opened
+});
+
+const submit = (data: { name: string, description: string }) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        const { ownerUuid } = getState().projects.creator;
+        dispatch<any>(createProject(data)).then(() => {
+            dispatch(dataExplorerActions.REQUEST_ITEMS({ id: PROJECT_PANEL_ID }));
+            dispatch<any>(getProjectList(ownerUuid));
+        });
+    };
+
+const mapDispatchToProps = (dispatch: Dispatch) => ({
+    handleClose: () => {
+        dispatch(actions.CLOSE_PROJECT_CREATOR());
+    },
+    onSubmit: (data: { name: string, description: string }) => {
+        dispatch<any>(submit(data));
+    }
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(DialogProjectCreate);
index 5ff8c66b20fd960441772c7243f9e5eefac66b3f..e2e145bbe34e713fb2f49f53efa8ae1beacb51c9 100644 (file)
@@ -14,16 +14,15 @@ import { ContextMenuAction, ContextMenuActionGroup } from "../../components/cont
 
 interface Props {
     id: string;
-    contextActions: ContextMenuActionGroup[];
     onRowClick: (item: any) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
     onRowDoubleClick: (item: any) => void;
-    onContextAction: (action: ContextMenuAction, item: any) => void;
 }
 
-const mapStateToProps = (state: RootState, { id, contextActions }: Props) =>
+const mapStateToProps = (state: RootState, { id }: Props) =>
     getDataExplorer(state.dataExplorer, id);
 
-const mapDispatchToProps = (dispatch: Dispatch, { id, contextActions, onRowClick, onRowDoubleClick, onContextAction }: Props) => ({
+const mapDispatchToProps = (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({
     onSearch: (searchValue: string) => {
         dispatch(actions.SET_SEARCH_VALUE({ id, searchValue }));
     },
@@ -48,13 +47,11 @@ const mapDispatchToProps = (dispatch: Dispatch, { id, contextActions, onRowClick
         dispatch(actions.SET_ROWS_PER_PAGE({ id, rowsPerPage }));
     },
 
-    contextActions,
-
     onRowClick,
 
     onRowDoubleClick,
-
-    onContextAction
+    
+    onContextMenu,
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(DataExplorer);
diff --git a/src/views-components/dialog-create/dialog-project-create.tsx b/src/views-components/dialog-create/dialog-project-create.tsx
new file mode 100644 (file)
index 0000000..ef07ea2
--- /dev/null
@@ -0,0 +1,140 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import TextField from '@material-ui/core/TextField';
+import Dialog from '@material-ui/core/Dialog';
+import DialogActions from '@material-ui/core/DialogActions';
+import DialogContent from '@material-ui/core/DialogContent';
+import DialogTitle from '@material-ui/core/DialogTitle';
+import { Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+
+import Validator from '../../utils/dialog-validator';
+
+interface ProjectCreateProps {
+  open: boolean;
+  handleClose: () => void;
+  onSubmit: (data: { name: string, description: string }) => void;
+}
+
+interface DialogState {
+  name: string;
+  description: string;
+  isNameValid: boolean;
+  isDescriptionValid: boolean;
+}
+
+class DialogProjectCreate extends React.Component<ProjectCreateProps & WithStyles<CssRules>> {
+  state: DialogState = {
+    name: '',
+    description: '',
+    isNameValid: false,
+    isDescriptionValid: true
+  };
+
+  render() {
+    const { name, description } = this.state;
+    const { classes, open, handleClose } = this.props;
+
+    return (
+      <Dialog
+        open={open}
+        onClose={handleClose}>
+        <div className={classes.dialog}>
+          <DialogTitle id="form-dialog-title" className={classes.dialogTitle}>Create a project</DialogTitle>
+          <DialogContent className={classes.dialogContent}>
+            <Validator
+              value={name}
+              onChange={e => this.isNameValid(e)}
+              isRequired={true}
+              render={hasError =>
+                <TextField
+                  margin="dense"
+                  className={classes.textField}
+                  id="name"
+                  onChange={e => this.handleProjectName(e)}
+                  label="Project name"
+                  error={hasError}
+                  fullWidth />} />
+            <Validator
+              value={description}
+              onChange={e => this.isDescriptionValid(e)}
+              isRequired={false}
+              render={hasError =>
+                <TextField
+                  margin="dense"
+                  className={classes.textField}
+                  id="description"
+                  onChange={e => this.handleDescriptionValue(e)}
+                  label="Description - optional"
+                  error={hasError}
+                  fullWidth />} />
+          </DialogContent>
+          <DialogActions>
+            <Button onClick={handleClose} className={classes.button} color="primary">CANCEL</Button>
+            <Button onClick={this.handleSubmit} className={classes.lastButton} color="primary" disabled={!this.state.isNameValid || (!this.state.isDescriptionValid && description.length > 0)} variant="raised">CREATE A PROJECT</Button>
+          </DialogActions>
+        </div>
+      </Dialog>
+    );
+  }
+
+  handleSubmit = () => {
+    this.props.onSubmit({
+      name: this.state.name,
+      description: this.state.description
+    });
+  }
+
+  handleProjectName(e: any) {
+    this.setState({
+      name: e.target.value,
+    });
+  }
+
+  handleDescriptionValue(e: any) {
+    this.setState({
+      description: e.target.value,
+    });
+  }
+
+  isNameValid(value: boolean | string) {
+    this.setState({
+      isNameValid: value,
+    });
+  }
+
+  isDescriptionValid(value: boolean | string) {
+    this.setState({
+      isDescriptionValid: value,
+    });
+  }
+}
+
+type CssRules = "button" | "lastButton" | "dialogContent" | "textField" | "dialog" | "dialogTitle";
+
+const styles: StyleRulesCallback<CssRules> = theme => ({
+  button: {
+    marginLeft: theme.spacing.unit
+  },
+  lastButton: {
+    marginLeft: theme.spacing.unit,
+    marginRight: "20px",
+  },
+  dialogContent: {
+    marginTop: "20px",
+  },
+  dialogTitle: {
+    paddingBottom: "0"
+  },
+  textField: {
+    marginTop: "32px",
+  },
+  dialog: {
+    minWidth: "600px",
+    minHeight: "320px"
+  }
+});
+
+export default withStyles(styles)(DialogProjectCreate);
\ No newline at end of file
index 1230e3b7db60abe9901db66fa28a310a6737c804..d2082395125e740e1cff707293d4e7f8d081740b 100644 (file)
@@ -35,6 +35,7 @@ export interface MainAppBarActionProps {
     onSearch: (searchText: string) => void;
     onBreadcrumbClick: (breadcrumb: Breadcrumb) => void;
     onMenuItemClick: (menuItem: MainAppBarMenuItem) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
     onDetailsPanelToggle: () => void;
 }
 
@@ -70,7 +71,10 @@ export const MainAppBar: React.SFC<MainAppBarProps> = (props) => {
         </Toolbar>
         <Toolbar >
             {
-                props.user && <Breadcrumbs items={props.breadcrumbs} onClick={props.onBreadcrumbClick} />
+                props.user && <Breadcrumbs
+                    items={props.breadcrumbs}
+                    onClick={props.onBreadcrumbClick}
+                    onContextMenu={props.onContextMenu} />
             }
             <IconButton color="inherit" onClick={props.onDetailsPanelToggle}>
                 <InfoIcon />
index f51b65e054df7f8ab2d69a263e658f9a1fe2a7d8..511cbbbc7380198e12eec9f6f7f48bcffd99efae 100644 (file)
@@ -16,15 +16,17 @@ export interface ProjectTreeProps {
     projects: Array<TreeItem<Project>>;
     toggleOpen: (id: string, status: TreeItemStatus) => void;
     toggleActive: (id: string, status: TreeItemStatus) => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: TreeItem<Project>) => void;
 }
 
 class ProjectTree<T> extends React.Component<ProjectTreeProps & WithStyles<CssRules>> {
     render(): ReactElement<any> {
-        const { classes, projects, toggleOpen, toggleActive } = this.props;
+        const { classes, projects, toggleOpen, toggleActive, onContextMenu } = this.props;
         const { active, listItemText, row, treeContainer } = classes;
         return (
             <div className={treeContainer}>
                 <Tree items={projects}
+                    onContextMenu={onContextMenu}
                     toggleItemOpen={toggleOpen}
                     toggleItemActive={toggleActive}
                     render={(project: TreeItem<Project>) =>
index 0708b1637083321c831433229bd322264d2fad61..0b1be099d0a7aba336d8d142d6b8b05e0e1695cc 100644 (file)
@@ -4,10 +4,9 @@
 
 import * as React from 'react';
 import { ProjectPanelItem } from './project-panel-item';
-import { Grid, Typography, Button, Toolbar, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import { Grid, Typography, Button, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { formatDate, formatFileSize } from '../../common/formatters';
 import DataExplorer from "../../views-components/data-explorer/data-explorer";
-import { ContextMenuAction } from '../../components/context-menu/context-menu';
 import { DispatchProp, connect } from 'react-redux';
 import { DataColumns } from '../../components/data-table/data-table';
 import { RouteComponentProps } from 'react-router';
@@ -26,13 +25,16 @@ export interface ProjectPanelFilter extends DataTableFilterItem {
 type ProjectPanelProps = {
     currentItemId: string,
     onItemClick: (item: ProjectPanelItem) => void,
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: ProjectPanelItem) => void;
+    onDialogOpen: (ownerUuid: string) => void;
     onItemDoubleClick: (item: ProjectPanelItem) => void,
     onItemRouteChange: (itemId: string) => void
 }
     & DispatchProp
     & WithStyles<CssRules>
     & RouteComponentProps<{ id: string }>;
-class ProjectPanel extends React.Component<ProjectPanelProps> {
+
+class ProjectPanel extends React.Component<ProjectPanelProps> {    
     render() {
         return <div>
             <div className={this.props.classes.toolbar}>
@@ -42,16 +44,15 @@ class ProjectPanel extends React.Component<ProjectPanelProps> {
                 <Button color="primary" variant="raised" className={this.props.classes.button}>
                     Run a process
                 </Button>
-                <Button color="primary" variant="raised" className={this.props.classes.button}>
-                    Create a project
+                <Button color="primary" onClick={() => this.props.onDialogOpen(this.props.currentItemId)} variant="raised" className={this.props.classes.button}>
+                    New project
                 </Button>
             </div>
             <DataExplorer
                 id={PROJECT_PANEL_ID}
-                contextActions={contextMenuActions}
                 onRowClick={this.props.onItemClick}
                 onRowDoubleClick={this.props.onItemDoubleClick}
-                onContextAction={this.executeAction} />;
+                onContextMenu={this.props.onContextMenu} />
         </div>;
     }
 
@@ -60,11 +61,6 @@ class ProjectPanel extends React.Component<ProjectPanelProps> {
             this.props.onItemRouteChange(match.params.id);
         }
     }
-
-    executeAction = (action: ContextMenuAction, item: ProjectPanelItem) => {
-        alert(`Executing ${action.name} on ${item.name}`);
-    }
-
 }
 
 type CssRules = "toolbar" | "button";
@@ -76,7 +72,7 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     },
     button: {
         marginLeft: theme.spacing.unit
-    }
+    },
 });
 
 const renderName = (item: ProjectPanelItem) =>
@@ -220,29 +216,6 @@ export const columns: DataColumns<ProjectPanelItem, ProjectPanelFilter> = [{
     width: "150px"
 }];
 
-const contextMenuActions = [[{
-    icon: "fas fa-users fa-fw",
-    name: "Share"
-}, {
-    icon: "fas fa-sign-out-alt fa-fw",
-    name: "Move to"
-}, {
-    icon: "fas fa-star fa-fw",
-    name: "Add to favourite"
-}, {
-    icon: "fas fa-edit fa-fw",
-    name: "Rename"
-}, {
-    icon: "fas fa-copy fa-fw",
-    name: "Make a copy"
-}, {
-    icon: "fas fa-download fa-fw",
-    name: "Download"
-}], [{
-    icon: "fas fa-trash-alt fa-fw",
-    name: "Remove"
-}
-]];
 
 export default withStyles(styles)(
     connect((state: RootState) => ({ currentItemId: state.projects.currentItemId }))(
index 959025bfbe8a43f37d52c7d9defd2627f274ee59..6ad4d247456c9d995b3a44562e5defd765756e14 100644 (file)
@@ -27,6 +27,11 @@ import projectActions from "../../store/project/project-action";
 import ProjectPanel from "../project-panel/project-panel";
 import DetailsPanel from '../../views-components/details-panel/details-panel';
 import { ArvadosTheme } from '../../common/custom-theme';
+import ContextMenu, { ContextMenuAction } from '../../components/context-menu/context-menu';
+import { mockAnchorFromMouseEvent } from '../../components/popover/helpers';
+import CreateProjectDialog from "../../views-components/create-project-dialog/create-project-dialog";
+import { authService } from '../../services/services';
+
 import detailsPanelActions, { loadDetails } from "../../store/details-panel/details-panel-action";
 import { ResourceKind } from '../../models/kinds';
 
@@ -92,6 +97,10 @@ interface NavMenuItem extends MainAppBarMenuItem {
 }
 
 interface WorkbenchState {
+    contextMenu: {
+        anchorEl?: HTMLElement;
+        itemUuid?: string;
+    };
     anchorEl: any;
     searchText: string;
     menuItems: {
@@ -104,6 +113,11 @@ interface WorkbenchState {
 
 class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
     state = {
+        contextMenu: {
+            anchorEl: undefined,
+            itemUuid: undefined
+        },
+        isCreationDialogOpen: false,
         anchorEl: null,
         searchText: "",
         breadcrumbs: [],
@@ -145,6 +159,9 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
         onMenuItemClick: (menuItem: NavMenuItem) => menuItem.action(),
         onDetailsPanelToggle: () => {
             this.props.dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+        },
+        onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: NavBreadcrumb) => {
+            this.openContextMenu(event, breadcrumb.itemId);
         }
     };
 
@@ -157,6 +174,33 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
         this.props.dispatch(projectActions.RESET_PROJECT_TREE_ACTIVITY(itemId));
     }
 
+    handleCreationDialogOpen = (itemUuid: string) => {
+        this.closeContextMenu();
+        this.props.dispatch(projectActions.OPEN_PROJECT_CREATOR({ ownerUuid: itemUuid }));
+    }
+
+
+    openContextMenu = (event: React.MouseEvent<HTMLElement>, itemUuid: string) => {
+        event.preventDefault();
+        this.setState({
+            contextMenu: {
+                anchorEl: mockAnchorFromMouseEvent(event),
+                itemUuid
+            }
+        });
+    }
+
+    closeContextMenu = () => {
+        this.setState({ contextMenu: {} });
+    }
+
+    openCreateDialog = (item: ContextMenuAction) => {
+        const { itemUuid } = this.state.contextMenu;
+        if (item.openCreateDialog && itemUuid) {
+            this.handleCreationDialogOpen(itemUuid);
+        }
+    }
+
     render() {
         const path = getTreePath(this.props.projects, this.props.currentProjectId);
         const breadcrumbs = path.map(item => ({
@@ -174,8 +218,7 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
                         searchText={this.state.searchText}
                         user={this.props.user}
                         menuItems={this.state.menuItems}
-                        {...this.mainAppBarActions}
-                    />
+                        {...this.mainAppBarActions} />
                 </div>
                 {user &&
                     <Drawer
@@ -187,15 +230,16 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
                         <SidePanel
                             toggleOpen={this.toggleSidePanelOpen}
                             toggleActive={this.toggleSidePanelActive}
-                            sidePanelItems={this.props.sidePanelItems}>
+                            sidePanelItems={this.props.sidePanelItems}
+                            onContextMenu={(event) => this.openContextMenu(event, authService.getUuid() || "")}>
                             <ProjectTree
                                 projects={this.props.projects}
                                 toggleOpen={itemId => this.props.dispatch<any>(setProjectItem(itemId, ItemMode.OPEN))}
+                                onContextMenu={(event, item) => this.openContextMenu(event, item.data.uuid)}
                                 toggleActive={itemId => {
                                     this.props.dispatch<any>(setProjectItem(itemId, ItemMode.ACTIVE));
                                     this.props.dispatch<any>(loadDetails(itemId, ResourceKind.Project));
-                                }}
-                            />
+                                }}/>
                         </SidePanel>
                     </Drawer>}
                 <main className={classes.contentWrapper}>
@@ -206,12 +250,20 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
                     </div>
                     <DetailsPanel />
                 </main>
+                <ContextMenu
+                    anchorEl={this.state.contextMenu.anchorEl}
+                    actions={contextMenuActions}
+                    onActionClick={this.openCreateDialog}
+                    onClose={this.closeContextMenu} />
+                <CreateProjectDialog />
             </div>
         );
     }
 
     renderProjectPanel = (props: RouteComponentProps<{ id: string }>) => <ProjectPanel
         onItemRouteChange={itemId => this.props.dispatch<any>(setProjectItem(itemId, ItemMode.ACTIVE))}
+        onContextMenu={(event, item) => this.openContextMenu(event, item.uuid)}
+        onDialogOpen={this.handleCreationDialogOpen}
         onItemClick={item => {
             this.props.dispatch<any>(loadDetails(item.uuid, item.kind as ResourceKind));
         }}
@@ -220,8 +272,35 @@ class Workbench extends React.Component<WorkbenchProps, WorkbenchState> {
             this.props.dispatch<any>(loadDetails(item.uuid, ResourceKind.Project));
         }}
         {...props} />
+}
 
+const contextMenuActions = [[{
+    icon: "fas fa-plus fa-fw",
+    name: "New project",
+    openCreateDialog: true
+}, {
+    icon: "fas fa-users fa-fw",
+    name: "Share"
+}, {
+    icon: "fas fa-sign-out-alt fa-fw",
+    name: "Move to"
+}, {
+    icon: "fas fa-star fa-fw",
+    name: "Add to favourite"
+}, {
+    icon: "fas fa-edit fa-fw",
+    name: "Rename"
+}, {
+    icon: "fas fa-copy fa-fw",
+    name: "Make a copy"
+}, {
+    icon: "fas fa-download fa-fw",
+    name: "Download"
+}], [{
+    icon: "fas fa-trash-alt fa-fw",
+    name: "Remove"
 }
+]];
 
 export default connect<WorkbenchDataProps>(
     (state: RootState) => ({