16437: Removes context items when projects are not editable by user
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Fri, 5 Jun 2020 20:35:40 +0000 (22:35 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Wed, 17 Jun 2020 19:52:17 +0000 (21:52 +0200)
Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>

14 files changed:
src/index.tsx
src/models/resource.ts
src/store/context-menu/context-menu-actions.test.ts [new file with mode: 0644]
src/store/context-menu/context-menu-actions.ts
src/store/resources/resources.test.ts [new file with mode: 0644]
src/store/resources/resources.ts
src/views-components/context-menu/action-sets/collection-action-set.test.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.test.ts [new file with mode: 0644]
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views/favorite-panel/favorite-panel.tsx
src/views/project-panel/project-panel.tsx
src/views/shared-with-me-panel/shared-with-me-panel.tsx

index a12dabfae9b62d3777dda3191c6a868b08f8fd9c..2cee0540862ac2fc9699c043425d850111d90914 100644 (file)
@@ -21,7 +21,7 @@ import { CustomTheme } from '~/common/custom-theme';
 import { fetchConfig } from '~/common/config';
 import { addMenuActionSet, ContextMenuKind } from '~/views-components/context-menu/context-menu';
 import { rootProjectActionSet } from "~/views-components/context-menu/action-sets/root-project-action-set";
-import { projectActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
+import { 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';
@@ -67,6 +67,7 @@ console.log(`Starting arvados [${getBuildInfo()}]`);
 
 addMenuActionSet(ContextMenuKind.ROOT_PROJECT, rootProjectActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT, projectActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_PROJECT, readOnlyProjectActionSet);
 addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet);
 addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
index 4708a9dac8e98a618fbc853e39a2d48c10cb367f..d8cdd4a00503ef2e86b9bd846193cd8750f525b3 100644 (file)
@@ -14,6 +14,10 @@ export interface Resource {
     etag: string;
 }
 
+export interface EditableResource extends Resource {
+    isEditable: boolean;
+}
+
 export interface TrashableResource extends Resource {
     trashAt: string;
     deleteAt: string;
diff --git a/src/store/context-menu/context-menu-actions.test.ts b/src/store/context-menu/context-menu-actions.test.ts
new file mode 100644 (file)
index 0000000..4bcbf9f
--- /dev/null
@@ -0,0 +1,161 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as resource from '~/models/resource';
+import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
+import { resourceKindToContextMenuKind } from './context-menu-actions';
+
+describe('context-menu-actions', () => {
+    describe('resourceKindToContextMenuKind', () => {
+        const uuid = '123';
+
+        describe('ResourceKind.PROJECT', () => {
+            beforeEach(() => {
+                // setup
+                jest.spyOn(resource, 'extractUuidKind')
+                    .mockImplementation(() => resource.ResourceKind.PROJECT);
+            });
+
+            it('should return ContextMenuKind.PROJECT_ADMIN', () => {
+                // given
+                const isAdmin = true;
+
+                // when
+                const result = resourceKindToContextMenuKind(uuid, isAdmin);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.PROJECT_ADMIN);
+            });
+
+            it('should return ContextMenuKind.PROJECT', () => {
+                // given
+                const isAdmin = false;
+                const isEditable = true;
+
+                // when
+                const result = resourceKindToContextMenuKind(uuid, isAdmin, isEditable);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.PROJECT);
+            });
+
+            it('should return ContextMenuKind.READONLY_PROJECT', () => {
+                // given
+                const isAdmin = false;
+                const isEditable = false;
+
+                // when
+                const result = resourceKindToContextMenuKind(uuid, isAdmin, isEditable);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.READONLY_PROJECT);
+            });
+        });
+
+        describe('ResourceKind.COLLECTION', () => {
+            beforeEach(() => {
+                // setup
+                jest.spyOn(resource, 'extractUuidKind')
+                    .mockImplementation(() => resource.ResourceKind.COLLECTION);
+            });
+
+            it('should return ContextMenuKind.COLLECTION_ADMIN', () => {
+                // given
+                const isAdmin = true;
+
+                // when
+                const result = resourceKindToContextMenuKind(uuid, isAdmin);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.COLLECTION_ADMIN);
+            });
+
+            it('should return ContextMenuKind.COLLECTION_RESOURCE', () => {
+                // given
+                const isAdmin = false;
+                const isEditable = true;
+
+                // when
+                const result = resourceKindToContextMenuKind(uuid, isAdmin, isEditable);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.COLLECTION_RESOURCE);
+            });
+
+            it('should return ContextMenuKind.READONLY_COLLECTION', () => {
+                // given
+                const isAdmin = false;
+                const isEditable = false;
+
+                // when
+                const result = resourceKindToContextMenuKind(uuid, isAdmin, isEditable);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.READONLY_COLLECTION);
+            });
+        });
+
+        describe('ResourceKind.PROCESS', () => {
+            beforeEach(() => {
+                // setup
+                jest.spyOn(resource, 'extractUuidKind')
+                    .mockImplementation(() => resource.ResourceKind.PROCESS);
+            });
+
+            it('should return ContextMenuKind.PROCESS_ADMIN', () => {
+                // given
+                const isAdmin = true;
+
+                // when
+                const result = resourceKindToContextMenuKind(uuid, isAdmin);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.PROCESS_ADMIN);
+            });
+
+            it('should return ContextMenuKind.PROCESS_RESOURCE', () => {
+                // given
+                const isAdmin = false;
+
+                // when
+                const result = resourceKindToContextMenuKind(uuid, isAdmin);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.PROCESS_RESOURCE);
+            });
+        });
+
+        describe('ResourceKind.USER', () => {
+            beforeEach(() => {
+                // setup
+                jest.spyOn(resource, 'extractUuidKind')
+                    .mockImplementation(() => resource.ResourceKind.USER);
+            });
+
+            it('should return ContextMenuKind.ROOT_PROJECT', () => {
+                // when
+                const result = resourceKindToContextMenuKind(uuid);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.ROOT_PROJECT);
+            });
+        });
+
+        describe('ResourceKind.LINK', () => {
+            beforeEach(() => {
+                // setup
+                jest.spyOn(resource, 'extractUuidKind')
+                    .mockImplementation(() => resource.ResourceKind.LINK);
+            });
+
+            it('should return ContextMenuKind.LINK', () => {
+                // when
+                const result = resourceKindToContextMenuKind(uuid);
+
+                // then
+                expect(result).toEqual(ContextMenuKind.LINK);
+            });
+        });
+    });
+});
\ No newline at end of file
index 2ba6bc2cc78d487842df65625a709eb6877bda7b..1f766bd3e4ad38f0cf50fc934e1982fa9ec6782e 100644 (file)
@@ -7,11 +7,11 @@ import { ContextMenuPosition } from "./context-menu-reducer";
 import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
 import { Dispatch } from 'redux';
 import { RootState } from '~/store/store';
