fixed conflicts
authorPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Wed, 21 Nov 2018 15:14:05 +0000 (16:14 +0100)
committerPawel Kowalczyk <pawel.kowalczyk@contractors.roche.com>
Wed, 21 Nov 2018 15:14:05 +0000 (16:14 +0100)
Feature #13864

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

85 files changed:
src/common/webdav.ts
src/components/data-explorer/data-explorer.tsx
src/components/icon/icon.tsx
src/index.tsx
src/models/repositories.ts [new file with mode: 0644]
src/models/resource.ts
src/models/ssh-key.ts [new file with mode: 0644]
src/models/tree.test.ts
src/models/tree.ts
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/services/authorized-keys-service/authorized-keys-service.ts [new file with mode: 0644]
src/services/collection-service/collection-service.ts
src/services/common-service/common-resource-service.ts
src/services/groups-service/groups-service.ts
src/services/project-service/project-service.ts
src/services/repositories-service/repositories-service.ts [new file with mode: 0644]
src/services/services.ts
src/store/advanced-tab/advanced-tab.ts
src/store/auth/auth-action.ts
src/store/auth/auth-reducer.test.ts
src/store/auth/auth-reducer.ts
src/store/collection-panel/collection-panel-files/collection-panel-files-actions.ts
src/store/collections/collection-copy-actions.ts
src/store/collections/collection-move-actions.ts
src/store/collections/collection-partial-copy-actions.ts
src/store/collections/collection-upload-actions.ts
src/store/context-menu/context-menu-actions.ts
src/store/details-panel/details-panel-action.ts
src/store/navigation/navigation-action.ts
src/store/processes/process-copy-actions.ts
src/store/processes/process-move-actions.ts
src/store/projects/project-move-actions.ts
src/store/repositories/repositories-actions.ts [new file with mode: 0644]
src/store/repositories/repositories-reducer.ts [new file with mode: 0644]
src/store/run-process-panel/run-process-panel-actions.ts
src/store/store.ts
src/store/tree-picker/picker-id.tsx [new file with mode: 0644]
src/store/tree-picker/tree-picker-actions.ts
src/store/tree-picker/tree-picker-reducer.ts
src/store/workbench/workbench-actions.ts
src/validators/is-rsa-key.tsx [new file with mode: 0644]
src/validators/validators.tsx
src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/context-menu/action-sets/collection-resource-action-set.ts
src/views-components/context-menu/action-sets/process-action-set.ts
src/views-components/context-menu/action-sets/process-resource-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/action-sets/repository-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/trashed-collection-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views-components/details-panel/details-panel.tsx
src/views-components/details-panel/project-details.tsx
src/views-components/dialog-copy/dialog-collection-partial-copy.tsx
src/views-components/dialog-copy/dialog-copy.tsx
src/views-components/dialog-create/dialog-repository-create.tsx [new file with mode: 0644]
src/views-components/dialog-create/dialog-ssh-key-create.tsx [new file with mode: 0644]
src/views-components/dialog-forms/copy-collection-dialog.ts
src/views-components/dialog-forms/copy-process-dialog.ts
src/views-components/dialog-forms/create-repository-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/create-ssh-key-dialog.ts [new file with mode: 0644]
src/views-components/dialog-forms/move-collection-dialog.ts
src/views-components/dialog-forms/move-process-dialog.ts
src/views-components/dialog-forms/move-project-dialog.ts
src/views-components/dialog-forms/partial-copy-collection-dialog.ts
src/views-components/dialog-move/dialog-move-to.tsx
src/views-components/form-fields/collection-form-fields.tsx
src/views-components/form-fields/repository-form-fields.tsx [new file with mode: 0644]
src/views-components/form-fields/ssh-key-form-fields.tsx [new file with mode: 0644]
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-dialog.tsx [new file with mode: 0644]
src/views-components/project-properties-dialog/project-properties-form.tsx [new file with mode: 0644]
src/views-components/project-tree-picker/project-tree-picker.tsx
src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx
src/views-components/projects-tree-picker/projects-tree-picker.tsx
src/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx [new file with mode: 0644]
src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx [new file with mode: 0644]
src/views-components/repository-remove-dialog/repository-remove-dialog.ts [new file with mode: 0644]
src/views-components/rich-text-editor-dialog/rich-text-editor-dialog.tsx
src/views/repositories-panel/repositories-panel.tsx [new file with mode: 0644]
src/views/ssh-key-panel/ssh-key-panel-root.tsx [new file with mode: 0644]
src/views/ssh-key-panel/ssh-key-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

index 27e1f22de5be8c642072d7ae42b80f9189fe524b..8aea568af320bf7c91a10d5b476162ad7453d2a0 100644 (file)
@@ -25,9 +25,23 @@ export class WebDAV {
         this.request({
             ...config, url,
             method: 'PUT',
-            data,
+            data
         })
 
+    upload = (url: string, path: string, files: File[], config: WebDAVRequestConfig = {}) => {
+        const fd = new FormData();
+        fd.append('path', path);
+        files.forEach((f, idx) => {
+            fd.append(`file-${idx}`, f);
+        });
+
+        return this.request({
+            ...config, url,
+            method: 'PUT',
+            data: fd
+        });
+    }
+
     copy = (url: string, destination: string, config: WebDAVRequestConfig = {}) =>
         this.request({
             ...config, url,
@@ -62,13 +76,27 @@ export class WebDAV {
                 r.upload.addEventListener('progress', config.onUploadProgress);
             }
 
-            r.addEventListener('load', () => resolve(r));
-            r.addEventListener('error', () => reject(r));
+            r.addEventListener('load', () => {
+                if (r.status === 404) {
+                    return reject(r);
+                } else {
+                    return resolve(r);
+                }
+            });
+
+            r.addEventListener('error', () => {
+                return reject(r);
+            });
+
+            r.upload.addEventListener('error', () => {
+                return reject(r);
+            });
 
             r.send(config.data);
         });
     }
 }
