[ARVADOS-WORKBENCH2] updated: 1.3.0-167-g47b1e9b2

Git user git at public.curoverse.com
Sun Dec 16 17:39:48 EST 2018


Summary of changes:
 src/common/labels.ts                               |   6 +
 src/components/autocomplete/autocomplete.tsx       |   5 +-
 src/components/data-explorer/data-explorer.tsx     |   7 +-
 src/components/icon/icon.tsx                       |   2 +
 src/index.tsx                                      |   2 +
 src/models/link.ts                                 |   5 +-
 src/models/resource.ts                             |   3 +
 src/routes/route-change-handlers.ts                |  24 +++-
 src/routes/routes.ts                               |  24 +++-
 .../ancestors-service/ancestors-service.ts         |  21 +++-
 src/services/workflow-service/workflow-service.ts  |  35 ++++++
 src/store/advanced-tab/advanced-tab.ts             |  50 +++++++-
 src/store/auth/auth-action.ts                      |   9 +-
 .../collection-panel/collection-panel-action.ts    |  12 ++
 src/store/context-menu/context-menu-actions.ts     |   2 +
 src/store/dialog/dialog-actions.ts                 |   3 +-
 src/store/dialog/dialog-reducer.ts                 |  13 +-
 .../group-details-panel-actions.ts                 | 112 +++++++++++++++++
 src/store/groups-panel/groups-panel-actions.ts     | 132 ++++++++++++++-------
 .../groups-panel-middleware-service.ts             |   2 +-
 src/store/link-panel/link-panel-actions.ts         |  57 +++++++++
 .../link-panel-middleware-service.ts}              |  40 +++----
 src/store/navigation/navigation-action.ts          |  12 +-
 src/store/process-panel/process-panel-actions.ts   |  15 +++
 src/store/processes/process-input-actions.ts       |  11 +-
 src/store/processes/process-update-actions.ts      |   3 +-
 .../run-process-panel/run-process-panel-actions.ts |  27 ++++-
 .../run-process-panel/run-process-panel-reducer.ts |  12 ++
 src/store/store.ts                                 |   6 +
 src/store/users/users-actions.ts                   |  17 ++-
 .../virtual-machines/virtual-machines-actions.ts   |  32 +++--
 src/store/workbench/workbench-actions.ts           |   8 ++
 src/validators/min-length.tsx                      |  10 ++
 src/validators/validators.tsx                      |   1 +
 .../action-sets/group-member-action-set.ts         |   2 +-
 .../{ssh-key-action-set.ts => link-action-set.ts}  |  10 +-
 src/views-components/context-menu/context-menu.tsx |   3 +-
 src/views-components/data-explorer/renderers.tsx   |  72 ++++++++++-
 .../dialog-forms/add-group-member-dialog.tsx       |  48 ++++++++
 .../dialog-update/dialog-process-update.tsx        |   3 +-
 .../form-fields/process-form-fields.tsx            |   9 +-
 .../groups-dialog/member-attributes-dialog.tsx     |   3 +-
 .../groups-dialog/member-remove-dialog.ts          |   2 +-
 .../links-dialog/attributes-dialog.tsx             |  73 ++++++++++++
 .../remove-dialog.tsx                              |   8 +-
 src/views-components/main-app-bar/account-menu.tsx |  18 +--
 src/views-components/main-app-bar/admin-menu.tsx   |  43 +++++++
 src/views-components/main-app-bar/main-app-bar.tsx |   4 +-
 .../main-content-bar/main-content-bar.tsx          |   8 +-
 .../sharing-dialog/people-select.tsx               |   3 +
 src/views/collection-panel/collection-panel.tsx    |  17 ++-
 .../group-details-panel/group-details-panel.tsx    |   4 +-
 src/views/groups-panel/groups-panel.tsx            |   2 +-
 src/views/link-panel/link-panel-root.tsx           |  92 ++++++++++++++
 src/views/link-panel/link-panel.tsx                |  35 ++++++
 .../process-panel/process-information-card.tsx     |   9 +-
 src/views/process-panel/process-panel-root.tsx     |   4 +-
 src/views/process-panel/process-panel.tsx          |   5 +-
 .../repositories-panel/repositories-panel.tsx      |   2 +-
 .../run-process-panel/run-process-second-step.tsx  |  32 ++++-
 .../run-process-panel/workflow-preset-select.tsx   |  68 +++++++++++
 .../virtual-machine-admin-panel.tsx                | 112 +++++++++++++++++
 ...ne-panel.tsx => virtual-machine-user-panel.tsx} | 131 +++++++-------------
 src/views/workbench/workbench.tsx                  |  17 ++-
 64 files changed, 1294 insertions(+), 265 deletions(-)
 create mode 100644 src/store/link-panel/link-panel-actions.ts
 copy src/store/{users/user-panel-middleware-service.ts => link-panel/link-panel-middleware-service.ts} (65%)
 create mode 100644 src/validators/min-length.tsx
 copy src/views-components/context-menu/action-sets/{ssh-key-action-set.ts => link-action-set.ts} (72%)
 create mode 100644 src/views-components/dialog-forms/add-group-member-dialog.tsx
 create mode 100644 src/views-components/links-dialog/attributes-dialog.tsx
 copy src/views-components/{ssh-keys-dialog => links-dialog}/remove-dialog.tsx (68%)
 create mode 100644 src/views-components/main-app-bar/admin-menu.tsx
 create mode 100644 src/views/link-panel/link-panel-root.tsx
 create mode 100644 src/views/link-panel/link-panel.tsx
 create mode 100644 src/views/run-process-panel/workflow-preset-select.tsx
 create mode 100644 src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx
 rename src/views/virtual-machine-panel/{virtual-machine-panel.tsx => virtual-machine-user-panel.tsx} (53%)

       via  47b1e9b2fa97d03438d4ef35f90a5fd0a33bbe34 (commit)
       via  d4b62bc7f7c6e0caf13cf9de78bd2bb9c306e497 (commit)
       via  8634bd88e7bbb8f8d62bbc4016f062fbe30234c8 (commit)
       via  59329caeae17903b97b90b167df5a8122a0c9d95 (commit)
       via  4a8dc9ca8b3c18cf0c21a2537ecef40da5522b67 (commit)
       via  879059e4c20ed40c59a992bce7b1b18bba61d672 (commit)
       via  20aeb9bd5ac0416709beab209b2e33cff88ab753 (commit)
       via  295d62fd5e44819cb55737a86c42db633e097cd8 (commit)
       via  e94e528642f80d57bb6ae5bb717880b2b9adeaca (commit)
       via  7f2af309d0184d3515a1f910bbcb6435f5cd58fb (commit)
       via  a4927ea74470ad483921813d93597b451e3d8e3e (commit)
       via  163d52ede5411167eb4d5786f40b382d992c5126 (commit)
       via  fc43f027e70d0702c88aa61c92dfeed7ac8b793b (commit)
       via  d7a29f892371764b1bff2e6ec64f8011c001b725 (commit)
       via  7cf7cf1a0b0066d044e4648a1311ad7241317128 (commit)
       via  6e92c390c1302fe8d680ebc03c8d048cf7f11fdf (commit)
       via  d8dea15148aaf550018d9f8bf1b273da6bc79f12 (commit)
       via  7ce310947c62c1f514a4c0fe8a367cfff2baf407 (commit)
       via  413bef83277b0623b319f0c651448b0eaf39048a (commit)
       via  936aa32e065b7f672e27b95262720c2ce8258bf6 (commit)
       via  920ccdc45d7c3a8517b430ab17af53c3fc23cf6d (commit)
       via  8c9cf2d12a513379d13db279b076314b292c037e (commit)
       via  454ef1c106b3d738526d65ecfae8db98ad7bebc2 (commit)
       via  090f4825bdd30925a10c6df1b9493df0c2e8f541 (commit)
       via  183874da8b5eb84617e3c81a586c0f7c09946d89 (commit)
       via  877a089738b525098e5e6e63179b6826408f9b5d (commit)
       via  edb6e7b588bc443de1c54782812064f00e6e5b53 (commit)
       via  9fe5431b326d565cb423613f7752a4dad9d9aedd (commit)
       via  1832d4a40997469ae0c2d3e6f2e5a552b834118b (commit)
       via  3dbe57077135d1684407ba6cc2a0d20cfcb33618 (commit)
       via  00e249f6a7b5e9da3a4b39bfc9d88ec96e928ec0 (commit)
       via  4d2ff0c66175bf47c5643e9500c2cc6d7caf8c8b (commit)
       via  fbeaaaf7f54590fc6fd02b990ee8e4fe1be41817 (commit)
       via  4f09ca588d03cc6e1be52190f902c21a0ae2a850 (commit)
       via  bbfd038f8d53725f154ff139229a74d961792915 (commit)
       via  a8350416b8f225a64aa207c5823e42ede43fd7d0 (commit)
       via  54063448526c6eac346823d6cc66ba7a05f2cce7 (commit)
       via  7a81b9d2c37ceb5add5df8fe9fe48b409d971e37 (commit)
       via  9a29063c1833eb300da899559bc322e7bca50f97 (commit)
       via  188fed64a7cfe68460f64a874dba3fd280d5d561 (commit)
       via  91fc1bd2a1df91b37755f3453e6e9693baa4ce64 (commit)
       via  2b1802c27fe8eb3664da3378fa7f59761d9ce184 (commit)
       via  4f4f2feaf1ac31946a87290ba4eef3a6b5455f2d (commit)
       via  1b15486e36f96b209a1854c1f33f8330e97f1b94 (commit)
       via  6c0ca05293d2d1bb5b4b0df63f541eb4f75428c3 (commit)
       via  88c4b93c3e4f05afff419374277299d61ac61176 (commit)
       via  6eaf8881f39c83f9073921277f81274425921054 (commit)
       via  4e3cefd4fa42762aac756f3163dfff9047f2e516 (commit)
       via  c0b8c031bf6327c8ed22fb05ec40f3045b5aa1f1 (commit)
       via  860aa7438d52897f646c3482f8656be1193d8123 (commit)
       via  c7b35b9342e953cc3cae862b6958c18589f48037 (commit)
       via  94ca0c19fac51ae89bed3a9bbb2b90545697dfbf (commit)
       via  6c12dcde231f8a6d419a4e154d6906bce944963c (commit)
       via  222a0099d2b8285b8770092f5da01314e0c7de7d (commit)
       via  4c3faee100f2d676bb18dc68f7cf1c4ac25ae50d (commit)
       via  3fdf49aeaf054284ec59e18885e66f798777ada9 (commit)
       via  b8e9a146d964192cc2f0bdc95100644fa53f7ca6 (commit)
       via  bf1d33cba4c15502866dda0ba4385d746033e773 (commit)
       via  6aadd480a93c6b1332cba0d3924362af11412e02 (commit)
       via  6f071fa34ec74d0ba035eb57e102307763d99496 (commit)
       via  f96eb4f60a314f2e5b0a21afd1ab836598d6c91f (commit)
       via  64c8c2628cbeadba5dce7e18e41028108142b766 (commit)
       via  d366e025618106edb2419941a041cd0f4214b245 (commit)
      from  78e1b6a903071209acc47ba9272ce87a6561e67e (commit)

