[ARVADOS-WORKBENCH2] created: 2.4.0-45-gf1158a80
Git user
git at public.arvados.org
Fri May 13 13:27:44 UTC 2022
at f1158a80eae96784f909ad496487f5604fe95329 (commit)
commit f1158a80eae96784f909ad496487f5604fe95329
Author: Daniel Kutyła <daniel.kutyla at contractors.roche.com>
Date: Fri May 13 15:26:42 2022 +0200
18692: Initial frozen project implementation with tests
Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla at contractors.roche.com>
diff --git a/cypress/integration/project.spec.js b/cypress/integration/project.spec.js
index 0017e416..5929fb90 100644
--- a/cypress/integration/project.spec.js
+++ b/cypress/integration/project.spec.js
@@ -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
index 00000000..10e18151
--- /dev/null
+++ b/src/common/frozen-resources.ts
@@ -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
diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx
index 19b4beea..178c4cbe 100644
--- a/src/components/icon/icon.tsx
+++ b/src/components/icon/icon.tsx
@@ -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} />;
diff --git a/src/index.tsx b/src/index.tsx
index f928ea8a..4dde654c 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -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);
diff --git a/src/models/project.ts b/src/models/project.ts
index b47b426f..b490864d 100644
--- a/src/models/project.ts
+++ b/src/models/project.ts
@@ -5,6 +5,7 @@
import { GroupClass, GroupResource } from "./group";
export interface ProjectResource extends GroupResource {
+ frozenByUuid: null|string;
groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE;
}
diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts
index 1116949a..7b1b0257 100644
--- a/src/store/context-menu/context-menu-actions.ts
+++ b/src/store/context-menu/context-menu-actions.ts
@@ -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
index 00000000..f135522b
--- /dev/null
+++ b/src/store/projects/project-lock-actions.ts
@@ -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
diff --git a/src/views-components/context-menu/action-sets/project-action-set.ts b/src/views-components/context-menu/action-sets/project-action-set.ts
index a079bf4f..77abb128 100644
--- a/src/views-components/context-menu/action-sets/project-action-set.ts
+++ b/src/views-components/context-menu/action-sets/project-action-set.ts
@@ -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
index 00000000..aa867ded
--- /dev/null
+++ b/src/views-components/context-menu/actions/lock-action.tsx
@@ -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 >));
diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx
index 6f3a4389..244f61c4 100644
--- a/src/views-components/context-menu/context-menu.tsx
+++ b/src/views-components/context-menu/context-menu.tsx
@@ -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",
diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx
index cd9f972e..245a6597 100644
--- a/src/views-components/data-explorer/renderers.tsx
+++ b/src/views-components/data-explorer/renderers.tsx
@@ -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 === '') {
diff --git a/src/views-components/side-panel-button/side-panel-button.tsx b/src/views-components/side-panel-button/side-panel-button.tsx
index a219e55a..c708fe13 100644
--- a/src/views-components/side-panel-button/side-panel-button.tsx
+++ b/src/views-components/side-panel-button/side-panel-button.tsx
@@ -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;
diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx
index fb5b6205..f98f3a1a 100644
--- a/src/views/project-panel/project-panel.tsx
+++ b/src/views/project-panel/project-panel.tsx
@@ -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 : {},
-----------------------------------------------------------------------
hooks/post-receive
--
More information about the arvados-commits
mailing list