Merge branch '18123-group-edit-page-rebase1' into main. Closes #18123
authorStephen Smith <stephen@curii.com>
Thu, 16 Dec 2021 17:27:26 +0000 (12:27 -0500)
committerStephen Smith <stephen@curii.com>
Thu, 16 Dec 2021 17:27:26 +0000 (12:27 -0500)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

33 files changed:
cypress/integration/group-manage.spec.js [new file with mode: 0644]
src/common/labels.ts
src/components/data-explorer/data-explorer.tsx
src/components/icon/icon.tsx
src/index.tsx
src/models/group.ts
src/models/project.ts
src/store/context-menu/context-menu-actions.ts
src/store/group-details-panel/group-details-panel-actions.ts
src/store/group-details-panel/group-details-panel-members-middleware-service.ts [moved from src/store/group-details-panel/group-details-panel-middleware-service.ts with 55% similarity]
src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts [new file with mode: 0644]
src/store/groups-panel/groups-panel-actions.ts
src/store/groups-panel/groups-panel-middleware-service.ts
src/store/navigation/navigation-action.ts
src/store/projects/project-update-actions.ts
src/store/resources/resources-actions.ts
src/store/sharing-dialog/sharing-dialog-actions.ts
src/store/side-panel-tree/side-panel-tree-actions.ts
src/store/store.ts
src/store/workbench/workbench-actions.ts
src/views-components/context-menu/action-sets/group-action-set.ts
src/views-components/context-menu/action-sets/permission-edit-action-set.ts [new file with mode: 0644]
src/views-components/context-menu/context-menu.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/dialog-forms/add-group-member-dialog.tsx [deleted file]
src/views-components/dialog-forms/create-group-dialog.tsx [deleted file]
src/views-components/dialog-forms/update-project-dialog.ts
src/views-components/dialog-update/dialog-project-update.tsx
src/views-components/form-fields/project-form-fields.tsx
src/views-components/side-panel-tree/side-panel-tree.tsx
src/views/group-details-panel/group-details-panel.tsx
src/views/groups-panel/groups-panel.tsx
src/views/workbench/workbench.tsx