-import { getResource } from '../resources/resources';
+import { getResource, getResourceWithEditableStatus } from '../resources/resources';
 import { ProjectResource } from '~/models/project';
 import { UserResource } from '~/models/user';
 import { isSidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
-import { extractUuidKind, ResourceKind } from '~/models/resource';
+import { extractUuidKind, ResourceKind, EditableResource } from '~/models/resource';
 import { Process } from '~/store/processes/process';
 import { RepositoryResource } from '~/models/repositories';
 import { SshKeyResource } from '~/models/ssh-key';
@@ -34,6 +34,7 @@ export type ContextMenuResource = {
     kind: ResourceKind,
     menuKind: ContextMenuKind;
     isTrashed?: boolean;
+    isEditable?: boolean;
     outputUuid?: string;
     workflowUuid?: string;
 };
@@ -153,16 +154,17 @@ export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>,
         }
     };
 
-export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
+export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) =>
     (dispatch: Dispatch, getState: () => RootState) => {
-        const res = getResource<ProjectResource>(projectUuid)(getState().resources);
-        const isAdmin = getState().auth.user!.isAdmin;
-        if (res) {
+        const { isAdmin, uuid: userUuid } = getState().auth.user!;
+        const res = getResourceWithEditableStatus<ProjectResource & EditableResource>(resourceUuid, userUuid)(getState().resources);
+        const menuKind = resourceKindToContextMenuKind(resourceUuid, isAdmin, (res || {} as EditableResource).isEditable);
+        if (res && menuKind) {
             dispatch<any>(openContextMenu(event, {
                 name: res.name,
                 uuid: res.uuid,
                 kind: res.kind,
-                menuKind: !isAdmin ? ContextMenuKind.PROJECT : ContextMenuKind.PROJECT_ADMIN,
+                menuKind,
                 ownerUuid: res.ownerUuid,
                 isTrashed: res.isTrashed
             }));
@@ -198,13 +200,17 @@ export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, pro
         }
     };
 
-export const resourceKindToContextMenuKind = (uuid: string, isAdmin?: boolean) => {
+export const resourceKindToContextMenuKind = (uuid: string, isAdmin?: boolean, isEditable?: boolean) => {
     const kind = extractUuidKind(uuid);
     switch (kind) {
         case ResourceKind.PROJECT:
-            return !isAdmin ? ContextMenuKind.PROJECT : ContextMenuKind.PROJECT_ADMIN;
+            return !isAdmin ?
+                isEditable ? ContextMenuKind.PROJECT : ContextMenuKind.READONLY_PROJECT :
+                ContextMenuKind.PROJECT_ADMIN;
         case ResourceKind.COLLECTION:
-            return !isAdmin ? ContextMenuKind.COLLECTION_RESOURCE : ContextMenuKind.COLLECTION_ADMIN;
+            return !isAdmin ?
+                isEditable ? ContextMenuKind.COLLECTION_RESOURCE : ContextMenuKind.READONLY_COLLECTION :
+                ContextMenuKind.COLLECTION_ADMIN;
         case ResourceKind.PROCESS:
             return !isAdmin ? ContextMenuKind.PROCESS_RESOURCE : ContextMenuKind.PROCESS_ADMIN;
         case ResourceKind.USER:
diff --git a/src/store/resources/resources.test.ts b/src/store/resources/resources.test.ts
new file mode 100644 (file)
index 0000000..aad93c6
--- /dev/null
@@ -0,0 +1,140 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { getResourceWithEditableStatus } from "./resources";
+import { ResourceKind } from "~/models/resource";
+
+const groupFixtures = {
+    user_uuid: 'zzzzz-tpzed-0123456789ab789',
+    user_resource_uuid: 'zzzzz-tpzed-0123456789abcde',
+    unknown_user_resource_uuid: 'zzzzz-tpzed-0123456789ab987',
+    editable_collection_resource_uuid: 'zzzzz-4zz18-0123456789ab456',
+    not_editable_collection_resource_uuid: 'zzzzz-4zz18-0123456789ab654',
+    editable_project_resource_uuid: 'zzzzz-j7d0g-0123456789ab123',
+    not_editable_project_resource_uuid: 'zzzzz-j7d0g-0123456789ab321',
+};
+
+describe('resources', () => {
+    describe('getResourceWithEditableStatus', () => {
+        const resourcesState = {
+            [groupFixtures.editable_project_resource_uuid]: {
+                uuid: groupFixtures.editable_project_resource_uuid,
+                ownerUuid: groupFixtures.user_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.PROJECT,
+                writableBy: [groupFixtures.user_uuid],
+                etag: 'string',
+            },
+            [groupFixtures.editable_collection_resource_uuid]: {
+                uuid: groupFixtures.editable_collection_resource_uuid,
+                ownerUuid: groupFixtures.editable_project_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.COLLECTION,
+                etag: 'string',
+            },
+            [groupFixtures.not_editable_project_resource_uuid]: {
+                uuid: groupFixtures.not_editable_project_resource_uuid,
+                ownerUuid: groupFixtures.unknown_user_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.PROJECT,
+                writableBy: [groupFixtures.unknown_user_resource_uuid],
+                etag: 'string',
+            },
+            [groupFixtures.not_editable_collection_resource_uuid]: {
+                uuid: groupFixtures.not_editable_collection_resource_uuid,
+                ownerUuid: groupFixtures.not_editable_project_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.COLLECTION,
+                etag: 'string',
+            },
+            [groupFixtures.user_resource_uuid]: {
+                uuid: groupFixtures.user_resource_uuid,
+                ownerUuid: groupFixtures.user_resource_uuid,
+                createdAt: 'string',
+                modifiedByClientUuid: 'string',
+                modifiedByUserUuid: 'string',
+                modifiedAt: 'string',
+                href: 'string',
+                kind: ResourceKind.USER,
+                etag: 'string',
+            }
+        };
+
+        it('should return editable user resource (resource UUID is equal to user UUID)', () => {
+            // given
+            const id = groupFixtures.user_resource_uuid;
+            const userUuid = groupFixtures.user_resource_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeTruthy();
+        });
+
+        it('should return editable project resource', () => {
+            // given
+            const id = groupFixtures.editable_project_resource_uuid;
+            const userUuid = groupFixtures.user_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeTruthy();
+        });
+
+        it('should return editable collection resource', () => {
+            // given
+            const id = groupFixtures.editable_collection_resource_uuid;
+            const userUuid = groupFixtures.user_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeTruthy();
+        });
+
+        it('should return not editable project resource', () => {
+            // given
+            const id = groupFixtures.not_editable_project_resource_uuid;
+            const userUuid = groupFixtures.user_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeFalsy();
+        });
+
+        it('should return not editable collection resource', () => {
+            // given
+            const id = groupFixtures.not_editable_collection_resource_uuid;
+            const userUuid = groupFixtures.user_uuid;
+
+            // when
+            const result = getResourceWithEditableStatus(id, userUuid)(resourcesState);
+
+            // then
+            expect(result!.isEditable).toBeFalsy();
+        });
+    });
+});
\ No newline at end of file
index e7153decd70af11a91131c8f8009a26a74df73eb..eb3c5509f2f1c3f99c2afc028c4ae18e7e308b6c 100644 (file)
@@ -2,11 +2,44 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Resource } from "~/models/resource";
+import { Resource, EditableResource } from "~/models/resource";
 import { ResourceKind } from '~/models/resource';
+import { ProjectResource } from "~/models/project";
+import { GroupResource } from "~/models/group";
 
 export type ResourcesState = { [key: string]: Resource };
 
+const getResourceWritableBy = (state: ResourcesState, id: string, userUuid: string): string[] => {
+    if (!id) {
+        return [];
+    }
+
+    if (id === userUuid) {
+        return [userUuid];
+    }
+
+    const resource = (state[id] as ProjectResource);
+
+    if (!resource) {
+        return [];
+    }
+
+    const { writableBy } = resource;
+
+    return writableBy || getResourceWritableBy(state, resource.ownerUuid, userUuid);
+};
+
+export const getResourceWithEditableStatus = <T extends EditableResource & GroupResource>(id: string, userUuid?: string) =>
+    (state: ResourcesState): T | undefined => {
+        const resource = JSON.parse(JSON.stringify(state[id] as T));
+
+        if (resource) {
+            resource.isEditable = userUuid ? getResourceWritableBy(state, id, userUuid).indexOf(userUuid) > -1 : false;
+        }
+
+        return resource;
+    };
+
 export const getResource = <T extends Resource = Resource>(id: string) =>
     (state: ResourcesState): T | undefined =>
         state[id] as T;
diff --git a/src/views-components/context-menu/action-sets/collection-action-set.test.ts b/src/views-components/context-menu/action-sets/collection-action-set.test.ts
new file mode 100644 (file)
index 0000000..9182f3f
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { collectionActionSet, readOnlyCollectionActionSet } from "./collection-action-set";
+
+describe('collection-action-set', () => {
+    const flattCollectionActionSet = collectionActionSet.reduce((prev, next) => prev.concat(next), []);
+    const flattReadOnlyCollectionActionSet = readOnlyCollectionActionSet.reduce((prev, next) => prev.concat(next), []);
+    describe('collectionActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattCollectionActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should contain readOnlyCollectionActionSet items', () => {
+            // then
+            expect(flattCollectionActionSet)
+                .toEqual(expect.arrayContaining(flattReadOnlyCollectionActionSet));
+        })
+    });
+
+    describe('readOnlyCollectionActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattReadOnlyCollectionActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should not contain collectionActionSet items', () => {
+            // then
+            expect(flattReadOnlyCollectionActionSet)
+                .not.toEqual(expect.arrayContaining(flattCollectionActionSet));
+        })
+    });
+});
\ No newline at end of file
index ea97a9b17ebfb4937d4b22d5b58fbc4ea4276c11..fba2a53acc2d3a42b8400e6ce4fffdbc45c25287 100644 (file)
@@ -49,30 +49,33 @@ export const readOnlyCollectionActionSet: ContextMenuActionSet = [[
     },
 ]];
 