+
 export interface WebDAVRequestConfig {
     headers?: {
         [key: string]: string;
index 08f52d00d37663f321106be226cea10c81ef08f0..f863ba13617adcc50c6ded0aac032445787079eb 100644 (file)
@@ -4,13 +4,13 @@
 
 import * as React from 'react';
 import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip } from '@material-ui/core';
-import MoreVertIcon from "@material-ui/icons/MoreVert";
 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 { MoreOptionsIcon } from '~/components/icon/icon';
 
 type CssRules = 'searchBox' | "toolbar" | "footer" | "root" | 'moreOptionsButton';
 
@@ -127,7 +127,7 @@ export const DataExplorer = withStyles(styles)(
             <Grid container justify="center">
                 <Tooltip title="More options" disableFocusListener>
                     <IconButton className={this.props.classes.moreOptionsButton} onClick={event => this.props.onContextMenu(event, item)}>
-                        <MoreVertIcon />
+                        <MoreOptionsIcon />
                     </IconButton>
                 </Tooltip>
             </Grid>
index 4e1a0635704b533a358adc2e78718e3f575f1c05..f0f7da1d5f2694876095dee857d9509e080d7f53 100644 (file)
@@ -31,6 +31,7 @@ import Input from '@material-ui/icons/Input';
 import InsertDriveFile from '@material-ui/icons/InsertDriveFile';
 import LastPage from '@material-ui/icons/LastPage';
 import LibraryBooks from '@material-ui/icons/LibraryBooks';
+import ListAlt from '@material-ui/icons/ListAlt';
 import Menu from '@material-ui/icons/Menu';
 import MoreVert from '@material-ui/icons/MoreVert';
 import Mail from '@material-ui/icons/Mail';
@@ -54,6 +55,7 @@ export type IconType = React.SFC<{ className?: string, style?: object }>;
 export const AddIcon: IconType = (props) => <Add {...props} />;
 export const AddFavoriteIcon: IconType = (props) => <StarBorder {...props} />;
 export const AdvancedIcon: IconType = (props) => <SettingsApplications {...props} />;
+export const AttributesIcon: IconType = (props) => <ListAlt {...props} />;
 export const BackIcon: IconType = (props) => <ArrowBack {...props} />;
 export const CustomizeTableIcon: IconType = (props) => <Menu {...props} />;
 export const CommandIcon: IconType = (props) => <LastPage {...props} />;
index efe3a576df3a7ca03bcade88819442f90c0a8663..922720a411c29bf9eccd9b17b766605f8dc89f31 100644 (file)
@@ -48,6 +48,7 @@ import { getBuildInfo } from '~/common/app-info';
 import { DragDropContextProvider } from 'react-dnd';
 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';
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -64,6 +65,7 @@ addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet)
 addMenuActionSet(ContextMenuKind.PROCESS, processActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet);
 addMenuActionSet(ContextMenuKind.TRASH, trashActionSet);
+addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
 
 fetchConfig()
     .then(({ config, apiHost }) => {
diff --git a/src/models/repositories.ts b/src/models/repositories.ts
new file mode 100644 (file)
index 0000000..02b99be
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from "~/models/resource";
+
+export interface RepositoryResource extends Resource {
+    name: string;
+    cloneUrls: string[];
+}
index f200713e6a6ceb4eea96ccec749c1c9534d79922..d2f524fc51d47a4537fd624fd47c9c9952e50b05 100644 (file)
@@ -28,6 +28,7 @@ export enum ResourceKind {
     LOG = "arvados#log",
     PROCESS = "arvados#containerRequest",
     PROJECT = "arvados#group",
+    REPOSITORY = "arvados#repository",
     USER = "arvados#user",
     VIRTUAL_MACHINE = "arvados#virtualMachine",
     WORKFLOW = "arvados#workflow",
@@ -40,6 +41,7 @@ export enum ResourceObjectType {
     CONTAINER_REQUEST = 'xvhdp',
     GROUP = 'j7d0g',
     LOG = '57u5n',
+    REPOSITORY = 's0uqq',
     USER = 'tpzed',
     VIRTUAL_MACHINE = '2x53u',
     WORKFLOW = '7fd4e',
@@ -77,6 +79,8 @@ export const extractUuidKind = (uuid: string = '') => {
             return ResourceKind.WORKFLOW;
         case ResourceObjectType.VIRTUAL_MACHINE:
             return ResourceKind.VIRTUAL_MACHINE;
+        case ResourceObjectType.REPOSITORY:
+            return ResourceKind.REPOSITORY;
         default:
             return undefined;
     }
diff --git a/src/models/ssh-key.ts b/src/models/ssh-key.ts
new file mode 100644 (file)
index 0000000..8ccbd92
--- /dev/null
@@ -0,0 +1,17 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Resource } from '~/models/resource';
+
+export enum KeyType {
+    SSH = 'SSH'
+}
+
+export interface SshKeyResource extends Resource {
+    name: string;
+    keyType: KeyType;
+    authorizedUserUuid: string;
+    publicKey: string;
+    expiresAt: string;
+}
\ No newline at end of file
index 54b11d47aadd2795cc32f8ce1f2ef095270d9b8e..3c7fdca9afdee6357b204ac4f4edad199155d79b 100644 (file)
@@ -18,6 +18,14 @@ describe('Tree', () => {
         expect(Tree.getNode('Node 1')(newTree)).toEqual(initTreeNode({ id: 'Node 1', value: 'Value 1' }));
     });
 
+    it('appends a subtree', () => {
+        const newTree = Tree.setNode(initTreeNode({ id: 'Node 1', value: 'Value 1' }))(tree);
+        const subtree = Tree.setNode(initTreeNode({ id: 'Node 2', value: 'Value 2' }))(Tree.createTree());
+        const mergedTree = Tree.appendSubtree('Node 1', subtree)(newTree);
+        expect(Tree.getNode('Node 1')(mergedTree)).toBeDefined();
+        expect(Tree.getNode('Node 2')(mergedTree)).toBeDefined();
+    });
+
     it('adds new node reference to parent children', () => {
         const newTree = pipe(
             Tree.setNode(initTreeNode({ id: 'Node 1', parent: '', value: 'Value 1' })),
@@ -89,6 +97,6 @@ describe('Tree', () => {
             initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 'Value 2' }),
         ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
         const mappedTree = Tree.mapTreeValues<string, number>(value => parseInt(value.split(' ')[1], 10))(newTree);
-        expect(Tree.getNode('Node 2')(mappedTree)).toEqual(initTreeNode({id: 'Node 2', parent: 'Node 1', value: 2 }));
+        expect(Tree.getNode('Node 2')(mappedTree)).toEqual(initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 2 }));
     });
 });
index 8e18f9fab78cd9e9ee27a2a9345aa1e5b223cd58..fe52a97b0fcdd3806579318d968f2efc8206f364 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { pipe } from 'lodash/fp';
+import { pipe, map, reduce } from 'lodash/fp';
 export type Tree<T> = Record<string, TreeNode<T>>;
 
 export const TREE_ROOT_ID = '';
@@ -34,6 +34,13 @@ export const createTree = <T>(): Tree<T> => ({});
 
 export const getNode = (id: string) => <T>(tree: Tree<T>): TreeNode<T> | undefined => tree[id];
 
+export const appendSubtree = <T>(id: string, subtree: Tree<T>) => (tree: Tree<T>) =>
+    pipe(
+        getNodeDescendants(''),
+        map(node => node.parent === '' ? { ...node, parent: id } : node),
+        reduce((newTree, node) => setNode(node)(newTree), tree)
+    )(subtree) as Tree<T>;
+
 export const setNode = <T>(node: TreeNode<T>) => (tree: Tree<T>): Tree<T> => {
     return pipe(
         (tree: Tree<T>) => getNode(node.id)(tree) === node
index ca15a150aa626d8f3d1b3322448b093eb8ba60e2..22d0b7c711364d2a466b8b0af5e2b7d465cb73aa 100644 (file)
@@ -4,8 +4,8 @@
 
 import { History, Location } from 'history';
 import { RootStore } from '~/store/store';
-import { matchProcessRoute, matchProcessLogRoute, matchProjectRoute, matchCollectionRoute, matchFavoritesRoute, matchTrashRoute, matchRootRoute, matchSharedWithMeRoute, matchRunProcessRoute, matchWorkflowRoute, matchSearchResultsRoute, matchVirtualMachineRoute } from './routes';
-import { loadProject, loadCollection, loadFavorites, loadTrash, loadProcess, loadProcessLog, loadVirtualMachines } 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';
 
@@ -23,11 +23,13 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     const trashMatch = matchTrashRoute(pathname);
     const processMatch = matchProcessRoute(pathname);
     const processLogMatch = matchProcessLogRoute(pathname);
+    const repositoryMatch = matchRepositoriesRoute(pathname); 
     const searchResultsMatch = matchSearchResultsRoute(pathname);
     const sharedWithMeMatch = matchSharedWithMeRoute(pathname);
     const runProcessMatch = matchRunProcessRoute(pathname);
     const virtualMachineMatch = matchVirtualMachineRoute(pathname);
     const workflowMatch = matchWorkflowRoute(pathname);
+    const sshKeysMatch = matchSshKeysRoute(pathname);
 
     if (projectMatch) {
         store.dispatch(loadProject(projectMatch.params.id));
@@ -53,5 +55,9 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(loadSearchResults);
     } else if (virtualMachineMatch) {
         store.dispatch(loadVirtualMachines);
+    } else if(repositoryMatch) {
+        store.dispatch(loadRepositories);
+    } else if (sshKeysMatch) {
+        store.dispatch(loadSshKeys);
     }
 };
index 3723847ca8b3ed5cc9632ae547e44f3f3602be91..71cdfdacad218da9ea51cf636ffacd2cf95916ad 100644 (file)
@@ -16,11 +16,13 @@ export const Routes = {
     FAVORITES: '/favorites',
     TRASH: '/trash',
     PROCESS_LOGS: `/process-logs/:id(${RESOURCE_UUID_PATTERN})`,
+    REPOSITORIES: '/repositories',
     SHARED_WITH_ME: '/shared-with-me',
     RUN_PROCESS: '/run-process',
     VIRTUAL_MACHINES: '/virtual-machines',
     WORKFLOWS: '/workflows',
-    SEARCH_RESULTS: '/search-results'
+    SEARCH_RESULTS: '/search-results',
+    SSH_KEYS: `/ssh-keys`
 };
 
 export const getResourceUrl = (uuid: string) => {
@@ -80,3 +82,9 @@ export const matchSearchResultsRoute = (route: string) =>
 
 export const matchVirtualMachineRoute = (route: string) =>
     matchPath<ResourceRouteParams>(route, { path: Routes.VIRTUAL_MACHINES });
+    
+export const matchRepositoriesRoute = (route: string) =>
+    matchPath<ResourceRouteParams>(route, { path: Routes.REPOSITORIES });
+    
+export const matchSshKeysRoute = (route: string) =>
+    matchPath(route, { path: Routes.SSH_KEYS });
diff --git a/src/services/authorized-keys-service/authorized-keys-service.ts b/src/services/authorized-keys-service/authorized-keys-service.ts
new file mode 100644 (file)
index 0000000..c952f42
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { AxiosInstance } from "axios";
+import { SshKeyResource } from '~/models/ssh-key';
+import { CommonResourceService, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
+import { ApiActions } from "~/services/api/api-actions";
+
+export enum AuthorizedKeysServiceError {
+    UNIQUE_PUBLIC_KEY = 'UniquePublicKey',
+    INVALID_PUBLIC_KEY = 'InvalidPublicKey',
+}
+
+export class AuthorizedKeysService extends CommonResourceService<SshKeyResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "authorized_keys", actions);
+    }
+}
+
+export const getAuthorizedKeysServiceError = (errorResponse: any) => {
+    if ('errors' in errorResponse && 'errorToken' in errorResponse) {
+        const error = errorResponse.errors.join('');
+        switch (true) {
+            case /Public key does not appear to be a valid ssh-rsa or dsa public key/.test(error):
+                return AuthorizedKeysServiceError.INVALID_PUBLIC_KEY;
+            case /Public key already exists in the database, use a different key./.test(error):
+                return AuthorizedKeysServiceError.UNIQUE_PUBLIC_KEY;
+            default:
+                return CommonResourceServiceError.UNKNOWN;
+        }
+    }
+    return CommonResourceServiceError.NONE;
+};
\ No newline at end of file
index 00ba85478bedcac9665a64087b913e3ef3a11e5d..b0d5cb1445db854e8d88d43e10c95a6b59e80569 100644 (file)
@@ -62,7 +62,6 @@ export class CollectionService extends TrashableResourceService<CollectionResour
 
     private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }) {
         const fileURL = `c=${collectionUuid}/${file.name}`;
-        const fileContent = await fileToArrayBuffer(file);
         const requestConfig = {
             headers: {
                 'Content-Type': 'text/octet-stream'
@@ -71,8 +70,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
                 onProgress(fileId, e.loaded, e.total, Date.now());
             }
         };
-        return this.webdavClient.put(fileURL, fileContent, requestConfig);
-
+        return this.webdavClient.upload(fileURL, '', [file], requestConfig);
     }
 
     update(uuid: string, data: Partial<CollectionResource>) {
index 70c1df0e2bc5e665a3c62c05b39b7a50a6705c62..6114c560526e7b598c033b0a9c6ae4435bd10047 100644 (file)
@@ -35,6 +35,7 @@ export enum CommonResourceServiceError {
     UNIQUE_VIOLATION = 'UniqueViolation',
     OWNERSHIP_CYCLE = 'OwnershipCycle',
     MODIFYING_CONTAINER_REQUEST_FINAL_STATE = 'ModifyingContainerRequestFinalState',
+    NAME_HAS_ALREADY_BEEN_TAKEN = 'NameHasAlreadyBeenTaken',
     UNKNOWN = 'Unknown',
     NONE = 'None'
 }
@@ -150,6 +151,8 @@ export const getCommonResourceServiceError = (errorResponse: any) => {
                 return CommonResourceServiceError.OWNERSHIP_CYCLE;
             case /Mounts cannot be modified in state 'Final'/.test(error):
                 return CommonResourceServiceError.MODIFYING_CONTAINER_REQUEST_FINAL_STATE;
+            case /Name has already been taken/.test(error):
+                return CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN;
             default:
                 return CommonResourceServiceError.UNKNOWN;
         }
index bdb51198c32088510ad331a09dd3818fb927dcd7..d1e2eff2791c101ef7fd21b2b52ff6e6fb0ed438 100644 (file)
@@ -59,7 +59,7 @@ export class GroupsService<T extends GroupResource = GroupResource> extends Tras
         const { items, ...res } = response;
         const mappedItems = items.map((item: GroupContentsResource) => {
             const mappedItem = TrashableResourceService.mapKeys(_.camelCase)(item);
-            if (item.kind === ResourceKind.COLLECTION) {
+            if (item.kind === ResourceKind.COLLECTION || item.kind === ResourceKind.PROJECT) {
                 const { properties } = item;
                 return { ...mappedItem, properties };
             } else {
index 2dc3eeb0a87f61ee5af40454802d4d8b09e9e860..d60034711ed8402e654c3fa43494c963952a0a4a 100644 (file)
@@ -7,7 +7,8 @@ import { ProjectResource } from "~/models/project";
 import { GroupClass } from "~/models/group";
 import { ListArguments } from "~/services/common-service/common-resource-service";
 import { FilterBuilder, joinFilters } from "~/services/api/filter-builder";
-
+import { TrashableResourceService } from '~/services/common-service/trashable-resource-service';
+import { snakeCase } from 'lodash';
 export class ProjectService extends GroupsService<ProjectResource> {
 
     create(data: Partial<ProjectResource>) {
@@ -15,6 +16,29 @@ export class ProjectService extends GroupsService<ProjectResource> {
         return super.create(projectData);
     }
 
+    update(uuid: string, data: Partial<ProjectResource>) {
+        if (uuid && data && data.properties) {
+            const { properties } = data;
+            const mappedData = {
+                ...TrashableResourceService.mapKeys(snakeCase)(data),
+                properties,
+            };
+            return TrashableResourceService
+                .defaultResponse(
+                    this.serverApi
+                        .put<ProjectResource>(this.resourceType + uuid, mappedData),
+                    this.actions,
+                    false
+                );
+        }
+        return TrashableResourceService
+            .defaultResponse(
+                this.serverApi
+                    .put<ProjectResource>(this.resourceType + uuid, data && TrashableResourceService.mapKeys(snakeCase)(data)),
+                this.actions
+            );
+    }
+
     list(args: ListArguments = {}) {
         return super.list({
             ...args,
diff --git a/src/services/repositories-service/repositories-service.ts b/src/services/repositories-service/repositories-service.ts
new file mode 100644 (file)
index 0000000..34f7f3f
--- /dev/null
@@ -0,0 +1,22 @@
+// 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 { RepositoryResource } from '~/models/repositories';
+import { ApiActions } from '~/services/api/api-actions';
+
+ export class RepositoriesService extends CommonResourceService<RepositoryResource> {
+    constructor(serverApi: AxiosInstance, actions: ApiActions) {
+        super(serverApi, "repositories", actions);
+    }
+
+     getAllPermissions() {
+        return CommonResourceService.defaultResponse(
+            this.serverApi
+                .get('repositories/get_all_permissions'),
+            this.actions
+        );
+    }
+} 
\ No newline at end of file
index 9e9fcc59d3d72201b26cb2f5614c7349b244997c..f1ef86b88555a7760a43d90178b8a7a36d796285 100644 (file)
@@ -25,6 +25,8 @@ 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';
 
 export type ServiceRepository = ReturnType<typeof createServices>;
 
@@ -35,6 +37,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const webdavClient = new WebDAV();
     webdavClient.defaults.baseURL = config.keepWebServiceUrl;
 
+    const authorizedKeysService = new AuthorizedKeysService(apiClient, actions);
     const containerRequestService = new ContainerRequestService(apiClient, actions);
     const containerService = new ContainerService(apiClient, actions);
     const groupsService = new GroupsService(apiClient, actions);
@@ -43,6 +46,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
     const logService = new LogService(apiClient, actions);
     const permissionService = new PermissionService(apiClient, actions);
     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);
@@ -59,6 +63,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
         ancestorsService,
         apiClient,
         authService,
+        authorizedKeysService,
         collectionFilesService,
         collectionService,
         containerRequestService,
@@ -70,6 +75,7 @@ export const createServices = (config: Config, actions: ApiActions) => {
         logService,
         permissionService,
         projectService,
+        repositoriesService,
         searchService,
         tagService,
         userService,
index ba0cf77db75e6fc2bcacc8183e5bff2d551e902e..6ad8af22a67ac8295d32793c276137fd2e6a4d98 100644 (file)
@@ -14,6 +14,7 @@ import { CollectionResource } from '~/models/collection';
 import { ProjectResource } from '~/models/project';
 import { ServiceRepository } from '~/services/services';
 import { FilterBuilder } from '~/services/api/filter-builder';
+import { RepositoryResource } from '~/models/repositories';
 
 export const ADVANCED_TAB_DIALOG = 'advancedTabDialog';
 
@@ -46,34 +47,46 @@ enum ProjectData {
     DELETE_AT = 'delete_at'
 }
 
-export const openAdvancedTabDialog = (uuid: string) =>
+enum RepositoryData {
+    REPOSITORY = 'repository',
+    CREATED_AT = 'created_at'
+}
+
+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 user = await services.userService.get(data.ownerUuid);
-        const metadata = await services.linkService.list({
-            filters: new FilterBuilder()
-                .addEqual('headUuid', uuid)
-                .getFilters()
-        });
-        if (data) {
-            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 }));
+        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 }));
         }
     };
 
-const advancedTabData = (uuid: string, metadata: any, user: any, apiResponseKind: any, data: any, resourceKind: CollectionData | ProcessData | ProjectData, resourcePrefix: GroupContentsResourcePrefix, resourceKindProperty: CollectionData | ProcessData | ProjectData, property: any) => {
+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) => {
     return {
         uuid,
         user,
@@ -82,9 +95,9 @@ const advancedTabData = (uuid: string, metadata: any, user: any, apiResponseKind
         pythonHeader: pythonHeader(resourceKind),
         pythonExample: pythonExample(uuid, resourcePrefix),
         cliGetHeader: cliGetHeader(resourceKind),
-        cliGetExample: cliGetExample(uuid, resourcePrefix),
+        cliGetExample: cliGetExample(uuid, resourceKind),
         cliUpdateHeader: cliUpdateHeader(resourceKind, resourceKindProperty),
-        cliUpdateExample: cliUpdateExample(uuid, resourceKind, property, resourceKind),
+        cliUpdateExample: cliUpdateExample(uuid, resourceKind, property, resourceKindProperty),
         curlHeader: curlHeader(resourceKind, resourceKindProperty),
         curlExample: curlExample(uuid, resourcePrefix, property, resourceKind, resourceKindProperty),
     };
@@ -104,8 +117,8 @@ const pythonExample = (uuid: string, resourcePrefix: string) => {
 const cliGetHeader = (resourceKind: string) =>
     `An example arv command to get a ${resourceKind} using its uuid:`;
 
-const cliGetExample = (uuid: string, resourcePrefix: string) => {
-    const cliGetExample = `arv ${resourcePrefix} get \\
+const cliGetExample = (uuid: string, resourceKind: string) => {
+    const cliGetExample = `arv ${resourceKind} get \\
  --uuid ${uuid}`;
 
     return cliGetExample;
@@ -227,5 +240,18 @@ const groupRequestApiResponse = (apiResponse: ProjectResource) => {
 "delete_at": ${stringify(deleteAt)},
 "properties": ${stringifyObject(properties)}`;
 
+    return response;
+};
+
+const repositoryApiResponse = (apiResponse: RepositoryResource) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name } = apiResponse;
+    const response = `"uuid": "${uuid}",
+"owner_uuid": "${ownerUuid}",
+"modified_by_client_uuid": ${stringify(modifiedByClientUuid)},
+"modified_by_user_uuid": ${stringify(modifiedByUserUuid)},
+"modified_at": ${stringify(modifiedAt)},
+"name": ${stringify(name)},
+"created_at": "${createdAt}"`;
+
     return response;
 };
\ No newline at end of file
index ac2e0b7e2f68c6e699e294710d582582701b5fc2..3658c589b6a4814f8dddc359494e744c1b0cf77c 100644 (file)
@@ -4,10 +4,16 @@
 
 import { ofType, unionize, UnionOf } from '~/common/unionize';
 import { Dispatch } from "redux";
-import { User } from "~/models/user";
+import { reset, stopSubmit } from 'redux-form';
+import { AxiosInstance } from "axios";
 import { RootState } from "../store";
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
 import { ServiceRepository } from "~/services/services";
-import { AxiosInstance } from "axios";
+import { getAuthorizedKeysServiceError, AuthorizedKeysServiceError } from '~/services/authorized-keys-service/authorized-keys-service';
+import { KeyType, SshKeyResource } from '~/models/ssh-key';
+import { User } from "~/models/user";
 
 export const authActions = unionize({
     SAVE_API_TOKEN: ofType<string>(),
@@ -15,9 +21,18 @@ export const authActions = unionize({
     LOGOUT: {},
     INIT: ofType<{ user: User, token: string }>(),
     USER_DETAILS_REQUEST: {},
-    USER_DETAILS_SUCCESS: ofType<User>()
+    USER_DETAILS_SUCCESS: ofType<User>(),
+    SET_SSH_KEYS: ofType<SshKeyResource[]>(),
+    ADD_SSH_KEY: ofType<SshKeyResource>()
 });
 
+export const SSH_KEY_CREATE_FORM_NAME = 'sshKeyCreateFormName';
+
+export interface SshKeyCreateFormDialogData {
+    publicKey: string;
+    name: string;
+}
+
 function setAuthorizationHeader(services: ServiceRepository, token: string) {
     services.apiClient.defaults.headers.common = {
         Authorization: `OAuth2 ${token}`
@@ -70,4 +85,46 @@ export const getUserDetails = () => (dispatch: Dispatch, getState: () => RootSta
     });
 };
 
+export const openSshKeyCreateDialog = () => dialogActions.OPEN_DIALOG({ id: SSH_KEY_CREATE_FORM_NAME, data: {} });
+
+export const createSshKey = (data: SshKeyCreateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const userUuid = getState().auth.user!.uuid;
+            const { name, publicKey } = data;
+            const newSshKey = await services.authorizedKeysService.create({
+                name, 
+                publicKey,
+                keyType: KeyType.SSH,
+                authorizedUserUuid: userUuid
+            });
+            dispatch(dialogActions.CLOSE_DIALOG({ id: SSH_KEY_CREATE_FORM_NAME }));
+            dispatch(reset(SSH_KEY_CREATE_FORM_NAME));
+            dispatch(authActions.ADD_SSH_KEY(newSshKey));
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Public key has been successfully created.",
+                hideDuration: 2000
+            }));
+        } catch (e) {
+            const error = getAuthorizedKeysServiceError(e);
+            if (error === AuthorizedKeysServiceError.UNIQUE_PUBLIC_KEY) {
+                dispatch(stopSubmit(SSH_KEY_CREATE_FORM_NAME, { publicKey: 'Public key already exists.' }));
+            } else if (error === AuthorizedKeysServiceError.INVALID_PUBLIC_KEY) {
+                dispatch(stopSubmit(SSH_KEY_CREATE_FORM_NAME, { publicKey: 'Public key is invalid' }));
+            }
+        }
+    };
+
+export const loadSshKeysPanel = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            dispatch(setBreadcrumbs([{ label: 'SSH Keys'}]));
+            const response = await services.authorizedKeysService.list();
+            dispatch(authActions.SET_SSH_KEYS(response.items));
+        } catch (e) {
+            return;
+        }
+    };
+
+
 export type AuthAction = UnionOf<typeof authActions>;