diff --git a/cypress/integration/group-manage.spec.js b/cypress/integration/group-manage.spec.js
new file mode 100644 (file)
index 0000000..c98c220
--- /dev/null
@@ -0,0 +1,284 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+describe('Group manage tests', function() {
+    let activeUser;
+    let adminUser;
+    let otherUser;
+    let userThree;
+    const groupName = `Test group (${Math.floor(999999 * Math.random())})`;
+
+    before(function() {
+        // Only set up common users once. These aren't set up as aliases because
+        // aliases are cleaned up after every test. Also it doesn't make sense
+        // to set the same users on beforeEach() over and over again, so we
+        // separate a little from Cypress' 'Best Practices' here.
+        cy.getUser('admin', 'Admin', 'User', true, true)
+            .as('adminUser').then(function() {
+                adminUser = this.adminUser;
+            }
+        );
+        cy.getUser('user', 'Active', 'User', false, true)
+            .as('activeUser').then(function() {
+                activeUser = this.activeUser;
+            }
+        );
+        cy.getUser('otheruser', 'Other', 'User', false, true)
+            .as('otherUser').then(function() {
+                otherUser = this.otherUser;
+            }
+        );
+        cy.getUser('userThree', 'User', 'Three', false, true)
+            .as('userThree').then(function() {
+                userThree = this.userThree;
+            }
+        );
+    });
+
+    beforeEach(function() {
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
+
+    it('creates a new group', function() {
+        cy.loginAs(activeUser);
+
+        // Navigate to Groups
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+
+        // Create new group
+        cy.get('[data-cy=groups-panel-new-group]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'Create Group')
+            .within(() => {
+                cy.get('input[name=name]').type(groupName);
+                cy.get('[data-cy=users-field] input').type("three");
+            });
+        cy.get('[role=tooltip]').click();
+        cy.get('[data-cy=form-dialog] button[type=submit]').click();
+        
+        // Check that the group was created
+        cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click();
+        cy.get('[data-cy=group-members-data-explorer]').contains(activeUser.user.full_name);
+        cy.get('[data-cy=group-members-data-explorer]').contains(userThree.user.full_name);
+    });
+
+    it('adds users to the group', function() {
+        // Add other user to the group
+        cy.get('[data-cy=group-member-add]').click();
+        cy.get('.sharing-dialog')
+            .should('contain', 'Sharing settings')
+            .within(() => {
+                cy.get('[data-cy=invite-people-field] input').type("other");
+            });
+        cy.get('[role=tooltip]').click();
+        cy.get('.sharing-dialog').contains('Save').click();
+
+        // Check that both users are present with appropriate permissions
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read');
+            });
+        cy.get('[data-cy=group-members-data-explorer] tr')
+            .contains(activeUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Manage');
+            });
+    });
+
+    it('changes permission level of a member', function() {
+        // Test change permission level
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read')
+                    .parents('td')
+                    .within(() => {
+                        cy.get('button').click();
+                    });
+            });
+        cy.get('[data-cy=context-menu]')
+            .contains('Write')
+            .click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Write');
+            });
+    });
+
+    it('can unhide and re-hide users', function() {
+        // Must use admin user to have manage permission on user
+        cy.loginAs(adminUser);
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+        cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click();
+
+        // Check that other user is hidden
+        cy.get('[data-cy=group-details-permissions-tab]').click();
+        cy.get('[data-cy=group-permissions-data-explorer]')
+            .should('not.contain', otherUser.user.full_name)
+        cy.get('[data-cy=group-details-members-tab]').click();
+
+        // Test unhide
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.get('[data-cy=user-visible-checkbox]').click();
+            });
+        // Check that other user is visible
+        cy.get('[data-cy=group-details-permissions-tab]').click();
+        cy.get('[data-cy=group-permissions-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read');
+            });
+        // Test re-hide
+        cy.get('[data-cy=group-details-members-tab]').click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.get('[data-cy=user-visible-checkbox]').click();
+            });
+        // Check that other user is hidden
+        cy.get('[data-cy=group-details-permissions-tab]').click();
+        cy.get('[data-cy=group-permissions-data-explorer]')
+            .should('not.contain', otherUser.user.full_name)
+    });
+
+    it('displays resources shared with the group', function() {
+        // Switch to activeUser
+        cy.loginAs(activeUser);
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+
+        // Get groupUuid and create shared project
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .contains(groupName)
+            .parents('tr')
+            .find('[data-cy=uuid]')
+            .invoke('text')
+            .as('groupUuid')
+            .then((groupUuid) => {
+                cy.createProject({
+                    owningUser: activeUser,
+                    projectName: 'test-project',
+                }).as('testProject').then((testProject) => {
+                    cy.shareWith(activeUser.token, groupUuid, testProject.uuid, 'can_read');
+                });
+            });
+
+        // Check that the project is listed in permissions
+        cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click();
+        cy.get('[data-cy=group-details-permissions-tab]').click();
+        cy.get('[data-cy=group-permissions-data-explorer]')
+            .contains('test-project')
+            .parents('tr')
+            .within(() => {
+                cy.contains('Read');
+            });
+    });
+
+    it('removes users from the group', function() {
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+        cy.get('[data-cy=groups-panel-data-explorer]').contains(groupName).click();
+
+        // Remove other user
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(otherUser.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.get('[data-cy=resource-delete-button]').click();
+            });
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .should('not.contain', otherUser.user.full_name);
+
+        // Remove user three
+        cy.get('[data-cy=group-members-data-explorer]')
+            .contains(userThree.user.full_name)
+            .parents('tr')
+            .within(() => {
+                cy.get('[data-cy=resource-delete-button]').click();
+            });
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        cy.get('[data-cy=group-members-data-explorer]')
+            .should('not.contain', userThree.user.full_name);
+    });
+
+    it('renames the group', function() {
+        // Navigate to Groups
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+
+        // Open rename dialog
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .contains(groupName)
+            .rightclick();
+        cy.get('[data-cy=context-menu]')
+            .contains('Rename')
+            .click();
+
+        // Rename the group
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'Edit Group')
+            .within(() => {
+                cy.get('input[name=name]').clear().type(groupName + ' (renamed)');
+                cy.get('button[type=submit]').click();
+            });
+
+        // Check that the group was renamed
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .contains(groupName + ' (renamed)');
+    });
+
+    it('deletes the group', function() {
+        // Navigate to Groups
+        cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+
+        // Delete the group
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .contains(groupName + ' (renamed)')
+            .rightclick();
+        cy.get('[data-cy=context-menu]')
+            .contains('Remove')
+            .click();
+        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+
+        // Check that the group was deleted
+        cy.get('[data-cy=groups-panel-data-explorer]')
+            .should('not.contain', groupName + ' (renamed)');
+    });
+
+    it('disables group-related controls for built-in groups', function() {
+        cy.loginAs(adminUser);
+
+        ['All users', 'Anonymous users', 'System group'].forEach((builtInGroup) => {
+            cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+            cy.get('[data-cy=groups-panel-data-explorer]').contains(builtInGroup).click();
+
+            // Check group member actions
+            cy.get('[data-cy=group-members-data-explorer]')
+                .within(() => {
+                    cy.get('[data-cy=group-member-add]').should('not.exist');
+                    cy.get('[data-cy=user-visible-checkbox] input').should('be.disabled');
+                    cy.get('[data-cy=resource-delete-button]').should('be.disabled');
+                    cy.get('[data-cy=edit-permission-button]').should('not.exist');
+                });
+
+            // Check permissions actions
+            cy.get('[data-cy=group-details-permissions-tab]').click();
+            cy.get('[data-cy=group-permissions-data-explorer]').within(() => {
+                cy.get('[data-cy=resource-delete-button]').should('be.disabled');
+                cy.get('[data-cy=edit-permission-button]').should('not.exist');
+            });
+        });
+    });
+
+});
index f534bd2b4e30546bb8740a8e354cde6a0fef0903..682513fb165e31105363a71decf7c2b4a74fa8fe 100644 (file)
@@ -11,6 +11,8 @@ export const resourceLabel = (type: string, subtype = '') => {
         case ResourceKind.PROJECT:
             if (subtype === "filter") {
                 return "Filter group";
+            } else if (subtype === "role") {
+                return "Group";
             }
             return "Project";
         case ResourceKind.PROCESS:
index 05125f12c7311b8a4fe700a7dd61923ce6e682c8..55840ae9fd52a752f8b90e73c1f3cc06370c19b9 100644 (file)
@@ -110,18 +110,19 @@ export const DataExplorer = withStyles(styles)(
                 doHidePanel, doMaximizePanel, panelName, panelMaximized
             } = this.props;
 
-            return <Paper className={classes.root} {...paperProps} key={paperKey}>
+            const dataCy = this.props["data-cy"];
+            return <Paper className={classes.root} {...paperProps} key={paperKey} data-cy={dataCy}>
                 <Grid container direction="column" wrap="nowrap" className={classes.container}>
                 {title && <Grid item xs className={classes.title}>{title}</Grid>}
-                {(!hideColumnSelector || !hideSearchInput) && <Grid item xs><Toolbar className={title ? classes.toolbarUnderTitle : classes.toolbar}>
+                {(!hideColumnSelector || !hideSearchInput || !!actions) && <Grid item xs><Toolbar className={title ? classes.toolbarUnderTitle : classes.toolbar}>
                     <Grid container justify="space-between" wrap="nowrap" alignItems="center">
-                        <div className={classes.searchBox}>
+                        {!hideSearchInput && <div className={classes.searchBox}>
                             {!hideSearchInput && <SearchInput
                                 label={searchLabel}
                                 value={searchValue}
                                 selfClearProp={currentItemUuid}
                                 onSearch={onSearch} />}
-                        </div>
+                        </div>}
                         {actions}
                         {!hideColumnSelector && <ColumnSelector
                             columns={columns}
index 523eefbd10c7b75bb0e6904f59a1abf62555f563..15a9f02d7339ab9c05411135d00d8190f06a0a92 100644 (file)
@@ -63,14 +63,17 @@ import Visibility from '@material-ui/icons/Visibility';
 import VisibilityOff from '@material-ui/icons/VisibilityOff';
 import VpnKey from '@material-ui/icons/VpnKey';
 import LinkOutlined from '@material-ui/icons/LinkOutlined';
+import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
+import Computer from '@material-ui/icons/Computer';
 
 // Import FontAwesome icons
 import { library } from '@fortawesome/fontawesome-svg-core';
-import { faPencilAlt, faSlash } from '@fortawesome/free-solid-svg-icons';
+import { faPencilAlt, faSlash, faUsers } from '@fortawesome/free-solid-svg-icons';
 import { CropFreeSharp } from '@material-ui/icons';
 library.add(
     faPencilAlt,
     faSlash,
+    faUsers,
 );
 
 export const ReadOnlyIcon = (props: any) =>
@@ -82,6 +85,11 @@ export const ReadOnlyIcon = (props: any) =>
         </div>
     </span>;
 
+export const GroupsIcon = (props: any) =>
+    <span {...props}>
+        <span className="fas fa-users" />
+    </span>;
+
 export const CollectionOldVersionIcon = (props: any) =>
     <Tooltip title='Old version'>
         <Badge badgeContent={<History fontSize='small' />}>
@@ -155,3 +163,6 @@ export const WorkflowIcon: IconType = (props) => <Code {...props} />;
 export const WarningIcon: IconType = (props) => <Warning style={{ color: '#fbc02d', height: '30px', width: '30px' }} {...props} />;
 export const Link: IconType = (props) => <LinkOutlined {...props} />;
 export const FolderSharedIcon: IconType = (props) => <FolderShared {...props} />;
+export const CanReadIcon: IconType = (props) => <RemoveRedEye {...props} />;
+export const CanWriteIcon: IconType = (props) => <Edit {...props} />;
+export const CanManageIcon: IconType = (props) => <Computer {...props} />;
index 6ad22a551633a71d577ebe74e35f632d894f05ba..0b04c29e4c896c5387688caeadb35cc29bafc43a 100644 (file)
@@ -59,6 +59,7 @@ import { linkActionSet } from 'views-components/context-menu/action-sets/link-ac
 import { loadFileViewersConfig } from 'store/file-viewers/file-viewers-actions';
 import { processResourceAdminActionSet } from 'views-components/context-menu/action-sets/process-resource-admin-action-set';
 import { filterGroupAdminActionSet, projectAdminActionSet } from 'views-components/context-menu/action-sets/project-admin-action-set';
+import { permissionEditActionSet } from 'views-components/context-menu/action-sets/permission-edit-action-set';
 import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action';
 import { storeRedirects } from './common/redirect-to';
@@ -99,6 +100,7 @@ addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
 addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
+addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet);
 
 storeRedirects();
 
index 365e9ccebb9fc22341da3589f2dc993287d304c7..a0c22212b794e72c6c2ce02e17c06f1c59ebacb9 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ResourceKind, TrashableResource } from "./resource";
+import { ResourceKind, TrashableResource, ResourceObjectType, RESOURCE_UUID_REGEX } from "./resource";
 
 export interface GroupResource extends TrashableResource {
     kind: ResourceKind.GROUP;
@@ -17,4 +17,17 @@ export interface GroupResource extends TrashableResource {
 export enum GroupClass {
     PROJECT = 'project',
     FILTER  = 'filter',
+    ROLE  = 'role',
 }
+
+export const BUILTIN_GROUP_IDS = [
+    'fffffffffffffff',
+    'anonymouspublic',
+    '000000000000000',
+]
+
+export const isBuiltinGroup = (uuid: string) => {
+    const match = RESOURCE_UUID_REGEX.exec(uuid);
+    const parts = match ? match[0].split('-') : [];
+    return parts.length === 3 && parts[1] === ResourceObjectType.GROUP && BUILTIN_GROUP_IDS.includes(parts[2]);
+};
index 86ac04f6dd58222d869bd29980ed03715f0adba7..b47b426f274989db009552aab3ecbd9e8c1bfa1e 100644 (file)
@@ -5,7 +5,7 @@
 import { GroupClass, GroupResource } from "./group";
 
 export interface ProjectResource extends GroupResource {
-    groupClass: GroupClass.PROJECT | GroupClass.FILTER;
+    groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE;
 }
 
 export const getProjectUrl = (uuid: string) => {
index 59a6813be0919b1448d7ebb5869236186c5af9fa..9a8733ba6ffa9ae77db8118f79c4d2c070064f78 100644 (file)
@@ -20,6 +20,7 @@ import { ProcessResource } from 'models/process';
 import { CollectionResource } from 'models/collection';
 import { GroupClass, GroupResource } from 'models/group';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
+import { LinkResource } from 'models/link';
 
 export const contextMenuActions = unionize({
     OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
@@ -193,6 +194,19 @@ export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, pro
         }
     };
 
+export const openPermissionEditContextMenu = (event: React.MouseEvent<HTMLElement>, link: LinkResource) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        if (link) {
+            dispatch<any>(openContextMenu(event, {
+                name: link.name,
+                uuid: link.uuid,
+                kind: link.kind,
+                menuKind: ContextMenuKind.PERMISSION_EDIT,
+                ownerUuid: link.ownerUuid,
+            }));
+        }
+    };
+
 export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         const { isAdmin: isAdminUser, uuid: userUuid } = getState().auth.user!;
index 01e6c151f0c02479a9c25d71dc6b73fa84f4c3aa..e00ff77340514714053f20a9bb1ed74b227f79f5 100644 (file)
@@ -6,74 +6,57 @@ import { bindDataExplorerActions } from 'store/data-explorer/data-explorer-actio
 import { Dispatch } from 'redux';
 import { propertiesActions } from 'store/properties/properties-actions';
 import { getProperty } from 'store/properties/properties';
-import { Participant } from 'views-components/sharing-dialog/participant-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 { 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 { PermissionResource, PermissionLevel } from 'models/permission';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import { UserResource, getUserDisplayName } from 'models/user';
+import { LinkResource } from 'models/link';
+import { deleteResources } from 'store/resources/resources-actions';
+import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-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 GROUP_DETAILS_MEMBERS_PANEL_ID = 'groupDetailsMembersPanel';
+export const GROUP_DETAILS_PERMISSIONS_PANEL_ID = 'groupDetailsPermissionsPanel';
 export const MEMBER_ATTRIBUTES_DIALOG = 'memberAttributesDialog';
 export const MEMBER_REMOVE_DIALOG = 'memberRemoveDialog';
 
-export const GroupDetailsPanelActions = bindDataExplorerActions(GROUP_DETAILS_PANEL_ID);
+export const GroupMembersPanelActions = bindDataExplorerActions(GROUP_DETAILS_MEMBERS_PANEL_ID);
+export const GroupPermissionsPanelActions = bindDataExplorerActions(GROUP_DETAILS_PERMISSIONS_PANEL_ID);
 
 export const loadGroupDetailsPanel = (groupUuid: string) =>
     (dispatch: Dispatch) => {
-        dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_PANEL_ID, value: groupUuid }));
-        dispatch(GroupDetailsPanelActions.REQUEST_ITEMS());
+        dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_MEMBERS_PANEL_ID, value: groupUuid }));
+        dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
+        dispatch(propertiesActions.SET_PROPERTY({ key: GROUP_DETAILS_PERMISSIONS_PANEL_ID, value: groupUuid }));
+        dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
     };
 
-export const getCurrentGroupDetailsPanelUuid = getProperty<string>(GROUP_DETAILS_PANEL_ID);
-
-export interface AddGroupMembersFormData {
-    [ADD_GROUP_MEMBERS_USERS_FIELD_NAME]: Participant[];
-}
+export const getCurrentGroupDetailsPanelUuid = getProperty<string>(GROUP_DETAILS_MEMBERS_PANEL_ID);
 
 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) => {
-
+    (dispatch: Dispatch, getState: () => RootState) => {
         const groupUuid = getCurrentGroupDetailsPanelUuid(getState().properties);
-
         if (groupUuid) {
+            dispatch<any>(openSharingDialog(groupUuid, () => {
+                dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
+            }));
+        }
+    };
 
-            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 editPermissionLevel = (uuid: string, level: PermissionLevel) =>
+    async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+        try {
+            await permissionService.update(uuid, {name: level});
+            dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
+            dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Permission level changed.', hideDuration: 2000 }));
+        } catch (e) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: 'Failed to update permission',
+                kind: SnackbarKind.ERROR,
+            }));
         }
     };
 
