Merge branch 'master'
authorMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Sun, 16 Dec 2018 21:44:03 +0000 (22:44 +0100)
committerMichal Klobukowski <michal.klobukowski@contractors.roche.com>
Sun, 16 Dec 2018 21:44:03 +0000 (22:44 +0100)
Feature #14505

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

29 files changed:
src/components/autocomplete/autocomplete.tsx
src/index.tsx
src/models/link.ts
src/routes/route-change-handlers.ts
src/routes/routes.ts
src/services/api/filter-builder.ts
src/store/breadcrumbs/breadcrumbs-actions.ts
src/store/group-details-panel/group-details-panel-actions.ts [new file with mode: 0644]
src/store/group-details-panel/group-details-panel-middleware-service.ts [new file with mode: 0644]
src/store/groups-panel/groups-panel-actions.ts [new file with mode: 0644]
src/store/groups-panel/groups-panel-middleware-service.ts [new file with mode: 0644]
src/store/navigation/navigation-action.ts
src/store/store.ts
src/store/workbench/workbench-actions.ts
src/validators/min-length.tsx [new file with mode: 0644]
src/views-components/context-menu/action-sets/group-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/group-member-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/context-menu.tsx
src/views-components/dialog-forms/add-group-member-dialog.tsx [new file with mode: 0644]
src/views-components/dialog-forms/create-group-dialog.tsx [new file with mode: 0644]
src/views-components/groups-dialog/attributes-dialog.tsx [new file with mode: 0644]
src/views-components/groups-dialog/member-attributes-dialog.tsx [new file with mode: 0644]
src/views-components/groups-dialog/member-remove-dialog.ts [new file with mode: 0644]
src/views-components/groups-dialog/remove-dialog.ts [new file with mode: 0644]
src/views-components/main-app-bar/account-menu.tsx
src/views-components/sharing-dialog/people-select.tsx
src/views/group-details-panel/group-details-panel.tsx [new file with mode: 0644]
src/views/groups-panel/groups-panel.tsx [new file with mode: 0644]
src/views/workbench/workbench.tsx