-export const collectionActionSet: ContextMenuActionSet = readOnlyCollectionActionSet.concat([[
-    {
-        icon: RenameIcon,
-        name: "Edit collection",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionUpdateDialog(resource));
-        }
-    },
-    {
-        icon: ShareIcon,
-        name: "Share",
-        execute: (dispatch, { uuid }) => {
-            dispatch<any>(openSharingDialog(uuid));
-        }
-    },
-    {
-        icon: MoveToIcon,
-        name: "Move to",
-        execute: (dispatch, resource) => dispatch<any>(openMoveCollectionDialog(resource))
-    },
-    {
-        component: ToggleTrashAction,
-        execute: (dispatch, resource) => {
-            dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
-        }
-    },
-]]);
+export const collectionActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyCollectionActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: RenameIcon,
+            name: "Edit collection",
+            execute: (dispatch, resource) => {
+                dispatch<any>(openCollectionUpdateDialog(resource));
+            }
+        },
+        {
+            icon: ShareIcon,
+            name: "Share",
+            execute: (dispatch, { uuid }) => {
+                dispatch<any>(openSharingDialog(uuid));
+            }
+        },
+        {
+            icon: MoveToIcon,
+            name: "Move to",
+            execute: (dispatch, resource) => dispatch<any>(openMoveCollectionDialog(resource))
+        },
+        {
+            component: ToggleTrashAction,
+            execute: (dispatch, resource) => {
+                dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
+            }
+        },
+    ]
+];
diff --git a/src/views-components/context-menu/action-sets/project-action-set.test.ts b/src/views-components/context-menu/action-sets/project-action-set.test.ts
new file mode 100644 (file)
index 0000000..fd32822
--- /dev/null
@@ -0,0 +1,36 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { projectActionSet, readOnlyProjectActionSet } from "./project-action-set";
+
+describe('project-action-set', () => {
+    const flattProjectActionSet = projectActionSet.reduce((prev, next) => prev.concat(next), []);
+    const flattReadOnlyProjectActionSet = readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []);
+
+    describe('projectActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattProjectActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should contain readOnlyProjectActionSet items', () => {
+            // then
+            expect(flattProjectActionSet)
+                .toEqual(expect.arrayContaining(flattReadOnlyProjectActionSet));
+        })
+    });
+
+    describe('readOnlyProjectActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattReadOnlyProjectActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should not contain projectActionSet items', () => {
+            // then
+            expect(flattReadOnlyProjectActionSet)
+                .not.toEqual(expect.arrayContaining(flattProjectActionSet));
+        })
+    });
+});
\ No newline at end of file
index 32616fce9731a987eff015e5d30c03bc45aa0153..4f92aeb8132720abccda9a374bce7b6221be4cbe 100644 (file)
@@ -17,28 +17,7 @@ import { openSharingDialog } from "~/store/sharing-dialog/sharing-dialog-actions
 import { openAdvancedTabDialog } from "~/store/advanced-tab/advanced-tab";
 import { toggleDetailsPanel } from '~/store/details-panel/details-panel-action';
 