@@ -104,28 +87,63 @@ export const removeGroupMember = (uuid: string) =>
         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 ...', kind: SnackbarKind.INFO }));
 
             await deleteGroupMember({
-                user: {
+                link: {
                     uuid,
-                    name: user ? getUserDisplayName(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());
+            dispatch(GroupMembersPanelActions.REQUEST_ITEMS());
 
         }
 
     };
+
+export const setMemberIsHidden = (memberLinkUuid: string, permissionLinkUuid: string, visible: boolean) =>
+    async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
+        const memberLink = getResource<LinkResource>(memberLinkUuid)(getState().resources);
+
+        if (!visible && permissionLinkUuid) {
+            // Remove read permission
+            try {
+                await permissionService.delete(permissionLinkUuid);
+                dispatch<any>(deleteResources([permissionLinkUuid]));
+                dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Removed read permission.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                }));
+            } catch (e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Failed to remove permission',
+                    kind: SnackbarKind.ERROR,
+                }));
+            }
+        } else if (visible && memberLink) {
+            // Create read permission
+            try {
+                await permissionService.create({
+                    headUuid: memberLink.tailUuid,
+                    tailUuid: memberLink.headUuid,
+                    name: PermissionLevel.CAN_READ,
+                });
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Created read permission.',
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                }));
+                dispatch(GroupPermissionsPanelActions.REQUEST_ITEMS());
+            } catch(e) {
+                dispatch(snackbarActions.OPEN_SNACKBAR({
+                    message: 'Failed to create permission',
+                    kind: SnackbarKind.ERROR,
+                }));
+            }
+        }
+    };
similarity index 55%
rename from src/store/group-details-panel/group-details-panel-middleware-service.ts
rename to src/store/group-details-panel/group-details-panel-members-middleware-service.ts
index 834b4c2139a48de92e8138a29dcd2727e862729c..e6f18f7f98489a451cd0c19afdba912393184cf9 100644 (file)
@@ -10,10 +10,11 @@ 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 { getCurrentGroupDetailsPanelUuid, GroupMembersPanelActions } from 'store/group-details-panel/group-details-panel-actions';
 import { LinkClass } from 'models/link';
+import { ResourceKind } from 'models/resource';
 
-export class GroupDetailsPanelMiddlewareService extends DataExplorerMiddlewareService {
+export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddlewareService {
 
     constructor(private services: ServiceRepository, id: string) {
         super(id);
@@ -26,24 +27,41 @@ export class GroupDetailsPanelMiddlewareService extends DataExplorerMiddlewareSe
             api.dispatch(groupsDetailsPanelDataExplorerIsNotSet());
         } else {
             try {
-                const permissions = await this.services.permissionService.list({
+                const groupResource = await this.services.groupsService.get(groupUuid);
+                api.dispatch(updateResources([groupResource]));
+
+                const permissionsIn = await this.services.permissionService.list({
                     filters: new FilterBuilder()
-                        .addEqual('tail_uuid', groupUuid)
+                        .addEqual('head_uuid', groupUuid)
                         .addEqual('link_class', LinkClass.PERMISSION)
                         .getFilters()
                 });
-                api.dispatch(updateResources(permissions.items));
-                const users = await this.services.userService.list({
+                api.dispatch(updateResources(permissionsIn.items));
+
+                api.dispatch(GroupMembersPanelActions.SET_ITEMS({
+                    ...listResultsToDataExplorerItemsMeta(permissionsIn),
+                    items: permissionsIn.items.map(item => item.uuid),
+                }));
+
+                const usersIn = await this.services.userService.list({
                     filters: new FilterBuilder()
-                        .addIn('uuid', permissions.items.map(item => item.headUuid))
+                        .addIn('uuid', permissionsIn.items
+                            .filter((item) => item.tailKind === ResourceKind.USER)
+                            .map(item => item.tailUuid))
                         .getFilters(),
                     count: "none"
                 });
-                api.dispatch(GroupDetailsPanelActions.SET_ITEMS({
-                    ...listResultsToDataExplorerItemsMeta(permissions),
-                    items: users.items.map(item => item.uuid),
-                }));
-                api.dispatch(updateResources(users.items));
+                api.dispatch(updateResources(usersIn.items));
+
+                const projectsIn = await this.services.projectService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsIn.items
+                            .filter((item) => item.tailKind === ResourceKind.PROJECT)
+                            .map(item => item.tailUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(projectsIn.items));
             } catch (e) {
                 api.dispatch(couldNotFetchGroupDetailsContents());
             }
@@ -53,12 +71,12 @@ export class GroupDetailsPanelMiddlewareService extends DataExplorerMiddlewareSe
 
 const groupsDetailsPanelDataExplorerIsNotSet = () =>
     snackbarActions.OPEN_SNACKBAR({
-        message: 'Group details panel is not ready.',
+        message: 'Group members panel is not ready.',
         kind: SnackbarKind.ERROR
     });
 
 const couldNotFetchGroupDetailsContents = () =>
     snackbarActions.OPEN_SNACKBAR({
-        message: 'Could not fetch group details.',
+        message: 'Could not fetch group members.',
         kind: SnackbarKind.ERROR
     });
diff --git a/src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts b/src/store/group-details-panel/group-details-panel-permissions-middleware-service.ts
new file mode 100644 (file)
index 0000000..9e41409
--- /dev/null
@@ -0,0 +1,89 @@
+// 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, GroupPermissionsPanelActions } from 'store/group-details-panel/group-details-panel-actions';
+import { LinkClass } from 'models/link';
+import { ResourceKind } from 'models/resource';
+
+export class GroupDetailsPanelPermissionsMiddlewareService 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 permissionsOut = await this.services.permissionService.list({
+                    filters: new FilterBuilder()
+                        .addEqual('tail_uuid', groupUuid)
+                        .addEqual('link_class', LinkClass.PERMISSION)
+                        .getFilters()
+                });
+                api.dispatch(updateResources(permissionsOut.items));
+
+                api.dispatch(GroupPermissionsPanelActions.SET_ITEMS({
+                    ...listResultsToDataExplorerItemsMeta(permissionsOut),
+                    items: permissionsOut.items.map(item => item.uuid),
+                }));
+
+                const usersOut = await this.services.userService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsOut.items
+                            .filter((item) => item.headKind === ResourceKind.USER)
+                            .map(item => item.headUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(usersOut.items));
+
+                const collectionsOut = await this.services.collectionService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsOut.items
+                            .filter((item) => item.headKind === ResourceKind.COLLECTION)
+                            .map(item => item.headUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(collectionsOut.items));
+
+                const projectsOut = await this.services.projectService.list({
+                    filters: new FilterBuilder()
+                        .addIn('uuid', permissionsOut.items
+                            .filter((item) => item.headKind === ResourceKind.PROJECT)
+                            .map(item => item.headUuid))
+                        .getFilters(),
+                    count: "none"
+                });
+                api.dispatch(updateResources(projectsOut.items));
+            } catch (e) {
+                api.dispatch(couldNotFetchGroupDetailsContents());
+            }
+        }
+    }
+}
+
+const groupsDetailsPanelDataExplorerIsNotSet = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Group permissions panel is not ready.',
+        kind: SnackbarKind.ERROR
+    });
+
+const couldNotFetchGroupDetailsContents = () =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: 'Could not fetch group permissions.',
+        kind: SnackbarKind.ERROR
+    });
index 0d92e946309b9ca1afb4906a239403167e1c965c..c72b00177c35f2ba42e377ca12d647a581b8cd7a 100644 (file)
@@ -3,25 +3,22 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from 'redux';
-import { reset, startSubmit, stopSubmit, FormErrors } from 'redux-form';
+import { reset, startSubmit, stopSubmit, FormErrors, initialize } from 'redux-form';
 import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
 import { dialogActions } from 'store/dialog/dialog-actions';
-import { Participant } from 'views-components/sharing-dialog/participant-select';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
 import { getResource } from 'store/resources/resources';
-import { GroupResource } from 'models/group';
+import { GroupResource, GroupClass } from 'models/group';
 import { getCommonResourceServiceError, CommonResourceServiceError } from 'services/common-service/common-resource-service';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { PermissionLevel } from 'models/permission';
 import { PermissionService } from 'services/permission-service/permission-service';
 import { FilterBuilder } from 'services/api/filter-builder';
+import { ProjectUpdateFormDialogData, PROJECT_UPDATE_FORM_NAME } from 'store/projects/project-update-actions';
 
 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';
 
@@ -30,9 +27,9 @@ 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));
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(initialize(PROJECT_UPDATE_FORM_NAME, {}));
+        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {sourcePanel: GroupClass.ROLE, create: true} }));
     };
 
 export const openGroupAttributes = (uuid: string) =>
@@ -63,16 +60,38 @@ export const openRemoveGroupDialog = (uuid: string) =>
         }));
     };
 
-export interface CreateGroupFormData {
-    [CREATE_GROUP_NAME_FIELD_NAME]: string;
-    [CREATE_GROUP_USERS_FIELD_NAME]?: Participant[];
-}
+// Group edit dialog uses project update dialog with sourcePanel set to reload the appropriate parts
+export const openGroupUpdateDialog = (resource: ProjectUpdateFormDialogData) =>
+    (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch(initialize(PROJECT_UPDATE_FORM_NAME, resource));
+        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {sourcePanel: GroupClass.ROLE} }));
+    };
 