Those revisions listed above that are new to this repository have
not appeared on any other notification email; so we list those
revisions in full, below.


commit 47b1e9b2fa97d03438d4ef35f90a5fd0a33bbe34
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 23:24:56 2018 +0100

    Update group members implementation to match docs
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts
index 1c6223bd..8632098e 100644
--- a/src/store/groups-panel/groups-panel-actions.ts
+++ b/src/store/groups-panel/groups-panel-actions.ts
@@ -110,29 +110,6 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
         }
     };
 
-interface DeleteGroupMemberArgs {
-    user: { uuid: string, name: string };
-    group: { uuid: string, name: string };
-    dispatch: Dispatch;
-    permissionService: PermissionService;
-}
-
-export const deleteGroupMember = async ({ user, group, ...args }: DeleteGroupMemberArgs) => {
-
-    await deletePermission({
-        tail: user,
-        head: group,
-        ...args,
-    });
-
-    await deletePermission({
-        tail: group,
-        head: user,
-        ...args,
-    });
-
-};
-
 interface AddGroupMemberArgs {
     user: { uuid: string, name: string };
     group: { uuid: string, name: string };
@@ -140,17 +117,14 @@ interface AddGroupMemberArgs {
     permissionService: PermissionService;
 }
 
+/**
+ * Group membership is determined by whether the group has can_read permission on an object. 
+ * If a group G can_read an object A, then we say A is a member of G.
+ * 
+ * [Permission model docs](https://doc.arvados.org/api/permission-model.html)
+ */
 export const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
 
-
-
-    await createPermission({
-        head: { ...group },
-        tail: { ...user },
-        permissionLevel: PermissionLevel.CAN_MANAGE,
-        ...args,
-    });
-
     await createPermission({
         head: { ...user },
         tail: { ...group },
@@ -189,6 +163,23 @@ const createPermission = async ({ head, tail, permissionLevel, dispatch, permiss
 
 };
 
+interface DeleteGroupMemberArgs {
+    user: { uuid: string, name: string };
+    group: { uuid: string, name: string };
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+export const deleteGroupMember = async ({ user, group, ...args }: DeleteGroupMemberArgs) => {
+
+    await deletePermission({
+        tail: group,
+        head: user,
+        ...args,
+    });
+
+};
+
 interface DeletePermissionLinkArgs {
     head: { uuid: string, name: string };
     tail: { uuid: string, name: string };
diff --git a/src/store/groups-panel/groups-panel-middleware-service.ts b/src/store/groups-panel/groups-panel-middleware-service.ts
index d3ff5932..7c70666e 100644
--- a/src/store/groups-panel/groups-panel-middleware-service.ts
+++ b/src/store/groups-panel/groups-panel-middleware-service.ts
@@ -67,7 +67,7 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService
                 const permissions = await this.services.permissionService.list({
 
                     filters: new FilterBuilder()
-                        .addIn('headUuid', response.items.map(item => item.uuid))
+                        .addIn('tailUuid', response.items.map(item => item.uuid))
                         .getFilters()
 
                 });
diff --git a/src/views/groups-panel/groups-panel.tsx b/src/views/groups-panel/groups-panel.tsx
index 44a262fd..8fa47f71 100644
--- a/src/views/groups-panel/groups-panel.tsx
+++ b/src/views/groups-panel/groups-panel.tsx
@@ -124,7 +124,7 @@ const GroupMembersCount = connect(
         const permissions = filterResources((resource: LinkResource) =>
             resource.kind === ResourceKind.LINK &&
             resource.linkClass === LinkClass.PERMISSION &&
-            resource.headUuid === props.uuid
+            resource.tailUuid === props.uuid
         )(state.resources);
 
         return {

commit d4b62bc7f7c6e0caf13cf9de78bd2bb9c306e497
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 22:54:08 2018 +0100

    Hide search input from GroupDetailsPanel
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/views/group-details-panel/group-details-panel.tsx b/src/views/group-details-panel/group-details-panel.tsx
index 7334a93a..f81c2404 100644
--- a/src/views/group-details-panel/group-details-panel.tsx
+++ b/src/views/group-details-panel/group-details-panel.tsx
@@ -96,6 +96,7 @@ export const GroupDetailsPanel = connect(
                     onContextMenu={this.handleContextMenu}
                     contextMenuColumn={true}
                     hideColumnSelector
+                    hideSearchInput
                     actions={
                         <Grid container justify='flex-end'>
                             <Button

commit 8634bd88e7bbb8f8d62bbc4016f062fbe30234c8
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 22:52:26 2018 +0100

    Clean up app bar menus after merge
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/views-components/main-app-bar/account-menu.tsx b/src/views-components/main-app-bar/account-menu.tsx
index a1116540..028116ea 100644
--- a/src/views-components/main-app-bar/account-menu.tsx
+++ b/src/views-components/main-app-bar/account-menu.tsx
@@ -11,11 +11,6 @@ import { DispatchProp, connect } from 'react-redux';
 import { logout } from '~/store/auth/auth-action';
 import { RootState } from "~/store/store";
 import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
-import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
-import {
-    navigateToKeepServices, navigateToComputeNodes,
-    navigateToApiClientAuthorizations, navigateToGroups
-} from '~/store/navigation/navigation-action';
 import { navigateToUsers } from '~/store/navigation/navigation-action';
 import { navigateToSshKeysUser, navigateToMyAccount } from '~/store/navigation/navigation-action';
 import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
@@ -39,13 +34,7 @@ export const AccountMenu = connect(mapStateToProps)(
                     {getUserFullname(user)}
                 </MenuItem>
                 <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
-                {!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
                 <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
-                <MenuItem onClick={() => dispatch(navigateToUsers)}>Users</MenuItem>
-                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToGroups)}>Groups</MenuItem>}
-                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToApiClientAuthorizations)}>Api Tokens</MenuItem>}
-                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem>}
-                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToComputeNodes)}>Compute Nodes</MenuItem>}
                 <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
                 <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
                 <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
diff --git a/src/views-components/main-app-bar/admin-menu.tsx b/src/views-components/main-app-bar/admin-menu.tsx
index 88aafbae..8f9527e0 100644
--- a/src/views-components/main-app-bar/admin-menu.tsx
+++ b/src/views-components/main-app-bar/admin-menu.tsx
@@ -35,9 +35,9 @@ export const AdminMenu = connect(mapStateToProps)(
                 <MenuItem onClick={() => dispatch(NavigationAction.navigateToSshKeysAdmin)}>Ssh Keys</MenuItem>
                 <MenuItem onClick={() => dispatch(NavigationAction.navigateToApiClientAuthorizations)}>Api Tokens</MenuItem>
                 <MenuItem onClick={() => dispatch(openUserPanel())}>Users</MenuItem>
+                <MenuItem onClick={() => dispatch(NavigationAction.navigateToGroups)}>Groups</MenuItem>}
                 <MenuItem onClick={() => dispatch(NavigationAction.navigateToComputeNodes)}>Compute Nodes</MenuItem>
                 <MenuItem onClick={() => dispatch(NavigationAction.navigateToKeepServices)}>Keep Services</MenuItem>
                 <MenuItem onClick={() => dispatch(NavigationAction.navigateToLinks)}>Links</MenuItem>
-                <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
             </DropdownMenu>
             : null);

commit 59329caeae17903b97b90b167df5a8122a0c9d95
Merge: 4a8dc9ca 6e92c390
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 22:44:03 2018 +0100

    Merge branch 'master'
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --cc src/index.tsx
index f64e076a,e73f08c4..508fa7c3
--- a/src/index.tsx
+++ b/src/index.tsx
@@@ -56,8 -56,7 +56,9 @@@ import { virtualMachineActionSet } fro
  import { userActionSet } from '~/views-components/context-menu/action-sets/user-action-set';
  import { computeNodeActionSet } from '~/views-components/context-menu/action-sets/compute-node-action-set';
  import { apiClientAuthorizationActionSet } from '~/views-components/context-menu/action-sets/api-client-authorization-action-set';
 +import { groupActionSet } from '~/views-components/context-menu/action-sets/group-action-set';
 +import { groupMemberActionSet } from '~/views-components/context-menu/action-sets/group-member-action-set';
+ import { linkActionSet } from '~/views-components/context-menu/action-sets/link-action-set';
  
  console.log(`Starting arvados [${getBuildInfo()}]`);
  
@@@ -79,10 -78,9 +80,11 @@@ addMenuActionSet(ContextMenuKind.SSH_KE
  addMenuActionSet(ContextMenuKind.VIRTUAL_MACHINE, virtualMachineActionSet);
  addMenuActionSet(ContextMenuKind.KEEP_SERVICE, keepServiceActionSet);
  addMenuActionSet(ContextMenuKind.USER, userActionSet);
+ addMenuActionSet(ContextMenuKind.LINK, linkActionSet);
  addMenuActionSet(ContextMenuKind.NODE, computeNodeActionSet);
  addMenuActionSet(ContextMenuKind.API_CLIENT_AUTHORIZATION, apiClientAuthorizationActionSet);
 +addMenuActionSet(ContextMenuKind.GROUPS, groupActionSet);
 +addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
  
  fetchConfig()
      .then(({ config, apiHost }) => {
diff --cc src/models/link.ts
index 1798e447,d931f7f2..785d531c
--- a/src/models/link.ts
+++ b/src/models/link.ts
@@@ -2,8 -2,9 +2,8 @@@
  //
  // SPDX-License-Identifier: AGPL-3.0
  
- import { Resource, ResourceKind } from "./resource";
 -import { Resource } from "./resource";
  import { TagProperty } from "~/models/tag";
 -import { ResourceKind } from '~/models/resource';
++import { Resource, ResourceKind } from '~/models/resource';
  
  export interface LinkResource extends Resource {
      headUuid: string;
diff --cc src/routes/route-change-handlers.ts
index cbfbfd38,655c806f..7b37509f
--- a/src/routes/route-change-handlers.ts
+++ b/src/routes/route-change-handlers.ts
@@@ -34,8 -37,9 +37,11 @@@ const handleLocationChange = (store: Ro
      const apiClientAuthorizationsMatch = Routes.matchApiClientAuthorizationsRoute(pathname);
      const myAccountMatch = Routes.matchMyAccountRoute(pathname);
      const userMatch = Routes.matchUsersRoute(pathname);
 +    const groupsMatch = Routes.matchGroupsRoute(pathname);
 +    const groupDetailsMatch = Routes.matchGroupDetailsRoute(pathname);
+     const linksMatch = Routes.matchLinksRoute(pathname);
+ 
+     store.dispatch(dialogActions.CLOSE_ALL_DIALOGS());
  
      if (projectMatch) {
          store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id));
@@@ -73,11 -81,9 +83,13 @@@
          store.dispatch(WorkbenchActions.loadApiClientAuthorizations);
      } else if (myAccountMatch) {
          store.dispatch(WorkbenchActions.loadMyAccount);
-     }else if (userMatch) {
+     } else if (userMatch) {
          store.dispatch(WorkbenchActions.loadUsers);
 +    } else if (groupsMatch) {
 +        store.dispatch(WorkbenchActions.loadGroupsPanel);
 +    } else if (groupDetailsMatch) {
 +        store.dispatch(WorkbenchActions.loadGroupDetailsPanel(groupDetailsMatch.params.id));
+     } else if (linksMatch) {
+         store.dispatch(WorkbenchActions.loadLinks);
      }
  };
diff --cc src/routes/routes.ts
index 6d44725c,05f6663f..661a065e
--- a/src/routes/routes.ts
+++ b/src/routes/routes.ts
@@@ -28,8 -30,7 +30,9 @@@ export const Routes = 
      COMPUTE_NODES: `/nodes`,
      USERS: '/users',
      API_CLIENT_AUTHORIZATIONS: `/api_client_authorizations`,
 +    GROUPS: '/groups',
 +    GROUP_DETAILS: `/group/:id(${RESOURCE_UUID_PATTERN})`,
+     LINKS: '/links'
  };
  
  export const getResourceUrl = (uuid: string) => {
@@@ -113,8 -118,5 +122,11 @@@ export const matchComputeNodesRoute = (
  export const matchApiClientAuthorizationsRoute = (route: string) =>
      matchPath(route, { path: Routes.API_CLIENT_AUTHORIZATIONS });
  
 +export const matchGroupsRoute = (route: string) =>
 +    matchPath(route, { path: Routes.GROUPS });
 +
 +export const matchGroupDetailsRoute = (route: string) =>
 +    matchPath<ResourceRouteParams>(route, { path: Routes.GROUP_DETAILS });
++    
+ export const matchLinksRoute = (route: string) =>
 -    matchPath(route, { path: Routes.LINKS });
++    matchPath(route, { path: Routes.LINKS });
diff --cc src/store/navigation/navigation-action.ts
index 9aa4a32c,92443c02..c53c55e8
--- a/src/store/navigation/navigation-action.ts
+++ b/src/store/navigation/navigation-action.ts
@@@ -81,6 -84,4 +87,8 @@@ export const navigateToUsers = push(Rou
  
  export const navigateToApiClientAuthorizations = push(Routes.API_CLIENT_AUTHORIZATIONS);
  
 -export const navigateToLinks = push(Routes.LINKS);
 +export const navigateToGroups = push(Routes.GROUPS);
 +
 +export const navigateToGroupDetails = compose(push, getGroupUrl);
++
++export const navigateToLinks = push(Routes.LINKS);
diff --cc src/store/store.ts
index ad70868e,792224d2..3aef8f50
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@@ -50,10 -50,8 +50,12 @@@ import { UserMiddlewareService } from '
  import { USERS_PANEL_ID } from '~/store/users/users-actions';
  import { computeNodesReducer } from '~/store/compute-nodes/compute-nodes-reducer';
  import { apiClientAuthorizationsReducer } from '~/store/api-client-authorizations/api-client-authorizations-reducer';
 +import { GroupsPanelMiddlewareService } from '~/store/groups-panel/groups-panel-middleware-service';
 +import { GROUPS_PANEL_ID } from '~/store/groups-panel/groups-panel-actions';
 +import { GroupDetailsPanelMiddlewareService } from '~/store/group-details-panel/group-details-panel-middleware-service';
 +import { GROUP_DETAILS_PANEL_ID } from '~/store/group-details-panel/group-details-panel-actions';
+ import { LINK_PANEL_ID } from '~/store/link-panel/link-panel-actions';
+ import { LinkMiddlewareService } from '~/store/link-panel/link-panel-middleware-service';
  
  const composeEnhancers =
      (process.env.NODE_ENV === 'development' &&
@@@ -88,13 -86,9 +90,16 @@@ export function configureStore(history
      const userPanelMiddleware = dataExplorerMiddleware(
          new UserMiddlewareService(services, USERS_PANEL_ID)
      );
 +    const groupsPanelMiddleware = dataExplorerMiddleware(
 +        new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID)
 +    );
 +    const groupDetailsPanelMiddleware = dataExplorerMiddleware(
 +        new GroupDetailsPanelMiddlewareService(services, GROUP_DETAILS_PANEL_ID)
 +    );
 +
+     const linkPanelMiddleware = dataExplorerMiddleware(
+         new LinkMiddlewareService(services, LINK_PANEL_ID)
+     );
      const middlewares: Middleware[] = [
          routerMiddleware(history),
          thunkMiddleware.withExtraArgument(services),
@@@ -105,8 -99,7 +110,9 @@@
          sharedWithMePanelMiddleware,
          workflowPanelMiddleware,
          userPanelMiddleware,
 +        groupsPanelMiddleware,
 +        groupDetailsPanelMiddleware,
+         linkPanelMiddleware
      ];
      const enhancer = composeEnhancers(applyMiddleware(...middlewares));
      return createStore(rootReducer, enhancer);
diff --cc src/store/workbench/workbench-actions.ts
index e185e8cf,85540f0b..af2afab2
--- a/src/store/workbench/workbench-actions.ts
+++ b/src/store/workbench/workbench-actions.ts
@@@ -100,8 -98,7 +102,9 @@@ export const loadWorkbench = () =
                  dispatch(workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns }));
                  dispatch(searchResultsPanelActions.SET_COLUMNS({ columns: searchResultsPanelColumns }));
                  dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns }));
 +                dispatch(groupPanelActions.GroupsPanelActions.SET_COLUMNS({ columns: groupsPanelColumns }));
 +                dispatch(groupDetailsPanelActions.GroupDetailsPanelActions.SET_COLUMNS({columns: groupDetailsPanelColumns}));
+                 dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
                  dispatch<any>(initSidePanelTree());
                  if (router.location) {
                      const match = matchRootRoute(router.location.pathname);
diff --cc src/views-components/context-menu/context-menu.tsx
index e148a78a,a9200ebb..4ce2f521
--- a/src/views-components/context-menu/context-menu.tsx
+++ b/src/views-components/context-menu/context-menu.tsx
@@@ -75,7 -75,6 +75,8 @@@ export enum ContextMenuKind 
      VIRTUAL_MACHINE = "VirtualMachine",
      KEEP_SERVICE = "KeepService",
      USER = "User",
 +    NODE = "Node",
 +    GROUPS = "Group",
-     GROUP_MEMBER = "GroupMember"
++    GROUP_MEMBER = "GroupMember",
+     LINK = "Link",
 -    NODE = "Node"
  }
diff --cc src/views-components/main-app-bar/account-menu.tsx
index cb8fd566,1609aafa..a1116540
--- a/src/views-components/main-app-bar/account-menu.tsx
+++ b/src/views-components/main-app-bar/account-menu.tsx
@@@ -12,12 -12,8 +12,13 @@@ import { logout } from '~/store/auth/au
  import { RootState } from "~/store/store";
  import { openCurrentTokenDialog } from '~/store/current-token-dialog/current-token-dialog-actions';
  import { openRepositoriesPanel } from "~/store/repositories/repositories-actions";
- import { 
-     navigateToSshKeys, navigateToKeepServices, navigateToComputeNodes,
-     navigateToApiClientAuthorizations, navigateToMyAccount, navigateToGroups
++import {
++    navigateToKeepServices, navigateToComputeNodes,
++    navigateToApiClientAuthorizations, navigateToGroups
 +} from '~/store/navigation/navigation-action';
- import { openVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
 +import { navigateToUsers } from '~/store/navigation/navigation-action';
+ import { navigateToSshKeysUser, navigateToMyAccount } from '~/store/navigation/navigation-action';
+ import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
  
  interface AccountMenuProps {
      user?: User;
@@@ -37,15 -33,10 +38,15 @@@ export const AccountMenu = connect(mapS
                  <MenuItem>
                      {getUserFullname(user)}
                  </MenuItem>
-                 <MenuItem onClick={() => dispatch(openVirtualMachines())}>Virtual Machines</MenuItem>
-                 <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
+                 <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
+                 {!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
                  <MenuItem onClick={() => dispatch(openCurrentTokenDialog)}>Current token</MenuItem>
-                 <MenuItem onClick={() => dispatch(navigateToSshKeys)}>Ssh Keys</MenuItem>
 +                <MenuItem onClick={() => dispatch(navigateToUsers)}>Users</MenuItem>
-                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToGroups)}>Groups</MenuItem> }
-                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToApiClientAuthorizations)}>Api Tokens</MenuItem> }
-                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem> }
-                 { user.isAdmin && <MenuItem onClick={() => dispatch(navigateToComputeNodes)}>Compute Nodes</MenuItem> }
++                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToGroups)}>Groups</MenuItem>}
++                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToApiClientAuthorizations)}>Api Tokens</MenuItem>}
++                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToKeepServices)}>Keep Services</MenuItem>}
++                {user.isAdmin && <MenuItem onClick={() => dispatch(navigateToComputeNodes)}>Compute Nodes</MenuItem>}
+                 <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
                  <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
                  <MenuItem onClick={() => dispatch(logout())}>Logout</MenuItem>
              </DropdownMenu>
