18692: Initial frozen project implementation with tests
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Fri, 13 May 2022 13:26:42 +0000 (15:26 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Fri, 13 May 2022 13:26:42 +0000 (15:26 +0200)
Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>

13 files changed:
cypress/integration/project.spec.js
src/common/frozen-resources.ts [new file with mode: 0644]
src/components/icon/icon.tsx
src/index.tsx
src/models/project.ts
src/store/context-menu/context-menu-actions.ts
src/store/projects/project-lock-actions.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/actions/lock-action.tsx [new file with mode: 0644]
src/views-components/context-menu/context-menu.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/side-panel-button/side-panel-button.tsx
src/views/project-panel/project-panel.tsx

index 0017e416c65e57627f4e0dc8c6a7d5b778428227..5929fb90c05e38351adfe9f14666172e26394419 100644 (file)
@@ -260,4 +260,100 @@ describe('Project tests', function() {
                 });
         });
     });
+
+    describe.only('Frozen projects', () => {
+        beforeEach(() => {  
+            cy.createGroup(activeUser.token, {
+                name: `Main project ${Math.floor(Math.random() * 999999)}`,
+                group_class: 'project',
+            }).as('mainProject');
+    
+            cy.createGroup(adminUser.token, {
+                name: `Admin project ${Math.floor(Math.random() * 999999)}`,
+                group_class: 'project',
+            }).as('adminProject').then((mainProject) => {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, 'can_write');
+            });
+
+            cy.get('@mainProject').then((mainProject) => {
+                cy.createGroup(adminUser.token, {
+                    name : `Sub project ${Math.floor(Math.random() * 999999)}`,
+                    group_class: 'project',
+                    owner_uuid: mainProject.uuid,
+                }).as('subProject');
+
+                cy.createCollection(adminUser.token, {
+                    name: `Main collection ${Math.floor(Math.random() * 999999)}`,
+                    owner_uuid: mainProject.uuid,
+                    manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+                }).as('mainCollection');        
+            });
+        });
+
+        it('should be able to froze own project', () => {
+            cy.getAll('@mainProject').then(([mainProject]) => {
+                cy.loginAs(activeUser);
+
+                cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick();
+
+                cy.get('[data-cy=context-menu]').contains('Lock').click();
+
+                cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick();
+
+                cy.get('[data-cy=context-menu]').contains('Lock').should('not.exist');
+            });
+        });
+
+        it('should not be able to modify items within the frozen project', () => {
+            cy.getAll('@mainProject', '@mainCollection').then(([mainProject, mainCollection]) => {
+                cy.loginAs(activeUser);
+
+                cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick();
+
+                cy.get('[data-cy=context-menu]').contains('Lock').click();
+
+                cy.get('[data-cy=project-panel]').contains(mainProject.name).click();
+
+                cy.get('[data-cy=project-panel]').contains(mainCollection.name).rightclick();
+
+                cy.get('[data-cy=context-menu]').contains('Move to trash').should('not.exist');
+            });
+        });
+
+        it('should not be able to froze not owned project', () => {
+            cy.getAll('@adminProject').then(([adminProject]) => {
+                cy.loginAs(activeUser);
+
+                cy.get('[data-cy=side-panel-tree]').contains('Shared with me').click();
+
+                cy.get('main').contains(adminProject.name).rightclick();
+
+                cy.get('[data-cy=context-menu]').contains('Lock').click();
+
+                cy.get('main').contains(adminProject.name).rightclick();
+
+                cy.get('[data-cy=context-menu]').contains('Lock').should('exist');
+            });
+        });
+
+        it('should be able to unfroze project if user is an admin', () => {
+            cy.getAll('@adminProject').then(([adminProject]) => {
+                cy.loginAs(adminUser);
+
+                cy.get('main').contains(adminProject.name).rightclick();
+
+                cy.get('[data-cy=context-menu]').contains('Lock').click();
+
+                cy.wait(1000);
+
+                cy.get('main').contains(adminProject.name).rightclick();
+
+                cy.get('[data-cy=context-menu]').contains('Unlock').click();
+
+                cy.get('main').contains(adminProject.name).rightclick();
+                
+                cy.get('[data-cy=context-menu]').contains('Lock').should('exist');
+            });
+        });
+    });
 });
