Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>
# SPDX-License-Identifier: AGPL-3.0
REACT_APP_ARVADOS_CONFIG_URL=/config.json
-REACT_APP_ARVADOS_API_HOST=c97qk.arvadosapi.com
-HTTPS=true
\ No newline at end of file
+REACT_APP_ARVADOS_API_HOST=ce8i5.arvadosapi.com
+#
+#127.0.0.1:9999
+HTTPS=false
\ No newline at end of file
});
});
+ 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');
+ });
+ });
+ });
+
it('copies project URL to clipboard', () => {
const projectName = `Test project (${Math.floor(999999 * Math.random())})`;
});
});
+
}
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} />}
const currentPDH = (collectionPanel.item || {}).portableDataHash;
React.useEffect(() => {
if (currentPDH) {
- // Avoid fetching the same content level twice
- if (leftKey !== rightKey) {
- fetchData([leftKey, rightKey], true);
- } else {
- fetchData(rightKey, true);
- }
+ fetchData([leftKey, rightKey], true);
}
}, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps
: <div className={classes.rowEmpty}>No directories available</div>
}}
</AutoSizer>
- : <div className={classes.row}><CircularProgress className={classes.loader} size={30} /></div> }
+ : <div data-cy="collection-loader" className={classes.row}><CircularProgress className={classes.loader} size={30} /></div> }
</div>
</div>
<div className={classes.rightPanel} data-cy="collection-files-right-panel">
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
export const snackbarActions = unionize({
OPEN_SNACKBAR: ofType<{message: string; hideDuration?: number, kind?: SnackbarKind, link?: string}>(),
- CLOSE_SNACKBAR: ofType<{}>(),
+ CLOSE_SNACKBAR: ofType<{}|null>(),
SHIFT_MESSAGES: ofType<{}>()
});
})
};
},
- CLOSE_SNACKBAR: () => ({
- ...state,
- open: false
- }),
+ CLOSE_SNACKBAR: (payload) => {
+ let newMessages: any = [...state.messages];// state.messages.filter(({ message }) => message !== payload);
+
+ if (payload === undefined || JSON.stringify(payload) === '{}') {
+ newMessages.pop();
+ } else {
+ newMessages = state.messages.filter((message, index) => index !== payload);
+ }
+
+ return {
+ ...state,
+ messages: newMessages,
+ open: newMessages.length > 0
+ }
+ },
SHIFT_MESSAGES: () => {
const messages = state.messages.filter((m, idx) => idx > 0);
return {
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 }));
)(new FilterBuilder());
const { items } = await services.groupsService.contents(loadShared ? '' : id, { filters, excludeHomeProject: loadShared || undefined });
-
dispatch<any>(receiveTreePickerData<GroupContentsResource>({
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: "API Details",
- 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 === '') {
import { DetailsData } from "./details-data";
import { CollectionDetailsAttributes } from 'views/collection-panel/collection-panel';
import { RootState } from 'store/store';
-import { filterResources, getResource } from 'store/resources/resources';
+import { filterResources, getResource, ResourcesState } from 'store/resources/resources';
import { connect } from 'react-redux';
import { Button, Grid, ListItem, StyleRulesCallback, Typography, withStyles, WithStyles } from '@material-ui/core';
import { formatDate, formatFileSize } from 'common/formatters';
import { navigateTo } from 'store/navigation/navigation-action';
import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
import { openCollectionUpdateDialog } from 'store/collections/collection-update-actions';
+import { resourceIsFrozen } from 'common/frozen-resources';
export type CssRules = 'versionBrowserHeader'
| 'versionBrowserItem'
}
interface CollectionInfoDataProps {
+ resources: ResourcesState;
currentCollection: CollectionResource | undefined;
}
const ciMapStateToProps = (state: RootState): CollectionInfoDataProps => {
return {
+ resources: state.resources,
currentCollection: getResource<CollectionResource>(state.detailsPanel.resourceUuid)(state.resources),
};
};
const CollectionInfo = withStyles(styles)(
connect(ciMapStateToProps, ciMapDispatchToProps)(
- ({ currentCollection, editCollection, classes }: CollectionInfoProps) =>
+ ({ currentCollection, resources, editCollection, classes }: CollectionInfoProps) =>
currentCollection !== undefined
? <div>
<Button
+ disabled={resourceIsFrozen(currentCollection, resources)}
className={classes.editButton} variant='contained'
data-cy='details-panel-edit-btn' color='primary' size='small'
onClick={() => editCollection(currentCollection)}>
import { toggleDetailsPanel, SLIDE_TIMEOUT, openDetailsPanel } from 'store/details-panel/details-panel-action';
import { FileDetails } from 'views-components/details-panel/file-details';
import { getNode } from 'models/tree';
+import { resourceIsFrozen } from 'common/frozen-resources';
type CssRules = 'root' | 'container' | 'opened' | 'headerContainer' | 'headerIcon' | 'tabContainer';
const file = resource
? undefined
: getNode(detailsPanel.resourceUuid)(collectionPanelFiles);
+
+ let isFrozen = false;
+ if (resource) {
+ isFrozen = resourceIsFrozen(resource, resources);
+ }
+
return {
+ isFrozen,
authConfig: auth.config,
isOpened: detailsPanel.isOpened,
tabNr: detailsPanel.tabNr,
isOpened: boolean;
tabNr: number;
res: DetailsResource;
+ isFrozen: boolean;
}
type DetailsPanelProps = DetailsPanelDataProps & WithStyles<CssRules>;
import { ResourceWithName } from '../data-explorer/renderers';
import { GroupClass } from "models/group";
import { openProjectUpdateDialog, ProjectUpdateFormDialogData } from 'store/projects/project-update-actions';
+import { RootState } from 'store/store';
+import { ResourcesState } from 'store/resources/resources';
+import { resourceIsFrozen } from 'common/frozen-resources';
export class ProjectDetails extends DetailsData<ProjectResource> {
getIcon(className?: string) {
onClick: (prj: ProjectUpdateFormDialogData) => () => void;
}
+const mapStateToProps = (state: RootState): { resources: ResourcesState } => {
+ return {
+ resources: state.resources
+ };
+};
+
const mapDispatchToProps = (dispatch: Dispatch) => ({
onClick: (prj: ProjectUpdateFormDialogData) =>
() => dispatch<any>(openProjectUpdateDialog(prj)),
type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDetailsComponentActionProps & WithStyles<CssRules>;
-const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
+const ProjectDetailsComponent = connect(mapStateToProps, mapDispatchToProps)(
withStyles(styles)(
- ({ classes, project, onClick }: ProjectDetailsComponentProps) => <div>
+ ({ classes, project, resources, onClick }: ProjectDetailsComponentProps & { resources: ResourcesState }) => <div>
{project.groupClass !== GroupClass.FILTER ?
<Button onClick={onClick({
uuid: project.uuid,
description: project.description,
properties: project.properties,
})}
+ disabled={resourceIsFrozen(project, resources)}
className={classes.editButton} variant='contained'
data-cy='details-panel-edit-btn' color='primary' size='small'>
<RenameIcon className={classes.editIcon} /> Edit
dispatch<any>(
data.kind === ResourceKind.COLLECTION
? loadCollection(id, pickerId)
- : loadProject({ id, pickerId, includeCollections, includeFiles })
+ : loadProject({ id, pickerId, includeCollections, includeFiles, options })
);
} else if (!('type' in data) && loadRootItem) {
loadRootItem(item as TreeItem<ProjectsTreePickerRootItem>, pickerId, includeCollections, includeFiles, options);
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
// SPDX-License-Identifier: AGPL-3.0
import React from 'react';
-import { values, memoize, pipe } from 'lodash/fp';
+import { values, pipe } from 'lodash/fp';
import { HomeTreePicker } from 'views-components/projects-tree-picker/home-tree-picker';
import { SharedTreePicker } from 'views-components/projects-tree-picker/shared-tree-picker';
import { FavoritesTreePicker } from 'views-components/projects-tree-picker/favorites-tree-picker';
</div>;
};
-const getRelatedTreePickers = memoize(pipe(getProjectsTreePickerIds, values));
+const getRelatedTreePickers = pipe(getProjectsTreePickerIds, values);
const disableActivation = [SHARED_PROJECT_ID, FAVORITES_PROJECT_ID];
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;
export const SidePanelTree = connect(undefined, mapDispatchToProps)(
(props: SidePanelTreeActionProps) =>
- <span data-cy="side-panel-tree">
+ <div data-cy="side-panel-tree">
<TreePicker {...props} render={renderSidePanelItem} pickerId={SIDE_PANEL_TREE} />
- </span>);
+ </div>);
const renderSidePanelItem = (item: TreeItem<ProjectResource>) => {
const name = typeof item.data === 'string' ? item.data : item.data.name;
import { RootState } from "store/store";
import { Button, IconButton, StyleRulesCallback, WithStyles, withStyles, SnackbarContent } from '@material-ui/core';
import MaterialSnackbar, { SnackbarOrigin } from "@material-ui/core/Snackbar";
-import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { snackbarActions, SnackbarKind, SnackbarMessage } from "store/snackbar/snackbar-actions";
import { navigateTo } from 'store/navigation/navigation-action';
import WarningIcon from '@material-ui/icons/Warning';
import CheckCircleIcon from '@material-ui/icons/CheckCircle';
anchorOrigin?: SnackbarOrigin;
autoHideDuration?: number;
open: boolean;
- message?: React.ReactElement<any>;
- kind: SnackbarKind;
- link?: string;
+ messages: SnackbarMessage[];
}
interface SnackbarEventProps {
- onClose?: (event: React.SyntheticEvent<any>, reason: string) => void;
+ onClose?: (event: React.SyntheticEvent<any>, reason: string, message?: string) => void;
onExited: () => void;
onClick: (uuid: string) => void;
}
return {
anchorOrigin: { vertical: "bottom", horizontal: "right" },
open: state.snackbar.open,
- message: <span>{messages.length > 0 ? messages[0].message : ""}</span>,
- autoHideDuration: messages.length > 0 ? messages[0].hideDuration : 0,
- kind: messages.length > 0 ? messages[0].kind : SnackbarKind.INFO,
- link: messages.length > 0 ? messages[0].link : ''
+ messages,
+ autoHideDuration: messages.length > 0 ? messages[0].hideDuration : 0
};
};
const mapDispatchToProps = (dispatch: Dispatch): SnackbarEventProps => ({
- onClose: (event: any, reason: string) => {
+ onClose: (event: any, reason: string, id: undefined) => {
if (reason !== "clickaway") {
- dispatch(snackbarActions.CLOSE_SNACKBAR());
+ dispatch(snackbarActions.CLOSE_SNACKBAR(id));
}
},
onExited: () => {
}
});
-type CssRules = "success" | "error" | "info" | "warning" | "icon" | "iconVariant" | "message" | "linkButton";
+type CssRules = "success" | "error" | "info" | "warning" | "icon" | "iconVariant" | "message" | "linkButton" | "snackbarContent";
const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
success: {
},
linkButton: {
fontWeight: 'bolder'
+ },
+ snackbarContent: {
+ marginBottom: '1rem'
}
});
[SnackbarKind.ERROR]: [ErrorIcon, classes.error]
};
- const [Icon, cssClass] = variants[props.kind];
-
-
-
return (
<MaterialSnackbar
open={props.open}
- message={props.message}
onClose={props.onClose}
onExited={props.onExited}
anchorOrigin={props.anchorOrigin}
autoHideDuration={props.autoHideDuration}>
- <div data-cy="snackbar"><SnackbarContent
- className={classNames(cssClass)}
- aria-describedby="client-snackbar"
- message={
- <span id="client-snackbar" className={classes.message}>
- <Icon className={classNames(classes.icon, classes.iconVariant)} />
- {props.message}
- </span>
+ <div data-cy="snackbar">
+ {
+ props.messages.map((message, index) => {
+ const [Icon, cssClass] = variants[message.kind];
+
+ return <SnackbarContent
+ key={`${index}-${message.message}`}
+ className={classNames(cssClass, classes.snackbarContent)}
+ aria-describedby="client-snackbar"
+ message={
+ <span id="client-snackbar" className={classes.message}>
+ <Icon className={classNames(classes.icon, classes.iconVariant)} />
+ {message.message}
+ </span>
+ }
+ action={actions(message, props.onClick, props.onClose, classes, index, props.autoHideDuration)}
+ />
+ })
}
- action={actions(props)}
- /></div>
+ </div>
</MaterialSnackbar>
);
}
));
-const actions = (props: SnackbarProps) => {
- const { link, onClose, onClick, classes } = props;
+const actions = (props: SnackbarMessage, onClick, onClose, classes, index, autoHideDuration) => {
+ if (onClose && autoHideDuration) {
+ setTimeout(onClose, autoHideDuration);
+ }
+
const actions = [
<IconButton
key="close"
aria-label="Close"
color="inherit"
- onClick={e => onClose && onClose(e, '')}>
+ onClick={e => onClose && onClose(e, '', index)}>
<CloseIcon className={classes.icon} />
</IconButton>
];
- if (link) {
+ if (props.link) {
actions.splice(0, 0,
<Button key="goTo"
aria-label="goTo"
size="small"
color="inherit"
className={classes.linkButton}
- onClick={() => onClick(link)}>
+ onClick={() => onClick(props.link)}>
<span data-cy='snackbar-goto-action'>Go To</span>
</Button>
);
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));
+ const resource = resources[node.id];
return {
active: node.active,
- data: node.value,
+ data: resource ? { ...resource, name: node.value.name || node.value } : undefined || node.value,
id: node.id,
items: items.length > 0 ? items : undefined,
open: node.expanded,
import { Link as ButtonLink } from '@material-ui/core';
import { ResourceWithName, ResponsiblePerson } from 'views-components/data-explorer/renderers';
import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
+import { resourceIsFrozen } from 'common/frozen-resources';
type CssRules = 'root'
| 'button'
}
}
}
+
+ if (item && isWritable) {
+ isWritable = !resourceIsFrozen(item, state.resources);
+ }
+
return { item, isWritable, isOldVersion };
})(
class extends React.Component<CollectionPanelProps> {
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 : {},
}
openDialog = () => {
+ this.componentDidMount();
this.setState({ open: true });
}
}
openDialog = () => {
+ this.componentDidMount();
this.setState({ open: true });
}
}
renderDialog() {
- return <Dialog
+ return this.state.open ? <Dialog
open={this.state.open}
onClose={this.closeDialog}
fullWidth
color='primary'
onClick={this.submit}>Ok</Button>
</DialogActions>
- </Dialog>;
+ </Dialog> : null;
}
});