Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>
});
});
});
+
+ describe('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('Freeze').click();
+
+ cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick();
+
+ cy.get('[data-cy=context-menu]').contains('Freeze').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('Freeze').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 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('Freeze').should('not.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('Freeze').click();
+
+ cy.wait(1000);
+
+ cy.get('main').contains(adminProject.name).rightclick();
+
+ cy.get('[data-cy=context-menu]').contains('Unfreeze').click();
+
+ cy.get('main').contains(adminProject.name).rightclick();
+
+ cy.get('[data-cy=context-menu]').contains('Freeze').should('exist');
+ });
+ });
+ });
});
+
}
export interface ClusterConfigJSON {
+ API: {
+ UnfreezeProjectRequiresAdmin: boolean
+ },
ClusterID: string;
RemoteClusters: {
[key: string]: {
};
export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): ClusterConfigJSON => ({
+ API: {
+ UnfreezeProjectRequiresAdmin: false,
+ },
ClusterID: "",
RemoteClusters: {},
Services: {
--- /dev/null
+// 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
private static instance: ServicesProvider;
+ private store;
private services;
private constructor() {}
}
return this.services;
}
+
+ public setStore(newStore): void {
+ if (!this.store) {
+ this.store = newStore;
+ }
+ }
+
+ public getStore() {
+ if (!this.store) {
+ throw "Please check if store has been set in the index.ts before the app is initiated"; // eslint-disable-line no-throw-literal
+ }
+
+ return this.store;
+ }
}
export default ServicesProvider.getInstance();
describe("<Breadcrumbs />", () => {
let onClick: () => void;
+ let resources = {};
beforeEach(() => {
onClick = jest.fn();
const items = [
{ label: 'breadcrumb 1' }
];
- const breadcrumbs = shallow(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />).dive();
+ const breadcrumbs = shallow(<Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />).dive();
expect(breadcrumbs.find(Button)).toHaveLength(1);
expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(0);
});
{ label: 'breadcrumb 1' },
{ label: 'breadcrumb 2' }
];
- const breadcrumbs = shallow(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />).dive();
+ const breadcrumbs = shallow(<Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />).dive();
expect(breadcrumbs.find(Button)).toHaveLength(2);
expect(breadcrumbs.find(ChevronRightIcon)).toHaveLength(1);
});
{ label: 'breadcrumb 1' },
{ label: 'breadcrumb 2' }
];
- const breadcrumbs = shallow(<Breadcrumbs items={items} onClick={onClick} onContextMenu={jest.fn()} />).dive();
+ const breadcrumbs = shallow(<Breadcrumbs items={items} resources={resources} onClick={onClick} onContextMenu={jest.fn()} />).dive();
breadcrumbs.find(Button).at(1).simulate('click');
expect(onClick).toBeCalledWith(items[1]);
});
import ChevronRightIcon from '@material-ui/icons/ChevronRight';
import { withStyles } from '@material-ui/core';
import { IllegalNamingWarning } from '../warning/warning';
-import { IconType } from 'components/icon/icon';
+import { IconType, FreezeIcon } from 'components/icon/icon';
import grey from '@material-ui/core/colors/grey';
+import { ResourceBreadcrumb } from 'store/breadcrumbs/breadcrumbs-actions';
+import { ResourcesState } from 'store/resources/resources';
export interface Breadcrumb {
label: string;
icon?: IconType;
}
-type CssRules = "item" | "currentItem" | "label" | "icon";
+type CssRules = "item" | "currentItem" | "label" | "icon" | "frozenIcon";
const styles: StyleRulesCallback<CssRules> = theme => ({
item: {
},
icon: {
fontSize: 20,
- color: grey["600"]
+ color: grey["600"],
+ marginRight: '10px',
+ },
+ frozenIcon: {
+ fontSize: 20,
+ color: grey["600"],
+ marginLeft: '10px',
},
});
export interface BreadcrumbsProps {
- items: Breadcrumb[];
- onClick: (breadcrumb: Breadcrumb) => void;
- onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: Breadcrumb) => void;
+ items: ResourceBreadcrumb[];
+ resources: ResourcesState;
+ onClick: (breadcrumb: ResourceBreadcrumb) => void;
+ onContextMenu: (event: React.MouseEvent<HTMLElement>, breadcrumb: ResourceBreadcrumb) => void;
}
export const Breadcrumbs = withStyles(styles)(
- ({ classes, onClick, onContextMenu, items }: BreadcrumbsProps & WithStyles<CssRules>) =>
+ ({ classes, onClick, onContextMenu, items, resources }: BreadcrumbsProps & WithStyles<CssRules>) =>
<Grid container data-cy='breadcrumbs' alignItems="center" wrap="nowrap">
{
items.map((item, index) => {
className={classes.label}>
{item.label}
</Typography>
+ {
+ (resources[item.uuid] as any)?.frozenByUuid ? <FreezeIcon className={classes.frozenIcon} /> : null
+ }
</Button>
</Tooltip>
{!isLastItem && <ChevronRightIcon color="inherit" className={classes.item} />}
faEllipsisH,
);
+export const FreezeIcon = (props: any) =>
+ <span {...props}>
+ <span className='fas fa-snowflake' />
+ </span>
+
+export const UnfreezeIcon = (props: any) =>
+ <div {...props}>
+ <div className="fa-layers fa-1x fa-fw">
+ <span className="fas fa-slash"
+ data-fa-mask="fas fa-snowflake" data-fa-transform="down-1.5" />
+ <span className="fas fa-slash" />
+ </div>
+ </div>;
+
export const PendingIcon = (props: any) =>
<span {...props}>
<span className='fas fa-ellipsis-h' />
import React from 'react';
import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core";
import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
-import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon } from 'components/icon/icon';
+import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon, FreezeIcon } from 'components/icon/icon';
import { ReactElement } from "react";
import CircularProgress from '@material-ui/core/CircularProgress';
import classnames from "classnames";
| 'toggableIcon'
| 'checkbox'
| 'childItem'
- | 'childItemIcon';
+ | 'childItemIcon'
+ | 'frozenIcon';
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
list: {
active: {
color: theme.palette.primary.main,
},
+ frozenIcon: {
+ fontSize: 20,
+ color: theme.palette.grey["600"],
+ marginLeft: '10px',
+ },
});
export enum TreeItemStatus {
flatTree?: boolean;
status: TreeItemStatus;
items?: Array<TreeItem<T>>;
+ isFrozen?: boolean;
}
export interface TreeProps<T> {
<span style={{ fontSize: '0.875rem' }}>
{item.data.name}
</span>
+ {
+ !!item.data.frozenByUuid ? <FreezeIcon className={props.classes.frozenIcon} /> : null
+ }
</span>
</div>
</div>)
: () => this.props.showSelection ? true : false;
const { levelIndentation = 20, itemRightPadding = 20 } = this.props;
-
return <List className={list}>
{items && items.map((it: TreeItem<T>, idx: number) =>
<div key={`item/${level}/${it.id}`}>
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';
import { groupMemberActionSet } from 'views-components/context-menu/action-sets/group-member-action-set';
import { linkActionSet } from 'views-components/context-menu/action-sets/link-action-set';
import { loadFileViewersConfig } from 'store/file-viewers/file-viewers-actions';
-import { filterGroupAdminActionSet, projectAdminActionSet } from 'views-components/context-menu/action-sets/project-admin-action-set';
+import { filterGroupAdminActionSet, frozenAdminActionSet, projectAdminActionSet } from 'views-components/context-menu/action-sets/project-admin-action-set';
import { permissionEditActionSet } from 'views-components/context-menu/action-sets/permission-edit-action-set';
import { workflowActionSet } from 'views-components/context-menu/action-sets/workflow-action-set';
import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
+addMenuActionSet(ContextMenuKind.FROZEN_PROJECT, frozenActionSet);
+addMenuActionSet(ContextMenuKind.FROZEN_PROJECT_ADMIN, frozenAdminActionSet);
addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
addMenuActionSet(ContextMenuKind.PERMISSION_EDIT, permissionEditActionSet);
addMenuActionSet(ContextMenuKind.WORKFLOW, workflowActionSet);
const store = configureStore(history, services, config);
+ servicesProvider.setStore(store);
+
store.subscribe(initListener(history, store, services, config));
store.dispatch(initAuth(config));
store.dispatch(setBuildInfo());
import { GroupClass, GroupResource } from "./group";
export interface ProjectResource extends GroupResource {
+ frozenByUuid: null|string;
+ canManage: boolean;
groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE;
}
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';
+import { ProjectResource } from 'models/project';
export const contextMenuActions = unionize({
OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
isEditable?: boolean;
outputUuid?: string;
workflowUuid?: string;
+ isAdmin?: boolean;
+ isFrozen?: boolean;
storageClassesDesired?: string[];
properties?: { [key: string]: string | string[] };
};
description: res.description,
ownerUuid: res.ownerUuid,
isTrashed: ('isTrashed' in res) ? res.isTrashed : false,
+ isFrozen: !!(res as ProjectResource).frozenByUuid,
}));
}
};
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 (isFrozen) {
+ return isAdminUser ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT;
+ }
+
return (isAdminUser && !readonly)
? (resource && resource.groupClass !== GroupClass.FILTER)
? ContextMenuKind.PROJECT_ADMIN
? ContextMenuKind.OLD_VERSION_COLLECTION
: (isTrashed && isEditable)
? ContextMenuKind.TRASHED_COLLECTION
- : (isAdminUser && !readonly)
+ : (isAdminUser && isEditable)
? ContextMenuKind.COLLECTION_ADMIN
: isEditable
? ContextMenuKind.COLLECTION
: ContextMenuKind.READONLY_COLLECTION;
case ResourceKind.PROCESS:
- return (isAdminUser && !readonly)
+ return (isAdminUser && isEditable)
? ContextMenuKind.PROCESS_ADMIN
: readonly
? ContextMenuKind.READONLY_PROCESS_RESOURCE
--- /dev/null
+// 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 { loadResource } from "store/resources/resources-actions";
+import { RootState } from "store/store";
+
+export const freezeProject = (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());
+ dispatch<any>(loadResource(uuid, false));
+ return updatedProject;
+ };
+
+export const unfreezeProject = (uuid: string) =>
+ async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+
+ const updatedProject = await services.projectService.update(uuid, {
+ frozenByUuid: null
+ });
+
+ dispatch(projectPanelActions.REQUEST_ITEMS());
+ dispatch<any>(loadResource(uuid, false));
+ return updatedProject;
+ };
\ No newline at end of file
includeFiles?: boolean;
includeFilterGroups?: boolean;
loadShared?: boolean;
+ options?: { showOnlyOwned: boolean; showOnlyWritable: boolean; };
}
export const loadProject = (params: LoadProjectParams) =>
async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
- const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false } = params;
+ const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false, options } = params;
dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
return false;
}
+
+ if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+ return false;
+ }
+
return true;
}),
extractNodeData: item => ({
}));
}
};
-export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false) =>
+export const loadUserProject = (pickerId: string, includeCollections = false, includeFiles = false, options?: { showOnlyOwned: boolean, showOnlyWritable: boolean } ) =>
async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
const uuid = getUserUuid(getState());
if (uuid) {
- dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles }));
+ dispatch(loadProject({ id: uuid, pickerId, includeCollections, includeFiles, options }));
}
};
pickerId: string;
includeCollections?: boolean;
includeFiles?: boolean;
+ options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
}
export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
return false;
}
+ if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+ return false;
+ }
+
return true;
}),
extractNodeData: item => ({
dispatch<any>(receiveTreePickerData<LinkResource>({
id: 'Public Favorites',
pickerId,
- data: items,
+ data: items.filter(item => {
+ if (params.options && params.options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as any).frozenByUuid) {
+ return false;
+ }
+
+ return true;
+ }),
extractNodeData: item => ({
id: item.headUuid,
value: item,
// SPDX-License-Identifier: AGPL-3.0
import { connect } from "react-redux";
-import { Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs';
+import { Breadcrumb, Breadcrumbs as BreadcrumbsComponent, BreadcrumbsProps } from 'components/breadcrumbs/breadcrumbs';
import { RootState } from 'store/store';
import { Dispatch } from 'redux';
import { navigateTo } from 'store/navigation/navigation-action';
import { getProperty } from '../../store/properties/properties';
import { ResourceBreadcrumb, BREADCRUMBS } from '../../store/breadcrumbs/breadcrumbs-actions';
import { openSidePanelContextMenu } from 'store/context-menu/context-menu-actions';
+import { ProjectResource } from "models/project";
-type BreadcrumbsDataProps = Pick<BreadcrumbsProps, 'items'>;
+type BreadcrumbsDataProps = Pick<BreadcrumbsProps, 'items' | 'resources'>;
type BreadcrumbsActionProps = Pick<BreadcrumbsProps, 'onClick' | 'onContextMenu'>;
-const mapStateToProps = () => ({ properties }: RootState): BreadcrumbsDataProps => ({
- items: getProperty<ResourceBreadcrumb[]>(BREADCRUMBS)(properties) || []
+const mapStateToProps = () => ({ properties, resources }: RootState): BreadcrumbsDataProps => ({
+ items: (getProperty<ResourceBreadcrumb[]>(BREADCRUMBS)(properties) || []),
+ resources,
});
const mapDispatchToProps = (dispatch: Dispatch): BreadcrumbsActionProps => ({
- onClick: ({ uuid }: ResourceBreadcrumb) => {
+ onClick: ({ uuid }: Breadcrumb & ProjectResource) => {
dispatch<any>(navigateTo(uuid));
},
- onContextMenu: (event, breadcrumb: ResourceBreadcrumb) => {
+ onContextMenu: (event, breadcrumb: Breadcrumb & ProjectResource) => {
dispatch<any>(openSidePanelContextMenu(event, breadcrumb.uuid));
}
});
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 { freezeProject, unfreezeProject } from "store/projects/project-lock-actions";
-export const readOnlyProjectActionSet: ContextMenuActionSet = [[
- {
- component: ToggleFavoriteAction,
- name: 'ToggleFavoriteAction',
- execute: (dispatch, resource) => {
- dispatch<any>(toggleFavorite(resource)).then(() => {
- dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
- });
- }
- },
- {
- icon: OpenIcon,
- name: "Open in new tab",
- execute: (dispatch, resource) => {
- dispatch<any>(openInNewTabAction(resource));
- }
- },
- {
- icon: Link,
- name: "Copy to clipboard",
- execute: (dispatch, resource) => {
- dispatch<any>(copyToClipboardAction(resource));
- }
- },
- {
- icon: DetailsIcon,
- name: "View details",
- execute: dispatch => {
- dispatch<any>(toggleDetailsPanel());
- }
- },
- {
- icon: AdvancedIcon,
- name: "Advanced",
- execute: (dispatch, resource) => {
- dispatch<any>(openAdvancedTabDialog(resource.uuid));
- }
- },
- {
- icon: FolderSharedIcon,
- name: "Open with 3rd party client",
- execute: (dispatch, resource) => {
- dispatch<any>(openWebDavS3InfoDialog(resource.uuid));
+export const toggleFavoriteAction = {
+ component: ToggleFavoriteAction,
+ name: 'ToggleFavoriteAction',
+ execute: (dispatch, resource) => {
+ dispatch(toggleFavorite(resource)).then(() => {
+ dispatch(favoritePanelActions.REQUEST_ITEMS());
+ });
+ }
+};
+
+export const openInNewTabMenuAction = {
+ icon: OpenIcon,
+ name: "Open in new tab",
+ execute: (dispatch, resource) => {
+ dispatch(openInNewTabAction(resource));
+ }
+};
+
+export const copyToClipboardMenuAction = {
+ icon: Link,
+ name: "Copy to clipboard",
+ execute: (dispatch, resource) => {
+ dispatch(copyToClipboardAction(resource));
+ }
+};
+
+export const viewDetailsAction = {
+ icon: DetailsIcon,
+ name: "View details",
+ execute: dispatch => {
+ dispatch(toggleDetailsPanel());
+ }
+}
+
+export const advancedAction = {
+ icon: AdvancedIcon,
+ name: "Advanced",
+ execute: (dispatch, resource) => {
+ dispatch(openAdvancedTabDialog(resource.uuid));
+ }
+}
+
+export const openWith3rdPartyClientAction = {
+ icon: FolderSharedIcon,
+ name: "Open with 3rd party client",
+ execute: (dispatch, resource) => {
+ dispatch(openWebDavS3InfoDialog(resource.uuid));
+ }
+}
+
+export const editProjectAction = {
+ icon: RenameIcon,
+ name: "Edit project",
+ execute: (dispatch, resource) => {
+ dispatch(openProjectUpdateDialog(resource));
+ }
+}
+
+export const shareAction = {
+ icon: ShareIcon,
+ name: "Share",
+ execute: (dispatch, { uuid }) => {
+ dispatch(openSharingDialog(uuid));
+ }
+}
+
+export const moveToAction = {
+ icon: MoveToIcon,
+ name: "Move to",
+ execute: (dispatch, resource) => {
+ dispatch(openMoveProjectDialog(resource));
+ }
+}
+
+export const toggleTrashAction = {
+ component: ToggleTrashAction,
+ name: 'ToggleTrashAction',
+ execute: (dispatch, resource) => {
+ dispatch(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!));
+ }
+}
+
+export const freezeProjectAction = {
+ component: ToggleLockAction,
+ name: 'ToggleLockAction',
+ execute: (dispatch, resource) => {
+ if (resource.isFrozen) {
+ dispatch(unfreezeProject(resource.uuid));
+ } else {
+ dispatch(freezeProject(resource.uuid));
}
- },
+ }
+}
+
+export const newProjectAction: any = {
+ icon: NewProjectIcon,
+ name: "New project",
+ execute: (dispatch, resource): void => {
+ dispatch(openProjectCreateDialog(resource.uuid));
+ }
+}
+
+export const readOnlyProjectActionSet: ContextMenuActionSet = [[
+ toggleFavoriteAction,
+ openInNewTabMenuAction,
+ copyToClipboardMenuAction,
+ viewDetailsAction,
+ advancedAction,
+ openWith3rdPartyClientAction,
+]];
+
+export const filterGroupActionSet: ContextMenuActionSet = [[
+ toggleFavoriteAction,
+ openInNewTabMenuAction,
+ copyToClipboardMenuAction,
+ viewDetailsAction,
+ advancedAction,
+ openWith3rdPartyClientAction,
+ editProjectAction,
+ shareAction,
+ moveToAction,
+ toggleTrashAction,
]];
-export const filterGroupActionSet: ContextMenuActionSet = [
- [
- ...readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []),
- {
- 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,
- name: 'ToggleTrashAction',
- execute: (dispatch, resource) => {
- dispatch<any>(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!));
- }
- },
- ]
-];
-
-export const projectActionSet: ContextMenuActionSet = [
- [
- ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
- {
- icon: NewProjectIcon,
- name: "New project",
- execute: (dispatch, resource) => {
- dispatch<any>(openProjectCreateDialog(resource.uuid));
- }
- },
- ]
-];
+export const frozenActionSet: ContextMenuActionSet = [[
+ shareAction,
+ toggleFavoriteAction,
+ openInNewTabMenuAction,
+ copyToClipboardMenuAction,
+ viewDetailsAction,
+ advancedAction,
+ openWith3rdPartyClientAction,
+ freezeProjectAction
+]];
+
+export const projectActionSet: ContextMenuActionSet = [[
+ toggleFavoriteAction,
+ openInNewTabMenuAction,
+ copyToClipboardMenuAction,
+ viewDetailsAction,
+ advancedAction,
+ openWith3rdPartyClientAction,
+ editProjectAction,
+ shareAction,
+ moveToAction,
+ toggleTrashAction,
+ newProjectAction,
+ freezeProjectAction,
+]];
import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
-import { projectActionSet, filterGroupActionSet } from "views-components/context-menu/action-sets/project-action-set";
+import { shareAction, toggleFavoriteAction, openInNewTabMenuAction, copyToClipboardMenuAction, viewDetailsAction, advancedAction, openWith3rdPartyClientAction, freezeProjectAction, editProjectAction, moveToAction, toggleTrashAction, newProjectAction } from "views-components/context-menu/action-sets/project-action-set";
+
+export const togglePublicFavoriteAction = {
+ component: TogglePublicFavoriteAction,
+ name: 'TogglePublicFavoriteAction',
+ execute: (dispatch, resource) => {
+ dispatch(togglePublicFavorite(resource)).then(() => {
+ dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+ });
+}}
export const projectAdminActionSet: ContextMenuActionSet = [[
- ...projectActionSet.reduce((prev, next) => prev.concat(next), []),
- {
- component: TogglePublicFavoriteAction,
- name: 'TogglePublicFavoriteAction',
- execute: (dispatch, resource) => {
- dispatch<any>(togglePublicFavorite(resource)).then(() => {
- dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
- });
- }
- }
+ toggleFavoriteAction,
+ openInNewTabMenuAction,
+ copyToClipboardMenuAction,
+ viewDetailsAction,
+ advancedAction,
+ openWith3rdPartyClientAction,
+ editProjectAction,
+ shareAction,
+ moveToAction,
+ toggleTrashAction,
+ newProjectAction,
+ freezeProjectAction,
+ togglePublicFavoriteAction
]];
export const filterGroupAdminActionSet: ContextMenuActionSet = [[
- ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
- {
- component: TogglePublicFavoriteAction,
- name: 'TogglePublicFavoriteAction',
- execute: (dispatch, resource) => {
- dispatch<any>(togglePublicFavorite(resource)).then(() => {
- dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
- });
- }
- }
+ toggleFavoriteAction,
+ openInNewTabMenuAction,
+ copyToClipboardMenuAction,
+ viewDetailsAction,
+ advancedAction,
+ openWith3rdPartyClientAction,
+ editProjectAction,
+ shareAction,
+ moveToAction,
+ toggleTrashAction,
+ togglePublicFavoriteAction
+]];
+
+
+export const frozenAdminActionSet: ContextMenuActionSet = [[
+ shareAction,
+ togglePublicFavoriteAction,
+ toggleFavoriteAction,
+ openInNewTabMenuAction,
+ copyToClipboardMenuAction,
+ viewDetailsAction,
+ advancedAction,
+ openWith3rdPartyClientAction,
+ freezeProjectAction
]];
--- /dev/null
+// 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 { FreezeIcon, UnfreezeIcon } 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";
+import { resourceIsFrozen } from "common/frozen-resources";
+
+const mapStateToProps = (state: RootState, props: { onClick: () => {} }) => ({
+ isAdmin: !!state.auth.user?.isAdmin,
+ isLocked: !!(state.resources[state.contextMenu.resource!.uuid] as ProjectResource).frozenByUuid,
+ canManage: (state.resources[state.contextMenu.resource!.uuid] as ProjectResource).canManage,
+ canUnfreeze: !state.auth.remoteHostsConfig[state.auth.homeCluster]?.clusterConfig?.API?.UnfreezeProjectRequiresAdmin,
+ resource: state.contextMenu.resource,
+ resources: state.resources,
+ onClick: props.onClick
+});
+
+export const ToggleLockAction = withRouter(connect(mapStateToProps)((props: {
+ resource: any,
+ resources: any,
+ onClick: () => void,
+ state: RootState, isAdmin: boolean, isLocked: boolean, canManage: boolean, canUnfreeze: boolean,
+} & RouteComponentProps) =>
+ (props.canManage && !props.isLocked) || (props.isLocked && props.canManage && (props.canUnfreeze || props.isAdmin)) ?
+ resourceIsFrozen(props.resource, props.resources) ? null :
+ <ListItem
+ button
+ onClick={props.onClick} >
+ <ListItemIcon>
+ {props.isLocked
+ ? <UnfreezeIcon />
+ : <FreezeIcon />}
+ </ListItemIcon>
+ <ListItemText style={{ textDecoration: 'none' }}>
+ {props.isLocked
+ ? <>Unfreeze project</>
+ : <>Freeze project</>}
+ </ListItemText>
+ </ListItem > : null));
import { Dispatch } from "redux";
import { ContextMenuItem } from "components/context-menu/context-menu";
import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { RootState } from "store/store";
export interface ContextMenuAction extends ContextMenuItem {
- execute(dispatch: Dispatch, resource: ContextMenuResource): void;
+ execute(dispatch: Dispatch, resource: ContextMenuResource, state?: any): void;
}
export type ContextMenuActionSet = Array<Array<ContextMenuAction>>;
PROJECT = "Project",
FILTER_GROUP = "FilterGroup",
READONLY_PROJECT = 'ReadOnlyProject',
+ FROZEN_PROJECT = 'FrozenProject',
+ FROZEN_PROJECT_ADMIN = 'FrozenProjectAdmin',
PROJECT_ADMIN = "ProjectAdmin",
FILTER_GROUP_ADMIN = "FilterGroupAdmin",
RESOURCE = "Resource",
import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
import { Resource, ResourceKind, TrashableResource } from 'models/resource';
import {
+ FreezeIcon,
ProjectIcon,
FilterGroupIcon,
CollectionIcon,
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) => {
<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>}>
+ <FreezeIcon style={{ fontSize: "inherit" }}/>
+ </Tooltip>;
+ } else {
+ return null;
+ }
+}
+
export const ResourceName = connect(
(state: RootState, props: { uuid: string }) => {
const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
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 === '') {
export const HomeTreePicker = connect(() => ({
rootItemIcon: ProjectIcon,
}), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
- loadRootItem: (_, pickerId, includeCollections, includeFiles) => {
- dispatch<any>(loadUserProject(pickerId, includeCollections, includeFiles));
+ loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => {
+ dispatch<any>(loadUserProject(pickerId, includeCollections, includeFiles, options));
},
}))(ProjectsTreePicker);
\ No newline at end of file
export const PublicFavoritesTreePicker = connect(() => ({
rootItemIcon: PublicFavoriteIcon,
}), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
- loadRootItem: (_, pickerId, includeCollections, includeFiles) => {
- dispatch<any>(loadPublicFavoritesProject({ pickerId, includeCollections, includeFiles }));
+ loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => {
+ dispatch<any>(loadPublicFavoritesProject({ pickerId, includeCollections, includeFiles, options }));
},
}))(ProjectsTreePicker);
\ No newline at end of file
export const SharedTreePicker = connect(() => ({
rootItemIcon: ShareMeIcon,
}), (dispatch: Dispatch): Pick<ProjectsTreePickerProps, 'loadRootItem'> => ({
- loadRootItem: (_, pickerId, includeCollections, includeFiles) => {
- dispatch<any>(loadProject({ id: 'Shared with me', pickerId, includeCollections, includeFiles, loadShared: true }));
+ loadRootItem: (_, pickerId, includeCollections, includeFiles, options) => {
+ dispatch<any>(loadProject({ id: 'Shared with me', pickerId, includeCollections, includeFiles, loadShared: true, options }));
},
}))(ProjectsTreePicker);
\ No newline at end of file
import { pluginConfig } from 'plugins';
import { ElementListReducer } from 'common/plugintypes';
import { Location } from 'history';
+import { ProjectResource } from 'models/project';
type CssRules = 'button' | 'menuItem' | 'icon';
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;
import { getNodeChildrenIds, Tree as Ttree, createTree, getNode, TreeNodeStatus } from 'models/tree';
import { Dispatch } from "redux";
import { initTreeNode } from '../../models/tree';
+import { ResourcesState } from "store/resources/resources";
type Callback<T> = (event: React.MouseEvent<HTMLElement>, item: TreeItem<T>, pickerId: string) => void;
export interface TreePickerProps<T> {
return item;
};
-const memoizedMapStateToProps = () => {
- let prevTree: Ttree<any>;
- let mappedProps: Pick<TreeProps<any>, 'items' | 'disableRipple' | 'itemsMap'>;
- return <T>(state: RootState, props: TreePickerProps<T>): Pick<TreeProps<T>, 'items' | 'disableRipple' | 'itemsMap'> => {
+const mapStateToProps =
+ <T>(state: RootState, props: TreePickerProps<T>): Pick<TreeProps<T>, 'items' | 'disableRipple' | 'itemsMap'> => {
const itemsIdMap: Map<string, TreeItem<T>> = new Map();
const tree = state.treePicker[props.pickerId] || createTree();
- if (tree !== prevTree) {
- prevTree = tree;
- mappedProps = {
- disableRipple: true,
- items: getNodeChildrenIds('')(tree)
- .map(treePickerToTreeItems(tree))
- .map(item => addToItemsIdMap(item, itemsIdMap))
- .map(parentItem => ({
- ...parentItem,
- flatTree: true,
- items: flatTree(itemsIdMap, 2, parentItem.items || []),
- })),
- itemsMap: itemsIdMap,
- };
- }
- return mappedProps;
+
+ return {
+ disableRipple: true,
+ items: getNodeChildrenIds('')(tree)
+ .map(treePickerToTreeItems(tree, state.resources))
+ .map(item => addToItemsIdMap(item, itemsIdMap))
+ .map(parentItem => ({
+ ...parentItem,
+ flatTree: true,
+ items: flatTree(itemsIdMap, 2, parentItem.items || []),
+ })),
+ itemsMap: itemsIdMap,
+ };
};
-};
const mapDispatchToProps = (_: Dispatch, props: TreePickerProps<any>): Pick<TreeProps<any>, 'onContextMenu' | 'toggleItemOpen' | 'toggleItemActive' | 'toggleItemSelection'> => ({
onContextMenu: (event, item) => props.onContextMenu(event, item, props.pickerId),
toggleItemSelection: (event, item) => props.toggleItemSelection(event, item, props.pickerId),
});
-export const TreePicker = connect(memoizedMapStateToProps(), mapDispatchToProps)(Tree);
+export const TreePicker = connect(mapStateToProps, mapDispatchToProps)(Tree);
-const treePickerToTreeItems = (tree: Ttree<any>) =>
+const treePickerToTreeItems = (tree: Ttree<any>, resources: ResourcesState) =>
(id: string): TreeItem<any> => {
const node = getNode(id)(tree) || initTreeNode({ id: '', value: 'InvalidNode' });
const items = getNodeChildrenIds(node.id)(tree)
- .map(treePickerToTreeItems(tree));
+ .map(treePickerToTreeItems(tree, resources));
return {
active: node.active,
- data: node.value,
+ data: resources[node.id] || node.value,
id: node.id,
items: items.length > 0 ? items : undefined,
open: node.expanded,
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";
}
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;
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 : {},