index 1202bacb125b5e1afc61908cb64f9953946b41f6..25ce2c1122d7b9688eb71193ddb1be1ce3ec5649 100644 (file)
@@ -44,7 +44,8 @@ describe('auth-reducer', () => {
         const state = reducer(initialState, authActions.SAVE_API_TOKEN("token"));
         expect(state).toEqual({
             apiToken: "token",
-            user: undefined
+            user: undefined,
+            sshKeys: []
         });
     });
 
@@ -62,6 +63,7 @@ describe('auth-reducer', () => {
         const state = reducer(initialState, authActions.USER_DETAILS_SUCCESS(user));
         expect(state).toEqual({
             apiToken: undefined,
+            sshKeys: [],
             user: {
                 email: "test@test.com",
                 firstName: "John",
index a4195322c867316ce201f8d03ea4c28bffd25825..8f234dad35bf8a9d68f508ab47d4a1166eea454e 100644 (file)
@@ -5,13 +5,21 @@
 import { authActions, AuthAction } from "./auth-action";
 import { User } from "~/models/user";
 import { ServiceRepository } from "~/services/services";
+import { SshKeyResource } from '~/models/ssh-key';
 
 export interface AuthState {
     user?: User;
     apiToken?: string;
+    sshKeys?: SshKeyResource[];
 }
 
-export const authReducer = (services: ServiceRepository) => (state: AuthState = {}, action: AuthAction) => {
+const initialState: AuthState = {
+    user: undefined,
+    apiToken: undefined,
+    sshKeys: []
+};
+
+export const authReducer = (services: ServiceRepository) => (state: AuthState = initialState, action: AuthAction) => {
     return authActions.match(action, {
         SAVE_API_TOKEN: (token: string) => {
             return {...state, apiToken: token};
@@ -28,6 +36,12 @@ export const authReducer = (services: ServiceRepository) => (state: AuthState =
         USER_DETAILS_SUCCESS: (user: User) => {
             return {...state, user};
         },
+        SET_SSH_KEYS: (sshKeys: SshKeyResource[]) => {
+            return {...state, sshKeys};
+        },
+        ADD_SSH_KEY: (sshKey: SshKeyResource) => {
+            return { ...state, sshKeys: state.sshKeys!.concat(sshKey) };
+        },
         default: () => state
     });
 };
index 4764d436e022e532b3d3d1b09039bec23e72940c..3f82d29e921e670fdf4f3b453290c9ec32de5c01 100644 (file)
@@ -7,7 +7,7 @@ import { Dispatch } from "redux";
 import { CollectionFilesTree, CollectionFileType } from "~/models/collection-file";
 import { ServiceRepository } from "~/services/services";
 import { RootState } from "../../store";
-import { snackbarActions } from "../../snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind } from "../../snackbar/snackbar-actions";
 import { dialogActions } from '../../dialog/dialog-actions';
 import { getNodeValue } from "~/models/tree";
 import { filterCollectionFilesBySelection } from './collection-panel-files-state';
@@ -37,10 +37,18 @@ export const removeCollectionFiles = (filePaths: string[]) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const currentCollection = getState().collectionPanel.item;
         if (currentCollection) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
-            await services.collectionService.deleteFiles(currentCollection.uuid, filePaths);
-            dispatch<any>(loadCollectionFiles(currentCollection.uuid));
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing...' }));
+            try {
+                await services.collectionService.deleteFiles('', filePaths);
+                dispatch<any>(loadCollectionFiles(currentCollection.uuid));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
+            } catch (e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Could not remove file.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR
+                }));
+            }
         }
     };
 
index d0387609bd79af99e22630f83b218af2c842afbd..e5a6676c6b4dbc789199f589e3a5315ebe2ed199 100644 (file)
@@ -11,12 +11,14 @@ import { ServiceRepository } from '~/services/services';
 import { getCommonResourceServiceError, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
 import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
+import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 
 export const COLLECTION_COPY_FORM_NAME = 'collectionCopyFormName';
 
 export const openCollectionCopyDialog = (resource: { name: string, uuid: string }) =>
     (dispatch: Dispatch) => {
         dispatch<any>(resetPickerProjectTree());
+        dispatch<any>(initProjectsTreePicker(COLLECTION_COPY_FORM_NAME));
         const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: '', uuid: resource.uuid };
         dispatch<any>(initialize(COLLECTION_COPY_FORM_NAME, initialData));
         dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_COPY_FORM_NAME, data: {} }));
index 54508e139f2b2b35bdfb14fb0bdfe805c1c3847e..770eed1a7872f9f3c30ec65f5f091f4ad2a329fb 100644 (file)
@@ -13,12 +13,14 @@ import { projectPanelActions } from '~/store/project-panel/project-panel-action'
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
+import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 
 export const COLLECTION_MOVE_FORM_NAME = 'collectionMoveFormName';
 
 export const openMoveCollectionDialog = (resource: { name: string, uuid: string }) =>
     (dispatch: Dispatch) => {
         dispatch<any>(resetPickerProjectTree());
+        dispatch<any>(initProjectsTreePicker(COLLECTION_MOVE_FORM_NAME));
         dispatch(initialize(COLLECTION_MOVE_FORM_NAME, resource));
         dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MOVE_FORM_NAME, data: {} }));
     };
index 4dac9c7d7e5ce55d4246c885dcb7a707b54e7957..b9ada5ee01fa1014bc1ef2fc5605c0c94ade9ec9 100644 (file)
@@ -12,6 +12,7 @@ import { filterCollectionFilesBySelection } from '../collection-panel/collection
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { getCommonResourceServiceError, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
 import { progressIndicatorActions } from "~/store/progress-indicator/progress-indicator-actions";
+import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 
 export const COLLECTION_PARTIAL_COPY_FORM_NAME = 'COLLECTION_PARTIAL_COPY_DIALOG';
 
@@ -32,6 +33,7 @@ export const openCollectionPartialCopyDialog = () =>
             };
             dispatch(initialize(COLLECTION_PARTIAL_COPY_FORM_NAME, initialData));
             dispatch<any>(resetPickerProjectTree());
+            dispatch<any>(initProjectsTreePicker(COLLECTION_PARTIAL_COPY_FORM_NAME));
             dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_PARTIAL_COPY_FORM_NAME, data: {} }));
         }
     };
index c410cf04920f77c32a187657ac768865e9426a5e..cf8c37c890f6d24c79de79c37ab776f690ca6f61 100644 (file)
@@ -52,7 +52,7 @@ export const submitCollectionFiles = () =>
                     hideDuration: 2000,
                     kind: SnackbarKind.ERROR
                 }));
-                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));                
+                dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_UPLOAD_FILES_DIALOG));
             }
         }
     };
index 32bc47b6b8cd04f6e884614abe1d7fb46bfbe0c5..596ac87b098f89503ce248a9c82786a5f5f6ae78 100644 (file)
@@ -12,8 +12,8 @@ import { ProjectResource } from '~/models/project';
 import { UserResource } from '~/models/user';
 import { isSidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
 import { extractUuidKind, ResourceKind } from '~/models/resource';
-import { matchProcessRoute } from '~/routes/routes';
 import { Process } from '~/store/processes/process';
+import { RepositoryResource } from '~/models/repositories';
 
 export const contextMenuActions = unionize({
     OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
@@ -30,6 +30,7 @@ export type ContextMenuResource = {
     kind: ResourceKind,
     menuKind: ContextMenuKind;
     isTrashed?: boolean;
+    index?: number
 };
 export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) =>
     event.nativeEvent.detail === 0;
@@ -60,6 +61,18 @@ export const openCollectionFilesContextMenu = (event: React.MouseEvent<HTMLEleme
         }));
     };
 
+export const openRepositoryContextMenu = (event: React.MouseEvent<HTMLElement>, index: number, repository: RepositoryResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+            dispatch<any>(openContextMenu(event, {
+                name: '',
+                uuid: repository.uuid,
+                ownerUuid: repository.ownerUuid,
+                kind: ResourceKind.REPOSITORY,
+                menuKind: ContextMenuKind.REPOSITORY,
+                index
+            }));
+    };
+
 export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<UserResource>(projectUuid)(getState().resources);
index 2724a3e3465dbbac374a029f1f68c321dce2a9b1..2c742a1f38a3f4e63698019c092dec34935f1079 100644 (file)
@@ -3,6 +3,18 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from '~/common/unionize';
+import { RootState } from '~/store/store';
+import { Dispatch } from 'redux';
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { getResource } from '~/store/resources/resources';
+import { ProjectResource } from "~/models/project";
+import { ServiceRepository } from '~/services/services';
+import { TagProperty } from '~/models/tag';
+import { startSubmit, stopSubmit } from 'redux-form';
+import { resourcesActions } from '~/store/resources/resources-actions';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+
+export const SLIDE_TIMEOUT = 500;
 
 export const detailsPanelActions = unionize({
     TOGGLE_DETAILS_PANEL: ofType<{}>(),
@@ -11,8 +23,57 @@ export const detailsPanelActions = unionize({
 
 export type DetailsPanelAction = UnionOf<typeof detailsPanelActions>;
 
-export const loadDetailsPanel = (uuid: string) => detailsPanelActions.LOAD_DETAILS_PANEL(uuid);
+export const PROJECT_PROPERTIES_FORM_NAME = 'projectPropertiesFormName';
+export const PROJECT_PROPERTIES_DIALOG_NAME = 'projectPropertiesDialogName';
 
+export const loadDetailsPanel = (uuid: string) => detailsPanelActions.LOAD_DETAILS_PANEL(uuid);
 
+export const openProjectPropertiesDialog = () =>
+    (dispatch: Dispatch) => {
+        dispatch<any>(dialogActions.OPEN_DIALOG({ id: PROJECT_PROPERTIES_DIALOG_NAME, data: { } }));
+    };
 
+export const deleteProjectProperty = (key: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { detailsPanel, resources } = getState();
+        const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
+        try {
+            if (project) {
+                delete project.properties[key];
+                const updatedProject = await services.projectService.update(project.uuid, project);
+                dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully deleted.", hideDuration: 2000 }));
+            }
+        } catch (e) {
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_PROPERTIES_FORM_NAME }));
+            throw new Error('Could not remove property from the project.');
+        }
+    };
 
+export const createProjectProperty = (data: TagProperty) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { detailsPanel, resources } = getState();
+        const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
+        dispatch(startSubmit(PROJECT_PROPERTIES_FORM_NAME));
+        try {
+            if (project) {
+                project.properties[data.key] = data.value;
+                const updatedProject = await services.projectService.update(project.uuid, project);
+                dispatch(resourcesActions.SET_RESOURCES([updatedProject]));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Property has been successfully added.", hideDuration: 2000 }));
+                dispatch(stopSubmit(PROJECT_PROPERTIES_FORM_NAME));
+            }
+            return;
+        } catch (e) {
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_PROPERTIES_FORM_NAME }));
+            throw new Error('Could not add property to the project.');
+        }
+    };
+export const toggleDetailsPanel = () => (dispatch: Dispatch) => {
+    // because of material-ui issue resizing details panel breaks tabs.
+    // triggering window resize event fixes that.
+    setTimeout(() => {
+        window.dispatchEvent(new Event('resize'));
+    }, SLIDE_TIMEOUT);
+    dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+};
index c4cf625271df1820e4742f92b3d92b31c005cce2..2bfd8b9944ec75e4911ab2bbd08c19676cab62c5 100644 (file)
@@ -63,3 +63,7 @@ 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 bb8d8f5aeca95d9fd1c27de94817e99cc7251cc3..cd3fe21c28abb97d96334aa2c562202ba8202560 100644 (file)
@@ -11,6 +11,7 @@ import { ServiceRepository } from '~/services/services';
 import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
 import { getProcess, ProcessStatus, getProcessStatus } from '~/store/processes/process';
 import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 
 export const PROCESS_COPY_FORM_NAME = 'processCopyFormName';
 
@@ -21,6 +22,7 @@ export const openCopyProcessDialog = (resource: { name: string, uuid: string })
             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: {} }));
index 6df826992861cc965abef5c430bd780a22d11164..edba5a8574e16814c6a23988d85a1d4657d431b5 100644 (file)
@@ -13,6 +13,7 @@ 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 { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 
 export const PROCESS_MOVE_FORM_NAME = 'processMoveFormName';
 
@@ -23,6 +24,7 @@ export const openMoveProcessDialog = (resource: { name: string, uuid: string })
             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 {
index c251bdf8f8724d3a6fd7969dce4aae0b011d1ab2..cacd49e68f8f8d5699d807bf1c2df286863719c6 100644 (file)
@@ -10,12 +10,14 @@ import { RootState } from '~/store/store';
 import { getCommonResourceServiceError, CommonResourceServiceError } from "~/services/common-service/common-resource-service";
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { resetPickerProjectTree } from '~/store/project-tree-picker/project-tree-picker-actions';
+import { initProjectsTreePicker } from '~/store/tree-picker/tree-picker-actions';
 
 export const PROJECT_MOVE_FORM_NAME = 'projectMoveFormName';
 
 export const openMoveProjectDialog = (resource: { name: string, uuid: string }) =>
     (dispatch: Dispatch) => {
         dispatch<any>(resetPickerProjectTree());
+        dispatch<any>(initProjectsTreePicker(PROJECT_MOVE_FORM_NAME));
         dispatch(initialize(PROJECT_MOVE_FORM_NAME, resource));
         dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_MOVE_FORM_NAME, data: {} }));
     };