-export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
+export const updateGroup = (project: ProjectUpdateFormDialogData) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = project.uuid || '';
+        dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME));
+        try {
+            const updatedGroup = await services.groupsService.update(uuid, { name: project.name, description: project.description });
+            dispatch(GroupsPanelActions.REQUEST_ITEMS());
+            dispatch(reset(PROJECT_UPDATE_FORM_NAME));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
+            return updatedGroup;
+        } catch (e) {
+            dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME));
+            const error = getCommonResourceServiceError(e);
+            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+                dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Group with the same name already exists.' } as FormErrors));
+            }
+            return ;
+        }
+    };
+
+export const createGroup = ({ name, users = [], description }: ProjectUpdateFormDialogData) =>
     async (dispatch: Dispatch, _: {}, { groupsService, permissionService }: ServiceRepository) => {
-        dispatch(startSubmit(CREATE_GROUP_FORM));
+        dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME));
         try {
-            const newGroup = await groupsService.create({ name });
+            const newGroup = await groupsService.create({ name, description, groupClass: GroupClass.ROLE });
             for (const user of users) {
                 await addGroupMember({
                     user,
@@ -81,8 +100,8 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
                     permissionService,
                 });
             }
-            dispatch(dialogActions.CLOSE_DIALOG({ id: CREATE_GROUP_DIALOG }));
-            dispatch(reset(CREATE_GROUP_FORM));
+            dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
+            dispatch(reset(PROJECT_UPDATE_FORM_NAME));
             dispatch(loadGroupsPanel());
             dispatch(snackbarActions.OPEN_SNACKBAR({
                 message: `${newGroup.name} group has been created`,
@@ -92,7 +111,7 @@ export const createGroup = ({ name, users = [] }: CreateGroupFormData) =>
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(CREATE_GROUP_FORM, { name: 'Group with the same name already exists.' } as FormErrors));
+                dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Group with the same name already exists.' } as FormErrors));
             }
             return;
         }
@@ -113,8 +132,8 @@ interface AddGroupMemberArgs {
  */
 export const addGroupMember = async ({ user, group, ...args }: AddGroupMemberArgs) => {
     await createPermission({
-        head: { ...user },
-        tail: { ...group },
+        head: { ...group },
+        tail: { ...user },
         permissionLevel: PermissionLevel.CAN_READ,
         ...args,
     });
@@ -144,33 +163,29 @@ const createPermission = async ({ head, tail, permissionLevel, dispatch, permiss
 };
 
 interface DeleteGroupMemberArgs {
-    user: { uuid: string, name: string };
-    group: { uuid: string, name: string };
+    link: { uuid: string };
     dispatch: Dispatch;
     permissionService: PermissionService;
 }
 
-export const deleteGroupMember = async ({ user, group, ...args }: DeleteGroupMemberArgs) => {
+export const deleteGroupMember = async ({ link, ...args }: DeleteGroupMemberArgs) => {
     await deletePermission({
-        tail: group,
-        head: user,
+        uuid: link.uuid,
         ...args,
     });
 };
 
 interface DeletePermissionLinkArgs {
-    head: { uuid: string, name: string };
-    tail: { uuid: string, name: string };
+    uuid: string;
     dispatch: Dispatch;
     permissionService: PermissionService;
 }
 
-export const deletePermission = async ({ head, tail, dispatch, permissionService }: DeletePermissionLinkArgs) => {
+export const deletePermission = async ({ uuid, dispatch, permissionService }: DeletePermissionLinkArgs) => {
     try {
         const permissionsResponse = await permissionService.list({
             filters: new FilterBuilder()
-                .addEqual('tail_uuid', tail.uuid)
-                .addEqual('head_uuid', head.uuid)
+                .addEqual('uuid', uuid)
                 .getFilters()
         });
         const [permission] = permissionsResponse.items;
@@ -181,8 +196,8 @@ export const deletePermission = async ({ head, tail, dispatch, permissionService
         }
     } catch (e) {
         dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: `Could not delete ${tail.name} -> ${head.name} relation`,
+            message: `Could not delete ${uuid} permission`,
             kind: SnackbarKind.ERROR,
         }));
     }
-};
\ No newline at end of file
+};
index 5fb4718dfffac0d2b639b087032fbfc7e35936e6..2841550686d61dc9d1f7e7652e05334cdbbff4f3 100644 (file)
@@ -36,7 +36,7 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService
                     order.addOrder(direction, 'name');
                 }
                 const filters = new FilterBuilder()
-                    .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
+                    .addEqual('group_class', GroupClass.ROLE)
                     .addILike('name', dataExplorer.searchValue)
                     .getFilters();
                 const response = await this.services.groupsService
@@ -52,7 +52,7 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService
                 }));
                 const permissions = await this.services.permissionService.list({
                     filters: new FilterBuilder()
-                        .addIn('tail_uuid', response.items.map(item => item.uuid))
+                        .addIn('head_uuid', response.items.map(item => item.uuid))
                         .getFilters()
                 });
                 api.dispatch(updateResources(permissions.items));
@@ -74,4 +74,3 @@ const couldNotFetchFavoritesContents = () =>
         message: 'Could not fetch groups.',
         kind: SnackbarKind.ERROR
     });
-
index 97082e5acf0b04c0397f67b816bca1209c76d94e..19cc36ae6d51752fb92150495a22115648ac2f72 100644 (file)
@@ -9,7 +9,6 @@ import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-action
 import { Routes, getProcessLogUrl, getGroupUrl, getNavUrl } from 'routes/routes';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
-import { GROUPS_PANEL_LABEL } from 'store/breadcrumbs/breadcrumbs-actions';
 import { pluginConfig } from 'plugins';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 
@@ -64,7 +63,7 @@ export const navigateTo = (uuid: string) =>
             case SidePanelTreeCategory.TRASH:
                 dispatch(navigateToTrash);
                 return;
-            case GROUPS_PANEL_LABEL:
+            case SidePanelTreeCategory.GROUPS:
                 dispatch(navigateToGroups);
                 return;
             case SidePanelTreeCategory.ALL_PROCESSES:
index 35100eb6e92230e8cbcb1eb6f23089cd5a92bdf3..ba17675380074bf761ceda72e904f596f386955f 100644 (file)
@@ -3,16 +3,19 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from "redux";
-import { FormErrors, initialize, startSubmit, stopSubmit } from 'redux-form';
+import { FormErrors, initialize, reset, startSubmit, stopSubmit } from 'redux-form';
 import { RootState } from "store/store";
 import { dialogActions } from "store/dialog/dialog-actions";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
 import { ServiceRepository } from "services/services";
 import { projectPanelActions } from 'store/project-panel/project-panel-action';
+import { GroupClass } from "models/group";
+import { Participant } from "views-components/sharing-dialog/participant-select";
 
 export interface ProjectUpdateFormDialogData {
     uuid: string;
     name: string;
+    users?: Participant[];
     description?: string;
 }
 
@@ -21,7 +24,7 @@ export const PROJECT_UPDATE_FORM_NAME = 'projectUpdateFormName';
 export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         dispatch(initialize(PROJECT_UPDATE_FORM_NAME, resource));
-        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {} }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_UPDATE_FORM_NAME, data: {sourcePanel: GroupClass.PROJECT} }));
     };
 
 export const updateProject = (project: ProjectUpdateFormDialogData) =>
@@ -31,6 +34,7 @@ export const updateProject = (project: ProjectUpdateFormDialogData) =>
         try {
             const updatedProject = await services.projectService.update(uuid, { name: project.name, description: project.description });
             dispatch(projectPanelActions.REQUEST_ITEMS());
+            dispatch(reset(PROJECT_UPDATE_FORM_NAME));
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
             return updatedProject;
         } catch (e) {
index cdb76e0bd00e9a4154bd9b8700e75239e243d84d..6c05da32f6cbdcee6b558683385d9cce87dcabe5 100644 (file)
@@ -18,6 +18,8 @@ export type ResourcesAction = UnionOf<typeof resourcesActions>;
 
 export const updateResources = (resources: Resource[]) => resourcesActions.SET_RESOURCES(resources);
 
+export const deleteResources = (resources: string[]) => resourcesActions.DELETE_RESOURCES(resources);
+
 export const loadResource = (uuid: string, showErrors?: boolean) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         try {
index 54ad6791d6b69c5272d786464e553386221502e0..4c0b88250a676f16a24b5d330af457b1d757670b 100644 (file)
@@ -21,9 +21,9 @@ import { progressIndicatorActions } from 'store/progress-indicator/progress-indi
 import { snackbarActions, SnackbarKind } from "../snackbar/snackbar-actions";
 import { extractUuidKind, ResourceKind } from "models/resource";
 
-export const openSharingDialog = (resourceUuid: string) =>
+export const openSharingDialog = (resourceUuid: string, refresh?: () => void) =>
     (dispatch: Dispatch) => {
-        dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: resourceUuid }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: SHARING_DIALOG_NAME, data: {resourceUuid, refresh} }));
         dispatch<any>(loadSharingDialog);
     };
 
@@ -34,16 +34,21 @@ export const connectSharingDialog = withDialog(SHARING_DIALOG_NAME);
 export const connectSharingDialogProgress = withProgress(SHARING_DIALOG_NAME);
 
 
-export const saveSharingDialogChanges = async (dispatch: Dispatch) => {
+export const saveSharingDialogChanges = async (dispatch: Dispatch, getState: () => RootState) => {
     dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
     await dispatch<any>(savePublicPermissionChanges);
     await dispatch<any>(saveManagementChanges);
     await dispatch<any>(sendInvitations);
     dispatch(reset(SHARING_INVITATION_FORM_NAME));
     await dispatch<any>(loadSharingDialog);
+
+    const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
+    if (dialog && dialog.data.refresh) {
+        dialog.data.refresh();
+    }
 };
 
-export const sendSharingInvitations = async (dispatch: Dispatch) => {
+export const sendSharingInvitations = async (dispatch: Dispatch, getState: () => RootState) => {
     dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
     await dispatch<any>(sendInvitations);
     dispatch(closeSharingDialog());
@@ -52,15 +57,25 @@ export const sendSharingInvitations = async (dispatch: Dispatch) => {
         kind: SnackbarKind.SUCCESS,
     }));
     dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
+    
+    const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
+    if (dialog && dialog.data.refresh) {
+        dialog.data.refresh();
+    }
 };
 
