From: Daniel Kutyła Date: Fri, 5 Jun 2020 20:35:40 +0000 (+0200) Subject: 16437: Removes context items when projects are not editable by user X-Git-Tag: 2.1.0~25^2 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/72ed766fa1a94853a1c57d0f43390a81e3d92c90 16437: Removes context items when projects are not editable by user Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła --- diff --git a/src/index.tsx b/src/index.tsx index a12dabfa..2cee0540 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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); diff --git a/src/models/resource.ts b/src/models/resource.ts index 4708a9da..d8cdd4a0 100644 --- a/src/models/resource.ts +++ b/src/models/resource.ts @@ -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 index 00000000..4bcbf9f2 --- /dev/null +++ b/src/store/context-menu/context-menu-actions.test.ts @@ -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 diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index 2ba6bc2c..1f766bd3 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -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, } }; -export const openProjectContextMenu = (event: React.MouseEvent, projectUuid: string) => +export const openProjectContextMenu = (event: React.MouseEvent, resourceUuid: string) => (dispatch: Dispatch, getState: () => RootState) => { - const res = getResource(projectUuid)(getState().resources); - const isAdmin = getState().auth.user!.isAdmin; - if (res) { + const { isAdmin, uuid: userUuid } = getState().auth.user!; + const res = getResourceWithEditableStatus(resourceUuid, userUuid)(getState().resources); + const menuKind = resourceKindToContextMenuKind(resourceUuid, isAdmin, (res || {} as EditableResource).isEditable); + if (res && menuKind) { dispatch(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, 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 index 00000000..aad93c66 --- /dev/null +++ b/src/store/resources/resources.test.ts @@ -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 diff --git a/src/store/resources/resources.ts b/src/store/resources/resources.ts index e7153dec..eb3c5509 100644 --- a/src/store/resources/resources.ts +++ b/src/store/resources/resources.ts @@ -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 = (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 = (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 index 00000000..9182f3f2 --- /dev/null +++ b/src/views-components/context-menu/action-sets/collection-action-set.test.ts @@ -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 diff --git a/src/views-components/context-menu/action-sets/collection-action-set.ts b/src/views-components/context-menu/action-sets/collection-action-set.ts index ea97a9b1..fba2a53a 100644 --- a/src/views-components/context-menu/action-sets/collection-action-set.ts +++ b/src/views-components/context-menu/action-sets/collection-action-set.ts @@ -49,30 +49,33 @@ export const readOnlyCollectionActionSet: ContextMenuActionSet = [[ }, ]]; -export const collectionActionSet: ContextMenuActionSet = readOnlyCollectionActionSet.concat([[ - { - icon: RenameIcon, - name: "Edit collection", - execute: (dispatch, resource) => { - dispatch(openCollectionUpdateDialog(resource)); - } - }, - { - icon: ShareIcon, - name: "Share", - execute: (dispatch, { uuid }) => { - dispatch(openSharingDialog(uuid)); - } - }, - { - icon: MoveToIcon, - name: "Move to", - execute: (dispatch, resource) => dispatch(openMoveCollectionDialog(resource)) - }, - { - component: ToggleTrashAction, - execute: (dispatch, resource) => { - dispatch(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(openCollectionUpdateDialog(resource)); + } + }, + { + icon: ShareIcon, + name: "Share", + execute: (dispatch, { uuid }) => { + dispatch(openSharingDialog(uuid)); + } + }, + { + icon: MoveToIcon, + name: "Move to", + execute: (dispatch, resource) => dispatch(openMoveCollectionDialog(resource)) + }, + { + component: ToggleTrashAction, + execute: (dispatch, resource) => { + dispatch(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 index 00000000..fd328221 --- /dev/null +++ b/src/views-components/context-menu/action-sets/project-action-set.test.ts @@ -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 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 32616fce..4f92aeb8 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 @@ -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(openProjectCreateDialog(resource.uuid)); - } - }, - { - icon: RenameIcon, - name: "Edit project", - execute: (dispatch, resource) => { - dispatch(openProjectUpdateDialog(resource)); - } - }, - { - icon: ShareIcon, - name: "Share", - execute: (dispatch, { uuid }) => { - dispatch(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(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(openAdvancedTabDialog(resource.uuid)); } }, - { - component: ToggleTrashAction, - execute: (dispatch, resource) => { - dispatch(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(openProjectCreateDialog(resource.uuid)); + } + }, + { + icon: RenameIcon, + name: "Edit project", + execute: (dispatch, resource) => { + dispatch(openProjectUpdateDialog(resource)); + } + }, + { + icon: ShareIcon, + name: "Share", + execute: (dispatch, { uuid }) => { + dispatch(openSharingDialog(uuid)); + } + }, + { + icon: MoveToIcon, + name: "Move to", + execute: (dispatch, resource) => { + dispatch(openMoveProjectDialog(resource)); + } + }, + { + component: ToggleTrashAction, + execute: (dispatch, resource) => { + dispatch(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!)); + } + }, + ] +]; diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index 55b0abd8..db5765ee 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -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", diff --git a/src/views/favorite-panel/favorite-panel.tsx b/src/views/favorite-panel/favorite-panel.tsx index e7234009..9c906eef 100644 --- a/src/views/favorite-panel/favorite-panel.tsx +++ b/src/views/favorite-panel/favorite-panel.tsx @@ -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 = [ 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 { handleContextMenu = (event: React.MouseEvent, resourceUuid: string) => { - const menuKind = resourceKindToContextMenuKind(resourceUuid, this.props.isAdmin); + const { isAdmin, userUuid, resources } = this.props; + const resource = getResourceWithEditableStatus(resourceUuid, userUuid)(resources); + const menuKind = resourceKindToContextMenuKind(resourceUuid, isAdmin, (resource || {} as EditableResource).isEditable); if (menuKind) { this.props.dispatch(openContextMenu(event, { name: '', diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx index 1e26bc0d..687e17df 100644 --- a/src/views/project-panel/project-panel.tsx +++ b/src/views/project-panel/project-panel.tsx @@ -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 { render() { @@ -149,8 +151,9 @@ export const ProjectPanel = withStyles(styles)( } handleContextMenu = (event: React.MouseEvent, resourceUuid: string) => { - const menuKind = resourceKindToContextMenuKind(resourceUuid, this.props.isAdmin); - const resource = getResource(resourceUuid)(this.props.resources); + const { isAdmin, userUuid, resources } = this.props; + const resource = getResourceWithEditableStatus(resourceUuid, userUuid)(resources); + const menuKind = resourceKindToContextMenuKind(resourceUuid, isAdmin, (resource || {} as EditableResource).isEditable); if (menuKind && resource) { this.props.dispatch(openContextMenu(event, { name: resource.name, diff --git a/src/views/shared-with-me-panel/shared-with-me-panel.tsx b/src/views/shared-with-me-panel/shared-with-me-panel.tsx index 7fd00ba1..c9408752 100644 --- a/src/views/shared-with-me-panel/shared-with-me-panel.tsx +++ b/src/views/shared-with-me-panel/shared-with-me-panel.tsx @@ -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 = (theme: ArvadosTheme) => ({ interface SharedWithMePanelDataProps { resources: ResourcesState; isAdmin: boolean; + userUuid: string; } type SharedWithMePanelProps = SharedWithMePanelDataProps & DispatchProp & WithStyles; @@ -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 { render() { @@ -53,8 +56,9 @@ export const SharedWithMePanel = withStyles(styles)( } handleContextMenu = (event: React.MouseEvent, resourceUuid: string) => { - const menuKind = resourceKindToContextMenuKind(resourceUuid, this.props.isAdmin); - const resource = getResource(resourceUuid)(this.props.resources); + const { isAdmin, userUuid, resources } = this.props; + const resource = getResourceWithEditableStatus(resourceUuid, userUuid)(resources); + const menuKind = resourceKindToContextMenuKind(resourceUuid, isAdmin, (resource || {} as EditableResource).isEditable); if (menuKind && resource) { this.props.dispatch(openContextMenu(event, { name: '',