Merge remote-tracking branch 'origin/main' into 18692-frozen-projects-workbench-support
authorDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Wed, 5 Oct 2022 16:18:46 +0000 (18:18 +0200)
committerDaniel Kutyła <daniel.kutyla@contractors.roche.com>
Wed, 5 Oct 2022 16:19:19 +0000 (18:19 +0200)
Arvados-DCO-1.1-Signed-off-by: Daniel Kutyła <daniel.kutyla@contractors.roche.com>

39 files changed:
cypress/integration/project.spec.js
src/common/config.ts
src/common/frozen-resources.ts [new file with mode: 0644]
src/common/service-provider.ts
src/components/breadcrumbs/breadcrumbs.test.tsx
src/components/breadcrumbs/breadcrumbs.tsx
src/components/collection-panel-files/collection-panel-files.tsx
src/components/icon/icon.tsx
src/components/tree/tree.tsx
src/index.tsx
src/models/project.ts
src/store/context-menu/context-menu-actions.ts
src/store/projects/project-lock-actions.ts [new file with mode: 0644]
src/store/snackbar/snackbar-actions.ts
src/store/snackbar/snackbar-reducer.ts
src/store/tree-picker/tree-picker-actions.ts
src/views-components/breadcrumbs/breadcrumbs.ts
src/views-components/context-menu/action-sets/project-action-set.ts
src/views-components/context-menu/action-sets/project-admin-action-set.ts
src/views-components/context-menu/actions/lock-action.tsx [new file with mode: 0644]
src/views-components/context-menu/context-menu-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/details-panel/collection-details.tsx
src/views-components/details-panel/details-panel.tsx
src/views-components/details-panel/project-details.tsx
src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx
src/views-components/projects-tree-picker/home-tree-picker.tsx
src/views-components/projects-tree-picker/projects-tree-picker.tsx
src/views-components/projects-tree-picker/public-favorites-tree-picker.tsx
src/views-components/projects-tree-picker/shared-tree-picker.tsx
src/views-components/side-panel-button/side-panel-button.tsx
src/views-components/side-panel-tree/side-panel-tree.tsx
src/views-components/snackbar/snackbar.tsx
src/views-components/tree-picker/tree-picker.ts
src/views/collection-panel/collection-panel.tsx
src/views/project-panel/project-panel.tsx
src/views/run-process-panel/inputs/file-input.tsx
src/views/run-process-panel/inputs/project-input.tsx

index ea795e6c393a23fa1edb33783c65322d0e72985a..93e4257b5b55f137eb626f9669e784b2ba7a5172 100644 (file)
@@ -312,6 +312,98 @@ describe('Project tests', function() {
         });
     });
 