diff --git a/src/store/repositories/repositories-actions.ts b/src/store/repositories/repositories-actions.ts
new file mode 100644 (file)
index 0000000..a672738
--- /dev/null
@@ -0,0 +1,107 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action';
+import { RootState } from '~/store/store';
+import { ServiceRepository } from "~/services/services";
+import { navigateToRepositories } from "~/store/navigation/navigation-action";
+import { unionize, ofType, UnionOf } from "~/common/unionize";
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { RepositoryResource } from "~/models/repositories";
+import { startSubmit, reset, stopSubmit } from "redux-form";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "~/services/common-service/common-resource-service";
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+
+export const repositoriesActions = unionize({
+    SET_REPOSITORIES: ofType<any>(),
+});
+
+export type RepositoriesActions = UnionOf<typeof repositoriesActions>;
+
+export const REPOSITORIES_PANEL = 'repositoriesPanel';
+export const REPOSITORIES_SAMPLE_GIT_DIALOG = 'repositoriesSampleGitDialog';
+export const REPOSITORY_ATTRIBUTES_DIALOG = 'repositoryAttributesDialog';
+export const REPOSITORY_CREATE_FORM_NAME = 'repositoryCreateFormName';
+export const REPOSITORY_REMOVE_DIALOG = 'repositoryRemoveDialog';
+
+export const openRepositoriesSampleGitDialog = () =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuidPrefix = getState().properties.uuidPrefix;
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORIES_SAMPLE_GIT_DIALOG, data: { uuidPrefix } }));
+    };
+
+export const openRepositoryAttributes = (index: number) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const repositoryData = getState().repositories.items[index];
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORY_ATTRIBUTES_DIALOG, data: { repositoryData } }));
+    };
+
+export const openRepositoryCreateDialog = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = await services.authService.getUuid();
+        const user = await services.userService.get(userUuid!);
+        dispatch(reset(REPOSITORY_CREATE_FORM_NAME));
+        dispatch(dialogActions.OPEN_DIALOG({ id: REPOSITORY_CREATE_FORM_NAME, data: { user } }));
+    };
+
+export const createRepository = (repository: RepositoryResource) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = await services.authService.getUuid();
+        const user = await services.userService.get(userUuid!);
+        dispatch(startSubmit(REPOSITORY_CREATE_FORM_NAME));
+        try {
+            const newRepository = await services.repositoriesService.create({ name: `${user.username}/${repository.name}` });
+            dispatch(dialogActions.CLOSE_DIALOG({ id: REPOSITORY_CREATE_FORM_NAME }));
+            dispatch(reset(REPOSITORY_CREATE_FORM_NAME));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Repository has been successfully created.", hideDuration: 2000, kind: SnackbarKind.SUCCESS })); 
+            dispatch<any>(loadRepositoriesData());     
+            return newRepository;
+        } catch (e) {
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.NAME_HAS_ALREADY_BEEN_TAKEN) {
+                dispatch(stopSubmit(REPOSITORY_CREATE_FORM_NAME, { name: 'Repository with the same name already exists.' }));
+            }
+            return undefined;
+        }
+    };
+
+export const openRemoveRepositoryDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: REPOSITORY_REMOVE_DIALOG,
+            data: {
+                title: 'Remove repository',
+                text: 'Are you sure you want to remove this repository?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeRepository = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+        await services.repositoriesService.delete(uuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000 }));
+        dispatch<any>(loadRepositoriesData());
+    };
+
+const repositoriesBindedActions = bindDataExplorerActions(REPOSITORIES_PANEL);
+
+export const openRepositoriesPanel = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<any>(navigateToRepositories);
+    };
+
+export const loadRepositoriesData = () =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const repositories = await services.repositoriesService.list();
+        dispatch(repositoriesActions.SET_REPOSITORIES(repositories.items));
+    };
+
+export const loadRepositoriesPanel = () =>
+    (dispatch: Dispatch) => {
+        dispatch(repositoriesBindedActions.REQUEST_ITEMS());
+    };
\ No newline at end of file
diff --git a/src/store/repositories/repositories-reducer.ts b/src/store/repositories/repositories-reducer.ts
new file mode 100644 (file)
index 0000000..3ef8289
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { repositoriesActions, RepositoriesActions } from '~/store/repositories/repositories-actions';
+import { RepositoryResource } from '~/models/repositories';
+
+interface Repositories {
+    items: RepositoryResource[];
+}
+
+const initialState: Repositories = {
+    items: []
+};
+
+export const repositoriesReducer = (state = initialState, action: RepositoriesActions): Repositories =>
+    repositoriesActions.match(action, {
+        SET_REPOSITORIES: items => ({ ...state, items }),
+        default: () => state
+    });
\ No newline at end of file
index 314b76214c8501141aa9ca9ab592555dc7fe1e6b..a21f7c04bf90b9b55462dc57e7eb2c0e31a48e1a 100644 (file)
@@ -57,7 +57,7 @@ export const openSetWorkflowDialog = (workflow: WorkflowResource) =>
             dispatch(dialogActions.OPEN_DIALOG({
                 id: SET_WORKFLOW_DIALOG,
                 data: {
-                    title: 'Data loss warning',
+                    title: 'Form will be cleared',
                     text: 'Changing a workflow will clean all input fields in next step.',
                     confirmButtonLabel: 'Change Workflow',
                     workflow
index 3f1f4a25b4ed4ce6e57b3ecdeb05117c45fe5915..4ab0918e60fb0ac6a636d03e4f95d0d9d3c6e468 100644 (file)
@@ -44,6 +44,7 @@ import { SEARCH_RESULTS_PANEL_ID } from '~/store/search-results-panel/search-res
 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 =
     (process.env.NODE_ENV === 'development' &&
@@ -113,5 +114,6 @@ const createRootReducer = (services: ServiceRepository) => combineReducers({
     runProcessPanel: runProcessPanelReducer,
     appInfo: appInfoReducer,
     searchBar: searchBarReducer,
-    virtualMachines: virtualMachinesReducer
+    virtualMachines: virtualMachinesReducer,
+    repositories: repositoriesReducer
 });
diff --git a/src/store/tree-picker/picker-id.tsx b/src/store/tree-picker/picker-id.tsx
new file mode 100644 (file)
index 0000000..3907ba8
--- /dev/null
@@ -0,0 +1,16 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+
+export interface PickerIdProp {
+    pickerId: string;
+}
+
+export const pickerId =
+    (id: string) =>
+        <P extends PickerIdProp>(Component: React.ComponentType<P>) =>
+            (props: P) =>
+                <Component {...props} pickerId={id} />;
+                
\ No newline at end of file
index ffc49bc28980a7f70d47e4cc400bb424aed755f7..657d65b75f71aea7217bf488d51a76a85765ec1e 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "~/common/unionize";
-import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId } from '~/models/tree';
+import { TreeNode, initTreeNode, getNodeDescendants, TreeNodeStatus, getNode, TreePickerId, Tree } from '~/models/tree';
 import { Dispatch } from 'redux';
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
@@ -11,15 +11,16 @@ import { FilterBuilder } from '~/services/api/filter-builder';
 import { pipe, values } from 'lodash/fp';
 import { ResourceKind } from '~/models/resource';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
-import { CollectionDirectory, CollectionFile } from '~/models/collection-file';
 import { getTreePicker, TreePicker } from './tree-picker';
 import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
 import { OrderBuilder } from '~/services/api/order-builder';
 import { ProjectResource } from '~/models/project';
+import { mapTree } from '../../models/tree';
 
 export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
     LOAD_TREE_PICKER_NODE_SUCCESS: ofType<{ id: string, nodes: Array<TreeNode<any>>, pickerId: string }>(),
+    APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
     ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
     DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
@@ -60,7 +61,7 @@ export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Va
     )();
 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
     getAllNodes<Value>(pickerId, node => node.selected)(state);
-    
+
 export const initProjectsTreePicker = (pickerId: string) =>
     async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
         const { home, shared, favorites } = getProjectsTreePickerIds(pickerId);
@@ -135,19 +136,16 @@ export const loadCollection = (id: string, pickerId: string) =>
             const node = getNode(id)(picker);
             if (node && 'kind' in node.value && node.value.kind === ResourceKind.COLLECTION) {
 
-                const files = await services.collectionService.files(node.value.portableDataHash);
-                const data = getNodeDescendants('')(files).map(node => node.value);
-
-                dispatch<any>(receiveTreePickerData<CollectionDirectory | CollectionFile>({
-                    id,
-                    pickerId,
-                    data,
-                    extractNodeData: value => ({
-                        id: value.id,
-                        status: TreeNodeStatus.LOADED,
-                        value,
-                    }),
-                }));
+                const filesTree = await services.collectionService.files(node.value.portableDataHash);
+
+                dispatch(
+                    treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
+                        id,
+                        pickerId,
+                        subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
+                    }));
+
+                dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
             }
         }
     };
@@ -177,13 +175,13 @@ export const loadUserProject = (pickerId: string, includeCollections = false, in
         }
     };
 
