Merge branch 'main' of git.arvados.org:arvados-workbench2 into 16073-process-io-panels
authorStephen Smith <stephen@curii.com>
Fri, 21 Oct 2022 14:14:28 +0000 (10:14 -0400)
committerStephen Smith <stephen@curii.com>
Fri, 21 Oct 2022 14:14:28 +0000 (10:14 -0400)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

73 files changed:
cypress/integration/project.spec.js
cypress/integration/search.spec.js
cypress/integration/user-profile.spec.js
cypress/integration/virtual-machine-admin.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/chips-input/chips-input.tsx
src/components/code-snippet/code-snippet.tsx
src/components/collection-panel-files/collection-panel-files.tsx
src/components/icon/icon.tsx
src/components/multi-panel-view/multi-panel-view.tsx
src/components/search-input/search-input.test.tsx
src/components/search-input/search-input.tsx
src/components/tree/tree.tsx
src/index.tsx
src/models/project.ts
src/services/api/filter-builder.ts
src/services/common-service/common-resource-service.test.ts
src/services/common-service/common-service.ts
src/store/advanced-tab/advanced-tab.tsx
src/store/context-menu/context-menu-actions.ts
src/store/open-in-new-tab/open-in-new-tab.actions.ts
src/store/projects/project-lock-actions.ts [new file with mode: 0644]
src/store/sharing-dialog/sharing-dialog-actions.ts
src/store/snackbar/snackbar-actions.ts
src/store/snackbar/snackbar-reducer.ts
src/store/tree-picker/tree-picker-actions.ts
src/store/virtual-machines/virtual-machines-actions.ts
src/views-components/advanced-tab-dialog/advanced-tab-dialog.tsx
src/views-components/breadcrumbs/breadcrumbs.ts
src/views-components/context-menu/action-sets/api-client-authorization-action-set.ts
src/views-components/context-menu/action-sets/collection-action-set.ts
src/views-components/context-menu/action-sets/group-action-set.ts
src/views-components/context-menu/action-sets/group-member-action-set.ts
src/views-components/context-menu/action-sets/keep-service-action-set.ts
src/views-components/context-menu/action-sets/link-action-set.ts
src/views-components/context-menu/action-sets/process-resource-action-set.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/action-sets/repository-action-set.ts
src/views-components/context-menu/action-sets/search-results-action-set.ts
src/views-components/context-menu/action-sets/ssh-key-action-set.ts
src/views-components/context-menu/action-sets/trashed-collection-action-set.ts
src/views-components/context-menu/action-sets/user-action-set.ts
src/views-components/context-menu/action-sets/virtual-machine-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-components/virtual-machines-dialog/add-login-dialog.tsx
src/views-components/virtual-machines-dialog/group-array-input.tsx
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
src/views/virtual-machine-panel/virtual-machine-admin-panel.tsx
src/views/virtual-machine-panel/virtual-machine-user-panel.tsx
yarn.lock