diff --cc src/views/workbench/workbench.tsx
index 695834a6,025540e2..bff328e8
--- a/src/views/workbench/workbench.tsx
+++ b/src/views/workbench/workbench.tsx
@@@ -160,8 -158,7 +166,9 @@@ export const WorkbenchPanel 
                                  <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
                                  <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
                                  <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
 +                                <Route path={Routes.GROUPS} component={GroupsPanel} />
 +                                <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
+                                 <Route path={Routes.LINKS} component={LinkPanel} />
                              </Switch>
                          </Grid>
                      </Grid>
@@@ -203,9 -197,8 +211,10 @@@
              <ProjectPropertiesDialog />
              <RemoveApiClientAuthorizationDialog />
              <RemoveComputeNodeDialog />
 +            <RemoveGroupDialog />
 +            <RemoveGroupMemberDialog />
              <RemoveKeepServiceDialog />
+             <RemoveLinkDialog />
              <RemoveProcessDialog />
              <RemoveRepositoryDialog />
              <RemoveSshKeyDialog />

commit 4a8dc9ca8b3c18cf0c21a2537ecef40da5522b67
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 22:28:58 2018 +0100

    Enable autofocus to AddGroupMembersDialog
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/views-components/dialog-forms/add-group-member-dialog.tsx b/src/views-components/dialog-forms/add-group-member-dialog.tsx
index 3012a4db..f4a5c2cf 100644
--- a/src/views-components/dialog-forms/add-group-member-dialog.tsx
+++ b/src/views-components/dialog-forms/add-group-member-dialog.tsx
@@ -41,6 +41,7 @@ const UsersFieldValidation = [minLength(1, () => 'Select at least one user')];
 
 const UsersSelect = ({ fields }: WrappedFieldArrayProps<Person>) =>
     <PeopleSelect
