From: Lucas Di Pentima Date: Fri, 1 May 2020 20:56:22 +0000 (-0300) Subject: 16118: Restricts UI elements when a collection is read-only. X-Git-Tag: 2.1.0~29^2~11 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/d8a3b5fdd6f606800e9b321acb3fca10c5183cb9 16118: Restricts UI elements when a collection is read-only. * Shows a lock icon indicating the read-only access. * The three-dotted 'More options' menu only shows appropriate actions. * The properties panel only shows properties without the 'delete tag' button. * The files panel general 'More options' menu shows appropriate actions. * The files panel individual context menu also filters editing action when read-only. * The files panel's upload button isn't rendered on read-only collections. Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima --- diff --git a/src/components/collection-panel-files/collection-panel-files.tsx b/src/components/collection-panel-files/collection-panel-files.tsx index 0a443907..3de4068f 100644 --- a/src/components/collection-panel-files/collection-panel-files.tsx +++ b/src/components/collection-panel-files/collection-panel-files.tsx @@ -12,9 +12,10 @@ import { DownloadIcon } from '~/components/icon/icon'; export interface CollectionPanelFilesProps { items: Array>; + isWritable: boolean; onUploadDataClick: () => void; - onItemMenuOpen: (event: React.MouseEvent, item: TreeItem) => void; - onOptionsMenuOpen: (event: React.MouseEvent) => void; + onItemMenuOpen: (event: React.MouseEvent, item: TreeItem, isWritable: boolean) => void; + onOptionsMenuOpen: (event: React.MouseEvent, isWritable: boolean) => void; onSelectionToggle: (event: React.MouseEvent, item: TreeItem) => void; onCollapseToggle: (id: string, status: TreeItemStatus) => void; onFileClick: (id: string) => void; @@ -48,25 +49,25 @@ const styles: StyleRulesCallback = theme => ({ export const CollectionPanelFiles = withStyles(styles)( - ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes, ...treeProps }: CollectionPanelFilesProps & WithStyles) => + ({ onItemMenuOpen, onOptionsMenuOpen, onUploadDataClick, classes, isWritable, ...treeProps }: CollectionPanelFilesProps & WithStyles) => Upload data - + } /> - + onOptionsMenuOpen(ev, isWritable)}> @@ -79,5 +80,5 @@ export const CollectionPanelFiles = File size - + onItemMenuOpen(ev, item, isWritable)} {...treeProps} /> ); diff --git a/src/components/file-tree/file-tree.tsx b/src/components/file-tree/file-tree.tsx index ad7ac73e..34a11cd6 100644 --- a/src/components/file-tree/file-tree.tsx +++ b/src/components/file-tree/file-tree.tsx @@ -26,7 +26,7 @@ export class FileTree extends React.Component { onContextMenu={this.handleContextMenu} toggleItemActive={this.handleToggleActive} toggleItemOpen={this.handleToggle} - toggleItemSelection={this.handleSelectionChange} + toggleItemSelection={this.handleSelectionChange} currentItemUuid={this.props.currentItemUuid} />; } diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx index a3d01e94..163010e4 100644 --- a/src/components/icon/icon.tsx +++ b/src/components/icon/icon.tsx @@ -53,6 +53,7 @@ import SettingsEthernet from '@material-ui/icons/SettingsEthernet'; import Star from '@material-ui/icons/Star'; import StarBorder from '@material-ui/icons/StarBorder'; import Warning from '@material-ui/icons/Warning'; +import Visibility from '@material-ui/icons/Lock'; import VpnKey from '@material-ui/icons/VpnKey'; export type IconType = React.SFC<{ className?: string, style?: object }>; @@ -96,6 +97,7 @@ export const ProcessIcon: IconType = (props) => ; export const ProjectIcon: IconType = (props) => ; export const ProjectsIcon: IconType = (props) => ; export const ProvenanceGraphIcon: IconType = (props) => ; +export const ReadOnlyIcon: IconType = (props) => ; export const RemoveIcon: IconType = (props) => ; export const RemoveFavoriteIcon: IconType = (props) => ; export const PublicFavoriteIcon: IconType = (props) => ; diff --git a/src/index.tsx b/src/index.tsx index d428b1c3..16759b7f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -24,10 +24,10 @@ import { rootProjectActionSet } from "~/views-components/context-menu/action-set import { projectActionSet } 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 } from '~/views-components/context-menu/action-sets/collection-files-action-set'; -import { collectionFilesItemActionSet } from '~/views-components/context-menu/action-sets/collection-files-item-action-set'; +import { collectionFilesActionSet, readOnlyCollectionFilesActionSet } from '~/views-components/context-menu/action-sets/collection-files-action-set'; +import { collectionFilesItemActionSet, readOnlyCollectionFilesItemActionSet } from '~/views-components/context-menu/action-sets/collection-files-item-action-set'; import { collectionFilesNotSelectedActionSet } from '~/views-components/context-menu/action-sets/collection-files-not-selected-action-set'; -import { collectionActionSet } from '~/views-components/context-menu/action-sets/collection-action-set'; +import { collectionActionSet, readOnlyCollectionActionSet } from '~/views-components/context-menu/action-sets/collection-action-set'; import { collectionResourceActionSet } from '~/views-components/context-menu/action-sets/collection-resource-action-set'; import { processActionSet } from '~/views-components/context-menu/action-sets/process-action-set'; import { loadWorkbench } from '~/store/workbench/workbench-actions'; @@ -70,10 +70,13 @@ addMenuActionSet(ContextMenuKind.PROJECT, projectActionSet); addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet); addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet); +addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES, readOnlyCollectionFilesActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_FILES_NOT_SELECTED, collectionFilesNotSelectedActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_FILES_ITEM, collectionFilesItemActionSet); +addMenuActionSet(ContextMenuKind.READONLY_COLLECTION_FILES_ITEM, readOnlyCollectionFilesItemActionSet); addMenuActionSet(ContextMenuKind.COLLECTION, collectionActionSet); addMenuActionSet(ContextMenuKind.COLLECTION_RESOURCE, collectionResourceActionSet); +addMenuActionSet(ContextMenuKind.READONLY_COLLECTION, readOnlyCollectionActionSet); addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet); addMenuActionSet(ContextMenuKind.PROCESS, processActionSet); addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet); diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index 431d15e8..2ba6bc2c 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -55,7 +55,7 @@ export const openContextMenu = (event: React.MouseEvent, resource: ); }; -export const openCollectionFilesContextMenu = (event: React.MouseEvent) => +export const openCollectionFilesContextMenu = (event: React.MouseEvent, isWritable: boolean) => (dispatch: Dispatch, getState: () => RootState) => { const isCollectionFileSelected = JSON.stringify(getState().collectionPanelFiles).includes('"selected":true'); dispatch(openContextMenu(event, { @@ -63,7 +63,11 @@ export const openCollectionFilesContextMenu = (event: React.MouseEvent resourcesActions.match(action, { - SET_RESOURCES: resources => resources.reduce((state, resource) => setResource(resource.uuid, resource)(state), state), - DELETE_RESOURCES: ids => ids.reduce((state, id) => deleteResource(id)(state), state), + SET_RESOURCES: resources => resources.reduce( + (state, resource) => setResource(resource.uuid, resource)(state), + state), + DELETE_RESOURCES: ids => ids.reduce( + (state, id) => deleteResource(id)(state), + state), default: () => state, }); \ No newline at end of file diff --git a/src/store/workbench/workbench-actions.ts b/src/store/workbench/workbench-actions.ts index dbf795b6..c6932558 100644 --- a/src/store/workbench/workbench-actions.ts +++ b/src/store/workbench/workbench-actions.ts @@ -283,7 +283,7 @@ export const loadCollection = (uuid: string) => OWNED: async collection => { dispatch(collectionPanelActions.SET_COLLECTION(collection as CollectionResource)); dispatch(updateResources([collection])); - await dispatch(activateSidePanelTreeItem(collection.ownerUuid)); + dispatch(activateSidePanelTreeItem(collection.ownerUuid)); dispatch(setSidePanelBreadcrumbs(collection.ownerUuid)); dispatch(loadCollectionPanel(collection.uuid)); }, @@ -301,7 +301,6 @@ export const loadCollection = (uuid: string) => dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH)); dispatch(loadCollectionPanel(collection.uuid)); }, - }); } }); diff --git a/src/views-components/collection-panel-files/collection-panel-files.ts b/src/views-components/collection-panel-files/collection-panel-files.ts index e5983b6b..eb16eb6c 100644 --- a/src/views-components/collection-panel-files/collection-panel-files.ts +++ b/src/views-components/collection-panel-files/collection-panel-files.ts @@ -52,11 +52,22 @@ const mapDispatchToProps = (dispatch: Dispatch): Pick { dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id })); }, - onItemMenuOpen: (event, item) => { - dispatch(openContextMenu(event, { menuKind: ContextMenuKind.COLLECTION_FILES_ITEM, kind: ResourceKind.COLLECTION, name: item.data.name, uuid: item.id, ownerUuid: '' })); + onItemMenuOpen: (event, item, isWritable) => { + dispatch(openContextMenu( + event, + { + menuKind: isWritable + ? ContextMenuKind.COLLECTION_FILES_ITEM + : ContextMenuKind.READONLY_COLLECTION_FILES_ITEM, + kind: ResourceKind.COLLECTION, + name: item.data.name, + uuid: item.id, + ownerUuid: '' + } + )); }, - onOptionsMenuOpen: (event) => { - dispatch(openCollectionFilesContextMenu(event)); + onOptionsMenuOpen: (event, isWritable) => { + dispatch(openCollectionFilesContextMenu(event, isWritable)); }, onFileClick: (id) => { dispatch(openDetailsPanel(id)); 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 9629f028..ea97a9b1 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 @@ -16,21 +16,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 collectionActionSet: ContextMenuActionSet = [[ - { - icon: RenameIcon, - name: "Edit collection", - execute: (dispatch, resource) => { - dispatch(openCollectionUpdateDialog(resource)); - } - }, - { - icon: ShareIcon, - name: "Share", - execute: (dispatch, { uuid }) => { - dispatch(openSharingDialog(uuid)); - } - }, +export const readOnlyCollectionActionSet: ContextMenuActionSet = [[ { component: ToggleFavoriteAction, execute: (dispatch, resource) => { @@ -39,11 +25,6 @@ export const collectionActionSet: ContextMenuActionSet = [[ }); } }, - { - icon: MoveToIcon, - name: "Move to", - execute: (dispatch, resource) => dispatch(openMoveCollectionDialog(resource)) - }, { icon: CopyIcon, name: "Copy to project", @@ -59,13 +40,6 @@ export const collectionActionSet: ContextMenuActionSet = [[ dispatch(toggleDetailsPanel()); } }, - // { - // icon: ProvenanceGraphIcon, - // name: "Provenance graph", - // execute: (dispatch, resource) => { - // // add code - // } - // }, { icon: AdvancedIcon, name: "Advanced", @@ -73,17 +47,32 @@ export const collectionActionSet: ContextMenuActionSet = [[ dispatch(openAdvancedTabDialog(resource.uuid)); } }, +]]; + +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!!)); } }, - // { - // icon: RemoveIcon, - // name: "Remove", - // execute: (dispatch, resource) => { - // // add code - // } - // } -]]; +]]); diff --git a/src/views-components/context-menu/action-sets/collection-files-action-set.ts b/src/views-components/context-menu/action-sets/collection-files-action-set.ts index 885f222c..fc0139c8 100644 --- a/src/views-components/context-menu/action-sets/collection-files-action-set.ts +++ b/src/views-components/context-menu/action-sets/collection-files-action-set.ts @@ -7,32 +7,42 @@ import { collectionPanelFilesAction, openMultipleFilesRemoveDialog } from "~/sto import { openCollectionPartialCopyDialog, openCollectionPartialCopyToSelectedCollectionDialog } from '~/store/collections/collection-partial-copy-actions'; import { DownloadCollectionFileAction } from "~/views-components/context-menu/actions/download-collection-file-action"; -export const collectionFilesActionSet: ContextMenuActionSet = [[{ - name: "Select all", - execute: dispatch => { - dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES()); +export const readOnlyCollectionFilesActionSet: ContextMenuActionSet = [[ + { + name: "Select all", + execute: dispatch => { + dispatch(collectionPanelFilesAction.SELECT_ALL_COLLECTION_FILES()); + } + }, + { + name: "Unselect all", + execute: dispatch => { + dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES()); + } + }, + { + component: DownloadCollectionFileAction, + execute: () => { return; } + }, + { + name: "Create a new collection with selected", + execute: dispatch => { + dispatch(openCollectionPartialCopyDialog()); + } + }, + { + name: "Copy selected into the collection", + execute: dispatch => { + dispatch(openCollectionPartialCopyToSelectedCollectionDialog()); + } } -}, { - name: "Unselect all", - execute: dispatch => { - dispatch(collectionPanelFilesAction.UNSELECT_ALL_COLLECTION_FILES()); - } -}, { - name: "Remove selected", - execute: dispatch => { - dispatch(openMultipleFilesRemoveDialog()); - } -}, { - component: DownloadCollectionFileAction, - execute: () => { return; } -}, { - name: "Create a new collection with selected", - execute: dispatch => { - dispatch(openCollectionPartialCopyDialog()); - } -}, { - name: "Copy selected into the collection", - execute: dispatch => { - dispatch(openCollectionPartialCopyToSelectedCollectionDialog()); - } -}]]; +]]; + +export const collectionFilesActionSet: ContextMenuActionSet = readOnlyCollectionFilesActionSet.concat([[ + { + name: "Remove selected", + execute: dispatch => { + dispatch(openMultipleFilesRemoveDialog()); + } + }, +]]); diff --git a/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts b/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts index 61603edf..4c6874c6 100644 --- a/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts +++ b/src/views-components/context-menu/action-sets/collection-files-item-action-set.ts @@ -9,22 +9,30 @@ import { openFileRemoveDialog, openRenameFileDialog } from '~/store/collection-p import { CollectionFileViewerAction } from '~/views-components/context-menu/actions/collection-file-viewer-action'; -export const collectionFilesItemActionSet: ContextMenuActionSet = [[{ - name: "Rename", - icon: RenameIcon, - execute: (dispatch, resource) => { - dispatch(openRenameFileDialog({ name: resource.name, id: resource.uuid })); +export const readOnlyCollectionFilesItemActionSet: ContextMenuActionSet = [[ + { + component: DownloadCollectionFileAction, + execute: () => { return; } + }, + { + component: CollectionFileViewerAction, + execute: () => { return; }, } -}, { - component: DownloadCollectionFileAction, - execute: () => { return; } -}, { - name: "Remove", - icon: RemoveIcon, - execute: (dispatch, resource) => { - dispatch(openFileRemoveDialog(resource.uuid)); +]]; + +export const collectionFilesItemActionSet: ContextMenuActionSet = readOnlyCollectionFilesItemActionSet.concat([[ + { + name: "Rename", + icon: RenameIcon, + execute: (dispatch, resource) => { + dispatch(openRenameFileDialog({ name: resource.name, id: resource.uuid })); + } + }, + { + name: "Remove", + icon: RemoveIcon, + execute: (dispatch, resource) => { + dispatch(openFileRemoveDialog(resource.uuid)); + } } -}], [{ - component: CollectionFileViewerAction, - execute: () => { return; }, -}]]; +]]); \ No newline at end of file diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index 65e98cc5..55b0abd8 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -70,11 +70,14 @@ export enum ContextMenuKind { FAVORITE = "Favorite", TRASH = "Trash", COLLECTION_FILES = "CollectionFiles", + READONLY_COLLECTION_FILES = "ReadOnlyCollectionFiles", COLLECTION_FILES_ITEM = "CollectionFilesItem", + READONLY_COLLECTION_FILES_ITEM = "ReadOnlyCollectionFilesItem", COLLECTION_FILES_NOT_SELECTED = "CollectionFilesNotSelected", COLLECTION = 'Collection', COLLECTION_ADMIN = 'CollectionAdmin', COLLECTION_RESOURCE = 'CollectionResource', + READONLY_COLLECTION = 'ReadOnlyCollection', TRASHED_COLLECTION = 'TrashedCollection', PROCESS = "Process", PROCESS_ADMIN = 'ProcessAdmin', diff --git a/src/views/collection-panel/collection-panel.tsx b/src/views/collection-panel/collection-panel.tsx index c4221937..64de885f 100644 --- a/src/views/collection-panel/collection-panel.tsx +++ b/src/views/collection-panel/collection-panel.tsx @@ -11,7 +11,7 @@ import { connect, DispatchProp } from "react-redux"; import { RouteComponentProps } from 'react-router'; import { ArvadosTheme } from '~/common/custom-theme'; import { RootState } from '~/store/store'; -import { MoreOptionsIcon, CollectionIcon } from '~/components/icon/icon'; +import { MoreOptionsIcon, CollectionIcon, ReadOnlyIcon } from '~/components/icon/icon'; import { DetailsAttribute } from '~/components/details-attribute/details-attribute'; import { CollectionResource } from '~/models/collection'; import { CollectionPanelFiles } from '~/views-components/collection-panel-files/collection-panel-files'; @@ -25,8 +25,11 @@ import { openDetailsPanel } from '~/store/details-panel/details-panel-action'; import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions'; import { getPropertyChip } from '~/views-components/resource-properties-form/property-chip'; import { IllegalNamingWarning } from '~/components/warning/warning'; +import { GroupResource } from '~/models/group'; +import { UserResource } from '~/models/user'; +import { getUserUuid } from '~/common/getuser'; -type CssRules = 'card' | 'iconHeader' | 'tag' | 'label' | 'value' | 'link'; +type CssRules = 'card' | 'iconHeader' | 'tag' | 'label' | 'value' | 'link' | 'centeredLabel'; const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ card: { @@ -43,6 +46,10 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ label: { fontSize: '0.875rem' }, + centeredLabel: { + fontSize: '0.875rem', + textAlign: 'center' + }, value: { textTransform: 'none', fontSize: '0.875rem' @@ -58,6 +65,7 @@ const styles: StyleRulesCallback = (theme: ArvadosTheme) => ({ interface CollectionPanelDataProps { item: CollectionResource; + isWritable: boolean; } type CollectionPanelProps = CollectionPanelDataProps & DispatchProp @@ -65,13 +73,22 @@ type CollectionPanelProps = CollectionPanelDataProps & DispatchProp export const CollectionPanel = withStyles(styles)( connect((state: RootState, props: RouteComponentProps<{ id: string }>) => { - const item = getResource(props.match.params.id)(state.resources); - return { item }; + const currentUserUUID = getUserUuid(state); + const item = getResource(props.match.params.id)(state.resources); + let isWritable = false; + if (item && item.ownerUuid === currentUserUUID) { + isWritable = true; + } else if (item) { + const itemOwner = getResource(item.ownerUuid)(state.resources); + if (itemOwner) { + isWritable = itemOwner.writableBy.indexOf(currentUserUUID || '') >= 0; + } + } + return { item, isWritable }; })( class extends React.Component { - render() { - const { classes, item, dispatch } = this.props; + const { classes, item, dispatch, isWritable } = this.props; return item ? <> @@ -81,7 +98,11 @@ export const CollectionPanel = withStyles(styles)( } - action={ + action={
+ {isWritable === false && + + + } - } - title={item && {item.name}} +
} + title={{item.name}} titleTypographyProps={this.titleProps} - subheader={item && item.description} + subheader={item.description} subheaderTypographyProps={this.titleProps} /> + linkToUuid={item.uuid} /> + linkToUuid={item.portableDataHash} /> + label='Number of files' value={item.fileCount} /> + label='Content size' value={formatFileSize(item.fileSizeTotal)} /> + label='Owner' linkToUuid={item.ownerUuid} /> {(item.properties.container_request || item.properties.containerRequest) && dispatch(navigateToProcess(item.properties.container_request || item.properties.containerRequest))}> @@ -123,28 +144,35 @@ export const CollectionPanel = withStyles(styles)( - + {isWritable && - + } - {Object.keys(item.properties).map(k => + { Object.keys(item.properties).length > 0 + ? Object.keys(item.properties).map(k => Array.isArray(item.properties[k]) ? item.properties[k].map((v: string) => getPropertyChip( k, v, - this.handleDelete(k, v), + isWritable + ? this.handleDelete(k, item.properties[k]) + : undefined, classes.tag)) : getPropertyChip( k, item.properties[k], - this.handleDelete(k, item.properties[k]), + isWritable + ? this.handleDelete(k, item.properties[k]) + : undefined, classes.tag) - )} + ) + :
No properties set on this collection.
+ }
- +
: null; @@ -152,15 +180,18 @@ export const CollectionPanel = withStyles(styles)( handleContextMenu = (event: React.MouseEvent) => { const { uuid, ownerUuid, name, description, kind, isTrashed } = this.props.item; + const { isWritable } = this.props; const resource = { uuid, ownerUuid, name, description, kind, - menuKind: isTrashed - ? ContextMenuKind.TRASHED_COLLECTION - : ContextMenuKind.COLLECTION + menuKind: isWritable + ? isTrashed + ? ContextMenuKind.TRASHED_COLLECTION + : ContextMenuKind.COLLECTION + : ContextMenuKind.READONLY_COLLECTION }; this.props.dispatch(openContextMenu(event, resource)); }