index c5811bb6ea716b44692752c68d7f630d61208e7f..b250c7b8ecd43bfc89171e4a9de373bf0cd22ffa 100644 (file)
@@ -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();
index e73f08c46ba8b48d2fab54bd5cbe0de89fb3b753..508fa7c3dad5cb140246352cb821b56c98d49d7a 100644 (file)
@@ -56,6 +56,8 @@ import { virtualMachineActionSet } from '~/views-components/context-menu/action-
 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()}]`);
@@ -81,6 +83,8 @@ 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 }) => {
index d931f7f21898b394b2b2efb73349838e533532a8..785d531cf7d609fec3af696d16c3fbb9028753a4 100644 (file)
@@ -2,9 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-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;
@@ -14,6 +13,7 @@ export interface LinkResource extends Resource {
     linkClass: string;
     name: string;
     properties: TagProperty;
+    kind: ResourceKind.LINK;
 }
 
 export enum LinkClass {
index 655c806f3a3b0337cc1a89eccae6b7a29ddaa832..7b37509f90b94be0cd053e3052e52ef438bac8fc 100644 (file)
@@ -37,6 +37,8 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     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());
@@ -83,6 +85,10 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
         store.dispatch(WorkbenchActions.loadMyAccount);
     } 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);
     }
index 05f6663fe3c2bbb8021423249e5e045263ab3be7..661a065eb35848bbfaef168d58af2781960f92ad 100644 (file)
@@ -30,6 +30,8 @@ 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'
 };
 
@@ -51,6 +53,8 @@ export const getProcessUrl = (uuid: string) => `/processes/${uuid}`;
 
 export const getProcessLogUrl = (uuid: string) => `/process-logs/${uuid}`;
 
+export const getGroupUrl = (uuid: string) => `/group/${uuid}`;
+
 export interface ResourceRouteParams {
     id: string;
 }
@@ -118,5 +122,11 @@ export const matchComputeNodesRoute = (route: string) =>
 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 });
\ No newline at end of file
+    matchPath(route, { path: Routes.LINKS });
index 08746c81b9ee1d91736b4e9e09eb79b820bd916f..4b3db9fa2b92bb976a577a7e0eb2ee8de4161ab5 100644 (file)
@@ -11,8 +11,8 @@ export function joinFilters(filters0?: string, filters1?: string) {
 export class FilterBuilder {
     constructor(private filters = "") { }
 
-    public addEqual(field: string, value?: string | boolean, resourcePrefix?: string) {
-        return this.addCondition(field, "=", value, "", "", resourcePrefix );
+    public addEqual(field: string, value?: string | boolean | null, resourcePrefix?: string) {
+        return this.addCondition(field, "=", value, "", "", resourcePrefix);
     }
 
     public addLike(field: string, value?: string, resourcePrefix?: string) {
@@ -59,13 +59,13 @@ export class FilterBuilder {
         return this.filters;
     }
 
-    private addCondition(field: string, cond: string, value?: string | string[] | boolean, prefix: string = "", postfix: string = "", resourcePrefix?: string) {
-        if (value) {
+    private addCondition(field: string, cond: string, value?: string | string[] | boolean | null, prefix: string = "", postfix: string = "", resourcePrefix?: string) {
+        if (value !== undefined) {
             if (typeof value === "string") {
                 value = `"${prefix}${value}${postfix}"`;
             } else if (Array.isArray(value)) {
                 value = `["${value.join(`","`)}"]`;
-            } else {
+            } else if (value !== null) {
                 value = value ? "true" : "false";
             }
 
index 8b1eb2b0cc0f0be28b9c68059ecb4528327aa71b..71de5ce425e44472f8e0bdac832159f4e3510c14 100644 (file)
@@ -14,6 +14,7 @@ import { ServiceRepository } from '~/services/services';
 import { SidePanelTreeCategory, activateSidePanelTreeItem } from '~/store/side-panel-tree/side-panel-tree-actions';
 import { updateResources } from '../resources/resources-actions';
 import { ResourceKind } from '~/models/resource';
+import { GroupResource } from '~/models/group';
 
 export const BREADCRUMBS = 'breadcrumbs';
 
@@ -89,3 +90,22 @@ export const setProcessBreadcrumbs = (processUuid: string) =>
             dispatch<any>(setProjectBreadcrumbs(process.containerRequest.ownerUuid));
         }
     };
+
+export const GROUPS_PANEL_LABEL = 'Groups';
+
+export const setGroupsBreadcrumbs = () =>
+    setBreadcrumbs([{ label: GROUPS_PANEL_LABEL }]);
+
+export const setGroupDetailsBreadcrumbs = (groupUuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+
+        const group = getResource<GroupResource>(groupUuid)(getState().resources);
+
+        const breadcrumbs: ResourceBreadcrumb[] = [
+            { label: GROUPS_PANEL_LABEL, uuid: GROUPS_PANEL_LABEL },
+            { label: group ? group.name : groupUuid, uuid: groupUuid },
+        ];
+
+        dispatch(setBreadcrumbs(breadcrumbs));
+
+    };
diff --git a/src/store/group-details-panel/group-details-panel-actions.ts b/src/store/group-details-panel/group-details-panel-actions.ts
new file mode 100644 (file)
index 0000000..4ad0159
--- /dev/null
@@ -0,0 +1,132 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { bindDataExplorerActions } from '~/store/data-explorer/data-explorer-action';
+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, startSubmit } from 'redux-form';
+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';
+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';
+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);
+
+export const loadGroupDetailsPanel = (groupUuid: string) =>
+    (dispatch: Dispatch) => {
+        dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_PANEL_ID, value: groupUuid }));
+        dispatch(GroupDetailsPanelActions.REQUEST_ITEMS());
+    };
+
+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));
+    };
+
+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());
+
+        }
+    };
+
+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, { 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/group-details-panel/group-details-panel-middleware-service.ts b/src/store/group-details-panel/group-details-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..bf424c5
--- /dev/null
@@ -0,0 +1,79 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from "redux";
+import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta } from "~/store/data-explorer/data-explorer-middleware-service";
+import { RootState } from "~/store/store";
+import { ServiceRepository } from "~/services/services";
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getDataExplorer } from "~/store/data-explorer/data-explorer-reducer";
+import { FilterBuilder } from '~/services/api/filter-builder';
+import { updateResources } from '~/store/resources/resources-actions';
+import { getCurrentGroupDetailsPanelUuid, GroupDetailsPanelActions } from '~/store/group-details-panel/group-details-panel-actions';
+import { LinkClass } from '~/models/link';
+
+export class GroupDetailsPanelMiddlewareService extends DataExplorerMiddlewareService {
+
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+        const groupUuid = getCurrentGroupDetailsPanelUuid(api.getState().properties);
+
+        if (!dataExplorer || !groupUuid) {
+
+            api.dispatch(groupsDetailsPanelDataExplorerIsNotSet());
+
+        } else {
+
+            try {
+
+                const permissions = await this.services.permissionService.list({
+
+                    filters: new FilterBuilder()
+                        .addEqual('tailUuid', groupUuid)
+                        .addEqual('linkClass', LinkClass.PERMISSION)
+                        .getFilters()
+
+                });
+
+                api.dispatch(updateResources(permissions.items));
+
+                const users = await this.services.userService.list({
+
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissions.items.map(item => item.headUuid))
+                        .getFilters()
+
+                });
+
+                api.dispatch(GroupDetailsPanelActions.SET_ITEMS({
+                    ...listResultsToDataExplorerItemsMeta(permissions),
+                    items: users.items.map(item => item.uuid),
+                }));
+
+                api.dispatch(updateResources(users.items));
+
+            } catch (e) {
+
+                api.dispatch(couldNotFetchGroupDetailsContents());
+
+            }
+        }
+    }
+}
+
+const groupsDetailsPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Group details panel is not ready.'
+    });
+
+const couldNotFetchGroupDetailsContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch group details.',
+        kind: SnackbarKind.ERROR
+    });
diff --git a/src/store/groups-panel/groups-panel-actions.ts b/src/store/groups-panel/groups-panel-actions.ts
new file mode 100644 (file)
index 0000000..1c6223b
--- /dev/null
@@ -0,0 +1,234 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from 'redux';
+import { reset, startSubmit, stopSubmit, FormErrors } from 'redux-form';
+import { bindDataExplorerActions } from "~/store/data-explorer/data-explorer-action";
+import { dialogActions } from '~/store/dialog/dialog-actions';
+import { Person } from '~/views-components/sharing-dialog/people-select';
+import { RootState } from '~/store/store';
+import { ServiceRepository } from '~/services/services';
+import { getResource } from '~/store/resources/resources';
+import { GroupResource } from '~/models/group';
+import { getCommonResourceServiceError, CommonResourceServiceError } from '~/services/common-service/common-resource-service';
+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";
+export const CREATE_GROUP_FORM = "createGroupForm";
+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 GroupsPanelActions = bindDataExplorerActions(GROUPS_PANEL_ID);
+
+export const loadGroupsPanel = () => GroupsPanelActions.REQUEST_ITEMS();
+
+export const openCreateGroupDialog = () =>
+    (dispatch: Dispatch) => {
+        dispatch(dialogActions.OPEN_DIALOG({ id: CREATE_GROUP_DIALOG, data: {} }));
+        dispatch(reset(CREATE_GROUP_FORM));
+    };
+
+export const openGroupAttributes = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const { resources } = getState();
+        const data = getResource<GroupResource>(uuid)(resources);
+        dispatch(dialogActions.OPEN_DIALOG({ id: GROUP_ATTRIBUTES_DIALOG, data }));
+    };
+
+export const removeGroup = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...' }));
+        await services.groupsService.delete(uuid);
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        dispatch<any>(loadGroupsPanel());
+    };
+
+export const openRemoveGroupDialog = (uuid: string) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: GROUP_REMOVE_DIALOG,
+            data: {
+                title: 'Remove group',
+                text: 'Are you sure you want to remove this group?',
+                confirmButtonLabel: 'Remove',
+                uuid
+            }
+        }));
+    };
+
+export interface CreateGroupFormData {
+    [CREATE_GROUP_NAME_FIELD_NAME]: string;
+    [CREATE_GROUP_USERS_FIELD_NAME]?: Person[];
+}
+
+export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
+    async (dispatch: Dispatch, _: {}, { groupsService, permissionService }: ServiceRepository) => {
+
+        dispatch(startSubmit(CREATE_GROUP_FORM));
+
+        try {
+
+            const newGroup = await groupsService.create({ name });
+
+            for (const user of users) {
+
+                await addGroupMember({
+                    user,
+                    group: newGroup,
+                    dispatch,
+                    permissionService,
+                });
+
+            }
+
+            dispatch(dialogActions.CLOSE_DIALOG({ id: CREATE_GROUP_DIALOG }));
+            dispatch(reset(CREATE_GROUP_FORM));
+            dispatch(loadGroupsPanel());
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: `${newGroup.name} group has been created`,
+                kind: SnackbarKind.SUCCESS
+            }));
+
+            return newGroup;
+
+        } catch (e) {
+
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_VIOLATION) {
+                dispatch(stopSubmit(CREATE_GROUP_FORM, { name: 'Group with the same name already exists.' } as FormErrors));
+            }
+
+            return;
+
+        }
+    };
+
+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 };
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+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 },
+        permissionLevel: PermissionLevel.CAN_READ,
+        ...args,
+    });
+
+};
+
+interface CreatePermissionLinkArgs {
+    head: { uuid: string, name: string };
+    tail: { uuid: string, name: string };
+    permissionLevel: PermissionLevel;
+    dispatch: Dispatch;
+    permissionService: PermissionService;
+}
+
+const createPermission = async ({ head, tail, permissionLevel, dispatch, permissionService }: CreatePermissionLinkArgs) => {
+
+    try {
+
+        await permissionService.create({
+            tailUuid: tail.uuid,
+            headUuid: head.uuid,
+            name: permissionLevel,
+        });
+
+    } catch (e) {
+
+        dispatch(snackbarActions.OPEN_SNACKBAR({
+            message: `Could not add ${tail.name} -> ${head.name} relation`,
+            kind: SnackbarKind.ERROR,
+        }));
+
+    }
+
+};
+
+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
diff --git a/src/store/groups-panel/groups-panel-middleware-service.ts b/src/store/groups-panel/groups-panel-middleware-service.ts
new file mode 100644 (file)
index 0000000..d3ff593
--- /dev/null
@@ -0,0 +1,97 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch, MiddlewareAPI } from "redux";
+import { DataExplorerMiddlewareService, listResultsToDataExplorerItemsMeta, dataExplorerToListParams } from "~/store/data-explorer/data-explorer-middleware-service";
+import { RootState } from "~/store/store";
+import { ServiceRepository } from "~/services/services";
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+import { getDataExplorer, DataExplorer, getSortColumn } from "~/store/data-explorer/data-explorer-reducer";
+import { GroupsPanelActions } from '~/store/groups-panel/groups-panel-actions';
+import { FilterBuilder } from '~/services/api/filter-builder';
+import { updateResources } from '~/store/resources/resources-actions';
+import { OrderBuilder, OrderDirection } from '~/services/api/order-builder';
+import { GroupResource, GroupClass } from '~/models/group';
+import { SortDirection } from '~/components/data-table/data-column';
+import { GroupsPanelColumnNames } from '~/views/groups-panel/groups-panel';
+
+export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService {
+
+    constructor(private services: ServiceRepository, id: string) {
+        super(id);
+    }
+
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+
+        const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
+
+        if (!dataExplorer) {
+
+            api.dispatch(groupsPanelDataExplorerIsNotSet());
+
+        } else {
+
+            try {
+
+                const order = new OrderBuilder<GroupResource>();
+                const sortColumn = getSortColumn(dataExplorer);
+                if (sortColumn) {
+                    const direction =
+                        sortColumn.sortDirection === SortDirection.ASC && sortColumn.name === GroupsPanelColumnNames.GROUP
+                            ? OrderDirection.ASC
+                            : OrderDirection.DESC;
+
+                    order.addOrder(direction, 'name');
+                }
+
+                const filters = new FilterBuilder()
+                    .addNotIn('groupClass', [GroupClass.PROJECT])
+                    .addILike('name', dataExplorer.searchValue)
+                    .getFilters();
+
+                const response = await this.services.groupsService
+                    .list({
+                        ...dataExplorerToListParams(dataExplorer),
+                        filters,
+                        order: order.getOrder(),
+                    });
+
+                api.dispatch(updateResources(response.items));
+
+                api.dispatch(GroupsPanelActions.SET_ITEMS({
+                    ...listResultsToDataExplorerItemsMeta(response),
+                    items: response.items.map(item => item.uuid),
+                }));
+
+                const permissions = await this.services.permissionService.list({
+
+                    filters: new FilterBuilder()
+                        .addIn('headUuid', response.items.map(item => item.uuid))
+                        .getFilters()
+
+                });
+
+                api.dispatch(updateResources(permissions.items));
+
+
+            } catch (e) {
+
+                api.dispatch(couldNotFetchFavoritesContents());
+
+            }
+        }
+    }
+}
+
+const groupsPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Groups panel is not ready.'
+    });
+
+const couldNotFetchFavoritesContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch groups.',
+        kind: SnackbarKind.ERROR
+    });
+
index 92443c02dc1cf90800a50418d167c72f253d92b5..c53c55e89287212f91715481a8998fe429fddaf2 100644 (file)
@@ -8,9 +8,10 @@ import { ResourceKind, extractUuidKind } from '~/models/resource';
 import { getCollectionUrl } from "~/models/collection";
 import { getProjectUrl } from "~/models/project";
 import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions';
-import { Routes, getProcessUrl, getProcessLogUrl } from '~/routes/routes';
+import { Routes, getProcessUrl, getProcessLogUrl, getGroupUrl } from '~/routes/routes';
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
+import { GROUPS_PANEL_LABEL } from '~/store/breadcrumbs/breadcrumbs-actions';
 
 export const navigateTo = (uuid: string) =>
     async (dispatch: Dispatch) => {
@@ -32,6 +33,8 @@ export const navigateTo = (uuid: string) =>
             dispatch(navigateToWorkflows);
         } else if (uuid === SidePanelTreeCategory.TRASH) {
             dispatch(navigateToTrash);
+        } else if (uuid === GROUPS_PANEL_LABEL) {
+            dispatch(navigateToGroups);
         }
     };
 
@@ -84,4 +87,8 @@ export const navigateToUsers = push(Routes.USERS);
 
 export const navigateToApiClientAuthorizations = push(Routes.API_CLIENT_AUTHORIZATIONS);
 
-export const navigateToLinks = push(Routes.LINKS);
\ No newline at end of file
+export const navigateToGroups = push(Routes.GROUPS);
+
+export const navigateToGroupDetails = compose(push, getGroupUrl);
+
+export const navigateToLinks = push(Routes.LINKS);
index 792224d240f7434595f409db117a85744fbf528a..3aef8f500013fb301d6e6089296b9a05909a561e 100644 (file)
@@ -50,6 +50,10 @@ import { UserMiddlewareService } from '~/store/users/user-panel-middleware-servi
 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';
 
@@ -86,6 +90,13 @@ export function configureStore(history: History, services: ServiceRepository): R
     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)
     );
@@ -99,6 +110,8 @@ export function configureStore(history: History, services: ServiceRepository): R
         sharedWithMePanelMiddleware,
         workflowPanelMiddleware,
         userPanelMiddleware,
+        groupsPanelMiddleware,
+        groupDetailsPanelMiddleware,
         linkPanelMiddleware
     ];
     const enhancer = composeEnhancers(applyMiddleware(...middlewares));
index 85540f0b434ac90826b93aba3561f726d3325578..af2afab29f53ba33b2a06bed69e6174ddf085e9b 100644 (file)
@@ -14,7 +14,7 @@ import { favoritePanelActions } from '~/store/favorite-panel/favorite-panel-acti
 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 '~/store/breadcrumbs/breadcrumbs-actions';
+import { setSidePanelBreadcrumbs, setProcessBreadcrumbs, setSharedWithMeBreadcrumbs, setTrashBreadcrumbs, setBreadcrumbs, setGroupDetailsBreadcrumbs, setGroupsBreadcrumbs } 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';
@@ -64,6 +64,10 @@ import { linkPanelColumns } from '~/views/link-panel/link-panel-root';
 import { userPanelColumns } from '~/views/user-panel/user-panel';
 import { loadComputeNodesPanel } from '~/store/compute-nodes/compute-nodes-actions';
 import { loadApiClientAuthorizationsPanel } from '~/store/api-client-authorizations/api-client-authorizations-actions';
+import * as groupPanelActions from '~/store/groups-panel/groups-panel-actions';
+import { groupsPanelColumns } from '~/views/groups-panel/groups-panel';
+import * as groupDetailsPanelActions from '~/store/group-details-panel/group-details-panel-actions';
+import { groupDetailsPanelColumns } from '~/views/group-details-panel/group-details-panel';
 
 export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
 
@@ -98,6 +102,8 @@ 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) {
@@ -453,6 +459,20 @@ export const loadApiClientAuthorizations = handleFirstTimeLoad(
         await dispatch(loadApiClientAuthorizationsPanel());
     });
 
+export const loadGroupsPanel = handleFirstTimeLoad(
+    (dispatch: Dispatch<any>) => {
+        dispatch(setGroupsBreadcrumbs());
+        dispatch(groupPanelActions.loadGroupsPanel());
+    });
+
+
+export const loadGroupDetailsPanel = (groupUuid: string) =>
+    handleFirstTimeLoad(
+        (dispatch: Dispatch<any>) => {
+            dispatch(setGroupDetailsBreadcrumbs(groupUuid));
+            dispatch(groupDetailsPanelActions.loadGroupDetailsPanel(groupUuid));
+        });
+
 const finishLoadingProject = (project: GroupContentsResource | string) =>
     async (dispatch: Dispatch<any>) => {
         const uuid = typeof project === 'string' ? project : project.uuid;
diff --git a/src/validators/min-length.tsx b/src/validators/min-length.tsx
new file mode 100644 (file)
index 0000000..9b26953
--- /dev/null
@@ -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);
diff --git a/src/views-components/context-menu/action-sets/group-action-set.ts b/src/views-components/context-menu/action-sets/group-action-set.ts
new file mode 100644 (file)
index 0000000..8d718a3
--- /dev/null
@@ -0,0 +1,28 @@
+// 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, AttributesIcon } from "~/components/icon/icon";
+import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
+import { openGroupAttributes, openRemoveGroupDialog } from "~/store/groups-panel/groups-panel-actions";
+
+export const groupActionSet: ContextMenuActionSet = [[{
+    name: "Attributes",
+    icon: AttributesIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openGroupAttributes(uuid));
+    }
+}, {
+    name: "Advanced",
+    icon: AdvancedIcon,
+    execute: (dispatch, resource) => {
+        dispatch<any>(openAdvancedTabDialog(resource.uuid));
+    }
+}, {
+    name: "Remove",
+    icon: RemoveIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openRemoveGroupDialog(uuid));
+    }
+}]];
\ No newline at end of file
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
new file mode 100644 (file)
index 0000000..a8b3dd1
--- /dev/null
@@ -0,0 +1,28 @@
+// 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, AttributesIcon } from "~/components/icon/icon";
+import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
+import { openGroupMemberAttributes, openRemoveGroupMemberDialog } from '~/store/group-details-panel/group-details-panel-actions';
+
+export const groupMemberActionSet: ContextMenuActionSet = [[{
+    name: "Attributes",
+    icon: AttributesIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openGroupMemberAttributes(uuid));
+    }
+}, {
+    name: "Advanced",
+    icon: AdvancedIcon,
+    execute: (dispatch, resource) => {
+        dispatch<any>(openAdvancedTabDialog(resource.uuid));
+    }
+}, {
+    name: "Remove",
+    icon: RemoveIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(openRemoveGroupMemberDialog(uuid));
+    }
+}]];
\ No newline at end of file
index a9200ebb363df15541e6da5abe641c8200d98309..4ce2f5214d5f337d2488f0acb232d6d1ea5f1b0d 100644 (file)
@@ -75,6 +75,8 @@ export enum ContextMenuKind {
     VIRTUAL_MACHINE = "VirtualMachine",
     KEEP_SERVICE = "KeepService",
     USER = "User",
+    NODE = "Node",
+    GROUPS = "Group",
+    GROUP_MEMBER = "GroupMember",
     LINK = "Link",
-    NODE = "Node"
 }
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 (file)
index 0000000..f4a5c2c
--- /dev/null
@@ -0,0 +1,48 @@
+// 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, 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) =>
+        <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
+        autofocus
+        label='Enter email adresses '
+        items={fields.getAll() || []}
+        onSelect={fields.push}
+        onDelete={fields.remove} />;
diff --git a/src/views-components/dialog-forms/create-group-dialog.tsx b/src/views-components/dialog-forms/create-group-dialog.tsx
new file mode 100644 (file)
index 0000000..554ad79
--- /dev/null
@@ -0,0 +1,62 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { compose } from "redux";
+import { reduxForm, InjectedFormProps, Field, WrappedFieldArrayProps, FieldArray } from 'redux-form';
+import { withDialog, WithDialogProps } from "~/store/dialog/with-dialog";
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { CREATE_GROUP_DIALOG, CREATE_GROUP_FORM, createGroup, CreateGroupFormData, CREATE_GROUP_NAME_FIELD_NAME, CREATE_GROUP_USERS_FIELD_NAME } from '~/store/groups-panel/groups-panel-actions';
+import { TextField } from '~/components/text-field/text-field';
+import { maxLength } from '~/validators/max-length';
+import { require } from '~/validators/require';
+import { PeopleSelect, Person } from '~/views-components/sharing-dialog/people-select';
+
+export const CreateGroupDialog = compose(
+    withDialog(CREATE_GROUP_DIALOG),
+    reduxForm<CreateGroupFormData>({
+        form: CREATE_GROUP_FORM,
+        onSubmit: (data, dispatch) => {
+            dispatch(createGroup(data));
+        }
+    })
+)(
+    (props: CreateGroupDialogComponentProps) =>
+        <FormDialog
+            dialogTitle='Create a group'
+            formFields={CreateGroupFormFields}
+            submitLabel='Create'
+            {...props}
+        />
+);
+
+type CreateGroupDialogComponentProps = WithDialogProps<{}> & InjectedFormProps<CreateGroupFormData>;
+
+const CreateGroupFormFields = () =>
+    <>
+        <GroupNameField />
+        <UsersField />
+    </>;
+
+const GroupNameField = () =>
+    <Field
+        name={CREATE_GROUP_NAME_FIELD_NAME}
+        component={TextField}
+        validate={GROUP_NAME_VALIDATION}
+        label="Name"
+        autoFocus={true} />;
+
+const GROUP_NAME_VALIDATION = [require, maxLength(255)];
+
+const UsersField = () =>
+    <FieldArray
+        name={CREATE_GROUP_USERS_FIELD_NAME}
+        component={UsersSelect} />;
+
+const UsersSelect = ({ fields }: WrappedFieldArrayProps<Person>) =>
+    <PeopleSelect
+        label='Enter email adresses '
+        items={fields.getAll() || []}
+        onSelect={fields.push}
+        onDelete={fields.remove} />;
diff --git a/src/views-components/groups-dialog/attributes-dialog.tsx b/src/views-components/groups-dialog/attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..f6ab8c1
--- /dev/null
@@ -0,0 +1,101 @@
+// 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 { WithStyles, withStyles } from '@material-ui/core/styles';
+import { ArvadosTheme } from '~/common/custom-theme';
+import { compose } from "redux";
+import { GroupResource } from "~/models/group";
+import { GROUP_ATTRIBUTES_DIALOG } from "~/store/groups-panel/groups-panel-actions";
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles = withStyles<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 GroupAttributesDataProps {
+    data: GroupResource;
+}
+
+type GroupAttributesProps = GroupAttributesDataProps & WithStyles<CssRules>;
+
+export const GroupAttributesDialog = compose(
+    withDialog(GROUP_ATTRIBUTES_DIALOG),
+    styles)(
+        (props: WithDialogProps<GroupAttributesProps> & GroupAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant="body2" className={props.classes.spacing}>
+                        {props.data && attributes(props.data, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (group: GroupResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, deleteAt, description, etag, href, isTrashed, trashAt} = group;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    {name && <Grid item>Name</Grid>}
+                    {ownerUuid && <Grid item>Owner uuid</Grid>}
+                    {createdAt && <Grid item>Created at</Grid>}
+                    {modifiedAt && <Grid item>Modified at</Grid>}
+                    {modifiedByUserUuid && <Grid item>Modified by user uuid</Grid>}
+                    {modifiedByClientUuid && <Grid item>Modified by client uuid</Grid>}
+                    {uuid && <Grid item>uuid</Grid>}
+                    {deleteAt && <Grid item>Delete at</Grid>}
+                    {description && <Grid item>Description</Grid>}
+                    {etag && <Grid item>Etag</Grid>}
+                    {href && <Grid item>Href</Grid>}
+                    {isTrashed && <Grid item>Is trashed</Grid>}
+                    {trashAt && <Grid item>Trashed at</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 item>{deleteAt}</Grid>
+                    <Grid item>{description}</Grid>
+                    <Grid item>{etag}</Grid>
+                    <Grid item>{href}</Grid>
+                    <Grid item>{isTrashed}</Grid>
+                    <Grid item>{trashAt}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
diff --git a/src/views-components/groups-dialog/member-attributes-dialog.tsx b/src/views-components/groups-dialog/member-attributes-dialog.tsx
new file mode 100644 (file)
index 0000000..7aa8653
--- /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 { 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 { 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/group-details-panel/group-details-panel-actions';
+
+type CssRules = 'rightContainer' | 'leftContainer' | 'spacing';
+
+const styles = withStyles<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 GroupAttributesDataProps {
+    data: PermissionResource;
+}
+
+type GroupAttributesProps = GroupAttributesDataProps & WithStyles<CssRules>;
+
+export const GroupMemberAttributesDialog = compose(
+    withDialog(MEMBER_ATTRIBUTES_DIALOG),
+    styles)(
+        (props: WithDialogProps<GroupAttributesProps> & GroupAttributesProps) =>
+            <Dialog open={props.open}
+                onClose={props.closeDialog}
+                fullWidth
+                maxWidth="sm">
+                <DialogTitle>Attributes</DialogTitle>
+                <DialogContent>
+                    <Typography variant="body2" className={props.classes.spacing}>
+                        {props.data && attributes(props.data, props.classes)}
+                    </Typography>
+                </DialogContent>
+                <DialogActions>
+                    <Button
+                        variant='flat'
+                        color='primary'
+                        onClick={props.closeDialog}>
+                        Close
+                </Button>
+                </DialogActions>
+            </Dialog>
+    );
+
+const attributes = (memberGroup: PermissionResource, classes: any) => {
+    const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, etag, href, linkClass } = memberGroup;
+    return (
+        <span>
+            <Grid container direction="row">
+                <Grid item xs={5} className={classes.rightContainer}>
+                    {name && <Grid item>Name</Grid>}
+                    {ownerUuid && <Grid item>Owner uuid</Grid>}
+                    {createdAt && <Grid item>Created at</Grid>}
+                    {modifiedAt && <Grid item>Modified at</Grid>}
+                    {modifiedByUserUuid && <Grid item>Modified by user uuid</Grid>}
+                    {modifiedByClientUuid && <Grid item>Modified by client uuid</Grid>}
+                    {uuid && <Grid item>uuid</Grid>}
+                    {linkClass && <Grid item>Link Class</Grid>}
+                    {etag && <Grid item>Etag</Grid>}
+                    {href && <Grid item>Href</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 item>{linkClass}</Grid>
+                    <Grid item>{etag}</Grid>
+                    <Grid item>{href}</Grid>
+                </Grid>
+            </Grid>
+        </span>
+    );
+};
diff --git a/src/views-components/groups-dialog/member-remove-dialog.ts b/src/views-components/groups-dialog/member-remove-dialog.ts
new file mode 100644 (file)
index 0000000..bb5c3a2
--- /dev/null
@@ -0,0 +1,21 @@
+// 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 { removeGroupMember, MEMBER_REMOVE_DIALOG } from '~/store/group-details-panel/group-details-panel-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeGroupMember(props.data.uuid));
+    }
+});
+
+export const RemoveGroupMemberDialog = compose(
+    withDialog(MEMBER_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
diff --git a/src/views-components/groups-dialog/remove-dialog.ts b/src/views-components/groups-dialog/remove-dialog.ts
new file mode 100644 (file)
index 0000000..8220198
--- /dev/null
@@ -0,0 +1,21 @@
+// 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 { removeGroup, GROUP_REMOVE_DIALOG } from '~/store/groups-panel/groups-panel-actions';
+
+const mapDispatchToProps = (dispatch: Dispatch, props: WithDialogProps<any>) => ({
+    onConfirm: () => {
+        props.closeDialog();
+        dispatch<any>(removeGroup(props.data.uuid));
+    }
+});
+
+export const RemoveGroupDialog = compose(
+    withDialog(GROUP_REMOVE_DIALOG),
+    connect(null, mapDispatchToProps)
+)(ConfirmationDialog);
\ No newline at end of file
index 1609aafa0849a277432f343dbe39e788de788bf7..a1116540226a5e2d543fd48aba1718468481e55a 100644 (file)
@@ -12,6 +12,11 @@ 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";
 
@@ -36,6 +41,11 @@ export const AccountMenu = connect(mapStateToProps)(
                 <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>
index 2aada00e8ae7c75aee8266ccdcf7c3267c89fca3..f62e6f55ac0d5a27986c8d719051174d9cc88843 100644 (file)
@@ -17,9 +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;
@@ -43,12 +46,16 @@ export const PeopleSelect = connect()(
         };
 
         render() {
+
+            const { label = 'Invite people' } = this.props;
+
             return (
                 <Autocomplete
-                    label='Invite people'
+                    label={label}
                     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}
diff --git a/src/views/group-details-panel/group-details-panel.tsx b/src/views/group-details-panel/group-details-panel.tsx
new file mode 100644 (file)
index 0000000..7334a93
--- /dev/null
@@ -0,0 +1,125 @@
+// 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 { DataExplorer } from "~/views-components/data-explorer/data-explorer";
+import { DataColumns } from '~/components/data-table/data-table';
+import { ResourceUuid, ResourceFirstName, ResourceLastName, ResourceEmail, ResourceUsername } from '~/views-components/data-explorer/renderers';
+import { createTree } from '~/models/tree';
+import { noop } from 'lodash/fp';
+import { RootState } from '~/store/store';
+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';
+import { PermissionResource } from '~/models/permission';
+import { Grid, Button } from '@material-ui/core';
+import { AddIcon } from '~/components/icon/icon';
+
+export enum GroupDetailsPanelColumnNames {
+    FIRST_NAME = "First name",
+    LAST_NAME = "Last name",
+    UUID = "UUID",
+    EMAIL = "Email",
+    USERNAME = "Username",
+}
+
+export const groupDetailsPanelColumns: DataColumns<string> = [
+    {
+        name: GroupDetailsPanelColumnNames.FIRST_NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceFirstName uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelColumnNames.LAST_NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLastName uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceUuid uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelColumnNames.EMAIL,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceEmail uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelColumnNames.USERNAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceUsername uuid={uuid} />
+    },
+];
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = {
+    onContextMenu: openContextMenu,
+    onAddUser: openAddGroupMembersDialog,
+};
+
+export interface GroupDetailsPanelProps {
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
+    onAddUser: () => void;
+    resources: ResourcesState;
+}
+
+export const GroupDetailsPanel = connect(
+    mapStateToProps, mapDispatchToProps
+)(
+    class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps> {
+
+        render() {
+            return (
+                <DataExplorer
+                    id={GROUP_DETAILS_PANEL_ID}
+                    onRowClick={noop}
+                    onRowDoubleClick={noop}
+                    onContextMenu={this.handleContextMenu}
+                    contextMenuColumn={true}
+                    hideColumnSelector
+                    actions={
+                        <Grid container justify='flex-end'>
+                            <Button
+                                variant="contained"
+                                color="primary"
+                                onClick={this.props.onAddUser}>
+                                <AddIcon /> Add user
+                        </Button>
+                        </Grid>
+                    } />
+            );
+        }
+
+        handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+            const resource = getResource<PermissionResource>(resourceUuid)(this.props.resources);
+            if (resource) {
+                this.props.onContextMenu(event, {
+                    name: '',
+                    uuid: resource.uuid,
+                    ownerUuid: resource.ownerUuid,
+                    kind: resource.kind,
+                    menuKind: ContextMenuKind.GROUP_MEMBER
+                });
+            }
+        }
+    });
+
diff --git a/src/views/groups-panel/groups-panel.tsx b/src/views/groups-panel/groups-panel.tsx
new file mode 100644 (file)
index 0000000..44a262f
--- /dev/null
@@ -0,0 +1,135 @@
+// 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, Button, Typography } from "@material-ui/core";
+
+import { DataExplorer } from "~/views-components/data-explorer/data-explorer";
+import { DataColumns } from '~/components/data-table/data-table';
+import { SortDirection } from '~/components/data-table/data-column';
+import { ResourceOwner } from '~/views-components/data-explorer/renderers';
+import { AddIcon } from '~/components/icon/icon';
+import { ResourceName } from '~/views-components/data-explorer/renderers';
+import { createTree } from '~/models/tree';
+import { GROUPS_PANEL_ID, openCreateGroupDialog } from '~/store/groups-panel/groups-panel-actions';
+import { noop } from 'lodash/fp';
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { getResource, ResourcesState, filterResources } from '~/store/resources/resources';
+import { GroupResource } from '~/models/group';
+import { RootState } from '~/store/store';
+import { Dispatch } from 'redux';
+import { openContextMenu } from '~/store/context-menu/context-menu-actions';
+import { ResourceKind } from '~/models/resource';
+import { LinkClass, LinkResource } from '~/models/link';
+import { navigateToGroupDetails } from '~/store/navigation/navigation-action';
+
+export enum GroupsPanelColumnNames {
+    GROUP = "Name",
+    OWNER = "Owner",
+    MEMBERS = "Members",
+}
+
+export const groupsPanelColumns: DataColumns<string> = [
+    {
+        name: GroupsPanelColumnNames.GROUP,
+        selected: true,
+        configurable: true,
+        sortDirection: SortDirection.ASC,
+        filters: createTree(),
+        render: uuid => <ResourceName uuid={uuid} />
+    },
+    {
+        name: GroupsPanelColumnNames.OWNER,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceOwner uuid={uuid} />,
+    },
+    {
+        name: GroupsPanelColumnNames.MEMBERS,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <GroupMembersCount uuid={uuid} />,
+    },
+];
+
+const mapStateToProps = (state: RootState) => {
+    return {
+        resources: state.resources
+    };
+};
+
+const mapDispatchToProps = {
+    onContextMenu: openContextMenu,
+    onRowDoubleClick: (uuid: string) =>
+        navigateToGroupDetails(uuid),
+    onNewGroup: openCreateGroupDialog,
+};
+
+export interface GroupsPanelProps {
+    onNewGroup: () => void;
+    onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
+    onRowDoubleClick: (item: string) => void;
+    resources: ResourcesState;
+}
+
+export const GroupsPanel = connect(
+    mapStateToProps, mapDispatchToProps
+)(
+    class GroupsPanel extends React.Component<GroupsPanelProps> {
+
+        render() {
+            return (
+                <DataExplorer
+                    id={GROUPS_PANEL_ID}
+                    onRowClick={noop}
+                    onRowDoubleClick={this.props.onRowDoubleClick}
+                    onContextMenu={this.handleContextMenu}
+                    contextMenuColumn={true}
+                    hideColumnSelector
+                    actions={
+                        <Grid container justify='flex-end'>
+                            <Button
+                                variant="contained"
+                                color="primary"
+                                onClick={this.props.onNewGroup}>
+                                <AddIcon /> New group
+                        </Button>
+                        </Grid>
+                    } />
+            );
+        }
+
+        handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
+            const resource = getResource<GroupResource>(resourceUuid)(this.props.resources);
+            if (resource) {
+                this.props.onContextMenu(event, {
+                    name: '',
+                    uuid: resource.uuid,
+                    ownerUuid: resource.ownerUuid,
+                    kind: resource.kind,
+                    menuKind: ContextMenuKind.GROUPS
+                });
+            }
+        }
+    });
+
+
+const GroupMembersCount = connect(
+    (state: RootState, props: { uuid: string }) => {
+
+        const permissions = filterResources((resource: LinkResource) =>
+            resource.kind === ResourceKind.LINK &&
+            resource.linkClass === LinkClass.PERMISSION &&
+            resource.headUuid === props.uuid
+        )(state.resources);
+
+        return {
+            children: permissions.length,
+        };
+
+    }
+)(Typography);
index 025540e22ed89c6bab5ecd1a9dee6ddc11281a94..bff328e8c8c6ff448bc271d36068925a8b0cc81d 100644 (file)
@@ -79,6 +79,14 @@ import { UserPanel } from '~/views/user-panel/user-panel';
 import { UserAttributesDialog } from '~/views-components/user-dialog/attributes-dialog';
 import { CreateUserDialog } from '~/views-components/dialog-forms/create-user-dialog';
 import { HelpApiClientAuthorizationDialog } from '~/views-components/api-client-authorizations-dialog/help-dialog';
+import { GroupsPanel } from '~/views/groups-panel/groups-panel';
+import { CreateGroupDialog } from '~/views-components/dialog-forms/create-group-dialog';
+import { RemoveGroupDialog } from '~/views-components/groups-dialog/remove-dialog';
+import { GroupAttributesDialog } from '~/views-components/groups-dialog/attributes-dialog';
+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';
 
@@ -158,6 +166,8 @@ 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>
@@ -167,6 +177,7 @@ export const WorkbenchPanel =
             <Grid item>
                 <DetailsPanel />
             </Grid>
+            <AddGroupMembersDialog />
             <AdvancedTabDialog />
             <AttributesApiClientAuthorizationDialog />
             <AttributesComputeNodeDialog />
@@ -178,6 +189,7 @@ export const WorkbenchPanel =
             <CopyCollectionDialog />
             <CopyProcessDialog />
             <CreateCollectionDialog />
+            <CreateGroupDialog />
             <CreateProjectDialog />
             <CreateRepositoryDialog />
             <CreateSshKeyDialog />
@@ -185,6 +197,8 @@ export const WorkbenchPanel =
             <CurrentTokenDialog />
             <FileRemoveDialog />
             <FilesUploadCollectionDialog />
+            <GroupAttributesDialog />
+            <GroupMemberAttributesDialog />
             <HelpApiClientAuthorizationDialog />
             <MoveCollectionDialog />
             <MoveProcessDialog />
@@ -197,6 +211,8 @@ export const WorkbenchPanel =
             <ProjectPropertiesDialog />
             <RemoveApiClientAuthorizationDialog />
             <RemoveComputeNodeDialog />
+            <RemoveGroupDialog />
+            <RemoveGroupMemberDialog />
             <RemoveKeepServiceDialog />
             <RemoveLinkDialog />
             <RemoveProcessDialog />