index b2f6f33df33fc0772f0a6eedbf2973aff9a87d7e..93e4257b5b55f137eb626f9669e784b2ba7a5172 100644 (file)
@@ -301,7 +301,7 @@ describe('Project tests', function() {
 
                     cy.get('main').contains(projectName).rightclick();
 
-                    cy.get('[data-cy=context-menu]').contains('Advanced').click();
+                    cy.get('[data-cy=context-menu]').contains('API Details').click();
 
                     cy.get('[role=tablist]').contains('METADATA').click();
 
@@ -311,4 +311,123 @@ 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())})`;
+
+        cy.loginAs(activeUser);
+        cy.get('[data-cy=side-panel-button]').click();
+        cy.get('[data-cy=side-panel-new-project]').click();
+        cy.get('[data-cy=form-dialog]')
+            .should('contain', 'New Project')
+            .within(() => {
+                cy.get('[data-cy=name-field]').within(() => {
+                    cy.get('input').type(projectName);
+                });
+                cy.get('[data-cy=form-submit-btn]').click();
+            });
+
+        cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+        cy.get('[data-cy=project-panel]').contains(projectName).rightclick();
+        cy.get('[data-cy=context-menu]').contains('Copy to clipboard').click();
+        cy.window().then((win) => (
+            win.navigator.clipboard.readText().then((text) => {
+                expect(text).to.match(/https\:\/\/localhost\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/,);
+            })
+        ));
+
+    });
 });
+
index 2216c067f8423f7c36fdfd4c7b10ca23402d707c..c8e262f011097b43ad9b5902a7d75bacdfa26cf1 100644 (file)
@@ -105,6 +105,39 @@ describe('Search tests', function() {
         });
     });
 
+    it('can search items using quotes', function() {
+        const random = Math.floor(Math.random() * Math.floor(999999));
+        const colName = `Collection ${random}`;
+        const colName2 = `Collection test ${random}`;
+
+        // Creates the collection using the admin token so we can set up
+        // a bogus manifest text without block signatures.
+        cy.createCollection(adminUser.token, {
+            name: colName,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        }).as('collection1');
+
+        cy.createCollection(adminUser.token, {
+            name: colName2,
+            owner_uuid: activeUser.user.uuid,
+            preserve_version: true,
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        }).as('collection2');
+
+        cy.getAll('@collection1', '@collection2')
+            .then(function() {
+                cy.loginAs(activeUser);
+
+                cy.doSearch(colName);
+                cy.get('[data-cy=search-results] table tbody tr').should('have.length', 2);
+
+                cy.doSearch(`"${colName}"`);
+                cy.get('[data-cy=search-results] table tbody tr').should('have.length', 1);
+            });
+    });
+
     it('can display owner of the item', function() {
         const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
 
@@ -241,7 +274,7 @@ describe('Search tests', function() {
             cy.get('[data-cy=context-menu]').within((ctx) => {
                 // Check that there are 4 items in the menu
                 cy.get(ctx).children().should('have.length', 4);
-                cy.contains('Advanced');
+                cy.contains('API Details');
                 cy.contains('Copy to clipboard');
                 cy.contains('Open in new tab');
                 cy.contains('View details');
index 7d21249c4bc38d7b9331d3ee9d6a6fbd3ccc05a9..d91dbb0bc5bf4a49f98e7ffd256b2df31248edfb 100644 (file)
@@ -76,7 +76,7 @@ describe('User profile tests', function() {
     }) {
         cy.get('[data-cy=user-profile-panel-options-btn]').click();
         cy.get('[data-cy=context-menu]').within(() => {
-            cy.get('[role=button]').contains('Advanced');
+            cy.get('[role=button]').contains('API Details');
 
             cy.get('[role=button]').should(account ? 'contain' : 'not.contain', 'Account Settings');
             cy.get('[role=button]').should(activate ? 'contain' : 'not.contain', 'Activate User');
index f01a8911060ffd22e6545f8a3f0ffca137a4e190..49cb12394648138417404b52a001f2698002a541 100644 (file)
@@ -51,20 +51,30 @@ describe('Virtual machine login manage tests', function() {
                   })
             });
         cy.get('[role=tooltip]').click();
-        cy.get('[data-cy=form-dialog]')
+        cy.get('[data-cy=form-dialog]').as('add-login-dialog')
             .should('contain', 'Add login permission')
             .within(() => {
                 cy.get('label')
                   .contains('Add groups')
                   .parent()
                   .within(() => {
-                    cy.get('input').type('docker sudo{enter}');
+                    cy.get('input').type('docker ');
+                    // Veryfy submit enabled (form has changed)
+                    cy.get('@add-login-dialog').within(() => {
+                        cy.get('[data-cy=form-submit-btn]').should('be.enabled');
+                    });
+                    cy.get('input').type('sudo');
+                    // Veryfy submit disabled (partial input in chips)
+                    cy.get('@add-login-dialog').within(() => {
+                        cy.get('[data-cy=form-submit-btn]').should('be.disabled');
+                    });
+                    cy.get('input').type('{enter}');
                   })
             });
         cy.get('[data-cy=form-dialog]').within(() => {
             cy.get('[data-cy=form-submit-btn]').click();
         });
-        cy.get('[data-cy=snackbar]').contains('Permission updated');
+
         cy.get('[data-cy=vm-admin-table]')
             .contains(vmHost)
             .parents('tr')
@@ -93,7 +103,7 @@ describe('Virtual machine login manage tests', function() {
         cy.get('[data-cy=form-dialog]').within(() => {
             cy.get('[data-cy=form-submit-btn]').click();
         });
-        cy.get('[data-cy=snackbar]').contains('Permission updated');
+
         cy.get('[data-cy=vm-admin-table]')
             .contains(vmHost)
             .parents('tr')
@@ -163,7 +173,6 @@ describe('Virtual machine login manage tests', function() {
         });
 
         // Wait for page to finish loading
-        cy.get('[data-cy=snackbar]').contains('Permission updated');
         cy.get('[data-cy=vm-admin-table]')
             .contains(vmHost)
             .parents('tr')
@@ -194,7 +203,6 @@ describe('Virtual machine login manage tests', function() {
         cy.get('[data-cy=form-dialog]').within(() => {
             cy.get('[data-cy=form-submit-btn]').click();
         });
-        cy.get('[data-cy=snackbar]').contains('Permission updated');
 
         // Verify new login permissions
         // Check admin's vm page for login
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 cbb1fb1283b31246152f2c32b6a961cb5f256248..7b9ff4a6a81e4fe199301004d1905e6b0f664826 100644 (file)
@@ -12,6 +12,7 @@ interface ChipsInputProps<Value> {
     values: Value[];
     getLabel?: (value: Value) => string;
     onChange: (value: Value[]) => void;
+    onPartialInput?: (value: boolean) => void;
     handleFocus?: (e: any) => void;
     handleBlur?: (e: any) => void;
     chipsClassName?: string;
@@ -54,6 +55,9 @@ export const ChipsInput = withStyles(styles)(
 
         setText = (event: React.ChangeEvent<HTMLInputElement>) => {
             this.setState({ text: event.target.value }, () => {
+                // Update partial input status
+                this.props.onPartialInput && this.props.onPartialInput(this.state.text !== '');
+
                 // If pattern is provided, check for delimiter
                 if (this.props.pattern) {
                     const matches = this.state.text.match(this.props.pattern);
@@ -92,6 +96,7 @@ export const ChipsInput = withStyles(styles)(
                     this.setState({ text: '' });
                     this.props.onChange([...this.props.values, newValue]);
                 }
+                this.props.onPartialInput && this.props.onPartialInput(false);
             }
         }
 
index 83c378b899ec11a884cb6d213b932423a14ba0aa..5a5a7041d88a630717512e562060f3decaac1c2d 100644 (file)
@@ -30,6 +30,7 @@ export interface CodeSnippetDataProps {
     className?: string;
     apiResponse?: boolean;
     linked?: boolean;
+    children?: JSX.Element;
 }
 
 interface CodeSnippetAuthProps {
@@ -43,11 +44,12 @@ const mapStateToProps = (state: RootState): CodeSnippetAuthProps => ({
 });
 
 export const CodeSnippet = withStyles(styles)(connect(mapStateToProps)(
-    ({ classes, lines, linked, className, apiResponse, dispatch, auth }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) =>
+    ({ classes, lines, linked, className, apiResponse, dispatch, auth, children }: CodeSnippetProps & CodeSnippetAuthProps & DispatchProp) =>
         <Typography
         component="div"
         className={classNames(classes.root, className)}>
             <Typography className={apiResponse ? classes.space : className} component="pre">
+                {children}
                 {linked ?
                     lines.map((line, index) => <React.Fragment key={index}>{renderLinks(auth, dispatch)(line)}{`\n`}</React.Fragment>) :
                     lines.join('\n')
index 4f234743028f23cf2fd73464d3af2ad47e1a05f0..cf0b5e46e8b98f2f89dc9e5783a679736894bbcb 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 6eda97dc778c312fadfa84375a439f3e473d7bdf..daa776a06c3b269d5f51dbbf2aec73400ec0fef1 100644 (file)
@@ -59,8 +59,6 @@ import SettingsEthernet from '@material-ui/icons/SettingsEthernet';
 import Star from '@material-ui/icons/Star';
 import StarBorder from '@material-ui/icons/StarBorder';
 import Warning from '@material-ui/icons/Warning';
-import Visibility from '@material-ui/icons/Visibility';
-import VisibilityOff from '@material-ui/icons/VisibilityOff';
 import VpnKey from '@material-ui/icons/VpnKey';
 import LinkOutlined from '@material-ui/icons/LinkOutlined';
 import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
@@ -86,6 +84,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' />
@@ -184,8 +196,6 @@ export const SidePanelRightArrowIcon: IconType = (props) => <PlayArrow {...props
 export const TrashIcon: IconType = (props) => <Delete {...props} />;
 export const UserPanelIcon: IconType = (props) => <Person {...props} />;
 export const UsedByIcon: IconType = (props) => <Folder {...props} />;
-export const VisibleIcon: IconType = (props) => <Visibility {...props} />;
-export const InvisibleIcon: IconType = (props) => <VisibilityOff {...props} />;
 export const WorkflowIcon: IconType = (props) => <Code {...props} />;
 export const WarningIcon: IconType = (props) => <Warning style={{ color: '#fbc02d', height: '30px', width: '30px' }} {...props} />;
 export const Link: IconType = (props) => <LinkOutlined {...props} />;
index de8249909d3af4ba16c53b49490ec0144bc6b4d0..f4c3f3ba44f5624b83a0ebaf299239957d84263e 100644 (file)
@@ -15,7 +15,7 @@ import {
 import { GridProps } from '@material-ui/core/Grid';
 import { isArray } from 'lodash';
 import { DefaultView } from 'components/default-view/default-view';
-import { InfoIcon, InvisibleIcon, VisibleIcon } from 'components/icon/icon';
+import { InfoIcon } from 'components/icon/icon';
 import { ReactNodeArray } from 'prop-types';
 import classNames from 'classnames';
 
@@ -123,11 +123,12 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
             (panelStates[idx] &&
                 (panelStates[idx].visible || panelStates[idx].visible === undefined)));
     const [panelVisibility, setPanelVisibility] = useState<boolean[]>(visibility);
-    const [brightenedPanel, setBrightenedPanel] = useState<number>(-1);
+    const [highlightedPanel, setHighlightedPanel] = useState<number>(-1);
+    const [selectedPanel, setSelectedPanel] = useState<number>(-1);
     const panelRef = useRef<any>(null);
 
     let panels: JSX.Element[] = [];
-    let toggles: JSX.Element[] = [];
+    let buttons: JSX.Element[] = [];
 
     if (isArray(children)) {
         for (let idx = 0; idx < children.length; idx++) {
@@ -137,6 +138,7 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                     true,
                     ...panelVisibility.slice(idx+1)
                 ]);
+                setSelectedPanel(idx);
             };
             const hideFn = (idx: number) => () => {
                 setPanelVisibility([
@@ -153,44 +155,39 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
                     ...panelVisibility.slice(idx+1).map(() => false),
                 ])
             };
-            const toggleIcon = panelVisibility[idx]
-                ? <VisibleIcon className={classNames(classes.buttonIcon)} />
-                : <InvisibleIcon className={classNames(classes.buttonIcon)}/>
             const panelName = panelStates === undefined
                 ? `Panel ${idx+1}`
                 : (panelStates[idx] && panelStates[idx].name) || `Panel ${idx+1}`;
-            const toggleVariant = "outlined";
-            const toggleTooltip = panelVisibility[idx]
-                ? ''
-                :`Show ${panelName} panel`;
+            const btnVariant = panelVisibility[idx]
+                ? "contained"
+                : "outlined";
+            const btnTooltip = panelVisibility[idx]
+                ? ``
+                :`Open ${panelName} panel`;
             const panelIsMaximized = panelVisibility[idx] &&
                 panelVisibility.filter(e => e).length === 1;
 
-            let brightenerTimer: NodeJS.Timer;
-            toggles = [
-                ...toggles,
-                <Tooltip title={toggleTooltip} disableFocusListener>
-                    <Button variant={toggleVariant} size="small" color="primary"
+            buttons = [
+                ...buttons,
+                <Tooltip title={btnTooltip} disableFocusListener>
+                    <Button variant={btnVariant} size="small" color="primary"
                         className={classNames(classes.button)}
                         onMouseEnter={() => {
-                            brightenerTimer = setTimeout(
-                                () => setBrightenedPanel(idx), 100);
+                            setHighlightedPanel(idx);
                         }}
                         onMouseLeave={() => {
-                            brightenerTimer && clearTimeout(brightenerTimer);
-                            setBrightenedPanel(-1);
+                            setHighlightedPanel(-1);
                         }}
                         onClick={showFn(idx)}>
                             {panelName}
-                            {toggleIcon}
                     </Button>
                 </Tooltip>
             ];
 
             const aPanel =
                 <MPVHideablePanel key={idx} visible={panelVisibility[idx]} name={panelName}
-                    panelRef={(idx === brightenedPanel) ? panelRef : undefined}
-                    maximized={panelIsMaximized} illuminated={idx === brightenedPanel}
+                    panelRef={(idx === selectedPanel) ? panelRef : undefined}
+                    maximized={panelIsMaximized} illuminated={idx === highlightedPanel}
                     doHidePanel={hideFn(idx)} doMaximizePanel={maximizeFn(idx)}>
                     {children[idx]}
                 </MPVHideablePanel>;
@@ -200,9 +197,10 @@ const MPVContainerComponent = ({children, panelStates, classes, ...props}: MPVCo
 
     return <Grid container {...props}>
         <Grid container item direction="row">
-            { toggles.map((tgl, idx) => <Grid item key={idx}>{tgl}</Grid>) }
+            { buttons.map((tgl, idx) => <Grid item key={idx}>{tgl}</Grid>) }
         </Grid>
-        <Grid container item {...props} xs className={classes.content}>
+        <Grid container item {...props} xs className={classes.content}
+            onScroll={() => setSelectedPanel(-1)}>
             { panelVisibility.includes(true)
                 ? panels
                 : <Grid container item alignItems='center' justify='center'>
index c57d3608b06b470e260ed2ceabf76875290f3019..ba70f752b9d22fe131dd1b40fdbefe6b7dfa5731 100644 (file)
@@ -98,11 +98,22 @@ describe("<SearchInput />", () => {
     describe("on input target change", () => {
         it("clears the input value on selfClearProp change", () => {
             const searchInput = mount(<SearchInput selfClearProp="abc" value="123" onSearch={onSearch} debounce={1000}/>);
-            searchInput.setProps({ selfClearProp: 'aaa' });
+
+            // component should clear value upon creation
             jest.runTimersToTime(1000);
             expect(onSearch).toBeCalledWith("");
             expect(onSearch).toHaveBeenCalledTimes(1);
+
+            // component should not clear on same selfClearProp
+            searchInput.setProps({ selfClearProp: 'abc' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toHaveBeenCalledTimes(1);
+
+            // component should clear on selfClearProp change
+            searchInput.setProps({ selfClearProp: '111' });
+            jest.runTimersToTime(1000);
+            expect(onSearch).toBeCalledWith("");
+            expect(onSearch).toHaveBeenCalledTimes(2);
         });
     });
-
 });
index 50338f401c9387b680ef417a715e2130d932b265..8d86307ab71ac072ea676173b6098bcf5252c0d3 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
+import React, {useState, useEffect} from 'react';
 import { IconButton, StyleRulesCallback, withStyles, WithStyles, FormControl, InputLabel, Input, InputAdornment, Tooltip } from '@material-ui/core';
 import SearchIcon from '@material-ui/icons/Search';
 
@@ -45,82 +45,75 @@ interface SearchInputActionProps {
 
 type SearchInputProps = SearchInputDataProps & SearchInputActionProps & WithStyles<CssRules>;
 
-interface SearchInputState {
-    value: string;
-    label: string;
-    selfClearProp: string;
-}
-
 export const DEFAULT_SEARCH_DEBOUNCE = 1000;
 
-export const SearchInput = withStyles(styles)(
-    class extends React.Component<SearchInputProps> {
-        state: SearchInputState = {
-            value: "",
-            label: "",
-            selfClearProp: ""
-        };
+const SearchInputComponent = (props: SearchInputProps) => {
+    const [timeout, setTimeout] = useState(0);
+    const [value, setValue] = useState("");
+    const [label, setLabel] = useState("Search");
+    const [selfClearProp, setSelfClearProp] = useState("");
 
-        timeout: number;
-
-        render() {
-            return <form onSubmit={this.handleSubmit}>
-                <FormControl>
-                    <InputLabel>{this.state.label}</InputLabel>
-                    <Input
-                        type="text"
-                        data-cy="search-input"
-                        value={this.state.value}
-                        onChange={this.handleChange}
-                        endAdornment={
-                            <InputAdornment position="end">
-                                <Tooltip title='Search'>
-                                    <IconButton
-                                        onClick={this.handleSubmit}>
-                                        <SearchIcon />
-                                    </IconButton>
-                                </Tooltip>
-                            </InputAdornment>
-                        } />
-                </FormControl>
-            </form>;
+    useEffect(() => {
+        if (props.value) {
+            setValue(props.value);
         }
-
-        componentDidMount() {
-            this.setState({
-                value: this.props.value,
-                label: this.props.label || 'Search'
-            });
+        if (props.label) {
+            setLabel(props.label);
         }
 
-        componentWillReceiveProps(nextProps: SearchInputProps) {
-            if (nextProps.value !== this.props.value) {
-                this.setState({ value: nextProps.value });
-            }
-            if (this.state.value !== '' && nextProps.selfClearProp && nextProps.selfClearProp !== this.state.selfClearProp) {
-                this.props.onSearch('');
-                this.setState({ selfClearProp: nextProps.selfClearProp });
-            }
-        }
+        return () => {
+            setValue("");
+            clearTimeout(timeout);
+        };
+    }, [props.value, props.label]); // eslint-disable-line react-hooks/exhaustive-deps
 
-        componentWillUnmount() {
-            clearTimeout(this.timeout);
+    useEffect(() => {
+        if (selfClearProp !== props.selfClearProp) {
+            setValue("");
+            setSelfClearProp(props.selfClearProp);
+            handleChange({ target: { value: "" } } as any);
         }
+    }, [props.selfClearProp]); // eslint-disable-line react-hooks/exhaustive-deps
 
-        handleSubmit = (event: React.FormEvent<HTMLElement>) => {
-            event.preventDefault();
-            clearTimeout(this.timeout);
-            this.props.onSearch(this.state.value);
-        }
+    const handleSubmit = (event: React.FormEvent<HTMLElement>) => {
+        event.preventDefault();
+        clearTimeout(timeout);
+        props.onSearch(value);
+    };
 
-        handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-            clearTimeout(this.timeout);
-            this.setState({ value: event.target.value });
-            this.timeout = window.setTimeout(
-                () => this.props.onSearch(this.state.value),
-                this.props.debounce || DEFAULT_SEARCH_DEBOUNCE
-            );
+    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+        const { target: { value: eventValue } } = event;
+        clearTimeout(timeout);
+        setValue(eventValue);
+
+        setTimeout(window.setTimeout(
+            () => {
+                props.onSearch(eventValue);
+            },
+             props.debounce || DEFAULT_SEARCH_DEBOUNCE
+        ));
+    };
 
-        }
-    }
-);
+    return <form onSubmit={handleSubmit}>
+        <FormControl>
+            <InputLabel>{label}</InputLabel>
+            <Input
+                type="text"
+                data-cy="search-input"
+                value={value}
+                onChange={handleChange}
+                endAdornment={
+                    <InputAdornment position="end">
+                        <Tooltip title='Search'>
+                            <IconButton
+                                onClick={handleSubmit}>
+                                <SearchIcon />
+                            </IconButton>
+                        </Tooltip>
+                    </InputAdornment>
+                } />
+        </FormControl>
+    </form>;
+}
+
+export const SearchInput = withStyles(styles)(SearchInputComponent);
\ No newline at end of file
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 4809e7a80c83071b0d5889ce8a81b7b661bc4f83..da67935a1e5d8a44c5bf2c601c3fc639204ca8ab 100644 (file)
@@ -65,7 +65,18 @@ export class FilterBuilder {
     }
 
     public addFullTextSearch(value: string) {
-        const terms = value.trim().split(/(\s+)/);
+        const regex = /"[^"]*"/;
+        const matches: any[] = [];
+
+        let match = value.match(regex);
+
+        while (match) {
+            value = value.replace(match[0], "");
+            matches.push(match[0].replace(/"/g, ''));
+            match = value.match(regex);
+        }
+
+        const terms = value.trim().split(/(\s+)/).concat(matches);
         terms.forEach(term => {
             if (term !== " ") {
                 this.addCondition("any", "ilike", term, "%", "%");
index b94756ae02f18f0cf7f8ebaa85ec5dc950b17fbc..7f47f20ef77f296795b9003b1ef1df00ff679401 100644 (file)
@@ -136,8 +136,9 @@ describe("CommonResourceService", () => {
         await commonResourceService.list({ filters: tooBig });
         expect(axiosMock.history.get.length).toBe(0);
         expect(axiosMock.history.post.length).toBe(1);
-        expect(axiosMock.history.post[0].data.get('filters')).toBe(`[${tooBig}]`);
-        expect(axiosMock.history.post[0].params._method).toBe('GET');
+        const postParams = new URLSearchParams(axiosMock.history.post[0].data);
+        expect(postParams.get('filters')).toBe(`[${tooBig}]`);
+        expect(postParams.get('_method')).toBe('GET');
     });
 
     it("#list using GET when query string is not too big", async () => {
index f16a2024ad2c3a1775d4a5fb7e83f784e08554e4..bdae87ab4089a48e769c34fed83f6c0998418ba9 100644 (file)
@@ -156,7 +156,9 @@ export class CommonService<T> {
             );
         } else {
             // Using the POST special case to avoid URI length 414 errors.
-            const formData = new FormData();
+            // We must use urlencoded post body since api doesn't support form data
+            // const formData = new FormData();
+            const formData = new URLSearchParams();
             formData.append("_method", "GET");
             Object.keys(params).forEach(key => {
                 if (params[key] !== undefined) {
@@ -164,11 +166,7 @@ export class CommonService<T> {
                 }
             });
             return CommonService.defaultResponse(
-                this.serverApi.post(`/${this.resourceType}`, formData, {
-                    params: {
-                        _method: 'GET'
-                    }
-                }),
+                this.serverApi.post(`/${this.resourceType}`, formData, {}),
                 this.actions,
                 showErrors
             );
index 61fd705ac3778d335fcfeb8db9b9f0221f295d87..ac088f025b8cdd72374584748d3764bb5752df48 100644 (file)
@@ -26,8 +26,9 @@ import React from 'react';
 
 export const ADVANCED_TAB_DIALOG = 'advancedTabDialog';
 
-interface AdvancedTabDialogData {
-    apiResponse: any;
+export interface AdvancedTabDialogData {
+    uuid: string;
+    apiResponse: JSX.Element;
     metadata: ListResults<LinkResource> | string;
     user: UserResource | string;
     pythonHeader: string;
@@ -290,7 +291,7 @@ interface AdvancedTabData {
     uuid: string;
     metadata: ListResults<LinkResource> | string;
     user: UserResource | string;
-    apiResponseKind: any;
+    apiResponseKind: (apiResponse) => JSX.Element;
     data: AdvanceResponseData;
     resourceKind: AdvanceResourceKind;
     resourcePrefix: AdvanceResourcePrefix;
@@ -370,7 +371,7 @@ const stringify = (item: string | null | number | boolean) =>
 const stringifyObject = (item: any) =>
     JSON.stringify(item, null, 2) || 'null';
 
-const containerRequestApiResponse = (apiResponse: ContainerRequestResource) => {
+const containerRequestApiResponse = (apiResponse: ContainerRequestResource): JSX.Element => {
     const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, properties, state, requestingContainerUuid, containerUuid,
         containerCountMax, mounts, runtimeConstraints, containerImage, environment, cwd, command, outputPath, priority, expiresAt, filters, containerCount,
         useExisting, schedulingParameters, outputUuid, logUuid, outputName, outputTtl } = apiResponse;
@@ -409,7 +410,7 @@ const containerRequestApiResponse = (apiResponse: ContainerRequestResource) => {
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const collectionApiResponse = (apiResponse: CollectionResource) => {
+const collectionApiResponse = (apiResponse: CollectionResource): JSX.Element => {
     const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, properties, portableDataHash, replicationDesired,
         replicationConfirmedAt, replicationConfirmed, deleteAt, trashAt, isTrashed, storageClassesDesired,
         storageClassesConfirmed, storageClassesConfirmedAt, currentVersionUuid, version, preserveVersion, fileCount, fileSizeTotal } = apiResponse;
@@ -442,7 +443,7 @@ const collectionApiResponse = (apiResponse: CollectionResource) => {
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const groupRequestApiResponse = (apiResponse: ProjectResource) => {
+const groupRequestApiResponse = (apiResponse: ProjectResource): JSX.Element => {
     const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, description, groupClass, trashAt, isTrashed, deleteAt, properties, writableBy } = apiResponse;
     const response = `
 "uuid": "${uuid}",