+    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())})`;
 
@@ -338,3 +430,4 @@ describe('Project tests', function() {
 
     });
 });
+
index 2954d70493b31676663cd00586583f4966f06d0a..574445df09b05f5f47a8b0b57fc190352a8e1237 100644 (file)
@@ -13,6 +13,9 @@ interface WorkbenchConfig {
 }
 
 export interface ClusterConfigJSON {
+    API: {
+        UnfreezeProjectRequiresAdmin: boolean
+    },
     ClusterID: string;
     RemoteClusters: {
         [key: string]: {
@@ -222,6 +225,9 @@ export const mapRemoteHosts = (clusterConfigJSON: ClusterConfigJSON, config: Con
 };
 
 export const mockClusterConfigJSON = (config: Partial<ClusterConfigJSON>): ClusterConfigJSON => ({
+    API: {
+        UnfreezeProjectRequiresAdmin: false,
+    },
     ClusterID: "",
     RemoteClusters: {},
     Services: {
diff --git a/src/common/frozen-resources.ts b/src/common/frozen-resources.ts
new file mode 100644 (file)
index 0000000..8d22791
--- /dev/null
@@ -0,0 +1,19 @@
+// 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 && ownerUuid.indexOf('000000000000000') === -1) {
+        const parentResource: ProjectResource | undefined = getResource<ProjectResource>(ownerUuid)(resources);
+        isFrozen = !!parentResource?.frozenByUuid;
+        ownerUuid = parentResource?.ownerUuid;
+    }
+
+    return isFrozen;
+}
\ No newline at end of file
index 080916c55c3798d1e7e60507edff42503a9b95b0..e0504ebf8cc628898da3de5965d25ff26d93801e 100644 (file)
@@ -6,6 +6,7 @@ class ServicesProvider {
 
     private static instance: ServicesProvider;
 
+    private store;
     private services;
 
     private constructor() {}
@@ -30,6 +31,20 @@ class ServicesProvider {
         }
         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();
index fe3d2ab09983151d06cd2bfee697f2caa42667d6..1594c0366c6d11f8ffb238ce1f107de4c6d15c72 100644 (file)
@@ -15,6 +15,7 @@ configure({ adapter: new Adapter() });
 describe("<Breadcrumbs />", () => {
 
     let onClick: () => void;
+    let resources = {};
 
     beforeEach(() => {
         onClick = jest.fn();
@@ -24,7 +25,7 @@ describe("<Breadcrumbs />", () => {
         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);
     });
@@ -34,7 +35,7 @@ describe("<Breadcrumbs />", () => {
             { 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);
     });
@@ -44,7 +45,7 @@ describe("<Breadcrumbs />", () => {
             { 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]);
     });
index 3d668856ecd5d1c20e4faebc3b51d54466045277..717966611949c8858548f9206663296139b959f3 100644 (file)
@@ -7,15 +7,17 @@ import { Button, Grid, StyleRulesCallback, WithStyles, Typography, Tooltip } fro
 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: {
@@ -29,18 +31,25 @@ const styles: StyleRulesCallback<CssRules> = theme => ({
     },
     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) => {
@@ -69,6 +78,9 @@ export const Breadcrumbs = withStyles(styles)(
                                 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} />}
index 06b3c507dba2ba3a49ad998dc59e8277756121b2..06c3504ac52400ee13e0592d6e64fd326ddf95eb 100644 (file)
@@ -328,12 +328,7 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState
     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
 
@@ -539,7 +534,7 @@ export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState
                     : <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">
index a64ed0a8a00f12531b1283922b352abfcdd4392a..9ddc2aa2997c54a810b2e06df174916e37535d15 100644 (file)
@@ -83,6 +83,20 @@ library.add(
     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' />
index fc9dbc743ae19a15d59172bd2c30734eb223cd11..e37086213eae94fc0a997537c810520ee5aaea25 100644 (file)
@@ -5,7 +5,7 @@
 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";
@@ -26,7 +26,8 @@ type CssRules = 'list'
     | 'toggableIcon'
     | 'checkbox'
     | 'childItem'
-    | 'childItemIcon';
+    | 'childItemIcon'
+    | 'frozenIcon';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     list: {
@@ -83,6 +84,11 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     active: {
         color: theme.palette.primary.main,
     },
+    frozenIcon: {
+        fontSize: 20,
+        color: theme.palette.grey["600"],
+        marginLeft: '10px',
+    },
 });
 
 export enum TreeItemStatus {
@@ -102,6 +108,7 @@ export interface TreeItem<T> {
     flatTree?: boolean;
     status: TreeItemStatus;
     items?: Array<TreeItem<T>>;
+    isFrozen?: boolean;
 }
 
 export interface TreeProps<T> {
@@ -253,6 +260,9 @@ const FlatTree = (props: FlatTreeProps) =>
                             <span style={{ fontSize: '0.875rem' }}>
                                 {item.data.name}
                             </span>
+                            {
+                                !!item.data.frozenByUuid ? <FreezeIcon className={props.classes.frozenIcon} /> : null
+                            }
                         </span>
                     </div>
                 </div>)
@@ -270,7 +280,6 @@ export const Tree = withStyles(styles)(
                 : () => 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}`}>