-
+export const SHARED_PROJECT_ID = 'Shared with me';
 export const initSharedProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         dispatch(receiveTreePickerData({
             id: '',
             pickerId,
-            data: [{ uuid: 'Shared with me', name: 'Shared with me' }],
+            data: [{ uuid: SHARED_PROJECT_ID, name: SHARED_PROJECT_ID }],
             extractNodeData: value => ({
                 id: value.uuid,
                 status: TreeNodeStatus.INITIAL,
@@ -192,12 +190,13 @@ export const initSharedProject = (pickerId: string) =>
         }));
     };
 
+export const FAVORITES_PROJECT_ID = 'Favorites';
 export const initFavoritesProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         dispatch(receiveTreePickerData({
             id: '',
             pickerId,
-            data: [{ uuid: 'Favorites', name: 'Favorites' }],
+            data: [{ uuid: FAVORITES_PROJECT_ID, name: FAVORITES_PROJECT_ID }],
             extractNodeData: value => ({
                 id: value.uuid,
                 status: TreeNodeStatus.INITIAL,
index 341df7b8cf4aef58aec6cb58ee91518dc6aee26f..fb9bc50c7fbc8202399812cf26241065616a3045 100644 (file)
@@ -8,6 +8,7 @@ import { treePickerActions, TreePickerAction } from "./tree-picker-actions";
 import { compose } from "redux";
 import { activateNode, getNode, toggleNodeCollapse, toggleNodeSelection } from '~/models/tree';
 import { pipe } from 'lodash/fp';
+import { appendSubtree } from '~/models/tree';
 
 export const treePickerReducer = (state: TreePicker = {}, action: TreePickerAction) =>
     treePickerActions.match(action, {
@@ -17,6 +18,9 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi
         LOAD_TREE_PICKER_NODE_SUCCESS: ({ id, nodes, pickerId }) =>
             updateOrCreatePicker(state, pickerId, compose(receiveNodes(nodes)(id), setNodeStatus(id)(TreeNodeStatus.LOADED))),
 
+        APPEND_TREE_PICKER_NODE_SUBTREE: ({ id, subtree, pickerId}) =>
+            updateOrCreatePicker(state, pickerId, compose(appendSubtree(id, subtree), setNodeStatus(id)(TreeNodeStatus.LOADED))),
+
         TOGGLE_TREE_PICKER_NODE_COLLAPSE: ({ id, pickerId }) =>
             updateOrCreatePicker(state, pickerId, toggleNodeCollapse(id)),
 
index 52d6c9e62f409aea7f3b7df1c7b007be7c2c887a..12dbe7b1a8a2d71ebfcd35eb4c2fb27cd8c6fdec 100644 (file)
@@ -3,23 +3,23 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from 'redux';
-import { RootState } from "../store";
+import { RootState } from "~/store/store";
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
-import { snackbarActions } from '../snackbar/snackbar-actions';
-import { loadFavoritePanel } from '../favorite-panel/favorite-panel-action';
+import { snackbarActions } from '~/store/snackbar/snackbar-actions';
+import { loadFavoritePanel } from '~/store/favorite-panel/favorite-panel-action';
 import { openProjectPanel, projectPanelActions, setIsProjectPanelTrashed } from '~/store/project-panel/project-panel-action';
-import { activateSidePanelTreeItem, initSidePanelTree, SidePanelTreeCategory, loadSidePanelTreeProjects } from '../side-panel-tree/side-panel-tree-actions';
-import { loadResource, updateResources } from '../resources/resources-actions';
+import { activateSidePanelTreeItem, initSidePanelTree, SidePanelTreeCategory, loadSidePanelTreeProjects } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { loadResource, updateResources } from '~/store/resources/resources-actions';
 import { favoritePanelActions } from '~/store/favorite-panel/favorite-panel-action';
 import { projectPanelColumns } from '~/views/project-panel/project-panel';
 import { favoritePanelColumns } from '~/views/favorite-panel/favorite-panel';
 import { matchRootRoute } from '~/routes/routes';
-import { setSidePanelBreadcrumbs, setProcessBreadcrumbs, setSharedWithMeBreadcrumbs, setTrashBreadcrumbs, setBreadcrumbs } from '../breadcrumbs/breadcrumbs-actions';
-import { navigateToProject } from '../navigation/navigation-action';
+import { setSidePanelBreadcrumbs, setProcessBreadcrumbs, setSharedWithMeBreadcrumbs, setTrashBreadcrumbs, setBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
+import { navigateToProject } from '~/store/navigation/navigation-action';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { ServiceRepository } from '~/services/services';
-import { getResource } from '../resources/resources';
-import { getProjectPanelCurrentUuid } from '../project-panel/project-panel-action';
+import { getResource } from '~/store/resources/resources';
+import { getProjectPanelCurrentUuid } from '~/store/project-panel/project-panel-action';
 import * as projectCreateActions from '~/store/projects/project-create-actions';
 import * as projectMoveActions from '~/store/projects/project-move-actions';
 import * as projectUpdateActions from '~/store/projects/project-update-actions';
@@ -27,21 +27,22 @@ import * as collectionCreateActions from '~/store/collections/collection-create-
 import * as collectionCopyActions from '~/store/collections/collection-copy-actions';
 import * as collectionUpdateActions from '~/store/collections/collection-update-actions';
 import * as collectionMoveActions from '~/store/collections/collection-move-actions';
-import * as processesActions from '../processes/processes-actions';
+import * as processesActions from '~/store/processes/processes-actions';
 import * as processMoveActions from '~/store/processes/process-move-actions';
 import * as processUpdateActions from '~/store/processes/process-update-actions';
 import * as processCopyActions from '~/store/processes/process-copy-actions';
 import { trashPanelColumns } from "~/views/trash-panel/trash-panel";
 import { loadTrashPanel, trashPanelActions } from "~/store/trash-panel/trash-panel-action";
-import { initProcessLogsPanel } from '../process-logs-panel/process-logs-panel-actions';
+import { initProcessLogsPanel } from '~/store/process-logs-panel/process-logs-panel-actions';
 import { loadProcessPanel } from '~/store/process-panel/process-panel-actions';
 import { sharedWithMePanelActions } from '~/store/shared-with-me-panel/shared-with-me-panel-actions';
-import { loadSharedWithMePanel } from '../shared-with-me-panel/shared-with-me-panel-actions';
+import { loadSharedWithMePanel } from '~/store/shared-with-me-panel/shared-with-me-panel-actions';
 import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
 import { loadWorkflowPanel, workflowPanelActions } from '~/store/workflow-panel/workflow-panel-actions';
+import { loadSshKeysPanel } from '~/store/auth/auth-action';
 import { workflowPanelColumns } from '~/views/workflow-panel/workflow-panel-view';
 import { progressIndicatorActions } from '~/store/progress-indicator/progress-indicator-actions';
-import { getProgressIndicator } from '../progress-indicator/progress-indicator-reducer';
+import { getProgressIndicator } from '~/store/progress-indicator/progress-indicator-reducer';
 import { ResourceKind, extractUuidKind } from '~/models/resource';
 import { FilterBuilder } from '~/services/api/filter-builder';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
@@ -54,6 +55,7 @@ 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';
 
@@ -396,6 +398,17 @@ export const loadVirtualMachines = handleFirstTimeLoad(
         await dispatch(loadVirtualMachinesPanel());
         dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }]));
     });
+    
+export const loadRepositories = handleFirstTimeLoad(
+    async (dispatch: Dispatch<any>) => {
+        await dispatch(loadRepositoriesPanel());
+        dispatch(setBreadcrumbs([{ label: 'Repositories' }]));
+    });
+
+export const loadSshKeys = handleFirstTimeLoad(
+    async (dispatch: Dispatch<any>) => {
+        await dispatch(loadSshKeysPanel());
+    });
 
 const finishLoadingProject = (project: GroupContentsResource | string) =>
     async (dispatch: Dispatch<any>) => {
diff --git a/src/validators/is-rsa-key.tsx b/src/validators/is-rsa-key.tsx
new file mode 100644 (file)
index 0000000..7620a80
--- /dev/null
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+
+const ERROR_MESSAGE = 'Public key is invalid';
+
+export const isRsaKey = (value: any) => {
+    return value.match(/ssh-rsa AAAA[0-9A-Za-z+/]+[=]{0,3} ([^@]+@[^@]+)/i) ? undefined : ERROR_MESSAGE;
+};
index edc472657eb3178036fb0d8b3917dd0600e39ce5..c601df17416d8711be048d51144684703fa4fe8c 100644 (file)
@@ -4,6 +4,7 @@
 
 import { require } from './require';
 import { maxLength } from './max-length';
+import { isRsaKey } from './is-rsa-key';
 
 export const TAG_KEY_VALIDATION = [require, maxLength(255)];
 export const TAG_VALUE_VALIDATION = [require, maxLength(255)];
@@ -19,4 +20,9 @@ export const COPY_FILE_VALIDATION = [require];
 
 export const MOVE_TO_VALIDATION = [require];
 
-export const PROCESS_NAME_VALIDATION = [require, maxLength(255)];
\ No newline at end of file
+export const PROCESS_NAME_VALIDATION = [require, maxLength(255)];
+
+export const REPOSITORY_NAME_VALIDATION = [require, maxLength(255)];
+
+export const SSH_KEY_PUBLIC_VALIDATION = [require, isRsaKey, maxLength(1024)];
+export const SSH_KEY_NAME_VALIDATION = [require, maxLength(255)];
index 9a31a69ed0c1786e4397b82b20f26c0940926a5b..8bce416d2ac8b9eb24ca3fd3246ae14f403b0347 100644 (file)
@@ -78,7 +78,7 @@ export const AdvancedTabDialog = compose(
                 </Tabs>
                 <DialogContent className={classes.content}>
                     {value === 0 && <div>{dialogContentExample(apiResponse, classes)}</div>}
-                    {value === 1 && <div>{metadata.items.length > 0 ? <MetadataTab items={metadata.items} uuid={uuid} user={user} /> : dialogContentHeader('(No metadata links found)')}</div>}
+                    {value === 1 && <div>{metadata !== '' && metadata.items.length > 0 ? <MetadataTab items={metadata.items} uuid={uuid} user={user} /> : dialogContentHeader('(No metadata links found)')}</div>}
                     {value === 2 && dialogContent(pythonHeader, pythonExample, classes)}
                     {value === 3 && <div>
                         {dialogContent(cliGetHeader, cliGetExample, classes)}
index a33f78d12d5c922005eb70db8c08f5ee243d5d60..9d26fad2ff86a7659b90d1dfdd21b2e679576208 100644 (file)
@@ -15,6 +15,7 @@ import { toggleCollectionTrashed } from "~/store/trash/trash-actions";
 import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
 import { openSharingDialog } from '~/store/sharing-dialog/sharing-dialog-actions';
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
+import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
 export const collectionActionSet: ContextMenuActionSet = [[
     {
@@ -62,7 +63,7 @@ export const collectionActionSet: ContextMenuActionSet = [[
         icon: DetailsIcon,
         name: "View details",
         execute: dispatch => {
-            dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+            dispatch<any>(toggleDetailsPanel());
         }
     },
     // {
index c398a0a2c0a904f2e017720fbd12cfeaf7a29185..7730b1453812f730aab765254275298ab14e5bda 100644 (file)
@@ -15,6 +15,7 @@ import { toggleCollectionTrashed } from "~/store/trash/trash-actions";
 import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
 import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from '~/store/advanced-tab/advanced-tab';
+import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
 export const collectionResourceActionSet: ContextMenuActionSet = [[
     {
@@ -63,7 +64,7 @@ export const collectionResourceActionSet: ContextMenuActionSet = [[
         icon: DetailsIcon,
         name: "View details",
         execute: dispatch => {
-            dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+            dispatch<any>(toggleDetailsPanel());
         }
     },
     {
index 5db50dd50d85a86f61cdc94d9b05c0b65415c2da..2d152543caa248eb9e5b19e1edc35993aab396a4 100644 (file)
@@ -19,6 +19,7 @@ import { detailsPanelActions } from '~/store/details-panel/details-panel-action'
 import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
 import { openProcessInputDialog } from "~/store/processes/process-input-actions";
+import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
 export const processActionSet: ContextMenuActionSet = [[
     {
@@ -96,7 +97,7 @@ export const processActionSet: ContextMenuActionSet = [[
         icon: DetailsIcon,
         name: "View details",
         execute: dispatch => {
-            dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+            dispatch<any>(toggleDetailsPanel());
         }
     },
     // {
index be7f756c2ac7233689e2fdb459e11b9b1b6adef0..8cab9bfd5171b39f1171def4376cfa2e9dd15df5 100644 (file)
@@ -10,9 +10,9 @@ import { favoritePanelActions } from "~/store/favorite-panel/favorite-panel-acti
 import { openMoveProcessDialog } from '~/store/processes/process-move-actions';
 import { openProcessUpdateDialog } from "~/store/processes/process-update-actions";
 import { openCopyProcessDialog } from '~/store/processes/process-copy-actions';
-import { detailsPanelActions } from '~/store/details-panel/details-panel-action';
 import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
 import { openRemoveProcessDialog } from "~/store/processes/processes-actions";
+import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
 export const processResourceActionSet: ContextMenuActionSet = [[
     {
@@ -55,7 +55,7 @@ export const processResourceActionSet: ContextMenuActionSet = [[
         icon: DetailsIcon,
         name: "View details",
         execute: dispatch => {
-            dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+            dispatch<any>(toggleDetailsPanel());
         }
     },
     {
index aa82c7fa2864de5174a2f1779a8c00ba06b127d2..9b8ced5663037596646b1d1598ebd8eb6bc74d80 100644 (file)
@@ -16,6 +16,7 @@ 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";
+import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
 export const projectActionSet: ContextMenuActionSet = [[
     {
@@ -71,7 +72,7 @@ export const projectActionSet: ContextMenuActionSet = [[
         icon: DetailsIcon,
         name: "View details",
         execute: dispatch => {
-            dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+            dispatch<any>(toggleDetailsPanel());
         }
     },
     {
diff --git a/src/views-components/context-menu/action-sets/repository-action-set.ts b/src/views-components/context-menu/action-sets/repository-action-set.ts
new file mode 100644 (file)
index 0000000..cf7fb88
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set";
+import { AdvancedIcon, RemoveIcon, ShareIcon, AttributesIcon } from "~/components/icon/icon";
+import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
+import { openRepositoryAttributes, openRemoveRepositoryDialog } from "~/store/repositories/repositories-actions";
+import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions";
+
+export const repositoryActionSet: ContextMenuActionSet = [[{
+    name: "Attributes",
+    icon: AttributesIcon,
+    execute: (dispatch, { index }) => {
+        dispatch<any>(openRepositoryAttributes(index!));
+    }
+}, {
+    name: "Share",
+    icon: ShareIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openSharingDialog(uuid));
+    }
+}, {
+    name: "Advanced",
+    icon: AdvancedIcon,
+    execute: (dispatch, { uuid, index }) => {
+        dispatch<any>(openAdvancedTabDialog(uuid, index));
+    }
+}, {
+    name: "Remove",
+    icon: RemoveIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openRemoveRepositoryDialog(uuid));
+    }
+}]];
index 1f91d7e54361258ca45048284bf2033a51d78e45..cefef345f0a0df2b842a764a0ae37ce0b1a5d25e 100644 (file)
@@ -7,13 +7,14 @@ import { DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RestoreFromTrashIcon }
 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';
 
 export const trashedCollectionActionSet: ContextMenuActionSet = [[
     {
         icon: DetailsIcon,
         name: "View details",
         execute: dispatch => {
-            dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+            dispatch<any>(toggleDetailsPanel());
         }
     },
     {
index b6d2b91b1a5e58dd07353ed19eb024e48425c629..30ecc9810eb40d338c2ec37d538fb17d549cebea 100644 (file)
@@ -68,5 +68,6 @@ export enum ContextMenuKind {
     TRASHED_COLLECTION = 'TrashedCollection',
     PROCESS = "Process",
     PROCESS_RESOURCE = 'ProcessResource',
-    PROCESS_LOGS = "ProcessLogs"
+    PROCESS_LOGS = "ProcessLogs",
+    REPOSITORY = "Repository"
 }
index 5e5ccefcd37fd6e9df165fa2ea4d7d4ec0c240d2..fe434b6c731aef10539945b662cb36bec2c8b9dd 100644 (file)
@@ -10,7 +10,6 @@ import { ArvadosTheme } from '~/common/custom-theme';
 import * as classnames from "classnames";
 import { connect } from 'react-redux';
 import { RootState } from '~/store/store';
-import { detailsPanelActions } from "~/store/details-panel/details-panel-action";
 import { CloseIcon } from '~/components/icon/icon';
 import { EmptyResource } from '~/models/empty';
 import { Dispatch } from "redux";
@@ -24,11 +23,11 @@ import { DetailsResource } from "~/models/details";
 import { getResource } from '~/store/resources/resources';
 import { ResourceData } from "~/store/resources-data/resources-data-reducer";
 import { getResourceData } from "~/store/resources-data/resources-data";
+import { toggleDetailsPanel, SLIDE_TIMEOUT } from '~/store/details-panel/details-panel-action';
 
 type CssRules = 'root' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'tabContainer';
 
 const DRAWER_WIDTH = 320;
-const SLIDE_TIMEOUT = 500;
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         background: theme.palette.background.paper,
@@ -84,7 +83,7 @@ const mapStateToProps = ({ detailsPanel, resources, resourcesData }: RootState)
 
 const mapDispatchToProps = (dispatch: Dispatch) => ({
     onCloseDrawer: () => {
-        dispatch(detailsPanelActions.TOGGLE_DETAILS_PANEL());
+        dispatch<any>(toggleDetailsPanel());
     }
 });
 
index 18affbacfd0698e6ea3f5eef3b071f6eede3827f..91c5e027ba61cb9a68deb9f4b8f214145d76561b 100644 (file)
@@ -3,7 +3,10 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { ProjectIcon } from '~/components/icon/icon';
+import { compose } from 'redux';
+import { connect } from 'react-redux';
+import { openProjectPropertiesDialog } from '~/store/details-panel/details-panel-action';
+import { ProjectIcon, RenameIcon } from '~/components/icon/icon';
 import { ProjectResource } from '~/models/project';
 import { formatDate } from '~/common/formatters';
 import { ResourceKind } from '~/models/resource';
@@ -11,32 +14,74 @@ import { resourceLabel } from '~/common/labels';
 import { DetailsData } from "./details-data";
 import { DetailsAttribute } from "~/components/details-attribute/details-attribute";
 import { RichTextEditorLink } from '~/components/rich-text-editor-link/rich-text-editor-link';
+import { withStyles, StyleRulesCallback, Chip, WithStyles } from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
 
 export class ProjectDetails extends DetailsData<ProjectResource> {
-
     getIcon(className?: string) {
         return <ProjectIcon className={className} />;
     }
 
     getDetails() {
-        return <div>
-            <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROJECT)} />
-            {/* Missing attr */}
-            <DetailsAttribute label='Size' value='---' />
-            <DetailsAttribute label='Owner' value={this.item.ownerUuid} lowercaseValue={true} />
-            <DetailsAttribute label='Last modified' value={formatDate(this.item.modifiedAt)} />
-            <DetailsAttribute label='Created at' value={formatDate(this.item.createdAt)} />
-            {/* Missing attr */}
-            {/*<DetailsAttribute label='File size' value='1.4 GB' />*/}
-            <DetailsAttribute label='Description'>
-                {this.item.description ?
-                    <RichTextEditorLink
-                        title={`Description of ${this.item.name}`}
-                        content={this.item.description}
-                        label='Show full description' />
-                    : '---'
-                }
-            </DetailsAttribute>
-        </div>;
+        return <ProjectDetailsComponent project={this.item} />;
+    }
+}
+
+type CssRules = 'tag' | 'editIcon';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    tag: {
+        marginRight: theme.spacing.unit,
+        marginBottom: theme.spacing.unit
+    },
+    editIcon: {
+        fontSize: '1.125rem',
+        cursor: 'pointer'
     }
+});
+
+
+interface ProjectDetailsComponentDataProps {
+    project: ProjectResource;
+}
+
+interface ProjectDetailsComponentActionProps {
+    onClick: () => void;
 }
+
+const mapDispatchToProps = ({ onClick: openProjectPropertiesDialog });
+
+type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDetailsComponentActionProps & WithStyles<CssRules>;
+
+const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
+    withStyles(styles)(
+        ({ classes, project, onClick }: ProjectDetailsComponentProps) => <div>
+            <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROJECT)} />
+                {/* Missing attr */}
+                <DetailsAttribute label='Size' value='---' />
+                <DetailsAttribute label='Owner' value={project.ownerUuid} lowercaseValue={true} />
+                <DetailsAttribute label='Last modified' value={formatDate(project.modifiedAt)} />
+                <DetailsAttribute label='Created at' value={formatDate(project.createdAt)} />
+                {/* Missing attr */}
+                {/*<DetailsAttribute label='File size' value='1.4 GB' />*/}
+                <DetailsAttribute label='Description'>
+                    {project.description ?
+                        <RichTextEditorLink
+                            title={`Description of ${project.name}`}
+                            content={project.description}
+                            label='Show full description' />
+                        : '---'
+                    }
+                </DetailsAttribute>
+                <DetailsAttribute label='Properties'>
+                    <div onClick={onClick}>
+                        <RenameIcon className={classes.editIcon} />
+                    </div>
+                </DetailsAttribute>
+                {
+                    Object.keys(project.properties).map(k => {
+                        return <Chip key={k} className={classes.tag} label={`${k}: ${project.properties[k]}`} />;
+                    })
+                }
+        </div>
+));
\ No newline at end of file
index 7c335a358c9048cff8af1b136143252a009aad3b..095c2b9ca97cec2943d183094c94ffb09f61651e 100644 (file)
@@ -3,24 +3,29 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
+import { memoize } from "lodash/fp";
 import { FormDialog } from '~/components/form-dialog/form-dialog';
 import { CollectionNameField, CollectionDescriptionField, CollectionProjectPickerField } from '~/views-components/form-fields/collection-form-fields';
 import { WithDialogProps } from '~/store/dialog/with-dialog';
 import { InjectedFormProps } from 'redux-form';
 import { CollectionPartialCopyFormData } from '~/store/collections/collection-partial-copy-actions';
+import { PickerIdProp } from "~/store/tree-picker/picker-id";
 
 type DialogCollectionPartialCopyProps = WithDialogProps<string> & InjectedFormProps<CollectionPartialCopyFormData>;
 
-export const DialogCollectionPartialCopy = (props: DialogCollectionPartialCopyProps) =>
+export const DialogCollectionPartialCopy = (props: DialogCollectionPartialCopyProps & PickerIdProp) =>
     <FormDialog
         dialogTitle='Create a collection'
-        formFields={CollectionPartialCopyFields}
+        formFields={CollectionPartialCopyFields(props.pickerId)}
         submitLabel='Create a collection'
         {...props}
     />;
 
-export const CollectionPartialCopyFields = () => <div>
-    <CollectionNameField />
-    <CollectionDescriptionField />
-    <CollectionProjectPickerField />
-</div>;
+export const CollectionPartialCopyFields = memoize(
+    (pickerId: string) =>
+        () =>
+            <div>
+                <CollectionNameField />
+                <CollectionDescriptionField />
+                <CollectionProjectPickerField {...{ pickerId }} />
+            </div>);
index 415541595c564ff1d3b672062852af92aaf25461..de8a321cf695183ef17b435887469c183bce12f2 100644 (file)
@@ -3,6 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
+import { memoize } from 'lodash/fp';
 import { InjectedFormProps, Field } from 'redux-form';
 import { WithDialogProps } from '~/store/dialog/with-dialog';
 import { FormDialog } from '~/components/form-dialog/form-dialog';
@@ -10,25 +11,29 @@ import { ProjectTreePickerField } from '~/views-components/project-tree-picker/p
 import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from '~/validators/validators';
 import { TextField } from "~/components/text-field/text-field";
 import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
+import { PickerIdProp } from '~/store/tree-picker/picker-id';
 
 type CopyFormDialogProps = WithDialogProps<string> & InjectedFormProps<CopyFormDialogData>;
 
-export const DialogCopy = (props: CopyFormDialogProps) =>
+export const DialogCopy = (props: CopyFormDialogProps & PickerIdProp) =>
     <FormDialog
         dialogTitle='Make a copy'
-        formFields={CopyDialogFields}
+        formFields={CopyDialogFields(props.pickerId)}
         submitLabel='Copy'
         {...props}
     />;
 
-const CopyDialogFields = () => <span>
-    <Field
-        name='name'
-        component={TextField}
-        validate={COPY_NAME_VALIDATION}
-        label="Enter a new name for the copy" />
-    <Field
-        name="ownerUuid"
-        component={ProjectTreePickerField}
-        validate={COPY_FILE_VALIDATION} />
-</span>;
+const CopyDialogFields = memoize((pickerId: string) =>
+    () =>
+        <span>
+            <Field
+                name='name'
+                component={TextField}
+                validate={COPY_NAME_VALIDATION}
+                label="Enter a new name for the copy" />
+            <Field
+                name="ownerUuid"
+                component={ProjectTreePickerField}
+                validate={COPY_FILE_VALIDATION} 
+                pickerId={pickerId}/>
+        </span>);
diff --git a/src/views-components/dialog-create/dialog-repository-create.tsx b/src/views-components/dialog-create/dialog-repository-create.tsx
new file mode 100644 (file)
index 0000000..4581722
--- /dev/null
@@ -0,0 +1,21 @@
+// 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 { WithDialogProps } from '~/store/dialog/with-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { RepositoryNameField } from '~/views-components/form-fields/repository-form-fields';
+
+type DialogRepositoryProps = WithDialogProps<{}> & InjectedFormProps<any>;
+
+export const DialogRepositoryCreate = (props: DialogRepositoryProps) =>
+    <FormDialog
+        dialogTitle='Add new repository'
+        formFields={RepositoryNameField}
+        submitLabel='CREATE REPOSITORY'
+        {...props}
+    />;
+
+
diff --git a/src/views-components/dialog-create/dialog-ssh-key-create.tsx b/src/views-components/dialog-create/dialog-ssh-key-create.tsx
new file mode 100644 (file)
index 0000000..b7c9b1f
--- /dev/null
@@ -0,0 +1,25 @@
+// 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 { WithDialogProps } from '~/store/dialog/with-dialog';
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { SshKeyPublicField, SshKeyNameField } from '~/views-components/form-fields/ssh-key-form-fields';
+import { SshKeyCreateFormDialogData } from '~/store/auth/auth-action';
+
+type DialogSshKeyProps = WithDialogProps<{}> & InjectedFormProps<SshKeyCreateFormDialogData>;
+
+export const DialogSshKeyCreate = (props: DialogSshKeyProps) =>
+    <FormDialog
+        dialogTitle='Add new SSH key'
+        formFields={SshKeyAddFields}
+        submitLabel='Add new ssh key'
+        {...props}
+    />;
+
+const SshKeyAddFields = () => <span>
+    <SshKeyPublicField />
+    <SshKeyNameField />
+</span>;
index 41309fdff6952762ed9ae9d9c2922f53dc3e5082..3c8f7ebf537f4ed755ba2f380e9251a3a4f4d768 100644 (file)
@@ -9,6 +9,7 @@ import { COLLECTION_COPY_FORM_NAME } from '~/store/collections/collection-copy-a
 import { DialogCopy } from "~/views-components/dialog-copy/dialog-copy";
 import { copyCollection } from '~/store/workbench/workbench-actions';
 import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
+import { pickerId } from '~/store/tree-picker/picker-id';
 
 export const CopyCollectionDialog = compose(
     withDialog(COLLECTION_COPY_FORM_NAME),
@@ -17,5 +18,6 @@ export const CopyCollectionDialog = compose(
         onSubmit: (data, dispatch) => {
             dispatch(copyCollection(data));
         }
-    })
+    }),
+    pickerId(COLLECTION_COPY_FORM_NAME),
 )(DialogCopy);
\ No newline at end of file
index 4ec17c65da870a95b4a0b5255652c66b141179e7..89d38f83388dcb712ebfe3b2bd8b961801925114 100644 (file)
@@ -9,6 +9,7 @@ import { PROCESS_COPY_FORM_NAME } from '~/store/processes/process-copy-actions';
 import { DialogCopy } from "~/views-components/dialog-copy/dialog-copy";
 import { copyProcess } from '~/store/workbench/workbench-actions';
 import { CopyFormDialogData } from '~/store/copy-dialog/copy-dialog';
+import { pickerId } from "~/store/tree-picker/picker-id";
 
 export const CopyProcessDialog = compose(
     withDialog(PROCESS_COPY_FORM_NAME),
@@ -17,5 +18,6 @@ export const CopyProcessDialog = compose(
         onSubmit: (data, dispatch) => {
             dispatch(copyProcess(data));
         }
-    })
+    }),
+    pickerId(PROCESS_COPY_FORM_NAME),
 )(DialogCopy);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/create-repository-dialog.ts b/src/views-components/dialog-forms/create-repository-dialog.ts
new file mode 100644 (file)
index 0000000..04a13c0
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "~/store/dialog/with-dialog";
+import { createRepository, REPOSITORY_CREATE_FORM_NAME } from "~/store/repositories/repositories-actions";
+import { DialogRepositoryCreate } from "~/views-components/dialog-create/dialog-repository-create";
+
+export const CreateRepositoryDialog = compose(
+    withDialog(REPOSITORY_CREATE_FORM_NAME),
+    reduxForm<any>({
+        form: REPOSITORY_CREATE_FORM_NAME,
+        onSubmit: (repositoryName, dispatch) => {
+            dispatch(createRepository(repositoryName));
+        }
+    })
+)(DialogRepositoryCreate);
\ No newline at end of file
diff --git a/src/views-components/dialog-forms/create-ssh-key-dialog.ts b/src/views-components/dialog-forms/create-ssh-key-dialog.ts
new file mode 100644 (file)
index 0000000..e965243
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { compose } from "redux";
+import { reduxForm } from 'redux-form';
+import { withDialog } from "~/store/dialog/with-dialog";
+import { SSH_KEY_CREATE_FORM_NAME, createSshKey, SshKeyCreateFormDialogData } from '~/store/auth/auth-action';
+import { DialogSshKeyCreate } from '~/views-components/dialog-create/dialog-ssh-key-create';
+
+export const CreateSshKeyDialog = compose(
+    withDialog(SSH_KEY_CREATE_FORM_NAME),
+    reduxForm<SshKeyCreateFormDialogData>({
+        form: SSH_KEY_CREATE_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(createSshKey(data));
+        }
+    })
+)(DialogSshKeyCreate);
\ No newline at end of file
index fcdd999393ba7e765ad283110dd3a5ab1b7b7be3..b817b6a0fe435c80f510a7bc61eaa2063c3d3ea8 100644 (file)
@@ -9,6 +9,7 @@ import { DialogMoveTo } from '~/views-components/dialog-move/dialog-move-to';
 import { COLLECTION_MOVE_FORM_NAME } from '~/store/collections/collection-move-actions';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { moveCollection } from '~/store/workbench/workbench-actions';
+import { pickerId } from '~/store/tree-picker/picker-id';
 
 export const MoveCollectionDialog = compose(
     withDialog(COLLECTION_MOVE_FORM_NAME),
@@ -17,5 +18,6 @@ export const MoveCollectionDialog = compose(
         onSubmit: (data, dispatch) => {
             dispatch(moveCollection(data));
         }
-    })
+    }),
+    pickerId(COLLECTION_MOVE_FORM_NAME),
 )(DialogMoveTo);
index baea34bc71c63ba2a1c7634a34101f0974f0d669..ce854ef251a04eb0e49b7a65a54f8f1ea4f3cfc2 100644 (file)
@@ -9,6 +9,7 @@ import { PROCESS_MOVE_FORM_NAME } from '~/store/processes/process-move-actions';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { DialogMoveTo } from '~/views-components/dialog-move/dialog-move-to';
 import { moveProcess } from '~/store/workbench/workbench-actions';
+import { pickerId } from '~/store/tree-picker/picker-id';
 
 export const MoveProcessDialog = compose(
     withDialog(PROCESS_MOVE_FORM_NAME),
@@ -17,5 +18,6 @@ export const MoveProcessDialog = compose(
         onSubmit: (data, dispatch) => {
             dispatch(moveProcess(data));
         }
-    })
+    }),
+    pickerId(PROCESS_MOVE_FORM_NAME),
 )(DialogMoveTo);
\ No newline at end of file
index c1fbb76ebc5e973d70962ecea56c6f1359aaffa1..03e474b1778074668cfedcc41a44c4f3e9335d09 100644 (file)
@@ -9,6 +9,7 @@ import { PROJECT_MOVE_FORM_NAME } from '~/store/projects/project-move-actions';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
 import { DialogMoveTo } from '~/views-components/dialog-move/dialog-move-to';
 import { moveProject } from '~/store/workbench/workbench-actions';
+import { pickerId } from '~/store/tree-picker/picker-id';
 
 export const MoveProjectDialog = compose(
     withDialog(PROJECT_MOVE_FORM_NAME),
@@ -17,6 +18,7 @@ export const MoveProjectDialog = compose(
         onSubmit: (data, dispatch) => {
             dispatch(moveProject(data));
         }
-    })
+    }),
+    pickerId(PROJECT_MOVE_FORM_NAME),
 )(DialogMoveTo);
 
index 16f8275e8fb57821c8ed2f4b8065c4b8c6c43eb9..37d928be1c18c0348c06a0e9133ab4b6d7d806d7 100644 (file)
@@ -7,6 +7,7 @@ import { reduxForm } from 'redux-form';
 import { withDialog, } from '~/store/dialog/with-dialog';
 import { CollectionPartialCopyFormData, copyCollectionPartial, COLLECTION_PARTIAL_COPY_FORM_NAME } from '~/store/collections/collection-partial-copy-actions';
 import { DialogCollectionPartialCopy } from "~/views-components/dialog-copy/dialog-collection-partial-copy";
+import { pickerId } from "~/store/tree-picker/picker-id";
 
 
 export const PartialCopyCollectionDialog = compose(
@@ -16,4 +17,6 @@ export const PartialCopyCollectionDialog = compose(
         onSubmit: (data, dispatch) => {
             dispatch(copyCollectionPartial(data));
         }
-    }))(DialogCollectionPartialCopy);
\ No newline at end of file
+    }),
+    pickerId(COLLECTION_PARTIAL_COPY_FORM_NAME),
+)(DialogCollectionPartialCopy);
\ No newline at end of file
index 425b9e462a5439b47f3eb82a26fbe4eefe5481e0..c962522f3cf8292853bd743d08cfe92f55c18d7d 100644 (file)
@@ -3,24 +3,28 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
+import { memoize } from 'lodash/fp';
 import { InjectedFormProps, Field } from 'redux-form';
 import { WithDialogProps } from '~/store/dialog/with-dialog';
 import { FormDialog } from '~/components/form-dialog/form-dialog';
 import { ProjectTreePickerField } from '~/views-components/project-tree-picker/project-tree-picker';
 import { MOVE_TO_VALIDATION } from '~/validators/validators';
 import { MoveToFormDialogData } from '~/store/move-to-dialog/move-to-dialog';
+import { PickerIdProp } from "~/store/tree-picker/picker-id";
 
-export const DialogMoveTo = (props: WithDialogProps<string> & InjectedFormProps<MoveToFormDialogData>) =>
+export const DialogMoveTo = (props: WithDialogProps<string> & InjectedFormProps<MoveToFormDialogData> & PickerIdProp) =>
     <FormDialog
         dialogTitle='Move to'
-        formFields={MoveToDialogFields}
+        formFields={MoveToDialogFields(props.pickerId)}
         submitLabel='Move'
         {...props}
     />;
 
-const MoveToDialogFields = () =>
-    <Field
-        name="ownerUuid"
-        component={ProjectTreePickerField}
-        validate={MOVE_TO_VALIDATION} />;
+const MoveToDialogFields = memoize(
+    (pickerId: string) => () =>
+        <Field
+            name="ownerUuid"
+            pickerId={pickerId}
+            component={ProjectTreePickerField}
+            validate={MOVE_TO_VALIDATION} />);
 
index be5f93df6a52b401177f0fa12f27594e46abe2c4..2d2a7c80880b0fef31e428c46150fd504d506f95 100644 (file)
@@ -6,7 +6,8 @@ import * as React from "react";
 import { Field, WrappedFieldProps } from "redux-form";
 import { TextField } from "~/components/text-field/text-field";
 import { COLLECTION_NAME_VALIDATION, COLLECTION_DESCRIPTION_VALIDATION, COLLECTION_PROJECT_VALIDATION } from "~/validators/validators";
-import { ProjectTreePicker } from "~/views-components/project-tree-picker/project-tree-picker";
+import { ProjectTreePicker, ProjectTreePickerField } from "~/views-components/project-tree-picker/project-tree-picker";
+import { PickerIdProp } from '../../store/tree-picker/picker-id';
 
 export const CollectionNameField = () =>
     <Field
@@ -23,13 +24,9 @@ export const CollectionDescriptionField = () =>
         validate={COLLECTION_DESCRIPTION_VALIDATION}
         label="Description - optional" />;
 
-export const CollectionProjectPickerField = () =>
+export const CollectionProjectPickerField = (props: PickerIdProp) =>
     <Field
         name="projectUuid"
-        component={ProjectPicker}
+        pickerId={props.pickerId}
+        component={ProjectTreePickerField}
         validate={COLLECTION_PROJECT_VALIDATION} />;
-
-const ProjectPicker = (props: WrappedFieldProps) =>
-    <div style={{ height: '144px', display: 'flex', flexDirection: 'column' }}>
-        <ProjectTreePicker onChange={projectUuid => props.input.onChange(projectUuid)} />
-    </div>;
diff --git a/src/views-components/form-fields/repository-form-fields.tsx b/src/views-components/form-fields/repository-form-fields.tsx
new file mode 100644 (file)
index 0000000..932a5fe
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field } from "redux-form";
+import { TextField } from "~/components/text-field/text-field";
+import { REPOSITORY_NAME_VALIDATION } from "~/validators/validators";
+import { Grid } from "@material-ui/core";
+
+export const RepositoryNameField = (props: any) =>
+    <Grid container style={{ marginTop: '0', paddingTop: '24px' }}>
+        <Grid item xs={3}>
+            {props.data.user.username}/
+        </Grid>
+        <Grid item xs={7} style={{ bottom: '24px', position: 'relative' }}>
+            <Field
+                name='name'
+                component={TextField}
+                validate={REPOSITORY_NAME_VALIDATION}
+                label="Name"
+                autoFocus={true} />
+        </Grid>
+        <Grid item xs={2}>
+            .git
+        </Grid>
+        <Grid item xs={12}>
+            It may take a minute or two before you can clone your new repository.
+        </Grid>
+    </Grid>;
\ No newline at end of file
diff --git a/src/views-components/form-fields/ssh-key-form-fields.tsx b/src/views-components/form-fields/ssh-key-form-fields.tsx
new file mode 100644 (file)
index 0000000..8724e08
--- /dev/null
@@ -0,0 +1,25 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Field } from "redux-form";
+import { TextField } from "~/components/text-field/text-field";
+import { SSH_KEY_PUBLIC_VALIDATION, SSH_KEY_NAME_VALIDATION } from "~/validators/validators";
+
+export const SshKeyPublicField = () =>
+    <Field
+        name='publicKey'
+        component={TextField}
+        validate={SSH_KEY_PUBLIC_VALIDATION}
+        autoFocus={true}
+        label="Public Key" />;
+
+export const SshKeyNameField = () =>
+    <Field
+        name='name'
+        component={TextField}
+        validate={SSH_KEY_NAME_VALIDATION}
+        label="Name" />;
+
+
index baf893e2e77e7da55fe5eced464096bd574d8d36..ca88021ccdb04c6491ec5389f94dade7ccd35424 100644 (file)
@@ -8,9 +8,11 @@ import { User, getUserFullname } from "~/models/user";
 import { DropdownMenu } from "~/components/dropdown-menu/dropdown-menu";
 import { UserPanelIcon } from "~/components/icon/icon";
 import { DispatchProp, connect } from 'react-redux';