@@ -463,7 +464,7 @@ const groupRequestApiResponse = (apiResponse: ProjectResource) => {
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const repositoryApiResponse = (apiResponse: RepositoryResource) => {
+const repositoryApiResponse = (apiResponse: RepositoryResource): JSX.Element => {
     const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, cloneUrls } = apiResponse;
     const response = `
 "uuid": "${uuid}",
@@ -478,7 +479,7 @@ const repositoryApiResponse = (apiResponse: RepositoryResource) => {
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const sshKeyApiResponse = (apiResponse: SshKeyResource) => {
+const sshKeyApiResponse = (apiResponse: SshKeyResource): JSX.Element => {
     const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, name, authorizedUserUuid, expiresAt } = apiResponse;
     const response = `
 "uuid": "${uuid}",
@@ -493,7 +494,7 @@ const sshKeyApiResponse = (apiResponse: SshKeyResource) => {
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource) => {
+const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource): JSX.Element => {
     const { uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid, hostname } = apiResponse;
     const response = `
 "hostname": ${stringify(hostname)},
@@ -508,7 +509,7 @@ const virtualMachineApiResponse = (apiResponse: VirtualMachinesResource) => {
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const keepServiceApiResponse = (apiResponse: KeepServiceResource) => {
+const keepServiceApiResponse = (apiResponse: KeepServiceResource): JSX.Element => {
     const {
         uuid, readOnly, serviceHost, servicePort, serviceSslFlag, serviceType,
         ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid
@@ -529,7 +530,7 @@ const keepServiceApiResponse = (apiResponse: KeepServiceResource) => {
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const userApiResponse = (apiResponse: UserResource) => {
+const userApiResponse = (apiResponse: UserResource): JSX.Element => {
     const {
         uuid, ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid,
         email, firstName, lastName, username, isActive, isAdmin, prefs, defaultOwnerUuid,
@@ -554,7 +555,7 @@ const userApiResponse = (apiResponse: UserResource) => {
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const apiClientAuthorizationApiResponse = (apiResponse: ApiClientAuthorization) => {
+const apiClientAuthorizationApiResponse = (apiResponse: ApiClientAuthorization): JSX.Element => {
     const {
         uuid, ownerUuid, apiToken, apiClientId, userId, createdByIpAddress, lastUsedByIpAddress,
         lastUsedAt, expiresAt, defaultOwnerUuid, scopes, updatedAt, createdAt
@@ -577,7 +578,7 @@ const apiClientAuthorizationApiResponse = (apiResponse: ApiClientAuthorization)
     return <span style={{ marginLeft: '-15px' }}>{'{'} {response} {'\n'} <span style={{ marginLeft: '-15px' }}>{'}'}</span></span>;
 };
 
-const linkApiResponse = (apiResponse: LinkResource) => {
+const linkApiResponse = (apiResponse: LinkResource): JSX.Element => {
     const {
         uuid, name, headUuid, properties, headKind, tailUuid, tailKind, linkClass,
         ownerUuid, createdAt, modifiedAt, modifiedByClientUuid, modifiedByUserUuid
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
index c465aae8695a51291918aa0fd630e57fe8b327c6..6b9db6a538e1322004f14138b4e45b0bd8b53a73 100644 (file)
@@ -21,7 +21,9 @@ export const copyToClipboardAction = (resource: any) => (dispatch: Dispatch, get
     // Copy to clipboard omits token to avoid accidental sharing
     const url = getNavUrl(resource.uuid, getState().auth, false);
 
-    if (url) {
+    if (url[0] === '/') {
+        copy(`${window.location.origin}${url}`);
+    } else if (url.length) {
         copy(url);
     }
 };
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 cdc6c0c7267d4bab58434999b260d8905de7bc82..c0fdeda5a74a546ee7c8c350f72f3d9b3c413dba 100644 (file)
@@ -152,11 +152,11 @@ export const initializeManagementForm = async (dispatch: Dispatch, getState: ()
         const { items: permissionLinks } = await permissionService.listResourcePermissions(resourceUuid);
         dispatch<any>(initializePublicAccessForm(permissionLinks));
         const filters = new FilterBuilder()
-            .addIn('uuid', permissionLinks.map(({ tailUuid }) => tailUuid))
+            .addIn('uuid', Array.from(new Set(permissionLinks.map(({ tailUuid }) => tailUuid))))
             .getFilters();
 
-        const { items: users } = await userService.list({ filters, count: "none" });
-        const { items: groups } = await groupsService.list({ filters, count: "none" });
+        const { items: users } = await userService.list({ filters, count: "none", limit: 1000 });
+        const { items: groups } = await groupsService.list({ filters, count: "none", limit: 1000 });
 
         const getEmail = (tailUuid: string) => {
             const user = users.find(({ uuid }) => uuid === tailUuid);
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 e4b17ea0b807d4bfbeb544e9f1a811aedbec290b..bd07efb6409f3f09368b10eaf60c479234257bdb 100644 (file)
@@ -83,7 +83,8 @@ export const loadVirtualMachinesAdminData = () =>
             filters: new FilterBuilder()
             .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
             .addEqual('name', PermissionLevel.CAN_LOGIN)
-            .getFilters()
+            .getFilters(),
+            limit: 1000
         });
         dispatch(updateResources(logins.items));
         dispatch(virtualMachinesActions.SET_LINKS(logins));
@@ -92,7 +93,8 @@ export const loadVirtualMachinesAdminData = () =>
             filters: new FilterBuilder()
             .addIn('uuid', logins.items.map(item => item.tailUuid))
             .getFilters(),
-            count: "none"
+            count: "none", // Necessary for federated queries
+            limit: 1000
         });
         dispatch(updateResources(users.items));
 
@@ -103,11 +105,13 @@ export const loadVirtualMachinesAdminData = () =>
 export const loadVirtualMachinesUserData = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch<any>(loadRequestedDate());
+        const user = getState().auth.user;
         const virtualMachines = await services.virtualMachineService.list();
         const virtualMachinesUuids = virtualMachines.items.map(it => it.uuid);
         const links = await services.linkService.list({
             filters: new FilterBuilder()
                 .addIn("head_uuid", virtualMachinesUuids)
+                .addEqual("tail_uuid", user?.uuid)
                 .getFilters()
         });
         dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
index f493df33ad2533cbf7fcba1a3c16993f2425deb1..bc84ed2cf3aeef617ff3cafcbacab432536a027a 100644 (file)
@@ -7,9 +7,11 @@ import { Dialog, DialogActions, Button, StyleRulesCallback, WithStyles, withStyl
 import { WithDialogProps } from 'store/dialog/with-dialog';
 import { withDialog } from "store/dialog/with-dialog";
 import { compose } from 'redux';
-import { ADVANCED_TAB_DIALOG } from "store/advanced-tab/advanced-tab";
+import { AdvancedTabDialogData, ADVANCED_TAB_DIALOG } from "store/advanced-tab/advanced-tab";
 import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet";
 import { MetadataTab } from 'views-components/advanced-tab-dialog/metadataTab';
+import { LinkResource } from "models/link";
+import { ListResults } from "services/common-service/common-service";
 
 type CssRules = 'content' | 'codeSnippet' | 'spacing';
 
@@ -34,7 +36,7 @@ export const AdvancedTabDialog = compose(
     withDialog(ADVANCED_TAB_DIALOG),
     withStyles(styles),
 )(
-    class extends React.Component<WithDialogProps<any> & WithStyles<CssRules>>{
+    class extends React.Component<WithDialogProps<AdvancedTabDialogData> & WithStyles<CssRules>>{
         state = {
             value: 0,
         };
@@ -67,7 +69,7 @@ export const AdvancedTabDialog = compose(
                 maxWidth="lg"
                 onClose={closeDialog}
                 onExit={() => this.setState({ value: 0 })} >
-                <DialogTitle>Advanced</DialogTitle>
+                <DialogTitle>API Details</DialogTitle>
                 <Tabs value={value} onChange={this.handleChange} fullWidth>
                     <Tab label="API RESPONSE" />
                     <Tab label="METADATA" />
@@ -78,8 +80,8 @@ export const AdvancedTabDialog = compose(
                 <DialogContent className={classes.content}>
                     {value === 0 && <div>{dialogContentExample(apiResponse, classes)}</div>}
                     {value === 1 && <div>
-                        {metadata !== '' && metadata.items.length > 0 ?
-                            <MetadataTab items={metadata.items} uuid={uuid} />
+                        {metadata !== '' && (metadata as ListResults<LinkResource>).items.length > 0 ?
+                            <MetadataTab items={(metadata as ListResults<LinkResource>).items} uuid={uuid} />
                             : dialogContentHeader('(No metadata links found)')}
                     </div>}
                     {value === 2 && dialogContent(pythonHeader, pythonExample, classes)}
@@ -110,8 +112,14 @@ const dialogContentHeader = (header: string) =>
         {header}
     </DialogContentText>;
 
-const dialogContentExample = (example: string, classes: any) =>
-    <DefaultCodeSnippet
+const dialogContentExample = (example: JSX.Element | string, classes: any) => {
+    // Pass string to lines param or JSX to child props
+    const stringData = example && (example as string).length ? (example as string) : undefined;
+    return <DefaultCodeSnippet
         apiResponse
         className={classes.codeSnippet}
-        lines={[example]} />;
\ No newline at end of file
+        lines={stringData ? [stringData] : []}
+    >
+        {example as JSX.Element || null}
+    </DefaultCodeSnippet>;
+}
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 3394b21104a4d122d2bdd813154d81d1de5198f2..aeaa6a22f85e50c3a6f39cfea891507c03114b63 100644 (file)
@@ -17,7 +17,7 @@ export const apiClientAuthorizationActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openApiClientAuthorizationAttributesDialog(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openAdvancedTabDialog(uuid));
index 9b0efac0409b5787b0c5d28b893441a858ffe475..edfaa3cdf0b84d609eab951ac380dabaca73c5ce 100644 (file)
@@ -78,7 +78,7 @@ const commonActionSet: ContextMenuActionSet = [[
     },
     {
         icon: AdvancedIcon,
-        name: "Advanced",
+        name: "API Details",
         execute: (dispatch, resource) => {
             dispatch<any>(openAdvancedTabDialog(resource.uuid));
         }
index 874a601b17efb9c615f7561fc311c2c7af94f032..f573af69a7807450dcff03fdb663d27a3322ac5a 100644 (file)
@@ -20,7 +20,7 @@ export const groupActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openGroupAttributes(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, resource) => {
         dispatch<any>(openAdvancedTabDialog(resource.uuid));
index b7215c7055378bf404a7ea7b8af5734780e3627c..37aa35c0fd26b0ba2f71520d9c19901a263e294b 100644 (file)
@@ -14,7 +14,7 @@ export const groupMemberActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openGroupMemberAttributes(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, resource) => {
         dispatch<any>(openAdvancedTabDialog(resource.uuid));
@@ -25,4 +25,4 @@ export const groupMemberActionSet: ContextMenuActionSet = [[{
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openRemoveGroupMemberDialog(uuid));
     }
-}]];
\ No newline at end of file
+}]];
index b2d30baf5e1df87a368c9cd3384d5f39b680f25c..820d1978fd37b9a16ff0796827a0ff8801fadeba 100644 (file)
@@ -14,7 +14,7 @@ export const keepServiceActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openKeepServiceAttributesDialog(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openAdvancedTabDialog(uuid));
index 0b70ba9b612b5271b272be228d2a63deece5a587..929a65a974e32724af766ffb9bbc16a72459c315 100644 (file)
@@ -14,7 +14,7 @@ export const linkActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openLinkAttributesDialog(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openAdvancedTabDialog(uuid));
index f17d74c92ba79dd1fd6ad68dcecf69f8ab38c2c7..56cfee85d1a4388fe261e1b1ffe2ba5b413ff902 100644 (file)
@@ -69,7 +69,7 @@ export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
     },
     {
         icon: AdvancedIcon,
-        name: "Advanced",
+        name: "API Details",
         execute: (dispatch, resource) => {
             dispatch<any>(openAdvancedTabDialog(resource.uuid));
         }
index a079bf4ff3c314b157d9f9bb8977a07a9c8300ee..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: "Advanced",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
-        }
-    },
-    {
-        icon: FolderSharedIcon,
-        name: "Open with 3rd party client",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openWebDavS3InfoDialog(resource.uuid));
+export const toggleFavoriteAction = {
+    component: ToggleFavoriteAction,
+    name: 'ToggleFavoriteAction',
+    execute: (dispatch, resource) => {
+        dispatch(toggleFavorite(resource)).then(() => {
+            dispatch(favoritePanelActions.REQUEST_ITEMS());
+        });
+    }
+};
+
+export const openInNewTabMenuAction = {
+    icon: OpenIcon,
+    name: "Open in new tab",
+    execute: (dispatch, resource) => {
+        dispatch(openInNewTabAction(resource));
+    }
+};
+
+export const copyToClipboardMenuAction = {
+    icon: Link,
+    name: "Copy to clipboard",
+    execute: (dispatch, resource) => {
+        dispatch(copyToClipboardAction(resource));
+    }
+};
+
+export const viewDetailsAction = {
+    icon: DetailsIcon,
+    name: "View details",
+    execute: dispatch => {
+        dispatch(toggleDetailsPanel());
+    }
+}
+
+export const advancedAction = {
+    icon: AdvancedIcon,
+    name: "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
 ]];
index fdd9d288ae57e7a10998b01f5a703912613d4527..12fec7c4024611b82ace644afc27ea7101f44a7a 100644 (file)
@@ -21,7 +21,7 @@ export const repositoryActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openSharingDialog(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, resource) => {
         dispatch<any>(openAdvancedTabDialog(resource.uuid));
index e916a1057ac6cf2f0ef667704f94505537156c49..aeb6d15501f782e20c29d22ac65b56cfb5f37a8c 100644 (file)
@@ -33,7 +33,7 @@ export const searchResultsActionSet: ContextMenuActionSet = [
         },
         {
             icon: AdvancedIcon,
-            name: "Advanced",
+            name: "API Details",
             execute: (dispatch, resource) => {
                 dispatch<any>(openAdvancedTabDialog(resource.uuid));
             }
index 587a05bc08a5c52b90af206f4f774ca70ff69378..d1a94cd311318e76cdbf0bfd9f61fada1a549838 100644 (file)
@@ -14,7 +14,7 @@ export const sshKeyActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openSshKeyAttributesDialog(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openAdvancedTabDialog(uuid));
index b3d893b4b345ce9ed554d74dc8b895643245059f..020ff5c747487c776ba11a040419853d656e9931 100644 (file)
@@ -25,7 +25,7 @@ export const trashedCollectionActionSet: ContextMenuActionSet = [[
     },
     {
         icon: AdvancedIcon,
-        name: "Advanced",
+        name: "API Details",
         execute: (dispatch, resource) => {
             dispatch<any>(openAdvancedTabDialog(resource.uuid));
         }
index c298e1ab33ba1ac24564acbbee1f0d8bbb0f2040..c00b7f1f285c157bd95e2d2ef5e386a0e94fb7d8 100644 (file)
@@ -32,7 +32,7 @@ export const userActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openUserProjects(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openAdvancedTabDialog(uuid));
index 721a6a2f46d6340df7e4f143abb0061352c1ad74..be9567cd035a2a244352f392d955ea915b61e3a2 100644 (file)
@@ -14,7 +14,7 @@ export const virtualMachineActionSet: ContextMenuActionSet = [[{
         dispatch<any>(openVirtualMachineAttributes(uuid));
     }
 }, {
-    name: "Advanced",
+    name: "API Details",
     icon: AdvancedIcon,
     execute: (dispatch, { uuid }) => {
         dispatch<any>(openAdvancedTabDialog(uuid));
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 5d105577db9689c3f5b0da9c1f16220704485db2..47e5b287ae7acb3fce265916da02964286b68748 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 29c34e6726c5d993f964132351b7586a678c3307..dd6e63bfc87fd8d9a77f082e303bce94714554cc 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 1654452bf493b656b031c648fa1faf96a1275e1a..b591bb8fcebbeb24b832db6dff40756a1ebe36b3 100644 (file)
@@ -9,7 +9,7 @@ import { withDialog, WithDialogProps } from "store/dialog/with-dialog";
 import { FormDialog } from 'components/form-dialog/form-dialog';
 import { VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, VIRTUAL_MACHINE_ADD_LOGIN_FORM, addUpdateVirtualMachineLogin, AddLoginFormData, VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD, VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD } from 'store/virtual-machines/virtual-machines-actions';
 import { ParticipantSelect } from 'views-components/sharing-dialog/participant-select';
-import { GroupArrayInput } from 'views-components/virtual-machines-dialog/group-array-input';
+import { GroupArrayInput, GroupArrayDataProps } from 'views-components/virtual-machines-dialog/group-array-input';
 
 export const VirtualMachineAddLoginDialog = compose(
     withDialog(VIRTUAL_MACHINE_ADD_LOGIN_DIALOG),
@@ -20,16 +20,25 @@ export const VirtualMachineAddLoginDialog = compose(
         }
     })
 )(
-    (props: CreateGroupDialogComponentProps) =>
-        <FormDialog
+    (props: CreateGroupDialogComponentProps) => {
+        const [hasPartialGroupInput, setPartialGroupInput] = React.useState<boolean>(false);
+
+        return <FormDialog
             dialogTitle={props.data.updating ? "Update login permission" : "Add login permission"}
             formFields={AddLoginFormFields}
             submitLabel={props.data.updating ? "Update" : "Add"}
             {...props}
-        />
+            data={{
+                ...props.data,
+                setPartialGroupInput,
+                hasPartialGroupInput,
+            }}
+            invalid={props.invalid || hasPartialGroupInput}
+        />;
+    }
 );
 
-type CreateGroupDialogComponentProps = WithDialogProps<{updating: boolean}> & InjectedFormProps<AddLoginFormData>;
+type CreateGroupDialogComponentProps = WithDialogProps<{updating: boolean}> & GroupArrayDataProps & InjectedFormProps<AddLoginFormData>;
 
 const AddLoginFormFields = (props) => {
     return <>
@@ -42,6 +51,8 @@ const AddLoginFormFields = (props) => {
             name={VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD}
             input={{id:"Add groups to VM login (eg: docker, sudo)", disabled:false}}
             required={false}
+            setPartialGroupInput={props.data.setPartialGroupInput}
+            hasPartialGroupInput={props.data.hasPartialGroupInput}
         />
     </>;
 }
index 3ea5e77c883e2316f2d2fc7dd16a40dfd7953558..95a86420795b36d22142b4277cd0d4387077ffd3 100644 (file)
@@ -4,34 +4,59 @@
 
 import React from 'react';
 import { StringArrayCommandInputParameter } from 'models/workflow';
-import { Field } from 'redux-form';
+import { Field, GenericField } from 'redux-form';
 import { GenericInputProps } from 'views/run-process-panel/inputs/generic-input';
 import { ChipsInput } from 'components/chips-input/chips-input';
 import { identity } from 'lodash';
-import { withStyles, WithStyles, FormGroup, Input, InputLabel, FormControl } from '@material-ui/core';
+import { withStyles, WithStyles, FormGroup, Input, InputLabel, FormControl, FormHelperText } from '@material-ui/core';
+import classnames from "classnames";
+import { ArvadosTheme } from 'common/custom-theme';
 
-export interface StringArrayInputProps {
+export interface GroupArrayDataProps {
+  hasPartialGroupInput?: boolean;
+  setPartialGroupInput?: (value: boolean) => void;
+}
+
+interface GroupArrayFieldProps {
+  commandInput: StringArrayCommandInputParameter;
+}
+
+const GroupArrayField = Field as new () => GenericField<GroupArrayDataProps & GroupArrayFieldProps>;
+
+export interface GroupArrayInputProps {
   name: string;
   input: StringArrayCommandInputParameter;
   required: boolean;
 }
 
-type CssRules = 'chips';
+type CssRules = 'chips' | 'partialInputHelper' | 'partialInputHelperVisible';
 
-const styles = {
+const styles = (theme: ArvadosTheme) => ({
     chips: {
         marginTop: "16px",
     },
-};
+    partialInputHelper: {
+        textAlign: 'right' as 'right',
+        visibility: 'hidden' as 'hidden',
+        color: theme.palette.error.dark,
+    },
+    partialInputHelperVisible: {
+        visibility: 'visible' as 'visible',
+    }
+});
 
-export const GroupArrayInput = ({name, input}: StringArrayInputProps) =>
-    <Field
-        name={name}
-        commandInput={input}
-        component={StringArrayInputComponent as any}
-        />;
+export const GroupArrayInput = ({name, input, setPartialGroupInput, hasPartialGroupInput}: GroupArrayInputProps & GroupArrayDataProps) => {
+  console.log(hasPartialGroupInput);
+  return <GroupArrayField
+      name={name}
+      commandInput={input}
+      component={GroupArrayInputComponent as any}
+      setPartialGroupInput={setPartialGroupInput}
+      hasPartialGroupInput={hasPartialGroupInput}
+      />;
+}
 
-const StringArrayInputComponent = (props: GenericInputProps) => {
+const GroupArrayInputComponent = (props: GenericInputProps & GroupArrayDataProps) => {
   return <FormGroup>
         <FormControl fullWidth error={props.meta.error}>
           <InputLabel shrink={props.meta.active || props.input.value.length > 0}>{props.commandInput.id}</InputLabel>
@@ -41,24 +66,30 @@ const StringArrayInputComponent = (props: GenericInputProps) => {
     };
 
 const StyledInputComponent = withStyles(styles)(
-  class InputComponent extends React.PureComponent<GenericInputProps & WithStyles<CssRules>>{
+  class InputComponent extends React.PureComponent<GenericInputProps & WithStyles<CssRules> & GroupArrayDataProps>{
       render() {
           const { classes } = this.props;
-          const { commandInput, input, meta } = this.props;
-          return <ChipsInput
-              deletable={!commandInput.disabled}
-              orderable={!commandInput.disabled}
-              disabled={commandInput.disabled}
-              values={input.value}
-              onChange={this.handleChange}
-              handleFocus={input.onFocus}
-              createNewValue={identity}
-              inputComponent={Input}
-              chipsClassName={classes.chips}
-              pattern={/[_a-z][-0-9_a-z]*/ig}
-              inputProps={{
-                  error: meta.error,
-              }} />;
+          const { commandInput, input, meta, hasPartialGroupInput } = this.props;
+          return <>
+            <ChipsInput
+                deletable={!commandInput.disabled}
+                orderable={!commandInput.disabled}
+                disabled={commandInput.disabled}
+                values={input.value}
+                onChange={this.handleChange}
+                handleFocus={input.onFocus}
+                createNewValue={identity}
+                inputComponent={Input}
+                chipsClassName={classes.chips}
+                pattern={/[_a-z][-0-9_a-z]*/ig}
+                onPartialInput={this.props.setPartialGroupInput}
+                inputProps={{
+                    error: meta.error || hasPartialGroupInput,
+                }} />
+                <FormHelperText className={classnames([classes.partialInputHelper, ...(hasPartialGroupInput ? [classes.partialInputHelperVisible] : [])])}>
+                  Press enter to complete group name
+                </FormHelperText>
+          </>;
       }
 
       handleChange = (values: {}[]) => {
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;
         }
 
     });
index 468ef35a984da4f7a1198d886c576e4d9a9e33ba..864218e4a17a17b9d531a8087bacb73757a06d7a 100644 (file)
@@ -16,7 +16,7 @@ import { VirtualMachineLogins, VirtualMachinesResource } from 'models/virtual-ma
 import { openVirtualMachinesContextMenu } from 'store/context-menu/context-menu-actions';
 import { ResourceUuid, VirtualMachineHostname, VirtualMachineLogin } from 'views-components/data-explorer/renderers';
 
-type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot';
+type CssRules = 'moreOptionsButton' | 'moreOptions' | 'chipsRoot' | 'vmTableWrapper';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     moreOptionsButton: {
@@ -31,6 +31,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     chipsRoot: {
         margin: `0px -${theme.spacing.unit / 2}px`,
     },
+    vmTableWrapper: {
+        overflowX: 'auto',
+    },
 });
 
 const mapStateToProps = (state: RootState) => {
@@ -95,7 +98,7 @@ export const VirtualMachineAdminPanel = compose(
 const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
     <Grid item xs={12}>
         <Card>
-            <CardContent>
+            <CardContent className={props.classes.vmTableWrapper}>
                 {virtualMachinesTable(props)}
             </CardContent>
         </Card>
index 70f97daf029cff6def9f86089030eea0edf7fb82..751ca5f190d1a7162d19a127e443f4f7287cb4f9 100644 (file)
@@ -19,7 +19,7 @@ import { CopyIcon } from 'components/icon/icon';
 import CopyToClipboard from 'react-copy-to-clipboard';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 
-type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'webshellButton';
+type CssRules = 'button' | 'codeSnippet' | 'link' | 'linkIcon' | 'rightAlign' | 'cardWithoutMachines' | 'icon' | 'chipsRoot' | 'copyIcon' | 'tableWrapper' | 'webshellButton';
 
 const EXTRA_TOKEN = "exraToken";
 
@@ -72,6 +72,9 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
             fontSize: '1rem'
         }
     },
+    tableWrapper: {
+        overflowX: 'auto',
+    },
     webshellButton: {
         textTransform: "initial",
     },
@@ -176,7 +179,9 @@ const CardContentWithVirtualMachines = (props: VirtualMachineProps) =>
                             </Tooltip>
                         </a>
                     </div>
-                    {virtualMachinesTable(props)}
+                    <div className={props.classes.tableWrapper}>
+                        {virtualMachinesTable(props)}
+                    </div>
                 </span>
 
             </CardContent>
index 64f25cc2e921d34644d2fad2d8b9a3a3cb10ec92..924540208807fca8d7f7a489e8889919bddf28de 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -4971,13 +4971,20 @@ __metadata:
   languageName: node
   linkType: hard
 
-"caniuse-lite@npm:1.0.30001299, caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001219":
+"caniuse-lite@npm:1.0.30001299":
   version: 1.0.30001299
   resolution: "caniuse-lite@npm:1.0.30001299"
   checksum: c770f60ebf3e0cc8043ba4db0ebec12d7a595a6b50cb4437c3c5c55b04de9d2413f711f2828be761e8c37bb46b927a8abe6b199b8f0ffc1a34af0ebdee84be27
   languageName: node
   linkType: hard
 
+"caniuse-lite@npm:^1.0.0, caniuse-lite@npm:^1.0.30000981, caniuse-lite@npm:^1.0.30001035, caniuse-lite@npm:^1.0.30001109, caniuse-lite@npm:^1.0.30001219":
+  version: 1.0.30001414
+  resolution: "caniuse-lite@npm:1.0.30001414"
+  checksum: 97210cfd15ded093b20c33d35bef9711a88402c3345411dad420c991a41a3e38ad17fd66721e8334c86e9b2e4aa2c1851d3631f1441afb73b92d93b2b8ca890d
+  languageName: node
+  linkType: hard
+
 "capture-exit@npm:^2.0.0":
   version: 2.0.0
   resolution: "capture-exit@npm:2.0.0"