\ No newline at end of file
diff --git a/src/common/frozen-resources.ts b/src/common/frozen-resources.ts
new file mode 100644 (file)
index 0000000..10e1815
--- /dev/null
@@ -0,0 +1,19 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ProjectResource } from "models/project";
+import { getResource } from "store/resources/resources";
+
+export const resourceIsFrozen = (resource: any, resources): boolean => {
+    let isFrozen: boolean = !!resource.frozenByUuid;
+    let ownerUuid: string | undefined = resource?.ownerUuid;
+
+    while(!isFrozen && !!ownerUuid) {
+        const parentResource: ProjectResource | undefined = getResource<ProjectResource>(ownerUuid)(resources);
+        isFrozen = !!parentResource?.frozenByUuid;
+        ownerUuid = parentResource?.ownerUuid;
+    }
+
+    return isFrozen;
+}
\ No newline at end of file
index 19b4beea1eb66274f45c2fc8ba1f1879a3194333..178c4cbe29676e65b01cf53c280b276908a81e5b 100644 (file)
@@ -73,6 +73,8 @@ import ExitToApp from '@material-ui/icons/ExitToApp';
 import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline';
 import NotInterested from '@material-ui/icons/NotInterested';
+import Lock from '@material-ui/icons/Lock'
+import LockOpen from '@material-ui/icons/LockOpen'
 
 // Import FontAwesome icons
 import { library } from '@fortawesome/fontawesome-svg-core';
@@ -153,6 +155,8 @@ export const PaginationLeftArrowIcon: IconType = (props) => <ChevronLeft {...pro
 export const PaginationRightArrowIcon: IconType = (props) => <ChevronRight {...props} />;
 export const ProcessIcon: IconType = (props) => <BubbleChart {...props} />;
 export const ProjectIcon: IconType = (props) => <Folder {...props} />;
+export const LockIcon: IconType = (props) => <Lock {...props} />;
+export const UnlockIcon: IconType = (props) => <LockOpen {...props} />;
 export const FilterGroupIcon: IconType = (props) => <Pageview {...props} />;
 export const ProjectsIcon: IconType = (props) => <Inbox {...props} />;
 export const ProvenanceGraphIcon: IconType = (props) => <DeviceHub {...props} />;
index f928ea8ae33447c8b166aebdcbe1c8827b30fb63..4dde654cbfe1c05f0579904414bce1a363810a80 100644 (file)
@@ -22,7 +22,7 @@ import { fetchConfig } from 'common/config';
 import servicesProvider from 'common/service-provider';
 import { addMenuActionSet, ContextMenuKind } from 'views-components/context-menu/context-menu';
 import { rootProjectActionSet } from "views-components/context-menu/action-sets/root-project-action-set";
-import { filterGroupActionSet, projectActionSet, readOnlyProjectActionSet } from "views-components/context-menu/action-sets/project-action-set";
+import { filterGroupActionSet, frozenActionSet, projectActionSet, readOnlyProjectActionSet } from "views-components/context-menu/action-sets/project-action-set";
 import { resourceActionSet } from 'views-components/context-menu/action-sets/resource-action-set';
 import { favoriteActionSet } from "views-components/context-menu/action-sets/favorite-action-set";
 import { collectionFilesActionSet, readOnlyCollectionFilesActionSet } from 'views-components/context-menu/action-sets/collection-files-action-set';
@@ -100,6 +100,12 @@ addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
+addMenuActionSet(ContextMenuKind.FROZEN_PROJECT, [
+    [
+        ...frozenActionSet.reduce((prev, next) => prev.concat(next), []),
+        ...readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []),
+    ]
+]);
 addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
 addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet);
 
index b47b426f274989db009552aab3ecbd9e8c1bfa1e..b490864d8ff539b8e6be82e3c2634a2ee2506a80 100644 (file)
@@ -5,6 +5,7 @@
 import { GroupClass, GroupResource } from "./group";
 
 export interface ProjectResource extends GroupResource {
+    frozenByUuid: null|string;
     groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE;
 }
 
index 1116949a6f31f769dfcf0fbb3ed56e147e7166c2..7b1b0257c8cc243e6b51284fa2324d3a56c9ae89 100644 (file)
@@ -21,6 +21,7 @@ import { CollectionResource } from 'models/collection';
 import { GroupClass, GroupResource } from 'models/group';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { LinkResource } from 'models/link';