+        autofocus
         label='Enter email adresses '
         items={fields.getAll() || []}
         onSelect={fields.push}

commit 879059e4c20ed40c59a992bce7b1b18bba61d672
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 22:27:58 2018 +0100

    Add autofocus prop to people select
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/views-components/sharing-dialog/people-select.tsx b/src/views-components/sharing-dialog/people-select.tsx
index bee59c22..f62e6f55 100644
--- a/src/views-components/sharing-dialog/people-select.tsx
+++ b/src/views-components/sharing-dialog/people-select.tsx
@@ -17,10 +17,12 @@ export interface Person {
     email: string;
     uuid: string;
 }
+
 export interface PeopleSelectProps {
 
     items: Person[];
     label?: string;
+    autofocus?: boolean;
 
     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
@@ -53,6 +55,7 @@ export const PeopleSelect = connect()(
                     value={this.state.value}
                     items={this.props.items}
                     suggestions={this.state.suggestions}
+                    autofocus={this.props.autofocus}
                     onChange={this.handleChange}
                     onCreate={this.handleCreate}
                     onSelect={this.handleSelect}

commit 20aeb9bd5ac0416709beab209b2e33cff88ab753
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 22:27:03 2018 +0100

    Add autofocus prop to autocomplete
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/components/autocomplete/autocomplete.tsx b/src/components/autocomplete/autocomplete.tsx
index c5811bb6..b250c7b8 100644
--- a/src/components/autocomplete/autocomplete.tsx
+++ b/src/components/autocomplete/autocomplete.tsx
@@ -15,6 +15,7 @@ export interface AutocompleteProps<Item, Suggestion> {
     suggestions?: Suggestion[];
     error?: boolean;
     helperText?: string;
+    autofocus?: boolean;
     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
     onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
     onFocus?: (event: React.FocusEvent<HTMLInputElement>) => void;
@@ -29,6 +30,7 @@ export interface AutocompleteState {
     suggestionsOpen: boolean;
     selectedSuggestionIndex: number;
 }
+
 export class Autocomplete<Value, Suggestion> extends React.Component<AutocompleteProps<Value, Suggestion>, AutocompleteState> {
 
     state = {
@@ -59,6 +61,7 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
 
     renderInput() {
         return <Input
+            autoFocus={this.props.autofocus}
             inputRef={this.inputRef}
             value={this.props.value}
             startAdornment={this.renderChips()}
@@ -124,7 +127,7 @@ export class Autocomplete<Value, Suggestion> extends React.Component<Autocomplet
         if (event.key === 'Enter') {
             if (this.isSuggestionBoxOpen() && selectedSuggestionIndex < suggestions.length) {
                 // prevent form submissions when selecting a suggestion
-                event.preventDefault(); 
+                event.preventDefault();
                 onSelect(suggestions[selectedSuggestionIndex]);
             } else if (this.props.value.length > 0) {
                 onCreate();

commit 295d62fd5e44819cb55737a86c42db633e097cd8
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 22:16:44 2018 +0100

    Implement group member removal
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts
index a4370d0c..4ad01594 100644
--- a/src/store/group-details-panel/group-details-panel-actions.ts
+++ b/src/store/group-details-panel/group-details-panel-actions.ts
@@ -9,7 +9,7 @@ import { getProperty } from '~/store/properties/properties';
 import { Person } from '~/views-components/sharing-dialog/people-select';
 import { dialogActions } from '~/store/dialog/dialog-actions';
 import { reset, startSubmit } from 'redux-form';
-import { addGroupMember } from '~/store/groups-panel/groups-panel-actions';
+import { addGroupMember, deleteGroupMember } from '~/store/groups-panel/groups-panel-actions';
 import { getResource } from '~/store/resources/resources';
 import { GroupResource } from '~/models/group';
 import { RootState } from '~/store/store';
@@ -17,6 +17,7 @@ import { ServiceRepository } from '~/services/services';
 import { PermissionResource } from '~/models/permission';
 import { GroupDetailsPanel } from '~/views/group-details-panel/group-details-panel';
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { UserResource, getUserFullname } from '~/models/user';
 
 export const GROUP_DETAILS_PANEL_ID = 'groupDetailsPanel';
 export const ADD_GROUP_MEMBERS_DIALOG = 'addGrupMembers';
@@ -98,9 +99,34 @@ export const openRemoveGroupMemberDialog = (uuid: string) =>
     };
 
 export const removeGroupMember = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
-        await services.permissionService.delete(uuid);
-        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-        dispatch(GroupDetailsPanelActions.REQUEST_ITEMS());
+
+    async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+
+        const groupUuid = getCurrentGroupDetailsPanelUuid(getState().properties);
+
+        if (groupUuid) {
+
+            const group = getResource<GroupResource>(groupUuid)(getState().resources);
+            const user = getResource<UserResource>(groupUuid)(getState().resources);
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+
+            await deleteGroupMember({
+                user: {
+                    uuid,
+                    name: user ? getUserFullname(user) : uuid,
+                },
+                group: {
+                    uuid: groupUuid,
+                    name: group ? group.name : groupUuid,
+                },
+                permissionService,
+                dispatch,
+            });
+
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            dispatch(GroupDetailsPanelActions.REQUEST_ITEMS());
+
+        }
+
     };
diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts
index 8bb0128c..1c6223bd 100644
--- a/src/store/groups-panel/groups-panel-actions.ts
+++ b/src/store/groups-panel/groups-panel-actions.ts
@@ -15,6 +15,7 @@ import { getCommonResourceServiceError, CommonResourceServiceError } from '~/ser
 import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 import { PermissionLevel, PermissionResource } from '~/models/permission';
 import { PermissionService } from '~/services/permission-service/permission-service';
+import { FilterBuilder } from '~/services/api/filter-builder';
 
 export const GROUPS_PANEL_ID = "groupsPanel";
 export const CREATE_GROUP_DIALOG = "createGroupDialog";
@@ -109,6 +110,29 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
         }
     };
 
+interface DeleteGroupMemberArgs {
+    user: { uuid: string, name: string };
+    group: { uuid: string, name: string };
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+export const deleteGroupMember = async ({ user, group, ...args }: DeleteGroupMemberArgs) => {
+
+    await deletePermission({
+        tail: user,
+        head: group,
+        ...args,
+    });
+
+    await deletePermission({
+        tail: group,
+        head: user,
+        ...args,
+    });
+
+};
+
 interface AddGroupMemberArgs {
     user: { uuid: string, name: string };
     group: { uuid: string, name: string };
@@ -163,4 +187,48 @@ const createPermission = async ({ head, tail, permissionLevel, dispatch, permiss
 
     }
 
+};
+
+interface DeletePermissionLinkArgs {
+    head: { uuid: string, name: string };
+    tail: { uuid: string, name: string };
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+export const deletePermission = async ({ head, tail, dispatch, permissionService }: DeletePermissionLinkArgs) => {
+
+    try {
+
+        const permissionsResponse = await permissionService.list({
+
+            filters: new FilterBuilder()
+                .addEqual('tailUuid', tail.uuid)
+                .addEqual('headUuid', head.uuid)
+                .getFilters()
+
+        });
+
+        const [permission] = permissionsResponse.items;
+
+        if (permission) {
+
+            await permissionService.delete(permission.uuid);
+
+        } else {
+
+            throw new Error('Permission not found');
+
+        }
+
+
+    } catch (e) {
+
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: `Could not delete ${tail.name} -> ${head.name} relation`,
+            kind: SnackbarKind.ERROR,
+        }));
+
+    }
+
 };
\ No newline at end of file

commit e94e528642f80d57bb6ae5bb717880b2b9adeaca
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 21:11:54 2018 +0100

    Move group details related actions to corresponding file
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts
index 75132a15..a4370d0c 100644
--- a/src/store/group-details-panel/group-details-panel-actions.ts
+++ b/src/store/group-details-panel/group-details-panel-actions.ts
@@ -14,11 +14,16 @@ import { getResource } from '~/store/resources/resources';
 import { GroupResource } from '~/models/group';
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
+import { PermissionResource } from '~/models/permission';
+import { GroupDetailsPanel } from '~/views/group-details-panel/group-details-panel';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
 
 export const GROUP_DETAILS_PANEL_ID = 'groupDetailsPanel';
 export const ADD_GROUP_MEMBERS_DIALOG = 'addGrupMembers';
 export const ADD_GROUP_MEMBERS_FORM = 'addGrupMembers';
 export const ADD_GROUP_MEMBERS_USERS_FIELD_NAME = 'users';
+export const MEMBER_ATTRIBUTES_DIALOG = 'memberAttributesDialog';
+export const MEMBER_REMOVE_DIALOG = 'memberRemoveDialog';
 
 export const GroupDetailsPanelActions = bindDataExplorerActions(GROUP_DETAILS_PANEL_ID);
 
@@ -71,3 +76,31 @@ export const addGroupMembers = ({ users }: AddGroupMembersFormData) =>
 
         }
     };
+
+export const openGroupMemberAttributes = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<PermissionResource>(uuid)(resources);
+        dispatch(dialogActions.OPEN_DIALOG({ id: MEMBER_ATTRIBUTES_DIALOG, data }));
+    };
+
+export const openRemoveGroupMemberDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: MEMBER_REMOVE_DIALOG,
+            data: {
+                title: 'Remove member',
+                text: 'Are you sure you want to remove this member from this group?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export const removeGroupMember = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+        await services.permissionService.delete(uuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        dispatch(GroupDetailsPanelActions.REQUEST_ITEMS());
+    };
diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts
index 3574ede9..8bb0128c 100644
--- a/src/store/groups-panel/groups-panel-actions.ts
+++ b/src/store/groups-panel/groups-panel-actions.ts
@@ -23,8 +23,6 @@ export const CREATE_GROUP_NAME_FIELD_NAME = 'name';
 export const CREATE_GROUP_USERS_FIELD_NAME = 'users';
 export const GROUP_ATTRIBUTES_DIALOG = 'groupAttributesDialog';
 export const GROUP_REMOVE_DIALOG = 'groupRemoveDialog';
-export const MEMBER_ATTRIBUTES_DIALOG = 'memberAttributesDialog';
-export const MEMBER_REMOVE_DIALOG = 'memberRemoveDialog';
 
 export const GroupsPanelActions = bindDataExplorerActions(GROUPS_PANEL_ID);
 
@@ -43,13 +41,6 @@ export const openGroupAttributes = (uuid: string) =>
         dispatch(dialogActions.OPEN_DIALOG({ id: GROUP_ATTRIBUTES_DIALOG, data }));
     };
 