-export const projectActionSet: ContextMenuActionSet = [[
-    {
-        icon: NewProjectIcon,
-        name: "New project",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openProjectCreateDialog(resource.uuid));
-        }
-    },
-    {
-        icon: RenameIcon,
-        name: "Edit project",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openProjectUpdateDialog(resource));
-        }
-    },
-    {
-        icon: ShareIcon,
-        name: "Share",
-        execute: (dispatch, { uuid }) => {
-            dispatch<any>(openSharingDialog(uuid));
-        }
-    },
+export const readOnlyProjectActionSet: ContextMenuActionSet = [[
     {
         component: ToggleFavoriteAction,
         execute: (dispatch, resource) => {
@@ -47,20 +26,6 @@ export const projectActionSet: ContextMenuActionSet = [[
             });
         }
     },
-    {
-        icon: MoveToIcon,
-        name: "Move to",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openMoveProjectDialog(resource));
-        }
-    },
-    // {
-    //     icon: CopyIcon,
-    //     name: "Copy to project",
-    //     execute: (dispatch, resource) => {
-    //         // add code
-    //     }
-    // },
     {
         icon: DetailsIcon,
         name: "View details",
@@ -75,10 +40,44 @@ export const projectActionSet: ContextMenuActionSet = [[
             dispatch<any>(openAdvancedTabDialog(resource.uuid));
         }
     },
-    {
-        component: ToggleTrashAction,
-        execute: (dispatch, resource) => {
-            dispatch<any>(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!));
-        }
-    },
 ]];