+import { resourceIsFrozen } from 'common/frozen-resources';
 
 export const contextMenuActions = unionize({
     OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
@@ -40,6 +41,8 @@ export type ContextMenuResource = {
     isEditable?: boolean;
     outputUuid?: string;
     workflowUuid?: string;
+    isAdmin?: boolean;
+    isFrozen?: boolean;
     storageClassesDesired?: string[];
     properties?: { [key: string]: string | string[] };
 };
@@ -224,10 +227,15 @@ export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
         const { isAdmin: isAdminUser, uuid: userUuid } = getState().auth.user!;
         const kind = extractUuidKind(uuid);
         const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(uuid, userUuid)(getState().resources);
+        const isFrozen = resourceIsFrozen(resource, getState().resources);
+        const isEditable = (isAdminUser || (resource || {} as EditableResource).isEditable) && !readonly && !isFrozen;
 
-        const isEditable = (isAdminUser || (resource || {} as EditableResource).isEditable) && !readonly;
         switch (kind) {
             case ResourceKind.PROJECT:
+                if (resource && !!(resource as any).frozenByUuid) {
+                    return ContextMenuKind.FROZEN_PROJECT;
+                }
+
                 return (isAdminUser && !readonly)
                     ? (resource && resource.groupClass !== GroupClass.FILTER)
                         ? ContextMenuKind.PROJECT_ADMIN
diff --git a/src/store/projects/project-lock-actions.ts b/src/store/projects/project-lock-actions.ts
new file mode 100644 (file)
index 0000000..f135522
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { Dispatch } from "redux";
+import { ServiceRepository } from "services/services";
+import { projectPanelActions } from "store/project-panel/project-panel-action";
+import { RootState } from "store/store";
+
+export const lockProject = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const userUUID = getState().auth.user!.uuid;
+
+        const updatedProject = await services.projectService.update(uuid, {
+            frozenByUuid: userUUID
+        });
+
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+        return updatedProject;
+    };
+
+export const unlockProject = (uuid: string) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+
+        const updatedProject = await services.projectService.update(uuid, {
+            frozenByUuid: null
+        });
+
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+        return updatedProject;
+    };
\ No newline at end of file
index a079bf4ff3c314b157d9f9bb8977a07a9c8300ee..77abb128a0f22e547680defd1cb4c9a21e64c599 100644 (file)
@@ -18,6 +18,8 @@ import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
 import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
 import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions";
+import { ToggleLockAction } from "../actions/lock-action";
+import { lockProject, unlockProject } from "store/projects/project-lock-actions";
 
 export const readOnlyProjectActionSet: ContextMenuActionSet = [[
     {
@@ -100,6 +102,23 @@ export const filterGroupActionSet: ContextMenuActionSet = [
     ]
 ];
 
+export const frozenActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleLockAction,
+            name: 'ToggleLockAction',
+            execute: (dispatch, resource) => {
+                if (resource.isFrozen) {
+                    dispatch<any>(unlockProject(resource.uuid));
+                } else {
+                    dispatch<any>(lockProject(resource.uuid));
+                }
+
+            }
+        }
+    ]
+];
+
 export const projectActionSet: ContextMenuActionSet = [
     [
         ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
@@ -110,5 +129,6 @@ export const projectActionSet: ContextMenuActionSet = [
                 dispatch<any>(openProjectCreateDialog(resource.uuid));
             }
         },
+        ...frozenActionSet.reduce((prev, next) => prev.concat(next), []),
     ]
 ];