-export const openGroupMemberAttributes = (uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const { resources } = getState();
-        const data = getResource<PermissionResource>(uuid)(resources);
-        dispatch(dialogActions.OPEN_DIALOG({ id: MEMBER_ATTRIBUTES_DIALOG, data }));
-    };
-
 export const removeGroup = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
@@ -71,27 +62,6 @@ export const openRemoveGroupDialog = (uuid: string) =>
         }));
     };
 
-export const removeGroupMember = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
-        await services.permissionService.delete(uuid);
-        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-        dispatch<any>(loadGroupsPanel());
-    };
-
-export const openRemoveGroupMemberDialog = (uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(dialogActions.OPEN_DIALOG({
-            id: MEMBER_REMOVE_DIALOG,
-            data: {
-                title: 'Remove member',
-                text: 'Are you sure you want to remove this member from this group?',
-                confirmButtonLabel: 'Remove',
-                uuid
-            }
-        }));
-    };
-
 export interface CreateGroupFormData {
     [CREATE_GROUP_NAME_FIELD_NAME]: string;
     [CREATE_GROUP_USERS_FIELD_NAME]?: Person[];
diff --git a/src/views-components/context-menu/action-sets/group-member-action-set.ts b/src/views-components/context-menu/action-sets/group-member-action-set.ts
index 73a9a773..a8b3dd1f 100644
--- a/src/views-components/context-menu/action-sets/group-member-action-set.ts
+++ b/src/views-components/context-menu/action-sets/group-member-action-set.ts
@@ -5,7 +5,7 @@
 import { ContextMenuActionSet } from "~/views-components/context-menu/context-menu-action-set";
 import { AdvancedIcon, RemoveIcon, AttributesIcon } from "~/components/icon/icon";
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
-import { openGroupMemberAttributes, openRemoveGroupMemberDialog } from "~/store/groups-panel/groups-panel-actions";
+import { openGroupMemberAttributes, openRemoveGroupMemberDialog } from '~/store/group-details-panel/group-details-panel-actions';
 
 export const groupMemberActionSet: ContextMenuActionSet = [[{
     name: "Attributes",
diff --git a/src/views-components/groups-dialog/member-attributes-dialog.tsx b/src/views-components/groups-dialog/member-attributes-dialog.tsx
index 6124cc33..7aa8653d 100644
--- a/src/views-components/groups-dialog/member-attributes-dialog.tsx
+++ b/src/views-components/groups-dialog/member-attributes-dialog.tsx
@@ -10,8 +10,7 @@ import { WithStyles, withStyles } from '@material-ui/core/styles';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { compose } from "redux";
 import { PermissionResource } from "~/models/permission";
-import { MEMBER_ATTRIBUTES_DIALOG } from "~/store/groups-panel/groups-panel-actions";
-import { UserResource } from "~/models/user";
+import { MEMBER_ATTRIBUTES_DIALOG } from '~/store/group-details-panel/group-details-panel-actions';
 
 type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
 
diff --git a/src/views-components/groups-dialog/member-remove-dialog.ts b/src/views-components/groups-dialog/member-remove-dialog.ts
index 04d144fd..bb5c3a2d 100644
--- a/src/views-components/groups-dialog/member-remove-dialog.ts
+++ b/src/views-components/groups-dialog/member-remove-dialog.ts
@@ -6,7 +6,7 @@ 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 { removeGroupMember, MEMBER_REMOVE_DIALOG } from '~/store/groups-panel/groups-panel-actions';
+import { removeGroupMember, MEMBER_REMOVE_DIALOG } from '~/store/group-details-panel/group-details-panel-actions';
 
 const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
     onConfirm: () => {

commit 7f2af309d0184d3515a1f910bbcb6435f5cd58fb
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 20:16:35 2018 +0100

    Create addGroupMembers action
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts
index e908e8e8..75132a15 100644
--- a/src/store/group-details-panel/group-details-panel-actions.ts
+++ b/src/store/group-details-panel/group-details-panel-actions.ts
@@ -8,14 +8,18 @@ import { propertiesActions } from '~/store/properties/properties-actions';
 import { getProperty } from '~/store/properties/properties';
 import { Person } from '~/views-components/sharing-dialog/people-select';
 import { dialogActions } from '~/store/dialog/dialog-actions';
-import { reset } from 'redux-form';
+import { reset, startSubmit } from 'redux-form';
+import { addGroupMember } from '~/store/groups-panel/groups-panel-actions';
+import { getResource } from '~/store/resources/resources';
+import { GroupResource } from '~/models/group';
+import { RootState } from '~/store/store';
+import { ServiceRepository } from '~/services/services';
 
 export const GROUP_DETAILS_PANEL_ID = 'groupDetailsPanel';
 export const ADD_GROUP_MEMBERS_DIALOG = 'addGrupMembers';
 export const ADD_GROUP_MEMBERS_FORM = 'addGrupMembers';
 export const ADD_GROUP_MEMBERS_USERS_FIELD_NAME = 'users';
 
-
 export const GroupDetailsPanelActions = bindDataExplorerActions(GROUP_DETAILS_PANEL_ID);
 
 export const loadGroupDetailsPanel = (groupUuid: string) =>
@@ -35,3 +39,35 @@ export const openAddGroupMembersDialog = () =>
         dispatch(dialogActions.OPEN_DIALOG({ id: ADD_GROUP_MEMBERS_DIALOG, data: {} }));
         dispatch(reset(ADD_GROUP_MEMBERS_FORM));
     };
+
+export const addGroupMembers = ({ users }: AddGroupMembersFormData) =>
+
+    async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+
+        const groupUuid = getCurrentGroupDetailsPanelUuid(getState().properties);
+
+        if (groupUuid) {
+
+            dispatch(startSubmit(ADD_GROUP_MEMBERS_FORM));
+
+            const group = getResource<GroupResource>(groupUuid)(getState().resources);
+
+            for (const user of users) {
+
+                await addGroupMember({
+                    user,
+                    group: {
+                        uuid: groupUuid,
+                        name: group ? group.name : groupUuid,
+                    },
+                    dispatch,
+                    permissionService,
+                });
+
+            }
+
+            dispatch(dialogActions.CLOSE_DIALOG({ id: ADD_GROUP_MEMBERS_FORM }));
+            dispatch(GroupDetailsPanelActions.REQUEST_ITEMS());
+
+        }
+    };
diff --git a/src/views-components/dialog-forms/add-group-member-dialog.tsx b/src/views-components/dialog-forms/add-group-member-dialog.tsx
index 3840ecd4..3012a4db 100644
--- a/src/views-components/dialog-forms/add-group-member-dialog.tsx
+++ b/src/views-components/dialog-forms/add-group-member-dialog.tsx
@@ -8,13 +8,16 @@ import { reduxForm, InjectedFormProps, WrappedFieldArrayProps, FieldArray } from
 import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
 import { FormDialog } from '~/components/form-dialog/form-dialog';
 import { PeopleSelect, Person } from '~/views-components/sharing-dialog/people-select';
-import { ADD_GROUP_MEMBERS_DIALOG, ADD_GROUP_MEMBERS_FORM, AddGroupMembersFormData, ADD_GROUP_MEMBERS_USERS_FIELD_NAME } from '~/store/group-details-panel/group-details-panel-actions';
+import { ADD_GROUP_MEMBERS_DIALOG, ADD_GROUP_MEMBERS_FORM, AddGroupMembersFormData, ADD_GROUP_MEMBERS_USERS_FIELD_NAME, addGroupMembers } from '~/store/group-details-panel/group-details-panel-actions';
 import { minLength } from '~/validators/min-length';
 
 export const AddGroupMembersDialog = compose(
     withDialog(ADD_GROUP_MEMBERS_DIALOG),
     reduxForm<AddGroupMembersFormData>({
         form: ADD_GROUP_MEMBERS_FORM,
+        onSubmit: (data, dispatch) => {
+            dispatch(addGroupMembers(data));
+        },
     })
 )(
     (props: AddGroupMembersDialogProps) =>

commit a4927ea74470ad483921813d93597b451e3d8e3e
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 20:01:46 2018 +0100

    Export addGroupMember function
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts
index be163c06..3574ede9 100644
--- a/src/store/groups-panel/groups-panel-actions.ts
+++ b/src/store/groups-panel/groups-panel-actions.ts
@@ -146,7 +146,7 @@ interface AddGroupMemberArgs {
     permissionService: PermissionService;
 }
 
-const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
+export const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
 
 
 

commit 163d52ede5411167eb4d5786f40b382d992c5126
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 19:53:11 2018 +0100

    Rename createPermission function
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts
index edda6af7..be163c06 100644
--- a/src/store/groups-panel/groups-panel-actions.ts
+++ b/src/store/groups-panel/groups-panel-actions.ts
@@ -148,14 +148,16 @@ interface AddGroupMemberArgs {
 
 const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
 
-    await createPermissionLink({
+
+
+    await createPermission({
         head: { ...group },
         tail: { ...user },
         permissionLevel: PermissionLevel.CAN_MANAGE,
         ...args,
     });
 
-    await createPermissionLink({
+    await createPermission({
         head: { ...user },
         tail: { ...group },
         permissionLevel: PermissionLevel.CAN_READ,
@@ -172,7 +174,7 @@ interface CreatePermissionLinkArgs {
     permissionService: PermissionService;
 }
 
-const createPermissionLink = async ({ head, tail, permissionLevel, dispatch, permissionService }: CreatePermissionLinkArgs) => {
+const createPermission = async ({ head, tail, permissionLevel, dispatch, permissionService }: CreatePermissionLinkArgs) => {
 
     try {
 

commit fc43f027e70d0702c88aa61c92dfeed7ac8b793b
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 19:39:19 2018 +0100

    Extract function for adding group member
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts
index 2787fc3b..edda6af7 100644
--- a/src/store/groups-panel/groups-panel-actions.ts
+++ b/src/store/groups-panel/groups-panel-actions.ts
@@ -108,18 +108,9 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
 
             for (const user of users) {
 
-                await createPermissionLink({
-                    head: { ...newGroup },
-                    tail: { ...user },
-                    permissionLevel: PermissionLevel.CAN_READ,
-                    dispatch,
-                    permissionService,
-                });
-
-                await createPermissionLink({
-                    head: { ...user },
-                    tail: { ...newGroup },
-                    permissionLevel: PermissionLevel.CAN_READ,
+                await addGroupMember({
+                    user,
+                    group: newGroup,
                     dispatch,
                     permissionService,
                 });
@@ -148,6 +139,30 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
         }
     };
 
+interface AddGroupMemberArgs {
+    user: { uuid: string, name: string };
+    group: { uuid: string, name: string };
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
+
+    await createPermissionLink({
+        head: { ...group },
+        tail: { ...user },
+        permissionLevel: PermissionLevel.CAN_MANAGE,
+        ...args,
+    });
+
+    await createPermissionLink({
+        head: { ...user },
+        tail: { ...group },
+        permissionLevel: PermissionLevel.CAN_READ,
+        ...args,
+    });
+
+};
 
 interface CreatePermissionLinkArgs {
     head: { uuid: string, name: string };

commit d7a29f892371764b1bff2e6ec64f8011c001b725
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 18:22:58 2018 +0100

    Create AddGroupMembersDialog
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts
index be134dd5..e908e8e8 100644
--- a/src/store/group-details-panel/group-details-panel-actions.ts
+++ b/src/store/group-details-panel/group-details-panel-actions.ts
@@ -6,8 +6,15 @@ import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-act
 import { Dispatch } from 'redux';
 import { propertiesActions } from '~/store/properties/properties-actions';
 import { getProperty } from '~/store/properties/properties';
+import { Person } from '~/views-components/sharing-dialog/people-select';
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { reset } from 'redux-form';
 
 export const GROUP_DETAILS_PANEL_ID = 'groupDetailsPanel';
+export const ADD_GROUP_MEMBERS_DIALOG = 'addGrupMembers';
+export const ADD_GROUP_MEMBERS_FORM = 'addGrupMembers';
+export const ADD_GROUP_MEMBERS_USERS_FIELD_NAME = 'users';
+
 
 export const GroupDetailsPanelActions = bindDataExplorerActions(GROUP_DETAILS_PANEL_ID);
 
@@ -18,3 +25,13 @@ export const loadGroupDetailsPanel = (groupUuid: string) =>
     };
 
 export const getCurrentGroupDetailsPanelUuid = getProperty<string>(GROUP_DETAILS_PANEL_ID);
+
+export interface AddGroupMembersFormData {
+    [ADD_GROUP_MEMBERS_USERS_FIELD_NAME]: Person[];
+}
+
+export const openAddGroupMembersDialog = () =>
+    (dispatch: Dispatch) => {
+        dispatch(dialogActions.OPEN_DIALOG({ id: ADD_GROUP_MEMBERS_DIALOG, data: {} }));
+        dispatch(reset(ADD_GROUP_MEMBERS_FORM));
+    };
diff --git a/src/views-components/dialog-forms/add-group-member-dialog.tsx b/src/views-components/dialog-forms/add-group-member-dialog.tsx
new file mode 100644
index 00000000..3840ecd4
--- /dev/null
+++ b/src/views-components/dialog-forms/add-group-member-dialog.tsx
@@ -0,0 +1,44 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { compose } from "redux";
+import { reduxForm, InjectedFormProps, WrappedFieldArrayProps, FieldArray } from 'redux-form';
+import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { PeopleSelect, Person } from '~/views-components/sharing-dialog/people-select';
+import { ADD_GROUP_MEMBERS_DIALOG, ADD_GROUP_MEMBERS_FORM, AddGroupMembersFormData, ADD_GROUP_MEMBERS_USERS_FIELD_NAME } from '~/store/group-details-panel/group-details-panel-actions';
+import { minLength } from '~/validators/min-length';
+
+export const AddGroupMembersDialog = compose(
+    withDialog(ADD_GROUP_MEMBERS_DIALOG),
+    reduxForm<AddGroupMembersFormData>({
+        form: ADD_GROUP_MEMBERS_FORM,
+    })
+)(
+    (props: AddGroupMembersDialogProps) =>
+        <FormDialog
+            dialogTitle='Add users'
+            formFields={UsersField}
+            submitLabel='Add'
+            {...props}
+        />
+);
+
+type AddGroupMembersDialogProps = WithDialogProps<{}> & InjectedFormProps<AddGroupMembersFormData>;
+
+const UsersField = () =>
+    <FieldArray
+        name={ADD_GROUP_MEMBERS_USERS_FIELD_NAME}
+        component={UsersSelect}
+        validate={UsersFieldValidation} />;
+
+const UsersFieldValidation = [minLength(1, () => 'Select at least one user')];
+
+const UsersSelect = ({ fields }: WrappedFieldArrayProps<Person>) =>
+    <PeopleSelect
+        label='Enter email adresses '
+        items={fields.getAll() || []}
+        onSelect={fields.push}
+        onDelete={fields.remove} />;
diff --git a/src/views/group-details-panel/group-details-panel.tsx b/src/views/group-details-panel/group-details-panel.tsx
index 749fd90f..7334a93a 100644
--- a/src/views/group-details-panel/group-details-panel.tsx
+++ b/src/views/group-details-panel/group-details-panel.tsx
@@ -11,7 +11,7 @@ import { ResourceUuid, ResourceFirstName, ResourceLastName, ResourceEmail, Resou
 import { createTree } from '~/models/tree';
 import { noop } from 'lodash/fp';
 import { RootState } from '~/store/store';
-import { GROUP_DETAILS_PANEL_ID } from '~/store/group-details-panel/group-details-panel-actions';
+import { GROUP_DETAILS_PANEL_ID, openAddGroupMembersDialog } from '~/store/group-details-panel/group-details-panel-actions';
 import { openContextMenu } from '~/store/context-menu/context-menu-actions';
 import { ResourcesState, getResource } from '~/store/resources/resources';
 import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
@@ -73,6 +73,7 @@ const mapStateToProps = (state: RootState) => {
 
 const mapDispatchToProps = {
     onContextMenu: openContextMenu,
+    onAddUser: openAddGroupMembersDialog,
 };
 
 export interface GroupDetailsPanelProps {
diff --git a/src/views/workbench/workbench.tsx b/src/views/workbench/workbench.tsx
index 76c7d251..695834a6 100644
--- a/src/views/workbench/workbench.tsx
+++ b/src/views/workbench/workbench.tsx
@@ -82,6 +82,7 @@ import { GroupAttributesDialog } from '~/views-components/groups-dialog/attribut
 import { GroupDetailsPanel } from '~/views/group-details-panel/group-details-panel';
 import { RemoveGroupMemberDialog } from '~/views-components/groups-dialog/member-remove-dialog';
 import { GroupMemberAttributesDialog } from '~/views-components/groups-dialog/member-attributes-dialog';
+import { AddGroupMembersDialog } from '~/views-components/dialog-forms/add-group-member-dialog';
 
 type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
 
@@ -169,6 +170,7 @@ export const WorkbenchPanel =
             <Grid item>
                 <DetailsPanel />
             </Grid>
+            <AddGroupMembersDialog />
             <AdvancedTabDialog />
             <AttributesApiClientAuthorizationDialog />
             <AttributesComputeNodeDialog />

commit 7cf7cf1a0b0066d044e4648a1311ad7241317128
Author: Michal Klobukowski <michal.klobukowski at contractors.roche.com>
Date:   Sun Dec 16 18:22:03 2018 +0100

    Create min length validator
    
    Feature #14505
    
    Arvados-DCO-1.1-Signed-off-by: Michal Klobukowski <michal.klobukowski at contractors.roche.com>

diff --git a/src/validators/min-length.tsx b/src/validators/min-length.tsx
new file mode 100644
index 00000000..9b269531
--- /dev/null
+++ b/src/validators/min-length.tsx
@@ -0,0 +1,10 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+export const ERROR_MESSAGE = (minLength: number) => `Min length is ${minLength}`;
+
+export const minLength =
+    (minLength: number, errorMessage = ERROR_MESSAGE) =>
+        (value: { length: number }) =>
+            value && value.length >= minLength ? undefined : errorMessage(minLength);

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list