+interface SharingDialogData {
+    resourceUuid: string;
+    refresh: () => void;
+}
+
 const loadSharingDialog = async (dispatch: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
 
-    const dialog = getDialog<string>(getState().dialog, SHARING_DIALOG_NAME);
+    const dialog = getDialog<SharingDialogData>(getState().dialog, SHARING_DIALOG_NAME);
     if (dialog) {
         dispatch(progressIndicatorActions.START_WORKING(SHARING_DIALOG_NAME));
         try {
-            const { items } = await permissionService.listResourcePermissions(dialog.data);
+            const { items } = await permissionService.listResourcePermissions(dialog.data.resourceUuid);
             dispatch<any>(initializePublicAccessForm(items));
             await dispatch<any>(initializeManagementForm(items));
             dispatch(progressIndicatorActions.STOP_WORKING(SHARING_DIALOG_NAME));
@@ -133,7 +148,7 @@ const initializePublicAccessForm = (permissionLinks: PermissionResource[]) =>
 const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootState, { permissionService }: ServiceRepository) => {
     const state = getState();
     const { user } = state.auth;
-    const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
+    const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
     if (dialog && user) {
         const { permissionUuid, visibility } = getSharingPublicAccessFormData(state);
 
@@ -150,7 +165,7 @@ const savePublicPermissionChanges = async (_: Dispatch, getState: () => RootStat
 
             await permissionService.create({
                 ownerUuid: user.uuid,
-                headUuid: dialog.data,
+                headUuid: dialog.data.resourceUuid,
                 tailUuid: getPublicGroupUuid(state),
                 name: PermissionLevel.CAN_READ,
             });
@@ -197,7 +212,7 @@ const saveManagementChanges = async (_: Dispatch, getState: () => RootState, { p
 const sendInvitations = async (_: Dispatch, getState: () => RootState, { permissionService, userService }: ServiceRepository) => {
     const state = getState();
     const { user } = state.auth;
-    const dialog = getDialog<string>(state.dialog, SHARING_DIALOG_NAME);
+    const dialog = getDialog<SharingDialogData>(state.dialog, SHARING_DIALOG_NAME);
     if (dialog && user) {
         const invitations = getFormValues(SHARING_INVITATION_FORM_NAME)(state) as SharingInvitationFormData;
 
@@ -207,7 +222,7 @@ const sendInvitations = async (_: Dispatch, getState: () => RootState, { permiss
         const invitationDataUsers = getUsersFromForm
             .map(person => ({
                 ownerUuid: user.uuid,
-                headUuid: dialog.data,
+                headUuid: dialog.data.resourceUuid,
                 tailUuid: person.uuid,
                 name: invitations.permissions
             }));
@@ -215,7 +230,7 @@ const sendInvitations = async (_: Dispatch, getState: () => RootState, { permiss
         const invitationsDataGroups = getGroupsFromForm.map(
             group => ({
                 ownerUuid: user.uuid,
-                headUuid: dialog.data,
+                headUuid: dialog.data.resourceUuid,
                 tailUuid: group.uuid,
                 name: invitations.permissions
             })
index 66521f352461a74abc6ae3e95d57115f5aa9c4b6..895fe79c0bdc9011bcb0c7b26c42c6ac3b68ea8e 100644 (file)
@@ -26,7 +26,8 @@ export enum SidePanelTreeCategory {
     WORKFLOWS = 'Workflows',
     FAVORITES = 'My Favorites',
     TRASH = 'Trash',
-    ALL_PROCESSES = 'All Processes'
+    ALL_PROCESSES = 'All Processes',
+    GROUPS = 'Groups',
 }
 
 export const SIDE_PANEL_TREE = 'sidePanelTree';
@@ -52,6 +53,7 @@ let SIDE_PANEL_CATEGORIES: string[] = [
     SidePanelTreeCategory.PUBLIC_FAVORITES,
     SidePanelTreeCategory.FAVORITES,
     SidePanelTreeCategory.WORKFLOWS,
+    SidePanelTreeCategory.GROUPS,
     SidePanelTreeCategory.ALL_PROCESSES,
     SidePanelTreeCategory.TRASH
 ];
index 59a0cb12eebbb560e4056b07289f2bbd2a96901b..688c8a0564e414dd7eecbffd70ec556debf1ac13 100644 (file)
@@ -51,8 +51,9 @@ import { UserMiddlewareService } from 'store/users/user-panel-middleware-service
 import { USERS_PANEL_ID } from 'store/users/users-actions';
 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 { GroupDetailsPanelMembersMiddlewareService } from 'store/group-details-panel/group-details-panel-members-middleware-service';
+import { GroupDetailsPanelPermissionsMiddlewareService } from 'store/group-details-panel/group-details-panel-permissions-middleware-service';
+import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_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';
 import { API_CLIENT_AUTHORIZATION_PANEL_ID } from 'store/api-client-authorizations/api-client-authorizations-actions';
@@ -116,8 +117,11 @@ export function configureStore(history: History, services: ServiceRepository, co
     const groupsPanelMiddleware = dataExplorerMiddleware(
         new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID)
     );
-    const groupDetailsPanelMiddleware = dataExplorerMiddleware(
-        new GroupDetailsPanelMiddlewareService(services, GROUP_DETAILS_PANEL_ID)
+    const groupDetailsPanelMembersMiddleware = dataExplorerMiddleware(
+        new GroupDetailsPanelMembersMiddlewareService(services, GROUP_DETAILS_MEMBERS_PANEL_ID)
+    );
+    const groupDetailsPanelPermissionsMiddleware = dataExplorerMiddleware(
+        new GroupDetailsPanelPermissionsMiddlewareService(services, GROUP_DETAILS_PERMISSIONS_PANEL_ID)
     );
     const linkPanelMiddleware = dataExplorerMiddleware(
         new LinkMiddlewareService(services, LINK_PANEL_ID)
@@ -157,7 +161,8 @@ export function configureStore(history: History, services: ServiceRepository, co
         workflowPanelMiddleware,
         userPanelMiddleware,
         groupsPanelMiddleware,
-        groupDetailsPanelMiddleware,
+        groupDetailsPanelMembersMiddleware,
+        groupDetailsPanelPermissionsMiddleware,
         linkPanelMiddleware,
         apiClientAuthorizationMiddlewareService,
         publicFavoritesMiddleware,
index 6ea30855fe4f108708801c6a49e6b1eb99532b9a..527d9d74bfe5ec60631774ca24c7aec953ae8527 100644 (file)
@@ -88,7 +88,7 @@ import { apiClientAuthorizationPanelColumns } from 'views/api-client-authorizati
 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';
+import { groupDetailsMembersPanelColumns, groupDetailsPermissionsPanelColumns } from 'views/group-details-panel/group-details-panel';
 import { DataTableFetchMode } from "components/data-table/data-table";
 import { loadPublicFavoritePanel, publicFavoritePanelActions } from 'store/public-favorites-panel/public-favorites-action';
 import { publicFavoritePanelColumns } from 'views/public-favorites-panel/public-favorites-panel';
@@ -136,7 +136,8 @@ export const loadWorkbench = () =>
             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(groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({ columns: groupDetailsMembersPanelColumns }));
+            dispatch(groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({ columns: groupDetailsPermissionsPanelColumns }));
             dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
             dispatch(apiClientAuthorizationsActions.SET_COLUMNS({ columns: apiClientAuthorizationPanelColumns }));
             dispatch(collectionsContentAddressActions.SET_COLUMNS({ columns: collectionContentAddressPanelColumns }));
@@ -271,6 +272,20 @@ export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialog
         }
     };
 
+export const updateGroup = (data: projectUpdateActions.ProjectUpdateFormDialogData) =>
+    async (dispatch: Dispatch) => {
+        const updatedGroup = await dispatch<any>(groupPanelActions.updateGroup(data));
+        if (updatedGroup) {
+            dispatch(snackbarActions.OPEN_SNACKBAR({
+                message: "Group has been successfully updated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS
+            }));
+            await dispatch<any>(loadSidePanelTreeProjects(updatedGroup.ownerUuid));
+            dispatch<any>(reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid]));
+        }
+    };
+
 export const loadCollection = (uuid: string) =>
     handleFirstTimeLoad(
         async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
index ad38cbeb89d7e8b8720d832bac3ea35e3d5d3238..874a601b17efb9c615f7561fc311c2c7af94f032 100644 (file)
@@ -3,11 +3,17 @@
 // 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 { RenameIcon, 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";
+import { openGroupAttributes, openRemoveGroupDialog, openGroupUpdateDialog } from "store/groups-panel/groups-panel-actions";
 
 export const groupActionSet: ContextMenuActionSet = [[{
+    name: "Rename",
+    icon: RenameIcon,
+    execute: (dispatch, resource) => {
+        dispatch<any>(openGroupUpdateDialog(resource));
+    }
+}, {
     name: "Attributes",
     icon: AttributesIcon,
     execute: (dispatch, { uuid }) => {
@@ -25,4 +31,4 @@ export const groupActionSet: ContextMenuActionSet = [[{
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openRemoveGroupDialog(uuid));
     }
-}]];
\ No newline at end of file
+}]];
diff --git a/src/views-components/context-menu/action-sets/permission-edit-action-set.ts b/src/views-components/context-menu/action-sets/permission-edit-action-set.ts
new file mode 100644 (file)
index 0000000..8663d3c
--- /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 { CanReadIcon, CanManageIcon, CanWriteIcon } from "components/icon/icon";
+import { editPermissionLevel } from 'store/group-details-panel/group-details-panel-actions';
+import { PermissionLevel } from "models/permission";
+
+export const permissionEditActionSet: ContextMenuActionSet = [[{
+    name: "Read",
+    icon: CanReadIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_READ));
+    }
+}, {
+    name: "Write",
+    icon: CanWriteIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_WRITE));
+    }
+}, {
+    name: "Manage",
+    icon: CanManageIcon,
+    execute: (dispatch, { uuid }) => {
+        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_MANAGE));
+    }
+}]];
index 603ee90b81eb244a09ce6678e9f4869d8c0a51bd..f2c43ced1f9e0ded2aa6277f72799e45d974863d 100644 (file)
@@ -98,5 +98,6 @@ export enum ContextMenuKind {
     USER = "User",
     GROUPS = "Group",
     GROUP_MEMBER = "GroupMember",
+    PERMISSION_EDIT = "PermissionEdit",
     LINK = "Link",
 }
index 3965e69d9da746d309963b01abede0a418e0b7d9..901704d9feafc906acd4535fa3c980a2ff88c280 100644 (file)
@@ -6,12 +6,12 @@ import React from 'react';
 import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core';
 import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
 import { Resource, ResourceKind, TrashableResource } from 'models/resource';
-import { ProjectIcon, FilterGroupIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon } from 'components/icon/icon';
+import { ProjectIcon, FilterGroupIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon, RemoveIcon, RenameIcon } from 'components/icon/icon';
 import { formatDate, formatFileSize, formatTime } from 'common/formatters';
 import { resourceLabel } from 'common/labels';
 import { connect, DispatchProp } from 'react-redux';
 import { RootState } from 'store/store';
-import { getResource } from 'store/resources/resources';
+import { getResource, filterResources } from 'store/resources/resources';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { getProcess, Process, getProcessStatus, getProcessStatusColor, getProcessRuntime } from 'store/processes/process';
 import { ArvadosTheme } from 'common/custom-theme';
@@ -20,23 +20,31 @@ import { WorkflowResource } from 'models/workflow';
 import { ResourceStatus as WorkflowStatus } from 'views/workflow-panel/workflow-panel-view';
 import { getUuidPrefix, openRunProcess } from 'store/workflow-panel/workflow-panel-actions';
 import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
-import { getUserFullname, User, UserResource } from 'models/user';
+import { getUserFullname, getUserDisplayName, User, UserResource } from 'models/user';
 import { toggleIsActive, toggleIsAdmin } from 'store/users/users-actions';
-import { LinkResource } from 'models/link';
-import { navigateTo } from 'store/navigation/navigation-action';
+import { LinkClass, LinkResource } from 'models/link';
+import { navigateTo, navigateToGroupDetails } from 'store/navigation/navigation-action';
 import { withResourceData } from 'views-components/data-explorer/with-resources';
 import { CollectionResource } from 'models/collection';
 import { IllegalNamingWarning } from 'components/warning/warning';
 import { loadResource } from 'store/resources/resources-actions';
-import { GroupClass } from 'models/group';
-
-const renderName = (dispatch: Dispatch, item: GroupContentsResource) =>
-    <Grid container alignItems="center" wrap="nowrap" spacing={16}>
+import { GroupClass, GroupResource, isBuiltinGroup } from 'models/group';
+import { openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions';
+import { setMemberIsHidden } from 'store/group-details-panel/group-details-panel-actions';
+import { formatPermissionLevel } from 'views-components/sharing-dialog/permission-select';
+import { PermissionLevel } from 'models/permission';
+import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-actions';
+import { getUserUuid } from 'common/getuser';
+
+const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
+
+    const navFunc = ("groupClass" in item && item.groupClass === GroupClass.ROLE ? navigateToGroupDetails : navigateTo);
+    return <Grid container alignItems="center" wrap="nowrap" spacing={16}>
         <Grid item>
             {renderIcon(item)}
         </Grid>
         <Grid item>
-            <Typography color="primary" style={{ width: 'auto', cursor: 'pointer' }} onClick={() => dispatch<any>(navigateTo(item.uuid))}>
+            <Typography color="primary" style={{ width: 'auto', cursor: 'pointer' }} onClick={() => dispatch<any>(navFunc(item.uuid))}>
                 {item.kind === ResourceKind.PROJECT || item.kind === ResourceKind.COLLECTION
                     ? <IllegalNamingWarning name={item.name} />
                     : null}
@@ -50,6 +58,7 @@ const renderName = (dispatch: Dispatch, item: GroupContentsResource) =>
             </Typography>
         </Grid>
     </Grid>;
+};
 
 export const ResourceName = connect(
     (state: RootState, props: { uuid: string }) => {
@@ -131,11 +140,11 @@ export const ResourceShare = connect(
     })((props: { ownerUuid?: string, uuidPrefix: string, uuid?: string } & DispatchProp<any>) =>
         resourceShare(props.dispatch, props.uuidPrefix, props.ownerUuid, props.uuid));
 
+// User Resources
 const renderFirstName = (item: { firstName: string }) => {
     return <Typography noWrap>{item.firstName}</Typography>;
 };
 
-// User Resources
 export const ResourceFirstName = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<UserResource>(props.uuid)(state.resources);
@@ -151,8 +160,18 @@ export const ResourceLastName = connect(
         return resource || { lastName: '' };
     })(renderLastName);
 
+const renderFullName = (item: { firstName: string, lastName: string }) =>
+    <Typography noWrap>{(item.firstName + " " + item.lastName).trim()}</Typography>;
+
+export const ResourceFullName = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const resource = getResource<UserResource>(props.uuid)(state.resources);
+        return resource || { firstName: '', lastName: '' };
+    })(renderFullName);
+
+
 const renderUuid = (item: { uuid: string }) =>
-    <Typography noWrap>{item.uuid}</Typography>;
+    <Typography data-cy="uuid" noWrap>{item.uuid}</Typography>;
 
 export const ResourceUuid = connect(
     (state: RootState, props: { uuid: string }) => {
@@ -169,19 +188,76 @@ export const ResourceEmail = connect(
         return resource || { email: '' };
     })(renderEmail);
 
-const renderIsActive = (props: { uuid: string, isActive: boolean, toggleIsActive: (uuid: string) => void }) =>
-    <Checkbox
-        color="primary"
-        checked={props.isActive}
-        onClick={() => props.toggleIsActive(props.uuid)} />;
+const renderIsActive = (props: { uuid: string, kind: ResourceKind, isActive: boolean, toggleIsActive: (uuid: string) => void, disabled?: boolean }) => {
+    if (props.kind === ResourceKind.USER) {
+        return <Checkbox
+            color="primary"
+            checked={props.isActive}
+            disabled={!!props.disabled}
+            onClick={() => props.toggleIsActive(props.uuid)} />;
+    } else {
+        return <Typography />;
+    }
+}
 
 export const ResourceIsActive = connect(
-    (state: RootState, props: { uuid: string }) => {
+    (state: RootState, props: { uuid: string, disabled?: boolean }) => {
         const resource = getResource<UserResource>(props.uuid)(state.resources);
-        return resource || { isActive: false };
+        return resource ? {...resource, disabled: !!props.disabled} : { isActive: false, kind: ResourceKind.NONE };
     }, { toggleIsActive }
 )(renderIsActive);
 
+export const ResourceLinkTailIsActive = connect(
+    (state: RootState, props: { uuid: string, disabled?: boolean }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const tailResource = getResource<UserResource>(link?.tailUuid || '')(state.resources);
+
+        return tailResource ? {...tailResource, disabled: !!props.disabled} : { isActive: false, kind: ResourceKind.NONE };
+    }, { toggleIsActive }
+)(renderIsActive);
+
+const renderIsHidden = (props: {
+                            memberLinkUuid: string,
+                            permissionLinkUuid: string,
+                            visible: boolean,
+                            canManage: boolean,
+                            setMemberIsHidden: (memberLinkUuid: string, permissionLinkUuid: string, hide: boolean) => void 
+                        }) => {
+    if (props.memberLinkUuid) {
+        return <Checkbox
+                data-cy="user-visible-checkbox"
+                color="primary"
+                checked={props.visible}
+                disabled={!props.canManage}
+                onClick={() => props.setMemberIsHidden(props.memberLinkUuid, props.permissionLinkUuid, !props.visible)} />;
+    } else {
+        return <Typography />;
+    }
+}
+
+export const ResourceLinkTailIsVisible = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const member = getResource<Resource>(link?.tailUuid || '')(state.resources);
+        const group = getResource<GroupResource>(link?.headUuid || '')(state.resources);
+        const permissions = filterResources((resource: LinkResource) => {
+            return resource.linkClass === LinkClass.PERMISSION
+                && resource.headUuid === link?.tailUuid
+                && resource.tailUuid === group?.uuid
+                && resource.name === PermissionLevel.CAN_READ;
+        })(state.resources);
+
+        const permissionLinkUuid = permissions.length > 0 ? permissions[0].uuid : '';
+        const isVisible = link && group && permissions.length > 0;
+        // Consider whether the current user canManage this resurce in addition when it's possible
+        const isBuiltin = isBuiltinGroup(link?.headUuid || '');
+
+        return member?.kind === ResourceKind.USER
+            ? { memberLinkUuid: link?.uuid, permissionLinkUuid, visible: isVisible, canManage: !isBuiltin }
+            : { memberLinkUuid: '', permissionLinkUuid: '', visible: false, canManage: false };
+    }, { setMemberIsHidden }
+)(renderIsHidden);
+
 const renderIsAdmin = (props: { uuid: string, isAdmin: boolean, toggleIsAdmin: (uuid: string) => void }) =>
     <Checkbox
         color="primary"
@@ -276,45 +352,45 @@ export const ResourceLinkClass = connect(
         return resource || { linkClass: '' };
     })(renderLinkClass);
 
-const renderLinkTail = (dispatch: Dispatch, item: { uuid: string, tailUuid: string, tailKind: string }) => {
-    const currentLabel = resourceLabel(item.tailKind);
-    const isUnknow = currentLabel === "Unknown";
-    return (<div>
-        {!isUnknow ? (
-            renderLink(dispatch, item.tailUuid, currentLabel)
-        ) : (
-                <Typography noWrap color="default">
-                    {item.tailUuid}
-                </Typography>
-            )}
-    </div>);
-};
+const getResourceDisplayName = (resource: Resource): string => {
+    if ((resource as UserResource).kind === ResourceKind.USER
+          && typeof (resource as UserResource).firstName !== 'undefined') {
+        // We can be sure the resource is UserResource
+        return getUserDisplayName(resource as UserResource);
+    } else {
+        return (resource as GroupContentsResource).name;
+    }
+}
+
+const renderResourceLink = (dispatch: Dispatch, item: Resource) => {
+    var displayName = getResourceDisplayName(item);
 
-const renderLink = (dispatch: Dispatch, uuid: string, label: string) =>
-    <Typography noWrap color="primary" style={{ 'cursor': 'pointer' }} onClick={() => dispatch<any>(navigateTo(uuid))}>
-        {label}: {uuid}
+    return <Typography noWrap color="primary" style={{ 'cursor': 'pointer' }} onClick={() => dispatch<any>(navigateTo(item.uuid))}>
+        {resourceLabel(item.kind, item && item.kind === ResourceKind.GROUP ? (item as GroupResource).groupClass || '' : '')}: {displayName || item.uuid}
     </Typography>;
+};
 
 export const ResourceLinkTail = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<LinkResource>(props.uuid)(state.resources);
+        const tailResource = getResource<Resource>(resource?.tailUuid || '')(state.resources);
+
         return {
-            item: resource || { uuid: '', tailUuid: '', tailKind: ResourceKind.NONE }
+            item: tailResource || { uuid: resource?.tailUuid || '', kind: resource?.tailKind || ResourceKind.NONE }
         };
-    })((props: { item: any } & DispatchProp<any>) =>
-        renderLinkTail(props.dispatch, props.item));
-
-const renderLinkHead = (dispatch: Dispatch, item: { uuid: string, headUuid: string, headKind: ResourceKind }) =>
-    renderLink(dispatch, item.headUuid, resourceLabel(item.headKind));
+    })((props: { item: Resource } & DispatchProp<any>) =>
+        renderResourceLink(props.dispatch, props.item));
 
 export const ResourceLinkHead = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<LinkResource>(props.uuid)(state.resources);