diff --git a/src/views-components/context-menu/actions/lock-action.tsx b/src/views-components/context-menu/actions/lock-action.tsx
new file mode 100644 (file)
index 0000000..aa867de
--- /dev/null
@@ -0,0 +1,34 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { ListItemIcon, ListItemText, ListItem } from "@material-ui/core";
+import { LockIcon, UnlockIcon } from "components/icon/icon";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { ProjectResource } from "models/project";
+import { withRouter, RouteComponentProps } from "react-router";
+
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+    isAdmin: state.auth.user!.isAdmin,
+    isLocked: !!(state.resources[state.contextMenu.resource!.uuid] as ProjectResource).frozenByUuid,
+    onClick: props.onClick
+});
+
+export const ToggleLockAction = withRouter(connect(mapStateToProps)((props: { isLocked: boolean, isAdmin: boolean, onClick: () => void } & RouteComponentProps) =>
+    props.isLocked && !props.isAdmin ? null :
+        < ListItem
+            button
+            onClick={props.onClick} >
+            <ListItemIcon>
+                {props.isLocked
+                    ? <UnlockIcon />
+                    : <LockIcon />}
+            </ListItemIcon>
+            <ListItemText style={{ textDecoration: 'none' }}>
+                {props.isLocked
+                    ? <>Unlock project</>
+                    : <>Lock project</>}
+            </ListItemText>
+        </ListItem >));
index 6f3a4389211363e9294bbfe3c53fdf530d32195a..244f61c421237b799e1fc1c89311bf8912bafd8c 100644 (file)
@@ -79,6 +79,7 @@ export enum ContextMenuKind {
     PROJECT = "Project",
     FILTER_GROUP = "FilterGroup",
     READONLY_PROJECT = 'ReadOnlyProject',
+    FROZEN_PROJECT = 'FrozenProject',
     PROJECT_ADMIN = "ProjectAdmin",
     FILTER_GROUP_ADMIN = "FilterGroupAdmin",
     RESOURCE = "Resource",
index cd9f972e249a32e6111bc17de17fab8b8c7e0b45..245a6597af28581e1a3e953c6c3fd018237d2799 100644 (file)
@@ -15,6 +15,7 @@ import {
 import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
 import { Resource, ResourceKind, TrashableResource } from 'models/resource';
 import {
+    LockIcon,
     ProjectIcon,
     FilterGroupIcon,
     CollectionIcon,
@@ -59,6 +60,7 @@ import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-a
 import { getUserUuid } from 'common/getuser';
 import { VirtualMachinesResource } from 'models/virtual-machines';
 import { CopyToClipboardSnackbar } from 'components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar';
+import { ProjectResource } from 'models/project';
 
 const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
 
@@ -79,11 +81,32 @@ const renderName = (dispatch: Dispatch, item: GroupContentsResource) => {
             <Typography variant="caption">
                 <FavoriteStar resourceUuid={item.uuid} />
                 <PublicFavoriteStar resourceUuid={item.uuid} />
+                {
+                    item.kind === ResourceKind.PROJECT && <FrozenProject item={item} />
+                }
             </Typography>
         </Grid>
     </Grid>;
 };
 
+const FrozenProject = (props: {item: ProjectResource}) => {
+    const [fullUsername, setFullusername] = React.useState<any>(null);
+    const getFullName = React.useCallback(() => {
+        if (props.item.frozenByUuid) {
+            setFullusername(<UserNameFromID uuid={props.item.frozenByUuid} />);
+        }
+    }, [props.item, setFullusername])
+
+    if (props.item.frozenByUuid) {
+
+        return <Tooltip onOpen={getFullName} enterDelay={500} title={<span>Project was frozen by {fullUsername}</span>}>
+            <LockIcon style={{ fontSize: "inherit" }}/>
+        </Tooltip>;
+    } else {
+        return null;
+    }
+}
+
 export const ResourceName = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
@@ -734,7 +757,7 @@ export const ResourceOwnerWithName =
 
 export const UserNameFromID =
     compose(userFromID)(
-        (props: { uuid: string, userFullname: string, dispatch: Dispatch }) => {
+        (props: { uuid: string, displayAsText?: string, userFullname: string, dispatch: Dispatch }) => {
             const { uuid, userFullname, dispatch } = props;
 
             if (userFullname === '') {
index a219e55a26d768b47d0e8b97e28ed9abb87b7868..c708fe13737023ddac182c05fe9da4b1c033d6a3 100644 (file)
@@ -21,6 +21,7 @@ import { extractUuidKind, ResourceKind } from 'models/resource';
 import { pluginConfig } from 'plugins';
 import { ElementListReducer } from 'common/plugintypes';
 import { Location } from 'history';
+import { ProjectResource } from 'models/project';
 
 type CssRules = 'button' | 'menuItem' | 'icon';
 
@@ -87,9 +88,10 @@ export const SidePanelButton = withStyles(styles)(
                 if (currentItemId === currentUserUUID) {
                     enabled = true;
                 } else if (matchProjectRoute(location ? location.pathname : '')) {
-                    const currentProject = getResource<GroupResource>(currentItemId)(resources);
-                    if (currentProject &&
+                    const currentProject = getResource<ProjectResource>(currentItemId)(resources);
+                    if (currentProject && currentProject.writableBy &&
                         currentProject.writableBy.indexOf(currentUserUUID || '') >= 0 &&
+                        !currentProject.frozenByUuid &&
                         !isProjectTrashed(currentProject, resources) &&
                         currentProject.groupClass !== GroupClass.FILTER) {
                         enabled = true;
index fb5b6205ce272290dabe9bb8a7cac61e324b4118..f98f3a1aa2289c45c120035caad734dc6e802bfa 100644 (file)
@@ -46,6 +46,7 @@ import {
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { GroupClass, GroupResource } from 'models/group';
 import { CollectionResource } from 'models/collection';
+import { resourceIsFrozen } from 'common/frozen-resources';
 
 type CssRules = 'root' | "button";
 
@@ -168,7 +169,7 @@ export const ProjectPanel = withStyles(styles)(
             }
 
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
-                const { resources } = this.props;
+                const { resources, isAdmin } = this.props;
                 const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
                 // When viewing the contents of a filter group, all contents should be treated as read only.
                 let readonly = false;
@@ -186,6 +187,8 @@ export const ProjectPanel = withStyles(styles)(
                         isTrashed: ('isTrashed' in resource) ? resource.isTrashed: false,
                         kind: resource.kind,
                         menuKind,
+                        isAdmin,
+                        isFrozen: resourceIsFrozen(resource, resources),
                         description: resource.description,
                         storageClassesDesired: (resource as CollectionResource).storageClassesDesired,
                         properties: ('properties' in resource) ? resource.properties : {},