-import { logout } from "~/store/auth/auth-action";
+import { logout } from '~/store/auth/auth-action';
 import { RootState } from "~/store/store";
-import { openCurrentTokenDialog } from '../../store/current-token-dialog/current-token-dialog-actions';
+import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
+import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
+import { navigateToSshKeys } from '~/store/navigation/navigation-action';
 import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
 
 interface AccountMenuProps {
@@ -32,7 +34,9 @@ export const AccountMenu = connect(mapStateToProps)(
                     {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>
                 <MenuItem>My account</MenuItem>
                 <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
             </DropdownMenu>
index b38f85b5ff32cd44fe5aee2cd88934fcca93e753..6b84bde2b6143a6e00abd80e6a17068eb27be66c 100644 (file)
@@ -6,11 +6,10 @@ import * as React from "react";
 import { Toolbar, IconButton, Tooltip, Grid } from "@material-ui/core";
 import { DetailsIcon } from "~/components/icon/icon";
 import { Breadcrumbs } from "~/views-components/breadcrumbs/breadcrumbs";
-import { detailsPanelActions } from "~/store/details-panel/details-panel-action";
 import { connect } from 'react-redux';
 import { RootState } from '~/store/store';
-import { matchWorkflowRoute } from '~/routes/routes';
-import { matchVirtualMachineRoute } from '~/routes/routes';
+import { matchWorkflowRoute, matchSshKeysRoute, matchRepositoriesRoute, matchVirtualMachineRoute } from '~/routes/routes';
+import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
 interface MainContentBarProps {
     onDetailsPanelToggle: () => void;
@@ -29,10 +28,22 @@ const isVirtualMachinePath = ({ router }: RootState) => {
     return !!match;
 };
 
+const isRepositoriesPath = ({ router }: RootState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchRepositoriesRoute(pathname);
+    return !!match;
+};
+
+const isSshKeysPath = ({ router }: RootState) => {
+    const pathname = router.location ? router.location.pathname : '';
+    const match = matchSshKeysRoute(pathname);
+    return !!match;
+};
+
 export const MainContentBar = connect((state: RootState) => ({
-    buttonVisible: !isWorkflowPath(state) && !isVirtualMachinePath(state)
+    buttonVisible: !isWorkflowPath(state) && !isSshKeysPath(state) && !isRepositoriesPath(state) && !isVirtualMachinePath(state)
 }), {
-        onDetailsPanelToggle: detailsPanelActions.TOGGLE_DETAILS_PANEL
+        onDetailsPanelToggle: toggleDetailsPanel
     })((props: MainContentBarProps) =>
         <Toolbar>
             <Grid container>
diff --git a/src/views-components/project-properties-dialog/project-properties-dialog.tsx b/src/views-components/project-properties-dialog/project-properties-dialog.tsx
new file mode 100644 (file)
index 0000000..d165f98
--- /dev/null
@@ -0,0 +1,73 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dispatch } from "redux";
+import { connect } from "react-redux";
+import { RootState } from '~/store/store';
+import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
+import { ProjectResource } from '~/models/project';
+import { PROJECT_PROPERTIES_DIALOG_NAME, deleteProjectProperty } from '~/store/details-panel/details-panel-action';
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Chip, withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { ProjectPropertiesForm } from '~/views-components/project-properties-dialog/project-properties-form';
+import { getResource } from '~/store/resources/resources';
+
+type CssRules = 'tag';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    tag: {
+        marginRight: theme.spacing.unit,
+        marginBottom: theme.spacing.unit
+    }
+});
+
+interface ProjectPropertiesDialogDataProps {
+    project: ProjectResource;
+}
+
+interface ProjectPropertiesDialogActionProps {
+    handleDelete: (key: string) => void;
+}
+
+const mapStateToProps = ({ detailsPanel, resources }: RootState): ProjectPropertiesDialogDataProps => {
+    const project = getResource(detailsPanel.resourceUuid)(resources) as ProjectResource;
+    return { project };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): ProjectPropertiesDialogActionProps => ({
+    handleDelete: (key: string) => dispatch<any>(deleteProjectProperty(key))
+});
+
+type ProjectPropertiesDialogProps =  ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
+
+export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToProps)(
+    withStyles(styles)(
+    withDialog(PROJECT_PROPERTIES_DIALOG_NAME)(
+        ({ classes, open, closeDialog, handleDelete, project }: ProjectPropertiesDialogProps) =>
+            <Dialog open={open}
+                onClose={closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>Properties</DialogTitle>
+                <DialogContent>
+                    <ProjectPropertiesForm />
+                    {project && project.properties && 
+                        Object.keys(project.properties).map(k => {
+                            return <Chip key={k} className={classes.tag}
+                                onDelete={() => handleDelete(k)}
+                                label={`${k}: ${project.properties[k]}`} />;
+                        })
+                    }
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={closeDialog}>
+                        Close
+                    </Button>
+                </DialogActions>
+            </Dialog>
+)));
\ No newline at end of file
diff --git a/src/views-components/project-properties-dialog/project-properties-form.tsx b/src/views-components/project-properties-dialog/project-properties-form.tsx
new file mode 100644 (file)
index 0000000..82ae040
--- /dev/null
@@ -0,0 +1,95 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { reduxForm, Field, reset } from 'redux-form';
+import { compose, Dispatch } from 'redux';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { StyleRulesCallback, withStyles, WithStyles, 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 { PROJECT_PROPERTIES_FORM_NAME, createProjectProperty } from '~/store/details-panel/details-panel-action';
+
+type CssRules = 'root' | 'keyField' | 'valueField' | 'buttonWrapper' | 'saveButton' | 'circularProgress';
+
+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'
+    }
+});
+
+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>
+        );
index a4e4c4062631e6881de807a19970ec9e8b6bd582..bae5d59f07dd10a699e9aaaf51f21817202f8106 100644 (file)
@@ -16,6 +16,9 @@ import { RootState } from "~/store/store";
 import { ServiceRepository } from "~/services/services";
 import { WrappedFieldProps } from 'redux-form';
 import { TreePickerId } from '~/models/tree';