+        const headResource = getResource<Resource>(resource?.headUuid || '')(state.resources);
+
         return {
-            item: resource || { uuid: '', headUuid: '', headKind: ResourceKind.NONE }
+            item: headResource || { uuid: resource?.headUuid || '', kind: resource?.headKind || ResourceKind.NONE }
         };
-    })((props: { item: any } & DispatchProp<any>) =>
-        renderLinkHead(props.dispatch, props.item));
+    })((props: { item: Resource } & DispatchProp<any>) =>
+        renderResourceLink(props.dispatch, props.item));
 
 export const ResourceLinkUuid = connect(
     (state: RootState, props: { uuid: string }) => {
@@ -322,6 +398,117 @@ export const ResourceLinkUuid = connect(
         return resource || { uuid: '' };
     })(renderUuid);
 
+export const ResourceLinkHeadUuid = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const headResource = getResource<Resource>(link?.headUuid || '')(state.resources);
+
+        return headResource || { uuid: '' };
+    })(renderUuid);
+
+export const ResourceLinkTailUuid = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const tailResource = getResource<Resource>(link?.tailUuid || '')(state.resources);
+
+        return tailResource || { uuid: '' };
+    })(renderUuid);
+
+const renderLinkDelete = (dispatch: Dispatch, item: LinkResource, canManage: boolean) => {
+    if (item.uuid) {
+        return canManage ?
+            <Typography noWrap>
+                <IconButton data-cy="resource-delete-button" onClick={() => dispatch<any>(openRemoveGroupMemberDialog(item.uuid))}>
+                    <RemoveIcon />
+                </IconButton>
+            </Typography> :
+            <Typography noWrap>
+                <IconButton disabled data-cy="resource-delete-button">
+                    <RemoveIcon />
+                </IconButton>
+            </Typography>;
+    } else {
+      return <Typography noWrap></Typography>;
+    }
+}
+
+export const ResourceLinkDelete = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || '');
+
+        return {
+            item: link || { uuid: '', kind: ResourceKind.NONE },
+            canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+        };
+    })((props: { item: LinkResource, canManage: boolean } & DispatchProp<any>) =>
+      renderLinkDelete(props.dispatch, props.item, props.canManage));
+
+export const ResourceLinkTailEmail = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const resource = getResource<UserResource>(link?.tailUuid || '')(state.resources);
+
+        return resource || { email: '' };
+    })(renderEmail);
+
+export const ResourceLinkTailUsername = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const resource = getResource<UserResource>(link?.tailUuid || '')(state.resources);
+
+        return resource || { username: '' };
+    })(renderUsername);
+
+const renderPermissionLevel = (dispatch: Dispatch, link: LinkResource, canManage: boolean) => {
+    return <Typography noWrap>
+        {formatPermissionLevel(link.name as PermissionLevel)}
+        {canManage ?
+            <IconButton data-cy="edit-permission-button" onClick={(event) => dispatch<any>(openPermissionEditContextMenu(event, link))}>
+                <RenameIcon />
+            </IconButton> :
+            ''
+        }
+    </Typography>;
+}
+
+export const ResourceLinkHeadPermissionLevel = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || '');
+
+        return {
+            link: link || { uuid: '', name: '', kind: ResourceKind.NONE },
+            canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+        };
+    })((props: { link: LinkResource, canManage: boolean } & DispatchProp<any>) =>
+        renderPermissionLevel(props.dispatch, props.link, props.canManage));
+
+export const ResourceLinkTailPermissionLevel = connect(
+    (state: RootState, props: { uuid: string }) => {
+        const link = getResource<LinkResource>(props.uuid)(state.resources);
+        const isBuiltin = isBuiltinGroup(link?.headUuid || '') || isBuiltinGroup(link?.tailUuid || '');
+
+        return {
+            link: link || { uuid: '', name: '', kind: ResourceKind.NONE },
+            canManage: link && getResourceLinkCanManage(state, link) && !isBuiltin,
+        };
+    })((props: { link: LinkResource, canManage: boolean } & DispatchProp<any>) =>
+        renderPermissionLevel(props.dispatch, props.link, props.canManage));
+
+const getResourceLinkCanManage = (state: RootState, link: LinkResource) => {
+    const headResource = getResource<Resource>(link.headUuid)(state.resources);
+    // const tailResource = getResource<Resource>(link.tailUuid)(state.resources);
+    const userUuid = getUserUuid(state);
+
+    if (headResource && headResource.kind === ResourceKind.GROUP) {
+        return userUuid ? (headResource as GroupResource).writableBy?.includes(userUuid) : false;
+    } else {
+        // true for now
+        return true;
+    }
+}
+
 // Process Resources
 const resourceRunProcess = (dispatch: Dispatch, uuid: string) => {
     return (
diff --git a/src/views-components/dialog-forms/add-group-member-dialog.tsx b/src/views-components/dialog-forms/add-group-member-dialog.tsx
deleted file mode 100644 (file)
index 443191f..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import 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 { ParticipantSelect, Participant } from 'views-components/sharing-dialog/participant-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 as any}
-        validate={UsersFieldValidation} />;
-
-const UsersFieldValidation = [minLength(1, () => 'Select at least one user')];
-
-const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
-    <ParticipantSelect
-        onlyPeople
-        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
deleted file mode 100644 (file)
index fceea26..0000000
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) The Arvados Authors. All rights reserved.
-//
-// SPDX-License-Identifier: AGPL-3.0
-
-import 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 { ParticipantSelect, Participant } from 'views-components/sharing-dialog/participant-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 as any}
-        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 as any} />;
-
-const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
-    <ParticipantSelect
-        onlyPeople
-        label='Enter email adresses '
-        items={fields.getAll() || []}
-        onSelect={fields.push}
-        onDelete={fields.remove} />;
index dca51b965676f8ef88971600fe10de2d7838ce21..4ba03f2ffa927ea681f4cdcd97726c572149a3c7 100644 (file)
@@ -7,14 +7,29 @@ import { reduxForm } from 'redux-form';
 import { withDialog } from "store/dialog/with-dialog";
 import { DialogProjectUpdate } from 'views-components/dialog-update/dialog-project-update';
 import { PROJECT_UPDATE_FORM_NAME, ProjectUpdateFormDialogData } from 'store/projects/project-update-actions';