index 5d939d36d5f33eec2caa0741e604ced7e2ffa662..c214228258fd7945a44438346718f4937180f334 100644 (file)
@@ -22,7 +22,7 @@ import { fetchConfig } from 'common/config';
 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';
@@ -60,7 +60,7 @@ import { groupActionSet } from 'views-components/context-menu/action-sets/group-
 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";
@@ -102,6 +102,8 @@ addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
 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);
@@ -150,6 +152,8 @@ fetchConfig()
 
         const store = configureStore(history, services, config);
 
+        servicesProvider.setStore(store);
+
         store.subscribe(initListener(history, store, services, config));
         store.dispatch(initAuth(config));
         store.dispatch(setBuildInfo());
index b47b426f274989db009552aab3ecbd9e8c1bfa1e..04dae4d22622a9ae44c39039167e011d7ba807a7 100644 (file)
@@ -5,6 +5,8 @@
 import { GroupClass, GroupResource } from "./group";
 
 export interface ProjectResource extends GroupResource {
+    frozenByUuid: null|string;
+    canManage: boolean;
     groupClass: GroupClass.PROJECT | GroupClass.FILTER | GroupClass.ROLE;
 }
 
index e00b65b3699e12285e68501e8e3eaae35b87ac70..3bc91ae0c74c1464aecb5e5c7d0ba7b3b56a0353 100644 (file)
@@ -21,6 +21,8 @@ import { CollectionResource } from 'models/collection';
 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 }>(),
@@ -40,6 +42,8 @@ export type ContextMenuResource = {
     isEditable?: boolean;
     outputUuid?: string;
     workflowUuid?: string;
+    isAdmin?: boolean;
+    isFrozen?: boolean;
     storageClassesDesired?: string[];
     properties?: { [key: string]: string | string[] };
 };
@@ -162,6 +166,7 @@ export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, res
                 description: res.description,
                 ownerUuid: res.ownerUuid,
                 isTrashed: ('isTrashed' in res) ? res.isTrashed : false,
+                isFrozen: !!(res as ProjectResource).frozenByUuid,
             }));
         }
     };
@@ -224,10 +229,15 @@ export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
         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
@@ -246,13 +256,13 @@ export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
                     ? 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
diff --git a/src/store/projects/project-lock-actions.ts b/src/store/projects/project-lock-actions.ts
new file mode 100644 (file)
index 0000000..98ebb38
--- /dev/null
@@ -0,0 +1,34 @@
+// 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
index c04371543f34058ca39ee54f091889baf6367675..7b6f2efd150e1d8e3170fb0e8017a3b4370b2f03 100644 (file)
@@ -20,7 +20,7 @@ export enum SnackbarKind {
 
 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<{}>()
 });
 
index fa1717c7a4aba0cd51b0e633d486150fde87b64a..c3fcfb0795e280fe36a844a4208e45c581b44838 100644 (file)
@@ -29,10 +29,21 @@ export const snackbarReducer = (state = initialState, action: SnackbarAction) =>
                 })
             };
         },
-        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 {
index 06abe39f7b3a3adead00a18d846eb52577f6e515..23f548cd679b89025f58446ca7429ef8c74e07d9 100644 (file)
@@ -103,10 +103,11 @@ interface LoadProjectParams {
     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 }));
 
@@ -118,7 +119,6 @@ export const loadProject = (params: LoadProjectParams) =>
         )(new FilterBuilder());
 
         const { items } = await services.groupsService.contents(loadShared ? '' : id, { filters, excludeHomeProject: loadShared || undefined });
-
         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
             id,
             pickerId,
@@ -126,6 +126,11 @@ export const loadProject = (params: LoadProjectParams) =>
                     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 => ({
@@ -183,11 +188,11 @@ export const initUserProject = (pickerId: string) =>
             }));
         }
     };
-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 }));
         }
     };
 
@@ -240,6 +245,7 @@ interface LoadFavoritesProjectParams {
     pickerId: string;
     includeCollections?: boolean;
     includeFiles?: boolean;
+    options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
 }
 
 export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