+import { ProjectsTreePicker } from '~/views-components/projects-tree-picker/projects-tree-picker';
+import { ProjectsTreePickerItem } from '~/views-components/projects-tree-picker/generic-projects-tree-picker';
+import { PickerIdProp } from '~/store/tree-picker/picker-id';
 
 type ProjectTreePickerProps = Pick<TreePickerProps<ProjectResource>, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>;
 
@@ -87,17 +90,17 @@ const renderTreeItem = (item: TreeItem<ProjectResource>) =>
         isActive={item.active}
         hasMargin={true} />;
 
-export const ProjectTreePickerField = (props: WrappedFieldProps) =>
+export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
     <div style={{ height: '200px', display: 'flex', flexDirection: 'column' }}>
-        <ProjectTreePicker onChange={handleChange(props)} />
+        <ProjectsTreePicker
+            pickerId={props.pickerId}
+            toggleItemActive={handleChange(props)} />
         {props.meta.dirty && props.meta.error &&
             <Typography variant='caption' color='error'>
                 {props.meta.error}
             </Typography>}
     </div>;
 
-const handleChange = (props: WrappedFieldProps) => (value: string) =>
-    props.input.value === value
-        ? props.input.onChange('')
-        : props.input.onChange(value);
-
+const handleChange = (props: WrappedFieldProps) =>
+    (_: any, { id }: TreeItem<ProjectsTreePickerItem>) =>
+        props.input.onChange(id);
index d8a5d49f0584346146d1f33b70e9aed6440de208..fafb05056ca712b86b841bb221da6258129ae080 100644 (file)
@@ -5,6 +5,7 @@
 import * as React from "react";
 import { Dispatch } from "redux";
 import { connect } from "react-redux";
+import { isEqual } from 'lodash/fp';
 import { TreeItem, TreeItemStatus } from '~/components/tree/tree';
 import { ProjectResource } from "~/models/project";
 import { treePickerActions } from "~/store/tree-picker/tree-picker-actions";
@@ -30,6 +31,7 @@ export interface ProjectsTreePickerDataProps {
     rootItemIcon: IconType;
     showSelection?: boolean;
     relatedTreePickers?: string[];
+    disableActivation?: string[];
     loadRootItem: (item: TreeItem<ProjectsTreePickerRootItem>, pickerId: string, includeCollections?: boolean, inlcudeFiles?: boolean) => void;
 }
 