-import { updateProject } from 'store/workbench/workbench-actions';
+import { updateProject, updateGroup } from 'store/workbench/workbench-actions';
+import { GroupClass } from "models/group";
+import { createGroup } from "store/groups-panel/groups-panel-actions";
 
 export const UpdateProjectDialog = compose(
     withDialog(PROJECT_UPDATE_FORM_NAME),
     reduxForm<ProjectUpdateFormDialogData>({
         form: PROJECT_UPDATE_FORM_NAME,
-        onSubmit: (data, dispatch) => {
-            dispatch(updateProject(data));
+        onSubmit: (data, dispatch, props) => {
+            switch (props.data.sourcePanel) {
+                case GroupClass.PROJECT:
+                    dispatch(updateProject(data));
+                    break;
+                case GroupClass.ROLE:
+                    if (data.uuid) {
+                        dispatch(updateGroup(data));
+                    } else {
+                        dispatch(createGroup(data));
+                    }
+                    break;
+                default:
+                    break;
+            }
         }
     })
-)(DialogProjectUpdate);
\ No newline at end of file
+)(DialogProjectUpdate);
index ac14e5dcbde66e618390ae2ced543aec57352c4e..fda7c47d7d33c72dd1766d1d5bc5aa2a050c167a 100644 (file)
@@ -7,19 +7,38 @@ import { InjectedFormProps } from 'redux-form';
 import { WithDialogProps } from 'store/dialog/with-dialog';
 import { ProjectUpdateFormDialogData } from 'store/projects/project-update-actions';
 import { FormDialog } from 'components/form-dialog/form-dialog';
-import { ProjectNameField, ProjectDescriptionField } from 'views-components/form-fields/project-form-fields';
+import { ProjectNameField, ProjectDescriptionField, UsersField } from 'views-components/form-fields/project-form-fields';
+import { GroupClass } from 'models/group';
 
-type DialogProjectProps = WithDialogProps<{}> & InjectedFormProps<ProjectUpdateFormDialogData>;
+type DialogProjectProps = WithDialogProps<{sourcePanel: GroupClass, create?: boolean}> & InjectedFormProps<ProjectUpdateFormDialogData>;
 
-export const DialogProjectUpdate = (props: DialogProjectProps) =>
-    <FormDialog
-        dialogTitle='Edit Project'
-        formFields={ProjectEditFields}
+export const DialogProjectUpdate = (props: DialogProjectProps) => {
+    let title = 'Edit Project';
+    let fields = ProjectEditFields;
+    const sourcePanel = props.data.sourcePanel || '';
+    const create = !!props.data.create;
+
+    if (sourcePanel === GroupClass.ROLE) {
+        title = create ? 'Create Group' : 'Edit Group';
+        fields = create ? GroupAddFields : ProjectEditFields;
+    }
+
+    return <FormDialog
+        dialogTitle={title}
+        formFields={fields}
         submitLabel='Save'
         {...props}
     />;
+};
 
+// Also used as "Group Edit Fields"
 const ProjectEditFields = () => <span>
     <ProjectNameField />
     <ProjectDescriptionField />
 </span>;
+
+const GroupAddFields = () => <span>
+    <ProjectNameField />
+    <UsersField />
+    <ProjectDescriptionField />
+</span>;
index 34d7cef711bf94555e3e74689dc1cc86c0849f3b..6ef723d3cda911869e7d4647e96cbb06196a31fc 100644 (file)
@@ -3,11 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from "react";
-import { Field, Validator } from "redux-form";
+import { Field, FieldArray, Validator, WrappedFieldArrayProps } from "redux-form";
 import { TextField, RichEditorTextField } from "components/text-field/text-field";
 import { PROJECT_NAME_VALIDATION, PROJECT_NAME_VALIDATION_ALLOW_SLASH } from "validators/validators";
 import { connect } from "react-redux";
 import { RootState } from "store/store";
+import { Participant, ParticipantSelect } from "views-components/sharing-dialog/participant-select";
 
 interface ProjectNameFieldProps {
     validate: Validator[];
@@ -42,3 +43,16 @@ export const ProjectDescriptionField = () =>
         name='description'
         component={RichEditorTextField as any}
         label="Description - optional" />;
+
+export const UsersField = () =>
+        <span data-cy='users-field'><FieldArray
+            name="users"
+            component={UsersSelect as any} /></span>;
+
+export const UsersSelect = ({ fields }: WrappedFieldArrayProps<Participant>) =>
+        <ParticipantSelect
+            onlyPeople
+            label='Search for users to add to the group'
+            items={fields.getAll() || []}
+            onSelect={fields.push}
+            onDelete={fields.remove} />;
index 95efee8cfb77ef72cd7adb2d54b4baeaedb54eca..e829483473636a08e79179807d131b1275181263 100644 (file)
@@ -9,7 +9,7 @@ import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
 import { TreeItem } from "components/tree/tree";
 import { ProjectResource } from "models/project";
 import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon";
-import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon } from 'components/icon/icon';
+import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon, GroupsIcon } from 'components/icon/icon';
 import { WorkflowIcon } from 'components/icon/icon';
 import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
 import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions';
@@ -82,6 +82,8 @@ const getSidePanelIcon = (category: string) => {
             return PublicFavoriteIcon;
         case SidePanelTreeCategory.ALL_PROCESSES:
             return ProcessIcon;
+        case SidePanelTreeCategory.GROUPS:
+            return GroupsIcon;
         default:
             return ProjectIcon;
     }
index d0f7973675acbd7e92d852215db35efe1b81d8a7..ce3f34c75348d39c4dbd1ee63df9b2e06a70d73c 100644 (file)
@@ -7,67 +7,129 @@ 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 { ResourceLinkHeadUuid, ResourceLinkTailUsername, ResourceLinkHeadPermissionLevel, ResourceLinkTailPermissionLevel, ResourceLinkHead, ResourceLinkTail, ResourceLinkDelete, ResourceLinkTailIsActive, ResourceLinkTailIsVisible } 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 { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID, openAddGroupMembersDialog, getCurrentGroupDetailsPanelUuid } 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 { Grid, Button, Tabs, Tab, Paper, WithStyles, withStyles, StyleRulesCallback } from '@material-ui/core';
 import { AddIcon } from 'components/icon/icon';
+import { getUserUuid } from 'common/getuser';
+import { GroupResource, isBuiltinGroup } from 'models/group';
+import { ArvadosTheme } from 'common/custom-theme';
 