+
+export const projectActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: NewProjectIcon,
+            name: "New project",
+            execute: (dispatch, resource) => {
+                dispatch<any>(openProjectCreateDialog(resource.uuid));
+            }
+        },
+        {
+            icon: RenameIcon,
+            name: "Edit project",
+            execute: (dispatch, resource) => {
+                dispatch<any>(openProjectUpdateDialog(resource));
+            }
+        },
+        {
+            icon: ShareIcon,
+            name: "Share",
+            execute: (dispatch, { uuid }) => {
+                dispatch<any>(openSharingDialog(uuid));
+            }
+        },
+        {
+            icon: MoveToIcon,
+            name: "Move to",
+            execute: (dispatch, resource) => {
+                dispatch<any>(openMoveProjectDialog(resource));
+            }
+        },
+        {
+            component: ToggleTrashAction,
+            execute: (dispatch, resource) => {
+                dispatch<any>(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!));
+            }
+        },
+    ]
+];
index 55b0abd8dc95f7b582b74aa31dbad12080c88108..db5765ee1281093c983989f45432aee38d2c8434 100644 (file)
@@ -65,6 +65,7 @@ export enum ContextMenuKind {
     API_CLIENT_AUTHORIZATION = "ApiClientAuthorization",
     ROOT_PROJECT = "RootProject",
     PROJECT = "Project",
+    READONLY_PROJECT = 'ReadOnlyProject',
     PROJECT_ADMIN = "ProjectAdmin",
     RESOURCE = "Resource",
     FAVORITE = "Favorite",
index e7234009d9e3baa15442a13ffbbb4bd2d917f55b..9c906eef76df9bfc31e20692e5f1b5ff4f1e940e 100644 (file)
@@ -10,7 +10,7 @@ import { DataColumns } from '~/components/data-table/data-table';
 import { RouteComponentProps } from 'react-router';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
 import { SortDirection } from '~/components/data-table/data-column';
-import { ResourceKind } from '~/models/resource';
+import { ResourceKind, EditableResource } from '~/models/resource';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { FAVORITE_PANEL_ID } from "~/store/favorite-panel/favorite-panel-action";
 import {
@@ -31,6 +31,8 @@ import { RootState } from '~/store/store';
 import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
 import { createTree } from '~/models/tree';
 import { getSimpleObjectTypeFilters } from '~/store/resource-type-filters/resource-type-filters';
+import { getResourceWithEditableStatus, ResourcesState } from '~/store/resources/resources';
+import { ProjectResource } from '~/models/project';
 
 type CssRules = "toolbar" | "button";
 
@@ -106,7 +108,9 @@ export const favoritePanelColumns: DataColumns<string> = [
 
 interface FavoritePanelDataProps {
     favorites: FavoritesState;
+    resources: ResourcesState;
     isAdmin: boolean;
+    userUuid: string;
 }
 
 interface FavoritePanelActionProps {
@@ -116,7 +120,9 @@ interface FavoritePanelActionProps {
 }
 const mapStateToProps = (state : RootState): FavoritePanelDataProps => ({
     favorites: state.favorites,
-    isAdmin: state.auth.user!.isAdmin
+    resources: state.resources,
+    isAdmin: state.auth.user!.isAdmin,
+    userUuid: state.auth.user!.uuid,
 });
 
 type FavoritePanelProps = FavoritePanelDataProps & FavoritePanelActionProps & DispatchProp
@@ -127,7 +133,9 @@ export const FavoritePanel = withStyles(styles)(
         class extends React.Component<FavoritePanelProps> {
 
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
-                const menuKind = resourceKindToContextMenuKind(resourceUuid, this.props.isAdmin);
+                const { isAdmin, userUuid, resources } = this.props;
+                const resource = getResourceWithEditableStatus<ProjectResource & EditableResource>(resourceUuid, userUuid)(resources);
+                const menuKind = resourceKindToContextMenuKind(resourceUuid, isAdmin, (resource || {} as EditableResource).isEditable);
                 if (menuKind) {
                     this.props.dispatch<any>(openContextMenu(event, {
                         name: '',
index 1e26bc0d5c1e29d2008cd7d5bf95f19a62747cc7..687e17dfaebad737ff6f8fe2d3b6eb2c5e688415 100644 (file)
@@ -14,11 +14,11 @@ import { RootState } from '~/store/store';
 import { DataTableFilterItem } from '~/components/data-table-filters/data-table-filters';
 import { ContainerRequestState } from '~/models/container-request';
 import { SortDirection } from '~/components/data-table/data-column';
-import { ResourceKind, Resource } from '~/models/resource';
+import { ResourceKind, Resource, EditableResource } from '~/models/resource';
 import { ResourceFileSize, ResourceLastModifiedDate, ProcessStatus, ResourceType, ResourceOwner } from '~/views-components/data-explorer/renderers';
 import { ProjectIcon } from '~/components/icon/icon';
 import { ResourceName } from '~/views-components/data-explorer/renderers';
-import { ResourcesState, getResource } from '~/store/resources/resources';
+import { ResourcesState, getResourceWithEditableStatus } from '~/store/resources/resources';
 import { loadDetailsPanel } from '~/store/details-panel/details-panel-action';
 import { resourceKindToContextMenuKind, openContextMenu } from '~/store/context-menu/context-menu-actions';
 import { ProjectResource } from '~/models/project';
@@ -115,6 +115,7 @@ interface ProjectPanelDataProps {
     currentItemId: string;
     resources: ResourcesState;
     isAdmin: boolean;
+    userUuid: string;
 }
 
 type ProjectPanelProps = ProjectPanelDataProps & DispatchProp
@@ -124,7 +125,8 @@ export const ProjectPanel = withStyles(styles)(
     connect((state: RootState) => ({
         currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
         resources: state.resources,
-        isAdmin: state.auth.user!.isAdmin
+        isAdmin: state.auth.user!.isAdmin,
+        userUuid: state.auth.user!.uuid,
     }))(
         class extends React.Component<ProjectPanelProps> {
             render() {
@@ -149,8 +151,9 @@ export const ProjectPanel = withStyles(styles)(
             }
 
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
-                const menuKind = resourceKindToContextMenuKind(resourceUuid, this.props.isAdmin);
-                const resource = getResource<ProjectResource>(resourceUuid)(this.props.resources);
+                const { isAdmin, userUuid, resources } = this.props;
+                const resource = getResourceWithEditableStatus<ProjectResource & EditableResource>(resourceUuid, userUuid)(resources);
+                const menuKind = resourceKindToContextMenuKind(resourceUuid, isAdmin, (resource || {} as EditableResource).isEditable);
                 if (menuKind && resource) {
                     this.props.dispatch<any>(openContextMenu(event, {
                         name: resource.name,
index 7fd00ba166d7a29159df8299392a216f4f23d815..c9408752b4d6027dcc21742b12e615281b4e0241 100644 (file)
@@ -9,13 +9,14 @@ import { connect, DispatchProp } from 'react-redux';
 import { RootState } from '~/store/store';
 import { ArvadosTheme } from '~/common/custom-theme';
 import { ShareMeIcon } from '~/components/icon/icon';
-import { ResourcesState, getResource } from '~/store/resources/resources';
+import { ResourcesState, getResourceWithEditableStatus } from '~/store/resources/resources';
 import { navigateTo } from "~/store/navigation/navigation-action";
 import { loadDetailsPanel } from "~/store/details-panel/details-panel-action";
 import { DataTableDefaultView } from '~/components/data-table-default-view/data-table-default-view';
 import { SHARED_WITH_ME_PANEL_ID } from '~/store/shared-with-me-panel/shared-with-me-panel-actions';
 import { openContextMenu, resourceKindToContextMenuKind } from '~/store/context-menu/context-menu-actions';
 import { GroupResource } from '~/models/group';
+import { EditableResource } from '~/models/resource';
 
 type CssRules = "toolbar" | "button";
 
@@ -32,6 +33,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 interface SharedWithMePanelDataProps {
     resources: ResourcesState;
     isAdmin: boolean;
+    userUuid: string;
 }
 
 type SharedWithMePanelProps = SharedWithMePanelDataProps & DispatchProp & WithStyles<CssRules>;
@@ -39,7 +41,8 @@ type SharedWithMePanelProps = SharedWithMePanelDataProps & DispatchProp & WithSt
 export const SharedWithMePanel = withStyles(styles)(
     connect((state: RootState) => ({
         resources: state.resources,
-        isAdmin: state.auth.user!.isAdmin
+        isAdmin: state.auth.user!.isAdmin,
+        userUuid: state.auth.user!.uuid,
     }))(
         class extends React.Component<SharedWithMePanelProps> {
             render() {
@@ -53,8 +56,9 @@ export const SharedWithMePanel = withStyles(styles)(
             }
 
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
-                const menuKind = resourceKindToContextMenuKind(resourceUuid, this.props.isAdmin);
-                const resource = getResource<GroupResource>(resourceUuid)(this.props.resources);
+                const { isAdmin, userUuid, resources } = this.props;
+                const resource = getResourceWithEditableStatus<GroupResource & EditableResource>(resourceUuid, userUuid)(resources);
+                const menuKind = resourceKindToContextMenuKind(resourceUuid, isAdmin, (resource || {} as EditableResource).isEditable);
                 if (menuKind && resource) {
                     this.props.dispatch<any>(openContextMenu(event, {
                         name: '',