@@ -43,6 +45,12 @@ const mapStateToProps = (_: any, { rootItemIcon, showSelection }: ProjectsTreePi
 const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollections, includeFiles, relatedTreePickers, ...props }: ProjectsTreePickerProps): PickedTreePickerProps => ({
     onContextMenu: () => { return; },
     toggleItemActive: (event, item, pickerId) => {
+        
+        const { disableActivation = [] } = props;
+        if(disableActivation.some(isEqual(item.id))){
+            return;
+        }
+
         dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: item.id, pickerId, relatedTreePickers }));
         if (props.toggleItemActive) {
             props.toggleItemActive(event, item, pickerId);
index 6c66d1a9f1d9ff91fb4ea7bbd4cc5d82d7ed0b75..ae98cf00896a3978c137a5593fdadc977c7978e0 100644 (file)
@@ -3,11 +3,11 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from 'react';
-import { values, memoize, pipe } from 'lodash/fp';
+import { values, memoize, pipe, pick } from 'lodash/fp';
 import { HomeTreePicker } from '~/views-components/projects-tree-picker/home-tree-picker';
 import { SharedTreePicker } from '~/views-components/projects-tree-picker/shared-tree-picker';
 import { FavoritesTreePicker } from '~/views-components/projects-tree-picker/favorites-tree-picker';
-import { getProjectsTreePickerIds } from '~/store/tree-picker/tree-picker-actions';
+import { getProjectsTreePickerIds, SHARED_PROJECT_ID, FAVORITES_PROJECT_ID } from '~/store/tree-picker/tree-picker-actions';
 import { TreeItem } from '~/components/tree/tree';
 import { ProjectsTreePickerItem } from './generic-projects-tree-picker';
 
@@ -23,11 +23,17 @@ export interface ProjectsTreePickerProps {
 export const ProjectsTreePicker = ({ pickerId, ...props }: ProjectsTreePickerProps) => {
     const { home, shared, favorites } = getProjectsTreePickerIds(pickerId);
     const relatedTreePickers = getRelatedTreePickers(pickerId);
+    const p = {
+        ...props,
+        relatedTreePickers,
+        disableActivation
+    };
     return <div>
-        <HomeTreePicker pickerId={home} {...props} {...{ relatedTreePickers }} />
-        <SharedTreePicker pickerId={shared} {...props} {...{ relatedTreePickers }} />
-        <FavoritesTreePicker pickerId={favorites} {...props} {...{ relatedTreePickers }} />
+        <HomeTreePicker pickerId={home} {...p} />
+        <SharedTreePicker pickerId={shared} {...p} />
+        <FavoritesTreePicker pickerId={favorites} {...p} />
     </div>;
 };
 
 const getRelatedTreePickers = memoize(pipe(getProjectsTreePickerIds, values));
+const disableActivation = [SHARED_PROJECT_ID, FAVORITES_PROJECT_ID];
diff --git a/src/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx b/src/views-components/repositories-sample-git-dialog/repositories-sample-git-dialog.tsx
new file mode 100644 (file)
index 0000000..1a00e97
--- /dev/null
@@ -0,0 +1,78 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from "@material-ui/core";
+import { WithDialogProps } from "~/store/dialog/with-dialog";
+import { withDialog } from '~/store/dialog/with-dialog';
+import { REPOSITORIES_SAMPLE_GIT_DIALOG } from "~/store/repositories/repositories-actions";
+import { DefaultCodeSnippet } from '~/components/default-code-snippet/default-code-snippet';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { compose } from "redux";
+
+type CssRules = 'codeSnippet' | 'link' | 'spacing';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    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'
+        }
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    }
+});
+
+interface RepositoriesSampleGitDataProps {
+    uuidPrefix: string;
+}
+
+type RepositoriesSampleGitProps = RepositoriesSampleGitDataProps & WithStyles<CssRules>;
+
+export const RepositoriesSampleGitDialog = compose(
+    withDialog(REPOSITORIES_SAMPLE_GIT_DIALOG),
+    withStyles(styles))(
+        (props: WithDialogProps<RepositoriesSampleGitProps> & RepositoriesSampleGitProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth='sm'>
+                <DialogTitle>Sample git quick start:</DialogTitle>
+                <DialogContent>
+                    <DefaultCodeSnippet
+                        className={props.classes.codeSnippet}
+                        lines={[snippetText(props.data.uuidPrefix)]} />
+                    <Typography variant="body2" className={props.classes.spacing}>
+                        See also:
+                        <div><a href="https://doc.arvados.org/user/getting_started/ssh-access-unix.html" className={props.classes.link} target="_blank">SSH access</a></div>
+                        <div><a href="https://doc.arvados.org/user/tutorials/tutorial-firstscript.html" className={props.classes.link} target="_blank">Writing a Crunch Script</a></div>
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const snippetText = (uuidPrefix: string) => `git clone git@git.${uuidPrefix}.arvadosapi.com:arvados.git
+cd arvados
+# edit files
+git add the/files/you/changed
+git commit
+git push
+`;
diff --git a/src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx b/src/views-components/repository-attributes-dialog/repository-attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..94a0e6c
--- /dev/null
@@ -0,0 +1,89 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from "react";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography, Grid } from "@material-ui/core";
+import { WithDialogProps } from "~/store/dialog/with-dialog";
+import { withDialog } from '~/store/dialog/with-dialog';
+import { REPOSITORY_ATTRIBUTES_DIALOG } from "~/store/repositories/repositories-actions";
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { compose } from "redux";
+import { RepositoryResource } from "~/models/repositories";
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    rightContainer: {
+        textAlign: 'right',
+        paddingRight: theme.spacing.unit * 2,
+        color: theme.palette.grey["500"]
+    },
+    leftContainer: {
+        textAlign: 'left',
+        paddingLeft: theme.spacing.unit * 2
+    },
+    spacing: {
+        paddingTop: theme.spacing.unit * 2
+    },
+});
+
+interface RepositoryAttributesDataProps {
+    repositoryData: RepositoryResource;
+}
+
+type RepositoryAttributesProps = RepositoryAttributesDataProps & WithStyles<CssRules>;
+
+export const RepositoryAttributesDialog = compose(
+    withDialog(REPOSITORY_ATTRIBUTES_DIALOG),
+    withStyles(styles))(
+        (props: WithDialogProps<RepositoryAttributesProps> & RepositoryAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant="body2" className={props.classes.spacing}>
+                        {props.data.repositoryData && attributes(props.data.repositoryData, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (repositoryData: RepositoryResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name } = repositoryData;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    <Grid item>Name</Grid>
+                    <Grid item>Owner uuid</Grid>
+                    <Grid item>Created at</Grid>
+                    <Grid item>Modified at</Grid>
+                    <Grid item>Modified by user uuid</Grid>
+                    <Grid item>Modified by client uuid</Grid>
+                    <Grid item>uuid</Grid>
+                </Grid>
+                <Grid item xs={7} className={classes.leftContainer}>
+                    <Grid item>{name}</Grid>
+                    <Grid item>{ownerUuid}</Grid>
+                    <Grid item>{createdAt}</Grid>
+                    <Grid item>{modifiedAt}</Grid>
+                    <Grid item>{modifiedByUserUuid}</Grid>
+                    <Grid item>{modifiedByClientUuid}</Grid>
+                    <Grid item>{uuid}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
diff --git a/src/views-components/repository-remove-dialog/repository-remove-dialog.ts b/src/views-components/repository-remove-dialog/repository-remove-dialog.ts
new file mode 100644 (file)
index 0000000..148e78b
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+import { Dispatch, compose } from 'redux';
+import { connect } from "react-redux";
+import { ConfirmationDialog } from "~/components/confirmation-dialog/confirmation-dialog";
+import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
+import { removeRepository, REPOSITORY_REMOVE_DIALOG } from '~/store/repositories/repositories-actions';
+
+ const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeRepository(props.data.uuid));
+    }
+});
+
+ export const RemoveRepositoryDialog = compose(
+    withDialog(REPOSITORY_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
index a997355410dc1f6eab0711142c23a6f27862e1c2..86422bafde731b5a36a288bb040934cf6a3fc84e 100644 (file)
@@ -3,8 +3,8 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import * as React from "react";
-import { Dialog, DialogTitle, DialogContent, DialogActions, Button, DialogContentText } from "@material-ui/core";
-import { WithDialogProps } from "../../store/dialog/with-dialog";
+import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from "@material-ui/core";
+import { WithDialogProps } from "~/store/dialog/with-dialog";
 import { withDialog } from '~/store/dialog/with-dialog';
 import { RICH_TEXT_EDITOR_DIALOG_NAME } from "~/store/rich-text-editor-dialog/rich-text-editor-dialog-actions";
 import RichTextEditor from 'react-rte';
diff --git a/src/views/repositories-panel/repositories-panel.tsx b/src/views/repositories-panel/repositories-panel.tsx
new file mode 100644 (file)
index 0000000..cfe59f0
--- /dev/null
@@ -0,0 +1,154 @@
+// 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, IconButton } from '@material-ui/core';
+import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { Link } from 'react-router-dom';
+import { Dispatch, compose } from 'redux';
+import { RootState } from '~/store/store';
+import { HelpIcon, AddIcon, MoreOptionsIcon } from '~/components/icon/icon';
+import { loadRepositoriesData, openRepositoriesSampleGitDialog, openRepositoryCreateDialog } from '~/store/repositories/repositories-actions';
+import { RepositoryResource } from '~/models/repositories';
+import { openRepositoryContextMenu } from '~/store/context-menu/context-menu-actions';
+import { Routes } from '~/routes/routes';
+
+
+type CssRules = 'link' | 'button' | 'icon' | 'iconRow' | 'moreOptionsButton' | 'moreOptions' | 'cloneUrls';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    link: {
+        textDecoration: 'none',
+        color: theme.palette.primary.main,
+        "&:hover": {
+            color: theme.palette.primary.dark,
+            transition: 'all 0.5s ease'
+        }
+    },
+    button: {
+        textAlign: 'right',
+        alignSelf: 'center'
+    },
+    icon: {
+        cursor: 'pointer',
+        color: theme.palette.grey["500"],
+        "&:hover": {
+            color: theme.palette.common.black,
+            transition: 'all 0.5s ease'
+        }
+    },
+    iconRow: {
+        paddingTop: theme.spacing.unit * 2,
+        textAlign: 'right'
+    },
+    moreOptionsButton: {
+        padding: 0
+    },
+    moreOptions: {
+        textAlign: 'right',
+        '&:last-child': {
+            paddingRight: 0
+        }
+    },
+    cloneUrls: {
+        whiteSpace: 'pre-wrap'
+    }
+});
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        repositories: state.repositories.items
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<RepositoriesActionProps, 'onOptionsMenuOpen' | 'loadRepositories' | 'openRepositoriesSampleGitDialog' | 'openRepositoryCreateDialog'> => ({
+    loadRepositories: () => dispatch<any>(loadRepositoriesData()),
+    onOptionsMenuOpen: (event, index, repository) => {
+        dispatch<any>(openRepositoryContextMenu(event, index, repository));
+    },
+    openRepositoriesSampleGitDialog: () => dispatch<any>(openRepositoriesSampleGitDialog()),
+    openRepositoryCreateDialog: () => dispatch<any>(openRepositoryCreateDialog())
+});
+
+interface RepositoriesActionProps {
+    loadRepositories: () => void;
+    onOptionsMenuOpen: (event: React.MouseEvent<HTMLElement>, index: number, repository: RepositoryResource) => void;
+    openRepositoriesSampleGitDialog: () => void;
+    openRepositoryCreateDialog: () => void;
+}
+
+interface RepositoriesDataProps {
+    repositories: RepositoryResource[];
+}
+
+
+type RepositoriesProps = RepositoriesDataProps & RepositoriesActionProps & WithStyles<CssRules>;
+
+export const RepositoriesPanel = compose(
+    withStyles(styles),
+    connect(mapStateToProps, mapDispatchToProps))(
+        class extends React.Component<RepositoriesProps> {
+            componentDidMount() {
+                this.props.loadRepositories();
+            }
+            render() {
+                const { classes, repositories, onOptionsMenuOpen, openRepositoriesSampleGitDialog, openRepositoryCreateDialog } = this.props;
+                return (
+                    <Card>
+                        <CardContent>
+                            <Grid container direction="row">
+                                <Grid item xs={8}>
+                                    <Typography variant="body2">
+                                        When you are using an Arvados virtual machine, you should clone the https:// URLs. This will authenticate automatically using your API token. <br />
+                                        In order to clone git repositories using SSH, <Link to={Routes.SSH_KEYS} className={classes.link}>add an SSH key to your account</Link> and clone the git@ URLs.
+                                    </Typography>
+                                </Grid>
+                                <Grid item xs={4} className={classes.button}>
+                                    <Button variant="contained" color="primary" onClick={openRepositoryCreateDialog}>
+                                        <AddIcon /> NEW REPOSITORY
+                                    </Button>
+                                </Grid>
+                            </Grid>
+                            <Grid item xs={12}>
+                                <div className={classes.iconRow}>
+                                    <Tooltip title="Sample git quick start">
+                                        <IconButton className={classes.moreOptionsButton} onClick={openRepositoriesSampleGitDialog}>
+                                            <HelpIcon className={classes.icon} />
+                                        </IconButton>
+                                    </Tooltip>
+                                </div>
+                            </Grid>
+                            <Grid item xs={12}>
+                                {repositories && <Table>
+                                    <TableHead>
+                                        <TableRow>
+                                            <TableCell>Name</TableCell>
+                                            <TableCell>URL</TableCell>
+                                            <TableCell />
+                                        </TableRow>
+                                    </TableHead>
+                                    <TableBody>
+                                        {repositories.map((repository, index) =>
+                                            <TableRow key={index}>
+                                                <TableCell>{repository.name}</TableCell>
+                                                <TableCell className={classes.cloneUrls}>{repository.cloneUrls.join("\n")}</TableCell>
+                                                <TableCell className={classes.moreOptions}>
+                                                    <Tooltip title="More options" disableFocusListener>
+                                                        <IconButton onClick={event => onOptionsMenuOpen(event, index, repository)} className={classes.moreOptionsButton}>
+                                                            <MoreOptionsIcon />
+                                                        </IconButton>
+                                                    </Tooltip>
+                                                </TableCell>
+                                            </TableRow>)}
+                                    </TableBody>
+                                </Table>}
+                            </Grid>
+                        </CardContent>
+                    </Card>
+                );
+            }
+        }
+    );
\ No newline at end of file
diff --git a/src/views/ssh-key-panel/ssh-key-panel-root.tsx b/src/views/ssh-key-panel/ssh-key-panel-root.tsx
new file mode 100644 (file)
index 0000000..f752228
--- /dev/null
@@ -0,0 +1,55 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { StyleRulesCallback, WithStyles, withStyles, Card, CardContent, Button, Typography } from '@material-ui/core';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { SshKeyResource } from '~/models/ssh-key';
+
+
+type CssRules = 'root' | 'link';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+       width: '100%'
+    },
+    link: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        margin: '0px 4px'
+    }
+});
+
+export interface SshKeyPanelRootActionProps {
+    onClick: () => void;
+}
+
+export interface SshKeyPanelRootDataProps {
+    sshKeys?: SshKeyResource[];
+}
+
+type SshKeyPanelRootProps = SshKeyPanelRootDataProps & SshKeyPanelRootActionProps & WithStyles<CssRules>;
+
+export const SshKeyPanelRoot = withStyles(styles)(
+    ({ classes, sshKeys, onClick }: SshKeyPanelRootProps) =>
+        <Card className={classes.root}>
+            <CardContent>
+                <Typography variant='body1' paragraph={true}>
+                    You have not yet set up an SSH public key for use with Arvados.
+                    <a href='https://doc.arvados.org/user/getting_started/ssh-access-unix.html' target='blank' className={classes.link}>
+                        Learn more.
+                    </a>
+                </Typography>
+                <Typography variant='body1' paragraph={true}>
+                    When you have an SSH key you would like to use, add it using button below.
+                </Typography>
+                <Button
+                    onClick={onClick}
+                    color="primary"
+                    variant="contained">
+                    Add New Ssh Key
+                </Button>
+            </CardContent>
+        </Card>
+    );
\ No newline at end of file
diff --git a/src/views/ssh-key-panel/ssh-key-panel.tsx b/src/views/ssh-key-panel/ssh-key-panel.tsx
new file mode 100644 (file)
index 0000000..f600677
--- /dev/null
@@ -0,0 +1,23 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { RootState } from '~/store/store';
+import { Dispatch } from 'redux';
+import { connect } from 'react-redux';
+import { SshKeyPanelRoot, SshKeyPanelRootDataProps, SshKeyPanelRootActionProps } from '~/views/ssh-key-panel/ssh-key-panel-root';
+import { openSshKeyCreateDialog } from '~/store/auth/auth-action';
+
+const mapStateToProps = (state: RootState): SshKeyPanelRootDataProps => {
+    return {
+        sshKeys: state.auth.sshKeys
+    };
+};
+
+const mapDispatchToProps = (dispatch: Dispatch): SshKeyPanelRootActionProps => ({
+    onClick: () => {
+        dispatch(openSshKeyCreateDialog());
+    }
+});
+
+export const SshKeyPanel = connect(mapStateToProps, mapDispatchToProps)(SshKeyPanelRoot);
\ No newline at end of file
index 4ebc99bd4754c94128643a6fee1336f3daf063dc..5ebf10567f1d87b1d0edc52343659f0cf645bedf 100644 (file)
@@ -44,10 +44,18 @@ import { RunProcessPanel } from '~/views/run-process-panel/run-process-panel';
 import SplitterLayout from 'react-splitter-layout';
 import { WorkflowPanel } from '~/views/workflow-panel/workflow-panel';
 import { SearchResultsPanel } from '~/views/search-results-panel/search-results-panel';
+import { SshKeyPanel } from '~/views/ssh-key-panel/ssh-key-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';
+import { RepositoryAttributesDialog } from '~/views-components/repository-attributes-dialog/repository-attributes-dialog';
+import { CreateRepositoryDialog } from '~/views-components/dialog-forms/create-repository-dialog';
+import { RemoveRepositoryDialog } from '~/views-components/repository-remove-dialog/repository-remove-dialog';
+import { CreateSshKeyDialog } from '~/views-components/dialog-forms/create-ssh-key-dialog';
 
 type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
 
@@ -118,6 +126,8 @@ export const WorkbenchPanel =
                                 <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} />
                             </Switch>
                         </Grid>
                     </Grid>
@@ -133,6 +143,8 @@ export const WorkbenchPanel =
             <CopyProcessDialog />
             <CreateCollectionDialog />
             <CreateProjectDialog />
+            <CreateRepositoryDialog />
+            <CreateSshKeyDialog />
             <CurrentTokenDialog />
             <FileRemoveDialog />
             <FilesUploadCollectionDialog />
@@ -143,8 +155,12 @@ export const WorkbenchPanel =
             <PartialCopyCollectionDialog />
             <ProcessCommandDialog />
             <ProcessInputDialog />
+            <ProjectPropertiesDialog />
             <RemoveProcessDialog />
+            <RemoveRepositoryDialog />
             <RenameFileDialog />
+            <RepositoryAttributesDialog />
+            <RepositoriesSampleGitDialog />
             <RichTextEditorDialog />
             <SharingDialog />
             <Snackbar />