-export enum GroupDetailsPanelColumnNames {
-    FIRST_NAME = "First name",
-    LAST_NAME = "Last name",
-    UUID = "UUID",
-    EMAIL = "Email",
+type CssRules = "root";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        width: '100%',
+    }
+});
+
+export enum GroupDetailsPanelMembersColumnNames {
+    FULL_NAME = "Name",
     USERNAME = "Username",
+    ACTIVE = "User Active",
+    VISIBLE = "Visible to other members",
+    PERMISSION = "Permission",
+    REMOVE = "Remove",
+}
+
+export enum GroupDetailsPanelPermissionsColumnNames {
+    NAME = "Name",
+    PERMISSION = "Permission",
+    UUID = "UUID",
+    REMOVE = "Remove",
 }
 
-export const groupDetailsPanelColumns: DataColumns<string> = [
+export const groupDetailsMembersPanelColumns: DataColumns<string> = [
     {
-        name: GroupDetailsPanelColumnNames.FIRST_NAME,
+        name: GroupDetailsPanelMembersColumnNames.FULL_NAME,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceFirstName uuid={uuid} />
+        render: uuid => <ResourceLinkTail uuid={uuid} />
     },
     {
-        name: GroupDetailsPanelColumnNames.LAST_NAME,
+        name: GroupDetailsPanelMembersColumnNames.USERNAME,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceLastName uuid={uuid} />
+        render: uuid => <ResourceLinkTailUsername uuid={uuid} />
     },
     {
-        name: GroupDetailsPanelColumnNames.UUID,
+        name: GroupDetailsPanelMembersColumnNames.ACTIVE,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceUuid uuid={uuid} />
+        render: uuid => <ResourceLinkTailIsActive uuid={uuid} disabled={true} />
     },
     {
-        name: GroupDetailsPanelColumnNames.EMAIL,
+        name: GroupDetailsPanelMembersColumnNames.VISIBLE,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceEmail uuid={uuid} />
+        render: uuid => <ResourceLinkTailIsVisible uuid={uuid} />
     },
     {
-        name: GroupDetailsPanelColumnNames.USERNAME,
+        name: GroupDetailsPanelMembersColumnNames.PERMISSION,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceUsername uuid={uuid} />
+        render: uuid => <ResourceLinkTailPermissionLevel uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelMembersColumnNames.REMOVE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkDelete uuid={uuid} />
+    },
+];
+
+export const groupDetailsPermissionsPanelColumns: DataColumns<string> = [
+    {
+        name: GroupDetailsPanelPermissionsColumnNames.NAME,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHead uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelPermissionsColumnNames.PERMISSION,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHeadPermissionLevel uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelPermissionsColumnNames.UUID,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkHeadUuid uuid={uuid} />
+    },
+    {
+        name: GroupDetailsPanelPermissionsColumnNames.REMOVE,
+        selected: true,
+        configurable: true,
+        filters: createTree(),
+        render: uuid => <ResourceLinkDelete uuid={uuid} />
     },
 ];
 
 const mapStateToProps = (state: RootState) => {
+    const groupUuid = getCurrentGroupDetailsPanelUuid(state.properties);
+    const group = getResource<GroupResource>(groupUuid || '')(state.resources);
+    const userUuid = getUserUuid(state);
+
     return {
-        resources: state.resources
+        resources: state.resources,
+        groupCanManage: userUuid && !isBuiltinGroup(group?.uuid || '')
+                            ? group?.writableBy?.includes(userUuid)
+                            : false,
     };
 };
 
@@ -80,47 +142,74 @@ export interface GroupDetailsPanelProps {
     onContextMenu: (event: React.MouseEvent<HTMLElement>, item: any) => void;
     onAddUser: () => void;
     resources: ResourcesState;
+    groupCanManage: boolean;
 }
 
-export const GroupDetailsPanel = connect(
+export const GroupDetailsPanel = withStyles(styles)(connect(
     mapStateToProps, mapDispatchToProps
 )(
-    class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps> {
+    class GroupDetailsPanel extends React.Component<GroupDetailsPanelProps & WithStyles<CssRules>> {
+        state = {
+          value: 0,
+        };
+
+        componentDidMount() {
+            this.setState({ value: 0 });
+        }
 
         render() {
+            const { value } = this.state;
             return (
-                <DataExplorer
-                    id={GROUP_DETAILS_PANEL_ID}
-                    onRowClick={noop}
-                    onRowDoubleClick={noop}
-                    onContextMenu={this.handleContextMenu}
-                    contextMenuColumn={true}
-                    hideColumnSelector
-                    hideSearchInput
-                    actions={
-                        <Grid container justify='flex-end'>
-                            <Button
-                                variant="contained"
-                                color="primary"
-                                onClick={this.props.onAddUser}>
-                                <AddIcon /> Add user
-                        </Button>
-                        </Grid>
-                    } />
+                <Paper className={this.props.classes.root}>
+                  <Tabs value={value} onChange={this.handleChange} variant="fullWidth">
+                      <Tab data-cy="group-details-members-tab" label="MEMBERS" />
+                      <Tab data-cy="group-details-permissions-tab" label="PERMISSIONS" />
+                  </Tabs>
+                  {value === 0 &&
+                      <DataExplorer
+                          id={GROUP_DETAILS_MEMBERS_PANEL_ID}
+                          data-cy="group-members-data-explorer"
+                          onRowClick={noop}
+                          onRowDoubleClick={noop}
+                          onContextMenu={noop}
+                          contextMenuColumn={false}
+                          hideColumnSelector
+                          hideSearchInput
+                          actions={
+                                this.props.groupCanManage &&
+                                <Grid container justify='flex-end'>
+                                    <Button
+                                      data-cy="group-member-add"
+                                      variant="contained"
+                                      color="primary"
+                                      onClick={this.props.onAddUser}>
+                                      <AddIcon /> Add user
+                                    </Button>
+                                </Grid>
+                          }
+                          paperProps={{
+                              elevation: 0,
+                          }} />
+                  }
+                  {value === 1 &&
+                      <DataExplorer
+                          id={GROUP_DETAILS_PERMISSIONS_PANEL_ID}
+                          data-cy="group-permissions-data-explorer"
+                          onRowClick={noop}
+                          onRowDoubleClick={noop}
+                          onContextMenu={noop}
+                          contextMenuColumn={false}
+                          hideColumnSelector
+                          hideSearchInput
+                          paperProps={{
+                              elevation: 0,
+                          }} />
+                  }
+                </Paper>
             );
         }
 
-        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
-                });
-            }
+        handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            this.setState({ value });
         }
-    });
-
+    }));
index faefab107de3b0365f37a917eaf1f4a3c41cb673..3251c729eee32d6df8d75a4c298d38d9bb0e8c4b 100644 (file)
@@ -8,7 +8,7 @@ import { Grid, Button, Typography, StyleRulesCallback, WithStyles, withStyles }
 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 { ResourceUuid } 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';
@@ -21,7 +21,6 @@ import { RootState } from 'store/store';
 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';
 import { ArvadosTheme } from 'common/custom-theme';
 
 type CssRules = "root";
@@ -34,7 +33,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 
 export enum GroupsPanelColumnNames {
     GROUP = "Name",
-    OWNER = "Owner",
+    UUID = "UUID",
     MEMBERS = "Members",
 }
 
@@ -48,11 +47,11 @@ export const groupsPanelColumns: DataColumns<string> = [
         render: uuid => <ResourceName uuid={uuid} />
     },
     {
-        name: GroupsPanelColumnNames.OWNER,
+        name: GroupsPanelColumnNames.UUID,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceOwner uuid={uuid} />,
+        render: uuid => <ResourceUuid uuid={uuid} />,
     },
     {
         name: GroupsPanelColumnNames.MEMBERS,
@@ -71,15 +70,12 @@ const mapStateToProps = (state: RootState) => {
 
 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;
 }
 
@@ -92,14 +88,16 @@ export const GroupsPanel = withStyles(styles)(connect(
             return (
                 <div className={this.props.classes.root}><DataExplorer
                     id={GROUPS_PANEL_ID}
+                    data-cy="groups-panel-data-explorer"
                     onRowClick={noop}
-                    onRowDoubleClick={this.props.onRowDoubleClick}
+                    onRowDoubleClick={noop}
                     onContextMenu={this.handleContextMenu}
                     contextMenuColumn={true}
                     hideColumnSelector
                     actions={
                         <Grid container justify='flex-end'>
                             <Button
+                                data-cy="groups-panel-new-group"
                                 variant="contained"
                                 color="primary"
                                 onClick={this.props.onNewGroup}>
@@ -114,8 +112,9 @@ export const GroupsPanel = withStyles(styles)(connect(
             const resource = getResource<GroupResource>(resourceUuid)(this.props.resources);
             if (resource) {
                 this.props.onContextMenu(event, {
-                    name: '',
+                    name: resource.name,
                     uuid: resource.uuid,
+                    description: resource.description,
                     ownerUuid: resource.ownerUuid,
                     kind: resource.kind,
                     menuKind: ContextMenuKind.GROUPS
@@ -131,7 +130,7 @@ const GroupMembersCount = connect(
         const permissions = filterResources((resource: LinkResource) =>
             resource.kind === ResourceKind.LINK &&
             resource.linkClass === LinkClass.PERMISSION &&
-            resource.tailUuid === props.uuid
+            resource.headUuid === props.uuid
         )(state.resources);
 
         return {
@@ -139,4 +138,4 @@ const GroupMembersCount = connect(
         };
 
     }
-)(Typography);
+)((props: {children: number}) => (<Typography children={props.children} />));
index 1c6bf03fd9baf73dc2dd1b2675d219c70ea1379b..25d70776e2f9c97ee779cf7594d577bf23f3844f 100644 (file)
@@ -82,13 +82,11 @@ import { HelpApiClientAuthorizationDialog } from 'views-components/api-client-au
 import { UserManageDialog } from 'views-components/user-dialog/manage-dialog';
 import { SetupShellAccountDialog } from 'views-components/dialog-forms/setup-shell-account-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';
 import { PartialCopyToCollectionDialog } from 'views-components/dialog-forms/partial-copy-to-collection-dialog';
 import { PublicFavoritePanel } from 'views/public-favorites-panel/public-favorites-panel';
 import { LinkAccountPanel } from 'views/link-account-panel/link-account-panel';
@@ -215,7 +213,6 @@ export const WorkbenchPanel =
             <Grid item>
                 <DetailsPanel />
             </Grid>
-            <AddGroupMembersDialog />
             <AdvancedTabDialog />
             <AttributesApiClientAuthorizationDialog />
             <AttributesKeepServiceDialog />
@@ -226,7 +223,6 @@ export const WorkbenchPanel =
             <CopyCollectionDialog />
             <CopyProcessDialog />
             <CreateCollectionDialog />
-            <CreateGroupDialog />
             <CreateProjectDialog />
             <CreateRepositoryDialog />
             <CreateSshKeyDialog />