@@ -265,6 +271,10 @@ export const loadFavoritesProject = (params: LoadFavoritesProjectParams,
                         return false;
                     }
 
+                    if (options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
+                    }
+
                     return true;
                 }),
                 extractNodeData: item => ({
@@ -301,7 +311,13 @@ export const loadPublicFavoritesProject = (params: LoadFavoritesProjectParams) =
         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,
index cb48b38fb62bfd95c5bc83f5b1a8dd0f5f835f4d..c4134aed4d948255fc0c07e8fb1ab9747e971836 100644 (file)
@@ -3,26 +3,28 @@
 // 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));
     }
 });
index e352d0c4ced3e17f99c200f007ab09bad2fef740..8181045ca3346d1e378aeffedd2c014a90545a35 100644 (file)
@@ -18,97 +18,155 @@ import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 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: "API Details",
+    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,
+]];
index ebf827aa6609fc7fcb1a8ce547a374bc3a3750bd..3faf675d94259f762e22d823201d88d15f0c63c3 100644 (file)
@@ -7,30 +7,56 @@ import { TogglePublicFavoriteAction } from "views-components/context-menu/action
 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
 ]];
diff --git a/src/views-components/context-menu/actions/lock-action.tsx b/src/views-components/context-menu/actions/lock-action.tsx
new file mode 100644 (file)
index 0000000..99eb756
--- /dev/null
@@ -0,0 +1,45 @@
+// 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));
index 1c9eb99b42a48b5a453f311054e862bb68e3b26e..abef7ec0d47e711fa48adb109f4d5b60e629fffb 100644 (file)
@@ -5,9 +5,10 @@
 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>>;
index a8e7fd028aa81cf477eeaa7b99d028da514f42bd..c659b7c508a7fd7af4cc2887742aabd4a773edc5 100644 (file)
@@ -79,6 +79,8 @@ export enum ContextMenuKind {
     PROJECT = "Project",
     FILTER_GROUP = "FilterGroup",
     READONLY_PROJECT = 'ReadOnlyProject',
+    FROZEN_PROJECT = 'FrozenProject',
+    FROZEN_PROJECT_ADMIN = 'FrozenProjectAdmin',
     PROJECT_ADMIN = "ProjectAdmin",
     FILTER_GROUP_ADMIN = "FilterGroupAdmin",
     RESOURCE = "Resource",
index 7822bdc6b4cd2411aaa37fc78a6a626200db35cf..e09160661b50a356422bc3d06a7e97434446573b 100644 (file)
@@ -15,6 +15,7 @@ import {
 import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
 import { Resource, ResourceKind, TrashableResource } from 'models/resource';
 import {
+    FreezeIcon,
     ProjectIcon,
     FilterGroupIcon,
     CollectionIcon,
@@ -59,6 +60,7 @@ import { openPermissionEditContextMenu } from 'store/context-menu/context-menu-a
 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) => {
 
@@ -79,11 +81,32 @@ 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);
@@ -745,7 +768,7 @@ export const ResourceWithName = userFromID(_resourceWithName);
 
 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 === '') {
index 5edfbc37e1b36354df7fb5c848763f3613e7ec72..4431465b87688ec985a11307d6fd23b8b9f7edae 100644 (file)
@@ -8,7 +8,7 @@ import { CollectionResource } from 'models/collection';
 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';
@@ -17,6 +17,7 @@ import { Dispatch } from 'redux';
 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'
@@ -82,6 +83,7 @@ export class CollectionDetails extends DetailsData<CollectionResource> {
 }
 
 interface CollectionInfoDataProps {
+    resources: ResourcesState;
     currentCollection: CollectionResource | undefined;
 }
 
@@ -91,6 +93,7 @@ interface CollectionInfoDispatchProps {
 
 const ciMapStateToProps = (state: RootState): CollectionInfoDataProps => {
     return {
+        resources: state.resources,
         currentCollection: getResource<CollectionResource>(state.detailsPanel.resourceUuid)(state.resources),
     };
 };
@@ -110,10 +113,11 @@ type CollectionInfoProps = CollectionInfoDataProps & CollectionInfoDispatchProps
 
 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)}>
index adbbab79333b385eec7b028c98c75aa7c4a041cb..e9175f57ba423e5069064db69876ddef15c97e1c 100644 (file)
@@ -27,6 +27,7 @@ import { getResource } from 'store/resources/resources';
 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';
 
@@ -87,7 +88,14 @@ const mapStateToProps = ({ auth, detailsPanel, resources, collectionPanelFiles }
     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,
@@ -111,6 +119,7 @@ export interface DetailsPanelDataProps {
     isOpened: boolean;
     tabNr: number;
     res: DetailsResource;
+    isFrozen: boolean;
 }
 
 type DetailsPanelProps = DetailsPanelDataProps & WithStyles<CssRules>;
index 6d48e984de0f5ec7c84178c82b16c4e3a924918f..7dc6709da591a84a7ecd813582aa509733008c0a 100644 (file)
@@ -19,6 +19,9 @@ import { getPropertyChip } from '../resource-properties-form/property-chip';
 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) {
@@ -59,6 +62,12 @@ interface ProjectDetailsComponentActionProps {
     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)),
@@ -66,9 +75,9 @@ const mapDispatchToProps = (dispatch: Dispatch) => ({
 
 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,
@@ -76,6 +85,7 @@ const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
                     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
index aa9fb60b1db7a4237be254fd5405528d6f90b2d5..d1c6fc6072c70ec8032623922e386c0904e78b97 100644 (file)
@@ -66,7 +66,7 @@ const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollectio
                 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);
index df5fa9c28341bfbfdf74ced449645d568e64684f..4e8eeda987e9fbac4123902c4a4976da088662fb 100644 (file)
@@ -11,7 +11,7 @@ import { ProjectIcon } from 'components/icon/icon';
 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
index ee8ce1d5ea5ffc365fa40b2334b9281bc2b53128..c4a6e3ab298cedc59e1aaaebd978265384245705 100644 (file)
@@ -3,7 +3,7 @@
 // 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';
@@ -46,5 +46,5 @@ export const ProjectsTreePicker = ({ pickerId, ...props }: ProjectsTreePickerPro
     </div>;
 };
 
-const getRelatedTreePickers = memoize(pipe(getProjectsTreePickerIds, values));
+const getRelatedTreePickers = pipe(getProjectsTreePickerIds, values);
 const disableActivation = [SHARED_PROJECT_ID, FAVORITES_PROJECT_ID];
index d2037af438972684c716139d58995bfa29487524..91551c9abf5cfce0f2604b485f958d0a184633e7 100644 (file)
@@ -11,7 +11,7 @@ import { loadPublicFavoritesProject } from 'store/tree-picker/tree-picker-action
 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
index d6a59bea2d6ec6802679dee7fc7bb1741bb2f120..201bd118bcd3b9d7c1ec12aa650cc4e4c9837c4d 100644 (file)
@@ -11,7 +11,7 @@ import { loadProject } from 'store/tree-picker/tree-picker-actions';
 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
index c813efb0a373f4080fe177b1234ce0222f5f5a1d..7874441588d51344247950f50d043de1c81e88fb 100644 (file)
@@ -21,6 +21,7 @@ import { extractUuidKind, ResourceKind } from 'models/resource';
 import { pluginConfig } from 'plugins';
 import { ElementListReducer } from 'common/plugintypes';
 import { Location } from 'history';
+import { ProjectResource } from 'models/project';
 
 type CssRules = 'button' | 'menuItem' | 'icon';
 
@@ -87,9 +88,10 @@ export const SidePanelButton = withStyles(styles)(
                 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;
index 7f5b8d738797ed9a4247ccf523510ce0b648eab2..c7e3376745d1442136770ceb90ab602712d2147b 100644 (file)
@@ -40,9 +40,9 @@ const mapDispatchToProps = (dispatch: Dispatch, props: SidePanelTreeProps): Side
 
 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;
index a33b6968255abd841b9eb59c7cc624453ea9344e..1887f0bde042c0e95fa5b78326dc71247ec75d10 100644 (file)
@@ -8,7 +8,7 @@ import { connect } from "react-redux";
 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';
@@ -23,13 +23,11 @@ interface SnackbarDataProps {
     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;
 }
@@ -39,17 +37,15 @@ const mapStateToProps = (state: RootState): SnackbarDataProps => {
     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: () => {
@@ -60,7 +56,7 @@ const mapDispatchToProps = (dispatch: Dispatch): SnackbarEventProps => ({
     }
 });
 
-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: {
@@ -88,6 +84,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     },
     linkButton: {
         fontWeight: 'bolder'
+    },
+    snackbarContent: {
+        marginBottom: '1rem'
     }
 });
 
@@ -104,53 +103,60 @@ export const Snackbar = withStyles(styles)(connect(mapStateToProps, mapDispatchT
             [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>
         );
index 86c76e0824ce6c78ffb66ea6c487a28ce9db313f..a6fdfefec9d21d013f72a6e87ffe3ecbc6ca83fe 100644 (file)
@@ -8,6 +8,7 @@ import { RootState } from "store/store";
 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> {
@@ -34,30 +35,23 @@ const addToItemsIdMap = <T>(item: TreeItem<T>, itemsIdMap: Map<string, TreeItem<
     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),
@@ -66,16 +60,17 @@ const mapDispatchToProps = (_: Dispatch, props: TreePickerProps<any>): Pick<Tree
     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,
index 9d127a605cc617cd2c7367aa460a039c185e34e9..a28d5254ca0aa6892789001b7a29da626ec105de 100644 (file)
@@ -36,6 +36,7 @@ import { Link } from 'react-router-dom';
 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'
@@ -133,6 +134,11 @@ export const CollectionPanel = withStyles(styles)(connect(
                 }
             }
         }
+
+        if (item && isWritable) {
+            isWritable = !resourceIsFrozen(item, state.resources);
+        }
+
         return { item, isWritable, isOldVersion };
     })(
         class extends React.Component<CollectionPanelProps> {
index ccb40d53ba958b35645c14fb0af4a874e72f1b88..d9d14ae31c14252330be10990f8fb1eb7ad04e08 100644 (file)
@@ -45,6 +45,7 @@ import {
 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";
 
@@ -165,7 +166,7 @@ export const ProjectPanel = withStyles(styles)(
             }
 
             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;
@@ -183,6 +184,8 @@ export const ProjectPanel = withStyles(styles)(
                         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 : {},
index c2e17c9502fe26dac7bad4bdf2663d6f87f6db51..a56d45f854f054c2882d1f8b41e8ecff5267ffa4 100644 (file)
@@ -78,6 +78,7 @@ const FileInputComponent = connect()(
         }
 
         openDialog = () => {
+            this.componentDidMount();
             this.setState({ open: true });
         }
 
index 7b45a6d18e18ac59e0cdf479f67659c239f81669..0c962ed8a326116f0c1134540e8d3f526d1c82a4 100644 (file)
@@ -72,6 +72,7 @@ export const ProjectInputComponent = connect(mapStateToProps)(
         }
 
         openDialog = () => {
+            this.componentDidMount();
             this.setState({ open: true });
         }
 
@@ -109,7 +110,7 @@ export const ProjectInputComponent = connect(mapStateToProps)(
         }
 
         renderDialog() {
-            return <Dialog
+            return this.state.open ? <Dialog
                 open={this.state.open}
                 onClose={this.closeDialog}
                 fullWidth
@@ -130,7 +131,7 @@ export const ProjectInputComponent = connect(mapStateToProps)(
                         color='primary'
                         onClick={this.submit}>Ok</Button>
                 </DialogActions>
-            </Dialog>;
+            </Dialog> : null;
         }
 
     });