Merge branch '21026-sanitize-html'
authorLisa Knox <lisaknox83@gmail.com>
Wed, 25 Oct 2023 17:02:33 +0000 (13:02 -0400)
committerLisa Knox <lisaknox83@gmail.com>
Wed, 25 Oct 2023 17:02:33 +0000 (13:02 -0400)
closes #21026

Arvados-DCO-1.1-Signed-off-by: Lisa Knox <lisa.knox@curii.com>

133 files changed:
cypress/fixtures/workflow_directory_array.yaml [new file with mode: 0644]
cypress/integration/banner-tooltip.spec.js
cypress/integration/collection.spec.js
cypress/integration/create-workflow.spec.js
cypress/integration/process.spec.js
cypress/integration/project.spec.js
cypress/integration/search.spec.js
cypress/integration/virtual-machine-admin.spec.js
cypress/support/commands.js
src/components/collection-panel-files/collection-panel-files.tsx
src/components/copy-to-clipboard-snackbar/copy-to-clipboard-snackbar.tsx
src/components/data-explorer/data-explorer.test.tsx
src/components/data-explorer/data-explorer.tsx
src/components/data-table-filters/data-table-filters-popover.tsx
src/components/data-table-filters/data-table-filters-tree.tsx
src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx [new file with mode: 0644]
src/components/data-table/data-table.test.tsx
src/components/data-table/data-table.tsx
src/components/icon/icon.tsx
src/components/multiselect-toolbar/MultiselectToolbar.tsx [new file with mode: 0644]
src/components/multiselect-toolbar/ms-kind-action-differentiator.ts [new file with mode: 0644]
src/components/multiselect-toolbar/ms-toolbar-action-filters.ts [new file with mode: 0644]
src/components/select-field/select-field.tsx
src/index.tsx
src/models/tree.test.ts
src/models/tree.ts
src/services/collection-service/collection-service-files-response.ts
src/services/collection-service/collection-service.ts
src/services/common-service/trashable-resource-service.ts
src/store/all-processes-panel/all-processes-panel-middleware-service.ts
src/store/breadcrumbs/breadcrumbs-actions.ts
src/store/collection-panel/collection-panel-action.ts
src/store/collections/collection-copy-actions.ts
src/store/collections/collection-move-actions.ts
src/store/collections/collection-partial-copy-actions.ts
src/store/collections/collection-partial-move-actions.ts
src/store/context-menu/context-menu-actions.ts
src/store/copy-dialog/copy-dialog.ts
src/store/data-explorer/data-explorer-action.ts
src/store/data-explorer/data-explorer-middleware-service.ts
src/store/data-explorer/data-explorer-middleware.ts
src/store/dialog/dialog-reducer.ts
src/store/dialog/with-dialog.ts
src/store/group-details-panel/group-details-panel-members-middleware-service.ts
src/store/link-panel/link-panel-middleware-service.ts
src/store/move-to-dialog/move-to-dialog.ts
src/store/multiselect/multiselect-actions.tsx [new file with mode: 0644]
src/store/multiselect/multiselect-reducer.tsx [new file with mode: 0644]
src/store/navigation/navigation-action.ts
src/store/open-in-new-tab/open-in-new-tab.actions.ts
src/store/process-panel/process-panel-actions.ts
src/store/process-panel/process-panel-reducer.ts
src/store/processes/process-copy-actions.ts
src/store/processes/process-move-actions.ts
src/store/processes/process-update-actions.ts
src/store/processes/process.ts
src/store/processes/processes-actions.ts
src/store/project-panel/project-panel-action-bind.ts [new file with mode: 0644]
src/store/project-panel/project-panel-action.ts
src/store/project-panel/project-panel-middleware-service.ts
src/store/projects/project-lock-actions.ts
src/store/projects/project-move-actions.ts
src/store/projects/project-update-actions.ts
src/store/resource-type-filters/resource-type-filters.test.ts
src/store/resources/resources.ts
src/store/run-process-panel/run-process-panel-actions.ts
src/store/search-results-panel/search-results-middleware-service.ts
src/store/shared-with-me-panel/shared-with-me-middleware-service.ts
src/store/store.ts
src/store/subprocess-panel/subprocess-panel-middleware-service.ts
src/store/trash-panel/trash-panel-middleware-service.ts
src/store/trash/trash-actions.ts
src/store/tree-picker/picker-id.tsx
src/store/tree-picker/tree-picker-actions.test.ts [new file with mode: 0644]
src/store/tree-picker/tree-picker-actions.ts
src/store/tree-picker/tree-picker-reducer.test.ts
src/store/tree-picker/tree-picker-reducer.ts
src/store/users/user-panel-middleware-service.ts
src/store/virtual-machines/virtual-machines-actions.ts
src/store/workbench/workbench-actions.ts
src/store/workflow-panel/workflow-panel-actions.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/collection-files-item-action-set.ts
src/views-components/context-menu/action-sets/favorite-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/permission-edit-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/resource-action-set.ts
src/views-components/context-menu/action-sets/root-project-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/trash-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/action-sets/workflow-action-set.ts
src/views-components/context-menu/context-menu-action-set.ts
src/views-components/context-menu/context-menu.tsx
src/views-components/data-explorer/data-explorer.tsx
src/views-components/dialog-copy/dialog-copy.tsx
src/views-components/dialog-copy/dialog-process-rerun.tsx
src/views-components/dialog-forms/copy-collection-dialog.ts
src/views-components/dialog-forms/copy-process-dialog.ts
src/views-components/dialog-forms/move-project-dialog.ts
src/views-components/form-fields/collection-form-fields.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views-components/multiselect-toolbar/ms-collection-action-set.ts [new file with mode: 0644]
src/views-components/multiselect-toolbar/ms-process-action-set.ts [new file with mode: 0644]
src/views-components/multiselect-toolbar/ms-project-action-set.ts [new file with mode: 0644]
src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx
src/views-components/projects-tree-picker/projects-tree-picker.tsx
src/views-components/projects-tree-picker/tree-picker-field.tsx
src/views-components/search-bar/search-bar-view.tsx
src/views/all-processes-panel/all-processes-panel.tsx
src/views/process-panel/process-io-card.tsx
src/views/process-panel/process-panel-root.tsx
src/views/process-panel/process-panel.tsx
src/views/project-panel/project-panel.tsx
src/views/run-process-panel/inputs/directory-array-input.tsx
src/views/run-process-panel/inputs/directory-input.tsx
src/views/run-process-panel/inputs/file-array-input.tsx
src/views/run-process-panel/inputs/file-input.tsx
src/views/run-process-panel/inputs/project-input.tsx
src/views/subprocess-panel/subprocess-panel.tsx
src/views/workbench/workbench.tsx
src/websocket/websocket.ts

diff --git a/cypress/fixtures/workflow_directory_array.yaml b/cypress/fixtures/workflow_directory_array.yaml
new file mode 100644 (file)
index 0000000..fbdbd32
--- /dev/null
@@ -0,0 +1,20 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+---
+"$graph":
+- class: Workflow
+  cwlVersion: v1.2
+  hints:
+  - acrContainerImage: 7009415fdc959d0c2819ee2e9db96561+261
+    class: http://arvados.org/cwl#WorkflowRunnerResources
+  id: "#main"
+  inputs:
+  - id: "#main/directoryInputName"
+    type:
+      items: Directory
+      type: array
+  outputs: []
+  steps: []
+cwlVersion: v1.2
index df84abe916cf4a656915850548615ab778fe0138..295bc380c3d20ed716da34f9240f41fb11bf192b 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Collection panel tests', function () {
+describe('Banner / tooltip tests', function () {
     let activeUser;
     let adminUser;
     let collectionUUID;
index 69e484173f80aa8fdcda1f4a95c46d572b41ce1d..f83a974193abe98926bb982ce4d731446ebd9bb3 100644 (file)
@@ -2,9 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-const path = require('path');
+const path = require("path");
 
-describe('Collection panel tests', function () {
+describe("Collection panel tests", function () {
     let activeUser;
     let adminUser;
     let downloadsFolder;
@@ -14,17 +14,17 @@ describe('Collection panel tests', function () {
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'Admin', 'User', true, true)
-            .as('adminUser').then(function () {
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-            );
-        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
-            .as('activeUser').then(function () {
+            });
+        cy.getUser("collectionuser1", "Collection", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-            );
-        downloadsFolder = Cypress.config('downloadsFolder');
+            });
+        downloadsFolder = Cypress.config("downloadsFolder");
     });
 
     beforeEach(function () {
@@ -32,328 +32,321 @@ describe('Collection panel tests', function () {
         cy.clearLocalStorage();
     });
 
-    it('allows to download mountain duck config for a collection', () => {
+    it("allows to download mountain duck config for a collection", () => {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-        .as('testCollection').then(function (testCollection) {
-            cy.loginAs(activeUser);
-            cy.goToPath(`/collections/${testCollection.uuid}`);
-
-            cy.get('[data-cy=collection-panel-options-btn]').click();
-            cy.get('[data-cy=context-menu]').contains('Open with 3rd party client').click();
-            cy.get('[data-cy=download-button').click();
-
-            const filename = path.join(downloadsFolder, `${testCollection.name}.duck`);
-
-            cy.readFile(filename, { timeout: 15000 })
-                .then((body) => {
-                    const childrenCollection = Array.prototype.slice.call(Cypress.$(body).find('dict')[0].children);
-                    const map = {};
-                    let i, j = 2;
-
-                    for (i=0; i < childrenCollection.length; i += j) {
-                      map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText;
-                    }
+            .as("testCollection")
+            .then(function (testCollection) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection.uuid}`);
 
-                    cy.get('#simple-tabpanel-0').find('a')
-                        .then((a) => {
-                            const [host, port] = a.text().split('@')[1].split('/')[0].split(':');
-                            expect(map['Protocol']).to.equal('davs');
-                            expect(map['UUID']).to.equal(testCollection.uuid);
-                            expect(map['Username']).to.equal(activeUser.user.username);
-                            expect(map['Port']).to.equal(port);
-                            expect(map['Hostname']).to.equal(host);
-                            if (map['Path']) {
-                                expect(map['Path']).to.equal(`/c=${testCollection.uuid}`);
-                            }
-                        });
-                })
-                .then(() => cy.task('clearDownload', { filename }));
-        });
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Open with 3rd party client").click();
+                cy.get("[data-cy=download-button").click();
+
+                const filename = path.join(downloadsFolder, `${testCollection.name}.duck`);
+
+                cy.readFile(filename, { timeout: 15000 })
+                    .then(body => {
+                        const childrenCollection = Array.prototype.slice.call(Cypress.$(body).find("dict")[0].children);
+                        const map = {};
+                        let i,
+                            j = 2;
+
+                        for (i = 0; i < childrenCollection.length; i += j) {
+                            map[childrenCollection[i].outerText] = childrenCollection[i + 1].outerText;
+                        }
+
+                        cy.get("#simple-tabpanel-0")
+                            .find("a")
+                            .then(a => {
+                                const [host, port] = a.text().split("@")[1].split("/")[0].split(":");
+                                expect(map["Protocol"]).to.equal("davs");
+                                expect(map["UUID"]).to.equal(testCollection.uuid);
+                                expect(map["Username"]).to.equal(activeUser.user.username);
+                                expect(map["Port"]).to.equal(port);
+                                expect(map["Hostname"]).to.equal(host);
+                                if (map["Path"]) {
+                                    expect(map["Path"]).to.equal(`/c=${testCollection.uuid}`);
+                                }
+                            });
+                    })
+                    .then(() => cy.task("clearDownload", { filename }));
+            });
     });
 
-    it('attempts to use a preexisting name creating or updating a collection', function() {
+    it("attempts to use a preexisting name creating or updating a collection", function () {
         const name = `Test collection ${Math.floor(Math.random() * 999999)}`;
         cy.createCollection(adminUser.token, {
             name: name,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         });
         cy.loginAs(activeUser);
         cy.goToPath(`/projects/${activeUser.user.uuid}`);
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('not.exist');
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
         // Attempt to create new collection with a duplicate name
-        cy.get('[data-cy=side-panel-button]').click();
-        cy.get('[data-cy=side-panel-new-collection]').click();
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'New collection')
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-collection]").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New collection")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(name);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
         // Error message should display, allowing editing the name
-        cy.get('[data-cy=form-dialog]').should('exist')
-            .and('contain', 'Collection with the same name already exists')
+        cy.get("[data-cy=form-dialog]")
+            .should("exist")
+            .and("contain", "Collection with the same name already exists")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(' renamed');
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(" renamed");
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
-        cy.get('[data-cy=form-dialog]').should('not.exist');
+        cy.get("[data-cy=form-dialog]").should("not.exist");
         // Attempt to rename the collection with the duplicate name
-        cy.get('[data-cy=collection-panel-options-btn]').click();
-        cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Edit Collection')
+        cy.get("[data-cy=collection-panel-options-btn]").click();
+        cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Edit Collection")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input')
-                        .type('{selectall}{backspace}')
-                        .type(name);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type("{selectall}{backspace}").type(name);
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
-        cy.get('[data-cy=form-dialog]').should('exist')
-            .and('contain', 'Collection with the same name already exists');
+        cy.get("[data-cy=form-dialog]").should("exist").and("contain", "Collection with the same name already exists");
     });
 
-    it('uses the property editor (from edit dialog) with vocabulary terms', function () {
+    it("uses the property editor (from edit dialog) with vocabulary terms", function () {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
-                cy.get('[data-cy=collection-info-panel')
-                    .should('contain', this.testCollection.name)
-                    .and('not.contain', 'Color: Magenta');
+                cy.get("[data-cy=collection-info-panel").should("contain", this.testCollection.name).and("not.contain", "Color: Magenta");
 
-                cy.get('[data-cy=collection-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-                cy.get('[data-cy=form-dialog]').should('contain', 'Properties');
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]").should("contain", "Properties");
 
                 // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
-                cy.get('[data-cy=resource-properties-form]').within(() => {
-                    cy.get('[data-cy=property-field-key]').within(() => {
-                        cy.get('input').type('Color');
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("Color");
                     });
-                    cy.get('[data-cy=property-field-value]').within(() => {
-                        cy.get('input').type('Magenta');
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("Magenta");
                     });
                     cy.root().submit();
                 });
                 // Confirm proper vocabulary labels are displayed on the UI.
-                cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta');
-                cy.get('[data-cy=form-dialog]').contains('Save').click();
-                cy.get('[data-cy=form-dialog]').should('not.exist');
+                cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
+                cy.get("[data-cy=form-dialog]").contains("Save").click();
+                cy.get("[data-cy=form-dialog]").should("not.exist");
                 // Confirm proper vocabulary IDs were saved on the backend.
-                cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`)
-                    .its('body').as('collection')
+                cy.doRequest("GET", `/arvados/v1/collections/${this.testCollection.uuid}`)
+                    .its("body")
+                    .as("collection")
                     .then(function () {
-                        expect(this.collection.properties.IDTAGCOLORS).to.equal('IDVALCOLORS3');
+                        expect(this.collection.properties.IDTAGCOLORS).to.equal("IDVALCOLORS3");
                     });
                 // Confirm the property is displayed on the UI.
-                cy.get('[data-cy=collection-info-panel')
-                    .should('contain', this.testCollection.name)
-                    .and('contain', 'Color: Magenta');
+                cy.get("[data-cy=collection-info-panel").should("contain", this.testCollection.name).and("contain", "Color: Magenta");
             });
     });
 
-    it('uses the editor (from details panel) with vocabulary terms', function () {
+    it("uses the editor (from details panel) with vocabulary terms", function () {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
-                cy.get('[data-cy=collection-info-panel')
-                    .should('contain', this.testCollection.name)
-                    .and('not.contain', 'Color: Magenta')
-                    .and('not.contain', 'Size: S');
-                cy.get('[data-cy=additional-info-icon]').click();
+                cy.get("[data-cy=collection-info-panel")
+                    .should("contain", this.testCollection.name)
+                    .and("not.contain", "Color: Magenta")
+                    .and("not.contain", "Size: S");
+                cy.get("[data-cy=additional-info-icon]").click();
 
-                cy.get('[data-cy=details-panel]').within(() => {
-                    cy.get('[data-cy=details-panel-edit-btn]').click();
+                cy.get("[data-cy=details-panel]").within(() => {
+                    cy.get("[data-cy=details-panel-edit-btn]").click();
                 });
-                cy.get('[data-cy=form-dialog').contains('Edit Collection');
+                cy.get("[data-cy=form-dialog").contains("Edit Collection");
 
                 // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
-                cy.get('[data-cy=resource-properties-form]').within(() => {
-                    cy.get('[data-cy=property-field-key]').within(() => {
-                        cy.get('input').type('Color');
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("Color");
                     });
-                    cy.get('[data-cy=property-field-value]').within(() => {
-                        cy.get('input').type('Magenta');
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("Magenta");
                     });
                     cy.root().submit();
                 });
                 // Confirm proper vocabulary labels are displayed on the UI.
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Color: Magenta');
+                cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
 
                 // Case-insensitive on-blur auto-selection test
                 // Key: Size (IDTAGSIZES) - Value: Small (IDVALSIZES2)
-                cy.get('[data-cy=resource-properties-form]').within(() => {
-                    cy.get('[data-cy=property-field-key]').within(() => {
-                        cy.get('input').type('sIzE');
+                cy.get("[data-cy=resource-properties-form]").within(() => {
+                    cy.get("[data-cy=property-field-key]").within(() => {
+                        cy.get("input").type("sIzE");
                     });
-                    cy.get('[data-cy=property-field-value]').within(() => {
-                        cy.get('input').type('sMaLL');
+                    cy.get("[data-cy=property-field-value]").within(() => {
+                        cy.get("input").type("sMaLL");
                     });
                     // Cannot "type()" TAB on Cypress so let's click another field
                     // to trigger the onBlur event.
-                    cy.get('[data-cy=property-field-key]').click();
+                    cy.get("[data-cy=property-field-key]").click();
                     cy.root().submit();
                 });
                 // Confirm proper vocabulary labels are displayed on the UI.
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Size: S');
+                cy.get("[data-cy=form-dialog]").should("contain", "Size: S");
 
-                cy.get('[data-cy=form-dialog]').contains('Save').click();
-                cy.get('[data-cy=form-dialog]').should('not.exist');
+                cy.get("[data-cy=form-dialog]").contains("Save").click();
+                cy.get("[data-cy=form-dialog]").should("not.exist");
 
                 // Confirm proper vocabulary IDs were saved on the backend.
-                cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`)
-                    .its('body').as('collection')
+                cy.doRequest("GET", `/arvados/v1/collections/${this.testCollection.uuid}`)
+                    .its("body")
+                    .as("collection")
                     .then(function () {
-                        expect(this.collection.properties.IDTAGCOLORS).to.equal('IDVALCOLORS3');
-                        expect(this.collection.properties.IDTAGSIZES).to.equal('IDVALSIZES2');
+                        expect(this.collection.properties.IDTAGCOLORS).to.equal("IDVALCOLORS3");
+                        expect(this.collection.properties.IDTAGSIZES).to.equal("IDVALSIZES2");
                     });
 
                 // Confirm properties display on the UI.
-                cy.get('[data-cy=collection-info-panel')
-                    .should('contain', this.testCollection.name)
-                    .and('contain', 'Color: Magenta')
-                    .and('contain', 'Size: S');
+                cy.get("[data-cy=collection-info-panel")
+                    .should("contain", this.testCollection.name)
+                    .and("contain", "Color: Magenta")
+                    .and("contain", "Size: S");
             });
     });
 
-    it('shows collection by URL', function () {
+    it("shows collection by URL", function () {
         cy.loginAs(activeUser);
         [true, false].map(function (isWritable) {
             // Using different file names to avoid test flakyness: the second iteration
             // on this loop may pass an assertion from the first iteration by looking
             // for the same file name.
-            const fileName = isWritable ? 'bar' : 'foo';
-            const subDirName = 'subdir';
+            const fileName = isWritable ? "bar" : "foo";
+            const subDirName = "subdir";
             cy.createGroup(adminUser.token, {
-                name: 'Shared project',
-                group_class: 'project',
-            }).as('sharedGroup').then(function () {
-                // Creates the collection using the admin token so we can set up
-                // a bogus manifest text without block signatures.
-                cy.doRequest('GET', '/arvados/v1/config', null, null)
-                    .its('body').should((clusterConfig) => {
-                      expect(clusterConfig.Collections, "clusterConfig").to.have.property("TrustAllContent", true);
-                      expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAV").have.property("ExternalURL");
-                      expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAVDownload").have.property("ExternalURL");
-                      const inlineUrl = clusterConfig.Services.WebDAV.ExternalURL !== ""
-                          ? clusterConfig.Services.WebDAV.ExternalURL
-                          : clusterConfig.Services.WebDAVDownload.ExternalURL;
-                      expect(inlineUrl).to.not.contain("*");
-                    })
-                    .createCollection(adminUser.token, {
-                      name: 'Test collection',
-                      owner_uuid: this.sharedGroup.uuid,
-                      properties: { someKey: 'someValue' },
-                      manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n./${subDirName} 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`
-                    })
-                    .as('testCollection').then(function () {
-                        // Share the group with active user.
-                        cy.createLink(adminUser.token, {
-                            name: isWritable ? 'can_write' : 'can_read',
-                            link_class: 'permission',
-                            head_uuid: this.sharedGroup.uuid,
-                            tail_uuid: activeUser.user.uuid
+                name: "Shared project",
+                group_class: "project",
+            })
+                .as("sharedGroup")
+                .then(function () {
+                    // Creates the collection using the admin token so we can set up
+                    // a bogus manifest text without block signatures.
+                    cy.doRequest("GET", "/arvados/v1/config", null, null)
+                        .its("body")
+                        .should(clusterConfig => {
+                            expect(clusterConfig.Collections, "clusterConfig").to.have.property("TrustAllContent", true);
+                            expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAV").have.property("ExternalURL");
+                            expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAVDownload").have.property("ExternalURL");
+                            const inlineUrl =
+                                clusterConfig.Services.WebDAV.ExternalURL !== ""
+                                    ? clusterConfig.Services.WebDAV.ExternalURL
+                                    : clusterConfig.Services.WebDAVDownload.ExternalURL;
+                            expect(inlineUrl).to.not.contain("*");
+                        })
+                        .createCollection(adminUser.token, {
+                            name: "Test collection",
+                            owner_uuid: this.sharedGroup.uuid,
+                            properties: { someKey: "someValue" },
+                            manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n./${subDirName} 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`,
                         })
-                        cy.goToPath(`/collections/${this.testCollection.uuid}`);
-
-                        // Check that name & uuid are correct.
-                        cy.get('[data-cy=collection-info-panel]')
-                            .should('contain', this.testCollection.name)
-                            .and('contain', this.testCollection.uuid)
-                            .and('not.contain', 'This is an old version');
-                        // Check for the read-only icon
-                        cy.get('[data-cy=read-only-icon]').should(`${isWritable ? 'not.' : ''}exist`);
-                        // Check that both read and write operations are available on
-                        // the 'More options' menu.
-                        cy.get('[data-cy=collection-panel-options-btn]')
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should('contain', 'Add to favorites')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Edit collection');
-                        cy.get('body').click(); // Collapse the menu avoiding details panel expansion
-                        cy.get('[data-cy=collection-info-panel]')
-                            .should('contain', 'someKey: someValue')
-                            .and('not.contain', 'anotherKey: anotherValue');
-                        // Check that the file listing show both read & write operations
-                        cy.waitForDom().get('[data-cy=collection-files-panel]').within(() => {
-                            cy.get('[data-cy=collection-files-right-panel]', { timeout: 5000 })
-                                .should('contain', fileName);
-                            if (isWritable) {
-                                cy.get('[data-cy=upload-button]')
-                                    .should(`${isWritable ? '' : 'not.'}contain`, 'Upload data');
-                            }
+                        .as("testCollection")
+                        .then(function () {
+                            // Share the group with active user.
+                            cy.createLink(adminUser.token, {
+                                name: isWritable ? "can_write" : "can_read",
+                                link_class: "permission",
+                                head_uuid: this.sharedGroup.uuid,
+                                tail_uuid: activeUser.user.uuid,
+                            });
+                            cy.goToPath(`/collections/${this.testCollection.uuid}`);
+
+                            // Check that name & uuid are correct.
+                            cy.get("[data-cy=collection-info-panel]")
+                                .should("contain", this.testCollection.name)
+                                .and("contain", this.testCollection.uuid)
+                                .and("not.contain", "This is an old version");
+                            // Check for the read-only icon
+                            cy.get("[data-cy=read-only-icon]").should(`${isWritable ? "not." : ""}exist`);
+                            // Check that both read and write operations are available on
+                            // the 'More options' menu.
+                            cy.get("[data-cy=collection-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]")
+                                .should("contain", "Add to favorites")
+                                .and(`${isWritable ? "" : "not."}contain`, "Edit collection");
+                            cy.get("body").click(); // Collapse the menu avoiding details panel expansion
+                            cy.get("[data-cy=collection-info-panel]")
+                                .should("contain", "someKey: someValue")
+                                .and("not.contain", "anotherKey: anotherValue");
+                            // Check that the file listing show both read & write operations
+                            cy.waitForDom()
+                                .get("[data-cy=collection-files-panel]")
+                                .within(() => {
+                                    cy.get("[data-cy=collection-files-right-panel]", { timeout: 5000 }).should("contain", fileName);
+                                    if (isWritable) {
+                                        cy.get("[data-cy=upload-button]").should(`${isWritable ? "" : "not."}contain`, "Upload data");
+                                    }
+                                });
+                            // Test context menus
+                            cy.get("[data-cy=collection-files-panel]").contains(fileName).rightclick();
+                            cy.get("[data-cy=context-menu]")
+                                .should("contain", "Download")
+                                .and("contain", "Open in new tab")
+                                .and("contain", "Copy to clipboard")
+                                .and(`${isWritable ? "" : "not."}contain`, "Rename")
+                                .and(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            cy.get("[data-cy=collection-files-panel]").contains(subDirName).rightclick();
+                            cy.get("[data-cy=context-menu]")
+                                .should("not.contain", "Download")
+                                .and("contain", "Open in new tab")
+                                .and("contain", "Copy to clipboard")
+                                .and(`${isWritable ? "" : "not."}contain`, "Rename")
+                                .and(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            // File/dir item 'more options' button
+                            cy.get("[data-cy=file-item-options-btn").first().click();
+                            cy.get("[data-cy=context-menu]").should(`${isWritable ? "" : "not."}contain`, "Remove");
+                            cy.get("body").click(); // Collapse the menu
+                            // Hamburger 'more options' menu button
+                            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]").should("contain", "Select all").click();
+                            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                            cy.get("[data-cy=context-menu]").should(`${isWritable ? "" : "not."}contain`, "Remove selected");
+                            cy.get("body").click(); // Collapse the menu
                         });
-                        // Test context menus
-                        cy.get('[data-cy=collection-files-panel]')
-                            .contains(fileName).rightclick();
-                        cy.get('[data-cy=context-menu]')
-                            .should('contain', 'Download')
-                            .and('contain', 'Open in new tab')
-                            .and('contain', 'Copy to clipboard')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
-                        cy.get('body').click(); // Collapse the menu
-                        cy.get('[data-cy=collection-files-panel]')
-                            .contains(subDirName).rightclick();
-                        cy.get('[data-cy=context-menu]')
-                            .should('not.contain', 'Download')
-                            .and('contain', 'Open in new tab')
-                            .and('contain', 'Copy to clipboard')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
-                            .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
-                        cy.get('body').click(); // Collapse the menu
-                        // File/dir item 'more options' button
-                        cy.get('[data-cy=file-item-options-btn')
-                            .first()
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should(`${isWritable ? '' : 'not.'}contain`, 'Remove');
-                        cy.get('body').click(); // Collapse the menu
-                        // Hamburger 'more options' menu button
-                        cy.get('[data-cy=collection-files-panel-options-btn]')
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should('contain', 'Select all')
-                            .click()
-                        cy.get('[data-cy=collection-files-panel-options-btn]')
-                            .click()
-                        cy.get('[data-cy=context-menu]')
-                            .should(`${isWritable ? '' : 'not.'}contain`, 'Remove selected')
-                        cy.get('body').click(); // Collapse the menu
-                    })
-            })
-        })
-    })
+                });
+        });
+    });
 
-    it('renames a file using valid names', function () {
-        function eachPair(lst, func){
-            for(var i=0; i < lst.length - 1; i++){
-                func(lst[i], lst[i + 1])
+    it("renames a file using valid names", function () {
+        function eachPair(lst, func) {
+            for (var i = 0; i < lst.length - 1; i++) {
+                func(lst[i], lst[i + 1]);
             }
         }
         // Creates the collection using the admin token so we can set up
@@ -361,191 +354,171 @@ describe('Collection panel tests', function () {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
                 const names = [
-                    'bar', // initial name already set
-                    '&',
-                    'foo',
-                    '&amp;',
-                    'I ❤️ ⛵️',
-                    '...',
-                    '#..',
-                    'some name with whitespaces',
-                    'some name with #2',
-                    'is this name legal? I hope it is',
-                    'some_file.pdf#',
-                    'some_file.pdf?',
-                    '?some_file.pdf',
-                    'some%file.pdf',
-                    'some%2Ffile.pdf',
-                    'some%22file.pdf',
-                    'some%20file.pdf',
+                    "bar", // initial name already set
+                    "&",
+                    "foo",
+                    "&amp;",
+                    "I ❤️ ⛵️",
+                    "...",
+                    "#..",
+                    "some name with whitespaces",
+                    "some name with #2",
+                    "is this name legal? I hope it is",
+                    "some_file.pdf#",
+                    "some_file.pdf?",
+                    "?some_file.pdf",
+                    "some%file.pdf",
+                    "some%2Ffile.pdf",
+                    "some%22file.pdf",
+                    "some%20file.pdf",
                     "G%C3%BCnter's%20file.pdf",
-                    'table%&?*2',
-                    'bar' // make sure we can go back to the original name as a last step
+                    "table%&?*2",
+                    "bar", // make sure we can go back to the original name as a last step
                 ];
-                cy.intercept({method: 'PUT', url: '**/arvados/v1/collections/*'}).as('renameRequest');
+                cy.intercept({ method: "PUT", url: "**/arvados/v1/collections/*" }).as("renameRequest");
                 eachPair(names, (from, to) => {
-                    cy.waitForDom().get('[data-cy=collection-files-panel]')
-                        .contains(`${from}`).rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Rename')
-                        .click();
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains(`${from}`).rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
-                            cy.get('input')
-                                .type('{selectall}{backspace}')
-                                .type(to, { parseSpecialCharSequences: false });
+                            cy.get("input").type("{selectall}{backspace}").type(to, { parseSpecialCharSequences: false });
                         });
-                    cy.get('[data-cy=form-submit-btn]').click();
-                    cy.wait('@renameRequest');
-                    cy.get('[data-cy=collection-files-panel]')
-                        .should('not.contain', `${from}`)
-                        .and('contain', `${to}`);
-                })
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.wait("@renameRequest");
+                    cy.get("[data-cy=collection-files-panel]").should("not.contain", `${from}`).and("contain", `${to}`);
+                });
             });
     });
 
-    it('renames a file to a different directory', function () {
+    it("renames a file to a different directory", function () {
         // Creates the collection using the admin token so we can set up
         // a bogus manifest text without block signatures.
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
-                ['subdir', 'G%C3%BCnter\'s%20file', 'table%&?*2'].forEach((subdir) => {
-                    cy.waitForDom().get('[data-cy=collection-files-panel]')
-                        .contains('bar').rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Rename')
-                        .click();
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                ["subdir", "G%C3%BCnter's%20file", "table%&?*2"].forEach(subdir => {
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains("bar").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
-                            cy.get('input').type(`{selectall}{backspace}${subdir}/foo`);
+                            cy.get("input").type(`{selectall}{backspace}${subdir}/foo`);
                         });
-                    cy.get('[data-cy=form-submit-btn]').click();
-                    cy.get('[data-cy=collection-files-panel]')
-                        .should('not.contain', 'bar')
-                        .and('contain', subdir);
-                    cy.get('[data-cy=collection-files-panel]').contains(subdir).click();
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.get("[data-cy=collection-files-panel]").should("not.contain", "bar").and("contain", subdir);
+                    cy.get("[data-cy=collection-files-panel]").contains(subdir).click();
 
                     // Rename 'subdir/foo' to 'bar'
                     cy.wait(1000);
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('foo').rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Rename')
-                        .click();
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                    cy.get("[data-cy=collection-files-panel]").contains("foo").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
-                            cy.get('input')
-                                .should('have.value', `${subdir}/foo`)
-                                .type(`{selectall}{backspace}bar`);
+                            cy.get("input").should("have.value", `${subdir}/foo`).type(`{selectall}{backspace}bar`);
                         });
-                    cy.get('[data-cy=form-submit-btn]').click();
+                    cy.get("[data-cy=form-submit-btn]").click();
 
                     // need to wait for dialog to dismiss
-                    cy.get('[data-cy=form-dialog]').should('not.exist');
+                    cy.get("[data-cy=form-dialog]").should("not.exist");
 
-                    cy.waitForDom().get('[data-cy=collection-files-panel]')
-                        .contains('Home')
-                        .click();
+                    cy.waitForDom().get("[data-cy=collection-files-panel]").contains("Home").click();
 
                     cy.wait(2000);
-                    cy.get('[data-cy=collection-files-panel]')
-                        .should('contain', subdir) // empty dir kept
-                        .and('contain', 'bar');
-
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains(subdir).rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Remove')
-                        .click();
-                    cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
-                    cy.get('[data-cy=form-dialog]').should('not.exist');
+                    cy.get("[data-cy=collection-files-panel]")
+                        .should("contain", subdir) // empty dir kept
+                        .and("contain", "bar");
+
+                    cy.get("[data-cy=collection-files-panel]").contains(subdir).rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Remove").click();
+                    cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                    cy.get("[data-cy=form-dialog]").should("not.exist");
                 });
             });
     });
 
-    it('shows collection owner', () => {
+    it("shows collection owner", () => {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then((testCollection) => {
+            .as("testCollection")
+            .then(testCollection => {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${testCollection.uuid}`);
                 cy.wait(5000);
-                cy.get('[data-cy=collection-info-panel]').contains(`Collection User`);
+                cy.get("[data-cy=collection-info-panel]").contains(`Collection User`);
             });
     });
 
-    it('tries to rename a file with illegal names', function () {
+    it("tries to rename a file with illegal names", function () {
         // Creates the collection using the admin token so we can set up
         // a bogus manifest text without block signatures.
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('testCollection').then(function () {
+            .as("testCollection")
+            .then(function () {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.testCollection.uuid}`);
 
                 const illegalNamesFromUI = [
-                    ['.', "Name cannot be '.' or '..'"],
-                    ['..', "Name cannot be '.' or '..'"],
-                    ['', 'This field is required'],
-                    [' ', 'Leading/trailing whitespaces not allowed'],
-                    [' foo', 'Leading/trailing whitespaces not allowed'],
-                    ['foo ', 'Leading/trailing whitespaces not allowed'],
-                    ['//foo', 'Empty dir name not allowed']
-                ]
+                    [".", "Name cannot be '.' or '..'"],
+                    ["..", "Name cannot be '.' or '..'"],
+                    ["", "This field is required"],
+                    [" ", "Leading/trailing whitespaces not allowed"],
+                    [" foo", "Leading/trailing whitespaces not allowed"],
+                    ["foo ", "Leading/trailing whitespaces not allowed"],
+                    ["//foo", "Empty dir name not allowed"],
+                ];
                 illegalNamesFromUI.forEach(([name, errMsg]) => {
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('bar').rightclick();
-                    cy.get('[data-cy=context-menu]')
-                        .contains('Rename')
-                        .click();
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                    cy.get("[data-cy=collection-files-panel]").contains("bar").rightclick();
+                    cy.get("[data-cy=context-menu]").contains("Rename").click();
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
-                            cy.get('input').type(`{selectall}{backspace}${name}`);
+                            cy.get("input").type(`{selectall}{backspace}${name}`);
                         });
-                    cy.get('[data-cy=form-dialog]')
-                        .should('contain', 'Rename')
+                    cy.get("[data-cy=form-dialog]")
+                        .should("contain", "Rename")
                         .within(() => {
                             cy.contains(`${errMsg}`);
                         });
-                    cy.get('[data-cy=form-cancel-btn]').click();
-                })
+                    cy.get("[data-cy=form-cancel-btn]").click();
+                });
             });
     });
 
-    it('can correctly display old versions', function () {
+    it("can correctly display old versions", function () {
         const colName = `Versioned Collection ${Math.floor(Math.random() * 999999)}`;
-        let colUuid = '';
-        let oldVersionUuid = '';
+        let colUuid = "";
+        let oldVersionUuid = "";
         // Make sure no other collections with this name exist
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${colName}"]]`,
-            include_old_versions: true
+            include_old_versions: true,
         })
-            .its('body.items').as('collections')
+            .its("body.items")
+            .as("collections")
             .then(function () {
                 expect(this.collections).to.be.empty;
             });
@@ -555,21 +528,23 @@ describe('Collection panel tests', function () {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
         })
-            .as('originalVersion').then(function () {
+            .as("originalVersion")
+            .then(function () {
                 // Change the file name to create a new version.
                 cy.updateCollection(adminUser.token, this.originalVersion.uuid, {
-                    manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n"
-                })
+                    manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n",
+                });
                 colUuid = this.originalVersion.uuid;
             });
         // Confirm that there are 2 versions of the collection
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${colName}"]]`,
-            include_old_versions: true
+            include_old_versions: true,
         })
-            .its('body.items').as('collections')
+            .its("body.items")
+            .as("collections")
             .then(function () {
                 expect(this.collections).to.have.lengthOf(2);
                 this.collections.map(function (aCollection) {
@@ -579,82 +554,80 @@ describe('Collection panel tests', function () {
                     }
                 });
                 // Check the old version displays as what it is.
-                cy.loginAs(activeUser)
+                cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${oldVersionUuid}`);
 
-                cy.get('[data-cy=collection-info-panel]').should('contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('exist');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]').should('contain', 'bar');
+                cy.get("[data-cy=collection-info-panel]").should("contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("exist");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "bar");
             });
     });
 
-    it('views & edits storage classes data', function () {
-        const colName= `Test Collection ${Math.floor(Math.random() * 999999)}`;
+    it("views & edits storage classes data", function () {
+        const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
         cy.createCollection(adminUser.token, {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n",
-        }).as('collection').then(function () {
-            expect(this.collection.storage_classes_desired).to.deep.equal(['default'])
-
-            cy.loginAs(activeUser)
-            cy.goToPath(`/collections/${this.collection.uuid}`);
-
-            // Initial check: it should show the 'default' storage class
-            cy.get('[data-cy=collection-info-panel]')
-                .should('contain', 'Storage classes')
-                .and('contain', 'default')
-                .and('not.contain', 'foo')
-                .and('not.contain', 'bar');
-            // Edit collection: add storage class 'foo'
-            cy.get('[data-cy=collection-panel-options-btn]').click();
-            cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-            cy.get('[data-cy=form-dialog]')
-                .should('contain', 'Edit Collection')
-                .and('contain', 'Storage classes')
-                .and('contain', 'default')
-                .and('contain', 'foo')
-                .and('contain', 'bar')
-                .within(() => {
-                    cy.get('[data-cy=checkbox-foo]').click();
-                });
-            cy.get('[data-cy=form-submit-btn]').click();
-            cy.get('[data-cy=collection-info-panel]')
-                .should('contain', 'default')
-                .and('contain', 'foo')
-                .and('not.contain', 'bar');
-            cy.doRequest('GET', `/arvados/v1/collections/${this.collection.uuid}`)
-                .its('body').as('updatedCollection')
-                .then(function () {
-                    expect(this.updatedCollection.storage_classes_desired).to.deep.equal(['default', 'foo']);
-                });
-            // Edit collection: remove storage class 'default'
-            cy.get('[data-cy=collection-panel-options-btn]').click();
-            cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-            cy.get('[data-cy=form-dialog]')
-                .should('contain', 'Edit Collection')
-                .and('contain', 'Storage classes')
-                .and('contain', 'default')
-                .and('contain', 'foo')
-                .and('contain', 'bar')
-                .within(() => {
-                    cy.get('[data-cy=checkbox-default]').click();
-                });
-            cy.get('[data-cy=form-submit-btn]').click();
-            cy.get('[data-cy=collection-info-panel]')
-                .should('not.contain', 'default')
-                .and('contain', 'foo')
-                .and('not.contain', 'bar');
-            cy.doRequest('GET', `/arvados/v1/collections/${this.collection.uuid}`)
-                .its('body').as('updatedCollection')
-                .then(function () {
-                    expect(this.updatedCollection.storage_classes_desired).to.deep.equal(['foo']);
-                });
         })
+            .as("collection")
+            .then(function () {
+                expect(this.collection.storage_classes_desired).to.deep.equal(["default"]);
+
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+
+                // Initial check: it should show the 'default' storage class
+                cy.get("[data-cy=collection-info-panel]")
+                    .should("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("not.contain", "foo")
+                    .and("not.contain", "bar");
+                // Edit collection: add storage class 'foo'
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
+                    .and("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("contain", "foo")
+                    .and("contain", "bar")
+                    .within(() => {
+                        cy.get("[data-cy=checkbox-foo]").click();
+                    });
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("contain", "default").and("contain", "foo").and("not.contain", "bar");
+                cy.doRequest("GET", `/arvados/v1/collections/${this.collection.uuid}`)
+                    .its("body")
+                    .as("updatedCollection")
+                    .then(function () {
+                        expect(this.updatedCollection.storage_classes_desired).to.deep.equal(["default", "foo"]);
+                    });
+                // Edit collection: remove storage class 'default'
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
+                    .and("contain", "Storage classes")
+                    .and("contain", "default")
+                    .and("contain", "foo")
+                    .and("contain", "bar")
+                    .within(() => {
+                        cy.get("[data-cy=checkbox-default]").click();
+                    });
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "default").and("contain", "foo").and("not.contain", "bar");
+                cy.doRequest("GET", `/arvados/v1/collections/${this.collection.uuid}`)
+                    .its("body")
+                    .as("updatedCollection")
+                    .then(function () {
+                        expect(this.updatedCollection.storage_classes_desired).to.deep.equal(["foo"]);
+                    });
+            });
     });
 
-    it('moves a collection to a different project', function () {
+    it("moves a collection to a different project", function () {
         const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
         const projName = `Test Project ${Math.floor(Math.random() * 999999)}`;
         const fileName = `Test_File_${Math.floor(Math.random() * 999999)}`;
@@ -663,86 +636,73 @@ describe('Collection panel tests', function () {
             name: collName,
             owner_uuid: activeUser.user.uuid,
             manifest_text: `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n`,
-        }).as('testCollection');
+        }).as("testCollection");
         cy.createGroup(adminUser.token, {
             name: projName,
-            group_class: 'project',
+            group_class: "project",
             owner_uuid: activeUser.user.uuid,
-        }).as('testProject');
+        }).as("testProject");
 
-        cy.getAll('@testCollection', '@testProject')
-            .then(function ([testCollection, testProject]) {
-                cy.loginAs(activeUser);
-                cy.goToPath(`/collections/${testCollection.uuid}`);
-                cy.get('[data-cy=collection-files-panel]').should('contain', fileName);
-                cy.get('[data-cy=collection-info-panel]')
-                    .should('not.contain', projName)
-                    .and('not.contain', testProject.uuid);
-                cy.get('[data-cy=collection-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Move to').click();
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Move to')
-                    .within(() => {
-                        // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
-                        cy.get('[data-cy=projects-tree-home-tree-picker]')
-                            .find('i')
-                            .then(el => el.click());
-                        cy.get('[data-cy=projects-tree-home-tree-picker]')
-                            .contains(projName)
-                            .click();
-                    });
-                cy.get('[data-cy=form-submit-btn]').click();
-                cy.get('[data-cy=snackbar]')
-                    .contains('Collection has been moved')
-                cy.get('[data-cy=collection-info-panel]')
-                    .contains(projName).and('contain', testProject.uuid);
-                // Double check that the collection is in the project
-                cy.goToPath(`/projects/${testProject.uuid}`);
-                cy.waitForDom().get('[data-cy=project-panel]').should('contain', collName);
-            });
+        cy.getAll("@testCollection", "@testProject").then(function ([testCollection, testProject]) {
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${testCollection.uuid}`);
+            cy.get("[data-cy=collection-files-panel]").should("contain", fileName);
+            cy.get("[data-cy=collection-info-panel]").should("not.contain", projName).and("not.contain", testProject.uuid);
+            cy.get("[data-cy=collection-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move to").click();
+            cy.get("[data-cy=form-dialog]")
+                .should("contain", "Move to")
+                .within(() => {
+                    // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                    cy.get("[data-cy=projects-tree-home-tree-picker]")
+                        .find("i")
+                        .then(el => el.click());
+                    cy.get("[data-cy=projects-tree-home-tree-picker]").contains(projName).click();
+                });
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.get("[data-cy=snackbar]").contains("Collection has been moved");
+            cy.get("[data-cy=collection-info-panel]").contains(projName).and("contain", testProject.uuid);
+            // Double check that the collection is in the project
+            cy.goToPath(`/projects/${testProject.uuid}`);
+            cy.waitForDom().get("[data-cy=project-panel]").should("contain", collName);
+        });
     });
 
-    it('automatically updates the collection UI contents without using the Refresh button', function () {
+    it("automatically updates the collection UI contents without using the Refresh button", function () {
         const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
 
         cy.createCollection(adminUser.token, {
             name: collName,
             owner_uuid: activeUser.user.uuid,
-        }).as('testCollection');
+        }).as("testCollection");
 
-        cy.getAll('@testCollection').then(function ([testCollection]) {
+        cy.getAll("@testCollection").then(function ([testCollection]) {
             cy.loginAs(activeUser);
 
-            const files = [
-                "foobar",
-                "anotherFile",
-                "",
-                "finalName",
-            ];
+            const files = ["foobar", "anotherFile", "", "finalName"];
 
             cy.goToPath(`/collections/${testCollection.uuid}`);
-            cy.get('[data-cy=collection-files-panel]').should('contain', 'This collection is empty');
-            cy.get('[data-cy=collection-files-panel]').should('not.contain', files[0]);
-            cy.get('[data-cy=collection-info-panel]').should('contain', collName);
+            cy.get("[data-cy=collection-files-panel]").should("contain", "This collection is empty");
+            cy.get("[data-cy=collection-files-panel]").should("not.contain", files[0]);
+            cy.get("[data-cy=collection-info-panel]").should("contain", collName);
 
             files.map((fileName, i, files) => {
                 cy.updateCollection(adminUser.token, testCollection.uuid, {
-                    name: `${collName + ' updated'}`,
+                    name: `${collName + " updated"}`,
                     manifest_text: fileName ? `. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:${fileName}\n` : "",
-                }).as('updatedCollection');
-                cy.getAll('@updatedCollection').then(function ([updatedCollection]) {
-                    expect(updatedCollection.name).to.equal(`${collName + ' updated'}`);
-                    cy.get('[data-cy=collection-info-panel]').should('contain', updatedCollection.name);
+                }).as("updatedCollection");
+                cy.getAll("@updatedCollection").then(function ([updatedCollection]) {
+                    expect(updatedCollection.name).to.equal(`${collName + " updated"}`);
+                    cy.get("[data-cy=collection-info-panel]").should("contain", updatedCollection.name);
                     fileName
-                        ? cy.get('[data-cy=collection-files-panel]').should('contain', fileName)
-                        : cy.get('[data-cy=collection-files-panel]').should('not.contain', files[i-1]);;
+                        ? cy.get("[data-cy=collection-files-panel]").should("contain", fileName)
+                        : cy.get("[data-cy=collection-files-panel]").should("not.contain", files[i - 1]);
                 });
             });
-
         });
     });
 
-    it('makes a copy of an existing collection', function() {
+    it("makes a copy of an existing collection", function () {
         const collName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
         const copyName = `Copy of: ${collName}`;
 
@@ -750,32 +710,28 @@ describe('Collection panel tests', function () {
             name: collName,
             owner_uuid: activeUser.user.uuid,
             manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:some-file\n",
-        }).as('collection').then(function () {
-            cy.loginAs(activeUser)
-            cy.goToPath(`/collections/${this.collection.uuid}`);
-            cy.get('[data-cy=collection-files-panel]')
-                .should('contain', 'some-file');
-            cy.get('[data-cy=collection-panel-options-btn]').click();
-            cy.get('[data-cy=context-menu]').contains('Make a copy').click();
-            cy.get('[data-cy=form-dialog]')
-                .should('contain', 'Make a copy')
-                .within(() => {
-                    cy.get('[data-cy=projects-tree-home-tree-picker]')
-                        .contains('Projects')
-                        .click();
-                    cy.get('[data-cy=form-submit-btn]').click();
-                });
-            cy.get('[data-cy=snackbar]')
-                .contains('Collection has been copied.')
-            cy.get('[data-cy=snackbar-goto-action]').click();
-            cy.get('[data-cy=project-panel]')
-                .contains(copyName).click();
-            cy.get('[data-cy=collection-files-panel]')
-                .should('contain', 'some-file');
-        });
+        })
+            .as("collection")
+            .then(function () {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${this.collection.uuid}`);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "some-file");
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Make a copy").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Make a copy")
+                    .within(() => {
+                        cy.get("[data-cy=projects-tree-home-tree-picker]").contains("Projects").click();
+                        cy.get("[data-cy=form-submit-btn]").click();
+                    });
+                cy.get("[data-cy=snackbar]").contains("Collection has been copied.");
+                cy.get("[data-cy=snackbar-goto-action]").click();
+                cy.get("[data-cy=project-panel]").contains(copyName).click();
+                cy.get("[data-cy=collection-files-panel]").should("contain", "some-file");
+            });
     });
 
-    it('uses the collection version browser to view a previous version', function () {
+    it("uses the collection version browser to view a previous version", function () {
         const colName = `Test Collection ${Math.floor(Math.random() * 999999)}`;
 
         // Creates the collection using the admin token so we can set up
@@ -784,586 +740,562 @@ describe('Collection panel tests', function () {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
         })
-            .as('collection').then(function () {
+            .as("collection")
+            .then(function () {
                 // Visit collection, check basic information
-                cy.loginAs(activeUser)
+                cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.collection.uuid}`);
 
-                cy.get('[data-cy=collection-info-panel]').should('not.contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('not.exist');
-                cy.get('[data-cy=collection-version-number]').should('contain', '1');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]').should('contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "1");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
 
                 // Modify collection, expect version number change
-                cy.get('[data-cy=collection-files-panel]').contains('foo').rightclick();
-                cy.get('[data-cy=context-menu]').contains('Remove').click();
-                cy.get('[data-cy=confirmation-dialog]').should('contain', 'Removing file');
-                cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
-                cy.get('[data-cy=collection-version-number]').should('contain', '2');
-                cy.get('[data-cy=collection-files-panel]').should('not.contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-files-panel]").contains("foo").rightclick();
+                cy.get("[data-cy=context-menu]").contains("Remove").click();
+                cy.get("[data-cy=confirmation-dialog]").should("contain", "Removing file");
+                cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                cy.get("[data-cy=collection-version-number]").should("contain", "2");
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
 
                 // Click on version number, check version browser. Click on past version.
-                cy.get('[data-cy=collection-version-browser]').should('not.exist');
-                cy.get('[data-cy=collection-version-number]').contains('2').click();
-                cy.get('[data-cy=collection-version-browser]')
-                    .should('contain', 'Nr').and('contain', 'Size').and('contain', 'Date')
+                cy.get("[data-cy=collection-version-browser]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").contains("2").click();
+                cy.get("[data-cy=collection-version-browser]")
+                    .should("contain", "Nr")
+                    .and("contain", "Size")
+                    .and("contain", "Date")
                     .within(() => {
                         // Version 1: 6 bytes in size
-                        cy.get('[data-cy=collection-version-browser-select-1]')
-                            .should('contain', '1')
-                            .and('contain', '6 B')
-                            .and('contain', adminUser.user.full_name);
+                        cy.get("[data-cy=collection-version-browser-select-1]")
+                            .should("contain", "1")
+                            .and("contain", "6 B")
+                            .and("contain", adminUser.user.full_name);
                         // Version 2: 3 bytes in size (one file removed)
-                        cy.get('[data-cy=collection-version-browser-select-2]')
-                            .should('contain', '2')
-                            .and('contain', '3 B')
-                            .and('contain', activeUser.user.full_name);
-                        cy.get('[data-cy=collection-version-browser-select-3]')
-                            .should('not.exist');
-                        cy.get('[data-cy=collection-version-browser-select-1]')
-                            .click();
+                        cy.get("[data-cy=collection-version-browser-select-2]")
+                            .should("contain", "2")
+                            .and("contain", "3 B")
+                            .and("contain", activeUser.user.full_name);
+                        cy.get("[data-cy=collection-version-browser-select-3]").should("not.exist");
+                        cy.get("[data-cy=collection-version-browser-select-1]").click();
                     });
-                cy.get('[data-cy=collection-info-panel]').should('contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('exist');
-                cy.get('[data-cy=collection-version-number]').should('contain', '1');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]')
-                    .should('contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-info-panel]").should("contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "1");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
 
                 // Check that only old collection action are available on context menu
-                cy.get('[data-cy=collection-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]')
-                    .should('contain', 'Restore version')
-                    .and('not.contain', 'Add to favorites');
-                cy.get('body').click(); // Collapse the menu avoiding details panel expansion
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").should("contain", "Restore version").and("not.contain", "Add to favorites");
+                cy.get("body").click(); // Collapse the menu avoiding details panel expansion
 
                 // Click on "head version" link, confirm that it's the latest version.
-                cy.get('[data-cy=collection-info-panel]').contains('head version').click();
-                cy.get('[data-cy=collection-info-panel]')
-                    .should('not.contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('not.exist');
-                cy.get('[data-cy=collection-version-number]').should('contain', '2');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]').
-                    should('not.contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-info-panel]").contains("head version").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "2");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
 
                 // Check that old collection action isn't available on context menu
-                cy.get('[data-cy=collection-panel-options-btn]').click()
-                cy.get('[data-cy=context-menu]').should('not.contain', 'Restore version')
-                cy.get('body').click(); // Collapse the menu avoiding details panel expansion
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").should("not.contain", "Restore version");
+                cy.get("body").click(); // Collapse the menu avoiding details panel expansion
 
                 // Make another change, confirm new version.
-                cy.get('[data-cy=collection-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Edit collection').click();
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Edit Collection')
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Edit collection").click();
+                cy.get("[data-cy=form-dialog]")
+                    .should("contain", "Edit Collection")
                     .within(() => {
                         // appends some text
-                        cy.get('input').first().type(' renamed');
+                        cy.get("input").first().type(" renamed");
                     });
-                cy.get('[data-cy=form-submit-btn]').click();
-                cy.get('[data-cy=collection-info-panel]')
-                    .should('not.contain', 'This is an old version');
-                cy.get('[data-cy=read-only-icon]').should('not.exist');
-                cy.get('[data-cy=collection-version-number]').should('contain', '3');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName + ' renamed');
-                cy.get('[data-cy=collection-files-panel]')
-                    .should('not.contain', 'foo').and('contain', 'bar');
-                cy.get('[data-cy=collection-version-browser-select-3]')
-                    .should('contain', '3').and('contain', '3 B');
+                cy.get("[data-cy=form-submit-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=read-only-icon]").should("not.exist");
+                cy.get("[data-cy=collection-version-number]").should("contain", "3");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName + " renamed");
+                cy.get("[data-cy=collection-files-panel]").should("not.contain", "foo").and("contain", "bar");
+                cy.get("[data-cy=collection-version-browser-select-3]").should("contain", "3").and("contain", "3 B");
 
                 // Check context menus on version browser
                 cy.waitForDom();
-                cy.get('[data-cy=collection-version-browser-select-3]').rightclick()
-                cy.get('[data-cy=context-menu]')
-                    .should('contain', 'Add to favorites')
-                    .and('contain', 'Make a copy')
-                    .and('contain', 'Edit collection');
-                cy.get('body').click();
+                cy.get("[data-cy=collection-version-browser-select-3]").rightclick();
+                cy.get("[data-cy=context-menu]")
+                    .should("contain", "Add to favorites")
+                    .and("contain", "Make a copy")
+                    .and("contain", "Edit collection");
+                cy.get("body").click();
                 // (and now an old version...)
-                cy.get('[data-cy=collection-version-browser-select-1]').rightclick()
-                cy.get('[data-cy=context-menu]')
-                    .should('not.contain', 'Add to favorites')
-                    .and('contain', 'Make a copy')
-                    .and('not.contain', 'Edit collection');
-                cy.get('body').click();
+                cy.get("[data-cy=collection-version-browser-select-1]").rightclick();
+                cy.get("[data-cy=context-menu]")
+                    .should("not.contain", "Add to favorites")
+                    .and("contain", "Make a copy")
+                    .and("not.contain", "Edit collection");
+                cy.get("body").click();
 
                 // Restore first version
-                cy.get('[data-cy=collection-version-browser]').within(() => {
-                    cy.get('[data-cy=collection-version-browser-select-1]').click();
+                cy.get("[data-cy=collection-version-browser]").within(() => {
+                    cy.get("[data-cy=collection-version-browser-select-1]").click();
                 });
-                cy.get('[data-cy=collection-panel-options-btn]').click()
-                cy.get('[data-cy=context-menu]').contains('Restore version').click();
-                cy.get('[data-cy=confirmation-dialog]').should('contain', 'Restore version');
-                cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
-                cy.get('[data-cy=collection-info-panel]')
-                    .should('not.contain', 'This is an old version');
-                cy.get('[data-cy=collection-version-number]').should('contain', '4');
-                cy.get('[data-cy=collection-info-panel]').should('contain', colName);
-                cy.get('[data-cy=collection-files-panel]')
-                    .should('contain', 'foo').and('contain', 'bar');
+                cy.get("[data-cy=collection-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Restore version").click();
+                cy.get("[data-cy=confirmation-dialog]").should("contain", "Restore version");
+                cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+                cy.get("[data-cy=collection-info-panel]").should("not.contain", "This is an old version");
+                cy.get("[data-cy=collection-version-number]").should("contain", "4");
+                cy.get("[data-cy=collection-info-panel]").should("contain", colName);
+                cy.get("[data-cy=collection-files-panel]").should("contain", "foo").and("contain", "bar");
             });
     });
 
-    it('copies selected files into new collection', () => {
+    it("copies selected files into new collection", () => {
         cy.createCollection(adminUser.token, {
             name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
         })
-            .as('collection').then(function () {
+            .as("collection")
+            .then(function () {
                 // Visit collection, check basic information
-                cy.loginAs(activeUser)
+                cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.collection.uuid}`);
 
-                cy.get('[data-cy=collection-files-panel]').within(() => {
-                    cy.get('input[type=checkbox]').first().click();
+                cy.get("[data-cy=collection-files-panel]").within(() => {
+                    cy.get("input[type=checkbox]").first().click();
                 });
 
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Copy selected into new collection').click();
+                cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Copy selected into new collection").click();
 
-                cy.get('[data-cy=form-dialog]').contains('Projects').click();
+                cy.get("[data-cy=form-dialog]").contains("Projects").click();
 
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
 
-                cy.waitForDom().get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
+                cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
 
-                cy.waitForDom().get('main').contains(`Files extracted from: ${this.collection.name}`).click();
-                cy.get('[data-cy=collection-files-panel]')
-                        .and('contain', 'bar');
+                cy.waitForDom().get("main").contains(`Files extracted from: ${this.collection.name}`).click();
+                cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
             });
     });
 
-    it('copies selected files into existing collection', () => {
+    it("copies selected files into existing collection", () => {
         cy.createCollection(adminUser.token, {
             name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
-        }).as('sourceCollection')
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
 
         cy.createCollection(adminUser.token, {
             name: `Destination Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ""
-        }).as('destinationCollection');
+            manifest_text: "",
+        }).as("destinationCollection");
 
-        cy.getAll('@sourceCollection', '@destinationCollection').then(function ([sourceCollection, destinationCollection]) {
-                // Visit collection, check basic information
-                cy.loginAs(activeUser)
-                cy.goToPath(`/collections/${sourceCollection.uuid}`);
+        cy.getAll("@sourceCollection", "@destinationCollection").then(function ([sourceCollection, destinationCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
 
-                cy.get('[data-cy=collection-files-panel]').within(() => {
-                    cy.get('input[type=checkbox]').first().click();
-                });
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+            });
 
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Copy selected into existing collection').click();
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Copy selected into existing collection").click();
 
-                cy.get('[data-cy=form-dialog]').contains(destinationCollection.name).click();
+            cy.get("[data-cy=form-dialog]").contains(destinationCollection.name).click();
 
-                cy.get('[data-cy=form-submit-btn]').click();
-                cy.wait(2000);
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.wait(2000);
 
-                cy.goToPath(`/collections/${destinationCollection.uuid}`);
+            cy.goToPath(`/collections/${destinationCollection.uuid}`);
 
-                cy.get('main').contains(destinationCollection.name).should('exist');
-                cy.get('[data-cy=collection-files-panel]')
-                        .and('contain', 'bar');
-            });
+            cy.get("main").contains(destinationCollection.name).should("exist");
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
     });
 
-    it('copies selected files into separate collections', () => {
+    it("copies selected files into separate collections", () => {
         cy.createCollection(adminUser.token, {
             name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
-        }).as('sourceCollection')
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
 
-        cy.getAll('@sourceCollection').then(function ([sourceCollection]) {
-                // Visit collection, check basic information
-                cy.loginAs(activeUser)
-                cy.goToPath(`/collections/${sourceCollection.uuid}`);
+        cy.getAll("@sourceCollection").then(function ([sourceCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
 
-                // Select both files
-                cy.waitForDom().get('[data-cy=collection-files-panel]').within(() => {
-                    cy.get('input[type=checkbox]').first().click();
-                    cy.get('input[type=checkbox]').last().click();
+            // Select both files
+            cy.waitForDom()
+                .get("[data-cy=collection-files-panel]")
+                .within(() => {
+                    cy.get("input[type=checkbox]").first().click();
+                    cy.get("input[type=checkbox]").last().click();
                 });
 
-                // Copy to separate collections
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Copy selected into separate collections').click();
-                cy.get('[data-cy=form-dialog]').contains('Projects').click();
-                cy.get('[data-cy=form-submit-btn]').click();
-
-                // Verify created collections
-                cy.waitForDom().get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
-                cy.get('main').contains(`File copied from collection ${sourceCollection.name}/foo`).click();
-                cy.get('[data-cy=collection-files-panel]')
-                        .and('contain', 'foo');
-                cy.get('.layout-pane-primary').contains('Projects').click();
-                cy.get('main').contains(`File copied from collection ${sourceCollection.name}/bar`).click();
-                cy.get('[data-cy=collection-files-panel]')
-                        .and('contain', 'bar');
-
-                // Verify separate collection menu items not present when single file selected
-                // Wait for dom for collection to re-render
-                cy.waitForDom().get('[data-cy=collection-files-panel]').within(() => {
-                    cy.get('input[type=checkbox]').first().click();
+            // Copy to separate collections
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Copy selected into separate collections").click();
+            cy.get("[data-cy=form-dialog]").contains("Projects").click();
+            cy.get("[data-cy=form-submit-btn]").click();
+
+            // Verify created collections
+            cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+            cy.get("main").contains(`File copied from collection ${sourceCollection.name}/foo`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "foo");
+            cy.get(".layout-pane-primary").contains("Projects").click();
+            cy.get("main").contains(`File copied from collection ${sourceCollection.name}/bar`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+
+            // Verify separate collection menu items not present when single file selected
+            // Wait for dom for collection to re-render
+            cy.waitForDom()
+                .get("[data-cy=collection-files-panel]")
+                .within(() => {
+                    cy.get("input[type=checkbox]").first().click();
                 });
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').should('not.contain', 'Copy selected into separate collections');
-                cy.get('[data-cy=context-menu]').should('not.contain', 'Move selected into separate collections');
-            });
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").should("not.contain", "Copy selected into separate collections");
+            cy.get("[data-cy=context-menu]").should("not.contain", "Move selected into separate collections");
+        });
     });
 
-    it('moves selected files into new collection', () => {
+    it("moves selected files into new collection", () => {
         cy.createCollection(adminUser.token, {
             name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
         })
-            .as('collection').then(function () {
+            .as("collection")
+            .then(function () {
                 // Visit collection, check basic information
-                cy.loginAs(activeUser)
+                cy.loginAs(activeUser);
                 cy.goToPath(`/collections/${this.collection.uuid}`);
 
-                cy.get('[data-cy=collection-files-panel]').within(() => {
-                    cy.get('input[type=checkbox]').first().click();
+                cy.get("[data-cy=collection-files-panel]").within(() => {
+                    cy.get("input[type=checkbox]").first().click();
                 });
 
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Move selected into new collection').click();
+                cy.get("[data-cy=collection-files-panel-options-btn]").click();
+                cy.get("[data-cy=context-menu]").contains("Move selected into new collection").click();
 
-                cy.get('[data-cy=form-dialog]').contains('Projects').click();
+                cy.get("[data-cy=form-dialog]").contains("Projects").click();
 
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
 
-                cy.waitForDom().get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
+                cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
 
-                cy.get('main').contains(`Files moved from: ${this.collection.name}`).click();
-                cy.get('[data-cy=collection-files-panel]')
-                        .and('contain', 'bar');
+                cy.get("main").contains(`Files moved from: ${this.collection.name}`).click();
+                cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
             });
     });
 
-    it('moves selected files into existing collection', () => {
+    it("moves selected files into existing collection", () => {
         cy.createCollection(adminUser.token, {
             name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
-        }).as('sourceCollection')
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
 
         cy.createCollection(adminUser.token, {
             name: `Destination Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ""
-        }).as('destinationCollection');
+            manifest_text: "",
+        }).as("destinationCollection");
 
-        cy.getAll('@sourceCollection', '@destinationCollection').then(function ([sourceCollection, destinationCollection]) {
-                // Visit collection, check basic information
-                cy.loginAs(activeUser)
-                cy.goToPath(`/collections/${sourceCollection.uuid}`);
+        cy.getAll("@sourceCollection", "@destinationCollection").then(function ([sourceCollection, destinationCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
 
-                cy.get('[data-cy=collection-files-panel]').within(() => {
-                    cy.get('input[type=checkbox]').first().click();
-                });
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+            });
 
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Move selected into existing collection').click();
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move selected into existing collection").click();
 
-                cy.get('[data-cy=form-dialog]').contains(destinationCollection.name).click();
+            cy.get("[data-cy=form-dialog]").contains(destinationCollection.name).click();
 
-                cy.get('[data-cy=form-submit-btn]').click();
-                cy.wait(2000);
+            cy.get("[data-cy=form-submit-btn]").click();
+            cy.wait(2000);
 
-                cy.goToPath(`/collections/${destinationCollection.uuid}`);
+            cy.goToPath(`/collections/${destinationCollection.uuid}`);
 
-                cy.get('main').contains(destinationCollection.name).should('exist');
-                cy.get('[data-cy=collection-files-panel]')
-                        .and('contain', 'bar');
-            });
+            cy.get("main").contains(destinationCollection.name).should("exist");
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
     });
 
-    it('moves selected files into separate collections', () => {
+    it("moves selected files into separate collections", () => {
         cy.createCollection(adminUser.token, {
             name: `Test Collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
-        }).as('sourceCollection')
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n",
+        }).as("sourceCollection");
 
-        cy.getAll('@sourceCollection').then(function ([sourceCollection]) {
-                // Visit collection, check basic information
-                cy.loginAs(activeUser)
-                cy.goToPath(`/collections/${sourceCollection.uuid}`);
-
-                // Select both files
-                cy.get('[data-cy=collection-files-panel]').within(() => {
-                    cy.get('input[type=checkbox]').first().click();
-                    cy.get('input[type=checkbox]').last().click();
-                });
+        cy.getAll("@sourceCollection").then(function ([sourceCollection]) {
+            // Visit collection, check basic information
+            cy.loginAs(activeUser);
+            cy.goToPath(`/collections/${sourceCollection.uuid}`);
 
-                // Copy to separate collections
-                cy.get('[data-cy=collection-files-panel-options-btn]').click();
-                cy.get('[data-cy=context-menu]').contains('Move selected into separate collections').click();
-                cy.get('[data-cy=form-dialog]').contains('Projects').click();
-                cy.get('[data-cy=form-submit-btn]').click();
-
-                // Verify created collections
-                cy.waitForDom().get('.layout-pane-primary', { timeout: 12000 }).contains('Projects').click();
-                cy.get('main').contains(`File moved from collection ${sourceCollection.name}/foo`).click();
-                cy.get('[data-cy=collection-files-panel]')
-                        .and('contain', 'foo');
-                cy.get('.layout-pane-primary').contains('Projects').click();
-                cy.get('main').contains(`File moved from collection ${sourceCollection.name}/bar`).click();
-                cy.get('[data-cy=collection-files-panel]')
-                        .and('contain', 'bar');
+            // Select both files
+            cy.get("[data-cy=collection-files-panel]").within(() => {
+                cy.get("input[type=checkbox]").first().click();
+                cy.get("input[type=checkbox]").last().click();
             });
+
+            // Copy to separate collections
+            cy.get("[data-cy=collection-files-panel-options-btn]").click();
+            cy.get("[data-cy=context-menu]").contains("Move selected into separate collections").click();
+            cy.get("[data-cy=form-dialog]").contains("Projects").click();
+            cy.get("[data-cy=form-submit-btn]").click();
+
+            // Verify created collections
+            cy.waitForDom().get(".layout-pane-primary", { timeout: 12000 }).contains("Projects").click();
+            cy.get("main").contains(`File moved from collection ${sourceCollection.name}/foo`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "foo");
+            cy.get(".layout-pane-primary").contains("Projects").click();
+            cy.get("main").contains(`File moved from collection ${sourceCollection.name}/bar`).click();
+            cy.get("[data-cy=collection-files-panel]").and("contain", "bar");
+        });
     });
 
-    it('creates new collection with properties on home project', function () {
+    it("creates new collection with properties on home project", function () {
         cy.loginAs(activeUser);
         cy.goToPath(`/projects/${activeUser.user.uuid}`);
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('not.exist');
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
         // Create new collection
-        cy.get('[data-cy=side-panel-button]').click();
-        cy.get('[data-cy=side-panel-new-collection]').click();
+        cy.get("[data-cy=side-panel-button]").click();
+        cy.get("[data-cy=side-panel-new-collection]").click();
         // Name between brackets tests bugfix #17582
         const collName = `[Test collection (${Math.floor(999999 * Math.random())})]`;
 
         // Select a storage class.
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'New collection')
-            .and('contain', 'Storage classes')
-            .and('contain', 'default')
-            .and('contain', 'foo')
-            .and('contain', 'bar')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "New collection")
+            .and("contain", "Storage classes")
+            .and("contain", "default")
+            .and("contain", "foo")
+            .and("contain", "bar")
             .within(() => {
-                cy.get('[data-cy=parent-field]').within(() => {
-                    cy.get('input').should('have.value', 'Home project');
+                cy.get("[data-cy=parent-field]").within(() => {
+                    cy.get("input").should("have.value", "Home project");
                 });
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(collName);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(collName);
                 });
-                cy.get('[data-cy=checkbox-foo]').click();
-            })
+                cy.get("[data-cy=checkbox-foo]").click();
+            });
 
         // Add a property.
         // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
-        cy.get('[data-cy=form-dialog]').should('not.contain', 'Color: Magenta');
-        cy.get('[data-cy=resource-properties-form]').within(() => {
-            cy.get('[data-cy=property-field-key]').within(() => {
-                cy.get('input').type('Color');
+        cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta");
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Color");
             });
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Magenta');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Magenta");
             });
             cy.root().submit();
         });
         // Confirm proper vocabulary labels are displayed on the UI.
-        cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta');
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
 
         // Value field should not complain about being required just after
         // adding a new property. See #19732
-        cy.get('[data-cy=form-dialog]').should('not.contain', 'This field is required');
+        cy.get("[data-cy=form-dialog]").should("not.contain", "This field is required");
 
-        cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-submit-btn]").click();
         // Confirm that the user was taken to the newly created collection
-        cy.get('[data-cy=form-dialog]').should('not.exist');
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('contain', collName);
-        cy.get('[data-cy=collection-info-panel]')
-            .should('contain', 'default')
-            .and('contain', 'foo')
-            .and('contain', 'Color: Magenta')
-            .and('not.contain', 'bar');
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", collName);
+        cy.get("[data-cy=collection-info-panel]")
+            .should("contain", "default")
+            .and("contain", "foo")
+            .and("contain", "Color: Magenta")
+            .and("not.contain", "bar");
         // Confirm that the collection's properties has the real values.
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${collName}"]]`,
         })
-        .its('body.items').as('collections')
-        .then(function() {
-            expect(this.collections).to.have.lengthOf(1);
-            expect(this.collections[0].properties).to.have.property(
-                'IDTAGCOLORS', 'IDVALCOLORS3');
-        });
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.have.lengthOf(1);
+                expect(this.collections[0].properties).to.have.property("IDTAGCOLORS", "IDVALCOLORS3");
+            });
     });
 
-    it('shows responsible person for collection if available', () => {
+    it("shows responsible person for collection if available", () => {
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: activeUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).as('testCollection1');
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).as("testCollection1");
 
         cy.createCollection(adminUser.token, {
             name: `Test collection ${Math.floor(Math.random() * 999999)}`,
             owner_uuid: adminUser.user.uuid,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).as('testCollection2').then(function (testCollection2) {
-            cy.shareWith(adminUser.token, activeUser.user.uuid, testCollection2.uuid, 'can_write');
-        });
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("testCollection2")
+            .then(function (testCollection2) {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, testCollection2.uuid, "can_write");
+            });
 
-        cy.getAll('@testCollection1', '@testCollection2')
-            .then(function ([testCollection1, testCollection2]) {
-                cy.loginAs(activeUser);
+        cy.getAll("@testCollection1", "@testCollection2").then(function ([testCollection1, testCollection2]) {
+            cy.loginAs(activeUser);
 
-                cy.goToPath(`/collections/${testCollection1.uuid}`);
-                cy.get('[data-cy=responsible-person-wrapper]')
-                    .contains(activeUser.user.uuid);
+            cy.goToPath(`/collections/${testCollection1.uuid}`);
+            cy.get("[data-cy=responsible-person-wrapper]").contains(activeUser.user.uuid);
 
-                cy.goToPath(`/collections/${testCollection2.uuid}`);
-                cy.get('[data-cy=responsible-person-wrapper]')
-                    .contains(adminUser.user.uuid);
-            });
+            cy.goToPath(`/collections/${testCollection2.uuid}`);
+            cy.get("[data-cy=responsible-person-wrapper]").contains(adminUser.user.uuid);
+        });
     });
 
-    describe('file upload', () => {
+    describe("file upload", () => {
         beforeEach(() => {
             cy.createCollection(adminUser.token, {
                 name: `Test collection ${Math.floor(Math.random() * 999999)}`,
                 owner_uuid: activeUser.user.uuid,
-                manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-            }).as('testCollection1');
+                manifest_text: "./subdir 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n. 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+            }).as("testCollection1");
         });
 
-        it('uploads a file and checks the collection UI to be fresh', () => {
-            cy.getAll('@testCollection1')
-                .then(function([testCollection1]) {
-                    cy.loginAs(activeUser);
-                    cy.goToPath(`/collections/${testCollection1.uuid}`);
-                    cy.get('[data-cy=upload-button]').click();
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('5mb_a.bin').should('not.exist');
-                    cy.get('[data-cy=collection-file-count]').should('contain', '2');
-                    cy.fixture('files/5mb.bin', 'base64').then(content => {
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
-                        cy.get('[data-cy=form-submit-btn]').click();
-                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
-                        cy.get('[data-cy=collection-files-panel]')
-                            .contains('5mb_a.bin').should('exist');
-                        cy.get('[data-cy=collection-file-count]').should('contain', '3');
-
-                        cy.get('[data-cy=collection-files-panel]').contains('subdir').click();
-                        cy.get('[data-cy=upload-button]').click();
-                        cy.fixture('files/5mb.bin', 'base64').then(content => {
-                            cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
-                            cy.get('[data-cy=form-submit-btn]').click();
-                            cy.waitForDom().get('[data-cy=form-submit-btn]').should('not.exist');
-                            // subdir gets unselected, I think this is a bug but
-                            // for the time being let's just make sure the test works.
-                            cy.get('[data-cy=collection-files-panel]').contains('subdir').click();
-                            cy.waitForDom().get('[data-cy=collection-files-right-panel]')
-                                 .contains('5mb_b.bin').should('exist');
-                        });
+        it("uploads a file and checks the collection UI to be fresh", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
+                cy.get("[data-cy=upload-button]").click();
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("not.exist");
+                cy.get("[data-cy=collection-file-count]").should("contain", "2");
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
+                    cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("exist");
+                    cy.get("[data-cy=collection-file-count]").should("contain", "3");
+
+                    cy.get("[data-cy=collection-files-panel]").contains("subdir").click();
+                    cy.get("[data-cy=upload-button]").click();
+                    cy.fixture("files/5mb.bin", "base64").then(content => {
+                        cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
+                        cy.get("[data-cy=form-submit-btn]").click();
+                        cy.waitForDom().get("[data-cy=form-submit-btn]").should("not.exist");
+                        // subdir gets unselected, I think this is a bug but
+                        // for the time being let's just make sure the test works.
+                        cy.get("[data-cy=collection-files-panel]").contains("subdir").click();
+                        cy.waitForDom().get("[data-cy=collection-files-right-panel]").contains("5mb_b.bin").should("exist");
                     });
                 });
+            });
         });
 
-        it('allows to cancel running upload', () => {
-            cy.getAll('@testCollection1')
-                .then(function([testCollection1]) {
-                    cy.loginAs(activeUser);
+        it("allows to cancel running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
 
-                    cy.goToPath(`/collections/${testCollection1.uuid}`);
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
 
-                    cy.get('[data-cy=upload-button]').click();
+                cy.get("[data-cy=upload-button]").click();
 
-                    cy.fixture('files/5mb.bin', 'base64').then(content => {
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
 
-                        cy.get('[data-cy=form-submit-btn]').click();
+                    cy.get("[data-cy=form-submit-btn]").click();
 
-                        cy.get('button').contains('Cancel').click();
+                    cy.get("button").contains("Cancel").click();
 
-                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
-                    });
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
                 });
+            });
         });
 
-        it('allows to cancel single file from the running upload', () => {
-            cy.getAll('@testCollection1')
-                .then(function([testCollection1]) {
-                    cy.loginAs(activeUser);
+        it("allows to cancel single file from the running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
 
-                    cy.goToPath(`/collections/${testCollection1.uuid}`);
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
 
-                    cy.get('[data-cy=upload-button]').click();
+                cy.get("[data-cy=upload-button]").click();
 
-                    cy.fixture('files/5mb.bin', 'base64').then(content => {
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
 
-                        cy.get('[data-cy=form-submit-btn]').click();
+                    cy.get("[data-cy=form-submit-btn]").click();
 
-                        cy.get('button[aria-label=Remove]').eq(1).click();
+                    cy.get("button[aria-label=Remove]").eq(1).click();
 
-                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
 
-                        cy.get('[data-cy=collection-files-panel]').contains('5mb_a.bin').should('exist');
-                    });
+                    cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("exist");
                 });
+            });
         });
 
-        it('allows to cancel all files from the running upload', () => {
-            cy.getAll('@testCollection1')
-                .then(function([testCollection1]) {
-                    cy.loginAs(activeUser);
-
-                    cy.goToPath(`/collections/${testCollection1.uuid}`);
-
-                    // Confirm initial collection state.
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('bar').should('exist');
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('5mb_a.bin').should('not.exist');
-                    cy.get('[data-cy=collection-files-panel]')
-                        .contains('5mb_b.bin').should('not.exist');
-
-                    cy.get('[data-cy=upload-button]').click();
-
-                    cy.fixture('files/5mb.bin', 'base64').then(content => {
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_a.bin');
-                        cy.get('[data-cy=drag-and-drop]').upload(content, '5mb_b.bin');
-
-                        cy.get('[data-cy=form-submit-btn]').click();
-
-                        cy.get('button[aria-label=Remove]').should('exist');
-                        cy.get('button[aria-label=Remove]')
-                            .click({ multiple: true, force: true });
-
-                        cy.get('[data-cy=form-submit-btn]').should('not.exist');
-
-                        // Confirm final collection state.
-                        cy.get('[data-cy=collection-files-panel]')
-                            .contains('bar').should('exist');
-                        // The following fails, but doesn't seem to happen
-                        // in the real world. Maybe there's a race between
-                        // the PUT request finishing and the 'Remove' button
-                        // dissapearing, because sometimes just one of the 2
-                        // files gets uploaded.
-                        // Maybe this will be needed to simulate a slow network:
-                        // https://docs.cypress.io/api/commands/intercept#Convenience-functions-1
-                        // cy.get('[data-cy=collection-files-panel]')
-                        //     .contains('5mb_a.bin').should('not.exist');
-                        // cy.get('[data-cy=collection-files-panel]')
-                        //     .contains('5mb_b.bin').should('not.exist');
-                    });
+        it("allows to cancel all files from the running upload", () => {
+            cy.getAll("@testCollection1").then(function ([testCollection1]) {
+                cy.loginAs(activeUser);
+
+                cy.goToPath(`/collections/${testCollection1.uuid}`);
+
+                // Confirm initial collection state.
+                cy.get("[data-cy=collection-files-panel]").contains("bar").should("exist");
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_a.bin").should("not.exist");
+                cy.get("[data-cy=collection-files-panel]").contains("5mb_b.bin").should("not.exist");
+
+                cy.get("[data-cy=upload-button]").click();
+
+                cy.fixture("files/5mb.bin", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_a.bin");
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "5mb_b.bin");
+
+                    cy.get("[data-cy=form-submit-btn]").click();
+
+                    cy.get("button[aria-label=Remove]").should("exist");
+                    cy.get("button[aria-label=Remove]").click({ multiple: true, force: true });
+
+                    cy.get("[data-cy=form-submit-btn]").should("not.exist");
+
+                    // Confirm final collection state.
+                    cy.get("[data-cy=collection-files-panel]").contains("bar").should("exist");
+                    // The following fails, but doesn't seem to happen
+                    // in the real world. Maybe there's a race between
+                    // the PUT request finishing and the 'Remove' button
+                    // dissapearing, because sometimes just one of the 2
+                    // files gets uploaded.
+                    // Maybe this will be needed to simulate a slow network:
+                    // https://docs.cypress.io/api/commands/intercept#Convenience-functions-1
+                    // cy.get('[data-cy=collection-files-panel]')
+                    //     .contains('5mb_a.bin').should('not.exist');
+                    // cy.get('[data-cy=collection-files-panel]')
+                    //     .contains('5mb_b.bin').should('not.exist');
                 });
+            });
         });
     });
-})
+});
index 0fe7e34117a96a4d870f47961e67c96f6cfef846..e6469039348338873aef4df1337556ffe3397cd5 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Multi-file deletion tests', function () {
+describe('Create workflow tests', function () {
     let activeUser;
     let adminUser;
 
@@ -204,4 +204,81 @@ describe('Multi-file deletion tests', function () {
                     });
             });
     }));
+
+    it('allows selecting collection subdirectories and reselects existing selections', () => {
+        cy.createProject({
+            owningUser: activeUser,
+            projectName: 'myProject1',
+            addToFavorites: true
+        });
+
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+            manifest_text: "./subdir/dir1 d41d8cd98f00b204e9800998ecf8427e+0 0:0:\\056\n./subdir/dir2 d41d8cd98f00b204e9800998ecf8427e+0 0:0:\\056\n"
+        })
+            .as('testCollection');
+
+        cy.getAll('@myProject1', '@testCollection')
+            .then(function ([myProject1, testCollection]) {
+                cy.readFile('cypress/fixtures/workflow_directory_array.yaml').then(workflow => {
+                    cy.createWorkflow(adminUser.token, {
+                        name: `TestWorkflow${Math.floor(Math.random() * 999999)}.cwl`,
+                        definition: workflow,
+                        owner_uuid: myProject1.uuid,
+                    })
+                        .as('testWorkflow');
+                });
+
+                cy.loginAs(activeUser);
+
+                cy.get('main').contains(myProject1.name).click();
+
+                cy.get('[data-cy=side-panel-button]').click();
+
+                cy.get('#aside-menu-list').contains('Run a workflow').click();
+
+                cy.get('@testWorkflow')
+                    .then((testWorkflow) => {
+                        cy.get('main').contains(testWorkflow.name).click();
+                        cy.get('[data-cy=run-process-next-button]').click();
+
+                        cy.get('label').contains('directoryInputName').parent('div').find('input').click();
+                        cy.get('div[role=dialog]')
+                            .within(() => {
+                                // must use .then to avoid selecting instead of expanding https://github.com/cypress-io/cypress/issues/5529
+                                cy.get('p').contains('Home Projects').closest('ul')
+                                    .find('i')
+                                    .then(el => el.click());
+
+                                cy.get(`[data-id=${testCollection.uuid}]`)
+                                    .find('i').click();
+
+                                cy.get(`[data-id="${testCollection.uuid}/subdir"]`)
+                                    .find('i').click();
+
+                                cy.contains('dir1').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
+                                cy.contains('dir2').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').click();
+
+                                cy.get('[data-cy=ok-button]').click();
+                            });
+
+                        // Verify subdirectories were selected
+                        cy.get('label').contains('directoryInputName').parent('div')
+                            .within(() => {
+                                cy.contains('dir1');
+                                cy.contains('dir2');
+                            });
+
+                        // Reopen tree picker and verify subdirectories are preselected
+                        cy.get('label').contains('directoryInputName').parent('div').find('input').click();
+                        cy.waitForDom().get('div[role=dialog]')
+                            .within(() => {
+                                cy.contains('dir1').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').should('be.checked');
+                                cy.contains('dir2').closest('[data-action=TOGGLE_ACTIVE]').parent().find('input[type=checkbox]').should('be.checked');
+                            });
+                    });
+
+            });
+    })
 })
index 64f27c50b54cf53e602cd5afdd944b55a2c88f9b..438acbf14dca2608ddf23b9dc3822d4aedbf3dd2 100644 (file)
@@ -2,30 +2,30 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContainerState } from 'models/container';
+import { ContainerState } from "models/container";
 
-describe('Process tests', function() {
+describe("Process tests", function () {
     let activeUser;
     let adminUser;
 
-    before(function() {
+    before(function () {
         // Only set up common users once. These aren't set up as aliases because
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'Admin', 'User', true, true)
-            .as('adminUser').then(function() {
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-        );
-        cy.getUser('user', 'Active', 'User', false, true)
-            .as('activeUser').then(function() {
+            });
+        cy.getUser("user", "Active", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-        );
+            });
     });
 
-    beforeEach(function() {
+    beforeEach(function () {
         cy.clearCookies();
         cy.clearLocalStorage();
     });
@@ -33,42 +33,46 @@ describe('Process tests', function() {
     function setupDockerImage(image_name) {
         // Create a collection that will be used as a docker image for the tests.
         cy.createCollection(adminUser.token, {
-            name: 'docker_image',
-            manifest_text: ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n"
-        }).as('dockerImage').then(function(dockerImage) {
-            // Give read permissions to the active user on the docker image.
-            cy.createLink(adminUser.token, {
-                link_class: 'permission',
-                name: 'can_read',
-                tail_uuid: activeUser.user.uuid,
-                head_uuid: dockerImage.uuid
-            }).as('dockerImagePermission').then(function() {
-                // Set-up docker image collection tags
-                cy.createLink(activeUser.token, {
-                    link_class: 'docker_image_repo+tag',
-                    name: image_name,
+            name: "docker_image",
+            manifest_text:
+                ". d21353cfe035e3e384563ee55eadbb2f+67108864 5c77a43e329b9838cbec18ff42790e57+55605760 0:122714624:sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678.tar\n",
+        })
+            .as("dockerImage")
+            .then(function (dockerImage) {
+                // Give read permissions to the active user on the docker image.
+                cy.createLink(adminUser.token, {
+                    link_class: "permission",
+                    name: "can_read",
+                    tail_uuid: activeUser.user.uuid,
                     head_uuid: dockerImage.uuid,
-                }).as('dockerImageRepoTag');
-                cy.createLink(activeUser.token, {
-                    link_class: 'docker_image_hash',
-                    name: 'sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678',
-                    head_uuid: dockerImage.uuid,
-                }).as('dockerImageHash');
-            })
-        });
-        return cy.getAll('@dockerImage', '@dockerImageRepoTag', '@dockerImageHash',
-            '@dockerImagePermission').then(function([dockerImage]) {
-                return dockerImage;
+                })
+                    .as("dockerImagePermission")
+                    .then(function () {
+                        // Set-up docker image collection tags
+                        cy.createLink(activeUser.token, {
+                            link_class: "docker_image_repo+tag",
+                            name: image_name,
+                            head_uuid: dockerImage.uuid,
+                        }).as("dockerImageRepoTag");
+                        cy.createLink(activeUser.token, {
+                            link_class: "docker_image_hash",
+                            name: "sha256:d8309758b8fe2c81034ffc8a10c36460b77db7bc5e7b448c4e5b684f9d95a678",
+                            head_uuid: dockerImage.uuid,
+                        }).as("dockerImageHash");
+                    });
             });
+        return cy.getAll("@dockerImage", "@dockerImageRepoTag", "@dockerImageHash", "@dockerImagePermission").then(function ([dockerImage]) {
+            return dockerImage;
+        });
     }
 
-    function createContainerRequest(user, name, docker_image, command, reuse = false, state = 'Uncommitted') {
-        return setupDockerImage(docker_image).then(function(dockerImage) {
+    function createContainerRequest(user, name, docker_image, command, reuse = false, state = "Uncommitted") {
+        return setupDockerImage(docker_image).then(function (dockerImage) {
             return cy.createContainerRequest(user.token, {
                 name: name,
                 command: command,
                 container_image: dockerImage.portable_data_hash, // for some reason, docker_image doesn't work here
-                output_path: 'stdout.txt',
+                output_path: "stdout.txt",
                 priority: 1,
                 runtime_constraints: {
                     vcpus: 1,
@@ -78,157 +82,144 @@ describe('Process tests', function() {
                 state: state,
                 mounts: {
                     foo: {
-                        kind: 'tmp',
-                        path: '/tmp/foo',
-                    }
-                }
+                        kind: "tmp",
+                        path: "/tmp/foo",
+                    },
+                },
             });
         });
     }
 
-    describe('Details panel', function() {
-        it('shows process details', function() {
+    describe("Details panel", function () {
+        it("shows process details", function () {
             createContainerRequest(
                 activeUser,
                 `test_container_request ${Math.floor(Math.random() * 999999)}`,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
+                "arvados/jobs",
+                ["echo", "hello world"],
+                false,
+                "Committed"
+            ).then(function (containerRequest) {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-details]').should('contain', containerRequest.name);
-                cy.get('[data-cy=process-details-attributes-modifiedby-user]').contains(`Active User (${activeUser.user.uuid})`);
-                cy.get('[data-cy=process-details-attributes-runtime-user]').should('not.exist');
+                cy.get("[data-cy=process-details]").should("contain", containerRequest.name);
+                cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`Active User (${activeUser.user.uuid})`);
+                cy.get("[data-cy=process-details-attributes-runtime-user]").should("not.exist");
             });
 
             // Fake submitted by another user
-            cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
-                req.reply((res) => {
-                    res.body.modified_by_user_uuid = 'zzzzz-tpzed-000000000000000';
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.reply(res => {
+                    res.body.modified_by_user_uuid = "zzzzz-tpzed-000000000000000";
                 });
             });
 
             createContainerRequest(
                 activeUser,
                 `test_container_request ${Math.floor(Math.random() * 999999)}`,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
+                "arvados/jobs",
+                ["echo", "hello world"],
+                false,
+                "Committed"
+            ).then(function (containerRequest) {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-details]').should('contain', containerRequest.name);
-                cy.get('[data-cy=process-details-attributes-modifiedby-user]').contains(`zzzzz-tpzed-000000000000000`);
-                cy.get('[data-cy=process-details-attributes-runtime-user]').contains(`Active User (${activeUser.user.uuid})`);
+                cy.get("[data-cy=process-details]").should("contain", containerRequest.name);
+                cy.get("[data-cy=process-details-attributes-modifiedby-user]").contains(`zzzzz-tpzed-000000000000000`);
+                cy.get("[data-cy=process-details-attributes-runtime-user]").contains(`Active User (${activeUser.user.uuid})`);
             });
         });
 
-        it('should show runtime status indicators', function() {
+        it("should show runtime status indicators", function () {
             // Setup running container with runtime_status error & warning messages
-            createContainerRequest(
-                activeUser,
-                'test_container_request',
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .as('containerRequest')
-            .then(function(containerRequest) {
-                expect(containerRequest.state).to.equal('Committed');
-                expect(containerRequest.container_uuid).not.to.be.equal('');
-
-                cy.getContainer(activeUser.token, containerRequest.container_uuid)
-                .then(function(queuedContainer) {
-                    expect(queuedContainer.state).to.be.equal('Queued');
-                });
-                cy.updateContainer(adminUser.token, containerRequest.container_uuid, {
-                    state: 'Locked'
-                }).then(function(lockedContainer) {
-                    expect(lockedContainer.state).to.be.equal('Locked');
-
-                    cy.updateContainer(adminUser.token, lockedContainer.uuid, {
-                        state: 'Running',
-                        runtime_status: {
-                            error: 'Something went wrong',
-                            errorDetail: 'Process exited with status 1',
-                            warning: 'Free disk space is low',
-                        }
-                    })
-                    .as('runningContainer')
-                    .then(function(runningContainer) {
-                        expect(runningContainer.state).to.be.equal('Running');
-                        expect(runningContainer.runtime_status).to.be.deep.equal({
-                            'error': 'Something went wrong',
-                            'errorDetail': 'Process exited with status 1',
-                            'warning': 'Free disk space is low',
-                        });
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed")
+                .as("containerRequest")
+                .then(function (containerRequest) {
+                    expect(containerRequest.state).to.equal("Committed");
+                    expect(containerRequest.container_uuid).not.to.be.equal("");
+
+                    cy.getContainer(activeUser.token, containerRequest.container_uuid).then(function (queuedContainer) {
+                        expect(queuedContainer.state).to.be.equal("Queued");
                     });
-                })
-            });
+                    cy.updateContainer(adminUser.token, containerRequest.container_uuid, {
+                        state: "Locked",
+                    }).then(function (lockedContainer) {
+                        expect(lockedContainer.state).to.be.equal("Locked");
+
+                        cy.updateContainer(adminUser.token, lockedContainer.uuid, {
+                            state: "Running",
+                            runtime_status: {
+                                error: "Something went wrong",
+                                errorDetail: "Process exited with status 1",
+                                warning: "Free disk space is low",
+                            },
+                        })
+                            .as("runningContainer")
+                            .then(function (runningContainer) {
+                                expect(runningContainer.state).to.be.equal("Running");
+                                expect(runningContainer.runtime_status).to.be.deep.equal({
+                                    error: "Something went wrong",
+                                    errorDetail: "Process exited with status 1",
+                                    warning: "Free disk space is low",
+                                });
+                            });
+                    });
+                });
             // Test that the UI shows the error and warning messages
-            cy.getAll('@containerRequest', '@runningContainer').then(function([containerRequest]) {
+            cy.getAll("@containerRequest", "@runningContainer").then(function ([containerRequest]) {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-runtime-status-error]')
-                    .should('contain', 'Something went wrong')
-                    .and('contain', 'Process exited with status 1');
-                cy.get('[data-cy=process-runtime-status-warning]')
-                    .should('contain', 'Free disk space is low')
-                    .and('contain', 'No additional warning details available');
+                cy.get("[data-cy=process-runtime-status-error]")
+                    .should("contain", "Something went wrong")
+                    .and("contain", "Process exited with status 1");
+                cy.get("[data-cy=process-runtime-status-warning]")
+                    .should("contain", "Free disk space is low")
+                    .and("contain", "No additional warning details available");
             });
 
-
             // Force container_count for testing
             let containerCount = 2;
-            cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
-                req.reply((res) => {
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.reply(res => {
                     res.body.container_count = containerCount;
                 });
             });
 
-            cy.getAll('@containerRequest').then(function([containerRequest]) {
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-runtime-status-retry-warning]', {timeout: 7000})
-                    .should('contain', 'Process retried 1 time');
+                cy.get("[data-cy=process-runtime-status-retry-warning]", { timeout: 7000 }).should("contain", "Process retried 1 time");
             });
 
-            cy.getAll('@containerRequest').then(function([containerRequest]) {
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
                 containerCount = 3;
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-runtime-status-retry-warning]', {timeout: 7000})
-                    .should('contain', 'Process retried 2 times');
+                cy.get("[data-cy=process-runtime-status-retry-warning]", { timeout: 7000 }).should("contain", "Process retried 2 times");
             });
         });
 
-        it('allows copying processes', function() {
-            const crName = 'first_container_request';
-            const copiedCrName = 'copied_container_request';
-            createContainerRequest(
-                activeUser,
-                crName,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
+        it("allows copying processes", function () {
+            const crName = "first_container_request";
+            const copiedCrName = "copied_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-details]').should('contain', crName);
+                cy.get("[data-cy=process-details]").should("contain", crName);
 
-                cy.get('[data-cy=process-details]').find('button[title="More options"]').click();
-                cy.get('ul[data-cy=context-menu]').contains("Copy and re-run process").click();
+                cy.get("[data-cy=process-details]").find('button[title="More options"]').click();
+                cy.get("ul[data-cy=context-menu]").contains("Copy and re-run process").click();
             });
 
-            cy.get('[data-cy=form-dialog]').within(() => {
-                cy.get('input[name=name]').clear().type(copiedCrName);
-                cy.get('[data-cy=projects-tree-home-tree-picker]').click();
-                cy.get('[data-cy=form-submit-btn]').click();
+            cy.get("[data-cy=form-dialog]").within(() => {
+                cy.get("input[name=name]").clear().type(copiedCrName);
+                cy.get("[data-cy=projects-tree-home-tree-picker]").click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
 
-            cy.get('[data-cy=process-details]').should('contain', copiedCrName);
-            cy.get('[data-cy=process-details]').find('button').contains('Run');
+            cy.get("[data-cy=process-details]").should("contain", copiedCrName);
+            cy.get("[data-cy=process-details]").find("button").contains("Run");
         });
 
-        const getFakeContainer = (fakeContainerUuid) => ({
+        const getFakeContainer = fakeContainerUuid => ({
             href: `/containers/${fakeContainerUuid}`,
             kind: "arvados#container",
             etag: "ecfosljpnxfari9a8m7e4yv06",
@@ -239,12 +230,12 @@ describe('Process tests', function() {
             modified_by_user_uuid: "zzzzz-tpzed-000000000000000",
             modified_at: "2023-02-15T19:12:45.987086000Z",
             command: [
-            "arvados-cwl-runner",
-            "--api=containers",
-            "--local",
-            "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza",
-            "/var/lib/cwl/workflow.json#main",
-            "/var/lib/cwl/cwl.input.json",
+                "arvados-cwl-runner",
+                "--api=containers",
+                "--local",
+                "--project-uuid=zzzzz-j7d0g-yr18k784zplfeza",
+                "/var/lib/cwl/workflow.json#main",
+                "/var/lib/cwl/cwl.input.json",
             ],
             container_image: "4ad7d11381df349e464694762db14e04+303",
             cwd: "/var/spool/cwl",
@@ -257,24 +248,24 @@ describe('Process tests', function() {
             output_path: "/var/spool/cwl",
             progress: null,
             runtime_constraints: {
-            API: true,
-            cuda: {
-                device_count: 0,
-                driver_version: "",
-                hardware_capability: "",
-            },
-            keep_cache_disk: 2147483648,
-            keep_cache_ram: 0,
-            ram: 1342177280,
-            vcpus: 1,
+                API: true,
+                cuda: {
+                    device_count: 0,
+                    driver_version: "",
+                    hardware_capability: "",
+                },
+                keep_cache_disk: 2147483648,
+                keep_cache_ram: 0,
+                ram: 1342177280,
+                vcpus: 1,
             },
             runtime_status: {},
             started_at: null,
             auth_uuid: null,
             scheduling_parameters: {
-            max_run_time: 0,
-            partitions: [],
-            preemptible: false,
+                max_run_time: 0,
+                partitions: [],
+                preemptible: false,
             },
             runtime_user_uuid: "zzzzz-tpzed-vllbpebicy84rd5",
             runtime_auth_scopes: ["all"],
@@ -287,43 +278,38 @@ describe('Process tests', function() {
             subrequests_cost: 0.0,
         });
 
-        it('shows cancel button when appropriate', function() {
+        it("shows cancel button when appropriate", function () {
             // Ignore collection requests
-            cy.intercept({method: 'GET', url: `**/arvados/v1/collections/*`}, {
-                statusCode: 200,
-                body: {}
-            });
+            cy.intercept(
+                { method: "GET", url: `**/arvados/v1/collections/*` },
+                {
+                    statusCode: 200,
+                    body: {},
+                }
+            );
 
             // Uncommitted container
             const crUncommitted = `Test process ${Math.floor(Math.random() * 999999)}`;
-            createContainerRequest(
-                activeUser,
-                crUncommitted,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Uncommitted')
-            .then(function(containerRequest) {
+            createContainerRequest(activeUser, crUncommitted, "arvados/jobs", ["echo", "hello world"], false, "Uncommitted").then(function (
+                containerRequest
+            ) {
                 // Navigate to process and verify run / cancel button
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
                 cy.waitForDom();
-                cy.get('[data-cy=process-details]').should('contain', crUncommitted);
-                cy.get('[data-cy=process-run-button]').should('exist');
-                cy.get('[data-cy=process-cancel-button]').should('not.exist');
+                cy.get("[data-cy=process-details]").should("contain", crUncommitted);
+                cy.get("[data-cy=process-run-button]").should("exist");
+                cy.get("[data-cy=process-cancel-button]").should("not.exist");
             });
 
             // Queued container
             const crQueued = `Test process ${Math.floor(Math.random() * 999999)}`;
-            const fakeCrUuid = 'zzzzz-dz642-000000000000001';
-            createContainerRequest(
-                activeUser,
-                crQueued,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
+            const fakeCrUuid = "zzzzz-dz642-000000000000001";
+            createContainerRequest(activeUser, crQueued, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
                 // Fake container uuid
-                cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
-                    req.reply((res) => {
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.reply(res => {
                         res.body.output_uuid = fakeCrUuid;
                         res.body.priority = 500;
                         res.body.state = "Committed";
@@ -332,31 +318,30 @@ describe('Process tests', function() {
 
                 // Fake container
                 const container = getFakeContainer(fakeCrUuid);
-                cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrUuid}`}, {
-                    statusCode: 200,
-                    body: {...container, state: "Queued", priority: 500}
-                });
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Queued", priority: 500 },
+                    }
+                );
 
                 // Navigate to process and verify cancel button
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
                 cy.waitForDom();
-                cy.get('[data-cy=process-details]').should('contain', crQueued);
-                cy.get('[data-cy=process-cancel-button]').contains('Cancel');
+                cy.get("[data-cy=process-details]").should("contain", crQueued);
+                cy.get("[data-cy=process-cancel-button]").contains("Cancel");
             });
 
             // Locked container
             const crLocked = `Test process ${Math.floor(Math.random() * 999999)}`;
-            const fakeCrLockedUuid = 'zzzzz-dz642-000000000000002';
-            createContainerRequest(
-                activeUser,
-                crLocked,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
+            const fakeCrLockedUuid = "zzzzz-dz642-000000000000002";
+            createContainerRequest(activeUser, crLocked, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
                 // Fake container uuid
-                cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
-                    req.reply((res) => {
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.reply(res => {
                         res.body.output_uuid = fakeCrLockedUuid;
                         res.body.priority = 500;
                         res.body.state = "Committed";
@@ -365,31 +350,30 @@ describe('Process tests', function() {
 
                 // Fake container
                 const container = getFakeContainer(fakeCrLockedUuid);
-                cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrLockedUuid}`}, {
-                    statusCode: 200,
-                    body: {...container, state: "Locked", priority: 500}
-                });
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrLockedUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Locked", priority: 500 },
+                    }
+                );
 
                 // Navigate to process and verify cancel button
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
                 cy.waitForDom();
-                cy.get('[data-cy=process-details]').should('contain', crLocked);
-                cy.get('[data-cy=process-cancel-button]').contains('Cancel');
+                cy.get("[data-cy=process-details]").should("contain", crLocked);
+                cy.get("[data-cy=process-cancel-button]").contains("Cancel");
             });
 
             // On Hold container
             const crOnHold = `Test process ${Math.floor(Math.random() * 999999)}`;
-            const fakeCrOnHoldUuid = 'zzzzz-dz642-000000000000003';
-            createContainerRequest(
-                activeUser,
-                crOnHold,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
+            const fakeCrOnHoldUuid = "zzzzz-dz642-000000000000003";
+            createContainerRequest(activeUser, crOnHold, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
                 // Fake container uuid
-                cy.intercept({method: 'GET', url: `**/arvados/v1/container_requests/${containerRequest.uuid}`}, (req) => {
-                    req.reply((res) => {
+                cy.intercept({ method: "GET", url: `**/arvados/v1/container_requests/${containerRequest.uuid}` }, req => {
+                    req.reply(res => {
                         res.body.output_uuid = fakeCrOnHoldUuid;
                         res.body.priority = 0;
                         res.body.state = "Committed";
@@ -398,1095 +382,1048 @@ describe('Process tests', function() {
 
                 // Fake container
                 const container = getFakeContainer(fakeCrOnHoldUuid);
-                cy.intercept({method: 'GET', url: `**/arvados/v1/container/${fakeCrOnHoldUuid}`}, {
-                    statusCode: 200,
-                    body: {...container, state: "Queued", priority: 0}
-                });
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/container/${fakeCrOnHoldUuid}` },
+                    {
+                        statusCode: 200,
+                        body: { ...container, state: "Queued", priority: 0 },
+                    }
+                );
 
                 // Navigate to process and verify cancel button
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
                 cy.waitForDom();
-                cy.get('[data-cy=process-details]').should('contain', crOnHold);
-                cy.get('[data-cy=process-run-button]').should('exist');
-                cy.get('[data-cy=process-cancel-button]').should('not.exist');
+                cy.get("[data-cy=process-details]").should("contain", crOnHold);
+                cy.get("[data-cy=process-run-button]").should("exist");
+                cy.get("[data-cy=process-cancel-button]").should("not.exist");
             });
         });
-
     });
 
-
-    describe('Logs panel', function() {
-        it('shows live process logs', function() {
-            cy.intercept({method: 'GET', url: '**/arvados/v1/containers/*'}, (req) => {
-                req.reply((res) => {
+    describe("Logs panel", function () {
+        it("shows live process logs", function () {
+            cy.intercept({ method: "GET", url: "**/arvados/v1/containers/*" }, req => {
+                req.reply(res => {
                     res.body.state = ContainerState.RUNNING;
                 });
             });
 
-            const crName = 'test_container_request';
-            createContainerRequest(
-                activeUser,
-                crName,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
                 // Create empty log file before loading process page
-                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
-                    ""
-                ])
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [""]);
 
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-details]').should('contain', crName);
-                cy.get('[data-cy=process-logs]')
-                    .should('contain', 'No logs yet')
-                    .and('not.contain', 'hello world');
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet").and("not.contain", "hello world");
 
                 // Append a log line
-                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
-                    "2023-07-18T20:14:48.128642814Z hello world"
-                ]).then(() => {
-                    cy.get('[data-cy=process-logs]', {timeout: 7000})
-                        .should('not.contain', 'No logs yet')
-                        .and('contain', 'hello world');
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", ["2023-07-18T20:14:48.128642814Z hello world"]).then(() => {
+                    cy.get("[data-cy=process-logs]", { timeout: 7000 }).should("not.contain", "No logs yet").and("contain", "hello world");
                 });
 
                 // Append new log line to different file
-                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", [
-                    "2023-07-18T20:14:49.128642814Z hello new line"
-                ]).then(() => {
-                    cy.get('[data-cy=process-logs]', {timeout: 7000})
-                        .should('not.contain', 'No logs yet')
-                        .and('contain', 'hello new line');
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", ["2023-07-18T20:14:49.128642814Z hello new line"]).then(() => {
+                    cy.get("[data-cy=process-logs]", { timeout: 7000 }).should("not.contain", "No logs yet").and("contain", "hello new line");
                 });
             });
         });
 
-        it('filters process logs by event type', function() {
+        it("filters process logs by event type", function () {
             const nodeInfoLogs = [
-                'Host Information',
-                'Linux compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p 5.4.0-1059-azure #62~18.04.1-Ubuntu SMP Tue Sep 14 17:53:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux',
-                'CPU Information',
-                'processor  : 0',
-                'vendor_id  : GenuineIntel',
-                'cpu family : 6',
-                'model      : 79',
-                'model name : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz'
+                "Host Information",
+                "Linux compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p 5.4.0-1059-azure #62~18.04.1-Ubuntu SMP Tue Sep 14 17:53:18 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux",
+                "CPU Information",
+                "processor  : 0",
+                "vendor_id  : GenuineIntel",
+                "cpu family : 6",
+                "model      : 79",
+                "model name : Intel(R) Xeon(R) CPU E5-2673 v4 @ 2.30GHz",
             ];
             const crunchRunLogs = [
-                '2022-03-22T13:56:22.542417997Z using local keepstore process (pid 3733) at http://localhost:46837, writing logs to keepstore.txt in log collection',
-                '2022-03-22T13:56:26.237571754Z crunch-run 2.4.0~dev20220321141729 (go1.17.1) started',
-                '2022-03-22T13:56:26.244704134Z crunch-run process has uid=0(root) gid=0(root) groups=0(root)',
-                '2022-03-22T13:56:26.244862836Z Executing container \'zzzzz-dz642-1wokwvcct9s9du3\' using docker runtime',
-                '2022-03-22T13:56:26.245037738Z Executing on host \'compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p\'',
+                "2022-03-22T13:56:22.542417997Z using local keepstore process (pid 3733) at http://localhost:46837, writing logs to keepstore.txt in log collection",
+                "2022-03-22T13:56:26.237571754Z crunch-run 2.4.0~dev20220321141729 (go1.17.1) started",
+                "2022-03-22T13:56:26.244704134Z crunch-run process has uid=0(root) gid=0(root) groups=0(root)",
+                "2022-03-22T13:56:26.244862836Z Executing container 'zzzzz-dz642-1wokwvcct9s9du3' using docker runtime",
+                "2022-03-22T13:56:26.245037738Z Executing on host 'compute-99cb150b26149780de44b929577e1aed-19rgca8vobuvc4p'",
             ];
             const stdoutLogs = [
-                '2022-03-22T13:56:22.542417987Z Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dui nisi, hendrerit porta sapien a, pretium dignissim purus.',
-                '2022-03-22T13:56:22.542417997Z Integer viverra, mauris finibus aliquet ultricies, dui mauris cursus justo, ut venenatis nibh ex eget neque.',
-                '2022-03-22T13:56:22.542418007Z In hac habitasse platea dictumst.',
-                '2022-03-22T13:56:22.542418027Z Fusce fringilla turpis id accumsan faucibus. Donec congue congue ex non posuere. In semper mi quis tristique rhoncus.',
-                '2022-03-22T13:56:22.542418037Z Interdum et malesuada fames ac ante ipsum primis in faucibus.',
-                '2022-03-22T13:56:22.542418047Z Quisque fermentum tortor ex, ut suscipit velit feugiat faucibus.',
-                '2022-03-22T13:56:22.542418057Z Donec vitae porta risus, at luctus nulla. Mauris gravida iaculis ipsum, id sagittis tortor egestas ac.',
-                '2022-03-22T13:56:22.542418067Z Maecenas condimentum volutpat nulla. Integer lacinia maximus risus eu posuere.',
-                '2022-03-22T13:56:22.542418077Z Donec vitae leo id augue gravida bibendum.',
-                '2022-03-22T13:56:22.542418087Z Nam libero libero, pretium ac faucibus elementum, mattis nec ex.',
-                '2022-03-22T13:56:22.542418097Z Nullam id laoreet nibh. Vivamus tellus metus, pretium quis justo ut, bibendum varius metus. Pellentesque vitae accumsan lorem, quis tincidunt augue.',
-                '2022-03-22T13:56:22.542418107Z Aliquam viverra nisi nulla, et efficitur dolor mattis in.',
-                '2022-03-22T13:56:22.542418117Z Sed at enim sit amet nulla tincidunt mattis. Aenean eget aliquet ex, non ultrices ex. Nulla ex tortor, vestibulum aliquam tempor ac, aliquam vel est.',
-                '2022-03-22T13:56:22.542418127Z Fusce auctor faucibus libero id venenatis. Etiam sodales, odio eu cursus efficitur, quam sem blandit ex, quis porttitor enim dui quis lectus. In id tincidunt felis.',
-                '2022-03-22T13:56:22.542418137Z Phasellus non ex quis arcu tempus faucibus molestie in sapien.',
-                '2022-03-22T13:56:22.542418147Z Duis tristique semper dolor, vitae pulvinar risus.',
-                '2022-03-22T13:56:22.542418157Z Aliquam tortor elit, luctus nec tortor eget, porta tristique nulla.',
-                '2022-03-22T13:56:22.542418167Z Nulla eget mollis ipsum.',
+                "2022-03-22T13:56:22.542417987Z Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec dui nisi, hendrerit porta sapien a, pretium dignissim purus.",
+                "2022-03-22T13:56:22.542417997Z Integer viverra, mauris finibus aliquet ultricies, dui mauris cursus justo, ut venenatis nibh ex eget neque.",
+                "2022-03-22T13:56:22.542418007Z In hac habitasse platea dictumst.",
+                "2022-03-22T13:56:22.542418027Z Fusce fringilla turpis id accumsan faucibus. Donec congue congue ex non posuere. In semper mi quis tristique rhoncus.",
+                "2022-03-22T13:56:22.542418037Z Interdum et malesuada fames ac ante ipsum primis in faucibus.",
+                "2022-03-22T13:56:22.542418047Z Quisque fermentum tortor ex, ut suscipit velit feugiat faucibus.",
+                "2022-03-22T13:56:22.542418057Z Donec vitae porta risus, at luctus nulla. Mauris gravida iaculis ipsum, id sagittis tortor egestas ac.",
+                "2022-03-22T13:56:22.542418067Z Maecenas condimentum volutpat nulla. Integer lacinia maximus risus eu posuere.",
+                "2022-03-22T13:56:22.542418077Z Donec vitae leo id augue gravida bibendum.",
+                "2022-03-22T13:56:22.542418087Z Nam libero libero, pretium ac faucibus elementum, mattis nec ex.",
+                "2022-03-22T13:56:22.542418097Z Nullam id laoreet nibh. Vivamus tellus metus, pretium quis justo ut, bibendum varius metus. Pellentesque vitae accumsan lorem, quis tincidunt augue.",
+                "2022-03-22T13:56:22.542418107Z Aliquam viverra nisi nulla, et efficitur dolor mattis in.",
+                "2022-03-22T13:56:22.542418117Z Sed at enim sit amet nulla tincidunt mattis. Aenean eget aliquet ex, non ultrices ex. Nulla ex tortor, vestibulum aliquam tempor ac, aliquam vel est.",
+                "2022-03-22T13:56:22.542418127Z Fusce auctor faucibus libero id venenatis. Etiam sodales, odio eu cursus efficitur, quam sem blandit ex, quis porttitor enim dui quis lectus. In id tincidunt felis.",
+                "2022-03-22T13:56:22.542418137Z Phasellus non ex quis arcu tempus faucibus molestie in sapien.",
+                "2022-03-22T13:56:22.542418147Z Duis tristique semper dolor, vitae pulvinar risus.",
+                "2022-03-22T13:56:22.542418157Z Aliquam tortor elit, luctus nec tortor eget, porta tristique nulla.",
+                "2022-03-22T13:56:22.542418167Z Nulla eget mollis ipsum.",
             ];
 
-            createContainerRequest(
-                activeUser,
-                'test_container_request',
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
-                cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", nodeInfoLogs).as('nodeInfoLogs');
-                cy.appendLog(adminUser.token, containerRequest.uuid, "crunch-run.txt", crunchRunLogs).as('crunchRunLogs');
-                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", stdoutLogs).as('stdoutLogs');
-
-                cy.getAll('@stdoutLogs', '@nodeInfoLogs', '@crunchRunLogs').then(function() {
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (
+                containerRequest
+            ) {
+                cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", nodeInfoLogs).as("nodeInfoLogs");
+                cy.appendLog(adminUser.token, containerRequest.uuid, "crunch-run.txt", crunchRunLogs).as("crunchRunLogs");
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", stdoutLogs).as("stdoutLogs");
+
+                cy.getAll("@stdoutLogs", "@nodeInfoLogs", "@crunchRunLogs").then(function () {
                     cy.loginAs(activeUser);
                     cy.goToPath(`/processes/${containerRequest.uuid}`);
                     // Should show main logs by default
-                    cy.get('[data-cy=process-logs-filter]', {timeout: 7000}).should('contain', 'Main logs');
-                    cy.get('[data-cy=process-logs]')
-                        .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
-                        .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
-                        .and('contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    cy.get("[data-cy=process-logs-filter]", { timeout: 7000 }).should("contain", "Main logs");
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("not.contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
                     // Select 'All logs'
-                    cy.get('[data-cy=process-logs-filter]').click();
-                    cy.get('body').contains('li', 'All logs').click();
-                    cy.get('[data-cy=process-logs]')
-                        .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
-                        .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
-                        .and('contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "All logs").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
                     // Select 'node-info' logs
-                    cy.get('[data-cy=process-logs-filter]').click();
-                    cy.get('body').contains('li', 'node-info').click();
-                    cy.get('[data-cy=process-logs]')
-                        .should('not.contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
-                        .and('contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
-                        .and('not.contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "node-info").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("not.contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("not.contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
                     // Select 'stdout' logs
-                    cy.get('[data-cy=process-logs-filter]').click();
-                    cy.get('body').contains('li', 'stdout').click();
-                    cy.get('[data-cy=process-logs]')
-                        .should('contain', stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
-                        .and('not.contain', nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
-                        .and('not.contain', crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "stdout").click();
+                    cy.get("[data-cy=process-logs]")
+                        .should("contain", stdoutLogs[Math.floor(Math.random() * stdoutLogs.length)])
+                        .and("not.contain", nodeInfoLogs[Math.floor(Math.random() * nodeInfoLogs.length)])
+                        .and("not.contain", crunchRunLogs[Math.floor(Math.random() * crunchRunLogs.length)]);
                 });
             });
         });
 
-        it('sorts combined logs', function() {
-            const crName = 'test_container_request';
-            createContainerRequest(
-                activeUser,
-                crName,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
+        it("sorts combined logs", function () {
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
                 cy.appendLog(adminUser.token, containerRequest.uuid, "node-info.txt", [
                     "3: nodeinfo 1",
                     "2: nodeinfo 2",
                     "1: nodeinfo 3",
                     "2: nodeinfo 4",
                     "3: nodeinfo 5",
-                ]).as('node-info');
+                ]).as("node-info");
 
                 cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
                     "2023-07-18T20:14:48.128642814Z first",
-                    "2023-07-18T20:14:49.128642814Z third"
-                ]).as('stdout');
+                    "2023-07-18T20:14:49.128642814Z third",
+                ]).as("stdout");
 
-                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", [
-                    "2023-07-18T20:14:48.528642814Z second"
-                ]).as('stderr');
+                cy.appendLog(adminUser.token, containerRequest.uuid, "stderr.txt", ["2023-07-18T20:14:48.528642814Z second"]).as("stderr");
 
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-details]').should('contain', crName);
-                cy.get('[data-cy=process-logs]')
-                    .should('contain', 'No logs yet');
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet");
 
-                cy.getAll('@node-info', '@stdout', '@stderr').then(() => {
+                cy.getAll("@node-info", "@stdout", "@stderr").then(() => {
                     // Verify sorted main logs
-                    cy.get('[data-cy=process-logs] pre', {timeout: 7000})
-                        .eq(0).should('contain', '2023-07-18T20:14:48.128642814Z first');
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(1).should('contain', '2023-07-18T20:14:48.528642814Z second');
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(2).should('contain', '2023-07-18T20:14:49.128642814Z third');
+                    cy.get("[data-cy=process-logs] pre", { timeout: 7000 }).eq(0).should("contain", "2023-07-18T20:14:48.128642814Z first");
+                    cy.get("[data-cy=process-logs] pre").eq(1).should("contain", "2023-07-18T20:14:48.528642814Z second");
+                    cy.get("[data-cy=process-logs] pre").eq(2).should("contain", "2023-07-18T20:14:49.128642814Z third");
 
                     // Switch to All logs
-                    cy.get('[data-cy=process-logs-filter]').click();
-                    cy.get('body').contains('li', 'All logs').click();
+                    cy.get("[data-cy=process-logs-filter]").click();
+                    cy.get("body").contains("li", "All logs").click();
                     // Verify non-sorted lines were preserved
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(0).should('contain', '3: nodeinfo 1');
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(1).should('contain', '2: nodeinfo 2');
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(2).should('contain', '1: nodeinfo 3');
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(3).should('contain', '2: nodeinfo 4');
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(4).should('contain', '3: nodeinfo 5');
+                    cy.get("[data-cy=process-logs] pre").eq(0).should("contain", "3: nodeinfo 1");
+                    cy.get("[data-cy=process-logs] pre").eq(1).should("contain", "2: nodeinfo 2");
+                    cy.get("[data-cy=process-logs] pre").eq(2).should("contain", "1: nodeinfo 3");
+                    cy.get("[data-cy=process-logs] pre").eq(3).should("contain", "2: nodeinfo 4");
+                    cy.get("[data-cy=process-logs] pre").eq(4).should("contain", "3: nodeinfo 5");
                     // Verify sorted logs
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(5).should('contain', '2023-07-18T20:14:48.128642814Z first');
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(6).should('contain', '2023-07-18T20:14:48.528642814Z second');
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(7).should('contain', '2023-07-18T20:14:49.128642814Z third');
+                    cy.get("[data-cy=process-logs] pre").eq(5).should("contain", "2023-07-18T20:14:48.128642814Z first");
+                    cy.get("[data-cy=process-logs] pre").eq(6).should("contain", "2023-07-18T20:14:48.528642814Z second");
+                    cy.get("[data-cy=process-logs] pre").eq(7).should("contain", "2023-07-18T20:14:49.128642814Z third");
                 });
             });
         });
 
-        it('correctly generates sniplines', function() {
+        it("correctly generates sniplines", function () {
             const SNIPLINE = `================ ✀ ================ ✀ ========= Some log(s) were skipped ========= ✀ ================ ✀ ================`;
-            const crName = 'test_container_request';
-            createContainerRequest(
-                activeUser,
-                crName,
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .then(function(containerRequest) {
-
+            const crName = "test_container_request";
+            createContainerRequest(activeUser, crName, "arvados/jobs", ["echo", "hello world"], false, "Committed").then(function (containerRequest) {
                 cy.appendLog(adminUser.token, containerRequest.uuid, "stdout.txt", [
-                    'X'.repeat(63999) + '_' +
-                    'O'.repeat(100) +
-                    '_' + 'X'.repeat(63999)
-                ]).as('stdout');
+                    "X".repeat(63999) + "_" + "O".repeat(100) + "_" + "X".repeat(63999),
+                ]).as("stdout");
 
                 cy.loginAs(activeUser);
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-details]').should('contain', crName);
-                cy.get('[data-cy=process-logs]')
-                    .should('contain', 'No logs yet');
+                cy.get("[data-cy=process-details]").should("contain", crName);
+                cy.get("[data-cy=process-logs]").should("contain", "No logs yet");
 
                 // Switch to stdout since lines are unsortable (no timestamp)
-                cy.get('[data-cy=process-logs-filter]').click();
-                cy.get('body').contains('li', 'stdout').click();
+                cy.get("[data-cy=process-logs-filter]").click();
+                cy.get("body").contains("li", "stdout").click();
 
-                cy.getAll('@stdout').then(() => {
+                cy.getAll("@stdout").then(() => {
                     // Verify first 64KB and snipline
-                    cy.get('[data-cy=process-logs] pre', {timeout: 7000})
-                        .eq(0).should('contain', 'X'.repeat(63999) + '_\n' + SNIPLINE);
+                    cy.get("[data-cy=process-logs] pre", { timeout: 7000 })
+                        .eq(0)
+                        .should("contain", "X".repeat(63999) + "_\n" + SNIPLINE);
                     // Verify last 64KB
-                    cy.get('[data-cy=process-logs] pre')
-                        .eq(1).should('contain', '_' + 'X'.repeat(63999));
+                    cy.get("[data-cy=process-logs] pre")
+                        .eq(1)
+                        .should("contain", "_" + "X".repeat(63999));
                     // Verify none of the Os got through
-                    cy.get('[data-cy=process-logs] pre')
-                        .should('not.contain', 'O');
+                    cy.get("[data-cy=process-logs] pre").should("not.contain", "O");
                 });
             });
         });
-
     });
 
-    describe('I/O panel', function() {
+    describe("I/O panel", function () {
         const testInputs = [
             {
                 definition: {
-                    "id": "#main/input_file",
-                    "label": "Label Description",
-                    "type": "File"
+                    id: "#main/input_file",
+                    label: "Label Description",
+                    type: "File",
                 },
                 input: {
-                    "input_file": {
-                        "basename": "input1.tar",
-                        "class": "File",
-                        "location": "keep:00000000000000000000000000000000+01/input1.tar",
-                        "secondaryFiles": [
+                    input_file: {
+                        basename: "input1.tar",
+                        class: "File",
+                        location: "keep:00000000000000000000000000000000+01/input1.tar",
+                        secondaryFiles: [
                             {
-                                "basename": "input1-2.txt",
-                                "class": "File",
-                                "location": "keep:00000000000000000000000000000000+01/input1-2.txt"
+                                basename: "input1-2.txt",
+                                class: "File",
+                                location: "keep:00000000000000000000000000000000+01/input1-2.txt",
                             },
                             {
-                                "basename": "input1-3.txt",
-                                "class": "File",
-                                "location": "keep:00000000000000000000000000000000+01/input1-3.txt"
+                                basename: "input1-3.txt",
+                                class: "File",
+                                location: "keep:00000000000000000000000000000000+01/input1-3.txt",
                             },
                             {
-                                "basename": "input1-4.txt",
-                                "class": "File",
-                                "location": "keep:00000000000000000000000000000000+01/input1-4.txt"
-                            }
-                        ]
-                    }
-                }
+                                basename: "input1-4.txt",
+                                class: "File",
+                                location: "keep:00000000000000000000000000000000+01/input1-4.txt",
+                            },
+                        ],
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_dir",
-                    "doc": "Doc Description",
-                    "type": "Directory"
+                    id: "#main/input_dir",
+                    doc: "Doc Description",
+                    type: "Directory",
                 },
                 input: {
-                    "input_dir": {
-                        "basename": "11111111111111111111111111111111+01",
-                        "class": "Directory",
-                        "location": "keep:11111111111111111111111111111111+01"
-                    }
-                }
+                    input_dir: {
+                        basename: "11111111111111111111111111111111+01",
+                        class: "Directory",
+                        location: "keep:11111111111111111111111111111111+01",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_bool",
-                    "doc": ["Doc desc 1", "Doc desc 2"],
-                    "type": "boolean"
+                    id: "#main/input_bool",
+                    doc: ["Doc desc 1", "Doc desc 2"],
+                    type: "boolean",
                 },
                 input: {
-                    "input_bool": true,
-                }
+                    input_bool: true,
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_int",
-                    "type": "int"
+                    id: "#main/input_int",
+                    type: "int",
                 },
                 input: {
-                    "input_int": 1,
-                }
+                    input_int: 1,
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_long",
-                    "type": "long"
+                    id: "#main/input_long",
+                    type: "long",
                 },
                 input: {
-                    "input_long" : 1,
-                }
+                    input_long: 1,
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_float",
-                    "type": "float"
+                    id: "#main/input_float",
+                    type: "float",
                 },
                 input: {
-                    "input_float": 1.5,
-                }
+                    input_float: 1.5,
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_double",
-                    "type": "double"
+                    id: "#main/input_double",
+                    type: "double",
                 },
                 input: {
-                    "input_double": 1.3,
-                }
+                    input_double: 1.3,
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_string",
-                    "type": "string"
+                    id: "#main/input_string",
+                    type: "string",
                 },
                 input: {
-                    "input_string": "Hello World",
-                }
+                    input_string: "Hello World",
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_file_array",
-                    "type": {
-                      "items": "File",
-                      "type": "array"
-                    }
+                    id: "#main/input_file_array",
+                    type: {
+                        items: "File",
+                        type: "array",
+                    },
                 },
                 input: {
-                    "input_file_array": [
+                    input_file_array: [
                         {
-                            "basename": "input2.tar",
-                            "class": "File",
-                            "location": "keep:00000000000000000000000000000000+02/input2.tar"
+                            basename: "input2.tar",
+                            class: "File",
+                            location: "keep:00000000000000000000000000000000+02/input2.tar",
                         },
                         {
-                            "basename": "input3.tar",
-                            "class": "File",
-                            "location": "keep:00000000000000000000000000000000+03/input3.tar",
-                            "secondaryFiles": [
+                            basename: "input3.tar",
+                            class: "File",
+                            location: "keep:00000000000000000000000000000000+03/input3.tar",
+                            secondaryFiles: [
                                 {
-                                    "basename": "input3-2.txt",
-                                    "class": "File",
-                                    "location": "keep:00000000000000000000000000000000+03/input3-2.txt"
-                                }
-                            ]
+                                    basename: "input3-2.txt",
+                                    class: "File",
+                                    location: "keep:00000000000000000000000000000000+03/input3-2.txt",
+                                },
+                            ],
                         },
                         {
-                            "$import": "import_path"
-                        }
-                    ]
-                }
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_dir_array",
-                    "type": {
-                      "items": "Directory",
-                      "type": "array"
-                    }
+                    id: "#main/input_dir_array",
+                    type: {
+                        items: "Directory",
+                        type: "array",
+                    },
                 },
                 input: {
-                    "input_dir_array": [
+                    input_dir_array: [
                         {
-                            "basename": "11111111111111111111111111111111+02",
-                            "class": "Directory",
-                            "location": "keep:11111111111111111111111111111111+02"
+                            basename: "11111111111111111111111111111111+02",
+                            class: "Directory",
+                            location: "keep:11111111111111111111111111111111+02",
                         },
                         {
-                            "basename": "11111111111111111111111111111111+03",
-                            "class": "Directory",
-                            "location": "keep:11111111111111111111111111111111+03"
+                            basename: "11111111111111111111111111111111+03",
+                            class: "Directory",
+                            location: "keep:11111111111111111111111111111111+03",
                         },
                         {
-                            "$import": "import_path"
-                        }
-                    ]
-                }
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_int_array",
-                    "type": {
-                      "items": "int",
-                      "type": "array"
-                    }
+                    id: "#main/input_int_array",
+                    type: {
+                        items: "int",
+                        type: "array",
+                    },
                 },
                 input: {
-                    "input_int_array": [
+                    input_int_array: [
                         1,
                         3,
                         5,
                         {
-                            "$import": "import_path"
-                        }
-                    ]
-                }
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_long_array",
-                    "type": {
-                      "items": "long",
-                      "type": "array"
-                    }
+                    id: "#main/input_long_array",
+                    type: {
+                        items: "long",
+                        type: "array",
+                    },
                 },
                 input: {
-                    "input_long_array": [
+                    input_long_array: [
                         10,
                         20,
                         {
-                            "$import": "import_path"
-                        }
-                    ]
-                }
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_float_array",
-                    "type": {
-                      "items": "float",
-                      "type": "array"
-                    }
+                    id: "#main/input_float_array",
+                    type: {
+                        items: "float",
+                        type: "array",
+                    },
                 },
                 input: {
-                    "input_float_array": [
+                    input_float_array: [
                         10.2,
                         10.4,
                         10.6,
                         {
-                            "$import": "import_path"
-                        }
-                    ]
-                }
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_double_array",
-                    "type": {
-                      "items": "double",
-                      "type": "array"
-                    }
+                    id: "#main/input_double_array",
+                    type: {
+                        items: "double",
+                        type: "array",
+                    },
                 },
                 input: {
-                    "input_double_array": [
+                    input_double_array: [
                         20.1,
                         20.2,
                         20.3,
                         {
-                            "$import": "import_path"
-                        }
-                    ]
-                }
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_string_array",
-                    "type": {
-                      "items": "string",
-                      "type": "array"
-                    }
+                    id: "#main/input_string_array",
+                    type: {
+                        items: "string",
+                        type: "array",
+                    },
                 },
                 input: {
-                    "input_string_array": [
+                    input_string_array: [
                         "Hello",
                         "World",
                         "!",
                         {
-                            "$import": "import_path"
-                        }
-                    ]
-                }
+                            $import: "import_path",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_bool_include",
-                    "type": "boolean"
+                    id: "#main/input_bool_include",
+                    type: "boolean",
                 },
                 input: {
-                    "input_bool_include": {
-                        "$include": "include_path"
-                    }
-                }
+                    input_bool_include: {
+                        $include: "include_path",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_int_include",
-                    "type": "int"
+                    id: "#main/input_int_include",
+                    type: "int",
                 },
                 input: {
-                    "input_int_include": {
-                        "$include": "include_path"
-                    }
-                }
+                    input_int_include: {
+                        $include: "include_path",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_float_include",
-                    "type": "float"
+                    id: "#main/input_float_include",
+                    type: "float",
                 },
                 input: {
-                    "input_float_include": {
-                        "$include": "include_path"
-                    }
-                }
+                    input_float_include: {
+                        $include: "include_path",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_string_include",
-                    "type": "string"
+                    id: "#main/input_string_include",
+                    type: "string",
                 },
                 input: {
-                    "input_string_include": {
-                        "$include": "include_path"
-                    }
-                }
+                    input_string_include: {
+                        $include: "include_path",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_file_include",
-                    "type": "File"
+                    id: "#main/input_file_include",
+                    type: "File",
                 },
                 input: {
-                    "input_file_include": {
-                        "$include": "include_path"
-                    }
-                }
+                    input_file_include: {
+                        $include: "include_path",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_directory_include",
-                    "type": "Directory"
+                    id: "#main/input_directory_include",
+                    type: "Directory",
                 },
                 input: {
-                    "input_directory_include": {
-                        "$include": "include_path"
-                    }
-                }
+                    input_directory_include: {
+                        $include: "include_path",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/input_file_url",
-                    "type": "File"
+                    id: "#main/input_file_url",
+                    type: "File",
                 },
                 input: {
-                    "input_file_url": {
-                        "basename": "index.html",
-                        "class": "File",
-                        "location": "http://example.com/index.html"
-                      }
-                }
-            }
+                    input_file_url: {
+                        basename: "index.html",
+                        class: "File",
+                        location: "http://example.com/index.html",
+                    },
+                },
+            },
         ];
 
         const testOutputs = [
             {
                 definition: {
-                    "id": "#main/output_file",
-                    "label": "Label Description",
-                    "type": "File"
+                    id: "#main/output_file",
+                    label: "Label Description",
+                    type: "File",
                 },
                 output: {
-                    "output_file": {
-                        "basename": "cat.png",
-                        "class": "File",
-                        "location": "cat.png"
-                    }
-                }
+                    output_file: {
+                        basename: "cat.png",
+                        class: "File",
+                        location: "cat.png",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_file_with_secondary",
-                    "doc": "Doc Description",
-                    "type": "File"
+                    id: "#main/output_file_with_secondary",
+                    doc: "Doc Description",
+                    type: "File",
                 },
                 output: {
-                    "output_file_with_secondary": {
-                        "basename": "main.dat",
-                        "class": "File",
-                        "location": "main.dat",
-                        "secondaryFiles": [
+                    output_file_with_secondary: {
+                        basename: "main.dat",
+                        class: "File",
+                        location: "main.dat",
+                        secondaryFiles: [
                             {
-                                "basename": "secondary.dat",
-                                "class": "File",
-                                "location": "secondary.dat"
+                                basename: "secondary.dat",
+                                class: "File",
+                                location: "secondary.dat",
                             },
                             {
-                                "basename": "secondary2.dat",
-                                "class": "File",
-                                "location": "secondary2.dat"
-                            }
-                        ]
-                    }
-                }
+                                basename: "secondary2.dat",
+                                class: "File",
+                                location: "secondary2.dat",
+                            },
+                        ],
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_dir",
-                    "doc": ["Doc desc 1", "Doc desc 2"],
-                    "type": "Directory"
+                    id: "#main/output_dir",
+                    doc: ["Doc desc 1", "Doc desc 2"],
+                    type: "Directory",
                 },
                 output: {
-                    "output_dir": {
-                        "basename": "outdir1",
-                        "class": "Directory",
-                        "location": "outdir1"
-                    }
-                }
+                    output_dir: {
+                        basename: "outdir1",
+                        class: "Directory",
+                        location: "outdir1",
+                    },
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_bool",
-                    "type": "boolean"
+                    id: "#main/output_bool",
+                    type: "boolean",
                 },
                 output: {
-                    "output_bool": true
-                }
+                    output_bool: true,
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_int",
-                    "type": "int"
+                    id: "#main/output_int",
+                    type: "int",
                 },
                 output: {
-                    "output_int": 1
-                }
+                    output_int: 1,
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_long",
-                    "type": "long"
+                    id: "#main/output_long",
+                    type: "long",
                 },
                 output: {
-                    "output_long": 1
-                }
+                    output_long: 1,
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_float",
-                    "type": "float"
+                    id: "#main/output_float",
+                    type: "float",
                 },
                 output: {
-                    "output_float": 100.5
-                }
+                    output_float: 100.5,
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_double",
-                    "type": "double"
+                    id: "#main/output_double",
+                    type: "double",
                 },
                 output: {
-                    "output_double": 100.3
-                }
+                    output_double: 100.3,
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_string",
-                    "type": "string"
+                    id: "#main/output_string",
+                    type: "string",
                 },
                 output: {
-                    "output_string": "Hello output"
-                }
+                    output_string: "Hello output",
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_file_array",
-                    "type": {
-                        "items": "File",
-                        "type": "array"
-                    }
+                    id: "#main/output_file_array",
+                    type: {
+                        items: "File",
+                        type: "array",
+                    },
                 },
                 output: {
-                    "output_file_array": [
+                    output_file_array: [
                         {
-                            "basename": "output2.tar",
-                            "class": "File",
-                            "location": "output2.tar"
+                            basename: "output2.tar",
+                            class: "File",
+                            location: "output2.tar",
                         },
                         {
-                            "basename": "output3.tar",
-                            "class": "File",
-                            "location": "output3.tar"
-                        }
-                    ]
-                }
+                            basename: "output3.tar",
+                            class: "File",
+                            location: "output3.tar",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_dir_array",
-                    "type": {
-                        "items": "Directory",
-                        "type": "array"
-                    }
+                    id: "#main/output_dir_array",
+                    type: {
+                        items: "Directory",
+                        type: "array",
+                    },
                 },
                 output: {
-                    "output_dir_array": [
+                    output_dir_array: [
                         {
-                            "basename": "outdir2",
-                            "class": "Directory",
-                            "location": "outdir2"
+                            basename: "outdir2",
+                            class: "Directory",
+                            location: "outdir2",
                         },
                         {
-                            "basename": "outdir3",
-                            "class": "Directory",
-                            "location": "outdir3"
-                        }
-                    ]
-                }
+                            basename: "outdir3",
+                            class: "Directory",
+                            location: "outdir3",
+                        },
+                    ],
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_int_array",
-                    "type": {
-                        "items": "int",
-                        "type": "array"
-                    }
+                    id: "#main/output_int_array",
+                    type: {
+                        items: "int",
+                        type: "array",
+                    },
                 },
                 output: {
-                    "output_int_array": [
-                        10,
-                        11,
-                        12
-                    ]
-                }
+                    output_int_array: [10, 11, 12],
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_long_array",
-                    "type": {
-                        "items": "long",
-                        "type": "array"
-                    }
+                    id: "#main/output_long_array",
+                    type: {
+                        items: "long",
+                        type: "array",
+                    },
                 },
                 output: {
-                    "output_long_array": [
-                        51,
-                        52
-                    ]
-                }
+                    output_long_array: [51, 52],
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_float_array",
-                    "type": {
-                        "items": "float",
-                        "type": "array"
-                    }
+                    id: "#main/output_float_array",
+                    type: {
+                        items: "float",
+                        type: "array",
+                    },
                 },
                 output: {
-                    "output_float_array": [
-                        100.2,
-                        100.4,
-                        100.6
-                    ]
-                }
+                    output_float_array: [100.2, 100.4, 100.6],
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_double_array",
-                    "type": {
-                        "items": "double",
-                        "type": "array"
-                    }
+                    id: "#main/output_double_array",
+                    type: {
+                        items: "double",
+                        type: "array",
+                    },
                 },
                 output: {
-                    "output_double_array": [
-                        100.1,
-                        100.2,
-                        100.3
-                    ]
-                }
+                    output_double_array: [100.1, 100.2, 100.3],
+                },
             },
             {
                 definition: {
-                    "id": "#main/output_string_array",
-                    "type": {
-                        "items": "string",
-                        "type": "array"
-                    }
+                    id: "#main/output_string_array",
+                    type: {
+                        items: "string",
+                        type: "array",
+                    },
                 },
                 output: {
-                    "output_string_array": [
-                        "Hello",
-                        "Output",
-                        "!"
-                    ]
-                }
-            }
+                    output_string_array: ["Hello", "Output", "!"],
+                },
+            },
         ];
 
         const verifyIOParameter = (name, label, doc, val, collection, multipleRows) => {
-            cy.get('table tr').contains(name).parents('tr').within(($mainRow) => {
-                label && cy.contains(label);
-
-                if (multipleRows) {
-                    cy.get($mainRow).nextUntil('[data-cy="process-io-param"]').as('secondaryRows');
-                    if (val) {
-                        if (Array.isArray(val)) {
-                            val.forEach(v => cy.get('@secondaryRows').contains(v));
-                        } else {
-                            cy.get('@secondaryRows').contains(val);
+            cy.get("table tr")
+                .contains(name)
+                .parents("tr")
+                .within($mainRow => {
+                    label && cy.contains(label);
+
+                    if (multipleRows) {
+                        cy.get($mainRow).nextUntil('[data-cy="process-io-param"]').as("secondaryRows");
+                        if (val) {
+                            if (Array.isArray(val)) {
+                                val.forEach(v => cy.get("@secondaryRows").contains(v));
+                            } else {
+                                cy.get("@secondaryRows").contains(val);
+                            }
                         }
-                    }
-                    if (collection) {
-                        cy.get('@secondaryRows').contains(collection);
-                    }
-                } else {
-                    if (val) {
-                        if (Array.isArray(val)) {
-                            val.forEach(v => cy.contains(v));
-                        } else {
-                            cy.contains(val);
+                        if (collection) {
+                            cy.get("@secondaryRows").contains(collection);
+                        }
+                    } else {
+                        if (val) {
+                            if (Array.isArray(val)) {
+                                val.forEach(v => cy.contains(v));
+                            } else {
+                                cy.contains(val);
+                            }
+                        }
+                        if (collection) {
+                            cy.contains(collection);
                         }
                     }
-                    if (collection) {
-                        cy.contains(collection);
-                    }
-                }
-
-
-            });
+                });
         };
 
         const verifyIOParameterImage = (name, url) => {
-            cy.get('table tr').contains(name).parents('tr').within(() => {
-                cy.get('[alt="Inline Preview"]')
-                    .should('be.visible')
-                    .and(($img) => {
-                        expect($img[0].naturalWidth).to.be.greaterThan(0);
-                        expect($img[0].src).contains(url);
-                    })
-            });
+            cy.get("table tr")
+                .contains(name)
+                .parents("tr")
+                .within(() => {
+                    cy.get('[alt="Inline Preview"]')
+                        .should("be.visible")
+                        .and($img => {
+                            expect($img[0].naturalWidth).to.be.greaterThan(0);
+                            expect($img[0].src).contains(url);
+                        });
+                });
         };
 
-        it('displays IO parameters with keep links and previews', function() {
+        it("displays IO parameters with keep links and previews", function () {
             // Create output collection for real files
             cy.createCollection(adminUser.token, {
                 name: `Test collection ${Math.floor(Math.random() * 999999)}`,
                 owner_uuid: activeUser.user.uuid,
-            }).then((testOutputCollection) => {
-                        cy.loginAs(activeUser);
+            }).then(testOutputCollection => {
+                cy.loginAs(activeUser);
 
-                        cy.goToPath(`/collections/${testOutputCollection.uuid}`);
+                cy.goToPath(`/collections/${testOutputCollection.uuid}`);
 
-                        cy.get('[data-cy=upload-button]').click();
+                cy.get("[data-cy=upload-button]").click();
 
-                        cy.fixture('files/cat.png', 'base64').then(content => {
-                            cy.get('[data-cy=drag-and-drop]').upload(content, 'cat.png');
-                            cy.get('[data-cy=form-submit-btn]').click();
-                            cy.waitForDom().get('[data-cy=form-submit-btn]').should('not.exist');
-                            // Confirm final collection state.
-                            cy.get('[data-cy=collection-files-panel]')
-                                .contains('cat.png').should('exist');
-                        });
+                cy.fixture("files/cat.png", "base64").then(content => {
+                    cy.get("[data-cy=drag-and-drop]").upload(content, "cat.png");
+                    cy.get("[data-cy=form-submit-btn]").click();
+                    cy.waitForDom().get("[data-cy=form-submit-btn]").should("not.exist");
+                    // Confirm final collection state.
+                    cy.get("[data-cy=collection-files-panel]").contains("cat.png").should("exist");
+                });
 
-                        cy.getCollection(activeUser.token, testOutputCollection.uuid).as('testOutputCollection');
-                    });
+                cy.getCollection(activeUser.token, testOutputCollection.uuid).as("testOutputCollection");
+            });
 
             // Get updated collection pdh
-            cy.getAll('@testOutputCollection').then(([testOutputCollection]) => {
+            cy.getAll("@testOutputCollection").then(([testOutputCollection]) => {
                 // Add output uuid and inputs to container request
-                cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
-                    req.reply((res) => {
+                cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                    req.reply(res => {
                         res.body.output_uuid = testOutputCollection.uuid;
                         res.body.mounts["/var/lib/cwl/cwl.input.json"] = {
-                            content: testInputs.map((param) => (param.input)).reduce((acc, val) => (Object.assign(acc, val)), {})
+                            content: testInputs.map(param => param.input).reduce((acc, val) => Object.assign(acc, val), {}),
                         };
                         res.body.mounts["/var/lib/cwl/workflow.json"] = {
                             content: {
-                                $graph: [{
-                                    id: "#main",
-                                    inputs: testInputs.map((input) => (input.definition)),
-                                    outputs: testOutputs.map((output) => (output.definition))
-                                }]
-                            }
+                                $graph: [
+                                    {
+                                        id: "#main",
+                                        inputs: testInputs.map(input => input.definition),
+                                        outputs: testOutputs.map(output => output.definition),
+                                    },
+                                ],
+                            },
                         };
                     });
                 });
 
                 // Stub fake output collection
-                cy.intercept({method: 'GET', url: `**/arvados/v1/collections/${testOutputCollection.uuid}*`}, {
-                    statusCode: 200,
-                    body: {
-                        uuid: testOutputCollection.uuid,
-                        portable_data_hash: testOutputCollection.portable_data_hash,
+                cy.intercept(
+                    { method: "GET", url: `**/arvados/v1/collections/${testOutputCollection.uuid}*` },
+                    {
+                        statusCode: 200,
+                        body: {
+                            uuid: testOutputCollection.uuid,
+                            portable_data_hash: testOutputCollection.portable_data_hash,
+                        },
                     }
-                });
+                );
 
                 // Stub fake output json
-                cy.intercept({method: 'GET', url: '**/c%3Dzzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json'}, {
-                    statusCode: 200,
-                    body: testOutputs.map((param) => (param.output)).reduce((acc, val) => (Object.assign(acc, val)), {})
-                });
+                cy.intercept(
+                    { method: "GET", url: "**/c%3Dzzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json" },
+                    {
+                        statusCode: 200,
+                        body: testOutputs.map(param => param.output).reduce((acc, val) => Object.assign(acc, val), {}),
+                    }
+                );
 
                 // Stub webdav response, points to output json
-                cy.intercept({method: 'PROPFIND', url: '*'}, {
-                    fixture: 'webdav-propfind-outputs.xml',
-                });
+                cy.intercept(
+                    { method: "PROPFIND", url: "*" },
+                    {
+                        fixture: "webdav-propfind-outputs.xml",
+                    }
+                );
             });
 
-            createContainerRequest(
-                activeUser,
-                'test_container_request',
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .as('containerRequest');
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as(
+                "containerRequest"
+            );
 
-            cy.getAll('@containerRequest', '@testOutputCollection').then(function([containerRequest, testOutputCollection]) {
+            cy.getAll("@containerRequest", "@testOutputCollection").then(function ([containerRequest, testOutputCollection]) {
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-io-card] h6').contains('Inputs')
-                    .parents('[data-cy=process-io-card]').within(() => {
-                        verifyIOParameter('input_file', null, "Label Description", 'input1.tar', '00000000000000000000000000000000+01');
-                        verifyIOParameter('input_file', null, "Label Description", 'input1-2.txt', undefined, true);
-                        verifyIOParameter('input_file', null, "Label Description", 'input1-3.txt', undefined, true);
-                        verifyIOParameter('input_file', null, "Label Description", 'input1-4.txt', undefined, true);
-                        verifyIOParameter('input_dir', null, "Doc Description", '/', '11111111111111111111111111111111+01');
-                        verifyIOParameter('input_bool', null, "Doc desc 1, Doc desc 2", 'true');
-                        verifyIOParameter('input_int', null, null, '1');
-                        verifyIOParameter('input_long', null, null, '1');
-                        verifyIOParameter('input_float', null, null, '1.5');
-                        verifyIOParameter('input_double', null, null, '1.3');
-                        verifyIOParameter('input_string', null, null, 'Hello World');
-                        verifyIOParameter('input_file_array', null, null, 'input2.tar', '00000000000000000000000000000000+02');
-                        verifyIOParameter('input_file_array', null, null, 'input3.tar', undefined, true);
-                        verifyIOParameter('input_file_array', null, null, 'input3-2.txt', undefined, true);
-                        verifyIOParameter('input_file_array', null, null, 'Cannot display value', undefined, true);
-                        verifyIOParameter('input_dir_array', null, null, '/', '11111111111111111111111111111111+02');
-                        verifyIOParameter('input_dir_array', null, null, '/', '11111111111111111111111111111111+03', true);
-                        verifyIOParameter('input_dir_array', null, null, 'Cannot display value', undefined, true);
-                        verifyIOParameter('input_int_array', null, null, ["1", "3", "5", "Cannot display value"]);
-                        verifyIOParameter('input_long_array', null, null, ["10", "20", "Cannot display value"]);
-                        verifyIOParameter('input_float_array', null, null, ["10.2", "10.4", "10.6", "Cannot display value"]);
-                        verifyIOParameter('input_double_array', null, null, ["20.1", "20.2", "20.3", "Cannot display value"]);
-                        verifyIOParameter('input_string_array', null, null, ["Hello", "World", "!", "Cannot display value"]);
-                        verifyIOParameter('input_bool_include', null, null, "Cannot display value");
-                        verifyIOParameter('input_int_include', null, null, "Cannot display value");
-                        verifyIOParameter('input_float_include', null, null, "Cannot display value");
-                        verifyIOParameter('input_string_include', null, null, "Cannot display value");
-                        verifyIOParameter('input_file_include', null, null, "Cannot display value");
-                        verifyIOParameter('input_directory_include', null, null, "Cannot display value");
-                        verifyIOParameter('input_file_url', null, null, "http://example.com/index.html");
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Inputs")
+                    .parents("[data-cy=process-io-card]")
+                    .within(() => {
+                        verifyIOParameter("input_file", null, "Label Description", "input1.tar", "00000000000000000000000000000000+01");
+                        verifyIOParameter("input_file", null, "Label Description", "input1-2.txt", undefined, true);
+                        verifyIOParameter("input_file", null, "Label Description", "input1-3.txt", undefined, true);
+                        verifyIOParameter("input_file", null, "Label Description", "input1-4.txt", undefined, true);
+                        verifyIOParameter("input_dir", null, "Doc Description", "/", "11111111111111111111111111111111+01");
+                        verifyIOParameter("input_bool", null, "Doc desc 1, Doc desc 2", "true");
+                        verifyIOParameter("input_int", null, null, "1");
+                        verifyIOParameter("input_long", null, null, "1");
+                        verifyIOParameter("input_float", null, null, "1.5");
+                        verifyIOParameter("input_double", null, null, "1.3");
+                        verifyIOParameter("input_string", null, null, "Hello World");
+                        verifyIOParameter("input_file_array", null, null, "input2.tar", "00000000000000000000000000000000+02");
+                        verifyIOParameter("input_file_array", null, null, "input3.tar", undefined, true);
+                        verifyIOParameter("input_file_array", null, null, "input3-2.txt", undefined, true);
+                        verifyIOParameter("input_file_array", null, null, "Cannot display value", undefined, true);
+                        verifyIOParameter("input_dir_array", null, null, "/", "11111111111111111111111111111111+02");
+                        verifyIOParameter("input_dir_array", null, null, "/", "11111111111111111111111111111111+03", true);
+                        verifyIOParameter("input_dir_array", null, null, "Cannot display value", undefined, true);
+                        verifyIOParameter("input_int_array", null, null, ["1", "3", "5", "Cannot display value"]);
+                        verifyIOParameter("input_long_array", null, null, ["10", "20", "Cannot display value"]);
+                        verifyIOParameter("input_float_array", null, null, ["10.2", "10.4", "10.6", "Cannot display value"]);
+                        verifyIOParameter("input_double_array", null, null, ["20.1", "20.2", "20.3", "Cannot display value"]);
+                        verifyIOParameter("input_string_array", null, null, ["Hello", "World", "!", "Cannot display value"]);
+                        verifyIOParameter("input_bool_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_int_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_float_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_string_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_file_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_directory_include", null, null, "Cannot display value");
+                        verifyIOParameter("input_file_url", null, null, "http://example.com/index.html");
                     });
-                cy.get('[data-cy=process-io-card] h6').contains('Outputs')
-                    .parents('[data-cy=process-io-card]').within((ctx) => {
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Outputs")
+                    .parents("[data-cy=process-io-card]")
+                    .within(ctx => {
                         cy.get(ctx).scrollIntoView();
-                        cy.get('[data-cy="io-preview-image-toggle"]').click({waitForAnimations: false});
+                        cy.get('[data-cy="io-preview-image-toggle"]').click({ waitForAnimations: false });
                         const outPdh = testOutputCollection.portable_data_hash;
 
-                        verifyIOParameter('output_file', null, "Label Description", 'cat.png', `${outPdh}`);
-                        verifyIOParameterImage('output_file', `/c=${outPdh}/cat.png`);
-                        verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'main.dat', `${outPdh}`);
-                        verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'secondary.dat', undefined, true);
-                        verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'secondary2.dat', undefined, true);
-                        verifyIOParameter('output_dir', null, "Doc desc 1, Doc desc 2", 'outdir1', `${outPdh}`);
-                        verifyIOParameter('output_bool', null, null, 'true');
-                        verifyIOParameter('output_int', null, null, '1');
-                        verifyIOParameter('output_long', null, null, '1');
-                        verifyIOParameter('output_float', null, null, '100.5');
-                        verifyIOParameter('output_double', null, null, '100.3');
-                        verifyIOParameter('output_string', null, null, 'Hello output');
-                        verifyIOParameter('output_file_array', null, null, 'output2.tar', `${outPdh}`);
-                        verifyIOParameter('output_file_array', null, null, 'output3.tar', undefined, true);
-                        verifyIOParameter('output_dir_array', null, null, 'outdir2', `${outPdh}`);
-                        verifyIOParameter('output_dir_array', null, null, 'outdir3', undefined, true);
-                        verifyIOParameter('output_int_array', null, null, ["10", "11", "12"]);
-                        verifyIOParameter('output_long_array', null, null, ["51", "52"]);
-                        verifyIOParameter('output_float_array', null, null, ["100.2", "100.4", "100.6"]);
-                        verifyIOParameter('output_double_array', null, null, ["100.1", "100.2", "100.3"]);
-                        verifyIOParameter('output_string_array', null, null, ["Hello", "Output", "!"]);
+                        verifyIOParameter("output_file", null, "Label Description", "cat.png", `${outPdh}`);
+                        verifyIOParameterImage("output_file", `/c=${outPdh}/cat.png`);
+                        verifyIOParameter("output_file_with_secondary", null, "Doc Description", "main.dat", `${outPdh}`);
+                        verifyIOParameter("output_file_with_secondary", null, "Doc Description", "secondary.dat", undefined, true);
+                        verifyIOParameter("output_file_with_secondary", null, "Doc Description", "secondary2.dat", undefined, true);
+                        verifyIOParameter("output_dir", null, "Doc desc 1, Doc desc 2", "outdir1", `${outPdh}`);
+                        verifyIOParameter("output_bool", null, null, "true");
+                        verifyIOParameter("output_int", null, null, "1");
+                        verifyIOParameter("output_long", null, null, "1");
+                        verifyIOParameter("output_float", null, null, "100.5");
+                        verifyIOParameter("output_double", null, null, "100.3");
+                        verifyIOParameter("output_string", null, null, "Hello output");
+                        verifyIOParameter("output_file_array", null, null, "output2.tar", `${outPdh}`);
+                        verifyIOParameter("output_file_array", null, null, "output3.tar", undefined, true);
+                        verifyIOParameter("output_dir_array", null, null, "outdir2", `${outPdh}`);
+                        verifyIOParameter("output_dir_array", null, null, "outdir3", undefined, true);
+                        verifyIOParameter("output_int_array", null, null, ["10", "11", "12"]);
+                        verifyIOParameter("output_long_array", null, null, ["51", "52"]);
+                        verifyIOParameter("output_float_array", null, null, ["100.2", "100.4", "100.6"]);
+                        verifyIOParameter("output_double_array", null, null, ["100.1", "100.2", "100.3"]);
+                        verifyIOParameter("output_string_array", null, null, ["Hello", "Output", "!"]);
                     });
             });
         });
 
-        it('displays IO parameters with no value', function() {
-
-            const fakeOutputUUID = 'zzzzz-4zz18-abcdefghijklmno';
-            const fakeOutputPDH = '11111111111111111111111111111111+99/';
+        it("displays IO parameters with no value", function () {
+            const fakeOutputUUID = "zzzzz-4zz18-abcdefghijklmno";
+            const fakeOutputPDH = "11111111111111111111111111111111+99/";
 
             cy.loginAs(activeUser);
 
             // Add output uuid and inputs to container request
-            cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
-                req.reply((res) => {
+            cy.intercept({ method: "GET", url: "**/arvados/v1/container_requests/*" }, req => {
+                req.reply(res => {
                     res.body.output_uuid = fakeOutputUUID;
                     res.body.mounts["/var/lib/cwl/cwl.input.json"] = {
-                        content: {}
+                        content: {},
                     };
                     res.body.mounts["/var/lib/cwl/workflow.json"] = {
                         content: {
-                            $graph: [{
-                                id: "#main",
-                                inputs: testInputs.map((input) => (input.definition)),
-                                outputs: testOutputs.map((output) => (output.definition))
-                            }]
-                        }
+                            $graph: [
+                                {
+                                    id: "#main",
+                                    inputs: testInputs.map(input => input.definition),
+                                    outputs: testOutputs.map(output => output.definition),
+                                },
+                            ],
+                        },
                     };
                 });
             });
 
             // Stub fake output collection
-            cy.intercept({method: 'GET', url: `**/arvados/v1/collections/${fakeOutputUUID}*`}, {
-                statusCode: 200,
-                body: {
-                    uuid: fakeOutputUUID,
-                    portable_data_hash: fakeOutputPDH,
+            cy.intercept(
+                { method: "GET", url: `**/arvados/v1/collections/${fakeOutputUUID}*` },
+                {
+                    statusCode: 200,
+                    body: {
+                        uuid: fakeOutputUUID,
+                        portable_data_hash: fakeOutputPDH,
+                    },
                 }
-            });
+            );
 
             // Stub fake output json
-            cy.intercept({method: 'GET', url: `**/c%3D${fakeOutputUUID}/cwl.output.json`}, {
-                statusCode: 200,
-                body: {}
-            });
+            cy.intercept(
+                { method: "GET", url: `**/c%3D${fakeOutputUUID}/cwl.output.json` },
+                {
+                    statusCode: 200,
+                    body: {},
+                }
+            );
 
-            cy.readFile('cypress/fixtures/webdav-propfind-outputs.xml').then((data) => {
+            cy.readFile("cypress/fixtures/webdav-propfind-outputs.xml").then(data => {
                 // Stub webdav response, points to output json
-                cy.intercept({method: 'PROPFIND', url: '*'}, {
-                    statusCode: 200,
-                    body: data.replace(/zzzzz-4zz18-zzzzzzzzzzzzzzz/g, fakeOutputUUID)
-                });
+                cy.intercept(
+                    { method: "PROPFIND", url: "*" },
+                    {
+                        statusCode: 200,
+                        body: data.replace(/zzzzz-4zz18-zzzzzzzzzzzzzzz/g, fakeOutputUUID),
+                    }
+                );
             });
 
-            createContainerRequest(
-                activeUser,
-                'test_container_request',
-                'arvados/jobs',
-                ['echo', 'hello world'],
-                false, 'Committed')
-            .as('containerRequest');
+            createContainerRequest(activeUser, "test_container_request", "arvados/jobs", ["echo", "hello world"], false, "Committed").as(
+                "containerRequest"
+            );
 
-            cy.getAll('@containerRequest').then(function([containerRequest]) {
+            cy.getAll("@containerRequest").then(function ([containerRequest]) {
                 cy.goToPath(`/processes/${containerRequest.uuid}`);
-                cy.get('[data-cy=process-io-card] h6').contains('Inputs')
-                    .parents('[data-cy=process-io-card]').within(() => {
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Inputs")
+                    .parents("[data-cy=process-io-card]")
+                    .within(() => {
                         cy.wait(2000);
                         cy.waitForDom();
-                        cy.get('tbody tr').each((item) => {
-                            cy.wrap(item).contains('No value');
+                        cy.get("tbody tr").each(item => {
+                            cy.wrap(item).contains("No value");
                         });
                     });
-                cy.get('[data-cy=process-io-card] h6').contains('Outputs')
-                    .parents('[data-cy=process-io-card]').within(() => {
-                        cy.get('tbody tr').each((item) => {
-                            cy.wrap(item).contains('No value');
+                cy.get("[data-cy=process-io-card] h6")
+                    .contains("Outputs")
+                    .parents("[data-cy=process-io-card]")
+                    .within(() => {
+                        cy.get("tbody tr").each(item => {
+                            cy.wrap(item).contains("No value");
                         });
                     });
             });
         });
     });
-
 });
index fd14cc4226323b331a22c09ea35724748a07f6b7..a8663d862261bdd51b1be0c353c2ba9ad1a363be 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Project tests', function() {
+describe("Project tests", function () {
     let activeUser;
     let adminUser;
 
-    before(function() {
+    before(function () {
         // Only set up common users once. These aren't set up as aliases because
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'Admin', 'User', true, true)
-            .as('adminUser').then(function() {
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-        );
-        cy.getUser('user', 'Active', 'User', false, true)
-            .as('activeUser').then(function() {
+            });
+        cy.getUser("user", "Active", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-        );
+            });
     });
 
-    beforeEach(function() {
+    beforeEach(function () {
         cy.clearCookies();
         cy.clearLocalStorage();
     });
 
-    it('creates a new project with multiple properties', function() {
+    it("creates a new project with multiple properties", function () {
         const projName = `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')
+        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(projName);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(projName);
                 });
-
             });
         // Key: Color (IDTAGCOLORS) - Value: Magenta (IDVALCOLORS3)
-        cy.get('[data-cy=form-dialog]').should('not.contain', 'Color: Magenta');
-        cy.get('[data-cy=resource-properties-form]').within(() => {
-            cy.get('[data-cy=property-field-key]').within(() => {
-                cy.get('input').type('Color');
+        cy.get("[data-cy=form-dialog]").should("not.contain", "Color: Magenta");
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Color");
             });
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Magenta');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Magenta");
             });
             cy.root().submit();
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Pink');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Pink");
             });
             cy.root().submit();
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Yellow');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Yellow");
             });
             cy.root().submit();
         });
         // Confirm proper vocabulary labels are displayed on the UI.
-        cy.get('[data-cy=form-dialog]').should('contain', 'Color: Magenta');
-        cy.get('[data-cy=form-dialog]').should('contain', 'Color: Pink');
-        cy.get('[data-cy=form-dialog]').should('contain', 'Color: Yellow');
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Magenta");
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Pink");
+        cy.get("[data-cy=form-dialog]").should("contain", "Color: Yellow");
 
-        cy.get('[data-cy=resource-properties-form]').within(() => {
-            cy.get('[data-cy=property-field-key]').within(() => {
-                cy.get('input').focus();
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").focus();
             });
-            cy.get('[data-cy=property-field-key]').should('not.contain', 'Color');
+            cy.get("[data-cy=property-field-key]").should("not.contain", "Color");
         });
 
         // Create project and confirm the properties' real values.
-        cy.get('[data-cy=form-submit-btn]').click();
-        cy.get('[data-cy=breadcrumb-last]').should('contain', projName);
-        cy.doRequest('GET', '/arvados/v1/groups', null, {
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.get("[data-cy=breadcrumb-last]").should("contain", projName);
+        cy.doRequest("GET", "/arvados/v1/groups", null, {
             filters: `[["name", "=", "${projName}"], ["group_class", "=", "project"]]`,
         })
-        .its('body.items').as('projects')
-        .then(function() {
-            expect(this.projects).to.have.lengthOf(1);
-            expect(this.projects[0].properties).to.deep.equal(
-                // Pink is not in the test vocab
-                {IDTAGCOLORS: ['IDVALCOLORS3', 'Pink', 'IDVALCOLORS1']});
-        });
+            .its("body.items")
+            .as("projects")
+            .then(function () {
+                expect(this.projects).to.have.lengthOf(1);
+                expect(this.projects[0].properties).to.deep.equal(
+                    // Pink is not in the test vocab
+                    { IDTAGCOLORS: ["IDVALCOLORS3", "Pink", "IDVALCOLORS1"] }
+                );
+            });
 
         // Open project edit via breadcrumbs
-        cy.get('[data-cy=breadcrumbs]').contains(projName).rightclick();
-        cy.get('[data-cy=context-menu]').contains('Edit').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=resource-properties-list]').within(() => {
-                cy.get('div[role=button]').contains('Color: Magenta');
-                cy.get('div[role=button]').contains('Color: Pink');
-                cy.get('div[role=button]').contains('Color: Yellow');
+        cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick();
+        cy.get("[data-cy=context-menu]").contains("Edit").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=resource-properties-list]").within(() => {
+                cy.get("div[role=button]").contains("Color: Magenta");
+                cy.get("div[role=button]").contains("Color: Pink");
+                cy.get("div[role=button]").contains("Color: Yellow");
             });
         });
         // Add another property
-        cy.get('[data-cy=resource-properties-form]').within(() => {
-            cy.get('[data-cy=property-field-key]').within(() => {
-                cy.get('input').type('Animal');
+        cy.get("[data-cy=resource-properties-form]").within(() => {
+            cy.get("[data-cy=property-field-key]").within(() => {
+                cy.get("input").type("Animal");
             });
-            cy.get('[data-cy=property-field-value]').within(() => {
-                cy.get('input').type('Dog');
+            cy.get("[data-cy=property-field-value]").within(() => {
+                cy.get("input").type("Dog");
             });
             cy.root().submit();
         });
-        cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-submit-btn]").click({ force: true });
         // Reopen edit via breadcrumbs and verify properties
-        cy.get('[data-cy=breadcrumbs]').contains(projName).rightclick();
-        cy.get('[data-cy=context-menu]').contains('Edit').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=resource-properties-list]').within(() => {
-                cy.get('div[role=button]').contains('Color: Magenta');
-                cy.get('div[role=button]').contains('Color: Pink');
-                cy.get('div[role=button]').contains('Color: Yellow');
-                cy.get('div[role=button]').contains('Animal: Dog');
+        cy.get("[data-cy=breadcrumbs]").contains(projName).rightclick();
+        cy.get("[data-cy=context-menu]").contains("Edit").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=resource-properties-list]").within(() => {
+                cy.get("div[role=button]").contains("Color: Magenta");
+                cy.get("div[role=button]").contains("Color: Pink");
+                cy.get("div[role=button]").contains("Color: Yellow");
+                cy.get("div[role=button]").contains("Animal: Dog");
             });
         });
     });
 
-    it('creates a project without and with description', function() {
+    it("creates a project without and with description", function () {
         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
         cy.loginAs(activeUser);
 
         // Create project
-        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')
+        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(projName);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(projName);
                 });
             });
-        cy.get('[data-cy=form-submit-btn]').click();
-        cy.get('[data-cy=form-dialog]').should('not.exist');
+        cy.get("[data-cy=form-submit-btn]").click();
+        cy.get("[data-cy=form-dialog]").should("not.exist");
 
         const editProjectDescription = (name, type) => {
-            cy.get('[data-cy=side-panel-tree]').contains('Home Projects').click();
-            cy.get('[data-cy=project-panel] tbody tr').contains(name).rightclick();
-            cy.get('[data-cy=context-menu]').contains('Edit').click();
-            cy.get('[data-cy=form-dialog]').within(() => {
-                cy.get('div[contenteditable=true]')
-                    .click()
-                    .type(type);
-                cy.get('[data-cy=form-submit-btn]').click();
+            cy.get("[data-cy=side-panel-tree]").contains("Home Projects").click();
+            cy.get("[data-cy=project-panel] tbody tr").contains(name).rightclick({ force: true });
+            cy.get("[data-cy=context-menu]").contains("Edit").click();
+            cy.get("[data-cy=form-dialog]").within(() => {
+                cy.get("div[contenteditable=true]").click().type(type);
+                cy.get("[data-cy=form-submit-btn]").click();
             });
         };
 
         const verifyProjectDescription = (name, description) => {
-            cy.doRequest('GET', '/arvados/v1/groups', null, {
+            cy.doRequest("GET", "/arvados/v1/groups", null, {
                 filters: `[["name", "=", "${name}"], ["group_class", "=", "project"]]`,
             })
-            .its('body.items').as('projects')
-            .then(function() {
-                expect(this.projects).to.have.lengthOf(1);
-                expect(this.projects[0].description).to.equal(description);
-            });
+                .its("body.items")
+                .as("projects")
+                .then(function () {
+                    expect(this.projects).to.have.lengthOf(1);
+                    expect(this.projects[0].description).to.equal(description);
+                });
         };
 
         // Edit description
-        editProjectDescription(projName, 'Test description');
+        editProjectDescription(projName, "Test description");
 
         // Check description is set
         verifyProjectDescription(projName, "<p>Test description</p>");
 
         // Clear description
-        editProjectDescription(projName, '{selectall}{backspace}');
+        editProjectDescription(projName, "{selectall}{backspace}");
 
         // Check description is null
         verifyProjectDescription(projName, null);
 
         // Set description to contain whitespace
-        editProjectDescription(projName, '{selectall}{backspace}    x');
-        editProjectDescription(projName, '{backspace}');
+        editProjectDescription(projName, "{selectall}{backspace}    x");
+        editProjectDescription(projName, "{backspace}");
 
         // Check description is null
         verifyProjectDescription(projName, null);
-
     });
 
-    it('creates new project on home project and then a subproject inside it', function() {
-        const createProject = function(name, parentName) {
-            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')
+    it("creates new project on home project and then a subproject inside it", function () {
+        const createProject = function (name, parentName) {
+            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=parent-field]').within(() => {
-                        cy.get('input').invoke('val').then((val) => {
-                            expect(val).to.include(parentName);
-                        });
+                    cy.get("[data-cy=parent-field]").within(() => {
+                        cy.get("input")
+                            .invoke("val")
+                            .then(val => {
+                                expect(val).to.include(parentName);
+                            });
                     });
-                    cy.get('[data-cy=name-field]').within(() => {
-                        cy.get('input').type(name);
+                    cy.get("[data-cy=name-field]").within(() => {
+                        cy.get("input").type(name);
                     });
                 });
-            cy.get('[data-cy=form-submit-btn]').click();
-        }
+            cy.get("[data-cy=form-submit-btn]").click();
+        };
 
         cy.loginAs(activeUser);
         cy.goToPath(`/projects/${activeUser.user.uuid}`);
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('not.exist');
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("not.exist");
         // Create new project
         const projName = `Test project (${Math.floor(999999 * Math.random())})`;
-        createProject(projName, 'Home project');
+        createProject(projName, "Home project");
         // Confirm that the user was taken to the newly created thing
-        cy.get('[data-cy=form-dialog]').should('not.exist');
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('contain', projName);
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", projName);
         // Create a subproject
         const subProjName = `Test project (${Math.floor(999999 * Math.random())})`;
         createProject(subProjName, projName);
-        cy.get('[data-cy=form-dialog]').should('not.exist');
-        cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
-        cy.get('[data-cy=breadcrumb-last]').should('contain', subProjName);
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=breadcrumb-first]").should("contain", "Projects");
+        cy.get("[data-cy=breadcrumb-last]").should("contain", subProjName);
     });
 
-    it('attempts to use a preexisting name creating a project', function() {
+    it("attempts to use a preexisting name creating a project", function () {
         const name = `Test project ${Math.floor(Math.random() * 999999)}`;
         cy.createGroup(activeUser.token, {
             name: name,
-            group_class: 'project',
+            group_class: "project",
         });
         cy.loginAs(activeUser);
         cy.goToPath(`/projects/${activeUser.user.uuid}`);
 
         // Attempt to create new collection with a duplicate name
-        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')
+        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(name);
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(name);
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
         // Error message should display, allowing editing the name
-        cy.get('[data-cy=form-dialog]').should('exist')
-            .and('contain', 'Project with the same name already exists')
+        cy.get("[data-cy=form-dialog]")
+            .should("exist")
+            .and("contain", "Project with the same name already exists")
             .within(() => {
-                cy.get('[data-cy=name-field]').within(() => {
-                    cy.get('input').type(' renamed');
+                cy.get("[data-cy=name-field]").within(() => {
+                    cy.get("input").type(" renamed");
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
-        cy.get('[data-cy=form-dialog]').should('not.exist');
+        cy.get("[data-cy=form-dialog]").should("not.exist");
     });
 
-    it('navigates to the parent project after trashing the one being displayed', function() {
+    it("navigates to the parent project after trashing the one being displayed", function () {
         cy.createGroup(activeUser.token, {
             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('testRootProject').then(function() {
-            cy.createGroup(activeUser.token, {
-                name : `Test subproject ${Math.floor(Math.random() * 999999)}`,
-                group_class: 'project',
-                owner_uuid: this.testRootProject.uuid,
-            }).as('testSubProject');
-        });
-        cy.getAll('@testRootProject', '@testSubProject').then(function([testRootProject, testSubProject]) {
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function () {
+                cy.createGroup(activeUser.token, {
+                    name: `Test subproject ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
+                    owner_uuid: this.testRootProject.uuid,
+                }).as("testSubProject");
+            });
+        cy.getAll("@testRootProject", "@testSubProject").then(function ([testRootProject, testSubProject]) {
             cy.loginAs(activeUser);
 
             // Go to subproject and trash it.
             cy.goToPath(`/projects/${testSubProject.uuid}`);
-            cy.get('[data-cy=side-panel-tree]').should('contain', testSubProject.name);
-            cy.get('[data-cy=breadcrumb-last]')
-                .should('contain', testSubProject.name)
-                .rightclick();
-            cy.get('[data-cy=context-menu]').contains('Move to trash').click();
+            cy.get("[data-cy=side-panel-tree]").should("contain", testSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testSubProject.name).rightclick();
+            cy.get("[data-cy=context-menu]").contains("Move to trash").click();
 
             // Confirm that the parent project should be displayed.
-            cy.get('[data-cy=breadcrumb-last]').should('contain', testRootProject.name);
-            cy.url().should('contain', `/projects/${testRootProject.uuid}`);
-            cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name);
+            cy.url().should("contain", `/projects/${testRootProject.uuid}`);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name);
 
             // Checks for bugfix #17637.
-            cy.get('[data-cy=not-found-content]').should('not.exist');
-            cy.get('[data-cy=not-found-page]').should('not.exist');
+            cy.get("[data-cy=not-found-content]").should("not.exist");
+            cy.get("[data-cy=not-found-page]").should("not.exist");
         });
     });
 
-    it('resets the search box only when navigating out of the current project', function() {
+    it("resets the search box only when navigating out of the current project", function () {
         const fooProjectNameA = `Test foo project ${Math.floor(Math.random() * 999999)}`;
         const fooProjectNameB = `Test foo project ${Math.floor(Math.random() * 999999)}`;
         const barProjectNameA = `Test bar project ${Math.floor(Math.random() * 999999)}`;
@@ -292,325 +294,323 @@ describe('Project tests', function() {
         [fooProjectNameA, fooProjectNameB, barProjectNameA].forEach(projName => {
             cy.createGroup(activeUser.token, {
                 name: projName,
-                group_class: 'project',
+                group_class: "project",
             });
         });
 
         cy.loginAs(activeUser);
-        cy.get('[data-cy=project-panel]')
-            .should('contain', fooProjectNameA)
-            .and('contain', fooProjectNameB)
-            .and('contain', barProjectNameA);
+        cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("contain", barProjectNameA);
 
-        cy.get('[data-cy=search-input]').type('foo');
-        cy.get('[data-cy=project-panel]')
-            .should('contain', fooProjectNameA)
-            .and('contain', fooProjectNameB)
-            .and('not.contain', barProjectNameA);
+        cy.get("[data-cy=search-input]").type("foo");
+        cy.get("[data-cy=project-panel]").should("contain", fooProjectNameA).and("contain", fooProjectNameB).and("not.contain", barProjectNameA);
 
         // Click on the table row to select it, search should remain the same.
-        cy.get(`p:contains(${fooProjectNameA})`)
-            .parent().parent().parent().parent().click();
-        cy.get('[data-cy=search-input] input').should('have.value', 'foo');
+        cy.get(`p:contains(${fooProjectNameA})`).parent().parent().parent().parent().click();
+        cy.get("[data-cy=search-input] input").should("have.value", "foo");
 
         // Click to navigate to the project, search should be reset
         cy.get(`p:contains(${fooProjectNameA})`).click();
-        cy.get('[data-cy=search-input] input').should('not.have.value', 'foo');
+        cy.get("[data-cy=search-input] input").should("not.have.value", "foo");
     });
 
-    it('navigates to the root project after trashing the parent of the one being displayed', function() {
+    it("navigates to the root project after trashing the parent of the one being displayed", function () {
         cy.createGroup(activeUser.token, {
             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('testRootProject').then(function() {
-            cy.createGroup(activeUser.token, {
-                name : `Test subproject ${Math.floor(Math.random() * 999999)}`,
-                group_class: 'project',
-                owner_uuid: this.testRootProject.uuid,
-            }).as('testSubProject').then(function() {
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function () {
                 cy.createGroup(activeUser.token, {
-                    name : `Test sub subproject ${Math.floor(Math.random() * 999999)}`,
-                    group_class: 'project',
-                    owner_uuid: this.testSubProject.uuid,
-                }).as('testSubSubProject');
+                    name: `Test subproject ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
+                    owner_uuid: this.testRootProject.uuid,
+                })
+                    .as("testSubProject")
+                    .then(function () {
+                        cy.createGroup(activeUser.token, {
+                            name: `Test sub subproject ${Math.floor(Math.random() * 999999)}`,
+                            group_class: "project",
+                            owner_uuid: this.testSubProject.uuid,
+                        }).as("testSubSubProject");
+                    });
             });
-        });
-        cy.getAll('@testRootProject', '@testSubProject', '@testSubSubProject').then(function([testRootProject, testSubProject, testSubSubProject]) {
+        cy.getAll("@testRootProject", "@testSubProject", "@testSubSubProject").then(function ([testRootProject, testSubProject, testSubSubProject]) {
             cy.loginAs(activeUser);
 
             // Go to innermost project and trash its parent.
             cy.goToPath(`/projects/${testSubSubProject.uuid}`);
-            cy.get('[data-cy=side-panel-tree]').should('contain', testSubSubProject.name);
-            cy.get('[data-cy=breadcrumb-last]').should('contain', testSubSubProject.name);
-            cy.get('[data-cy=side-panel-tree]')
-                .contains(testSubProject.name)
-                .rightclick();
-            cy.get('[data-cy=context-menu]').contains('Move to trash').click();
+            cy.get("[data-cy=side-panel-tree]").should("contain", testSubSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testSubSubProject.name);
+            cy.get("[data-cy=side-panel-tree]").contains(testSubProject.name).rightclick();
+            cy.get("[data-cy=context-menu]").contains("Move to trash").click();
 
             // Confirm that the trashed project's parent should be displayed.
-            cy.get('[data-cy=breadcrumb-last]').should('contain', testRootProject.name);
-            cy.url().should('contain', `/projects/${testRootProject.uuid}`);
-            cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubProject.name);
-            cy.get('[data-cy=side-panel-tree]').should('not.contain', testSubSubProject.name);
+            cy.get("[data-cy=breadcrumb-last]").should("contain", testRootProject.name);
+            cy.url().should("contain", `/projects/${testRootProject.uuid}`);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubProject.name);
+            cy.get("[data-cy=side-panel-tree]").should("not.contain", testSubSubProject.name);
 
             // Checks for bugfix #17637.
-            cy.get('[data-cy=not-found-content]').should('not.exist');
-            cy.get('[data-cy=not-found-page]').should('not.exist');
+            cy.get("[data-cy=not-found-content]").should("not.exist");
+            cy.get("[data-cy=not-found-page]").should("not.exist");
         });
     });
 
-    it('shows details panel when clicking on the info icon', () => {
+    it("shows details panel when clicking on the info icon", () => {
         cy.createGroup(activeUser.token, {
             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('testRootProject').then(function(testRootProject) {
-            cy.loginAs(activeUser);
+            group_class: "project",
+        })
+            .as("testRootProject")
+            .then(function (testRootProject) {
+                cy.loginAs(activeUser);
 
-            cy.get('[data-cy=side-panel-tree]').contains(testRootProject.name).click();
+                cy.get("[data-cy=side-panel-tree]").contains(testRootProject.name).click();
 
-            cy.get('[data-cy=additional-info-icon]').click();
+                cy.get("[data-cy=additional-info-icon]").click();
 
-            cy.contains(testRootProject.uuid).should('exist');
-        });
+                cy.contains(testRootProject.uuid).should("exist");
+            });
     });
 
-    it('clears search input when changing project', () => {
+    it("clears search input when changing project", () => {
         cy.createGroup(activeUser.token, {
             name: `Test root project ${Math.floor(Math.random() * 999999)}`,
-            group_class: 'project',
-        }).as('testProject1').then((testProject1) => {
-            cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, 'can_write');
-        });
+            group_class: "project",
+        })
+            .as("testProject1")
+            .then(testProject1 => {
+                cy.shareWith(adminUser.token, activeUser.user.uuid, testProject1.uuid, "can_write");
+            });
 
-        cy.getAll('@testProject1').then(function([testProject1]) {
+        cy.getAll("@testProject1").then(function ([testProject1]) {
             cy.loginAs(activeUser);
 
-            cy.get('[data-cy=side-panel-tree]').contains(testProject1.name).click();
+            cy.get("[data-cy=side-panel-tree]").contains(testProject1.name).click();
 
-            cy.get('[data-cy=search-input] input').type('test123');
+            cy.get("[data-cy=search-input] input").type("test123");
 
-            cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+            cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
 
-            cy.get('[data-cy=search-input] input').should('not.have.value', 'test123');
+            cy.get("[data-cy=search-input] input").should("not.have.value", "test123");
         });
     });
 
-    it('opens advanced popup for project with username', () => {
+    it("opens advanced popup for project with username", () => {
         const projectName = `Test project ${Math.floor(Math.random() * 999999)}`;
 
         cy.createGroup(adminUser.token, {
             name: projectName,
-            group_class: 'project',
-        }).as('mainProject')
+            group_class: "project",
+        }).as("mainProject");
 
-        cy.getAll('@mainProject')
-            .then(function ([mainProject]) {
-                cy.loginAs(adminUser);
+        cy.getAll("@mainProject").then(function ([mainProject]) {
+            cy.loginAs(adminUser);
 
-                cy.get('[data-cy=side-panel-tree]').contains('Groups').click();
+            cy.get("[data-cy=side-panel-tree]").contains("Groups").click();
 
-                cy.get('[data-cy=uuid]').eq(0).invoke('text').then(uuid => {
+            cy.get("[data-cy=uuid]")
+                .eq(0)
+                .invoke("text")
+                .then(uuid => {
                     cy.createLink(adminUser.token, {
-                        name: 'can_write',
-                        link_class: 'permission',
+                        name: "can_write",
+                        link_class: "permission",
                         head_uuid: mainProject.uuid,
-                        tail_uuid: uuid
+                        tail_uuid: uuid,
                     });
 
                     cy.createLink(adminUser.token, {
-                        name: 'can_write',
-                        link_class: 'permission',
+                        name: "can_write",
+                        link_class: "permission",
                         head_uuid: mainProject.uuid,
-                        tail_uuid: activeUser.user.uuid
+                        tail_uuid: activeUser.user.uuid,
                     });
 
-                    cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+                    cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
 
-                    cy.get('main').contains(projectName).rightclick();
+                    cy.get("main").contains(projectName).rightclick();
 
-                    cy.get('[data-cy=context-menu]').contains('API Details').click();
+                    cy.get("[data-cy=context-menu]").contains("API Details").click();
 
-                    cy.get('[role=tablist]').contains('METADATA').click();
+                    cy.get("[role=tablist]").contains("METADATA").click();
 
-                    cy.get('td').contains(uuid).should('exist');
+                    cy.get("td").contains(uuid).should("exist");
 
-                    cy.get('td').contains(activeUser.user.uuid).should('exist');
+                    cy.get("td").contains(activeUser.user.uuid).should("exist");
                 });
         });
     });
 
-    describe('Frozen projects', () => {
+    describe("Frozen projects", () => {
         beforeEach(() => {
             cy.createGroup(activeUser.token, {
                 name: `Main project ${Math.floor(Math.random() * 999999)}`,
-                group_class: 'project',
-            }).as('mainProject');
+                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');
-            });
+                group_class: "project",
+            })
+                .as("adminProject")
+                .then(mainProject => {
+                    cy.shareWith(adminUser.token, activeUser.user.uuid, mainProject.uuid, "can_write");
+                });
 
-            cy.get('@mainProject').then((mainProject) => {
+            cy.get("@mainProject").then(mainProject => {
                 cy.createGroup(adminUser.token, {
-                    name : `Sub project ${Math.floor(Math.random() * 999999)}`,
-                    group_class: 'project',
+                    name: `Sub project ${Math.floor(Math.random() * 999999)}`,
+                    group_class: "project",
                     owner_uuid: mainProject.uuid,
-                }).as('subProject');
+                }).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');
+                    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]) => {
+        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=project-panel]").contains(mainProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').click();
+                cy.get("[data-cy=context-menu]").contains("Freeze").click();
 
-                cy.get('[data-cy=project-panel]').contains(mainProject.name).rightclick();
+                cy.get("[data-cy=project-panel]").contains(mainProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').should('not.exist');
+                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]) => {
+        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=project-panel]").contains(mainProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').click();
+                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(mainProject.name).click();
 
-                cy.get('[data-cy=project-panel]').contains(mainCollection.name).rightclick();
+                cy.get("[data-cy=project-panel]").contains(mainCollection.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Move to trash').should('not.exist');
+                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]) => {
+        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("[data-cy=side-panel-tree]").contains("Shared with me").click();
 
-                cy.get('main').contains(adminProject.name).rightclick();
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').should('not.exist');
+                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]) => {
+        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("main").contains(adminProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').click();
+                cy.get("[data-cy=context-menu]").contains("Freeze").click();
 
                 cy.wait(1000);
 
-                cy.get('main').contains(adminProject.name).rightclick();
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Unfreeze').click();
+                cy.get("[data-cy=context-menu]").contains("Unfreeze").click();
 
-                cy.get('main').contains(adminProject.name).rightclick();
+                cy.get("main").contains(adminProject.name).rightclick();
 
-                cy.get('[data-cy=context-menu]').contains('Freeze').should('exist');
+                cy.get("[data-cy=context-menu]").contains("Freeze").should("exist");
             });
         });
     });
 
-    it('copies project URL to clipboard', () => {
+    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')
+        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=name-field]").within(() => {
+                    cy.get("input").type(projectName);
                 });
-                cy.get('[data-cy=form-submit-btn]').click();
+                cy.get("[data-cy=form-submit-btn]").click();
             });
-        cy.get('[data-cy=form-dialog]').should("not.exist");
-        cy.get('[data-cy=snackbar]').contains('created');
-        cy.get('[data-cy=snackbar]').should("not.exist");
-        cy.get('[data-cy=side-panel-tree]').contains('Projects').click();
+        cy.get("[data-cy=form-dialog]").should("not.exist");
+        cy.get("[data-cy=snackbar]").contains("created");
+        cy.get("[data-cy=snackbar]").should("not.exist");
+        cy.get("[data-cy=side-panel-tree]").contains("Projects").click();
         cy.waitForDom();
-        cy.get('[data-cy=project-panel]').contains(projectName).should('be.visible').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\:\/\/127\.0\.0\.1\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/,);
+        cy.get("[data-cy=project-panel]").contains(projectName).should("be.visible").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\:\/\/127\.0\.0\.1\:[0-9]+\/projects\/[a-z0-9]{5}-[a-z0-9]{5}-[a-z0-9]{15}/);
             })
-        ));
-
+        );
     });
 
-    it('sorts displayed items correctly', () => {
+    it.only("sorts displayed items correctly", () => {
         cy.loginAs(activeUser);
 
         cy.get('[data-cy=project-panel] button[title="Select columns"]').click();
-        cy.get('div[role=presentation] ul > div[role=button]').contains('Date Created').click();
-        cy.get('div[role=presentation] ul > div[role=button]').contains('Trash at').click();
-        cy.get('div[role=presentation] ul > div[role=button]').contains('Delete at').click();
-        cy.get('div[role=presentation] > div[aria-hidden=true]').click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Date Created").click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Trash at").click();
+        cy.get("div[role=presentation] ul > div[role=button]").contains("Delete at").click();
+        cy.get("div[role=presentation] > div[aria-hidden=true]").click();
 
-        cy.intercept({method: 'GET', url: '**/arvados/v1/groups/*/contents*'}).as('filteredQuery');
+        cy.intercept({ method: "GET", url: "**/arvados/v1/groups/*/contents*" }).as("filteredQuery");
         [
             {
                 name: "Name",
-                asc: "collections.name asc,container_requests.name asc,groups.name asc",
-                desc: "collections.name desc,container_requests.name desc,groups.name desc"
+                asc: "collections.name asc,container_requests.name asc,groups.name asc,container_requests.created_at desc",
+                desc: "collections.name desc,container_requests.name desc,groups.name desc,container_requests.created_at desc",
             },
             {
                 name: "Last Modified",
-                asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc",
-                desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc"
+                asc: "collections.modified_at asc,container_requests.modified_at asc,groups.modified_at asc,container_requests.created_at desc",
+                desc: "collections.modified_at desc,container_requests.modified_at desc,groups.modified_at desc,container_requests.created_at desc",
             },
             {
                 name: "Date Created",
-                asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc",
-                desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc"
-
+                asc: "collections.created_at asc,container_requests.created_at asc,groups.created_at asc,container_requests.created_at desc",
+                desc: "collections.created_at desc,container_requests.created_at desc,groups.created_at desc,container_requests.created_at desc",
             },
             {
                 name: "Trash at",
-                asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc",
-                desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc"
-
+                asc: "collections.trash_at asc,container_requests.trash_at asc,groups.trash_at asc,container_requests.created_at desc",
+                desc: "collections.trash_at desc,container_requests.trash_at desc,groups.trash_at desc,container_requests.created_at desc",
             },
             {
                 name: "Delete at",
-                asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc",
-                desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc"
-
+                asc: "collections.delete_at asc,container_requests.delete_at asc,groups.delete_at asc,container_requests.created_at desc",
+                desc: "collections.delete_at desc,container_requests.delete_at desc,groups.delete_at desc,container_requests.created_at desc",
             },
-        ].forEach((test) => {
-            cy.get('[data-cy=project-panel] table thead th').contains(test.name).click();
-            cy.wait('@filteredQuery').then(interception => {
-                const searchParams = new URLSearchParams((new URL(interception.request.url).search));
-                expect(searchParams.get('order')).to.eq(test.asc);
+        ].forEach(test => {
+            cy.get("[data-cy=project-panel] table thead th").contains(test.name).click();
+            cy.wait("@filteredQuery").then(interception => {
+                const searchParams = new URLSearchParams(new URL(interception.request.url).search);
+                expect(searchParams.get("order")).to.eq(test.asc);
             });
-            cy.get('[data-cy=project-panel] table thead th').contains(test.name).click();
-            cy.wait('@filteredQuery').then(interception => {
-                const searchParams = new URLSearchParams((new URL(interception.request.url).search));
-                expect(searchParams.get('order')).to.eq(test.desc);
+            cy.get("[data-cy=project-panel] table thead th").contains(test.name).click();
+            cy.wait("@filteredQuery").then(interception => {
+                const searchParams = new URLSearchParams(new URL(interception.request.url).search);
+                expect(searchParams.get("order")).to.eq(test.desc);
             });
         });
-
     });
 });
index 085298dcd0e05e1c485167c83f5b13a4291e6c27..d8aa35d3d2d4b6398282350f9d68e88ccb5a2030 100644 (file)
@@ -2,87 +2,91 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Search tests', function() {
+describe("Search tests", function () {
     let activeUser;
     let adminUser;
 
-    before(function() {
+    before(function () {
         // Only set up common users once. These aren't set up as aliases because
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'Admin', 'User', true, true)
-            .as('adminUser').then(function() {
+        cy.getUser("admin", "Admin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-        );
-        cy.getUser('collectionuser1', 'Collection', 'User', false, true)
-            .as('activeUser').then(function() {
+            });
+        cy.getUser("collectionuser1", "Collection", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-        );
-    })
+            });
+    });
 
-    beforeEach(function() {
-        cy.clearCookies()
-        cy.clearLocalStorage()
-    })
+    beforeEach(function () {
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
 
-    it('can search for old collection versions', function() {
+    it("can search for old collection versions", function () {
         const colName = `Versioned Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
-        let colUuid = '';
-        let oldVersionUuid = '';
+        let colUuid = "";
+        let oldVersionUuid = "";
         // Make sure no other collections with this name exist
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${colName}"]]`,
-            include_old_versions: true
+            include_old_versions: true,
         })
-        .its('body.items').as('collections')
-        .then(function() {
-            expect(this.collections).to.be.empty;
-        });
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.be.empty;
+            });
         // 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('originalVersion').then(function() {
-            // Change the file name to create a new version.
-            cy.updateCollection(adminUser.token, this.originalVersion.uuid, {
-                manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n"
-            })
-            colUuid = this.originalVersion.uuid;
-        });
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        })
+            .as("originalVersion")
+            .then(function () {
+                // Change the file name to create a new version.
+                cy.updateCollection(adminUser.token, this.originalVersion.uuid, {
+                    manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo\n",
+                });
+                colUuid = this.originalVersion.uuid;
+            });
         // Confirm that there are 2 versions of the collection
-        cy.doRequest('GET', '/arvados/v1/collections', null, {
+        cy.doRequest("GET", "/arvados/v1/collections", null, {
             filters: `[["name", "=", "${colName}"]]`,
-            include_old_versions: true
+            include_old_versions: true,
         })
-        .its('body.items').as('collections')
-        .then(function() {
-            expect(this.collections).to.have.lengthOf(2);
-            this.collections.map(function(aCollection) {
-                expect(aCollection.current_version_uuid).to.equal(colUuid);
-                if (aCollection.uuid !== aCollection.current_version_uuid) {
-                    oldVersionUuid = aCollection.uuid;
-                }
+            .its("body.items")
+            .as("collections")
+            .then(function () {
+                expect(this.collections).to.have.lengthOf(2);
+                this.collections.map(function (aCollection) {
+                    expect(aCollection.current_version_uuid).to.equal(colUuid);
+                    if (aCollection.uuid !== aCollection.current_version_uuid) {
+                        oldVersionUuid = aCollection.uuid;
+                    }
+                });
+                cy.loginAs(activeUser);
+                const searchQuery = `${colName} type:arvados#collection`;
+                // Search for only collection's current version
+                cy.doSearch(`${searchQuery}`);
+                cy.get("[data-cy=search-results]").should("contain", "head version");
+                cy.get("[data-cy=search-results]").should("not.contain", "version 1");
+                // ...and then, include old versions.
+                cy.doSearch(`${searchQuery} is:pastVersion`);
+                cy.get("[data-cy=search-results]").should("contain", "head version");
+                cy.get("[data-cy=search-results]").should("contain", "version 1");
             });
-            cy.loginAs(activeUser);
-            const searchQuery = `${colName} type:arvados#collection`;
-            // Search for only collection's current version
-            cy.doSearch(`${searchQuery}`);
-            cy.get('[data-cy=search-results]').should('contain', 'head version');
-            cy.get('[data-cy=search-results]').should('not.contain', 'version 1');
-            // ...and then, include old versions.
-            cy.doSearch(`${searchQuery} is:pastVersion`);
-            cy.get('[data-cy=search-results]').should('contain', 'head version');
-            cy.get('[data-cy=search-results]').should('contain', 'version 1');
-        });
     });
 
-    it('can display path of the selected item', function() {
+    it("can display path of the selected item", function () {
         const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
 
         // Creates the collection using the admin token so we can set up
@@ -91,21 +95,21 @@ describe('Search tests', function() {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).then(function() {
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function () {
             cy.loginAs(activeUser);
 
             cy.doSearch(colName);
 
-            cy.get('[data-cy=search-results]').should('contain', colName);
+            cy.get("[data-cy=search-results]").should("contain", colName);
 
-            cy.get('[data-cy=search-results]').contains(colName).closest('tr').click();
+            cy.get("[data-cy=search-results]").contains(colName).closest("tr").click();
 
-            cy.get('[data-cy=element-path]').should('contain', `/ Projects / ${colName}`);
+            cy.get("[data-cy=element-path]").should("contain", `/ Projects / ${colName}`);
         });
     });
 
-    it('can search items using quotes', 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}`;
@@ -116,138 +120,139 @@ describe('Search tests', function() {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).as('collection1');
+            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');
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).as("collection2");
 
-        cy.getAll('@collection1', '@collection2')
-            .then(function() {
-                cy.loginAs(activeUser);
+        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", 2);
 
-                cy.doSearch(`"${colName}"`);
-                cy.get('[data-cy=search-results] table tbody tr').should('have.length', 1);
-            });
+            cy.doSearch(`"${colName}"`);
+            cy.get("[data-cy=search-results] table tbody tr").should("have.length", 1);
+        });
     });
 
-    it('can display owner of the item', function() {
+    it("can display owner of the item", function () {
         const colName = `Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
 
         cy.createCollection(adminUser.token, {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).then(function() {
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function () {
             cy.loginAs(activeUser);
 
             cy.doSearch(colName);
 
-            cy.get('[data-cy=search-results]').should('contain', colName);
+            cy.get("[data-cy=search-results]").should("contain", colName);
 
-            cy.get('[data-cy=search-results]').contains(colName).closest('tr')
+            cy.get("[data-cy=search-results]")
+                .contains(colName)
+                .closest("tr")
                 .within(() => {
-                    cy.get('p').contains(activeUser.user.uuid).should('contain', activeUser.user.full_name);
+                    cy.get("p").contains(activeUser.user.uuid).should("contain", activeUser.user.full_name);
                 });
         });
     });
 
-    it('shows search context menu', function() {
+    it("shows search context menu", function () {
         const colName = `Home Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
         const federatedColName = `Federated Collection ${Math.floor(Math.random() * Math.floor(999999))}`;
         const federatedColUuid = "xxxxx-4zz18-000000000000000";
 
         // Intercept config to insert remote cluster
-        cy.intercept({method: 'GET', hostname: '127.0.0.1', url: '**/arvados/v1/config?nocache=*'}, (req) => {
-            req.reply((res) => {
+        cy.intercept({ method: "GET", hostname: "127.0.0.1", url: "**/arvados/v1/config?nocache=*" }, req => {
+            req.reply(res => {
                 res.body.RemoteClusters = {
                     "*": res.body.RemoteClusters["*"],
-                    "xxxxx": {
-                        "ActivateUsers": true,
-                        "Host": "xxxxx.fakecluster.tld",
-                        "Insecure": false,
-                        "Proxy": true,
-                        "Scheme": ""
-                    }
+                    xxxxx: {
+                        ActivateUsers: true,
+                        Host: "xxxxx.fakecluster.tld",
+                        Insecure: false,
+                        Proxy: true,
+                        Scheme: "",
+                    },
                 };
             });
         });
 
         // Fake remote cluster config
         cy.intercept(
-          {
-            method: "GET",
-            hostname: "xxxxx.fakecluster.tld",
-            url: "**/arvados/v1/config",
-          },
-          {
-            statusCode: 200,
-            body: {
-              API: {},
-              ClusterID: "xxxxx",
-              Collections: {},
-              Containers: {},
-              InstanceTypes: {},
-              Login: {},
-              Mail: { SupportEmailAddress: "arvados@example.com" },
-              RemoteClusters: {
-                "*": {
-                  ActivateUsers: false,
-                  Host: "",
-                  Insecure: false,
-                  Proxy: false,
-                  Scheme: "https",
-                },
-              },
-              Services: {
-                Composer: { ExternalURL: "" },
-                Controller: { ExternalURL: "https://xxxxx.fakecluster.tld:34763/" },
-                DispatchCloud: { ExternalURL: "" },
-                DispatchLSF: { ExternalURL: "" },
-                DispatchSLURM: { ExternalURL: "" },
-                GitHTTP: { ExternalURL: "https://xxxxx.fakecluster.tld:39105/" },
-                GitSSH: { ExternalURL: "" },
-                Health: { ExternalURL: "https://xxxxx.fakecluster.tld:42915/" },
-                Keepbalance: { ExternalURL: "" },
-                Keepproxy: { ExternalURL: "https://xxxxx.fakecluster.tld:46773/" },
-                Keepstore: { ExternalURL: "" },
-                RailsAPI: { ExternalURL: "" },
-                WebDAV: { ExternalURL: "https://xxxxx.fakecluster.tld:36041/" },
-                WebDAVDownload: { ExternalURL: "https://xxxxx.fakecluster.tld:42957/" },
-                WebShell: { ExternalURL: "" },
-                Websocket: { ExternalURL: "wss://xxxxx.fakecluster.tld:37121/websocket" },
-                Workbench1: { ExternalURL: "https://wb1.xxxxx.fakecluster.tld/" },
-                Workbench2: { ExternalURL: "https://wb2.xxxxx.fakecluster.tld/" },
-              },
-              StorageClasses: {
-                default: { Default: true, Priority: 0 },
-              },
-              Users: {},
-              Volumes: {},
-              Workbench: {},
+            {
+                method: "GET",
+                hostname: "xxxxx.fakecluster.tld",
+                url: "**/arvados/v1/config",
             },
-          }
+            {
+                statusCode: 200,
+                body: {
+                    API: {},
+                    ClusterID: "xxxxx",
+                    Collections: {},
+                    Containers: {},
+                    InstanceTypes: {},
+                    Login: {},
+                    Mail: { SupportEmailAddress: "arvados@example.com" },
+                    RemoteClusters: {
+                        "*": {
+                            ActivateUsers: false,
+                            Host: "",
+                            Insecure: false,
+                            Proxy: false,
+                            Scheme: "https",
+                        },
+                    },
+                    Services: {
+                        Composer: { ExternalURL: "" },
+                        Controller: { ExternalURL: "https://xxxxx.fakecluster.tld:34763/" },
+                        DispatchCloud: { ExternalURL: "" },
+                        DispatchLSF: { ExternalURL: "" },
+                        DispatchSLURM: { ExternalURL: "" },
+                        GitHTTP: { ExternalURL: "https://xxxxx.fakecluster.tld:39105/" },
+                        GitSSH: { ExternalURL: "" },
+                        Health: { ExternalURL: "https://xxxxx.fakecluster.tld:42915/" },
+                        Keepbalance: { ExternalURL: "" },
+                        Keepproxy: { ExternalURL: "https://xxxxx.fakecluster.tld:46773/" },
+                        Keepstore: { ExternalURL: "" },
+                        RailsAPI: { ExternalURL: "" },
+                        WebDAV: { ExternalURL: "https://xxxxx.fakecluster.tld:36041/" },
+                        WebDAVDownload: { ExternalURL: "https://xxxxx.fakecluster.tld:42957/" },
+                        WebShell: { ExternalURL: "" },
+                        Websocket: { ExternalURL: "wss://xxxxx.fakecluster.tld:37121/websocket" },
+                        Workbench1: { ExternalURL: "https://wb1.xxxxx.fakecluster.tld/" },
+                        Workbench2: { ExternalURL: "https://wb2.xxxxx.fakecluster.tld/" },
+                    },
+                    StorageClasses: {
+                        default: { Default: true, Priority: 0 },
+                    },
+                    Users: {},
+                    Volumes: {},
+                    Workbench: {},
+                },
+            }
         );
 
         cy.createCollection(adminUser.token, {
             name: colName,
             owner_uuid: activeUser.user.uuid,
             preserve_version: true,
-            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
-        }).then(function(testCollection) {
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n",
+        }).then(function (testCollection) {
             cy.loginAs(activeUser);
 
             // Intercept search results to add federated result
-            cy.intercept({method: 'GET', url: '**/arvados/v1/groups/contents?*'}, (req) => {
-                req.reply((res) => {
+            cy.intercept({ method: "GET", url: "**/arvados/v1/groups/contents?*" }, req => {
+                req.reply(res => {
                     res.body.items = [
                         res.body.items[0],
                         {
@@ -256,7 +261,7 @@ describe('Search tests', function() {
                             portable_data_hash: "00000000000000000000000000000000+0",
                             name: federatedColName,
                             href: res.body.items[0].href.replace(testCollection.uuid, federatedColUuid),
-                        }
+                        },
                     ];
                     res.body.items_available += 1;
                 });
@@ -266,55 +271,54 @@ describe('Search tests', function() {
 
             // Stub new window
             cy.window().then(win => {
-                cy.stub(win, 'open').as('Open')
+                cy.stub(win, "open").as("Open");
             });
 
             // Check copy to clipboard
-            cy.get('[data-cy=search-results]').contains(colName).rightclick();
-            cy.get('[data-cy=context-menu]').within((ctx) => {
+            cy.get("[data-cy=search-results]").contains(colName).rightclick();
+            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('API Details');
-                cy.contains('Copy to clipboard');
-                cy.contains('Open in new tab');
-                cy.contains('View details');
+                cy.get(ctx).children().should("have.length", 4);
+                cy.contains("API Details");
+                cy.contains("Copy to clipboard");
+                cy.contains("Open in new tab");
+                cy.contains("View details");
 
-                cy.contains('Copy to clipboard').click();
+                cy.contains("Copy to clipboard").click();
                 cy.waitForDom();
-                cy.window().then((win) => (
-                    win.navigator.clipboard.readText().then((text) => {
+                cy.window().then(win =>
+                    win.navigator.clipboard.readText().then(text => {
                         expect(text).to.match(new RegExp(`/collections/${testCollection.uuid}$`));
                     })
-                ));
+                );
             });
 
             // Check open in new tab
-            cy.get('[data-cy=search-results]').contains(colName).rightclick();
-            cy.get('[data-cy=context-menu]').within(() => {
-                cy.contains('Open in new tab').click();
+            cy.get("[data-cy=search-results]").contains(colName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Open in new tab").click();
                 cy.waitForDom();
-                cy.get('@Open').should('have.been.calledOnceWith', `${window.location.origin}/collections/${testCollection.uuid}`)
+                cy.get("@Open").should("have.been.calledOnceWith", `${window.location.origin}/collections/${testCollection.uuid}`);
             });
 
             // Check federated result copy to clipboard
-            cy.get('[data-cy=search-results]').contains(federatedColName).rightclick();
-            cy.get('[data-cy=context-menu]').within(() => {
-                cy.contains('Copy to clipboard').click();
+            cy.get("[data-cy=search-results]").contains(federatedColName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Copy to clipboard").click();
                 cy.waitForDom();
-                cy.window().then((win) => (
-                    win.navigator.clipboard.readText().then((text) => {
+                cy.window().then(win =>
+                    win.navigator.clipboard.readText().then(text => {
                         expect(text).to.equal(`https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`);
                     })
-                ));
+                );
             });
             // Check open in new tab
-            cy.get('[data-cy=search-results]').contains(federatedColName).rightclick();
-            cy.get('[data-cy=context-menu]').within(() => {
-                cy.contains('Open in new tab').click();
+            cy.get("[data-cy=search-results]").contains(federatedColName).rightclick();
+            cy.get("[data-cy=context-menu]").within(() => {
+                cy.contains("Open in new tab").click();
                 cy.waitForDom();
-                cy.get('@Open').should('have.been.calledWith', `https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`)
+                cy.get("@Open").should("have.been.calledWith", `https://wb2.xxxxx.fakecluster.tld/collections/${federatedColUuid}`);
             });
-
         });
     });
 });
index 80d649777bfb436d6f9052f17dd74ccbfd0d63ae..92011b208ee51a7887996ca4088c60b39838aad0 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Virtual machine login manage tests', function() {
+describe("Virtual machine login manage tests", function () {
     let activeUser;
     let adminUser;
 
     const vmHost = `vm-${Math.floor(999999 * Math.random())}.host`;
 
-    before(function() {
+    before(function () {
         // Only set up common users once. These aren't set up as aliases because
         // aliases are cleaned up after every test. Also it doesn't make sense
         // to set the same users on beforeEach() over and over again, so we
         // separate a little from Cypress' 'Best Practices' here.
-        cy.getUser('admin', 'VMAdmin', 'User', true, true)
-            .as('adminUser').then(function() {
+        cy.getUser("admin", "VMAdmin", "User", true, true)
+            .as("adminUser")
+            .then(function () {
                 adminUser = this.adminUser;
-            }
-        );
-        cy.getUser('user', 'VMActive', 'User', false, true)
-            .as('activeUser').then(function() {
+            });
+        cy.getUser("user", "VMActive", "User", false, true)
+            .as("activeUser")
+            .then(function () {
                 activeUser = this.activeUser;
-            }
-        );
+            });
     });
 
-    it('adds and removes vm logins', function() {
+    it("adds and removes vm logins", function () {
         cy.loginAs(adminUser);
-        cy.createVirtualMachine(adminUser.token, {hostname: vmHost});
+        cy.createVirtualMachine(adminUser.token, { hostname: vmHost });
 
         // Navigate to VM admin
         cy.get('header button[title="Admin Panel"]').click();
-        cy.get('#admin-menu').contains('Virtual Machines').click();
+        cy.get("#admin-menu").contains("Virtual Machines").click();
 
         // Add login permission to admin
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
                 cy.get('button[title="Add Login Permission"]').click();
             });
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Add login permission')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Add login permission")
             .within(() => {
-                cy.get('label')
-                  .contains('Search for user')
-                  .parent()
-                  .within(() => {
-                    cy.get('input').type('VMAdmin');
-                  })
+                cy.get("label")
+                    .contains("Search for user")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("VMAdmin");
+                    });
             });
-        cy.waitForDom().get('[role=tooltip]').click();
-        cy.get('[data-cy=form-dialog]').as('add-login-dialog')
-            .should('contain', 'Add login permission')
+        cy.waitForDom().get("[role=tooltip]").click();
+        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 ');
-                    // 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("label")
+                    .contains("Add groups")
+                    .parent()
+                    .within(() => {
+                        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('input').type('{enter}');
-                  })
             });
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
         });
 
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('admin');
-        });
+                cy.get("td").contains("admin");
+            });
 
         // Add login permission to activeUser
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
                 cy.get('button[title="Add Login Permission"]').click();
             });
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Add login permission')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Add login permission")
             .within(() => {
-                cy.get('label')
-                  .contains('Search for user')
-                  .parent()
-                  .within(() => {
-                    cy.get('input').type('VMActive user');
-                  })
+                cy.get("label")
+                    .contains("Search for user")
+                    .parent()
+                    .within(() => {
+                        cy.get("input").type("VMActive user");
+                    });
             });
-        cy.get('[role=tooltip]').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[role=tooltip]").click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
         });
 
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('user');
-        });
+                cy.get("td").contains("user");
+            });
 
         // Check admin's vm page for login
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-table]')
+        cy.get("[data-cy=vm-user-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('admin');
-                cy.get('td').contains('docker');
-                cy.get('td').contains('sudo');
-                cy.get('td').contains('ssh admin@' + vmHost);
-        });
+                cy.get("td").contains("admin");
+                cy.get("td").contains("docker");
+                cy.get("td").contains("sudo");
+                cy.get("td").contains("ssh admin@" + vmHost);
+            });
 
         // Check activeUser's vm page for login
         cy.loginAs(activeUser);
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-table]')
+        cy.get("[data-cy=vm-user-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('user');
-                cy.get('td').should('not.contain', 'docker');
-                cy.get('td').should('not.contain', 'sudo');
-                cy.get('td').contains('ssh user@' + vmHost);
-        });
+                cy.get("td").contains("user");
+                cy.get("td").should("not.contain", "docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh user@" + vmHost);
+            });
 
         // Edit login permissions
         cy.loginAs(adminUser);
         cy.get('header button[title="Admin Panel"]').click();
-        cy.get('#admin-menu').contains('Virtual Machines').click();
+        cy.get("#admin-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-admin-table]')
-            .contains('admin'); // Wait for page to finish
+        cy.get("[data-cy=vm-admin-table]").contains("admin"); // Wait for page to finish
 
-        cy.get('[data-cy=vm-admin-table]')
-            .contains(vmHost)
-            .parents('tr')
-            .contains('admin')
-            .click();
+        cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("admin").click();
 
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Update login permission')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Update login permission")
             .within(() => {
-                cy.get('label')
-                    .contains('Add groups')
-                    .parent()
-                    .as('groupInput');
+                cy.get("label").contains("Add groups").parent().as("groupInput");
             });
 
-        cy.get('@groupInput').within(() => {
-            cy.get('div[role=button]').contains('sudo').parent().find('svg').click();
-            cy.get('div[role=button]').contains('docker').parent().find('svg').click();
+        cy.get("@groupInput").within(() => {
+            cy.get("div[role=button]").contains("sudo").parent().find("svg").click();
+            cy.get("div[role=button]").contains("docker").parent().find("svg").click();
         });
 
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
         });
 
         // Wait for page to finish loading
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('div[role=button]')
-                    .parent()
-                    .first()
-                    .contains('admin')
+                cy.get("div[role=button]").parent().first().contains("admin");
             });
 
-        cy.get('[data-cy=vm-admin-table]')
-            .contains(vmHost)
-            .parents('tr')
-            .contains('user')
-            .click();
+        cy.get("[data-cy=vm-admin-table]").contains(vmHost).parents("tr").contains("user").click();
 
-        cy.get('[data-cy=form-dialog]')
-            .should('contain', 'Update login permission')
+        cy.get("[data-cy=form-dialog]")
+            .should("contain", "Update login permission")
             .within(() => {
-                cy.get('label')
-                    .contains('Add groups')
+                cy.get("label")
+                    .contains("Add groups")
                     .parent()
                     .within(() => {
-                        cy.get('input').type('docker{enter}');
-                    })
+                        cy.get("input").type("docker{enter}");
+                    });
             });
 
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('[data-cy=form-submit-btn]').click();
+        cy.get("[data-cy=form-dialog]").within(() => {
+            cy.get("[data-cy=form-submit-btn]").click();
         });
 
         // Verify new login permissions
         // Check admin's vm page for login
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-table]')
+        cy.get("[data-cy=vm-user-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('admin');
-                cy.get('td').should('not.contain', 'docker');
-                cy.get('td').should('not.contain', 'sudo');
-                cy.get('td').contains('ssh admin@' + vmHost);
-        });
+                cy.get("td").contains("admin");
+                cy.get("td").should("not.contain", "docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh admin@" + vmHost);
+            });
 
         // Verify new login permissions
         // Check activeUser's vm page for login
         cy.loginAs(activeUser);
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-table]')
+        cy.get("[data-cy=vm-user-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('td').contains('user');
-                cy.get('td').contains('docker');
-                cy.get('td').should('not.contain', 'sudo');
-                cy.get('td').contains('ssh user@' + vmHost);
-        });
+                cy.get("td").contains("user");
+                cy.get("td").contains("docker");
+                cy.get("td").should("not.contain", "sudo");
+                cy.get("td").contains("ssh user@" + vmHost);
+            });
 
         // Remove login permissions
         cy.loginAs(adminUser);
         cy.get('header button[title="Admin Panel"]').click();
-        cy.get('#admin-menu').contains('Virtual Machines').click();
+        cy.get("#admin-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-admin-table]')
-            .contains('user'); // Wait for page to finish
+        cy.get("[data-cy=vm-admin-table]").contains("user"); // Wait for page to finish
 
-        cy.get('[data-cy=vm-admin-table]')
+        cy.get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
-            .as('vmRow')
-            .contains('user')
-            .parents('[role=button]')
-            .find('svg')
-            .as('removeButton');
-        cy.get('@removeButton').click();
-        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
-
-        cy.get('@vmRow')
-            .within(() => {
-                cy.get('div[role=button]').should('not.contain', 'user');
-                cy.get('div[role=button]').should('have.length', 1)
-            });
+            .parents("tr")
+            .as("vmRow")
+            .contains("user")
+            .parents("[role=button]")
+            .find("svg")
+            .as("removeButton");
+        cy.get("@removeButton").click();
+        cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
+
+        cy.get("@vmRow").within(() => {
+            cy.get("div[role=button]").should("not.contain", "user");
+            cy.get("div[role=button]").should("have.length", 1);
+        });
 
-        cy.get('@vmRow')
-            .find('div[role=button]')
-            .contains('admin')
-            .parents('[role=button]')
-            .find('svg')
-            .as('removeButton');
-        cy.get('@removeButton').click();
-        cy.get('[data-cy=confirmation-dialog-ok-btn]').click();
+        cy.get("@vmRow").find("div[role=button]").contains("admin").parents("[role=button]").find("svg").as("removeButton");
+        cy.get("@removeButton").click();
+        cy.get("[data-cy=confirmation-dialog-ok-btn]").click();
 
-        cy.waitForDom().get('[data-cy=vm-admin-table]')
+        cy.waitForDom()
+            .get("[data-cy=vm-admin-table]")
             .contains(vmHost)
-            .parents('tr')
+            .parents("tr")
             .within(() => {
-                cy.get('div[role=button]').should('not.exist');
+                cy.get("div[role=button]").should("not.exist");
             });
 
         // Check admin's vm page for login
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-panel]')
-            .should('not.contain', vmHost);
+        cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost);
 
         // Check activeUser's vm page for login
         cy.loginAs(activeUser);
         cy.get('header button[title="Account Management"]').click();
-        cy.get('#account-menu').contains('Virtual Machines').click();
+        cy.get("#account-menu").contains("Virtual Machines").click();
 
-        cy.get('[data-cy=vm-user-panel]')
-            .should('not.contain', vmHost);
+        cy.get("[data-cy=vm-user-panel]").should("not.contain", vmHost);
     });
 });
index 67ddf45d8365bc77b769d4b7a857666d23af00ab..c0291e261328fc7b835d7dfca27e696090fe6de5 100644 (file)
 
 import { extractFilesData } from "services/collection-service/collection-service-files-response";
 
-const controllerURL = Cypress.env('controller_url');
-const systemToken = Cypress.env('system_token');
+const controllerURL = Cypress.env("controller_url");
+const systemToken = Cypress.env("system_token");
 let createdResources = [];
 
-const containerLogFolderPrefix = 'log for container ';
+const containerLogFolderPrefix = "log for container ";
 
 // Clean up anything that was created.  You can temporarily add
 // 'return' to the top if you need the resources to hang around to
@@ -44,7 +44,7 @@ afterEach(function () {
         return;
     }
     cy.log(`Cleaning ${createdResources.length} previously created resource(s).`);
-    createdResources.forEach(function({suffix, uuid}) {
+    createdResources.forEach(function ({ suffix, uuid }) {
         // Don't fail when a resource isn't already there, some objects may have
         // been removed, directly or indirectly, from the test that created them.
         cy.deleteResource(systemToken, suffix, uuid, false);
@@ -53,448 +53,416 @@ afterEach(function () {
 });
 
 Cypress.Commands.add(
-    "doRequest", (method = 'GET', path = '', data = null, qs = null,
-        token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
-    return cy.request({
-        method: method,
-        url: `${controllerURL.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`,
-        body: data,
-        qs: auth ? qs : Object.assign({ api_token: token }, qs),
-        auth: auth ? { bearer: `${token}` } : undefined,
-        followRedirect: followRedirect,
-        failOnStatusCode: failOnStatusCode
-    });
-});
-
-Cypress.Commands.add(
-    "doWebDAVRequest", (method = 'GET', path = '', data = null, qs = null,
-        token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
-    return cy.doRequest('GET', '/arvados/v1/config', null, null).then(({body: config}) => {
+    "doRequest",
+    (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
         return cy.request({
             method: method,
-            url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`,
+            url: `${controllerURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
             body: data,
             qs: auth ? qs : Object.assign({ api_token: token }, qs),
             auth: auth ? { bearer: `${token}` } : undefined,
             followRedirect: followRedirect,
-            failOnStatusCode: failOnStatusCode
+            failOnStatusCode: failOnStatusCode,
         });
-    });
-});
+    }
+);
 
 Cypress.Commands.add(
-    "getUser", (username, first_name = '', last_name = '', is_admin = false, is_active = true) => {
-        // Create user if not already created
-        return cy.doRequest('POST', '/auth/controller/callback', {
-            auth_info: JSON.stringify({
-                email: `${username}@example.local`,
-                username: username,
-                first_name: first_name,
-                last_name: last_name,
-                alternate_emails: []
-            }),
-            return_to: ',https://example.local'
-        }, null, systemToken, true, false) // Don't follow redirects so we can catch the token
-        .its('headers.location').as('location')
-        // Get its token and set the account up as admin and/or active
-        .then(function () {
-            this.userToken = this.location.split("=")[1]
-            assert.isString(this.userToken)
-            return cy.doRequest('GET', '/arvados/v1/users', null, {
-                filters: `[["username", "=", "${username}"]]`
-            })
-            .its('body.items.0').as('aUser')
+    "doWebDAVRequest",
+    (method = "GET", path = "", data = null, qs = null, token = systemToken, auth = false, followRedirect = true, failOnStatusCode = true) => {
+        return cy.doRequest("GET", "/arvados/v1/config", null, null).then(({ body: config }) => {
+            return cy.request({
+                method: method,
+                url: `${config.Services.WebDAVDownload.ExternalURL.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`,
+                body: data,
+                qs: auth ? qs : Object.assign({ api_token: token }, qs),
+                auth: auth ? { bearer: `${token}` } : undefined,
+                followRedirect: followRedirect,
+                failOnStatusCode: failOnStatusCode,
+            });
+        });
+    }
+);
+
+Cypress.Commands.add("getUser", (username, first_name = "", last_name = "", is_admin = false, is_active = true) => {
+    // Create user if not already created
+    return (
+        cy
+            .doRequest(
+                "POST",
+                "/auth/controller/callback",
+                {
+                    auth_info: JSON.stringify({
+                        email: `${username}@example.local`,
+                        username: username,
+                        first_name: first_name,
+                        last_name: last_name,
+                        alternate_emails: [],
+                    }),
+                    return_to: ",https://example.local",
+                },
+                null,
+                systemToken,
+                true,
+                false
+            ) // Don't follow redirects so we can catch the token
+            .its("headers.location")
+            .as("location")
+            // Get its token and set the account up as admin and/or active
             .then(function () {
-                cy.doRequest('PUT', `/arvados/v1/users/${this.aUser.uuid}`, {
-                    user: {
-                        is_admin: is_admin,
-                        is_active: is_active
-                    }
-                })
-                .its('body').as('theUser')
-                .then(function () {
-                    cy.doRequest('GET', '/arvados/v1/api_clients', null, {
-                        filters: `[["is_trusted", "=", false]]`,
-                        order: `["created_at desc"]`
+                this.userToken = this.location.split("=")[1];
+                assert.isString(this.userToken);
+                return cy
+                    .doRequest("GET", "/arvados/v1/users", null, {
+                        filters: `[["username", "=", "${username}"]]`,
                     })
-                    .its('body.items').as('apiClients')
+                    .its("body.items.0")
+                    .as("aUser")
                     .then(function () {
-                        if (this.apiClients.length > 0) {
-                            cy.doRequest('PUT', `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, {
-                                api_client: {
-                                    is_trusted: true
-                                }
-                            })
-                            .its('body').as('updatedApiClient')
-                            .then(function() {
-                                assert(this.updatedApiClient.is_trusted);
-                            })
-                        }
-                    })
-                    .then(function () {
-                        return { user: this.theUser, token: this.userToken };
-                    })
-                })
+                        cy.doRequest("PUT", `/arvados/v1/users/${this.aUser.uuid}`, {
+                            user: {
+                                is_admin: is_admin,
+                                is_active: is_active,
+                            },
+                        })
+                            .its("body")
+                            .as("theUser")
+                            .then(function () {
+                                cy.doRequest("GET", "/arvados/v1/api_clients", null, {
+                                    filters: `[["is_trusted", "=", false]]`,
+                                    order: `["created_at desc"]`,
+                                })
+                                    .its("body.items")
+                                    .as("apiClients")
+                                    .then(function () {
+                                        if (this.apiClients.length > 0) {
+                                            cy.doRequest("PUT", `/arvados/v1/api_clients/${this.apiClients[0].uuid}`, {
+                                                api_client: {
+                                                    is_trusted: true,
+                                                },
+                                            })
+                                                .its("body")
+                                                .as("updatedApiClient")
+                                                .then(function () {
+                                                    assert(this.updatedApiClient.is_trusted);
+                                                });
+                                        }
+                                    })
+                                    .then(function () {
+                                        return { user: this.theUser, token: this.userToken };
+                                    });
+                            });
+                    });
             })
-        })
-    }
-)
-
-Cypress.Commands.add(
-    "createLink", (token, data) => {
-        return cy.createResource(token, 'links', {
-            link: JSON.stringify(data)
-        })
-    }
-)
+    );
+});
 
-Cypress.Commands.add(
-    "createGroup", (token, data) => {
-        return cy.createResource(token, 'groups', {
-            group: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
+Cypress.Commands.add("createLink", (token, data) => {
+    return cy.createResource(token, "links", {
+        link: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    "trashGroup", (token, uuid) => {
-        return cy.deleteResource(token, 'groups', uuid);
-    }
-)
+Cypress.Commands.add("createGroup", (token, data) => {
+    return cy.createResource(token, "groups", {
+        group: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
+Cypress.Commands.add("trashGroup", (token, uuid) => {
+    return cy.deleteResource(token, "groups", uuid);
+});
 
-Cypress.Commands.add(
-    "createWorkflow", (token, data) => {
-        return cy.createResource(token, 'workflows', {
-            workflow: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
+Cypress.Commands.add("createWorkflow", (token, data) => {
+    return cy.createResource(token, "workflows", {
+        workflow: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "createCollection", (token, data) => {
-        return cy.createResource(token, 'collections', {
-            collection: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
+Cypress.Commands.add("createCollection", (token, data) => {
+    return cy.createResource(token, "collections", {
+        collection: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "getCollection", (token, uuid) => {
-        return cy.getResource(token, 'collections', uuid)
-    }
-)
+Cypress.Commands.add("getCollection", (token, uuid) => {
+    return cy.getResource(token, "collections", uuid);
+});
 
-Cypress.Commands.add(
-    "updateCollection", (token, uuid, data) => {
-        return cy.updateResource(token, 'collections', uuid, {
-            collection: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("updateCollection", (token, uuid, data) => {
+    return cy.updateResource(token, "collections", uuid, {
+        collection: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    "collectionReplaceFiles", (token, uuid, data) => {
-        return cy.updateResource(token, 'collections', uuid, {
-            collection: {
-                preserve_version: true,
-            },
-            replace_files: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("collectionReplaceFiles", (token, uuid, data) => {
+    return cy.updateResource(token, "collections", uuid, {
+        collection: {
+            preserve_version: true,
+        },
+        replace_files: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    "getContainer", (token, uuid) => {
-        return cy.getResource(token, 'containers', uuid)
-    }
-)
+Cypress.Commands.add("getContainer", (token, uuid) => {
+    return cy.getResource(token, "containers", uuid);
+});
 
-Cypress.Commands.add(
-    "updateContainer", (token, uuid, data) => {
-        return cy.updateResource(token, 'containers', uuid, {
-            container: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("updateContainer", (token, uuid, data) => {
+    return cy.updateResource(token, "containers", uuid, {
+        container: JSON.stringify(data),
+    });
+});
 
-Cypress.Commands.add(
-    "getContainerRequest", (token, uuid) => {
-        return cy.getResource(token, 'container_requests', uuid)
-    }
-)
+Cypress.Commands.add("getContainerRequest", (token, uuid) => {
+    return cy.getResource(token, "container_requests", uuid);
+});
 
-Cypress.Commands.add(
-    'createContainerRequest', (token, data) => {
-        return cy.createResource(token, 'container_requests', {
-            container_request: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
+Cypress.Commands.add("createContainerRequest", (token, data) => {
+    return cy.createResource(token, "container_requests", {
+        container_request: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "updateContainerRequest", (token, uuid, data) => {
-        return cy.updateResource(token, 'container_requests', uuid, {
-            container_request: JSON.stringify(data)
-        })
-    }
-)
+Cypress.Commands.add("updateContainerRequest", (token, uuid, data) => {
+    return cy.updateResource(token, "container_requests", uuid, {
+        container_request: JSON.stringify(data),
+    });
+});
 
 /**
  * Requires an admin token for log_uuid modification to succeed
  */
-Cypress.Commands.add(
-    "appendLog", (token, crUuid, fileName, lines = []) => (
-        cy.getContainerRequest(token, crUuid).then((containerRequest) => {
-            if (containerRequest.log_uuid) {
-                cy.listContainerRequestLogs(token, crUuid).then((logFiles) => {
-                    const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`;
-                    if (logFiles.find((file) => (file.name === fileName))) {
-                        // File exists, fetch and append
-                        return cy.doWebDAVRequest(
-                                "GET",
-                                `c=${filePath}`,
-                                null,
-                                null,
-                                token
-                            )
-                            .then(({ body: contents }) => cy.doWebDAVRequest(
-                                "PUT",
-                                `c=${filePath}`,
-                                contents.split("\n").concat(lines).join("\n"),
-                                null,
-                                token
-                            ));
-                    } else {
-                        // File not exists, put new file
-                        cy.doWebDAVRequest(
-                            "PUT",
-                            `c=${filePath}`,
-                            lines.join("\n"),
-                            null,
-                            token
-                        )
-                    }
-                });
-            } else {
-                // Create log collection
-                return cy.createCollection(token, {
+Cypress.Commands.add("appendLog", (token, crUuid, fileName, lines = []) =>
+    cy.getContainerRequest(token, crUuid).then(containerRequest => {
+        if (containerRequest.log_uuid) {
+            cy.listContainerRequestLogs(token, crUuid).then(logFiles => {
+                const filePath = `${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`;
+                if (logFiles.find(file => file.name === fileName)) {
+                    // File exists, fetch and append
+                    return cy
+                        .doWebDAVRequest("GET", `c=${filePath}`, null, null, token)
+                        .then(({ body: contents }) =>
+                            cy.doWebDAVRequest("PUT", `c=${filePath}`, contents.split("\n").concat(lines).join("\n"), null, token)
+                        );
+                } else {
+                    // File not exists, put new file
+                    cy.doWebDAVRequest("PUT", `c=${filePath}`, lines.join("\n"), null, token);
+                }
+            });
+        } else {
+            // Create log collection
+            return cy
+                .createCollection(token, {
                     name: `Test log collection ${Math.floor(Math.random() * 999999)}`,
                     owner_uuid: containerRequest.owner_uuid,
-                    manifest_text: ""
-                }).then((collection) => {
+                    manifest_text: "",
+                })
+                .then(collection => {
                     // Update CR log_uuid to fake log collection
                     cy.updateContainerRequest(token, containerRequest.uuid, {
                         log_uuid: collection.uuid,
-                    }).then(() => (
+                    }).then(() =>
                         // Create empty directory for container uuid
-                        cy.collectionReplaceFiles(token, collection.uuid, {
-                            [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0"
-                        }).then(() => (
-                            // Put new log file with contents into fake log collection
-                            cy.doWebDAVRequest(
-                                'PUT',
-                                `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`,
-                                lines.join('\n'),
-                                null,
-                                token
+                        cy
+                            .collectionReplaceFiles(token, collection.uuid, {
+                                [`/${containerLogFolderPrefix}${containerRequest.container_uuid}`]: "d41d8cd98f00b204e9800998ecf8427e+0",
+                            })
+                            .then(() =>
+                                // Put new log file with contents into fake log collection
+                                cy.doWebDAVRequest(
+                                    "PUT",
+                                    `c=${collection.uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}/${fileName}`,
+                                    lines.join("\n"),
+                                    null,
+                                    token
+                                )
                             )
-                        ))
-                    ));
+                    );
                 });
-            }
-        })
-    )
-)
-
-Cypress.Commands.add(
-    "listContainerRequestLogs", (token, crUuid) => (
-        cy.getContainerRequest(token, crUuid).then((containerRequest) => (
-            cy.doWebDAVRequest('PROPFIND', `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`, null, null, token)
-                .then(({body: data}) => {
-                    return extractFilesData(new DOMParser().parseFromString(data, "text/xml"));
-                })
-        ))
-    )
+        }
+    })
 );
 
-Cypress.Commands.add(
-    "createVirtualMachine", (token, data) => {
-        return cy.createResource(token, 'virtual_machines', {
-            virtual_machine: JSON.stringify(data),
-            ensure_unique_name: true
-        })
-    }
-)
-
-Cypress.Commands.add(
-    "getResource", (token, suffix, uuid) => {
-        return cy.doRequest('GET', `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
-            .its('body')
-            .then(function (resource) {
-                return resource;
+Cypress.Commands.add("listContainerRequestLogs", (token, crUuid) =>
+    cy.getContainerRequest(token, crUuid).then(containerRequest =>
+        cy
+            .doWebDAVRequest(
+                "PROPFIND",
+                `c=${containerRequest.log_uuid}/${containerLogFolderPrefix}${containerRequest.container_uuid}`,
+                null,
+                null,
+                token
+            )
+            .then(({ body: data }) => {
+                return extractFilesData(new DOMParser().parseFromString(data, "text/xml"));
             })
-    }
-)
+    )
+);
 
-Cypress.Commands.add(
-    "createResource", (token, suffix, data) => {
-        return cy.doRequest('POST', '/arvados/v1/' + suffix, data, null, token, true)
-            .its('body')
-            .then(function (resource) {
-                createdResources.push({suffix, uuid: resource.uuid});
-                return resource;
-            })
-    }
-)
+Cypress.Commands.add("createVirtualMachine", (token, data) => {
+    return cy.createResource(token, "virtual_machines", {
+        virtual_machine: JSON.stringify(data),
+        ensure_unique_name: true,
+    });
+});
 
-Cypress.Commands.add(
-    "deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
-        return cy.doRequest('DELETE', '/arvados/v1/' + suffix + '/' + uuid, null, null, token, false, true, failOnStatusCode)
-            .its('body')
-            .then(function (resource) {
-                return resource;
-            })
-    }
-)
+Cypress.Commands.add("getResource", (token, suffix, uuid) => {
+    return cy
+        .doRequest("GET", `/arvados/v1/${suffix}/${uuid}`, null, {}, token)
+        .its("body")
+        .then(function (resource) {
+            return resource;
+        });
+});
 
-Cypress.Commands.add(
-    "updateResource", (token, suffix, uuid, data) => {
-        return cy.doRequest('PATCH', '/arvados/v1/' + suffix + '/' + uuid, data, null, token, true)
-            .its('body')
-            .then(function (resource) {
-                return resource;
-            })
-    }
-)
+Cypress.Commands.add("createResource", (token, suffix, data) => {
+    return cy
+        .doRequest("POST", "/arvados/v1/" + suffix, data, null, token, true)
+        .its("body")
+        .then(function (resource) {
+            createdResources.push({ suffix, uuid: resource.uuid });
+            return resource;
+        });
+});
 
-Cypress.Commands.add(
-    "loginAs", (user) => {
-        cy.clearCookies()
-        cy.clearLocalStorage()
-        cy.visit(`/token/?api_token=${user.token}`);
-        cy.url({timeout: 10000}).should('contain', '/projects/');
-        cy.get('div#root').should('contain', 'Arvados Workbench (zzzzz)');
-        cy.get('div#root').should('not.contain', 'Your account is inactive');
-    }
-)
+Cypress.Commands.add("deleteResource", (token, suffix, uuid, failOnStatusCode = true) => {
+    return cy
+        .doRequest("DELETE", "/arvados/v1/" + suffix + "/" + uuid, null, null, token, false, true, failOnStatusCode)
+        .its("body")
+        .then(function (resource) {
+            return resource;
+        });
+});
 
-Cypress.Commands.add(
-    "testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => {
-        cy.get(container).contains(oldName).rightclick();
-        cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('input[name=name]').clear().type(newName);
-            cy.get(isProject ? 'div[contenteditable=true]' : 'input[name=description]').clear().type(newDescription);
-            cy.get('[data-cy=form-submit-btn]').click();
+Cypress.Commands.add("updateResource", (token, suffix, uuid, data) => {
+    return cy
+        .doRequest("PATCH", "/arvados/v1/" + suffix + "/" + uuid, data, null, token, true)
+        .its("body")
+        .then(function (resource) {
+            return resource;
         });
+});
 
-        cy.get(container).contains(newName).rightclick();
-        cy.get('[data-cy=context-menu]').contains(isProject ? 'Edit project' : 'Edit collection').click();
-        cy.get('[data-cy=form-dialog]').within(() => {
-            cy.get('input[name=name]').should('have.value', newName);
+Cypress.Commands.add("loginAs", user => {
+    cy.clearCookies();
+    cy.clearLocalStorage();
+    cy.visit(`/token/?api_token=${user.token}`);
+    cy.url({ timeout: 10000 }).should("contain", "/projects/");
+    cy.get("div#root").should("contain", "Arvados Workbench (zzzzz)");
+    cy.get("div#root").should("not.contain", "Your account is inactive");
+});
 
-            if (isProject) {
-                cy.get('span[data-text=true]').contains(newDescription);
-            } else {
-                cy.get('input[name=description]').should('have.value', newDescription);
-            }
+Cypress.Commands.add("testEditProjectOrCollection", (container, oldName, newName, newDescription, isProject = true) => {
+    cy.get(container).contains(oldName).rightclick();
+    cy.get("[data-cy=context-menu]")
+        .contains(isProject ? "Edit project" : "Edit collection")
+        .click();
+    cy.get("[data-cy=form-dialog]").within(() => {
+        cy.get("input[name=name]").clear().type(newName);
+        cy.get(isProject ? "div[contenteditable=true]" : "input[name=description]")
+            .clear()
+            .type(newDescription);
+        cy.get("[data-cy=form-submit-btn]").click();
+    });
 
-            cy.get('[data-cy=form-cancel-btn]').click();
-        });
-    }
-)
+    cy.get(container).contains(newName).rightclick();
+    cy.get("[data-cy=context-menu]")
+        .contains(isProject ? "Edit project" : "Edit collection")
+        .click();
+    cy.get("[data-cy=form-dialog]").within(() => {
+        cy.get("input[name=name]").should("have.value", newName);
+
+        if (isProject) {
+            cy.get("span[data-text=true]").contains(newDescription);
+        } else {
+            cy.get("input[name=description]").should("have.value", newDescription);
+        }
 
-Cypress.Commands.add(
-    "doSearch", (searchTerm) => {
-        cy.get('[data-cy=searchbar-input-field]').type(`{selectall}${searchTerm}{enter}`);
-    }
-)
+        cy.get("[data-cy=form-cancel-btn]").click();
+    });
+});
 
-Cypress.Commands.add(
-    "goToPath", (path) => {
-        return cy.window().its('appHistory').invoke('push', path);
-    }
-)
+Cypress.Commands.add("doSearch", searchTerm => {
+    cy.get("[data-cy=searchbar-input-field]").type(`{selectall}${searchTerm}{enter}`);
+});
 
-Cypress.Commands.add('getAll', (...elements) => {
-    const promise = cy.wrap([], { log: false })
+Cypress.Commands.add("goToPath", path => {
+    return cy.window().its("appHistory").invoke("push", path);
+});
+
+Cypress.Commands.add("getAll", (...elements) => {
+    const promise = cy.wrap([], { log: false });
 
     for (let element of elements) {
-        promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got])))
+        promise.then(arr => cy.get(element).then(got => cy.wrap([...arr, got])));
     }
 
-    return promise
-})
+    return promise;
+});
 
-Cypress.Commands.add('shareWith', (srcUserToken, targetUserUUID, itemUUID, permission = 'can_write') => {
+Cypress.Commands.add("shareWith", (srcUserToken, targetUserUUID, itemUUID, permission = "can_write") => {
     cy.createLink(srcUserToken, {
         name: permission,
-        link_class: 'permission',
+        link_class: "permission",
         head_uuid: itemUUID,
-        tail_uuid: targetUserUUID
+        tail_uuid: targetUserUUID,
     });
-})
+});
 
-Cypress.Commands.add('addToFavorites', (userToken, userUUID, itemUUID) => {
+Cypress.Commands.add("addToFavorites", (userToken, userUUID, itemUUID) => {
     cy.createLink(userToken, {
         head_uuid: itemUUID,
-        link_class: 'star',
-        name: '',
+        link_class: "star",
+        name: "",
         owner_uuid: userUUID,
         tail_uuid: userUUID,
     });
-})
+});
 
-Cypress.Commands.add('createProject', ({
-    owningUser,
-    targetUser,
-    projectName,
-    canWrite,
-    addToFavorites
-}) => {
-    const writePermission = canWrite ? 'can_write' : 'can_read';
+Cypress.Commands.add("createProject", ({ owningUser, targetUser, projectName, canWrite, addToFavorites }) => {
+    const writePermission = canWrite ? "can_write" : "can_read";
 
     cy.createGroup(owningUser.token, {
         name: `${projectName} ${Math.floor(Math.random() * 999999)}`,
-        group_class: 'project',
-    }).as(`${projectName}`).then((project) => {
-        if (targetUser && targetUser !== owningUser) {
-            cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
-        }
-        if (addToFavorites) {
-            const user = targetUser ? targetUser : owningUser;
-            cy.addToFavorites(user.token, user.user.uuid, project.uuid);
-        }
-    });
+        group_class: "project",
+    })
+        .as(`${projectName}`)
+        .then(project => {
+            if (targetUser && targetUser !== owningUser) {
+                cy.shareWith(owningUser.token, targetUser.user.uuid, project.uuid, writePermission);
+            }
+            if (addToFavorites) {
+                const user = targetUser ? targetUser : owningUser;
+                cy.addToFavorites(user.token, user.user.uuid, project.uuid);
+            }
+        });
 });
 
 Cypress.Commands.add(
-    'upload',
+    "upload",
     {
-        prevSubject: 'element',
+        prevSubject: "element",
     },
     (subject, file, fileName, binaryMode = true) => {
         cy.window().then(window => {
-            const blob = binaryMode
-                ? b64toBlob(file, '', 512)
-                : new Blob([file], {type: 'text/plain'});
+            const blob = binaryMode ? b64toBlob(file, "", 512) : new Blob([file], { type: "text/plain" });
             const testFile = new window.File([blob], fileName);
 
-            cy.wrap(subject).trigger('drop', {
+            cy.wrap(subject).trigger("drop", {
                 dataTransfer: { files: [testFile] },
             });
-        })
+        });
     }
-)
+);
 
-function b64toBlob(b64Data, contentType = '', sliceSize = 512) {
-    const byteCharacters = atob(b64Data)
-    const byteArrays = []
+function b64toBlob(b64Data, contentType = "", sliceSize = 512) {
+    const byteCharacters = atob(b64Data);
+    const byteArrays = [];
 
     for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
         const slice = byteCharacters.slice(offset, offset + sliceSize);
@@ -510,78 +478,85 @@ function b64toBlob(b64Data, contentType = '', sliceSize = 512) {
     }
 
     const blob = new Blob(byteArrays, { type: contentType });
-    return blob
+    return blob;
 }
 
 // From https://github.com/cypress-io/cypress/issues/7306#issuecomment-1076451070=
 // This command requires the async package (https://www.npmjs.com/package/async)
-Cypress.Commands.add('waitForDom', () => {
-    cy.window().then({
-        // Don't timeout before waitForDom finishes
-        timeout: 10000
-    }, win => {
-      let timeElapsed = 0;
-
-      cy.log("Waiting for DOM mutations to complete");
-
-      return new Cypress.Promise((resolve) => {
-        // set the required variables
-        let async = require("async");
-        let observerConfig = { attributes: true, childList: true, subtree: true };
-        let items = Array.apply(null, { length: 50 }).map(Number.call, Number);
-        win.mutationCount = 0;
-        win.previousMutationCount = null;
-
-        // create an observer instance
-        let observer = new win.MutationObserver((mutations) => {
-          mutations.forEach((mutation) => {
-            // Only record "attributes" type mutations that are not a "class" mutation.
-            // If the mutation is not an "attributes" type, then we always record it.
-            if (mutation.type === 'attributes' && mutation.attributeName !== 'class') {
-              win.mutationCount += 1;
-            } else if (mutation.type !== 'attributes') {
-              win.mutationCount += 1;
-            }
-          });
+Cypress.Commands.add("waitForDom", () => {
+    cy.window().then(
+        {
+            // Don't timeout before waitForDom finishes
+            timeout: 10000,
+        },
+        win => {
+            let timeElapsed = 0;
+
+            cy.log("Waiting for DOM mutations to complete");
+
+            return new Cypress.Promise(resolve => {
+                // set the required variables
+                let async = require("async");
+                let observerConfig = { attributes: true, childList: true, subtree: true };
+                let items = Array.apply(null, { length: 50 }).map(Number.call, Number);
+                win.mutationCount = 0;
+                win.previousMutationCount = null;
+
+                // create an observer instance
+                let observer = new win.MutationObserver(mutations => {
+                    mutations.forEach(mutation => {
+                        // Only record "attributes" type mutations that are not a "class" mutation.
+                        // If the mutation is not an "attributes" type, then we always record it.
+                        if (mutation.type === "attributes" && mutation.attributeName !== "class") {
+                            win.mutationCount += 1;
+                        } else if (mutation.type !== "attributes") {
+                            win.mutationCount += 1;
+                        }
+                    });
 
-          // initialize the previousMutationCount
-          if (win.previousMutationCount == null) win.previousMutationCount = 0;
-        });
+                    // initialize the previousMutationCount
+                    if (win.previousMutationCount == null) win.previousMutationCount = 0;
+                });
 
-        // watch the document body for the specified mutations
-        observer.observe(win.document.body, observerConfig);
-
-        // check the DOM for mutations up to 50 times for a maximum time of 5 seconds
-        async.eachSeries(items, function iteratee(item, callback) {
-          // keep track of the elapsed time so we can log it at the end of the command
-          timeElapsed = timeElapsed + 100;
-
-          // make each iteration of the loop 100ms apart
-          setTimeout(() => {
-            if (win.mutationCount === win.previousMutationCount) {
-              // pass an argument to the async callback to exit the loop
-              return callback('Resolved - DOM changes complete.');
-            } else if (win.previousMutationCount != null) {
-              // only set the previous count if the observer has checked the DOM at least once
-              win.previousMutationCount = win.mutationCount;
-              return callback();
-            } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) {
-              // this is an early exit in case nothing is changing in the DOM. That way we only
-              // wait 500ms instead of the full 5 seconds when no DOM changes are occurring.
-              return callback('Resolved - Exiting early since no DOM changes were detected.');
-            } else {
-              // proceed to the next iteration
-              return callback();
-            }
-          }, 100);
-        }, function done() {
-          // Log the total wait time so users can see it
-          cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`);
-
-          // disconnect the observer and resolve the promise
-          observer.disconnect();
-          resolve();
-        });
-      });
-    });
-  });
+                // watch the document body for the specified mutations
+                observer.observe(win.document.body, observerConfig);
+
+                // check the DOM for mutations up to 50 times for a maximum time of 5 seconds
+                async.eachSeries(
+                    items,
+                    function iteratee(item, callback) {
+                        // keep track of the elapsed time so we can log it at the end of the command
+                        timeElapsed = timeElapsed + 100;
+
+                        // make each iteration of the loop 100ms apart
+                        setTimeout(() => {
+                            if (win.mutationCount === win.previousMutationCount) {
+                                // pass an argument to the async callback to exit the loop
+                                return callback("Resolved - DOM changes complete.");
+                            } else if (win.previousMutationCount != null) {
+                                // only set the previous count if the observer has checked the DOM at least once
+                                win.previousMutationCount = win.mutationCount;
+                                return callback();
+                            } else if (win.mutationCount === 0 && win.previousMutationCount == null && item === 4) {
+                                // this is an early exit in case nothing is changing in the DOM. That way we only
+                                // wait 500ms instead of the full 5 seconds when no DOM changes are occurring.
+                                return callback("Resolved - Exiting early since no DOM changes were detected.");
+                            } else {
+                                // proceed to the next iteration
+                                return callback();
+                            }
+                        }, 100);
+                    },
+                    function done() {
+                        // Log the total wait time so users can see it
+                        cy.log(`DOM mutations ${timeElapsed >= 5000 ? "did not complete" : "completed"} in ${timeElapsed} ms`);
+
+                        // disconnect the observer and resolve the promise
+                        observer.disconnect();
+                        resolve();
+                    }
+                );
+            });
+        }
+    );
+});
index c9d5b65711cfe8f965731f588934fa980e062b8d..83de48dec8fb40552e7d5e3a970b27d087ef6983 100644 (file)
@@ -2,14 +2,14 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import classNames from 'classnames';
-import { connect } from 'react-redux';
+import React from "react";
+import classNames from "classnames";
+import { connect } from "react-redux";
 import { FixedSizeList } from "react-window";
 import AutoSizer from "react-virtualized-auto-sizer";
-import servicesProvider from 'common/service-provider';
-import { DownloadIcon, MoreHorizontalIcon, MoreVerticalIcon } from 'components/icon/icon';
-import { SearchInput } from 'components/search-input/search-input';
+import servicesProvider from "common/service-provider";
+import { DownloadIcon, MoreHorizontalIcon, MoreVerticalIcon } from "components/icon/icon";
+import { SearchInput } from "components/search-input/search-input";
 import {
     ListItemIcon,
     StyleRulesCallback,
@@ -21,25 +21,19 @@ import {
     Checkbox,
     CircularProgress,
     Button,
-} from '@material-ui/core';
-import { FileTreeData } from '../file-tree/file-tree-data';
-import { TreeItem, TreeItemStatus } from '../tree/tree';
-import { RootState } from 'store/store';
-import { WebDAV, WebDAVRequestConfig } from 'common/webdav';
-import { AuthState } from 'store/auth/auth-reducer';
-import { extractFilesData } from 'services/collection-service/collection-service-files-response';
-import {
-    DefaultIcon,
-    DirectoryIcon,
-    FileIcon,
-    BackIcon,
-    SidePanelRightArrowIcon
-} from 'components/icon/icon';
-import { setCollectionFiles } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
-import { sortBy } from 'lodash';
-import { formatFileSize } from 'common/formatters';
-import { getInlineFileUrl, sanitizeToken } from 'views-components/context-menu/actions/helpers';
-import { extractUuidKind, ResourceKind } from 'models/resource';
+} from "@material-ui/core";
+import { FileTreeData } from "../file-tree/file-tree-data";
+import { TreeItem, TreeItemStatus } from "../tree/tree";
+import { RootState } from "store/store";
+import { WebDAV, WebDAVRequestConfig } from "common/webdav";
+import { AuthState } from "store/auth/auth-reducer";
+import { extractFilesData } from "services/collection-service/collection-service-files-response";
+import { DefaultIcon, DirectoryIcon, FileIcon, BackIcon, SidePanelRightArrowIcon } from "components/icon/icon";
+import { setCollectionFiles } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { sortBy } from "lodash";
+import { formatFileSize } from "common/formatters";
+import { getInlineFileUrl, sanitizeToken } from "views-components/context-menu/actions/helpers";
+import { extractUuidKind, ResourceKind } from "models/resource";
 
 export interface CollectionPanelFilesProps {
     isWritable: boolean;
@@ -56,7 +50,8 @@ export interface CollectionPanelFilesProps {
     collectionPanel: any;
 }
 
-type CssRules = "backButton"
+type CssRules =
+    | "backButton"
     | "backButtonHidden"
     | "pathPanelPathWrapper"
     | "uploadButton"
@@ -84,515 +79,628 @@ type CssRules = "backButton"
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     wrapper: {
-        display: 'flex',
-        minHeight: '600px',
-        color: 'rgba(0,0,0,0.87)',
-        fontSize: '0.875rem',
+        display: "flex",
+        minHeight: "600px",
+        color: "rgba(0,0,0,0.87)",
+        fontSize: "0.875rem",
         fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
         fontWeight: 400,
-        lineHeight: '1.5',
-        letterSpacing: '0.01071em'
+        lineHeight: "1.5",
+        letterSpacing: "0.01071em",
     },
     backButton: {
-        color: '#00bfa5',
-        cursor: 'pointer',
-        float: 'left',
+        color: "#00bfa5",
+        cursor: "pointer",
+        float: "left",
     },
     backButtonHidden: {
-        display: 'none',
+        display: "none",
     },
     dataWrapper: {
-        minHeight: '500px'
+        minHeight: "500px",
     },
     row: {
-        display: 'flex',
-        marginTop: '0.5rem',
-        marginBottom: '0.5rem',
-        cursor: 'pointer',
+        display: "flex",
+        marginTop: "0.5rem",
+        marginBottom: "0.5rem",
+        cursor: "pointer",
         "&:hover": {
-            backgroundColor: 'rgba(0, 0, 0, 0.08)',
-        }
+            backgroundColor: "rgba(0, 0, 0, 0.08)",
+        },
     },
     rowEmpty: {
-        top: '40%',
-        width: '100%',
-        textAlign: 'center',
-        position: 'absolute'
+        top: "40%",
+        width: "100%",
+        textAlign: "center",
+        position: "absolute",
     },
     loader: {
-        top: '50%',
-        left: '50%',
-        marginTop: '-15px',
-        marginLeft: '-15px',
-        position: 'absolute'
+        top: "50%",
+        left: "50%",
+        marginTop: "-15px",
+        marginLeft: "-15px",
+        position: "absolute",
     },
     rowName: {
-        display: 'inline-flex',
-        flexDirection: 'column',
-        justifyContent: 'center'
+        display: "inline-flex",
+        flexDirection: "column",
+        justifyContent: "center",
     },
     searchWrapper: {
-        display: 'inline-block',
-        marginBottom: '1rem',
-        marginLeft: '1rem',
+        display: "inline-block",
+        marginBottom: "1rem",
+        marginLeft: "1rem",
     },
     searchWrapperHidden: {
-        width: '0px'
+        width: "0px",
     },
     rowSelection: {
-        padding: '0px',
+        padding: "0px",
     },
     rowActive: {
         color: `${theme.palette.primary.main} !important`,
     },
     listItemIcon: {
-        display: 'inline-flex',
-        flexDirection: 'column',
-        justifyContent: 'center'
+        display: "inline-flex",
+        flexDirection: "column",
+        justifyContent: "center",
     },
     pathPanelMenu: {
-        float: 'right',
-        marginTop: '-15px',
+        float: "right",
+        marginTop: "-15px",
     },
     pathPanel: {
-        padding: '0.5rem',
-        marginBottom: '0.5rem',
-        backgroundColor: '#fff',
-        boxShadow: '0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)',
+        padding: "0.5rem",
+        marginBottom: "0.5rem",
+        backgroundColor: "#fff",
+        boxShadow: "0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)",
     },
     pathPanelPathWrapper: {
-        display: 'inline-block',
+        display: "inline-block",
     },
     leftPanel: {
         flex: 0,
-        padding: '0 1rem 1rem',
-        marginRight: '1rem',
-        whiteSpace: 'nowrap',
-        position: 'relative',
-        backgroundColor: '#fff',
-        boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)',
+        padding: "0 1rem 1rem",
+        marginRight: "1rem",
+        whiteSpace: "nowrap",
+        position: "relative",
+        backgroundColor: "#fff",
+        boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)",
     },
     leftPanelVisible: {
         opacity: 1,
-        flex: '50%',
-        animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`
+        flex: "50%",
+        animation: `animateVisible 1000ms ${theme.transitions.easing.easeOut}`,
     },
     leftPanelHidden: {
         opacity: 0,
-        flex: 'initial',
-        padding: '0',
-        marginRight: '0',
+        flex: "initial",
+        padding: "0",
+        marginRight: "0",
     },
     "@keyframes animateVisible": {
         "0%": {
             opacity: 0,
-            flex: 'initial',
+            flex: "initial",
         },
         "100%": {
             opacity: 1,
-            flex: '50%',
-        }
+            flex: "50%",
+        },
     },
     rightPanel: {
-        flex: '50%',
-        padding: '1rem',
-        paddingTop: '0.5rem',
-        marginTop: '-0.5rem',
-        position: 'relative',
-        backgroundColor: '#fff',
-        boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)',
+        flex: "50%",
+        padding: "1rem",
+        paddingTop: "0.5rem",
+        marginTop: "-0.5rem",
+        position: "relative",
+        backgroundColor: "#fff",
+        boxShadow: "0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)",
     },
     pathPanelItem: {
-        cursor: 'pointer',
+        cursor: "pointer",
     },
     uploadIcon: {
-        transform: 'rotate(180deg)'
+        transform: "rotate(180deg)",
     },
     uploadButton: {
-        float: 'right',
+        float: "right",
     },
     moreOptionsButton: {
         width: theme.spacing.unit * 3,
         height: theme.spacing.unit * 3,
         marginRight: theme.spacing.unit,
-        marginTop: 'auto',
-        marginBottom: 'auto',
-        justifyContent: 'center',
+        marginTop: "auto",
+        marginBottom: "auto",
+        justifyContent: "center",
     },
     moreOptions: {
-        position: 'absolute'
+        position: "absolute",
     },
 });
 
 const pathPromise = {};
 
-export const CollectionPanelFiles = withStyles(styles)(connect((state: RootState) => ({
-    auth: state.auth,
-    collectionPanel: state.collectionPanel,
-    collectionPanelFiles: state.collectionPanelFiles,
-}))((props: CollectionPanelFilesProps & WithStyles<CssRules> & { auth: AuthState }) => {
-    const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props;
-    const { apiToken, config } = props.auth;
-
-    const webdavClient = new WebDAV({
-        baseURL: config.keepWebServiceUrl,
-        headers: {
-            Authorization: `Bearer ${apiToken}`
-        },
-    });
+export const CollectionPanelFiles = withStyles(styles)(
+    connect((state: RootState) => ({
+        auth: state.auth,
+        collectionPanel: state.collectionPanel,
+        collectionPanelFiles: state.collectionPanelFiles,
+    }))((props: CollectionPanelFilesProps & WithStyles<CssRules> & { auth: AuthState }) => {
+        const { classes, onItemMenuOpen, onUploadDataClick, isWritable, dispatch, collectionPanelFiles, collectionPanel } = props;
+        const { apiToken, config } = props.auth;
+
+        const webdavClient = new WebDAV({
+            baseURL: config.keepWebServiceUrl,
+            headers: {
+                Authorization: `Bearer ${apiToken}`,
+            },
+        });
+
+        const webDAVRequestConfig: WebDAVRequestConfig = {
+            headers: {
+                Depth: "1",
+            },
+        };
 
-    const webDAVRequestConfig: WebDAVRequestConfig = {
-        headers: {
-            Depth: '1',
-        },
-    };
-
-    const parentRef = React.useRef(null);
-    const [path, setPath] = React.useState<string[]>([]);
-    const [pathData, setPathData] = React.useState({});
-    const [isLoading, setIsLoading] = React.useState(false);
-    const [leftSearch, setLeftSearch] = React.useState('');
-    const [rightSearch, setRightSearch] = React.useState('');
-
-    const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join('/');
-    const rightKey = path.join('/');
-
-    const leftData = pathData[leftKey] || [];
-    const rightData = pathData[rightKey];
-
-    React.useEffect(() => {
-        if (props.currentItemUuid && extractUuidKind(props.currentItemUuid) === ResourceKind.COLLECTION) {
-            setPathData({});
-            setPath([props.currentItemUuid]);
-        }
-    }, [props.currentItemUuid]);
-
-    const fetchData = (keys, ignoreCache = false) => {
-        const keyArray = Array.isArray(keys) ? keys : [keys];
-
-        Promise.all(keyArray.filter(key => !!key)
-            .map((key) => {
-                const dataExists = !!pathData[key];
-                const runningRequest = pathPromise[key];
-
-                if (ignoreCache || (!dataExists && !runningRequest)) {
-                    if (!isLoading) {
-                        setIsLoading(true);
-                    }
+        const parentRef = React.useRef(null);
+        const [path, setPath] = React.useState<string[]>([]);
+        const [pathData, setPathData] = React.useState({});
+        const [isLoading, setIsLoading] = React.useState(false);
+        const [leftSearch, setLeftSearch] = React.useState("");
+        const [rightSearch, setRightSearch] = React.useState("");
 
-                    pathPromise[key] = true;
+        const leftKey = (path.length > 1 ? path.slice(0, path.length - 1) : path).join("/");
+        const rightKey = path.join("/");
 
-                    return webdavClient.propfind(`c=${key}`, webDAVRequestConfig);
-                }
+        const leftData = pathData[leftKey] || [];
+        const rightData = pathData[rightKey];
 
-                return Promise.resolve(null);
-            })
-            .filter((promise) => !!promise)
-        )
-            .then((requests) => {
-                const newState = requests.map((request, index) => {
-                    if (request && request.responseXML != null) {
-                        const key = keyArray[index];
-                        const result: any = extractFilesData(request.responseXML);
-                        const sortedResult = sortBy(result, (n) => n.name).sort((n1, n2) => {
-                            if (n1.type === 'directory' && n2.type !== 'directory') {
-                                return -1;
+        React.useEffect(() => {
+            if (props.currentItemUuid && extractUuidKind(props.currentItemUuid) === ResourceKind.COLLECTION) {
+                setPathData({});
+                setPath([props.currentItemUuid]);
+            }
+        }, [props.currentItemUuid]);
+
+        const fetchData = (keys, ignoreCache = false) => {
+            const keyArray = Array.isArray(keys) ? keys : [keys];
+
+            Promise.all(
+                keyArray
+                    .filter(key => !!key)
+                    .map(key => {
+                        const dataExists = !!pathData[key];
+                        const runningRequest = pathPromise[key];
+
+                        if (ignoreCache || (!dataExists && !runningRequest)) {
+                            if (!isLoading) {
+                                setIsLoading(true);
                             }
-                            if (n1.type !== 'directory' && n2.type === 'directory') {
-                                return 1;
+
+                            pathPromise[key] = true;
+
+                            return webdavClient.propfind(`c=${key}`, webDAVRequestConfig);
+                        }
+
+                        return Promise.resolve(null);
+                    })
+                    .filter(promise => !!promise)
+            )
+                .then(requests => {
+                    const newState = requests
+                        .map((request, index) => {
+                            if (request && request.responseXML != null) {
+                                const key = keyArray[index];
+                                const result: any = extractFilesData(request.responseXML);
+                                const sortedResult = sortBy(result, n => n.name).sort((n1, n2) => {
+                                    if (n1.type === "directory" && n2.type !== "directory") {
+                                        return -1;
+                                    }
+                                    if (n1.type !== "directory" && n2.type === "directory") {
+                                        return 1;
+                                    }
+                                    return 0;
+                                });
+
+                                return { [key]: sortedResult };
                             }
-                            return 0;
-                        });
+                            return {};
+                        })
+                        .reduce((prev, next) => {
+                            return { ...next, ...prev };
+                        }, {});
+                    setPathData(state => ({ ...state, ...newState }));
+                })
+                .finally(() => {
+                    setIsLoading(false);
+                    keyArray.forEach(key => delete pathPromise[key]);
+                });
+        };
 
-                        return { [key]: sortedResult };
-                    }
-                    return {};
-                }).reduce((prev, next) => {
-                    return { ...next, ...prev };
-                }, {});
-                setPathData((state) => ({ ...state, ...newState }));
-            })
-            .finally(() => {
-                setIsLoading(false);
-                keyArray.forEach(key => delete pathPromise[key]);
-            });
-    };
-
-    React.useEffect(() => {
-        if (rightKey) {
-            fetchData(rightKey);
-            setLeftSearch('');
-            setRightSearch('');
-        }
-    }, [rightKey, rightData]); // eslint-disable-line react-hooks/exhaustive-deps
-
-    const currentPDH = (collectionPanel.item || {}).portableDataHash;
-    React.useEffect(() => {
-        if (currentPDH) {
-            fetchData([leftKey, rightKey], true);
-        }
-    }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps
-
-    React.useEffect(() => {
-        if (rightData) {
-            const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
-            setCollectionFiles(filtered, false)(dispatch);
-        }
-    }, [rightData, dispatch, rightSearch]);
-
-    const handleRightClick = React.useCallback(
-        (event) => {
-            event.preventDefault();
-            let elem = event.target;
-
-            while (elem && elem.dataset && !elem.dataset.item) {
-                elem = elem.parentNode;
+        React.useEffect(() => {
+            if (rightKey) {
+                fetchData(rightKey);
+                setLeftSearch("");
+                setRightSearch("");
             }
+        }, [rightKey, rightData]); // eslint-disable-line react-hooks/exhaustive-deps
 
-            if (!elem || !elem.dataset) {
-                return;
+        const currentPDH = (collectionPanel.item || {}).portableDataHash;
+        React.useEffect(() => {
+            if (currentPDH) {
+                fetchData([leftKey, rightKey], true);
             }
+        }, [currentPDH]); // eslint-disable-line react-hooks/exhaustive-deps
 
-            const { id } = elem.dataset;
+        React.useEffect(() => {
+            if (rightData) {
+                const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
+                setCollectionFiles(filtered, false)(dispatch);
+            }
+        }, [rightData, dispatch, rightSearch]);
 
-            const item: any = {
-                id,
-                data: rightData.find((elem) => elem.id === id),
-            };
+        const handleRightClick = React.useCallback(
+            event => {
+                event.preventDefault();
+                let elem = event.target;
 
-            if (id) {
-                onItemMenuOpen(event, item, isWritable);
-            }
-        },
-        [onItemMenuOpen, isWritable, rightData]);
+                while (elem && elem.dataset && !elem.dataset.item) {
+                    elem = elem.parentNode;
+                }
 
-    React.useEffect(() => {
-        let node = null;
+                if (!elem || !elem.dataset) {
+                    return;
+                }
 
-        if (parentRef?.current) {
-            node = parentRef.current;
-            (node as any).addEventListener('contextmenu', handleRightClick);
-        }
+                const { id } = elem.dataset;
 
-        return () => {
-            if (node) {
-                (node as any).removeEventListener('contextmenu', handleRightClick);
-            }
-        };
-    }, [parentRef, handleRightClick]);
+                const item: any = {
+                    id,
+                    data: rightData.find(elem => elem.id === id),
+                };
 
-    const handleClick = React.useCallback(
-        (event: any) => {
-            let isCheckbox = false;
-            let isMoreButton = false;
-            let elem = event.target;
+                if (id) {
+                    onItemMenuOpen(event, item, isWritable);
+                }
+            },
+            [onItemMenuOpen, isWritable, rightData]
+        );
 
-            if (elem.type === 'checkbox') {
-                isCheckbox = true;
-            }
-            // The "More options" button click event could be triggered on its
-            // internal graphic element.
-            else if ((elem.dataset && elem.dataset.id === 'moreOptions') || (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === 'moreOptions')) {
-                isMoreButton = true;
-            }
+        React.useEffect(() => {
+            let node = null;
 
-            while (elem && elem.dataset && !elem.dataset.item) {
-                elem = elem.parentNode;
+            if (parentRef?.current) {
+                node = parentRef.current;
+                (node as any).addEventListener("contextmenu", handleRightClick);
             }
 
-            if (elem && elem.dataset && !isCheckbox && !isMoreButton) {
-                const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset;
-
-                if (breadcrumbPath) {
-                    const index = path.indexOf(breadcrumbPath);
-                    setPath((state) => ([...state.slice(0, index + 1)]));
+            return () => {
+                if (node) {
+                    (node as any).removeEventListener("contextmenu", handleRightClick);
                 }
+            };
+        }, [parentRef, handleRightClick]);
 
-                if (parentPath && type === 'directory') {
-                    if (path.length > 1) {
-                        path.pop()
-                    }
+        const handleClick = React.useCallback(
+            (event: any) => {
+                let isCheckbox = false;
+                let isMoreButton = false;
+                let elem = event.target;
 
-                    setPath((state) => ([...state, parentPath]));
+                if (elem.type === "checkbox") {
+                    isCheckbox = true;
                 }
-
-                if (subfolderPath && type === 'directory') {
-                    setPath((state) => ([...state, subfolderPath]));
+                // The "More options" button click event could be triggered on its
+                // internal graphic element.
+                else if (
+                    (elem.dataset && elem.dataset.id === "moreOptions") ||
+                    (elem.parentNode && elem.parentNode.dataset && elem.parentNode.dataset.id === "moreOptions")
+                ) {
+                    isMoreButton = true;
                 }
 
-                if (elem.dataset.id && type === 'file') {
-                    const item = rightData.find(({ id }) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id);
-                    const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item);
-                    const fileUrl = sanitizeToken(getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl), true);
-                    window.open(fileUrl, '_blank');
+                while (elem && elem.dataset && !elem.dataset.item) {
+                    elem = elem.parentNode;
                 }
-            }
 
-            if (isCheckbox) {
-                const { id } = elem.dataset;
-                const item = collectionPanelFiles[id];
-                props.onSelectionToggle(event, item);
-            }
-            if (isMoreButton) {
-                const { id } = elem.dataset;
-                const item: any = {
-                    id,
-                    data: rightData.find((elem) => elem.id === id),
-                };
-                onItemMenuOpen(event, item, isWritable);
-            }
-        },
-        [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps
-    );
-
-    const getItemIcon = React.useCallback(
-        (type: string, activeClass: string | null) => {
-            let Icon = DefaultIcon;
-
-            switch (type) {
-                case 'directory':
-                    Icon = DirectoryIcon;
-                    break;
-                case 'file':
-                    Icon = FileIcon;
-                    break;
-            }
+                if (elem && elem.dataset && !isCheckbox && !isMoreButton) {
+                    const { parentPath, subfolderPath, breadcrumbPath, type } = elem.dataset;
 
-            return (
-                <ListItemIcon className={classNames(classes.listItemIcon, activeClass)}>
-                    <Icon />
-                </ListItemIcon>
-            )
-        },
-        [classes]
-    );
+                    if (breadcrumbPath) {
+                        const index = path.indexOf(breadcrumbPath);
+                        setPath(state => [...state.slice(0, index + 1)]);
+                    }
 
-    const getActiveClass = React.useCallback(
-        (name) => {
-            return path[path.length - 1] === name ? classes.rowActive : null;
-        },
-        [path, classes]
-    );
+                    if (parentPath && type === "directory") {
+                        if (path.length > 1) {
+                            path.pop();
+                        }
 
-    const onOptionsMenuOpen = React.useCallback(
-        (ev, isWritable) => {
-            props.onOptionsMenuOpen(ev, isWritable);
-        },
-        [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps
-    );
-
-    return <div data-cy="collection-files-panel" onClick={handleClick} ref={parentRef}>
-        <div className={classes.pathPanel}>
-            <div className={classes.pathPanelPathWrapper}>
-                {path.map((p: string, index: number) =>
-                    <span key={`${index}-${p}`} data-item="true"
-                        className={classes.pathPanelItem} data-breadcrumb-path={p}>
-                        <span className={classes.rowActive}>{index === 0 ? 'Home' : p}</span> <b>/</b>&nbsp;
-                    </span>)
+                        setPath(state => [...state, parentPath]);
+                    }
+
+                    if (subfolderPath && type === "directory") {
+                        setPath(state => [...state, subfolderPath]);
+                    }
+
+                    if (elem.dataset.id && type === "file") {
+                        const item = rightData.find(({ id }) => id === elem.dataset.id) || leftData.find(({ id }) => id === elem.dataset.id);
+                        const enhancedItem = servicesProvider.getServices().collectionService.extendFileURL(item);
+                        const fileUrl = sanitizeToken(
+                            getInlineFileUrl(enhancedItem.url, config.keepWebServiceUrl, config.keepWebInlineServiceUrl),
+                            true
+                        );
+                        window.open(fileUrl, "_blank");
+                    }
                 }
-            </div>
-            <Tooltip className={classes.pathPanelMenu} title="More options" disableFocusListener>
-                <IconButton data-cy='collection-files-panel-options-btn'
-                    onClick={(ev) => {
-                        onOptionsMenuOpen(ev, isWritable);
-                    }}>
-                    <MoreVerticalIcon />
-                </IconButton>
-            </Tooltip>
-        </div>
-        <div className={classes.wrapper}>
-            <div className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)} data-cy="collection-files-left-panel">
-                <Tooltip title="Go back" className={path.length > 1 ? classes.backButton : classes.backButtonHidden}>
-                    <IconButton onClick={() => setPath((state) => ([...state.slice(0, state.length - 1)]))}>
-                        <BackIcon />
-                    </IconButton>
-                </Tooltip>
-                <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
-                    <SearchInput selfClearProp={leftKey} label="Search" value={leftSearch} onSearch={setLeftSearch} />
-                </div>
-                <div className={classes.dataWrapper}>{leftData
-                    ? <AutoSizer defaultWidth={0}>{({ height, width }) => {
-                        const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
-                        return !!filtered.length
-                            ? <FixedSizeList height={height} itemCount={filtered.length}
-                                itemSize={35} width={width}>{({ index, style }) => {
-                                    const { id, type, name } = filtered[index];
-                                    return <div data-id={id} style={style} data-item="true"
-                                        data-type={type} data-parent-path={name}
-                                        className={classNames(classes.row, getActiveClass(name))}
-                                        key={id}>
-                                        {getItemIcon(type, getActiveClass(name))}
-                                        <div className={classes.rowName}>
-                                            {name}
-                                        </div>
-                                        {getActiveClass(name)
-                                            ? <SidePanelRightArrowIcon
-                                                style={{ display: 'inline', marginTop: '5px', marginLeft: '5px' }} />
-                                            : null
-                                        }
-                                    </div>;
-                                }}</FixedSizeList>
-                            : <div className={classes.rowEmpty}>No directories available</div>
-                    }}
-                    </AutoSizer>
-                    : <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">
-                <div className={classes.searchWrapper}>
-                    <SearchInput selfClearProp={rightKey} label="Search" value={rightSearch} onSearch={setRightSearch} />
+
+                if (isCheckbox) {
+                    const { id } = elem.dataset;
+                    const item = collectionPanelFiles[id];
+                    props.onSelectionToggle(event, item);
+                }
+                if (isMoreButton) {
+                    const { id } = elem.dataset;
+                    const item: any = {
+                        id,
+                        data: rightData.find(elem => elem.id === id),
+                    };
+                    onItemMenuOpen(event, item, isWritable);
+                }
+            },
+            [path, setPath, collectionPanelFiles] // eslint-disable-line react-hooks/exhaustive-deps
+        );
+
+        const getItemIcon = React.useCallback(
+            (type: string, activeClass: string | null) => {
+                let Icon = DefaultIcon;
+
+                switch (type) {
+                    case "directory":
+                        Icon = DirectoryIcon;
+                        break;
+                    case "file":
+                        Icon = FileIcon;
+                        break;
+                }
+
+                return (
+                    <ListItemIcon className={classNames(classes.listItemIcon, activeClass)}>
+                        <Icon />
+                    </ListItemIcon>
+                );
+            },
+            [classes]
+        );
+
+        const getActiveClass = React.useCallback(
+            name => {
+                return path[path.length - 1] === name ? classes.rowActive : null;
+            },
+            [path, classes]
+        );
+
+        const onOptionsMenuOpen = React.useCallback(
+            (ev, isWritable) => {
+                props.onOptionsMenuOpen(ev, isWritable);
+            },
+            [props.onOptionsMenuOpen] // eslint-disable-line react-hooks/exhaustive-deps
+        );
+
+        return (
+            <div
+                data-cy="collection-files-panel"
+                onClick={handleClick}
+                ref={parentRef}
+            >
+                <div className={classes.pathPanel}>
+                    <div className={classes.pathPanelPathWrapper}>
+                        {path.map((p: string, index: number) => (
+                            <span
+                                key={`${index}-${p}`}
+                                data-item="true"
+                                className={classes.pathPanelItem}
+                                data-breadcrumb-path={p}
+                            >
+                                <span className={classes.rowActive}>{index === 0 ? "Home" : p}</span> <b>/</b>&nbsp;
+                            </span>
+                        ))}
+                    </div>
+                    <Tooltip
+                        className={classes.pathPanelMenu}
+                        title="More options"
+                        disableFocusListener
+                    >
+                        <IconButton
+                            data-cy="collection-files-panel-options-btn"
+                            onClick={ev => {
+                                onOptionsMenuOpen(ev, isWritable);
+                            }}
+                        >
+                            <MoreVerticalIcon />
+                        </IconButton>
+                    </Tooltip>
                 </div>
-                {isWritable &&
-                    <Button className={classes.uploadButton} data-cy='upload-button'
-                        onClick={() => {
-                            onUploadDataClick(rightKey === leftKey ? undefined : rightKey);
-                        }}
-                        variant='contained' color='primary' size='small'>
-                        <DownloadIcon className={classes.uploadIcon} />
-                        Upload data
-                    </Button>}
-                <div className={classes.dataWrapper}>{rightData && !isLoading
-                    ? <AutoSizer defaultHeight={500}>{({ height, width }) => {
-                        const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
-                        return !!filtered.length
-                            ? <FixedSizeList height={height} itemCount={filtered.length}
-                                itemSize={35} width={width}>{({ index, style }) => {
-                                    const { id, type, name, size } = filtered[index];
-
-                                    return <div style={style} data-id={id} data-item="true"
-                                        data-type={type} data-subfolder-path={name}
-                                        className={classes.row} key={id}>
-                                        <Checkbox color="primary"
-                                            className={classes.rowSelection}
-                                            checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
-                                        />&nbsp;
-                                        {getItemIcon(type, null)}
-                                        <div className={classes.rowName}>
-                                            {name}
-                                        </div>
-                                        <span className={classes.rowName} style={{
-                                            marginLeft: 'auto', marginRight: '1rem'
-                                        }}>
-                                            {formatFileSize(size)}
-                                        </span>
-                                        <Tooltip title="More options" disableFocusListener>
-                                            <IconButton data-id='moreOptions'
-                                                data-cy='file-item-options-btn'
-                                                className={classes.moreOptionsButton}>
-                                                <MoreHorizontalIcon
-                                                    data-id='moreOptions'
-                                                    className={classes.moreOptions} />
-                                            </IconButton>
-                                        </Tooltip>
-                                    </div>
-                                }}</FixedSizeList>
-                            : <div className={classes.rowEmpty}>This collection is empty</div>
-                    }}</AutoSizer>
-                    : <div className={classes.row}>
-                        <CircularProgress className={classes.loader} size={30} />
-                    </div>}
+                <div className={classes.wrapper}>
+                    <div
+                        className={classNames(classes.leftPanel, path.length > 1 ? classes.leftPanelVisible : classes.leftPanelHidden)}
+                        data-cy="collection-files-left-panel"
+                    >
+                        <Tooltip
+                            title="Go back"
+                            className={path.length > 1 ? classes.backButton : classes.backButtonHidden}
+                        >
+                            <IconButton onClick={() => setPath(state => [...state.slice(0, state.length - 1)])}>
+                                <BackIcon />
+                            </IconButton>
+                        </Tooltip>
+                        <div className={path.length > 1 ? classes.searchWrapper : classes.searchWrapperHidden}>
+                            <SearchInput
+                                selfClearProp={leftKey}
+                                label="Search"
+                                value={leftSearch}
+                                onSearch={setLeftSearch}
+                            />
+                        </div>
+                        <div className={classes.dataWrapper}>
+                            {leftData ? (
+                                <AutoSizer defaultWidth={0}>
+                                    {({ height, width }) => {
+                                        const filtered = leftData.filter(({ name }) => name.indexOf(leftSearch) > -1);
+                                        return !!filtered.length ? (
+                                            <FixedSizeList
+                                                height={height}
+                                                itemCount={filtered.length}
+                                                itemSize={35}
+                                                width={width}
+                                            >
+                                                {({ index, style }) => {
+                                                    const { id, type, name } = filtered[index];
+                                                    return (
+                                                        <div
+                                                            data-id={id}
+                                                            style={style}
+                                                            data-item="true"
+                                                            data-type={type}
+                                                            data-parent-path={name}
+                                                            className={classNames(classes.row, getActiveClass(name))}
+                                                            key={id}
+                                                        >
+                                                            {getItemIcon(type, getActiveClass(name))}
+                                                            <div className={classes.rowName}>{name}</div>
+                                                            {getActiveClass(name) ? (
+                                                                <SidePanelRightArrowIcon
+                                                                    style={{ display: "inline", marginTop: "5px", marginLeft: "5px" }}
+                                                                />
+                                                            ) : null}
+                                                        </div>
+                                                    );
+                                                }}
+                                            </FixedSizeList>
+                                        ) : (
+                                            <div className={classes.rowEmpty}>No directories available</div>
+                                        );
+                                    }}
+                                </AutoSizer>
+                            ) : (
+                                <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"
+                    >
+                        <div className={classes.searchWrapper}>
+                            <SearchInput
+                                selfClearProp={rightKey}
+                                label="Search"
+                                value={rightSearch}
+                                onSearch={setRightSearch}
+                            />
+                        </div>
+                        {isWritable && (
+                            <Button
+                                className={classes.uploadButton}
+                                data-cy="upload-button"
+                                onClick={() => {
+                                    onUploadDataClick(rightKey === leftKey ? undefined : rightKey);
+                                }}
+                                variant="contained"
+                                color="primary"
+                                size="small"
+                            >
+                                <DownloadIcon className={classes.uploadIcon} />
+                                Upload data
+                            </Button>
+                        )}
+                        <div className={classes.dataWrapper}>
+                            {rightData && !isLoading ? (
+                                <AutoSizer defaultHeight={500}>
+                                    {({ height, width }) => {
+                                        const filtered = rightData.filter(({ name }) => name.indexOf(rightSearch) > -1);
+                                        return !!filtered.length ? (
+                                            <FixedSizeList
+                                                height={height}
+                                                itemCount={filtered.length}
+                                                itemSize={35}
+                                                width={width}
+                                            >
+                                                {({ index, style }) => {
+                                                    const { id, type, name, size } = filtered[index];
+
+                                                    return (
+                                                        <div
+                                                            style={style}
+                                                            data-id={id}
+                                                            data-item="true"
+                                                            data-type={type}
+                                                            data-subfolder-path={name}
+                                                            className={classes.row}
+                                                            key={id}
+                                                        >
+                                                            <Checkbox
+                                                                color="primary"
+                                                                className={classes.rowSelection}
+                                                                checked={collectionPanelFiles[id] ? collectionPanelFiles[id].value.selected : false}
+                                                            />
+                                                            &nbsp;
+                                                            {getItemIcon(type, null)}
+                                                            <div className={classes.rowName}>{name}</div>
+                                                            <span
+                                                                className={classes.rowName}
+                                                                style={{
+                                                                    marginLeft: "auto",
+                                                                    marginRight: "1rem",
+                                                                }}
+                                                            >
+                                                                {formatFileSize(size)}
+                                                            </span>
+                                                            <Tooltip
+                                                                title="More options"
+                                                                disableFocusListener
+                                                            >
+                                                                <IconButton
+                                                                    data-id="moreOptions"
+                                                                    data-cy="file-item-options-btn"
+                                                                    className={classes.moreOptionsButton}
+                                                                >
+                                                                    <MoreHorizontalIcon
+                                                                        data-id="moreOptions"
+                                                                        className={classes.moreOptions}
+                                                                    />
+                                                                </IconButton>
+                                                            </Tooltip>
+                                                        </div>
+                                                    );
+                                                }}
+                                            </FixedSizeList>
+                                        ) : (
+                                            <div className={classes.rowEmpty}>This collection is empty</div>
+                                        );
+                                    }}
+                                </AutoSizer>
+                            ) : (
+                                <div className={classes.row}>
+                                    <CircularProgress
+                                        className={classes.loader}
+                                        size={30}
+                                    />
+                                </div>
+                            )}
+                        </div>
+                    </div>
                 </div>
             </div>
-        </div>
-    </div>
-}));
+        );
+    })
+);
index 3b2ff68a334cf112b3bc4edd6ae9b513a4dcc62b..586bb13b1e8e43b520b9848d16cbc7483ae79709 100644 (file)
@@ -2,9 +2,9 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from "react";
-import { connect, DispatchProp } from "react-redux";
-import { StyleRulesCallback, Tooltip, WithStyles, withStyles } from "@material-ui/core";
+import React from 'react';
+import { connect, DispatchProp } from 'react-redux';
+import { StyleRulesCallback, Tooltip, WithStyles, withStyles } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
 import CopyToClipboard from 'react-copy-to-clipboard';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
@@ -13,46 +13,50 @@ import { CopyIcon } from 'components/icon/icon';
 type CssRules = 'copyIcon';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
-  copyIcon: {
-    marginLeft: theme.spacing.unit,
-    color: theme.palette.grey["500"],
-    cursor: 'pointer',
-    display: 'inline',
-    '& svg': {
-      fontSize: '1rem',
-      verticalAlign: 'middle',
-    }
-  }
+    copyIcon: {
+        marginLeft: theme.spacing.unit,
+        color: theme.palette.grey['500'],
+        cursor: 'pointer',
+        display: 'inline',
+        '& svg': {
+            fontSize: '1rem',
+            verticalAlign: 'middle',
+        },
+    },
 });
 
 interface CopyToClipboardDataProps {
-  children?: React.ReactNode;
-  value: string;
+    children?: React.ReactNode;
+    value: string;
 }
 
 type CopyToClipboardProps = CopyToClipboardDataProps & WithStyles<CssRules> & DispatchProp;
 
-export const CopyToClipboardSnackbar = connect()(withStyles(styles)(
-  class CopyToClipboardSnackbar extends React.Component<CopyToClipboardProps> {
-    onCopy = () => {
-      this.props.dispatch(snackbarActions.OPEN_SNACKBAR({
-        message: 'Copied',
-        hideDuration: 2000,
-        kind: SnackbarKind.SUCCESS
-    }));
-    };
+export const CopyToClipboardSnackbar = connect()(
+    withStyles(styles)(
+        class CopyToClipboardSnackbar extends React.Component<CopyToClipboardProps> {
+            onCopy = () => {
+                this.props.dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: 'Copied',
+                        hideDuration: 2000,
+                        kind: SnackbarKind.SUCCESS,
+                    })
+                );
+            };
 
-    render() {
-      const { children, value, classes } = this.props;
-      return (
-        <Tooltip title="Copy to clipboard">
-          <span className={classes.copyIcon}>
-            <CopyToClipboard text={value} onCopy={this.onCopy}>
-              {children || <CopyIcon />}
-            </CopyToClipboard>
-          </span>
-        </Tooltip>
-      );
-    }
-  }
-));
+            render() {
+                const { children, value, classes } = this.props;
+                return (
+                    <Tooltip title='Copy to clipboard'>
+                        <span className={classes.copyIcon}>
+                            <CopyToClipboard text={value} onCopy={this.onCopy}>
+                                {children || <CopyIcon />}
+                            </CopyToClipboard>
+                        </span>
+                    </Tooltip>
+                );
+            }
+        }
+    )
+);
index dc7e8725793524b57bf7cfb76aadd5c466577274..b86567a54c4a73cde90e2c5ef115df5df63037ce 100644 (file)
@@ -4,29 +4,53 @@
 
 import React from "react";
 import { configure, mount } from "enzyme";
-import Adapter from 'enzyme-adapter-react-16';
+import Adapter from "enzyme-adapter-react-16";
 
 import { DataExplorer } from "./data-explorer";
 import { ColumnSelector } from "../column-selector/column-selector";
 import { DataTable, DataTableFetchMode } from "../data-table/data-table";
 import { SearchInput } from "../search-input/search-input";
 import { TablePagination } from "@material-ui/core";
-import { ProjectIcon } from '../icon/icon';
-import { SortDirection } from '../data-table/data-column';
+import { ProjectIcon } from "../icon/icon";
+import { SortDirection } from "../data-table/data-column";
+import { combineReducers, createStore } from "redux";
+import { Provider } from "react-redux";
 
 configure({ adapter: new Adapter() });
 
 describe("<DataExplorer />", () => {
+    let store;
+    beforeEach(() => {
+        const initialMSState = {
+            multiselect: {
+                checkedList: {},
+                isVisible: false,
+            },
+            resources: {},
+        };
+        store = createStore(
+            combineReducers({
+                multiselect: (state: any = initialMSState.multiselect, action: any) => state,
+                resources: (state: any = initialMSState.resources, action: any) => state,
+            })
+        );
+    });
 
     it("communicates with <SearchInput/>", () => {
         const onSearch = jest.fn();
         const onSetColumns = jest.fn();
-        const dataExplorer = mount(<DataExplorer
-            {...mockDataExplorerProps()}
-            items={[{ name: "item 1" }]}
-            searchValue="search value"
-            onSearch={onSearch}
-            onSetColumns={onSetColumns} />);
+
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    items={[{ name: "item 1" }]}
+                    searchValue="search value"
+                    onSearch={onSearch}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
         expect(dataExplorer.find(SearchInput).prop("value")).toEqual("search value");
         dataExplorer.find(SearchInput).prop("onSearch")("new value");
         expect(onSearch).toHaveBeenCalledWith("new value");
@@ -36,12 +60,17 @@ describe("<DataExplorer />", () => {
         const onColumnToggle = jest.fn();
         const onSetColumns = jest.fn();
         const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: {} }];
-        const dataExplorer = mount(<DataExplorer
-            {...mockDataExplorerProps()}
-            columns={columns}
-            onColumnToggle={onColumnToggle}
-            items={[{ name: "item 1" }]}
-            onSetColumns={onSetColumns} />);
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    columns={columns}
+                    onColumnToggle={onColumnToggle}
+                    items={[{ name: "item 1" }]}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
         expect(dataExplorer.find(ColumnSelector).prop("columns")).toBe(columns);
         dataExplorer.find(ColumnSelector).prop("onColumnToggle")("columns");
         expect(onColumnToggle).toHaveBeenCalledWith("columns");
@@ -54,15 +83,20 @@ describe("<DataExplorer />", () => {
         const onSetColumns = jest.fn();
         const columns = [{ name: "Column 1", render: jest.fn(), selected: true, configurable: true, sortDirection: SortDirection.ASC, filters: {} }];
         const items = [{ name: "item 1" }];
-        const dataExplorer = mount(<DataExplorer
-            {...mockDataExplorerProps()}
-            columns={columns}
-            items={items}
-            onFiltersChange={onFiltersChange}
-            onSortToggle={onSortToggle}
-            onRowClick={onRowClick}
-            onSetColumns={onSetColumns} />);
-        expect(dataExplorer.find(DataTable).prop("columns").slice(0, -1)).toEqual(columns);
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    columns={columns}
+                    items={items}
+                    onFiltersChange={onFiltersChange}
+                    onSortToggle={onSortToggle}
+                    onRowClick={onRowClick}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
+        expect(dataExplorer.find(DataTable).prop("columns").slice(1, 2)).toEqual(columns);
         expect(dataExplorer.find(DataTable).prop("items")).toBe(items);
         dataExplorer.find(DataTable).prop("onRowClick")("event", "rowClick");
         dataExplorer.find(DataTable).prop("onFiltersChange")("filtersChange");
@@ -76,14 +110,19 @@ describe("<DataExplorer />", () => {
         const onChangePage = jest.fn();
         const onChangeRowsPerPage = jest.fn();
         const onSetColumns = jest.fn();
-        const dataExplorer = mount(<DataExplorer
-            {...mockDataExplorerProps()}
-            items={[{ name: "item 1" }]}
-            page={10}
-            rowsPerPage={50}
-            onChangePage={onChangePage}
-            onChangeRowsPerPage={onChangeRowsPerPage}
-            onSetColumns={onSetColumns} />);
+        const dataExplorer = mount(
+            <Provider store={store}>
+                <DataExplorer
+                    {...mockDataExplorerProps()}
+                    items={[{ name: "item 1" }]}
+                    page={10}
+                    rowsPerPage={50}
+                    onChangePage={onChangePage}
+                    onChangeRowsPerPage={onChangeRowsPerPage}
+                    onSetColumns={onSetColumns}
+                />
+            </Provider>
+        );
         expect(dataExplorer.find(TablePagination).prop("page")).toEqual(10);
         expect(dataExplorer.find(TablePagination).prop("rowsPerPage")).toEqual(50);
         dataExplorer.find(TablePagination).prop("onChangePage")(undefined, 6);
@@ -115,6 +154,10 @@ const mockDataExplorerProps = () => ({
     defaultIcon: ProjectIcon,
     onSetColumns: jest.fn(),
     onLoadMore: jest.fn(),
-    defaultMessages: ['testing'],
-    contextMenuColumn: true
+    defaultMessages: ["testing"],
+    contextMenuColumn: true,
+    setCheckedListOnStore: jest.fn(),
+    toggleMSToolbar: jest.fn(),
+    isMSToolbarVisible: false,
+    checkedList: {},
 });
index dc097c390ef110fa2ff262d300e48d5fcaf62657..ad5762dfeb1bac4bda716b02ff60bdd646c6dbf0 100644 (file)
@@ -2,26 +2,22 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from '@material-ui/core';
+import React from "react";
+import { Grid, Paper, Toolbar, StyleRulesCallback, withStyles, WithStyles, TablePagination, IconButton, Tooltip, Button } from "@material-ui/core";
 import { ColumnSelector } from "components/column-selector/column-selector";
 import { DataTable, DataColumns, DataTableFetchMode } from "components/data-table/data-table";
 import { DataColumn } from "components/data-table/data-column";
-import { SearchInput } from 'components/search-input/search-input';
+import { SearchInput } from "components/search-input/search-input";
 import { ArvadosTheme } from "common/custom-theme";
-import { createTree } from 'models/tree';
-import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
-import {
-    CloseIcon,
-    IconType,
-    MaximizeIcon,
-    UnMaximizeIcon,
-    MoreVerticalIcon
-} from 'components/icon/icon';
-import { PaperProps } from '@material-ui/core/Paper';
-import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import { MultiselectToolbar } from "components/multiselect-toolbar/MultiselectToolbar";
+import { TCheckedList } from "components/data-table/data-table";
+import { createTree } from "models/tree";
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
+import { CloseIcon, IconType, MaximizeIcon, UnMaximizeIcon, MoreVerticalIcon } from "components/icon/icon";
+import { PaperProps } from "@material-ui/core/Paper";
+import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
 
-type CssRules = 'searchBox' | 'headerMenu' | "toolbar" | "footer" | "root" | 'moreOptionsButton' | 'title' | 'dataTable' | 'container';
+type CssRules = "searchBox" | "headerMenu" | "toolbar" | "footer" | "root" | "moreOptionsButton" | "title" | "dataTable" | "container";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     searchBox: {
@@ -32,31 +28,34 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         paddingRight: theme.spacing.unit,
     },
     footer: {
-        overflow: 'auto'
+        overflow: "auto",
     },
     root: {
-        height: '100%',
+        height: "100%",
     },
     moreOptionsButton: {
-        padding: 0
+        padding: 0,
     },
     title: {
-        display: 'inline-block',
+        display: "inline-block",
         paddingLeft: theme.spacing.unit * 2,
         paddingTop: theme.spacing.unit * 2,
-        fontSize: '18px'
+        fontSize: "18px",
     },
     dataTable: {
-        height: '100%',
-        overflow: 'auto',
+        height: "100%",
+        overflow: "auto",
     },
     container: {
-        height: '100%',
+        height: "100%",
     },
     headerMenu: {
-        float: 'right',
-        display: 'inline-block',
-    }
+        width: "100%",
+        float: "right",
+        display: "flex",
+        flexDirection: "row-reverse",
+        justifyContent: "space-between",
+    },
 });
 
 interface DataExplorerDataProps<T> {
@@ -83,6 +82,8 @@ interface DataExplorerDataProps<T> {
     paperKey?: string;
     currentItemUuid: string;
     elementPath?: string;
+    isMSToolbarVisible: boolean;
+    checkedList: TCheckedList;
 }
 
 interface DataExplorerActionProps<T> {
@@ -98,22 +99,23 @@ interface DataExplorerActionProps<T> {
     onChangeRowsPerPage: (rowsPerPage: number) => void;
     onLoadMore: (page: number) => void;
     extractKey?: (item: T) => React.Key;
+    toggleMSToolbar: (isVisible: boolean) => void;
+    setCheckedListOnStore: (checkedList: TCheckedList) => void;
 }
 
-type DataExplorerProps<T> = DataExplorerDataProps<T> &
-    DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
+type DataExplorerProps<T> = DataExplorerDataProps<T> & DataExplorerActionProps<T> & WithStyles<CssRules> & MPVPanelProps;
 
 export const DataExplorer = withStyles(styles)(
     class DataExplorerGeneric<T> extends React.Component<DataExplorerProps<T>> {
         state = {
             showLoading: false,
-            prevRefresh: '',
-            prevRoute: '',
+            prevRefresh: "",
+            prevRoute: "",
         };
 
         componentDidUpdate(prevProps: DataExplorerProps<T>) {
-            const currentRefresh = this.props.currentRefresh || '';
-            const currentRoute = this.props.currentRoute || '';
+            const currentRefresh = this.props.currentRefresh || "";
+            const currentRoute = this.props.currentRoute || "";
 
             if (currentRoute !== this.state.prevRoute) {
                 // Component already mounted, but the user comes from a route change,
@@ -146,119 +148,233 @@ export const DataExplorer = withStyles(styles)(
             // Component just mounted, so we need to show the loading indicator.
             this.setState({
                 showLoading: this.props.working,
-                prevRefresh: this.props.currentRefresh || '',
-                prevRoute: this.props.currentRoute || '',
+                prevRefresh: this.props.currentRefresh || "",
+                prevRoute: this.props.currentRoute || "",
             });
         }
 
         render() {
             const {
-                columns, onContextMenu, onFiltersChange, onSortToggle, extractKey,
-                rowsPerPage, rowsPerPageOptions, onColumnToggle, searchLabel, searchValue, onSearch,
-                items, itemsAvailable, onRowClick, onRowDoubleClick, classes,
-                defaultViewIcon, defaultViewMessages, hideColumnSelector, actions, paperProps, hideSearchInput,
-                paperKey, fetchMode, currentItemUuid, title,
-                doHidePanel, doMaximizePanel, doUnMaximizePanel, panelName, panelMaximized, elementPath
+                columns,
+                onContextMenu,
+                onFiltersChange,
+                onSortToggle,
+                extractKey,
+                rowsPerPage,
+                rowsPerPageOptions,
+                onColumnToggle,
+                searchLabel,
+                searchValue,
+                onSearch,
+                items,
+                itemsAvailable,
+                onRowClick,
+                onRowDoubleClick,
+                classes,
+                defaultViewIcon,
+                defaultViewMessages,
+                hideColumnSelector,
+                actions,
+                paperProps,
+                hideSearchInput,
+                paperKey,
+                fetchMode,
+                currentItemUuid,
+                title,
+                doHidePanel,
+                doMaximizePanel,
+                doUnMaximizePanel,
+                panelName,
+                panelMaximized,
+                elementPath,
+                toggleMSToolbar,
+                setCheckedListOnStore,
+                checkedList,
             } = this.props;
-            return <Paper className={classes.root} {...paperProps} key={paperKey} data-cy={this.props["data-cy"]}>
-                <Grid container direction="column" wrap="nowrap" className={classes.container}>
-                    <div>
-                        {title && <Grid item xs className={classes.title}>{title}</Grid>}
-                        {
-                            (!hideColumnSelector || !hideSearchInput || !!actions) &&
-                            <Grid className={classes.headerMenu} item xs>
-                                <Toolbar className={classes.toolbar}>
-                                    {!hideSearchInput && <div className={classes.searchBox}>
-                                        {!hideSearchInput && <SearchInput
-                                            label={searchLabel}
-                                            value={searchValue}
-                                            selfClearProp={''}
-                                            onSearch={onSearch} />}
-                                    </div>}
-                                    {actions}
-                                    {!hideColumnSelector && <ColumnSelector
-                                        columns={columns}
-                                        onColumnToggle={onColumnToggle} />}
-                                    { doUnMaximizePanel && panelMaximized &&
-                                    <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
-                                        <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
-                                    </Tooltip> }
-                                    { doMaximizePanel && !panelMaximized &&
-                                        <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
-                                            <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
-                                        </Tooltip> }
-                                    { doHidePanel &&
-                                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                                            <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
-                                        </Tooltip> }
-                                </Toolbar>
-                            </Grid>
-                        }
-                    </div>
-                <Grid item xs="auto" className={classes.dataTable}><DataTable
-                    columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
-                    items={items}
-                    onRowClick={(_, item: T) => onRowClick(item)}
-                    onContextMenu={onContextMenu}
-                    onRowDoubleClick={(_, item: T) => onRowDoubleClick(item)}
-                    onFiltersChange={onFiltersChange}
-                    onSortToggle={onSortToggle}
-                    extractKey={extractKey}
-                    working={this.state.showLoading}
-                    defaultViewIcon={defaultViewIcon}
-                    defaultViewMessages={defaultViewMessages}
-                    currentItemUuid={currentItemUuid}
-                    currentRoute={paperKey} /></Grid>
-                <Grid item xs><Toolbar className={classes.footer}>
-                    {
-                        elementPath &&
-                        <Grid container>
-                            <span data-cy="element-path">
-                                {elementPath}
-                            </span>
+            return (
+                <Paper
+                    className={classes.root}
+                    {...paperProps}
+                    key={paperKey}
+                    data-cy={this.props["data-cy"]}
+                >
+                    <Grid
+                        container
+                        direction="column"
+                        wrap="nowrap"
+                        className={classes.container}
+                    >
+                        <div>
+                            {title && (
+                                <Grid
+                                    item
+                                    xs
+                                    className={classes.title}
+                                >
+                                    {title}
+                                </Grid>
+                            )}
+                            {(!hideColumnSelector || !hideSearchInput || !!actions) && (
+                                <Grid
+                                    className={classes.headerMenu}
+                                    item
+                                    xs
+                                >
+                                    <Toolbar className={classes.toolbar}>
+                                        {!hideSearchInput && (
+                                            <div className={classes.searchBox}>
+                                                {!hideSearchInput && (
+                                                    <SearchInput
+                                                        label={searchLabel}
+                                                        value={searchValue}
+                                                        selfClearProp={""}
+                                                        onSearch={onSearch}
+                                                    />
+                                                )}
+                                            </div>
+                                        )}
+                                        {actions}
+                                        {!hideColumnSelector && (
+                                            <ColumnSelector
+                                                columns={columns}
+                                                onColumnToggle={onColumnToggle}
+                                            />
+                                        )}
+                                        {doUnMaximizePanel && panelMaximized && (
+                                            <Tooltip
+                                                title={`Unmaximize ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton onClick={doUnMaximizePanel}>
+                                                    <UnMaximizeIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                        {doMaximizePanel && !panelMaximized && (
+                                            <Tooltip
+                                                title={`Maximize ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton onClick={doMaximizePanel}>
+                                                    <MaximizeIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                        {doHidePanel && (
+                                            <Tooltip
+                                                title={`Close ${panelName || "panel"}`}
+                                                disableFocusListener
+                                            >
+                                                <IconButton
+                                                    disabled={panelMaximized}
+                                                    onClick={doHidePanel}
+                                                >
+                                                    <CloseIcon />
+                                                </IconButton>
+                                            </Tooltip>
+                                        )}
+                                    </Toolbar>
+                                    <MultiselectToolbar />
+                                </Grid>
+                            )}
+                        </div>
+                        <Grid
+                            item
+                            xs="auto"
+                            className={classes.dataTable}
+                        >
+                            <DataTable
+                                columns={this.props.contextMenuColumn ? [...columns, this.contextMenuColumn] : columns}
+                                items={items}
+                                onRowClick={(_, item: T) => onRowClick(item)}
+                                onContextMenu={onContextMenu}
+                                onRowDoubleClick={(_, item: T) => onRowDoubleClick(item)}
+                                onFiltersChange={onFiltersChange}
+                                onSortToggle={onSortToggle}
+                                extractKey={extractKey}
+                                working={this.state.showLoading}
+                                defaultViewIcon={defaultViewIcon}
+                                defaultViewMessages={defaultViewMessages}
+                                currentItemUuid={currentItemUuid}
+                                currentRoute={paperKey}
+                                toggleMSToolbar={toggleMSToolbar}
+                                setCheckedListOnStore={setCheckedListOnStore}
+                                checkedList={checkedList}
+                            />
+                        </Grid>
+                        <Grid
+                            item
+                            xs
+                        >
+                            <Toolbar className={classes.footer}>
+                                {elementPath && (
+                                    <Grid container>
+                                        <span data-cy="element-path">{elementPath}</span>
+                                    </Grid>
+                                )}
+                                <Grid
+                                    container={!elementPath}
+                                    justify="flex-end"
+                                >
+                                    {fetchMode === DataTableFetchMode.PAGINATED ? (
+                                        <TablePagination
+                                            count={itemsAvailable}
+                                            rowsPerPage={rowsPerPage}
+                                            rowsPerPageOptions={rowsPerPageOptions}
+                                            page={this.props.page}
+                                            onChangePage={this.changePage}
+                                            onChangeRowsPerPage={this.changeRowsPerPage}
+                                            // Disable next button on empty lists since that's not default behavior
+                                            nextIconButtonProps={itemsAvailable > 0 ? {} : { disabled: true }}
+                                            component="div"
+                                        />
+                                    ) : (
+                                        <Button
+                                            variant="text"
+                                            size="medium"
+                                            onClick={this.loadMore}
+                                        >
+                                            Load more
+                                        </Button>
+                                    )}
+                                </Grid>
+                            </Toolbar>
                         </Grid>
-                    }
-                    <Grid container={!elementPath} justify="flex-end">
-                        {fetchMode === DataTableFetchMode.PAGINATED ? <TablePagination
-                            count={itemsAvailable}
-                            rowsPerPage={rowsPerPage}
-                            rowsPerPageOptions={rowsPerPageOptions}
-                            page={this.props.page}
-                            onChangePage={this.changePage}
-                            onChangeRowsPerPage={this.changeRowsPerPage}
-                            // Disable next button on empty lists since that's not default behavior
-                            nextIconButtonProps={(itemsAvailable > 0) ? {} : {disabled: true}}
-                            component="div" /> : <Button
-                                variant="text"
-                                size="medium"
-                                onClick={this.loadMore}
-                            >Load more</Button>}
                     </Grid>
-                </Toolbar></Grid>
-                </Grid>
-            </Paper>;
+                </Paper>
+            );
         }
 
         changePage = (event: React.MouseEvent<HTMLButtonElement>, page: number) => {
             this.props.onChangePage(page);
-        }
+        };
 
-        changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = (event) => {
+        changeRowsPerPage: React.ChangeEventHandler<HTMLTextAreaElement | HTMLInputElement> = event => {
             this.props.onChangeRowsPerPage(parseInt(event.target.value, 10));
-        }
+        };
 
         loadMore = () => {
             this.props.onLoadMore(this.props.page + 1);
-        }
+        };
 
-        renderContextMenuTrigger = (item: T) =>
-            <Grid container justify="center">
-                <Tooltip title="More options" disableFocusListener>
-                    <IconButton className={this.props.classes.moreOptionsButton} onClick={event => this.props.onContextMenu(event, item)}>
+        renderContextMenuTrigger = (item: T) => (
+            <Grid
+                container
+                justify="center"
+            >
+                <Tooltip
+                    title="More options"
+                    disableFocusListener
+                >
+                    <IconButton
+                        className={this.props.classes.moreOptionsButton}
+                        onClick={event => this.props.onContextMenu(event, item)}
+                    >
                         <MoreVerticalIcon />
                     </IconButton>
                 </Tooltip>
             </Grid>
+        );
 
         contextMenuColumn: DataColumn<any, any> = {
             name: "Actions",
@@ -266,7 +382,7 @@ export const DataExplorer = withStyles(styles)(
             configurable: false,
             filters: createTree(),
             key: "context-actions",
-            render: this.renderContextMenuTrigger
+            render: this.renderContextMenuTrigger,
         };
     }
 );
index b51878664449b67a9f3a33b992fa9a0605820173..557abd825a004cf85c0a8fe1486f436940cfc79b 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React, { useEffect } from "react";
+import React, { useEffect } from 'react';
 import {
     WithStyles,
     withStyles,
@@ -16,28 +16,28 @@ import {
     Typography,
     CardContent,
     Tooltip,
-    IconButton
-} from "@material-ui/core";
-import classnames from "classnames";
-import { DefaultTransformOrigin } from "components/popover/helpers";
+    IconButton,
+} from '@material-ui/core';
+import classnames from 'classnames';
+import { DefaultTransformOrigin } from 'components/popover/helpers';
 import { createTree } from 'models/tree';
-import { DataTableFilters, DataTableFiltersTree } from "./data-table-filters-tree";
+import { DataTableFilters, DataTableFiltersTree } from './data-table-filters-tree';
 import { getNodeDescendants } from 'models/tree';
-import debounce from "lodash/debounce";
+import debounce from 'lodash/debounce';
 
-export type CssRules = "root" | "icon" | "iconButton" | "active" | "checkbox";
+export type CssRules = 'root' | 'icon' | 'iconButton' | 'active' | 'checkbox';
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     root: {
-        cursor: "pointer",
-        display: "inline-flex",
-        justifyContent: "flex-start",
-        flexDirection: "inherit",
-        alignItems: "center",
-        "&:hover": {
+        cursor: 'pointer',
+        display: 'inline-flex',
+        justifyContent: 'flex-start',
+        flexDirection: 'inherit',
+        alignItems: 'center',
+        '&:hover': {
             color: theme.palette.text.primary,
         },
-        "&:focus": {
+        '&:focus': {
             color: theme.palette.text.primary,
         },
     },
@@ -52,7 +52,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
         userSelect: 'none',
         width: 16,
         height: 15,
-        marginTop: 1
+        marginTop: 1,
     },
     iconButton: {
         color: theme.palette.text.primary,
@@ -60,13 +60,13 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     },
     checkbox: {
         width: 24,
-        height: 24
-    }
+        height: 24,
+    },
 });
 
 enum SelectionMode {
     ALL = 'all',
-    NONE = 'none'
+    NONE = 'none',
 }
 
 export interface DataTableFilterProps {
@@ -103,68 +103,52 @@ export const DataTableFiltersPopover = withStyles(styles)(
 
         render() {
             const { name, classes, defaultSelection = SelectionMode.ALL, children } = this.props;
-            const isActive = getNodeDescendants('')(this.state.filters)
-                .some(f => defaultSelection === SelectionMode.ALL
-                    ? !f.selected
-                    : f.selected
-                );
-            return <>
-                <Tooltip disableFocusListener title='Filters'>
-                    <ButtonBase
-                        className={classnames([classes.root, { [classes.active]: isActive }])}
-                        component="span"
-                        onClick={this.open}
-                        disableRipple>
-                        {children}
-                        <IconButton component='span' classes={{ root: classes.iconButton }} tabIndex={-1}>
-                            <i className={classnames(["fas fa-filter", classes.icon])}
-                                data-fa-transform="shrink-3"
-                                ref={this.icon} />
-                        </IconButton>
-                    </ButtonBase>
-                </Tooltip>
-                <Popover
-                    anchorEl={this.state.anchorEl}
-                    open={!!this.state.anchorEl}
-                    anchorOrigin={DefaultTransformOrigin}
-                    transformOrigin={DefaultTransformOrigin}
-                    onClose={this.close}>
-                    <Card>
-                        <CardContent>
-                            <Typography variant="caption">
-                                {name}
-                            </Typography>
-                        </CardContent>
-                        <DataTableFiltersTree
-                            filters={this.state.filters}
-                            mutuallyExclusive={this.props.mutuallyExclusive}
-                            onChange={this.onChange} />
-                        {this.props.mutuallyExclusive ||
-                        <CardActions>
-                            <Button
-                                color="primary"
-                                variant="outlined"
-                                size="small"
-                                onClick={this.close}>
-                                Close
-                            </Button>
-                        </CardActions >
-                        }
-                    </Card>
-                </Popover>
-                <this.MountHandler />
-            </>;
+            const isActive = getNodeDescendants('')(this.state.filters).some((f) => (defaultSelection === SelectionMode.ALL ? !f.selected : f.selected));
+            return (
+                <>
+                    <Tooltip disableFocusListener title='Filters'>
+                        <ButtonBase className={classnames([classes.root, { [classes.active]: isActive }])} component='span' onClick={this.open} disableRipple>
+                            {children}
+                            <IconButton component='span' classes={{ root: classes.iconButton }} tabIndex={-1}>
+                                <i className={classnames(['fas fa-filter', classes.icon])} data-fa-transform='shrink-3' ref={this.icon} />
+                            </IconButton>
+                        </ButtonBase>
+                    </Tooltip>
+                    <Popover
+                        anchorEl={this.state.anchorEl}
+                        open={!!this.state.anchorEl}
+                        anchorOrigin={DefaultTransformOrigin}
+                        transformOrigin={DefaultTransformOrigin}
+                        onClose={this.close}
+                    >
+                        <Card>
+                            <CardContent>
+                                <Typography variant='caption'>{name}</Typography>
+                            </CardContent>
+                            <DataTableFiltersTree filters={this.state.filters} mutuallyExclusive={this.props.mutuallyExclusive} onChange={this.onChange} />
+                            <>
+                                {this.props.mutuallyExclusive || (
+                                    <CardActions>
+                                        <Button color='primary' variant='outlined' size='small' onClick={this.close}>
+                                            Close
+                                        </Button>
+                                    </CardActions>
+                                )}
+                            </>
+                        </Card>
+                    </Popover>
+                    <this.MountHandler />
+                </>
+            );
         }
 
         static getDerivedStateFromProps(props: DataTableFilterProps, state: DataTableFilterState): DataTableFilterState {
-            return props.filters !== state.prevFilters
-                ? { ...state, filters: props.filters, prevFilters: props.filters }
-                : state;
+            return props.filters !== state.prevFilters ? { ...state, filters: props.filters, prevFilters: props.filters } : state;
         }
 
         open = () => {
             this.setState({ anchorEl: this.icon.current || undefined });
-        }
+        };
 
         onChange = (filters) => {
             this.setState({ filters });
@@ -179,9 +163,9 @@ export const DataTableFiltersPopover = withStyles(styles)(
                 // Non-mutually exclusive filters are debounced
                 this.submit();
             }
-        }
+        };
 
-        submit = debounce (() => {
+        submit = debounce(() => {
             const { onChange } = this.props;
             if (onChange) {
                 onChange(this.state.filters);
@@ -192,17 +176,16 @@ export const DataTableFiltersPopover = withStyles(styles)(
             useEffect(() => {
                 return () => {
                     this.submit.cancel();
-                }
-            },[]);
+                };
+            }, []);
             return null;
         };
 
         close = () => {
-            this.setState(prev => ({
+            this.setState((prev) => ({
                 ...prev,
-                anchorEl: undefined
+                anchorEl: undefined,
             }));
-        }
-
+        };
     }
 );
index 7b97865bba4b546085d6c90ab7cb68a7b5821e45..d52b58f5ae30ac6f09ed533a0e52e4213c13a8c7 100644 (file)
@@ -59,14 +59,14 @@ export class DataTableFiltersTree extends React.Component<DataTableFilterProps>
         if (item.selected) { return; }
 
         // Otherwise select this node and deselect the others
-        const filters = selectNode(item.id)(this.props.filters);
+        const filters = selectNode(item.id, true)(this.props.filters);
         const toDeselect = Object.keys(this.props.filters).filter((id) => (id !== item.id));
-        onChange(deselectNodes(toDeselect)(filters));
+        onChange(deselectNodes(toDeselect, true)(filters));
     }
 
     toggleFilter = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
         const { onChange = noop } = this.props;
-        onChange(toggleNodeSelection(item.id)(this.props.filters));
+        onChange(toggleNodeSelection(item.id, true)(this.props.filters));
     }
 
     toggleOpen = (_: React.MouseEvent, item: TreeItem<DataTableFilterItem>) => {
diff --git a/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx b/src/components/data-table-multiselect-popover/data-table-multiselect-popover.tsx
new file mode 100644 (file)
index 0000000..0248c82
--- /dev/null
@@ -0,0 +1,149 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { WithStyles, withStyles, ButtonBase, StyleRulesCallback, Theme, Popover, Card, Tooltip, IconButton } from "@material-ui/core";
+import classnames from "classnames";
+import { DefaultTransformOrigin } from "components/popover/helpers";
+import { grey } from "@material-ui/core/colors";
+import { TCheckedList } from "components/data-table/data-table";
+
+export type CssRules = "root" | "icon" | "iconButton" | "disabled" | "optionsContainer" | "option";
+
+const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
+    root: {
+        borderRadius: "7px",
+        "&:hover": {
+            backgroundColor: grey[200],
+        },
+        "&:focus": {
+            color: theme.palette.text.primary,
+        },
+    },
+    icon: {
+        cursor: "pointer",
+        fontSize: 20,
+        userSelect: "none",
+        "&:hover": {
+            color: theme.palette.text.primary,
+        },
+        paddingBottom: "5px",
+    },
+    iconButton: {
+        color: theme.palette.text.primary,
+        opacity: 0.6,
+        padding: 1,
+        paddingBottom: 5,
+    },
+    disabled: {
+        color: grey[500],
+    },
+    optionsContainer: {
+        padding: "1rem 0",
+        flex: 1,
+    },
+    option: {
+        cursor: "pointer",
+        display: "flex",
+        padding: "3px 2rem",
+        fontSize: "0.9rem",
+        alignItems: "center",
+        "&:hover": {
+            backgroundColor: "rgba(0, 0, 0, 0.08)",
+        },
+    },
+});
+
+export type DataTableMultiselectOption = {
+    name: string;
+    fn: (checkedList) => void;
+};
+
+export interface DataTableMultiselectProps {
+    name: string;
+    disabled: boolean;
+    options: DataTableMultiselectOption[];
+    checkedList: TCheckedList;
+}
+
+interface DataTableFMultiselectPopState {
+    anchorEl?: HTMLElement;
+}
+
+export const DataTableMultiselectPopover = withStyles(styles)(
+    class extends React.Component<DataTableMultiselectProps & WithStyles<CssRules>, DataTableFMultiselectPopState> {
+        state: DataTableFMultiselectPopState = {
+            anchorEl: undefined,
+        };
+        icon = React.createRef<HTMLElement>();
+
+        render() {
+            const { classes, children, options, checkedList, disabled } = this.props;
+            return (
+                <>
+                    <Tooltip
+                        disableFocusListener
+                        title="Select Options"
+                    >
+                        <ButtonBase
+                            className={classnames(classes.root)}
+                            component="span"
+                            onClick={disabled ? () => {} : this.open}
+                            disableRipple
+                        >
+                            {children}
+                            <IconButton
+                                component="span"
+                                classes={{ root: classes.iconButton }}
+                                tabIndex={-1}
+                            >
+                                <i
+                                    className={`${classnames(["fas fa-sort-down", classes.icon])}${disabled ? ` ${classes.disabled}` : ""}`}
+                                    data-fa-transform="shrink-3"
+                                    ref={this.icon}
+                                />
+                            </IconButton>
+                        </ButtonBase>
+                    </Tooltip>
+                    <Popover
+                        anchorEl={this.state.anchorEl}
+                        open={!!this.state.anchorEl}
+                        anchorOrigin={DefaultTransformOrigin}
+                        transformOrigin={DefaultTransformOrigin}
+                        onClose={this.close}
+                    >
+                        <Card>
+                            <div className={classes.optionsContainer}>
+                                {options.length &&
+                                    options.map((option, i) => (
+                                        <div
+                                            key={i}
+                                            className={classes.option}
+                                            onClick={() => {
+                                                option.fn(checkedList);
+                                                this.close();
+                                            }}
+                                        >
+                                            {option.name}
+                                        </div>
+                                    ))}
+                            </div>
+                        </Card>
+                    </Popover>
+                </>
+            );
+        }
+
+        open = () => {
+            this.setState({ anchorEl: this.icon.current || undefined });
+        };
+
+        close = () => {
+            this.setState(prev => ({
+                ...prev,
+                anchorEl: undefined,
+            }));
+        };
+    }
+);
index a72056d142aa110b09a43ffe0b82784d7a389e24..880868bdf8d54c4d0b24c198b07bcea7a66f3a0a 100644 (file)
@@ -4,13 +4,13 @@
 
 import React from "react";
 import { mount, configure } from "enzyme";
-import { pipe } from 'lodash/fp';
+import { pipe } from "lodash/fp";
 import { TableHead, TableCell, Typography, TableBody, Button, TableSortLabel } from "@material-ui/core";
 import Adapter from "enzyme-adapter-react-16";
 import { DataTable, DataColumns } from "./data-table";
 import { SortDirection, createDataColumn } from "./data-column";
-import { DataTableFiltersPopover } from 'components/data-table-filters/data-table-filters-popover';
-import { createTree, setNode, initTreeNode } from 'models/tree';
+import { DataTableFiltersPopover } from "components/data-table-filters/data-table-filters-popover";
+import { createTree, setNode, initTreeNode } from "models/tree";
 import { DataTableFilterItem } from "components/data-table-filters/data-table-filters-tree";
 
 configure({ adapter: new Adapter() });
@@ -22,30 +22,34 @@ describe("<DataTable />", () => {
                 name: "Column 1",
                 render: () => <span />,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
             createDataColumn({
                 name: "Column 2",
                 render: () => <span />,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
             createDataColumn({
                 name: "Column 3",
                 render: () => <span />,
                 selected: false,
-                configurable: true
+                configurable: true,
             }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={[{ key: "1", name: "item 1" }]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(2);
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[{ key: "1", name: "item 1" }]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell)).toHaveLength(3);
     });
 
     it("renders column name", () => {
@@ -54,18 +58,22 @@ describe("<DataTable />", () => {
                 name: "Column 1",
                 render: () => <span />,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={["item 1"]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column 1");
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell).last().text()).toBe("Column 1");
     });
 
     it("uses renderHeader instead of name prop", () => {
@@ -75,18 +83,22 @@ describe("<DataTable />", () => {
                 renderHeader: () => <span>Column Header</span>,
                 render: () => <span />,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={[]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableHead).find(TableCell).text()).toBe("Column Header");
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableHead).find(TableCell).last().text()).toBe("Column Header");
     });
 
     it("passes column key prop to corresponding cells", () => {
@@ -96,116 +108,137 @@ describe("<DataTable />", () => {
                 key: "column-1-key",
                 render: () => <span />,
                 selected: true,
-                configurable: true
-            })
+                configurable: true,
+            }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            working={false}
-            items={["item 1"]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableHead).find(TableCell).key()).toBe("column-1-key");
-        expect(dataTable.find(TableBody).find(TableCell).key()).toBe("column-1-key");
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                working={false}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableBody).find(TableCell).last().key()).toBe("column-1-key");
     });
 
     it("renders items", () => {
         const columns: DataColumns<string, string> = [
             createDataColumn({
                 name: "Column 1",
-                render: (item) => <Typography>{item}</Typography>,
+                render: item => <Typography>{item}</Typography>,
                 selected: true,
-                configurable: true
+                configurable: true,
             }),
             createDataColumn({
                 name: "Column 2",
-                render: (item) => <Button>{item}</Button>,
+                render: item => <Button>{item}</Button>,
                 selected: true,
-                configurable: true
-            })
+                configurable: true,
+            }),
         ];
-        const dataTable = mount(<DataTable
-            columns={columns}
-            working={false}
-            items={["item 1"]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={jest.fn()} />);
-        expect(dataTable.find(TableBody).find(Typography).text()).toBe("item 1");
-        expect(dataTable.find(TableBody).find(Button).text()).toBe("item 1");
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                working={false}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(TableBody).find(Typography).last().text()).toBe("item 1");
+        expect(dataTable.find(TableBody).find(Button).last().text()).toBe("item 1");
     });
 
     it("passes sorting props to <TableSortLabel />", () => {
         const columns: DataColumns<string, string> = [
             createDataColumn({
                 name: "Column 1",
-                sort: {direction: SortDirection.ASC, field: "length"},
+                sort: { direction: SortDirection.ASC, field: "length" },
                 selected: true,
                 configurable: true,
-                render: (item) => <Typography>{item}</Typography>
-            })];
+                render: item => <Typography>{item}</Typography>,
+            }),
+        ];
         const onSortToggle = jest.fn();
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={["item 1"]}
-            onFiltersChange={jest.fn()}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onContextMenu={jest.fn()}
-            onSortToggle={onSortToggle} />);
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={["item 1"]}
+                onFiltersChange={jest.fn()}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onContextMenu={jest.fn()}
+                onSortToggle={onSortToggle}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
         expect(dataTable.find(TableSortLabel).prop("active")).toBeTruthy();
         dataTable.find(TableSortLabel).at(0).simulate("click");
-        expect(onSortToggle).toHaveBeenCalledWith(columns[0]);
+        expect(onSortToggle).toHaveBeenCalledWith(columns[1]);
     });
 
     it("does not display <DataTableFiltersPopover /> if there is no filters provided", () => {
-        const columns: DataColumns<string, string> = [{
-            name: "Column 1",
-            selected: true,
-            configurable: true,
-            filters: [],
-            render: (item) => <Typography>{item}</Typography>
-        }];
+        const columns: DataColumns<string, string> = [
+            {
+                name: "Column 1",
+                selected: true,
+                configurable: true,
+                filters: [],
+                render: item => <Typography>{item}</Typography>,
+            },
+        ];
         const onFiltersChange = jest.fn();
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={[]}
-            onFiltersChange={onFiltersChange}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onSortToggle={jest.fn()}
-            onContextMenu={jest.fn()} />);
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={onFiltersChange}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onSortToggle={jest.fn()}
+                onContextMenu={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
         expect(dataTable.find(DataTableFiltersPopover)).toHaveLength(0);
     });
 
     it("passes filter props to <DataTableFiltersPopover />", () => {
-        const filters = pipe(
-            () => createTree<DataTableFilterItem>(),
-            setNode(initTreeNode({ id: 'filter', value: { name: 'filter' } }))
-        );
-        const columns: DataColumns<string, string> = [{
-            name: "Column 1",
-            selected: true,
-            configurable: true,
-            filters: filters(),
-            render: (item) => <Typography>{item}</Typography>
-        }];
+        const filters = pipe(() => createTree<DataTableFilterItem>(), setNode(initTreeNode({ id: "filter", value: { name: "filter" } })));
+        const columns: DataColumns<string, string> = [
+            {
+                name: "Column 1",
+                selected: true,
+                configurable: true,
+                filters: filters(),
+                render: item => <Typography>{item}</Typography>,
+            },
+        ];
         const onFiltersChange = jest.fn();
-        const dataTable = mount(<DataTable
-            columns={columns}
-            items={[]}
-            onFiltersChange={onFiltersChange}
-            onRowClick={jest.fn()}
-            onRowDoubleClick={jest.fn()}
-            onSortToggle={jest.fn()}
-            onContextMenu={jest.fn()} />);
-        expect(dataTable.find(DataTableFiltersPopover).prop("filters")).toBe(columns[0].filters);
+        const dataTable = mount(
+            <DataTable
+                columns={columns}
+                items={[]}
+                onFiltersChange={onFiltersChange}
+                onRowClick={jest.fn()}
+                onRowDoubleClick={jest.fn()}
+                onSortToggle={jest.fn()}
+                onContextMenu={jest.fn()}
+                setCheckedListOnStore={jest.fn()}
+            />
+        );
+        expect(dataTable.find(DataTableFiltersPopover).prop("filters")).toBe(columns[1].filters);
         dataTable.find(DataTableFiltersPopover).prop("onChange")([]);
-        expect(onFiltersChange).toHaveBeenCalledWith([], columns[0]);
+        expect(onFiltersChange).toHaveBeenCalledWith([], columns[1]);
     });
 });
index 4a82b6607c32ed44ecb77ea54bdd8a6cc1ab4a10..155d772f85855ddd86a90ecc7842065d543e830f 100644 (file)
@@ -2,23 +2,39 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { Table, TableBody, TableRow, TableCell, TableHead, TableSortLabel, StyleRulesCallback, Theme, WithStyles, withStyles, IconButton } from '@material-ui/core';
-import classnames from 'classnames';
-import { DataColumn, SortDirection } from './data-column';
-import { DataTableDefaultView } from '../data-table-default-view/data-table-default-view';
-import { DataTableFilters } from '../data-table-filters/data-table-filters-tree';
-import { DataTableFiltersPopover } from '../data-table-filters/data-table-filters-popover';
-import { countNodes, getTreeDirty } from 'models/tree';
-import { IconType, PendingIcon } from 'components/icon/icon';
-import { SvgIconProps } from '@material-ui/core/SvgIcon';
-import ArrowDownwardIcon from '@material-ui/icons/ArrowDownward';
+import React from "react";
+import {
+    Table,
+    TableBody,
+    TableRow,
+    TableCell,
+    TableHead,
+    TableSortLabel,
+    StyleRulesCallback,
+    Theme,
+    WithStyles,
+    withStyles,
+    IconButton,
+    Tooltip,
+} from "@material-ui/core";
+import classnames from "classnames";
+import { DataColumn, SortDirection } from "./data-column";
+import { DataTableDefaultView } from "../data-table-default-view/data-table-default-view";
+import { DataTableFilters } from "../data-table-filters/data-table-filters-tree";
+import { DataTableMultiselectPopover } from "../data-table-multiselect-popover/data-table-multiselect-popover";
+import { DataTableFiltersPopover } from "../data-table-filters/data-table-filters-popover";
+import { countNodes, getTreeDirty } from "models/tree";
+import { IconType, PendingIcon } from "components/icon/icon";
+import { SvgIconProps } from "@material-ui/core/SvgIcon";
+import ArrowDownwardIcon from "@material-ui/icons/ArrowDownward";
+import { createTree } from "models/tree";
+import { DataTableMultiselectOption } from "../data-table-multiselect-popover/data-table-multiselect-popover";
 
 export type DataColumns<I, R> = Array<DataColumn<I, R>>;
 
 export enum DataTableFetchMode {
     PAGINATED,
-    INFINITE
+    INFINITE,
 }
 
 export interface DataTableDataProps<I> {
@@ -35,154 +51,349 @@ export interface DataTableDataProps<I> {
     defaultViewMessages?: string[];
     currentItemUuid?: string;
     currentRoute?: string;
+    toggleMSToolbar: (isVisible: boolean) => void;
+    setCheckedListOnStore: (checkedList: TCheckedList) => void;
+    checkedList: TCheckedList;
 }
 
-type CssRules = "tableBody" | "root" | "content" | "noItemsInfo" | 'tableCell' | 'arrow' | 'arrowButton' | 'tableCellWorkflows' | 'loader';
+type CssRules =
+    | "tableBody"
+    | "root"
+    | "content"
+    | "noItemsInfo"
+    | "checkBoxHead"
+    | "checkBoxCell"
+    | "checkBox"
+    | "firstTableCell"
+    | "tableCell"
+    | "arrow"
+    | "arrowButton"
+    | "tableCellWorkflows"
+    | "loader";
 
 const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     root: {
-        width: '100%',
+        width: "100%",
     },
     content: {
-        display: 'inline-block',
-        width: '100%',
+        display: "inline-block",
+        width: "100%",
     },
     tableBody: {
-        background: theme.palette.background.paper
+        background: theme.palette.background.paper,
     },
     loader: {
-        left: '50%',
-        marginLeft: '-84px',
-        position: 'absolute'
+        left: "50%",
+        marginLeft: "-84px",
+        position: "absolute",
     },
     noItemsInfo: {
         textAlign: "center",
-        padding: theme.spacing.unit
+        padding: theme.spacing.unit,
+    },
+    checkBoxHead: {
+        padding: "0",
+        display: "flex",
+    },
+    checkBoxCell: {
+        padding: "0",
+        paddingLeft: "10px",
+    },
+    checkBox: {
+        cursor: "pointer",
     },
     tableCell: {
-        wordWrap: 'break-word',
-        paddingRight: '24px',
-        color: '#737373'
-
+        wordWrap: "break-word",
+        paddingRight: "24px",
+        color: "#737373",
+    },
+    firstTableCell: {
+        paddingLeft: "5px",
     },
     tableCellWorkflows: {
-        '&:nth-last-child(2)': {
-            padding: '0px',
-            maxWidth: '48px'
+        "&:nth-last-child(2)": {
+            padding: "0px",
+            maxWidth: "48px",
+        },
+        "&:last-child": {
+            padding: "0px",
+            paddingRight: "24px",
+            width: "48px",
         },
-        '&:last-child': {
-            padding: '0px',
-            paddingRight: '24px',
-            width: '48px'
-        }
     },
     arrow: {
-        margin: 0
+        margin: 0,
     },
     arrowButton: {
-        color: theme.palette.text.primary
-    }
+        color: theme.palette.text.primary,
+    },
 });
 
+export type TCheckedList = Record<string, boolean>;
+
+type DataTableState = {
+    isSelected: boolean;
+};
+
 type DataTableProps<T> = DataTableDataProps<T> & WithStyles<CssRules>;
 
 export const DataTable = withStyles(styles)(
     class Component<T> extends React.Component<DataTableProps<T>> {
+        state: DataTableState = {
+            isSelected: false,
+        };
+
+        componentDidMount(): void {
+            this.initializeCheckedList(this.props.items);
+        }
+
+        componentDidUpdate(prevProps: Readonly<DataTableProps<T>>, prevState: DataTableState) {
+            const { items, setCheckedListOnStore } = this.props;
+            const { isSelected } = this.state;
+            if (prevProps.items !== items) {
+                if (isSelected === true) this.setState({ isSelected: false });
+                if (items.length) this.initializeCheckedList(items);
+                else setCheckedListOnStore({});
+            }
+        }
+
+        checkBoxColumn: DataColumn<any, any> = {
+            name: "checkBoxColumn",
+            selected: true,
+            configurable: false,
+            filters: createTree(),
+            render: uuid => {
+                const { classes, checkedList } = this.props;
+                return (
+                    <input
+                        type="checkbox"
+                        name={uuid}
+                        className={classes.checkBox}
+                        checked={checkedList && checkedList[uuid] ? checkedList[uuid] : false}
+                        onChange={() => this.handleSelectOne(uuid)}
+                        onDoubleClick={ev => ev.stopPropagation()}></input>
+                );
+            },
+        };
+
+        multiselectOptions: DataTableMultiselectOption[] = [
+            { name: "All", fn: list => this.handleSelectAll(list) },
+            { name: "None", fn: list => this.handleSelectNone(list) },
+            { name: "Invert", fn: list => this.handleInvertSelect(list) },
+        ];
+
+        initializeCheckedList = (uuids: any[]): void => {
+            const newCheckedList = { ...this.props.checkedList };
+
+            uuids.forEach(uuid => {
+                if (!newCheckedList.hasOwnProperty(uuid)) {
+                    newCheckedList[uuid] = false;
+                }
+            });
+            for (const key in newCheckedList) {
+                if (!uuids.includes(key)) {
+                    delete newCheckedList[key];
+                }
+            }
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        isAllSelected = (list: TCheckedList): boolean => {
+            for (const key in list) {
+                if (list[key] === false) return false;
+            }
+            return true;
+        };
+
+        isAnySelected = (): boolean => {
+            const { checkedList } = this.props;
+            if (!Object.keys(checkedList).length) return false;
+            for (const key in checkedList) {
+                if (checkedList[key] === true) return true;
+            }
+            return false;
+        };
+
+        handleSelectOne = (uuid: string): void => {
+            const { checkedList } = this.props;
+            const newCheckedList = { ...checkedList };
+            newCheckedList[uuid] = !checkedList[uuid];
+            this.setState({ isSelected: this.isAllSelected(newCheckedList) });
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        handleSelectorSelect = (): void => {
+            const { checkedList } = this.props;
+            const { isSelected } = this.state;
+            isSelected ? this.handleSelectNone(checkedList) : this.handleSelectAll(checkedList);
+        };
+
+        handleSelectAll = (list: TCheckedList): void => {
+            if (Object.keys(list).length) {
+                const newCheckedList = { ...list };
+                for (const key in newCheckedList) {
+                    newCheckedList[key] = true;
+                }
+                this.setState({ isSelected: true });
+                this.props.setCheckedListOnStore(newCheckedList);
+            }
+        };
+
+        handleSelectNone = (list: TCheckedList): void => {
+            const newCheckedList = { ...list };
+            for (const key in newCheckedList) {
+                newCheckedList[key] = false;
+            }
+            this.setState({ isSelected: false });
+            this.props.setCheckedListOnStore(newCheckedList);
+        };
+
+        handleInvertSelect = (list: TCheckedList): void => {
+            if (Object.keys(list).length) {
+                const newCheckedList = { ...list };
+                for (const key in newCheckedList) {
+                    newCheckedList[key] = !list[key];
+                }
+                this.setState({ isSelected: this.isAllSelected(newCheckedList) });
+                this.props.setCheckedListOnStore(newCheckedList);
+            }
+        };
+
         render() {
-            const { items, classes, working } = this.props;
-            return <div className={classes.root}>
-                <div className={classes.content}>
-                    <Table>
-                        <TableHead>
-                            <TableRow>
-                                {this.mapVisibleColumns(this.renderHeadCell)}
-                            </TableRow>
-                        </TableHead>
-                        <TableBody className={classes.tableBody}>
-                            { !working && items.map(this.renderBodyRow) }
-                        </TableBody>
-                    </Table>
-                    { !!working &&
-                        <div className={classes.loader}>
-                            <DataTableDefaultView
-                                icon={PendingIcon}
-                                messages={['Loading data, please wait.']} />
-                        </div> }
-                    {items.length === 0 && !working && this.renderNoItemsPlaceholder(this.props.columns)}
+            const { items, classes, working, columns } = this.props;
+            if (columns[0].name === this.checkBoxColumn.name) columns.shift();
+            columns.unshift(this.checkBoxColumn);
+            return (
+                <div className={classes.root}>
+                    <div className={classes.content}>
+                        <Table>
+                            <TableHead>
+                                <TableRow>{this.mapVisibleColumns(this.renderHeadCell)}</TableRow>
+                            </TableHead>
+                            <TableBody className={classes.tableBody}>{!working && items.map(this.renderBodyRow)}</TableBody>
+                        </Table>
+                        {!!working && (
+                            <div className={classes.loader}>
+                                <DataTableDefaultView
+                                    icon={PendingIcon}
+                                    messages={["Loading data, please wait."]}
+                                />
+                            </div>
+                        )}
+                        {items.length === 0 && !working && this.renderNoItemsPlaceholder(this.props.columns)}
+                    </div>
                 </div>
-            </div>;
+            );
         }
 
         renderNoItemsPlaceholder = (columns: DataColumns<T, any>) => {
-            const dirty = columns.some((column) => getTreeDirty('')(column.filters));
-            return <DataTableDefaultView
-                icon={this.props.defaultViewIcon}
-                messages={this.props.defaultViewMessages}
-                filtersApplied={dirty} />;
-        }
+            const dirty = columns.some(column => getTreeDirty("")(column.filters));
+            return (
+                <DataTableDefaultView
+                    icon={this.props.defaultViewIcon}
+                    messages={this.props.defaultViewMessages}
+                    filtersApplied={dirty}
+                />
+            );
+        };
 
         renderHeadCell = (column: DataColumn<T, any>, index: number) => {
             const { name, key, renderHeader, filters, sort } = column;
-            const { onSortToggle, onFiltersChange, classes } = this.props;
-            return <TableCell className={classes.tableCell} key={key || index}>
-                {renderHeader ?
-                    renderHeader() :
-                    countNodes(filters) > 0
-                        ? <DataTableFiltersPopover
+            const { onSortToggle, onFiltersChange, classes, checkedList } = this.props;
+            const { isSelected } = this.state;
+            return column.name === "checkBoxColumn" ? (
+                <TableCell
+                    key={key || index}
+                    className={classes.checkBoxCell}>
+                    <div className={classes.checkBoxHead}>
+                        <Tooltip title={this.state.isSelected ? "Deselect All" : "Select All"}>
+                            <input
+                                type="checkbox"
+                                className={classes.checkBox}
+                                checked={isSelected}
+                                disabled={!this.props.items.length}
+                                onChange={this.handleSelectorSelect}></input>
+                        </Tooltip>
+                        <DataTableMultiselectPopover
+                            name={`Options`}
+                            disabled={!this.props.items.length}
+                            options={this.multiselectOptions}
+                            checkedList={checkedList}></DataTableMultiselectPopover>
+                    </div>
+                </TableCell>
+            ) : (
+                <TableCell
+                    className={index === 1 ? classes.firstTableCell : classes.tableCell}
+                    key={key || index}>
+                    {renderHeader ? (
+                        renderHeader()
+                    ) : countNodes(filters) > 0 ? (
+                        <DataTableFiltersPopover
                             name={`${name} filters`}
                             mutuallyExclusive={column.mutuallyExclusiveFilters}
-                            onChange={filters =>
-                                onFiltersChange &&
-                                onFiltersChange(filters, column)}
+                            onChange={filters => onFiltersChange && onFiltersChange(filters, column)}
                             filters={filters}>
                             {name}
                         </DataTableFiltersPopover>
-                        : sort
-                            ? <TableSortLabel
-                                active={sort.direction !== SortDirection.NONE}
-                                direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
-                                IconComponent={this.ArrowIcon}
-                                hideSortIcon
-                                onClick={() =>
-                                    onSortToggle &&
-                                    onSortToggle(column)}>
-                                {name}
-                            </TableSortLabel>
-                            : <span>
-                                {name}
-                            </span>}
-            </TableCell>;
-        }
+                    ) : sort ? (
+                        <TableSortLabel
+                            active={sort.direction !== SortDirection.NONE}
+                            direction={sort.direction !== SortDirection.NONE ? sort.direction : undefined}
+                            IconComponent={this.ArrowIcon}
+                            hideSortIcon
+                            onClick={() => onSortToggle && onSortToggle(column)}>
+                            {name}
+                        </TableSortLabel>
+                    ) : (
+                        <span>{name}</span>
+                    )}
+                </TableCell>
+            );
+        };
 
         ArrowIcon = ({ className, ...props }: SvgIconProps) => (
-            <IconButton component='span' className={this.props.classes.arrowButton} tabIndex={-1}>
-                <ArrowDownwardIcon {...props} className={classnames(className, this.props.classes.arrow)} />
+            <IconButton
+                component="span"
+                className={this.props.classes.arrowButton}
+                tabIndex={-1}>
+                <ArrowDownwardIcon
+                    {...props}
+                    className={classnames(className, this.props.classes.arrow)}
+                />
             </IconButton>
-        )
+        );
 
         renderBodyRow = (item: any, index: number) => {
             const { onRowClick, onRowDoubleClick, extractKey, classes, currentItemUuid, currentRoute } = this.props;
-            return <TableRow
-                hover
-                key={extractKey ? extractKey(item) : index}
-                onClick={event => onRowClick && onRowClick(event, item)}
-                onContextMenu={this.handleRowContextMenu(item)}
-                onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
-                selected={item === currentItemUuid}>
-                {this.mapVisibleColumns((column, index) => <TableCell key={column.key || index} className={currentRoute === '/workflows' ? classes.tableCellWorkflows : classes.tableCell}>
-                        {column.render(item)}
-                    </TableCell>
-                )}
-            </TableRow>;
-        }
+            return (
+                <TableRow
+                    hover
+                    key={extractKey ? extractKey(item) : index}
+                    onClick={event => onRowClick && onRowClick(event, item)}
+                    onContextMenu={this.handleRowContextMenu(item)}
+                    onDoubleClick={event => onRowDoubleClick && onRowDoubleClick(event, item)}
+                    selected={item === currentItemUuid}>
+                    {this.mapVisibleColumns((column, index) => (
+                        <TableCell
+                            key={column.key || index}
+                            className={
+                                currentRoute === "/workflows"
+                                    ? classes.tableCellWorkflows
+                                    : index === 0
+                                    ? classes.checkBoxCell
+                                    : `${classes.tableCell} ${index === 1 ? classes.firstTableCell : ""}`
+                            }>
+                            {column.render(item)}
+                        </TableCell>
+                    ))}
+                </TableRow>
+            );
+        };
 
         mapVisibleColumns = (fn: (column: DataColumn<T, any>, index: number) => React.ReactElement<any>) => {
             return this.props.columns.filter(column => column.selected).map(fn);
-        }
-
-        handleRowContextMenu = (item: T) =>
-            (event: React.MouseEvent<HTMLElement>) =>
-                this.props.onContextMenu(event, item)
+        };
 
+        handleRowContextMenu = (item: T) => (event: React.MouseEvent<HTMLElement>) => this.props.onContextMenu(event, item);
     }
 );
index 65ef3b8b0d4f41a2e19b072cf518adc942f1c792..5f3f4e659b5f3491cbe4f76c7b3154d826fa38cd 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { Badge, SvgIcon, Tooltip } from '@material-ui/core';
-import Add from '@material-ui/icons/Add';
-import ArrowBack from '@material-ui/icons/ArrowBack';
-import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
-import Build from '@material-ui/icons/Build';
-import Cached from '@material-ui/icons/Cached';
-import DescriptionIcon from '@material-ui/icons/Description';
-import ChevronLeft from '@material-ui/icons/ChevronLeft';
-import CloudUpload from '@material-ui/icons/CloudUpload';
-import Code from '@material-ui/icons/Code';
-import Create from '@material-ui/icons/Create';
-import ImportContacts from '@material-ui/icons/ImportContacts';
-import ChevronRight from '@material-ui/icons/ChevronRight';
-import Close from '@material-ui/icons/Close';
-import ContentCopy from '@material-ui/icons/FileCopyOutlined';
-import FileCopy from '@material-ui/icons/FileCopy';
-import CreateNewFolder from '@material-ui/icons/CreateNewFolder';
-import Delete from '@material-ui/icons/Delete';
-import DeviceHub from '@material-ui/icons/DeviceHub';
-import Edit from '@material-ui/icons/Edit';
-import ErrorRoundedIcon from '@material-ui/icons/ErrorRounded';
-import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
-import FlipToFront from '@material-ui/icons/FlipToFront';
-import Folder from '@material-ui/icons/Folder';
-import FolderShared from '@material-ui/icons/FolderShared';
-import Pageview from '@material-ui/icons/Pageview';
-import GetApp from '@material-ui/icons/GetApp';
-import Help from '@material-ui/icons/Help';
-import HelpOutline from '@material-ui/icons/HelpOutline';
-import History from '@material-ui/icons/History';
-import Inbox from '@material-ui/icons/Inbox';
-import Memory from '@material-ui/icons/Memory';
-import MoveToInbox from '@material-ui/icons/MoveToInbox';
-import Info from '@material-ui/icons/Info';
-import Input from '@material-ui/icons/Input';
-import InsertDriveFile from '@material-ui/icons/InsertDriveFile';
-import LastPage from '@material-ui/icons/LastPage';
-import LibraryBooks from '@material-ui/icons/LibraryBooks';
-import ListAlt from '@material-ui/icons/ListAlt';
-import Menu from '@material-ui/icons/Menu';
-import MoreVert from '@material-ui/icons/MoreVert';
-import MoreHoriz from '@material-ui/icons/MoreHoriz';
-import Mail from '@material-ui/icons/Mail';
-import Notifications from '@material-ui/icons/Notifications';
-import OpenInNew from '@material-ui/icons/OpenInNew';
-import People from '@material-ui/icons/People';
-import Person from '@material-ui/icons/Person';
-import PersonAdd from '@material-ui/icons/PersonAdd';
-import PlayArrow from '@material-ui/icons/PlayArrow';
-import Public from '@material-ui/icons/Public';
-import RateReview from '@material-ui/icons/RateReview';
-import RestoreFromTrash from '@material-ui/icons/History';
-import Search from '@material-ui/icons/Search';
-import SettingsApplications from '@material-ui/icons/SettingsApplications';
-import SettingsEthernet from '@material-ui/icons/SettingsEthernet';
-import Settings from '@material-ui/icons/Settings';
-import Star from '@material-ui/icons/Star';
-import StarBorder from '@material-ui/icons/StarBorder';
-import Warning from '@material-ui/icons/Warning';
-import VpnKey from '@material-ui/icons/VpnKey';
-import LinkOutlined from '@material-ui/icons/LinkOutlined';
-import RemoveRedEye from '@material-ui/icons/RemoveRedEye';
-import Computer from '@material-ui/icons/Computer';
-import WrapText from '@material-ui/icons/WrapText';
-import TextIncrease from '@material-ui/icons/ZoomIn';
-import TextDecrease from '@material-ui/icons/ZoomOut';
-import FullscreenSharp from '@material-ui/icons/FullscreenSharp';
-import FullscreenExitSharp from '@material-ui/icons/FullscreenExitSharp';
-import ExitToApp from '@material-ui/icons/ExitToApp';
-import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
-import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline';
-import NotInterested from '@material-ui/icons/NotInterested';
-import Image from '@material-ui/icons/Image';
-import Stop from '@material-ui/icons/Stop';
+import React from "react";
+import { Badge, SvgIcon, Tooltip } from "@material-ui/core";
+import Add from "@material-ui/icons/Add";
+import ArrowBack from "@material-ui/icons/ArrowBack";
+import ArrowDropDown from "@material-ui/icons/ArrowDropDown";
+import Build from "@material-ui/icons/Build";
+import Cached from "@material-ui/icons/Cached";
+import DescriptionIcon from "@material-ui/icons/Description";
+import ChevronLeft from "@material-ui/icons/ChevronLeft";
+import CloudUpload from "@material-ui/icons/CloudUpload";
+import Code from "@material-ui/icons/Code";
+import Create from "@material-ui/icons/Create";
+import ImportContacts from "@material-ui/icons/ImportContacts";
+import ChevronRight from "@material-ui/icons/ChevronRight";
+import Close from "@material-ui/icons/Close";
+import ContentCopy from "@material-ui/icons/FileCopyOutlined";
+import CreateNewFolder from "@material-ui/icons/CreateNewFolder";
+import Delete from "@material-ui/icons/Delete";
+import DeviceHub from "@material-ui/icons/DeviceHub";
+import Edit from "@material-ui/icons/Edit";
+import ErrorRoundedIcon from "@material-ui/icons/ErrorRounded";
+import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
+import FlipToFront from "@material-ui/icons/FlipToFront";
+import Folder from "@material-ui/icons/Folder";
+import FolderShared from "@material-ui/icons/FolderShared";
+import Pageview from "@material-ui/icons/Pageview";
+import GetApp from "@material-ui/icons/GetApp";
+import Help from "@material-ui/icons/Help";
+import HelpOutline from "@material-ui/icons/HelpOutline";
+import History from "@material-ui/icons/History";
+import Inbox from "@material-ui/icons/Inbox";
+import Memory from "@material-ui/icons/Memory";
+import MoveToInbox from "@material-ui/icons/MoveToInbox";
+import Info from "@material-ui/icons/Info";
+import Input from "@material-ui/icons/Input";
+import InsertDriveFile from "@material-ui/icons/InsertDriveFile";
+import LastPage from "@material-ui/icons/LastPage";
+import LibraryBooks from "@material-ui/icons/LibraryBooks";
+import ListAlt from "@material-ui/icons/ListAlt";
+import Menu from "@material-ui/icons/Menu";
+import MoreVert from "@material-ui/icons/MoreVert";
+import MoreHoriz from "@material-ui/icons/MoreHoriz";
+import Mail from "@material-ui/icons/Mail";
+import Notifications from "@material-ui/icons/Notifications";
+import OpenInNew from "@material-ui/icons/OpenInNew";
+import People from "@material-ui/icons/People";
+import Person from "@material-ui/icons/Person";
+import PersonAdd from "@material-ui/icons/PersonAdd";
+import PlayArrow from "@material-ui/icons/PlayArrow";
+import Public from "@material-ui/icons/Public";
+import RateReview from "@material-ui/icons/RateReview";
+import RestoreFromTrash from "@material-ui/icons/History";
+import Search from "@material-ui/icons/Search";
+import SettingsApplications from "@material-ui/icons/SettingsApplications";
+import SettingsEthernet from "@material-ui/icons/SettingsEthernet";
+import Settings from "@material-ui/icons/Settings";
+import Star from "@material-ui/icons/Star";
+import StarBorder from "@material-ui/icons/StarBorder";
+import Warning from "@material-ui/icons/Warning";
+import VpnKey from "@material-ui/icons/VpnKey";
+import LinkOutlined from "@material-ui/icons/LinkOutlined";
+import RemoveRedEye from "@material-ui/icons/RemoveRedEye";
+import Computer from "@material-ui/icons/Computer";
+import WrapText from "@material-ui/icons/WrapText";
+import TextIncrease from "@material-ui/icons/ZoomIn";
+import TextDecrease from "@material-ui/icons/ZoomOut";
+import FullscreenSharp from "@material-ui/icons/FullscreenSharp";
+import FullscreenExitSharp from "@material-ui/icons/FullscreenExitSharp";
+import ExitToApp from "@material-ui/icons/ExitToApp";
+import CheckCircleOutline from "@material-ui/icons/CheckCircleOutline";
+import RemoveCircleOutline from "@material-ui/icons/RemoveCircleOutline";
+import NotInterested from "@material-ui/icons/NotInterested";
+import Image from "@material-ui/icons/Image";
+import Stop from "@material-ui/icons/Stop";
+import FileCopy from "@material-ui/icons/FileCopy";
 
 // Import FontAwesome icons
-import { library } from '@fortawesome/fontawesome-svg-core';
-import { faPencilAlt, faSlash, faUsers, faEllipsisH } from '@fortawesome/free-solid-svg-icons';
-import { FormatAlignLeft } from '@material-ui/icons';
-library.add(
-    faPencilAlt,
-    faSlash,
-    faUsers,
-    faEllipsisH,
-);
+import { library } from "@fortawesome/fontawesome-svg-core";
+import { faPencilAlt, faSlash, faUsers, faEllipsisH } from "@fortawesome/free-solid-svg-icons";
+import { FormatAlignLeft } from "@material-ui/icons";
+library.add(faPencilAlt, faSlash, faUsers, faEllipsisH);
 
-export const FreezeIcon = (props: any) =>
+export const FreezeIcon = (props: any) => (
     <SvgIcon {...props}>
         <path d="M20.79,13.95L18.46,14.57L16.46,13.44V10.56L18.46,9.43L20.79,10.05L21.31,8.12L19.54,7.65L20,5.88L18.07,5.36L17.45,7.69L15.45,8.82L13,7.38V5.12L14.71,3.41L13.29,2L12,3.29L10.71,2L9.29,3.41L11,5.12V7.38L8.5,8.82L6.5,7.69L5.92,5.36L4,5.88L4.47,7.65L2.7,8.12L3.22,10.05L5.55,9.43L7.55,10.56V13.45L5.55,14.58L3.22,13.96L2.7,15.89L4.47,16.36L4,18.12L5.93,18.64L6.55,16.31L8.55,15.18L11,16.62V18.88L9.29,20.59L10.71,22L12,20.71L13.29,22L14.7,20.59L13,18.88V16.62L15.5,15.17L17.5,16.3L18.12,18.63L20,18.12L19.53,16.35L21.3,15.88L20.79,13.95M9.5,10.56L12,9.11L14.5,10.56V13.44L12,14.89L9.5,13.44V10.56Z" />
     </SvgIcon>
+);
 
-export const UnfreezeIcon = (props: any) =>
+export const UnfreezeIcon = (props: any) => (
     <SvgIcon {...props}>
         <path d="M11 5.12L9.29 3.41L10.71 2L12 3.29L13.29 2L14.71 3.41L13 5.12V7.38L15.45 8.82L17.45 7.69L18.07 5.36L20 5.88L19.54 7.65L21.31 8.12L20.79 10.05L18.46 9.43L16.46 10.56V13.26L14.5 11.3V10.56L12.74 9.54L10.73 7.53L11 7.38V5.12M18.46 14.57L16.87 13.67L19.55 16.35L21.3 15.88L20.79 13.95L18.46 14.57M13 16.62V18.88L14.7 20.59L13.29 22L12 20.71L10.71 22L9.29 20.59L11 18.88V16.62L8.55 15.18L6.55 16.31L5.93 18.64L4 18.12L4.47 16.36L2.7 15.89L3.22 13.96L5.55 14.58L7.55 13.45V10.56L5.55 9.43L3.22 10.05L2.7 8.12L4.47 7.65L4 5.89L1.11 3L2.39 1.73L22.11 21.46L20.84 22.73L14.1 16L13 16.62M12 14.89L12.63 14.5L9.5 11.39V13.44L12 14.89Z" />
     </SvgIcon>
+);
 
-export const PendingIcon = (props: any) =>
+export const PendingIcon = (props: any) => (
     <span {...props}>
-        <span className='fas fa-ellipsis-h' />
+        <span className="fas fa-ellipsis-h" />
     </span>
+);
 
-export const ReadOnlyIcon = (props: any) =>
+export const ReadOnlyIcon = (props: any) => (
     <span {...props}>
         <div className="fa-layers fa-1x fa-fw">
-            <span className="fas fa-slash"
-                data-fa-mask="fas fa-pencil-alt" data-fa-transform="down-1.5" />
+            <span
+                className="fas fa-slash"
+                data-fa-mask="fas fa-pencil-alt"
+                data-fa-transform="down-1.5"
+            />
             <span className="fas fa-slash" />
         </div>
-    </span>;
+    </span>
+);
 
-export const GroupsIcon = (props: any) =>
+export const GroupsIcon = (props: any) => (
     <span {...props}>
         <span className="fas fa-users" />
-    </span>;
+    </span>
+);
 
-export const CollectionOldVersionIcon = (props: any) =>
-    <Tooltip title='Old version'>
-        <Badge badgeContent={<History fontSize='small' />}>
+export const CollectionOldVersionIcon = (props: any) => (
+    <Tooltip title="Old version">
+        <Badge badgeContent={<History fontSize="small" />}>
             <CollectionIcon {...props} />
         </Badge>
-    </Tooltip>;
+    </Tooltip>
+);
 
 // https://materialdesignicons.com/icon/image-off
-export const ImageOffIcon = (props: any) =>
+export const ImageOffIcon = (props: any) => (
     <SvgIcon {...props}>
         <path d="M21 17.2L6.8 3H19C20.1 3 21 3.9 21 5V17.2M20.7 22L19.7 21H5C3.9 21 3 20.1 3 19V4.3L2 3.3L3.3 2L22 20.7L20.7 22M16.8 18L12.9 14.1L11 16.5L8.5 13.5L5 18H16.8Z" />
-    </SvgIcon>;
+    </SvgIcon>
+);
 
 // https://materialdesignicons.com/icon/inbox-arrow-up
-export const OutputIcon: IconType = (props: any) =>
+export const OutputIcon: IconType = (props: any) => (
     <SvgIcon {...props}>
         <path d="M14,14H10V11H8L12,7L16,11H14V14M16,11M5,15V5H19V15H15A3,3 0 0,1 12,18A3,3 0 0,1 9,15H5M19,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3" />
-    </SvgIcon>;
+    </SvgIcon>
+);
 
 // https://pictogrammers.com/library/mdi/icon/file-move/
-export const FileMoveIcon: IconType = (props: any) =>
+export const FileMoveIcon: IconType = (props: any) => (
     <SvgIcon {...props}>
         <path d="M14,17H18V14L23,18.5L18,23V20H14V17M13,9H18.5L13,3.5V9M6,2H14L20,8V12.34C19.37,12.12 18.7,12 18,12A6,6 0 0,0 12,18C12,19.54 12.58,20.94 13.53,22H6C4.89,22 4,21.1 4,20V4A2,2 0 0,1 6,2Z" />
-    </SvgIcon>;
+    </SvgIcon>
+);
 
 // https://pictogrammers.com/library/mdi/icon/checkbox-multiple-outline/
-export const CheckboxMultipleOutline: IconType = (props: any) =>
+export const CheckboxMultipleOutline: IconType = (props: any) => (
     <SvgIcon {...props}>
         <path d="M20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16V4A2,2 0 0,0 20,2M20,16H8V4H20V16M16,20V22H4A2,2 0 0,1 2,20V7H4V20H16M18.53,8.06L17.47,7L12.59,11.88L10.47,9.76L9.41,10.82L12.59,14L18.53,8.06Z" />
-    </SvgIcon>;
+    </SvgIcon>
+);
 
 // https://pictogrammers.com/library/mdi/icon/checkbox-multiple-blank-outline/
-export const CheckboxMultipleBlankOutline: IconType = (props: any) =>
+export const CheckboxMultipleBlankOutline: IconType = (props: any) => (
     <SvgIcon {...props}>
         <path d="M20,16V4H8V16H20M22,16A2,2 0 0,1 20,18H8C6.89,18 6,17.1 6,16V4C6,2.89 6.89,2 8,2H20A2,2 0 0,1 22,4V16M16,20V22H4A2,2 0 0,1 2,20V7H4V20H16Z" />
-    </SvgIcon>;
+    </SvgIcon>
+);
 
-export type IconType = React.SFC<{ className?: string, style?: object }>;
+export type IconType = React.SFC<{ className?: string; style?: object }>;
 
-// https://v4.mui.com/components/material-icons/
-export const AddIcon: IconType = (props) => <Add {...props} />;
-export const AddFavoriteIcon: IconType = (props) => <StarBorder {...props} />;
-export const AdminMenuIcon: IconType = (props) => <Build {...props} />;
-export const AdvancedIcon: IconType = (props) => <SettingsApplications {...props} />;
-export const AttributesIcon: IconType = (props) => <ListAlt {...props} />;
-export const BackIcon: IconType = (props) => <ArrowBack {...props} />;
-export const CustomizeTableIcon: IconType = (props) => <Menu {...props} />;
-export const CommandIcon: IconType = (props) => <LastPage {...props} />;
-export const CopyIcon: IconType = (props) => <ContentCopy {...props} />;
-export const FileCopyIcon: IconType = (props) => <FileCopy {...props} />;
-export const CollectionIcon: IconType = (props) => <LibraryBooks {...props} />;
-export const CloseIcon: IconType = (props) => <Close {...props} />;
-export const CloudUploadIcon: IconType = (props) => <CloudUpload {...props} />;
-export const DefaultIcon: IconType = (props) => <RateReview {...props} />;
-export const DetailsIcon: IconType = (props) => <Info {...props} />;
-export const DirectoryIcon: IconType = (props) => <Folder {...props} />;
-export const DownloadIcon: IconType = (props) => <GetApp {...props} />;
-export const EditSavedQueryIcon: IconType = (props) => <Create {...props} />;
-export const ExpandIcon: IconType = (props) => <ExpandMoreIcon {...props} />;
-export const ErrorIcon: IconType = (props) => <ErrorRoundedIcon style={{ color: '#ff0000' }} {...props} />;
-export const FavoriteIcon: IconType = (props) => <Star {...props} />;
-export const FileIcon: IconType = (props) => <DescriptionIcon {...props} />;
-export const HelpIcon: IconType = (props) => <Help {...props} />;
-export const HelpOutlineIcon: IconType = (props) => <HelpOutline {...props} />;
-export const ImportContactsIcon: IconType = (props) => <ImportContacts {...props} />;
-export const InfoIcon: IconType = (props) => <Info {...props} />;
-export const FileInputIcon: IconType = (props) => <InsertDriveFile {...props} />;
-export const KeyIcon: IconType = (props) => <VpnKey {...props} />;
-export const LogIcon: IconType = (props) => <SettingsEthernet {...props} />;
-export const MailIcon: IconType = (props) => <Mail {...props} />;
-export const MaximizeIcon: IconType = (props) => <FullscreenSharp {...props} />;
-export const MemoryIcon: IconType = (props) => <Memory {...props} />;
-export const UnMaximizeIcon: IconType = (props) => <FullscreenExitSharp {...props} />;
-export const MoreVerticalIcon: IconType = (props) => <MoreVert {...props} />;
-export const MoreHorizontalIcon: IconType = (props) => <MoreHoriz {...props} />;
-export const MoveToIcon: IconType = (props) => <Input {...props} />;
-export const NewProjectIcon: IconType = (props) => <CreateNewFolder {...props} />;
-export const NotificationIcon: IconType = (props) => <Notifications {...props} />;
-export const OpenIcon: IconType = (props) => <OpenInNew {...props} />;
-export const InputIcon: IconType = (props) => <MoveToInbox {...props} />;
-export const PaginationDownIcon: IconType = (props) => <ArrowDropDown {...props} />;
-export const PaginationLeftArrowIcon: IconType = (props) => <ChevronLeft {...props} />;
-export const PaginationRightArrowIcon: IconType = (props) => <ChevronRight {...props} />;
-export const ProcessIcon: IconType = (props) => <Settings {...props} />;
-export const ProjectIcon: IconType = (props) => <Folder {...props} />;
-export const FilterGroupIcon: IconType = (props) => <Pageview {...props} />;
-export const ProjectsIcon: IconType = (props) => <Inbox {...props} />;
-export const ProvenanceGraphIcon: IconType = (props) => <DeviceHub {...props} />;
-export const RemoveIcon: IconType = (props) => <Delete {...props} />;
-export const RemoveFavoriteIcon: IconType = (props) => <Star {...props} />;
-export const PublicFavoriteIcon: IconType = (props) => <Public {...props} />;
-export const RenameIcon: IconType = (props) => <Edit {...props} />;
-export const RestoreVersionIcon: IconType = (props) => <FlipToFront {...props} />;
-export const RestoreFromTrashIcon: IconType = (props) => <RestoreFromTrash {...props} />;
-export const ReRunProcessIcon: IconType = (props) => <Cached {...props} />;
-export const SearchIcon: IconType = (props) => <Search {...props} />;
-export const ShareIcon: IconType = (props) => <PersonAdd {...props} />;
-export const ShareMeIcon: IconType = (props) => <People {...props} />;
-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 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} />;
-export const FolderSharedIcon: IconType = (props) => <FolderShared {...props} />;
-export const CanReadIcon: IconType = (props) => <RemoveRedEye {...props} />;
-export const CanWriteIcon: IconType = (props) => <Edit {...props} />;
-export const CanManageIcon: IconType = (props) => <Computer {...props} />;
-export const AddUserIcon: IconType = (props) => <PersonAdd {...props} />;
-export const WordWrapOnIcon: IconType = (props) => <WrapText {...props} />;
-export const WordWrapOffIcon: IconType = (props) => <FormatAlignLeft {...props} />;
-export const TextIncreaseIcon: IconType = (props) => <TextIncrease {...props} />;
-export const TextDecreaseIcon: IconType = (props) => <TextDecrease {...props} />;
-export const DeactivateUserIcon: IconType = (props) => <NotInterested {...props} />;
-export const LoginAsIcon: IconType = (props) => <ExitToApp {...props} />;
-export const ActiveIcon: IconType = (props) => <CheckCircleOutline {...props} />;
-export const SetupIcon: IconType = (props) => <RemoveCircleOutline {...props} />;
-export const InactiveIcon: IconType = (props) => <NotInterested {...props} />;
-export const ImageIcon: IconType = (props) => <Image {...props} />;
-export const StartIcon: IconType = (props) => <PlayArrow {...props} />;
-export const StopIcon: IconType = (props) => <Stop {...props} />;
-export const SelectAllIcon: IconType = (props) => <CheckboxMultipleOutline {...props} />;
-export const SelectNoneIcon: IconType = (props) => <CheckboxMultipleBlankOutline {...props} />;
+export const AddIcon: IconType = props => <Add {...props} />;
+export const AddFavoriteIcon: IconType = props => <StarBorder {...props} />;
+export const AdminMenuIcon: IconType = props => <Build {...props} />;
+export const AdvancedIcon: IconType = props => <SettingsApplications {...props} />;
+export const AttributesIcon: IconType = props => <ListAlt {...props} />;
+export const BackIcon: IconType = props => <ArrowBack {...props} />;
+export const CustomizeTableIcon: IconType = props => <Menu {...props} />;
+export const CommandIcon: IconType = props => <LastPage {...props} />;
+export const CopyIcon: IconType = props => <ContentCopy {...props} />;
+export const FileCopyIcon: IconType = props => <FileCopy {...props} />;
+export const CollectionIcon: IconType = props => <LibraryBooks {...props} />;
+export const CloseIcon: IconType = props => <Close {...props} />;
+export const CloudUploadIcon: IconType = props => <CloudUpload {...props} />;
+export const DefaultIcon: IconType = props => <RateReview {...props} />;
+export const DetailsIcon: IconType = props => <Info {...props} />;
+export const DirectoryIcon: IconType = props => <Folder {...props} />;
+export const DownloadIcon: IconType = props => <GetApp {...props} />;
+export const EditSavedQueryIcon: IconType = props => <Create {...props} />;
+export const ExpandIcon: IconType = props => <ExpandMoreIcon {...props} />;
+export const ErrorIcon: IconType = props => (
+    <ErrorRoundedIcon
+        style={{ color: "#ff0000" }}
+        {...props}
+    />
+);
+export const FavoriteIcon: IconType = props => <Star {...props} />;
+export const FileIcon: IconType = props => <DescriptionIcon {...props} />;
+export const HelpIcon: IconType = props => <Help {...props} />;
+export const HelpOutlineIcon: IconType = props => <HelpOutline {...props} />;
+export const ImportContactsIcon: IconType = props => <ImportContacts {...props} />;
+export const InfoIcon: IconType = props => <Info {...props} />;
+export const FileInputIcon: IconType = props => <InsertDriveFile {...props} />;
+export const KeyIcon: IconType = props => <VpnKey {...props} />;
+export const LogIcon: IconType = props => <SettingsEthernet {...props} />;
+export const MailIcon: IconType = props => <Mail {...props} />;
+export const MaximizeIcon: IconType = props => <FullscreenSharp {...props} />;
+export const MemoryIcon: IconType = props => <Memory {...props} />;
+export const UnMaximizeIcon: IconType = props => <FullscreenExitSharp {...props} />;
+export const MoreVerticalIcon: IconType = props => <MoreVert {...props} />;
+export const MoreHorizontalIcon: IconType = props => <MoreHoriz {...props} />;
+export const MoveToIcon: IconType = props => <Input {...props} />;
+export const NewProjectIcon: IconType = props => <CreateNewFolder {...props} />;
+export const NotificationIcon: IconType = props => <Notifications {...props} />;
+export const OpenIcon: IconType = props => <OpenInNew {...props} />;
+export const InputIcon: IconType = props => <MoveToInbox {...props} />;
+export const PaginationDownIcon: IconType = props => <ArrowDropDown {...props} />;
+export const PaginationLeftArrowIcon: IconType = props => <ChevronLeft {...props} />;
+export const PaginationRightArrowIcon: IconType = props => <ChevronRight {...props} />;
+export const ProcessIcon: IconType = props => <Settings {...props} />;
+export const ProjectIcon: IconType = props => <Folder {...props} />;
+export const FilterGroupIcon: IconType = props => <Pageview {...props} />;
+export const ProjectsIcon: IconType = props => <Inbox {...props} />;
+export const ProvenanceGraphIcon: IconType = props => <DeviceHub {...props} />;
+export const RemoveIcon: IconType = props => <Delete {...props} />;
+export const RemoveFavoriteIcon: IconType = props => <Star {...props} />;
+export const PublicFavoriteIcon: IconType = props => <Public {...props} />;
+export const RenameIcon: IconType = props => <Edit {...props} />;
+export const RestoreVersionIcon: IconType = props => <FlipToFront {...props} />;
+export const RestoreFromTrashIcon: IconType = props => <RestoreFromTrash {...props} />;
+export const ReRunProcessIcon: IconType = props => <Cached {...props} />;
+export const SearchIcon: IconType = props => <Search {...props} />;
+export const ShareIcon: IconType = props => <PersonAdd {...props} />;
+export const ShareMeIcon: IconType = props => <People {...props} />;
+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 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} />;
+export const FolderSharedIcon: IconType = props => <FolderShared {...props} />;
+export const CanReadIcon: IconType = props => <RemoveRedEye {...props} />;
+export const CanWriteIcon: IconType = props => <Edit {...props} />;
+export const CanManageIcon: IconType = props => <Computer {...props} />;
+export const AddUserIcon: IconType = props => <PersonAdd {...props} />;
+export const WordWrapOnIcon: IconType = props => <WrapText {...props} />;
+export const WordWrapOffIcon: IconType = props => <FormatAlignLeft {...props} />;
+export const TextIncreaseIcon: IconType = props => <TextIncrease {...props} />;
+export const TextDecreaseIcon: IconType = props => <TextDecrease {...props} />;
+export const DeactivateUserIcon: IconType = props => <NotInterested {...props} />;
+export const LoginAsIcon: IconType = props => <ExitToApp {...props} />;
+export const ActiveIcon: IconType = props => <CheckCircleOutline {...props} />;
+export const SetupIcon: IconType = props => <RemoveCircleOutline {...props} />;
+export const InactiveIcon: IconType = props => <NotInterested {...props} />;
+export const ImageIcon: IconType = props => <Image {...props} />;
+export const StartIcon: IconType = props => <PlayArrow {...props} />;
+export const StopIcon: IconType = props => <Stop {...props} />;
+export const SelectAllIcon: IconType = props => <CheckboxMultipleOutline {...props} />;
+export const SelectNoneIcon: IconType = props => <CheckboxMultipleBlankOutline {...props} />;
diff --git a/src/components/multiselect-toolbar/MultiselectToolbar.tsx b/src/components/multiselect-toolbar/MultiselectToolbar.tsx
new file mode 100644 (file)
index 0000000..3d8ae0c
--- /dev/null
@@ -0,0 +1,213 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React from "react";
+import { connect } from "react-redux";
+import { StyleRulesCallback, withStyles, WithStyles, Toolbar, Tooltip, IconButton } from "@material-ui/core";
+import { ArvadosTheme } from "common/custom-theme";
+import { RootState } from "store/store";
+import { Dispatch } from "redux";
+import { TCheckedList } from "components/data-table/data-table";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+import { Resource, extractUuidKind } from "models/resource";
+import { getResource } from "store/resources/resources";
+import { ResourcesState } from "store/resources/resources";
+import { ContextMenuAction, ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { RestoreFromTrashIcon, TrashIcon } from "components/icon/icon";
+import { multiselectActionsFilters, TMultiselectActionsFilters, contextMenuActionConsts } from "./ms-toolbar-action-filters";
+import { kindToActionSet, findActionByName } from "./ms-kind-action-differentiator";
+import { msToggleTrashAction } from "views-components/multiselect-toolbar/ms-project-action-set";
+import { copyToClipboardAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { ContainerRequestResource } from "models/container-request";
+
+type CssRules = "root" | "button";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    root: {
+        display: "flex",
+        flexDirection: "row",
+        width: 0,
+        padding: 0,
+        margin: "1rem auto auto 0.5rem",
+        overflow: "hidden",
+        transition: "width 150ms",
+    },
+    button: {
+        width: "2.5rem",
+        height: "2.5rem ",
+    },
+});
+
+export type MultiselectToolbarProps = {
+    checkedList: TCheckedList;
+    resources: ResourcesState;
+    executeMulti: (action: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState) => void;
+};
+
+export const MultiselectToolbar = connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(
+    withStyles(styles)((props: MultiselectToolbarProps & WithStyles<CssRules>) => {
+        const { classes, checkedList } = props;
+        const currentResourceKinds = Array.from(selectedToKindSet(checkedList));
+
+        const currentPathIsTrash = window.location.pathname === "/trash";
+        const buttons =
+            currentPathIsTrash && selectedToKindSet(checkedList).size
+                ? [msToggleTrashAction]
+                : selectActionsByKind(currentResourceKinds, multiselectActionsFilters);
+
+        return (
+            <React.Fragment>
+                <Toolbar
+                    className={classes.root}
+                    style={{ width: `${buttons.length * 2.5}rem` }}
+                >
+                    {buttons.length ? (
+                        buttons.map((btn, i) =>
+                            btn.name === "ToggleTrashAction" ? (
+                                <Tooltip
+                                    className={classes.button}
+                                    title={currentPathIsTrash ? "Restore selected" : "Move to trash"}
+                                    key={i}
+                                    disableFocusListener
+                                >
+                                    <IconButton onClick={() => props.executeMulti(btn, checkedList, props.resources)}>
+                                        {currentPathIsTrash ? <RestoreFromTrashIcon /> : <TrashIcon />}
+                                    </IconButton>
+                                </Tooltip>
+                            ) : (
+                                <Tooltip
+                                    className={classes.button}
+                                    title={btn.name}
+                                    key={i}
+                                    disableFocusListener
+                                >
+                                    <IconButton onClick={() => props.executeMulti(btn, checkedList, props.resources)}>
+                                        {btn.icon ? btn.icon({}) : <></>}
+                                    </IconButton>
+                                </Tooltip>
+                            )
+                        )
+                    ) : (
+                        <></>
+                    )}
+                </Toolbar>
+            </React.Fragment>
+        );
+    })
+);
+
+export function selectedToArray(checkedList: TCheckedList): Array<string> {
+    const arrayifiedSelectedList: Array<string> = [];
+    for (const [key, value] of Object.entries(checkedList)) {
+        if (value === true) {
+            arrayifiedSelectedList.push(key);
+        }
+    }
+    return arrayifiedSelectedList;
+}
+
+export function selectedToKindSet(checkedList: TCheckedList): Set<string> {
+    const setifiedList = new Set<string>();
+    for (const [key, value] of Object.entries(checkedList)) {
+        if (value === true) {
+            setifiedList.add(extractUuidKind(key) as string);
+        }
+    }
+    return setifiedList;
+}
+
+function groupByKind(checkedList: TCheckedList, resources: ResourcesState): Record<string, ContextMenuResource[]> {
+    const result = {};
+    selectedToArray(checkedList).forEach(uuid => {
+        const resource = getResource(uuid)(resources) as ContainerRequestResource | Resource;
+        if (!result[resource.kind]) result[resource.kind] = [];
+        result[resource.kind].push(resource);
+    });
+    return result;
+}
+
+function filterActions(actionArray: ContextMenuActionSet, filters: Set<string>): Array<ContextMenuAction> {
+    return actionArray[0].filter(action => filters.has(action.name as string));
+}
+
+function selectActionsByKind(currentResourceKinds: Array<string>, filterSet: TMultiselectActionsFilters) {
+    const rawResult: Set<ContextMenuAction> = new Set();
+    const resultNames = new Set();
+    const allFiltersArray: ContextMenuAction[][] = [];
+    currentResourceKinds.forEach(kind => {
+        if (filterSet[kind]) {
+            const actions = filterActions(...filterSet[kind]);
+            allFiltersArray.push(actions);
+            actions.forEach(action => {
+                if (!resultNames.has(action.name)) {
+                    rawResult.add(action);
+                    resultNames.add(action.name);
+                }
+            });
+        }
+    });
+
+    const filteredNameSet = allFiltersArray.map(filterArray => {
+        const resultSet = new Set();
+        filterArray.forEach(action => resultSet.add(action.name || ""));
+        return resultSet;
+    });
+
+    const filteredResult = Array.from(rawResult).filter(action => {
+        for (let i = 0; i < filteredNameSet.length; i++) {
+            if (!filteredNameSet[i].has(action.name)) return false;
+        }
+        return true;
+    });
+
+    return filteredResult.sort((a, b) => {
+        const nameA = a.name || "";
+        const nameB = b.name || "";
+        if (nameA < nameB) {
+            return -1;
+        }
+        if (nameA > nameB) {
+            return 1;
+        }
+        return 0;
+    });
+}
+
+//--------------------------------------------------//
+
+function mapStateToProps(state: RootState) {
+    return {
+        checkedList: state.multiselect.checkedList as TCheckedList,
+        resources: state.resources,
+    };
+}
+
+function mapDispatchToProps(dispatch: Dispatch) {
+    return {
+        executeMulti: (selectedAction: ContextMenuAction, checkedList: TCheckedList, resources: ResourcesState): void => {
+            const kindGroups = groupByKind(checkedList, resources);
+            switch (selectedAction.name) {
+                case contextMenuActionConsts.MOVE_TO:
+                case contextMenuActionConsts.REMOVE:
+                    const firstResource = getResource(selectedToArray(checkedList)[0])(resources) as ContainerRequestResource | Resource;
+                    const action = findActionByName(selectedAction.name as string, kindToActionSet[firstResource.kind]);
+                    if (action) action.execute(dispatch, kindGroups[firstResource.kind]);
+                    break;
+                case contextMenuActionConsts.COPY_TO_CLIPBOARD:
+                    const selectedResources = selectedToArray(checkedList).map(uuid => getResource(uuid)(resources));
+                    dispatch<any>(copyToClipboardAction(selectedResources));
+                    break;
+                default:
+                    for (const kind in kindGroups) {
+                        const action = findActionByName(selectedAction.name as string, kindToActionSet[kind]);
+                        if (action) action.execute(dispatch, kindGroups[kind]);
+                    }
+                    break;
+            }
+        },
+    };
+}
diff --git a/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts b/src/components/multiselect-toolbar/ms-kind-action-differentiator.ts
new file mode 100644 (file)
index 0000000..e2f643b
--- /dev/null
@@ -0,0 +1,21 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ResourceKind } from "models/resource";
+import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { msCollectionActionSet } from "views-components/multiselect-toolbar/ms-collection-action-set";
+import { msProjectActionSet } from "views-components/multiselect-toolbar/ms-project-action-set";
+import { msProcessActionSet } from "views-components/multiselect-toolbar/ms-process-action-set";
+
+export function findActionByName(name: string, actionSet: ContextMenuActionSet) {
+    return actionSet[0].find(action => action.name === name);
+}
+
+const { COLLECTION, PROJECT, PROCESS } = ResourceKind;
+
+export const kindToActionSet: Record<string, ContextMenuActionSet> = {
+    [COLLECTION]: msCollectionActionSet,
+    [PROJECT]: msProjectActionSet,
+    [PROCESS]: msProcessActionSet,
+};
diff --git a/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts b/src/components/multiselect-toolbar/ms-toolbar-action-filters.ts
new file mode 100644 (file)
index 0000000..9145a82
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ResourceKind } from "models/resource";
+import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { msCollectionActionSet } from "views-components/multiselect-toolbar/ms-collection-action-set";
+import { msProjectActionSet } from "views-components/multiselect-toolbar/ms-project-action-set";
+import { msProcessActionSet } from "views-components/multiselect-toolbar/ms-process-action-set";
+
+export type TMultiselectActionsFilters = Record<string, [ContextMenuActionSet, Set<string>]>;
+
+export const contextMenuActionConsts = {
+    MAKE_A_COPY: "Make a copy",
+    MOVE_TO: "Move to",
+    TOGGLE_TRASH_ACTION: "ToggleTrashAction",
+    COPY_TO_CLIPBOARD: "Copy to clipboard",
+    COPY_AND_RERUN_PROCESS: "Copy and re-run process",
+    REMOVE: "Remove",
+};
+
+const { MOVE_TO, TOGGLE_TRASH_ACTION, REMOVE, MAKE_A_COPY } = contextMenuActionConsts;
+
+//these sets govern what actions are on the ms toolbar for each resource kind
+const projectMSActionsFilter = new Set([MOVE_TO, TOGGLE_TRASH_ACTION]);
+const processResourceMSActionsFilter = new Set([MOVE_TO, REMOVE]);
+const collectionMSActionsFilter = new Set([MAKE_A_COPY, MOVE_TO, TOGGLE_TRASH_ACTION]);
+
+const { COLLECTION, PROJECT, PROCESS } = ResourceKind;
+
+export const multiselectActionsFilters: TMultiselectActionsFilters = {
+    [PROJECT]: [msProjectActionSet, projectMSActionsFilter],
+    [PROCESS]: [msProcessActionSet, processResourceMSActionsFilter],
+    [COLLECTION]: [msCollectionActionSet, collectionMSActionsFilter],
+};
index 6fa7ddea626396494cc6f3cc5a2e01887a15da7d..bea0649632ecdafbb25fe81a467d03e3a15b5008 100644 (file)
@@ -11,55 +11,58 @@ type CssRules = 'formControl' | 'selectWrapper' | 'select' | 'option';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     formControl: {
-        width: '100%'
+        width: '100%',
     },
     selectWrapper: {
         backgroundColor: theme.palette.common.white,
         '&:before': {
-            borderBottomColor: 'rgba(0, 0, 0, 0.42)'
+            borderBottomColor: 'rgba(0, 0, 0, 0.42)',
         },
         '&:focus': {
-            outline: 'none'
-        }
+            outline: 'none',
+        },
     },
     select: {
         fontSize: '0.875rem',
         '&:focus': {
-            backgroundColor: 'rgba(0, 0, 0, 0.0)'
-        }
+            backgroundColor: 'rgba(0, 0, 0, 0.0)',
+        },
     },
     option: {
         fontSize: '0.875rem',
         backgroundColor: theme.palette.common.white,
-        height: '30px'
-    }
+        height: '30px',
+    },
 });
 
 interface NativeSelectFieldProps {
     disabled?: boolean;
 }
 
-export const NativeSelectField = withStyles(styles)
-    ((props: WrappedFieldProps & NativeSelectFieldProps & WithStyles<CssRules> & { items: any[] }) =>
-        <FormControl className={props.classes.formControl}>
-            <Select className={props.classes.selectWrapper}
-                native
-                value={props.input.value}
-                onChange={props.input.onChange}
-                disabled={props.meta.submitting || props.disabled}
-                name={props.input.name}
-                inputProps={{
-                    id: `id-${props.input.name}`,
-                    className: props.classes.select
-                }}>
-                {props.items.map(item => (
-                    <option key={item.key} value={item.key} className={props.classes.option}>
-                        {item.value}
-                    </option>
-                ))}
-            </Select>
-        </FormControl>
-    );
+export const NativeSelectField = withStyles(styles)((props: WrappedFieldProps & NativeSelectFieldProps & WithStyles<CssRules> & { items: any[] }) => (
+    <FormControl className={props.classes.formControl}>
+        <Select
+            className={props.classes.selectWrapper}
+            native
+            value={props.input.value}
+            onChange={props.input.onChange}
+            disabled={props.meta.submitting || props.disabled}
+            name={props.input.name}
+            inputProps={{
+                id: `id-${props.input.name}`,
+                className: props.classes.select,
+            }}>
+            {props.items.map(item => (
+                <option
+                    key={item.key}
+                    value={item.key}
+                    className={props.classes.option}>
+                    {item.value}
+                </option>
+            ))}
+        </Select>
+    </FormControl>
+));
 
 interface SelectFieldProps {
     children: React.ReactNode;
@@ -70,19 +73,15 @@ type SelectFieldCssRules = 'formControl';
 
 const selectFieldStyles: StyleRulesCallback<SelectFieldCssRules> = (theme: ArvadosTheme) => ({
     formControl: {
-        marginBottom: theme.spacing.unit * 3
+        marginBottom: theme.spacing.unit * 3,
     },
 });
-export const SelectField = withStyles(selectFieldStyles)(
-    (props: WrappedFieldProps & SelectFieldProps &  WithStyles<SelectFieldCssRules>) =>
-        <FormControl error={props.meta.invalid} className={props.classes.formControl}>
-            <InputLabel>
-                {props.label}
-            </InputLabel>
-            <Select
-                {...props.input}>
-                {props.children}
-            </Select>
-            <FormHelperText>{props.meta.error}</FormHelperText>
-        </FormControl>
-);
+export const SelectField = withStyles(selectFieldStyles)((props: WrappedFieldProps & SelectFieldProps & WithStyles<SelectFieldCssRules>) => (
+    <FormControl
+        error={props.meta.invalid}
+        className={props.classes.formControl}>
+        <InputLabel>{props.label}</InputLabel>
+        <Select {...props.input}>{props.children}</Select>
+        <FormHelperText>{props.meta.error}</FormHelperText>
+    </FormControl>
+));
index 7cc18783d13f70d02fcef54cdf745e3d9164d257..a3f6c1ee79f33ede1c7a543bc5a599d5292d24f8 100644 (file)
@@ -2,71 +2,97 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import ReactDOM from 'react-dom';
+import React from "react";
+import ReactDOM from "react-dom";
 import { Provider } from "react-redux";
-import { MainPanel } from 'views/main-panel/main-panel';
-import 'index.css';
-import { Route, Switch } from 'react-router';
+import { MainPanel } from "views/main-panel/main-panel";
+import "index.css";
+import { Route, Switch } from "react-router";
 import { createBrowserHistory } from "history";
 import { History } from "history";
-import { configureStore, RootStore } from 'store/store';
+import { configureStore, RootStore } from "store/store";
 import { ConnectedRouter } from "react-router-redux";
 import { ApiToken } from "views-components/api-token/api-token";
 import { AddSession } from "views-components/add-session/add-session";
 import { initAuth, logout } from "store/auth/auth-action";
 import { createServices } from "services/services";
-import { MuiThemeProvider } from '@material-ui/core/styles';
-import { CustomTheme } from 'common/custom-theme';
-import { fetchConfig } from 'common/config';
-import servicesProvider from 'common/service-provider';
-import { addMenuActionSet, ContextMenuKind } from 'views-components/context-menu/context-menu';
+import { MuiThemeProvider } from "@material-ui/core/styles";
+import { CustomTheme } from "common/custom-theme";
+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, 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 {
+    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, collectionFilesMultipleActionSet, readOnlyCollectionFilesActionSet, readOnlyCollectionFilesMultipleActionSet } from 'views-components/context-menu/action-sets/collection-files-action-set';
-import { collectionDirectoryItemActionSet, collectionFileItemActionSet, readOnlyCollectionDirectoryItemActionSet, readOnlyCollectionFileItemActionSet } from 'views-components/context-menu/action-sets/collection-files-item-action-set';
-import { collectionFilesNotSelectedActionSet } from 'views-components/context-menu/action-sets/collection-files-not-selected-action-set';
-import { collectionActionSet, collectionAdminActionSet, oldCollectionVersionActionSet, readOnlyCollectionActionSet } from 'views-components/context-menu/action-sets/collection-action-set';
-import { loadWorkbench } from 'store/workbench/workbench-actions';
-import { Routes } from 'routes/routes';
+import {
+    collectionFilesActionSet,
+    collectionFilesMultipleActionSet,
+    readOnlyCollectionFilesActionSet,
+    readOnlyCollectionFilesMultipleActionSet,
+} from "views-components/context-menu/action-sets/collection-files-action-set";
+import {
+    collectionDirectoryItemActionSet,
+    collectionFileItemActionSet,
+    readOnlyCollectionDirectoryItemActionSet,
+    readOnlyCollectionFileItemActionSet,
+} from "views-components/context-menu/action-sets/collection-files-item-action-set";
+import { collectionFilesNotSelectedActionSet } from "views-components/context-menu/action-sets/collection-files-not-selected-action-set";
+import {
+    collectionActionSet,
+    collectionAdminActionSet,
+    oldCollectionVersionActionSet,
+    readOnlyCollectionActionSet,
+} from "views-components/context-menu/action-sets/collection-action-set";
+import { loadWorkbench } from "store/workbench/workbench-actions";
+import { Routes } from "routes/routes";
 import { trashActionSet } from "views-components/context-menu/action-sets/trash-action-set";
-import { ServiceRepository } from 'services/services';
-import { initWebSocket } from 'websocket/websocket';
-import { Config } from 'common/config';
-import { addRouteChangeHandlers } from './routes/route-change-handlers';
-import { setTokenDialogApiHost } from 'store/token-dialog/token-dialog-actions';
+import { ServiceRepository } from "services/services";
+import { initWebSocket } from "websocket/websocket";
+import { Config } from "common/config";
+import { addRouteChangeHandlers } from "./routes/route-change-handlers";
+import { setTokenDialogApiHost } from "store/token-dialog/token-dialog-actions";
 import {
     processResourceActionSet,
+    runningProcessResourceActionSet,
     processResourceAdminActionSet,
-    readOnlyProcessResourceActionSet
-} from 'views-components/context-menu/action-sets/process-resource-action-set';
-import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
-import { trashedCollectionActionSet } from 'views-components/context-menu/action-sets/trashed-collection-action-set';
-import { setBuildInfo } from 'store/app-info/app-info-actions';
-import { getBuildInfo } from 'common/app-info';
-import { DragDropContextProvider } from 'react-dnd';
-import HTML5Backend from 'react-dnd-html5-backend';
-import { initAdvancedFormProjectsTree } from 'store/search-bar/search-bar-actions';
-import { repositoryActionSet } from 'views-components/context-menu/action-sets/repository-action-set';
-import { sshKeyActionSet } from 'views-components/context-menu/action-sets/ssh-key-action-set';
-import { keepServiceActionSet } from 'views-components/context-menu/action-sets/keep-service-action-set';
-import { loadVocabulary } from 'store/vocabulary/vocabulary-actions';
-import { virtualMachineActionSet } from 'views-components/context-menu/action-sets/virtual-machine-action-set';
-import { userActionSet } from 'views-components/context-menu/action-sets/user-action-set';
-import { apiClientAuthorizationActionSet } from 'views-components/context-menu/action-sets/api-client-authorization-action-set';
-import { groupActionSet } from 'views-components/context-menu/action-sets/group-action-set';
-import { groupMemberActionSet } from 'views-components/context-menu/action-sets/group-member-action-set';
-import { linkActionSet } from 'views-components/context-menu/action-sets/link-action-set';
-import { loadFileViewersConfig } from 'store/file-viewers/file-viewers-actions';
-import { filterGroupAdminActionSet, 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, readOnlyWorkflowActionSet } from 'views-components/context-menu/action-sets/workflow-action-set';
+    runningProcessResourceAdminActionSet,
+    readOnlyProcessResourceActionSet,
+} from "views-components/context-menu/action-sets/process-resource-action-set";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { trashedCollectionActionSet } from "views-components/context-menu/action-sets/trashed-collection-action-set";
+import { setBuildInfo } from "store/app-info/app-info-actions";
+import { getBuildInfo } from "common/app-info";
+import { DragDropContextProvider } from "react-dnd";
+import HTML5Backend from "react-dnd-html5-backend";
+import { initAdvancedFormProjectsTree } from "store/search-bar/search-bar-actions";
+import { repositoryActionSet } from "views-components/context-menu/action-sets/repository-action-set";
+import { sshKeyActionSet } from "views-components/context-menu/action-sets/ssh-key-action-set";
+import { keepServiceActionSet } from "views-components/context-menu/action-sets/keep-service-action-set";
+import { loadVocabulary } from "store/vocabulary/vocabulary-actions";
+import { virtualMachineActionSet } from "views-components/context-menu/action-sets/virtual-machine-action-set";
+import { userActionSet } from "views-components/context-menu/action-sets/user-action-set";
+import { apiClientAuthorizationActionSet } from "views-components/context-menu/action-sets/api-client-authorization-action-set";
+import { groupActionSet } from "views-components/context-menu/action-sets/group-action-set";
+import { groupMemberActionSet } from "views-components/context-menu/action-sets/group-member-action-set";
+import { linkActionSet } from "views-components/context-menu/action-sets/link-action-set";
+import { loadFileViewersConfig } from "store/file-viewers/file-viewers-actions";
+import {
+    filterGroupAdminActionSet,
+    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, readOnlyWorkflowActionSet } from "views-components/context-menu/action-sets/workflow-action-set";
 import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
-import { openNotFoundDialog } from './store/not-found-panel/not-found-panel-action';
-import { storeRedirects } from './common/redirect-to';
-import { searchResultsActionSet } from 'views-components/context-menu/action-sets/search-results-action-set';
+import { openNotFoundDialog } from "./store/not-found-panel/not-found-panel-action";
+import { storeRedirects } from "./common/redirect-to";
+import { searchResultsActionSet } from "views-components/context-menu/action-sets/search-results-action-set";
 
 console.log(`Starting arvados [${getBuildInfo()}]`);
 
@@ -90,6 +116,7 @@ addMenuActionSet(ContextMenuKind.READONLY_COLLECTION, readOnlyCollectionActionSe
 addMenuActionSet(ContextMenuKind.OLD_VERSION_COLLECTION, oldCollectionVersionActionSet);
 addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet);
+addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_RESOURCE, runningProcessResourceActionSet);
 addMenuActionSet(ContextMenuKind.READONLY_PROCESS_RESOURCE, readOnlyProcessResourceActionSet);
 addMenuActionSet(ContextMenuKind.TRASH, trashActionSet);
 addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
@@ -103,6 +130,7 @@ addMenuActionSet(ContextMenuKind.GROUPS, groupActionSet);
 addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
+addMenuActionSet(ContextMenuKind.RUNNING_PROCESS_ADMIN, runningProcessResourceAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
 addMenuActionSet(ContextMenuKind.FROZEN_PROJECT, frozenActionSet);
 addMenuActionSet(ContextMenuKind.FROZEN_PROJECT_ADMIN, frozenAdminActionSet);
@@ -114,84 +142,106 @@ addMenuActionSet(ContextMenuKind.SEARCH_RESULTS, searchResultsActionSet);
 
 storeRedirects();
 
-fetchConfig()
-    .then(({ config, apiHost }) => {
-        const history = createBrowserHistory();
+fetchConfig().then(({ config, apiHost }) => {
+    const history = createBrowserHistory();
 
-        // Provide browser's history access to Cypress to allow programmatic
-        // navigation.
-        if ((window as any).Cypress) {
-            (window as any).appHistory = history;
-        }
+    // Provide browser's history access to Cypress to allow programmatic
+    // navigation.
+    if ((window as any).Cypress) {
+        (window as any).appHistory = history;
+    }
+
+    const services = createServices(config, {
+        progressFn: (id, working) => {
+            //store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working }));
+        },
+        errorFn: (id, error, showSnackBar: boolean) => {
+            if (showSnackBar) {
+                console.error("Backend error:", error);
 
-        const services = createServices(config, {
-            progressFn: (id, working) => {
-                store.dispatch(progressIndicatorActions.TOGGLE_WORKING({ id, working }));
-            },
-            errorFn: (id, error, showSnackBar: boolean) => {
-                if (showSnackBar) {
-                    console.error("Backend error:", error);
-
-                    if (error.status === 404) {
-                        store.dispatch(openNotFoundDialog());
-                    } else if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
-                        // Catch auth errors when navigating and redirect to login preserving url location
-                        store.dispatch(logout(false, true));
-                    } else {
-                        store.dispatch(snackbarActions.OPEN_SNACKBAR({
-                            message: `${error.errors
-                                ? error.errors[0]
-                                : error.message}`,
+                if (error.status === 404) {
+                    store.dispatch(openNotFoundDialog());
+                } else if (error.status === 401 && error.errors[0].indexOf("Not logged in") > -1) {
+                    // Catch auth errors when navigating and redirect to login preserving url location
+                    store.dispatch(logout(false, true));
+                } else {
+                    store.dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: `${error.errors ? error.errors[0] : error.message}`,
                             kind: SnackbarKind.ERROR,
-                            hideDuration: 8000
+                            hideDuration: 8000,
                         })
-                        );
-                    }
+                    );
                 }
             }
-        });
-
-        // be sure this is initiated before the app starts
-        servicesProvider.setServices(services);
-
-        const store = configureStore(history, services, config);
-
-        servicesProvider.setStore(store);
-
-        store.subscribe(initListener(history, store, services, config));
-        store.dispatch(initAuth(config));
-        store.dispatch(setBuildInfo());
-        store.dispatch(setTokenDialogApiHost(apiHost));
-        store.dispatch(loadVocabulary);
-        store.dispatch(loadFileViewersConfig);
-
-        const TokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} loadMainApp={true} {...props} />;
-        const AddSessionComponent = (props: any) => <AddSession {...props} />;
-        const FedTokenComponent = (props: any) => <ApiToken authService={services.authService} config={config} loadMainApp={false} {...props} />;
-        const MainPanelComponent = (props: any) => <MainPanel {...props} />;
-
-        const App = () =>
-            <MuiThemeProvider theme={CustomTheme}>
-                <DragDropContextProvider backend={HTML5Backend}>
-                    <Provider store={store}>
-                        <ConnectedRouter history={history}>
-                            <Switch>
-                                <Route path={Routes.TOKEN} component={TokenComponent} />
-                                <Route path={Routes.FED_LOGIN} component={FedTokenComponent} />
-                                <Route path={Routes.ADD_SESSION} component={AddSessionComponent} />
-                                <Route path={Routes.ROOT} component={MainPanelComponent} />
-                            </Switch>
-                        </ConnectedRouter>
-                    </Provider>
-                </DragDropContextProvider>
-            </MuiThemeProvider>;
-
-        ReactDOM.render(
-            <App />,
-            document.getElementById('root') as HTMLElement
-        );
+        },
     });
 
+    // be sure this is initiated before the app starts
+    servicesProvider.setServices(services);
+
+    const store = configureStore(history, services, config);
+
+    servicesProvider.setStore(store);
+
+    store.subscribe(initListener(history, store, services, config));
+    store.dispatch(initAuth(config));
+    store.dispatch(setBuildInfo());
+    store.dispatch(setTokenDialogApiHost(apiHost));
+    store.dispatch(loadVocabulary);
+    store.dispatch(loadFileViewersConfig);
+
+    const TokenComponent = (props: any) => (
+        <ApiToken
+            authService={services.authService}
+            config={config}
+            loadMainApp={true}
+            {...props}
+        />
+    );
+    const AddSessionComponent = (props: any) => <AddSession {...props} />;
+    const FedTokenComponent = (props: any) => (
+        <ApiToken
+            authService={services.authService}
+            config={config}
+            loadMainApp={false}
+            {...props}
+        />
+    );
+    const MainPanelComponent = (props: any) => <MainPanel {...props} />;
+
+    const App = () => (
+        <MuiThemeProvider theme={CustomTheme}>
+            <DragDropContextProvider backend={HTML5Backend}>
+                <Provider store={store}>
+                    <ConnectedRouter history={history}>
+                        <Switch>
+                            <Route
+                                path={Routes.TOKEN}
+                                component={TokenComponent}
+                            />
+                            <Route
+                                path={Routes.FED_LOGIN}
+                                component={FedTokenComponent}
+                            />
+                            <Route
+                                path={Routes.ADD_SESSION}
+                                component={AddSessionComponent}
+                            />
+                            <Route
+                                path={Routes.ROOT}
+                                component={MainPanelComponent}
+                            />
+                        </Switch>
+                    </ConnectedRouter>
+                </Provider>
+            </DragDropContextProvider>
+        </MuiThemeProvider>
+    );
+
+    ReactDOM.render(<App />, document.getElementById("root") as HTMLElement);
+});
+
 const initListener = (history: History, store: RootStore, services: ServiceRepository, config: Config) => {
     let initialized = false;
     return async () => {
index 3c7fdca9afdee6357b204ac4f4edad199155d79b..0e8063b045602212f683043ac603aec884cfa4cb 100644 (file)
@@ -99,4 +99,35 @@ describe('Tree', () => {
         const mappedTree = Tree.mapTreeValues<string, number>(value => parseInt(value.split(' ')[1], 10))(newTree);
         expect(Tree.getNode('Node 2')(mappedTree)).toEqual(initTreeNode({ id: 'Node 2', parent: 'Node 1', value: 2 }));
     });
+
+    it('expands node ancestor chains', () => {
+        const newTree = [
+            initTreeNode({ id: 'Root Node 1', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.1', parent: 'Root Node 1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.1.1', parent: 'Node 1.1', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 1.2', parent: 'Root Node 1', value: 'Value 1' }),
+
+            initTreeNode({ id: 'Root Node 2', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1', parent: 'Root Node 2', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 2.1.1', parent: 'Node 2.1', value: 'Value 1' }),
+
+            initTreeNode({ id: 'Root Node 3', parent: '', value: 'Value 1' }),
+            initTreeNode({ id: 'Node 3.1', parent: 'Root Node 3', value: 'Value 1' }),
+        ].reduce((tree, node) => Tree.setNode(node)(tree), tree);
+
+        const expandedTree = Tree.expandNodeAncestors(
+            'Node 1.1.1', // Expands 1.1 and 1
+            'Node 2.1', // Expands 2
+        )(newTree);
+
+        expect(Tree.getNode('Root Node 1')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 1.1')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 1.1.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 1.2')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Root Node 2')(expandedTree)?.expanded).toEqual(true);
+        expect(Tree.getNode('Node 2.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 2.1.1')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Root Node 3')(expandedTree)?.expanded).toEqual(false);
+        expect(Tree.getNode('Node 3.1')(expandedTree)?.expanded).toEqual(false);
+    });
 });
index 996f98a465865ee5fd0861bcc391a505735ef115..aeb415411e8102a66acfcf11fbc51c6cb42d29ac 100644 (file)
@@ -138,6 +138,11 @@ export const deactivateNode = <T>(tree: Tree<T>) =>
 export const expandNode = (...ids: string[]) => <T>(tree: Tree<T>) =>
     mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree);
 
+export const expandNodeAncestors = (...ids: string[]) => <T>(tree: Tree<T>) => {
+    const ancestors = ids.reduce((acc, id): string[] => ([...acc, ...getNodeAncestorsIds(id)(tree)]), [] as string[]);
+    return mapTree((node: TreeNode<T>) => ancestors.some(id => id === node.id) ? { ...node, expanded: true } : node)(tree);
+}
+
 export const collapseNode = (...ids: string[]) => <T>(tree: Tree<T>) =>
     mapTree((node: TreeNode<T>) => ids.some(id => id === node.id) ? { ...node, expanded: false } : node)(tree);
 
@@ -151,37 +156,40 @@ export const setNodeStatus = (id: string) => (status: TreeNodeStatus) => <T>(tre
         : tree;
 };
 
-export const toggleNodeSelection = (id: string) => <T>(tree: Tree<T>) => {
+export const toggleNodeSelection = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
     const node = getNode(id)(tree);
+
     return node
-        ? pipe(
-            setNode({ ...node, selected: !node.selected }),
-            toggleAncestorsSelection(id),
-            toggleDescendantsSelection(id))(tree)
+        ? cascade
+            ? pipe(
+                setNode({ ...node, selected: !node.selected }),
+                toggleAncestorsSelection(id),
+                toggleDescendantsSelection(id))(tree)
+            : setNode({ ...node, selected: !node.selected })(tree)
         : tree;
 };
 
-export const selectNode = (id: string) => <T>(tree: Tree<T>) => {
+export const selectNode = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
     const node = getNode(id)(tree);
     return node && node.selected
         ? tree
-        : toggleNodeSelection(id)(tree);
+        : toggleNodeSelection(id, cascade)(tree);
 };
 
-export const selectNodes = (id: string | string[]) => <T>(tree: Tree<T>) => {
+export const selectNodes = (id: string | string[], cascade: boolean) => <T>(tree: Tree<T>) => {
     const ids = typeof id === 'string' ? [id] : id;
-    return ids.reduce((tree, id) => selectNode(id)(tree), tree);
+    return ids.reduce((tree, id) => selectNode(id, cascade)(tree), tree);
 };
-export const deselectNode = (id: string) => <T>(tree: Tree<T>) => {
+export const deselectNode = (id: string, cascade: boolean) => <T>(tree: Tree<T>) => {
     const node = getNode(id)(tree);
     return node && node.selected
-        ? toggleNodeSelection(id)(tree)
+        ? toggleNodeSelection(id, cascade)(tree)
         : tree;
 };
 
-export const deselectNodes = (id: string | string[]) => <T>(tree: Tree<T>) => {
+export const deselectNodes = (id: string | string[], cascade: boolean) => <T>(tree: Tree<T>) => {
     const ids = typeof id === 'string' ? [id] : id;
-    return ids.reduce((tree, id) => deselectNode(id)(tree), tree);
+    return ids.reduce((tree, id) => deselectNode(id, cascade)(tree), tree);
 };
 
 export const getSelectedNodes = <T>(tree: Tree<T>) =>
index b7e1f9c711e3cda968a1f39ffe78bfde7a0d7191..db56e317ff2474932c4d55702884d2f963e91860 100644 (file)
@@ -60,4 +60,4 @@ export const extractFilesData = (document: Document) => {
 
 export const getFileFullPath = ({ name, path }: CollectionFile | CollectionDirectory) => {
     return `${path}/${name}`;
-};
\ No newline at end of file
+};
index 7e28c37bee64b04060fe1fa5c11698aed403eb1e..de8f258708dc1e94d278a9324a8c68e3f2a9aa6a 100644 (file)
@@ -16,23 +16,24 @@ import { snakeCase } from "lodash";
 import { CommonResourceServiceError } from "services/common-service/common-resource-service";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
-type CollectionPartialUpdateOrCreate =  Partial<CollectionResource> & Pick<CollectionResource, "uuid"> |
-                                        Partial<CollectionResource> & Pick<CollectionResource, "ownerUuid">;
+type CollectionPartialUpdateOrCreate =
+    | (Partial<CollectionResource> & Pick<CollectionResource, "uuid">)
+    | (Partial<CollectionResource> & Pick<CollectionResource, "ownerUuid">);
 
-export const emptyCollectionPdh = 'd41d8cd98f00b204e9800998ecf8427e+0';
-export const SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE = 'Source and destination cannot be the same';
+export const emptyCollectionPdh = "d41d8cd98f00b204e9800998ecf8427e+0";
+export const SOURCE_DESTINATION_EQUAL_ERROR_MESSAGE = "Source and destination cannot be the same";
 
 export class CollectionService extends TrashableResourceService<CollectionResource> {
     constructor(serverApi: AxiosInstance, private keepWebdavClient: WebDAV, private authService: AuthService, actions: ApiActions) {
         super(serverApi, "collections", actions, [
-            'fileCount',
-            'fileSizeTotal',
-            'replicationConfirmed',
-            'replicationConfirmedAt',
-            'storageClassesConfirmed',
-            'storageClassesConfirmedAt',
-            'unsignedManifestText',
-            'version',
+            "fileCount",
+            "fileSizeTotal",
+            "replicationConfirmed",
+            "replicationConfirmedAt",
+            "storageClassesConfirmed",
+            "storageClassesConfirmedAt",
+            "unsignedManifestText",
+            "version",
         ]);
     }
 
@@ -47,7 +48,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
     }
 
     update(uuid: string, data: Partial<CollectionResource>, showErrors?: boolean) {
-        const select = [...Object.keys(data), 'version', 'modifiedAt'];
+        const select = [...Object.keys(data), "version", "modifiedAt"];
         return super.update(uuid, { ...data, preserveVersion: true }, showErrors, select);
     }
 
@@ -56,15 +57,16 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         if (request.responseXML != null) {
             return extractFilesData(request.responseXML);
         }
+
         return Promise.reject();
     }
 
     private combineFilePath(parts: string[]) {
         return parts.reduce((path, part) => {
             // Trim leading and trailing slashes
-            const trimmedPart = part.split('/').filter(Boolean).join('/');
+            const trimmedPart = part.split("/").filter(Boolean).join("/");
             if (trimmedPart.length) {
-                const separator = path.endsWith('/') ? '' : '/';
+                const separator = path.endsWith("/") ? "" : "/";
                 return `${path}${separator}${trimmedPart}`;
             } else {
                 return path;
@@ -80,20 +82,18 @@ export class CollectionService extends TrashableResourceService<CollectionResour
                 // Don't send uuid in payload when creating
                 uuid: undefined,
             },
-            replace_files: fileMap
+            replace_files: fileMap,
         };
         if (data.uuid) {
             return CommonService.defaultResponse(
-                this.serverApi
-                    .put<CollectionResource>(`/${this.resourceType}/${data.uuid}`, payload),
+                this.serverApi.put<CollectionResource>(`/${this.resourceType}/${data.uuid}`, payload),
                 this.actions,
                 true, // mapKeys
                 showErrors
             );
         } else {
             return CommonService.defaultResponse(
-                this.serverApi
-                    .post<CollectionResource>(`/${this.resourceType}`, payload),
+                this.serverApi.post<CollectionResource>(`/${this.resourceType}`, payload),
                 this.actions,
                 true, // mapKeys
                 showErrors
@@ -101,8 +101,10 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         }
     }
 
-    async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = '') {
-        if (collectionUuid === "" || files.length === 0) { return; }
+    async uploadFiles(collectionUuid: string, files: File[], onProgress?: UploadProgress, targetLocation: string = "") {
+        if (collectionUuid === "" || files.length === 0) {
+            return;
+        }
         // files have to be uploaded sequentially
         for (let idx = 0; idx < files.length; idx++) {
             await this.uploadFile(collectionUuid, files[idx], idx, onProgress, targetLocation);
@@ -111,36 +113,47 @@ export class CollectionService extends TrashableResourceService<CollectionResour
     }
 
     async renameFile(collectionUuid: string, collectionPdh: string, oldPath: string, newPath: string) {
-        return this.replaceFiles({uuid: collectionUuid}, {
-            [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
-            [this.combineFilePath([oldPath])]: '',
-        });
+        return this.replaceFiles(
+            { uuid: collectionUuid },
+            {
+                [this.combineFilePath([newPath])]: `${collectionPdh}${this.combineFilePath([oldPath])}`,
+                [this.combineFilePath([oldPath])]: "",
+            }
+        );
     }
 
     extendFileURL = (file: CollectionDirectory | CollectionFile) => {
-        const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith('/')
+        const baseUrl = this.keepWebdavClient.getBaseUrl().endsWith("/")
             ? this.keepWebdavClient.getBaseUrl().slice(0, -1)
             : this.keepWebdavClient.getBaseUrl();
         const apiToken = this.authService.getApiToken();
-        const encodedApiToken = apiToken ? encodeURI(apiToken) : '';
+        const encodedApiToken = apiToken ? encodeURI(apiToken) : "";
         const userApiToken = `/t=${encodedApiToken}/`;
-        const splittedPrevFileUrl = file.url.split('/');
-        const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join('/')}`;
+        const splittedPrevFileUrl = file.url.split("/");
+        const url = `${baseUrl}/${splittedPrevFileUrl[1]}${userApiToken}${splittedPrevFileUrl.slice(2).join("/")}`;
         return {
             ...file,
-            url
+            url,
         };
-    }
+    };
 
     async getFileContents(file: CollectionFile) {
         return (await this.keepWebdavClient.get(`c=${file.id}`)).response;
     }
 
-    private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }, targetLocation: string = '') {
-        const fileURL = `c=${targetLocation !== '' ? targetLocation : collectionUuid}/${file.name}`.replace('//', '/');
+    private async uploadFile(
+        collectionUuid: string,
+        file: File,
+        fileId: number,
+        onProgress: UploadProgress = () => {
+            return;
+        },
+        targetLocation: string = ""
+    ) {
+        const fileURL = `c=${targetLocation !== "" ? targetLocation : collectionUuid}/${file.name}`.replace("//", "/");
         const requestConfig = {
             headers: {
-                'Content-Type': 'text/octet-stream'
+                "Content-Type": "text/octet-stream",
             },
             onUploadProgress: (e: ProgressEvent) => {
                 onProgress(fileId, e.loaded, e.total, Date.now());
@@ -153,7 +166,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         const optimizedFiles = files
             .sort((a, b) => a.length - b.length)
             .reduce((acc, currentPath) => {
-                const parentPathFound = acc.find((parentPath) => currentPath.indexOf(`${parentPath}/`) > -1);
+                const parentPathFound = acc.find(parentPath => currentPath.indexOf(`${parentPath}/`) > -1);
 
                 if (!parentPathFound) {
                     return [...acc, currentPath];
@@ -165,40 +178,52 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         const fileMap = optimizedFiles.reduce((obj, filePath) => {
             return {
                 ...obj,
-                [this.combineFilePath([filePath])]: ''
-            }
-        }, {})
+                [this.combineFilePath([filePath])]: "",
+            };
+        }, {});
 
-        return this.replaceFiles({uuid: collectionUuid}, fileMap, showErrors);
+        return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
     }
 
-    copyFiles(sourcePdh: string, files: string[], destinationCollection: CollectionPartialUpdateOrCreate, destinationPath: string, showErrors?: boolean) {
+    copyFiles(
+        sourcePdh: string,
+        files: string[],
+        destinationCollection: CollectionPartialUpdateOrCreate,
+        destinationPath: string,
+        showErrors?: boolean
+    ) {
         const fileMap = files.reduce((obj, sourceFile) => {
-            const fileBasename = sourceFile.split('/').filter(Boolean).slice(-1).join("");
+            const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
             return {
                 ...obj,
-                [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`
+                [this.combineFilePath([destinationPath, fileBasename])]: `${sourcePdh}${this.combineFilePath([sourceFile])}`,
             };
         }, {});
 
         return this.replaceFiles(destinationCollection, fileMap, showErrors);
     }
 
-    moveFiles(sourceUuid: string, sourcePdh: string, files: string[], destinationCollection: CollectionPartialUpdateOrCreate, destinationPath: string, showErrors?: boolean) {
+    moveFiles(
+        sourceUuid: string,
+        sourcePdh: string,
+        files: string[],
+        destinationCollection: CollectionPartialUpdateOrCreate,
+        destinationPath: string,
+        showErrors?: boolean
+    ) {
         if (sourceUuid === destinationCollection.uuid) {
             let errors: CommonResourceServiceError[] = [];
             const fileMap = files.reduce((obj, sourceFile) => {
-                const fileBasename = sourceFile.split('/').filter(Boolean).slice(-1).join("");
+                const fileBasename = sourceFile.split("/").filter(Boolean).slice(-1).join("");
                 const fileDestinationPath = this.combineFilePath([destinationPath, fileBasename]);
                 const fileSourcePath = this.combineFilePath([sourceFile]);
                 const fileSourceUri = `${sourcePdh}${fileSourcePath}`;
 
-
                 if (fileDestinationPath !== fileSourcePath) {
                     return {
                         ...obj,
                         [fileDestinationPath]: fileSourceUri,
-                        [fileSourcePath]: '',
+                        [fileSourcePath]: "",
                     };
                 } else {
                     errors.push(CommonResourceServiceError.SOURCE_DESTINATION_CANNOT_BE_SAME);
@@ -207,22 +232,20 @@ export class CollectionService extends TrashableResourceService<CollectionResour
             }, {});
 
             if (errors.length === 0) {
-                return this.replaceFiles({uuid: sourceUuid}, fileMap, showErrors)
+                return this.replaceFiles({ uuid: sourceUuid }, fileMap, showErrors);
             } else {
-                return Promise.reject({errors});
+                return Promise.reject({ errors });
             }
         } else {
-            return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors)
-                .then(() => {
-                    return this.deleteFiles(sourceUuid, files, showErrors);
-                });
+            return this.copyFiles(sourcePdh, files, destinationCollection, destinationPath, showErrors).then(() => {
+                return this.deleteFiles(sourceUuid, files, showErrors);
+            });
         }
     }
 
     createDirectory(collectionUuid: string, path: string, showErrors?: boolean) {
-        const fileMap = {[this.combineFilePath([path])]: emptyCollectionPdh};
+        const fileMap = { [this.combineFilePath([path])]: emptyCollectionPdh };
 
-        return this.replaceFiles({uuid: collectionUuid}, fileMap, showErrors);
+        return this.replaceFiles({ uuid: collectionUuid }, fileMap, showErrors);
     }
-
 }
index 4d6b130b906d68a1f68c4e96e7e50a7b0fbeee9c..5e4704b64d7020852e5d314b18bb3270403c8fb8 100644 (file)
@@ -9,29 +9,25 @@ import { CommonResourceService } from "services/common-service/common-resource-s
 import { ApiActions } from "services/api/api-actions";
 
 export class TrashableResourceService<T extends TrashableResource> extends CommonResourceService<T> {
-
     constructor(serverApi: AxiosInstance, resourceType: string, actions: ApiActions, readOnlyFields: string[] = []) {
         super(serverApi, resourceType, actions, readOnlyFields);
     }
 
     trash(uuid: string): Promise<T> {
-        return CommonResourceService.defaultResponse(
-            this.serverApi
-                .post(this.resourceType + `/${uuid}/trash`),
-            this.actions
-        );
+        return CommonResourceService.defaultResponse(this.serverApi.post(this.resourceType + `/${uuid}/trash`), this.actions);
     }
 
     untrash(uuid: string): Promise<T> {
         const params = {
-            ensure_unique_name: true
+            ensure_unique_name: true,
         };
         return CommonResourceService.defaultResponse(
-            this.serverApi
-                .post(this.resourceType + `/${uuid}/untrash`, {
-                    params: CommonResourceService.mapKeys(snakeCase)(params)
-                }),
-            this.actions
+            this.serverApi.post(this.resourceType + `/${uuid}/untrash`, {
+                params: CommonResourceService.mapKeys(snakeCase)(params),
+            }),
+            this.actions,
+            undefined,
+            false
         );
     }
 }
index 227d2fa09c6e9617aeb15d34d1593703fbc71813..955d9689afc7f02eb471d0e965ad376cc9af16a4 100644 (file)
@@ -27,13 +27,13 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
         super(id);
     }
 
-    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean, background?: boolean) {
         const dataExplorer = getDataExplorer(api.getState().dataExplorer, this.getId());
         if (!dataExplorer) {
             api.dispatch(allProcessesPanelDataExplorerIsNotSet());
         } else {
             try {
-                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
                 const processItems = await this.services.containerRequestService.list(
                     {
                         ...getParams(dataExplorer),
@@ -41,7 +41,7 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
                         select: containerRequestFieldsNoMounts,
                     });
 
-                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
                 api.dispatch(resourcesActions.SET_RESOURCES(processItems.items));
                 await api.dispatch<any>(loadMissingProcessesInformation(processItems.items));
                 api.dispatch(allProcessesPanelActions.SET_ITEMS({
@@ -51,7 +51,7 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
                     rowsPerPage: processItems.limit
                 }));
             } catch {
-                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
                 api.dispatch(allProcessesPanelActions.SET_ITEMS({
                     items: [],
                     itemsAvailable: 0,
@@ -64,13 +64,13 @@ export class AllProcessesPanelMiddlewareService extends DataExplorerMiddlewareSe
     }
 }
 
-const getParams = ( dataExplorer: DataExplorer ) => ({
+const getParams = (dataExplorer: DataExplorer) => ({
     ...dataExplorerToListParams(dataExplorer),
     order: getOrder<ContainerRequestResource>(dataExplorer),
     filters: getFilters(dataExplorer)
 });
 
-const getFilters = ( dataExplorer: DataExplorer ) => {
+const getFilters = (dataExplorer: DataExplorer) => {
     const columns = dataExplorer.columns as DataColumns<string, ContainerRequestResource>;
     const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
     const activeStatusFilter = Object.keys(statusColumnFilters).find(
index 9c9baf08ad76977d708d41107acf1873fee6b440..9aebeb904c64115e574624163718d2fea43bcb82 100644 (file)
@@ -24,6 +24,7 @@ import { CollectionIcon, IconType, ProcessIcon, ProjectIcon, WorkflowIcon } from
 import { CollectionResource } from 'models/collection';
 import { getSidePanelIcon } from 'views-components/side-panel-tree/side-panel-tree';
 import { WorkflowResource } from 'models/workflow';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export const BREADCRUMBS = 'breadcrumbs';
 
@@ -57,60 +58,65 @@ const resourceToBreadcrumb = (resource: CollectionResource | ContainerRequestRes
 
 export const setSidePanelBreadcrumbs = (uuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const ancestors = await services.ancestorsService.ancestors(uuid, '');
-        dispatch(updateResources(ancestors));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs"));
+            const ancestors = await services.ancestorsService.ancestors(uuid, '');
+            dispatch(updateResources(ancestors));
 
-        let breadcrumbs: Breadcrumb[] = [];
-        const { collectionPanel: { item } } = getState();
+            let breadcrumbs: Breadcrumb[] = [];
+            const { collectionPanel: { item } } = getState();
 
-        const path = getState().router.location!.pathname;
-        const currentUuid = path.split('/')[2];
-        const uuidKind = extractUuidKind(currentUuid);
-        const rootUuid = getUserUuid(getState());
+            const path = getState().router.location!.pathname;
+            const currentUuid = path.split('/')[2];
+            const uuidKind = extractUuidKind(currentUuid);
+            const rootUuid = getUserUuid(getState());
 
-        if (ancestors.find(ancestor => ancestor.uuid === rootUuid)) {
-            // Handle home project uuid root
-            breadcrumbs.push({
-                label: SidePanelTreeCategory.PROJECTS,
-                uuid: SidePanelTreeCategory.PROJECTS,
-                icon: getSidePanelIcon(SidePanelTreeCategory.PROJECTS)
-            });
-        } else if (Object.values(SidePanelTreeCategory).includes(uuid as SidePanelTreeCategory)) {
-            // Handle SidePanelTreeCategory root
-            breadcrumbs.push({
-                label: uuid,
-                uuid: uuid,
-                icon: getSidePanelIcon(uuid)
-            });
-        }
+            if (ancestors.find(ancestor => ancestor.uuid === rootUuid)) {
+                // Handle home project uuid root
+                breadcrumbs.push({
+                    label: SidePanelTreeCategory.PROJECTS,
+                    uuid: SidePanelTreeCategory.PROJECTS,
+                    icon: getSidePanelIcon(SidePanelTreeCategory.PROJECTS)
+                });
+            } else if (Object.values(SidePanelTreeCategory).includes(uuid as SidePanelTreeCategory)) {
+                // Handle SidePanelTreeCategory root
+                breadcrumbs.push({
+                    label: uuid,
+                    uuid: uuid,
+                    icon: getSidePanelIcon(uuid)
+                });
+            }
 
-        breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
-            ancestor.kind === ResourceKind.GROUP
-                ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
-                : breadcrumbs,
-            breadcrumbs);
+            breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
+                ancestor.kind === ResourceKind.GROUP
+                    ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
+                    : breadcrumbs,
+                breadcrumbs);
 
-        if (uuidKind === ResourceKind.COLLECTION) {
-            const collectionItem = item ? item : await services.collectionService.get(currentUuid);
-            const parentProcessItem = await getCollectionParent(collectionItem)(services);
-            if (parentProcessItem) {
-                const mainProcessItem = await getProcessParent(parentProcessItem)(services);
-                mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
-                breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
-            }
-            dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
-        } else if (uuidKind === ResourceKind.PROCESS) {
-            const processItem = await services.containerRequestService.get(currentUuid);
-            const parentProcessItem = await getProcessParent(processItem)(services);
-            if (parentProcessItem) {
-                breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+            if (uuidKind === ResourceKind.COLLECTION) {
+                const collectionItem = item ? item : await services.collectionService.get(currentUuid);
+                const parentProcessItem = await getCollectionParent(collectionItem)(services);
+                if (parentProcessItem) {
+                    const mainProcessItem = await getProcessParent(parentProcessItem)(services);
+                    mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
+            } else if (uuidKind === ResourceKind.PROCESS) {
+                const processItem = await services.containerRequestService.get(currentUuid);
+                const parentProcessItem = await getProcessParent(processItem)(services);
+                if (parentProcessItem) {
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, processItem));
+            } else if (uuidKind === ResourceKind.WORKFLOW) {
+                const workflowItem = await services.workflowService.get(currentUuid);
+                dispatch(setBreadcrumbs(breadcrumbs, workflowItem));
             }
-            dispatch(setBreadcrumbs(breadcrumbs, processItem));
-        } else if (uuidKind === ResourceKind.WORKFLOW) {
-            const workflowItem = await services.workflowService.get(currentUuid);
-            dispatch(setBreadcrumbs(breadcrumbs, workflowItem));
+            dispatch(setBreadcrumbs(breadcrumbs));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs"));
         }
-        dispatch(setBreadcrumbs(breadcrumbs));
     };
 
 export const setSharedWithMeBreadcrumbs = (uuid: string) =>
@@ -121,45 +127,50 @@ export const setTrashBreadcrumbs = (uuid: string) =>
 
 export const setCategoryBreadcrumbs = (uuid: string, category: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const ancestors = await services.ancestorsService.ancestors(uuid, '');
-        dispatch(updateResources(ancestors));
-        const initialBreadcrumbs: Breadcrumb[] = [
-            {
-                label: category,
-                uuid: category,
-                icon: getSidePanelIcon(category)
-            }
-        ];
-        const { collectionPanel: { item } } = getState();
-        const path = getState().router.location!.pathname;
-        const currentUuid = path.split('/')[2];
-        const uuidKind = extractUuidKind(currentUuid);
-        let breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
-            ancestor.kind === ResourceKind.GROUP
-                ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
-                : breadcrumbs,
-            initialBreadcrumbs);
-        if (uuidKind === ResourceKind.COLLECTION) {
-            const collectionItem = item ? item : await services.collectionService.get(currentUuid);
-            const parentProcessItem = await getCollectionParent(collectionItem)(services);
-            if (parentProcessItem) {
-                const mainProcessItem = await getProcessParent(parentProcessItem)(services);
-                mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
-                breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
-            }
-            dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
-        } else if (uuidKind === ResourceKind.PROCESS) {
-            const processItem = await services.containerRequestService.get(currentUuid);
-            const parentProcessItem = await getProcessParent(processItem)(services);
-            if (parentProcessItem) {
-                breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid + "-breadcrumbs"));
+            const ancestors = await services.ancestorsService.ancestors(uuid, '');
+            dispatch(updateResources(ancestors));
+            const initialBreadcrumbs: Breadcrumb[] = [
+                {
+                    label: category,
+                    uuid: category,
+                    icon: getSidePanelIcon(category)
+                }
+            ];
+            const { collectionPanel: { item } } = getState();
+            const path = getState().router.location!.pathname;
+            const currentUuid = path.split('/')[2];
+            const uuidKind = extractUuidKind(currentUuid);
+            let breadcrumbs = ancestors.reduce((breadcrumbs, ancestor) =>
+                ancestor.kind === ResourceKind.GROUP
+                    ? [...breadcrumbs, resourceToBreadcrumb(ancestor)]
+                    : breadcrumbs,
+                initialBreadcrumbs);
+            if (uuidKind === ResourceKind.COLLECTION) {
+                const collectionItem = item ? item : await services.collectionService.get(currentUuid);
+                const parentProcessItem = await getCollectionParent(collectionItem)(services);
+                if (parentProcessItem) {
+                    const mainProcessItem = await getProcessParent(parentProcessItem)(services);
+                    mainProcessItem && breadcrumbs.push(resourceToBreadcrumb(mainProcessItem));
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, collectionItem));
+            } else if (uuidKind === ResourceKind.PROCESS) {
+                const processItem = await services.containerRequestService.get(currentUuid);
+                const parentProcessItem = await getProcessParent(processItem)(services);
+                if (parentProcessItem) {
+                    breadcrumbs.push(resourceToBreadcrumb(parentProcessItem));
+                }
+                dispatch(setBreadcrumbs(breadcrumbs, processItem));
+            } else if (uuidKind === ResourceKind.WORKFLOW) {
+                const workflowItem = await services.workflowService.get(currentUuid);
+                dispatch(setBreadcrumbs(breadcrumbs, workflowItem));
             }
-            dispatch(setBreadcrumbs(breadcrumbs, processItem));
-        } else if (uuidKind === ResourceKind.WORKFLOW) {
-            const workflowItem = await services.workflowService.get(currentUuid);
-            dispatch(setBreadcrumbs(breadcrumbs, workflowItem));
+            dispatch(setBreadcrumbs(breadcrumbs));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-breadcrumbs"));
         }
-        dispatch(setBreadcrumbs(breadcrumbs));
     };
 
 const getProcessParent = (childProcess: ContainerRequestResource) =>
index 7bab86320da1e00c2a3f2a1706b824722c87e14c..49573215af9224d55942d2dc2e33c8c177d017e5 100644 (file)
@@ -12,6 +12,7 @@ import { unionize, ofType, UnionOf } from 'common/unionize';
 import { SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export const collectionPanelActions = unionize({
     SET_COLLECTION: ofType<CollectionResource>(),
@@ -24,9 +25,14 @@ export const loadCollectionPanel = (uuid: string, forceReload = false) =>
         const { collectionPanel: { item } } = getState();
         let collection: CollectionResource | null = null;
         if (!item || item.uuid !== uuid || forceReload) {
-            collection = await services.collectionService.get(uuid);
-            dispatch(collectionPanelActions.SET_COLLECTION(collection));
-            dispatch(resourcesActions.SET_RESOURCES([collection]));
+            try {
+                dispatch(progressIndicatorActions.START_WORKING(uuid + "-panel"));
+                collection = await services.collectionService.get(uuid);
+                dispatch(collectionPanelActions.SET_COLLECTION(collection));
+                dispatch(resourcesActions.SET_RESOURCES([collection]));
+            } finally {
+                dispatch(progressIndicatorActions.STOP_WORKING(uuid + "-panel"));
+            }
         } else {
             collection = item;
         }
index eb9c64fdcc576a6cdb7a44b5af051b99b85977cb..c332ef5faf2bb3496cbf3a8e57fc082c68b502b3 100644 (file)
@@ -4,55 +4,81 @@
 
 import { Dispatch } from "redux";
 import { dialogActions } from "store/dialog/dialog-actions";
-import { FormErrors, initialize, startSubmit, stopSubmit } from 'redux-form';
-import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
-import { RootState } from 'store/store';
-import { ServiceRepository } from 'services/services';
-import { getCommonResourceServiceError, CommonResourceServiceError } from 'services/common-service/common-resource-service';
-import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
+import { FormErrors, initialize, startSubmit, stopSubmit } from "redux-form";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
 import { getResource } from "store/resources/resources";
 import { CollectionResource } from "models/collection";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
-export const COLLECTION_COPY_FORM_NAME = 'collectionCopyFormName';
+export const COLLECTION_COPY_FORM_NAME = "collectionCopyFormName";
+export const COLLECTION_MULTI_COPY_FORM_NAME = "collectionMultiCopyFormName";
 
-export const openCollectionCopyDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch) => {
-        dispatch<any>(resetPickerProjectTree());
-        dispatch<any>(initProjectsTreePicker(COLLECTION_COPY_FORM_NAME));
-        const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: '', uuid: resource.uuid };
-        dispatch<any>(initialize(COLLECTION_COPY_FORM_NAME, initialData));
-        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_COPY_FORM_NAME, data: {} }));
-    };
+export const openCollectionCopyDialog = (resource: { name: string; uuid: string; fromContextMenu?: boolean }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_COPY_FORM_NAME));
+    const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: "", uuid: resource.uuid, fromContextMenu: resource.fromContextMenu };
+    dispatch<any>(initialize(COLLECTION_COPY_FORM_NAME, initialData));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_COPY_FORM_NAME, data: {} }));
+};
+
+export const openMultiCollectionCopyDialog = (resource: { name: string; uuid: string; fromContextMenu?: boolean }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_MULTI_COPY_FORM_NAME));
+    const initialData: CopyFormDialogData = { name: `Copy of: ${resource.name}`, ownerUuid: "", uuid: resource.uuid, fromContextMenu: resource.fromContextMenu };
+    dispatch<any>(initialize(COLLECTION_MULTI_COPY_FORM_NAME, initialData));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MULTI_COPY_FORM_NAME, data: {} }));
+};
 
-export const copyCollection = (resource: CopyFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(COLLECTION_COPY_FORM_NAME));
+export const copyCollection =
+    (resource: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const formName = resource.fromContextMenu ? COLLECTION_COPY_FORM_NAME : COLLECTION_MULTI_COPY_FORM_NAME;
+        dispatch(startSubmit(formName));
         let collection = getResource<CollectionResource>(resource.uuid)(getState().resources);
         try {
             if (!collection) {
                 collection = await services.collectionService.get(resource.uuid);
             }
-            const collManifestText = await services.collectionService.get(resource.uuid, undefined, ['manifestText']);
+            const collManifestText = await services.collectionService.get(resource.uuid, undefined, ["manifestText"]);
             collection.manifestText = collManifestText.manifestText;
-            const {href, ...collectionRecord} = collection;
-            const newCollection = await services.collectionService.create({ ...collectionRecord, ownerUuid: resource.ownerUuid, name: resource.name });
-            dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
+            const { href, ...collectionRecord } = collection;
+            const newCollection = await services.collectionService.create(
+                {
+                    ...collectionRecord,
+                    ownerUuid: resource.ownerUuid,
+                    name: resource.name,
+                },
+                false
+            );
+            dispatch(dialogActions.CLOSE_DIALOG({ id: formName }));
             return newCollection;
         } catch (e) {
+            console.error("Error while copying collection: ", e);
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(
-                    COLLECTION_COPY_FORM_NAME,
-                    { ownerUuid: 'A collection with the same name already exists in the target project.' } as FormErrors
-                ));
+                dispatch(
+                    stopSubmit(formName, {
+                        ownerUuid: "A collection with the same name already exists in the target project.",
+                    } as FormErrors)
+                );
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: "Could not copy the collection.",
+                        hideDuration: 2000,
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
             } else {
-                dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_COPY_FORM_NAME }));
-                throw new Error('Could not copy the collection.');
+                dispatch(dialogActions.CLOSE_DIALOG({ id: formName }));
+                throw new Error("Could not copy the collection.");
             }
             return;
         } finally {
-            dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_COPY_FORM_NAME));
+            dispatch(progressIndicatorActions.STOP_WORKING(formName));
         }
     };
index 929f1612f7b8c3baa6ded53a46091e811cdc2a38..56c7b24c60e0ca555e097df8c58072447bae098a 100644 (file)
@@ -4,31 +4,30 @@
 
 import { Dispatch } from "redux";
 import { dialogActions } from "store/dialog/dialog-actions";
-import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
-import { ServiceRepository } from 'services/services';
-import { RootState } from 'store/store';
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
-import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
 import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
 import { getResource } from "store/resources/resources";
 import { CollectionResource } from "models/collection";
 
-export const COLLECTION_MOVE_FORM_NAME = 'collectionMoveFormName';
+export const COLLECTION_MOVE_FORM_NAME = "collectionMoveFormName";
 
-export const openMoveCollectionDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch) => {
-        dispatch<any>(resetPickerProjectTree());
-        dispatch<any>(initProjectsTreePicker(COLLECTION_MOVE_FORM_NAME));
-        dispatch(initialize(COLLECTION_MOVE_FORM_NAME, resource));
-        dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MOVE_FORM_NAME, data: {} }));
-    };
+export const openMoveCollectionDialog = (resource: { name: string; uuid: string }) => (dispatch: Dispatch) => {
+    dispatch<any>(resetPickerProjectTree());
+    dispatch<any>(initProjectsTreePicker(COLLECTION_MOVE_FORM_NAME));
+    dispatch(initialize(COLLECTION_MOVE_FORM_NAME, resource));
+    dispatch(dialogActions.OPEN_DIALOG({ id: COLLECTION_MOVE_FORM_NAME, data: {} }));
+};
 
-export const moveCollection = (resource: MoveToFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const moveCollection =
+    (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(COLLECTION_MOVE_FORM_NAME));
         let cachedCollection = getResource<CollectionResource>(resource.uuid)(getState().resources);
         try {
@@ -40,14 +39,18 @@ export const moveCollection = (resource: MoveToFormDialogData) =>
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
             dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
-            return {...cachedCollection, ...collection};
+            return { ...cachedCollection, ...collection };
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(COLLECTION_MOVE_FORM_NAME, { ownerUuid: 'A collection with the same name already exists in the target project.' } as FormErrors));
+                dispatch(
+                    stopSubmit(COLLECTION_MOVE_FORM_NAME, {
+                        ownerUuid: "A collection with the same name already exists in the target project.",
+                    } as FormErrors)
+                );
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_MOVE_FORM_NAME }));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the collection.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not move the collection.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
             }
             dispatch(progressIndicatorActions.STOP_WORKING(COLLECTION_MOVE_FORM_NAME));
             return;
index 4b3af2a2cdc0273a9913b53ec7d2a7069999cd7e..a0933c64da0ffb715d28df82db9472624346a7e7 100644 (file)
@@ -156,7 +156,13 @@ export const copyCollectionPartialToExistingCollection = (fileSelection: Collect
                 dispatch(progressIndicatorActions.START_WORKING(COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION));
 
                 // Copy files
-                const updatedCollection = await services.collectionService.copyFiles(fileSelection.collection.portableDataHash, fileSelection.selectedPaths, {uuid: formData.destination.uuid}, formData.destination.path || '/', false);
+                const updatedCollection = await services.collectionService.copyFiles(
+                    fileSelection.collection.portableDataHash,
+                    fileSelection.selectedPaths,
+                    {uuid: formData.destination.uuid},
+                    formData.destination.subpath || '/',
+                    false
+                );
                 dispatch(updateResources([updatedCollection]));
 
                 dispatch(dialogActions.CLOSE_DIALOG({ id: COLLECTION_PARTIAL_COPY_TO_SELECTED_COLLECTION }));
index 92e209811d91b465496f3dbf0ab169c7dbe09fef..56f7302db127873f3350b0c92ffb43a6aef3e279 100644 (file)
@@ -158,7 +158,7 @@ export const moveCollectionPartialToExistingCollection = (fileSelection: Collect
                     fileSelection.collection.portableDataHash,
                     fileSelection.selectedPaths,
                     {uuid: formData.destination.uuid},
-                    formData.destination.path || '/', false
+                    formData.destination.subpath || '/', false
                 );
                 dispatch(updateResources([updatedCollection]));
 
index 4abfb372f915152f62761cd4440df6605df0914a..464314877ff645328d838f2ddbbb1e4cd2a99ec7 100644 (file)
@@ -2,32 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { unionize, ofType, UnionOf } from 'common/unionize';
+import { unionize, ofType, UnionOf } from "common/unionize";
 import { ContextMenuPosition } from "./context-menu-reducer";
-import { ContextMenuKind } from 'views-components/context-menu/context-menu';
-import { Dispatch } from 'redux';
-import { RootState } from 'store/store';
-import { getResource, getResourceWithEditableStatus } from '../resources/resources';
-import { UserResource } from 'models/user';
-import { isSidePanelTreeCategory } from 'store/side-panel-tree/side-panel-tree-actions';
-import { extractUuidKind, ResourceKind, EditableResource, Resource } from 'models/resource';
-import { Process } from 'store/processes/process';
-import { RepositoryResource } from 'models/repositories';
-import { SshKeyResource } from 'models/ssh-key';
-import { VirtualMachinesResource } from 'models/virtual-machines';
-import { KeepServiceResource } from 'models/keep-services';
-import { ProcessResource } from 'models/process';
-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';
-import { filterCollectionFilesBySelection } from 'store/collection-panel/collection-panel-files/collection-panel-files-state';
+import { ContextMenuKind } from "views-components/context-menu/context-menu";
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getResource, getResourceWithEditableStatus } from "../resources/resources";
+import { UserResource } from "models/user";
+import { isSidePanelTreeCategory } from "store/side-panel-tree/side-panel-tree-actions";
+import { extractUuidKind, ResourceKind, EditableResource, Resource } from "models/resource";
+import { Process, isProcessCancelable } from "store/processes/process";
+import { RepositoryResource } from "models/repositories";
+import { SshKeyResource } from "models/ssh-key";
+import { VirtualMachinesResource } from "models/virtual-machines";
+import { KeepServiceResource } from "models/keep-services";
+import { ProcessResource } from "models/process";
+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";
+import { getProcess } from "store/processes/process";
+import { filterCollectionFilesBySelection } from "store/collection-panel/collection-panel-files/collection-panel-files-state";
 
 export const contextMenuActions = unionize({
-    OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition, resource: ContextMenuResource }>(),
-    CLOSE_CONTEXT_MENU: ofType<{}>()
+    OPEN_CONTEXT_MENU: ofType<{ position: ContextMenuPosition; resource: ContextMenuResource }>(),
+    CLOSE_CONTEXT_MENU: ofType<{}>(),
 });
 
 export type ContextMenuAction = UnionOf<typeof contextMenuActions>;
@@ -37,7 +38,7 @@ export type ContextMenuResource = {
     uuid: string;
     ownerUuid: string;
     description?: string;
-    kind: ResourceKind,
+    kind: ResourceKind;
     menuKind: ContextMenuKind | string;
     isTrashed?: boolean;
     isEditable?: boolean;
@@ -47,192 +48,214 @@ export type ContextMenuResource = {
     isFrozen?: boolean;
     storageClassesDesired?: string[];
     properties?: { [key: string]: string | string[] };
+    isMulti?: boolean;
+    fromContextMenu?: boolean;
 };
 
 export const isKeyboardClick = (event: React.MouseEvent<HTMLElement>) => event.nativeEvent.detail === 0;
 
-export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) =>
-    (dispatch: Dispatch) => {
-        event.preventDefault();
-        const { left, top } = event.currentTarget.getBoundingClientRect();
-        dispatch(
-            contextMenuActions.OPEN_CONTEXT_MENU({
-                position: {
-                    x: event.clientX || left,
-                    y: event.clientY || top,
-                },
-                resource
-            })
-        );
-    };
+export const openContextMenu = (event: React.MouseEvent<HTMLElement>, resource: ContextMenuResource) => (dispatch: Dispatch) => {
+    event.preventDefault();
+    const { left, top } = event.currentTarget.getBoundingClientRect();
+    dispatch(
+        contextMenuActions.OPEN_CONTEXT_MENU({
+            position: {
+                x: event.clientX || left,
+                y: event.clientY || top,
+            },
+            resource,
+        })
+    );
+};
 
-export const openCollectionFilesContextMenu = (event: React.MouseEvent<HTMLElement>, isWritable: boolean) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openCollectionFilesContextMenu =
+    (event: React.MouseEvent<HTMLElement>, isWritable: boolean) => (dispatch: Dispatch, getState: () => RootState) => {
         const selectedCount = filterCollectionFilesBySelection(getState().collectionPanelFiles, true).length;
         const multiple = selectedCount > 1;
-        dispatch<any>(openContextMenu(event, {
-            name: '',
-            uuid: '',
-            ownerUuid: '',
-            description: '',
-            kind: ResourceKind.COLLECTION,
-            menuKind: selectedCount > 0
-                ? isWritable
-                    ? multiple ? ContextMenuKind.COLLECTION_FILES_MULTIPLE : ContextMenuKind.COLLECTION_FILES
-                    : multiple ? ContextMenuKind.READONLY_COLLECTION_FILES_MULTIPLE : ContextMenuKind.READONLY_COLLECTION_FILES
-                : ContextMenuKind.COLLECTION_FILES_NOT_SELECTED
-        }));
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: "",
+                ownerUuid: "",
+                description: "",
+                kind: ResourceKind.COLLECTION,
+                menuKind:
+                    selectedCount > 0
+                        ? isWritable
+                            ? multiple
+                                ? ContextMenuKind.COLLECTION_FILES_MULTIPLE
+                                : ContextMenuKind.COLLECTION_FILES
+                            : multiple
+                            ? ContextMenuKind.READONLY_COLLECTION_FILES_MULTIPLE
+                            : ContextMenuKind.READONLY_COLLECTION_FILES
+                        : ContextMenuKind.COLLECTION_FILES_NOT_SELECTED,
+            })
+        );
     };
 
-export const openRepositoryContextMenu = (event: React.MouseEvent<HTMLElement>, repository: RepositoryResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
-            uuid: repository.uuid,
-            ownerUuid: repository.ownerUuid,
-            kind: ResourceKind.REPOSITORY,
-            menuKind: ContextMenuKind.REPOSITORY
-        }));
+export const openRepositoryContextMenu =
+    (event: React.MouseEvent<HTMLElement>, repository: RepositoryResource) => (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: repository.uuid,
+                ownerUuid: repository.ownerUuid,
+                kind: ResourceKind.REPOSITORY,
+                menuKind: ContextMenuKind.REPOSITORY,
+            })
+        );
     };
 
-export const openVirtualMachinesContextMenu = (event: React.MouseEvent<HTMLElement>, repository: VirtualMachinesResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
-            uuid: repository.uuid,
-            ownerUuid: repository.ownerUuid,
-            kind: ResourceKind.VIRTUAL_MACHINE,
-            menuKind: ContextMenuKind.VIRTUAL_MACHINE
-        }));
+export const openVirtualMachinesContextMenu =
+    (event: React.MouseEvent<HTMLElement>, repository: VirtualMachinesResource) => (dispatch: Dispatch, getState: () => RootState) => {
+        dispatch<any>(
+            openContextMenu(event, {
+                name: "",
+                uuid: repository.uuid,
+                ownerUuid: repository.ownerUuid,
+                kind: ResourceKind.VIRTUAL_MACHINE,
+                menuKind: ContextMenuKind.VIRTUAL_MACHINE,
+            })
+        );
     };
 
-export const openSshKeyContextMenu = (event: React.MouseEvent<HTMLElement>, sshKey: SshKeyResource) =>
-    (dispatch: Dispatch) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
+export const openSshKeyContextMenu = (event: React.MouseEvent<HTMLElement>, sshKey: SshKeyResource) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
             uuid: sshKey.uuid,
             ownerUuid: sshKey.ownerUuid,
             kind: ResourceKind.SSH_KEY,
-            menuKind: ContextMenuKind.SSH_KEY
-        }));
-    };
+            menuKind: ContextMenuKind.SSH_KEY,
+        })
+    );
+};
 
-export const openKeepServiceContextMenu = (event: React.MouseEvent<HTMLElement>, keepService: KeepServiceResource) =>
-    (dispatch: Dispatch) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
+export const openKeepServiceContextMenu = (event: React.MouseEvent<HTMLElement>, keepService: KeepServiceResource) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
             uuid: keepService.uuid,
             ownerUuid: keepService.ownerUuid,
             kind: ResourceKind.KEEP_SERVICE,
-            menuKind: ContextMenuKind.KEEP_SERVICE
-        }));
-    };
+            menuKind: ContextMenuKind.KEEP_SERVICE,
+        })
+    );
+};
 
-export const openApiClientAuthorizationContextMenu =
-    (event: React.MouseEvent<HTMLElement>, resourceUuid: string) =>
-        (dispatch: Dispatch) => {
-            dispatch<any>(openContextMenu(event, {
-                name: '',
-                uuid: resourceUuid,
-                ownerUuid: '',
-                kind: ResourceKind.API_CLIENT_AUTHORIZATION,
-                menuKind: ContextMenuKind.API_CLIENT_AUTHORIZATION
-            }));
-        };
+export const openApiClientAuthorizationContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => (dispatch: Dispatch) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
+            uuid: resourceUuid,
+            ownerUuid: "",
+            kind: ResourceKind.API_CLIENT_AUTHORIZATION,
+            menuKind: ContextMenuKind.API_CLIENT_AUTHORIZATION,
+        })
+    );
+};
 
-export const openRootProjectContextMenu = (event: React.MouseEvent<HTMLElement>, projectUuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openRootProjectContextMenu =
+    (event: React.MouseEvent<HTMLElement>, projectUuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<UserResource>(projectUuid)(getState().resources);
         if (res) {
-            dispatch<any>(openContextMenu(event, {
-                name: '',
-                uuid: res.uuid,
-                ownerUuid: res.uuid,
-                kind: res.kind,
-                menuKind: ContextMenuKind.ROOT_PROJECT,
-                isTrashed: false
-            }));
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: "",
+                    uuid: res.uuid,
+                    ownerUuid: res.uuid,
+                    kind: res.kind,
+                    menuKind: ContextMenuKind.ROOT_PROJECT,
+                    isTrashed: false,
+                })
+            );
         }
     };
 
-export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openProjectContextMenu =
+    (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<GroupContentsResource>(resourceUuid)(getState().resources);
         const menuKind = dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
         if (res && menuKind) {
-            dispatch<any>(openContextMenu(event, {
-                name: res.name,
-                uuid: res.uuid,
-                kind: res.kind,
-                menuKind,
-                description: res.description,
-                ownerUuid: res.ownerUuid,
-                isTrashed: ('isTrashed' in res) ? res.isTrashed : false,
-                isFrozen: !!(res as ProjectResource).frozenByUuid,
-            }));
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: res.name,
+                    uuid: res.uuid,
+                    kind: res.kind,
+                    menuKind,
+                    description: res.description,
+                    ownerUuid: res.ownerUuid,
+                    isTrashed: "isTrashed" in res ? res.isTrashed : false,
+                    isFrozen: !!(res as ProjectResource).frozenByUuid,
+                })
+            );
         }
     };
 
-export const openSidePanelContextMenu = (event: React.MouseEvent<HTMLElement>, id: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        if (!isSidePanelTreeCategory(id)) {
-            const kind = extractUuidKind(id);
-            if (kind === ResourceKind.USER) {
-                dispatch<any>(openRootProjectContextMenu(event, id));
-            } else if (kind === ResourceKind.PROJECT) {
-                dispatch<any>(openProjectContextMenu(event, id));
-            }
+export const openSidePanelContextMenu = (event: React.MouseEvent<HTMLElement>, id: string) => (dispatch: Dispatch, getState: () => RootState) => {
+    if (!isSidePanelTreeCategory(id)) {
+        const kind = extractUuidKind(id);
+        if (kind === ResourceKind.USER) {
+            dispatch<any>(openRootProjectContextMenu(event, id));
+        } else if (kind === ResourceKind.PROJECT) {
+            dispatch<any>(openProjectContextMenu(event, id));
         }
-    };
+    }
+};
 
-export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, process: Process) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        const res = getResource<ProcessResource>(process.containerRequest.uuid)(getState().resources);
-        if (res) {
-            dispatch<any>(openContextMenu(event, {
+export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, process: Process) => (dispatch: Dispatch, getState: () => RootState) => {
+    const res = getResource<ProcessResource>(process.containerRequest.uuid)(getState().resources);
+    if (res) {
+        dispatch<any>(
+            openContextMenu(event, {
                 uuid: res.uuid,
                 ownerUuid: res.ownerUuid,
                 kind: ResourceKind.PROCESS,
                 name: res.name,
                 description: res.description,
-                outputUuid: res.outputUuid || '',
-                workflowUuid: res.properties.template_uuid || '',
-                menuKind: ContextMenuKind.PROCESS_RESOURCE
-            }));
-        }
-    };
+                outputUuid: res.outputUuid || "",
+                workflowUuid: res.properties.template_uuid || "",
+                menuKind: isProcessCancelable(process) ? ContextMenuKind.RUNNING_PROCESS_RESOURCE : ContextMenuKind.PROCESS_RESOURCE
+            })
+        );
+    }
+};
 
-export const openPermissionEditContextMenu = (event: React.MouseEvent<HTMLElement>, link: LinkResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openPermissionEditContextMenu =
+    (event: React.MouseEvent<HTMLElement>, link: LinkResource) => (dispatch: Dispatch, getState: () => RootState) => {
         if (link) {
-            dispatch<any>(openContextMenu(event, {
-                name: link.name,
-                uuid: link.uuid,
-                kind: link.kind,
-                menuKind: ContextMenuKind.PERMISSION_EDIT,
-                ownerUuid: link.ownerUuid,
-            }));
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: link.name,
+                    uuid: link.uuid,
+                    kind: link.kind,
+                    menuKind: ContextMenuKind.PERMISSION_EDIT,
+                    ownerUuid: link.ownerUuid,
+                })
+            );
         }
     };
 
-export const openUserContextMenu = (event: React.MouseEvent<HTMLElement>, user: UserResource) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch<any>(openContextMenu(event, {
-            name: '',
+export const openUserContextMenu = (event: React.MouseEvent<HTMLElement>, user: UserResource) => (dispatch: Dispatch, getState: () => RootState) => {
+    dispatch<any>(
+        openContextMenu(event, {
+            name: "",
             uuid: user.uuid,
             ownerUuid: user.ownerUuid,
             kind: user.kind,
-            menuKind: ContextMenuKind.USER
-        }));
-    };
+            menuKind: ContextMenuKind.USER,
+        })
+    );
+};
 
-export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
+export const resourceUuidToContextMenuKind =
+    (uuid: string, readonly = false) =>
     (dispatch: Dispatch, getState: () => RootState) => {
         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 && !isFrozen;
 
         switch (kind) {
             case ResourceKind.PROJECT:
@@ -240,35 +263,41 @@ export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
                     return isAdminUser ? ContextMenuKind.FROZEN_PROJECT_ADMIN : ContextMenuKind.FROZEN_PROJECT;
                 }
 
-                return (isAdminUser && !readonly)
-                    ? (resource && resource.groupClass !== GroupClass.FILTER)
+                return isAdminUser && !readonly
+                    ? resource && resource.groupClass !== GroupClass.FILTER
                         ? ContextMenuKind.PROJECT_ADMIN
                         : ContextMenuKind.FILTER_GROUP_ADMIN
                     : isEditable
-                        ? (resource && resource.groupClass !== GroupClass.FILTER)
-                            ? ContextMenuKind.PROJECT
-                            : ContextMenuKind.FILTER_GROUP
-                        : ContextMenuKind.READONLY_PROJECT;
+                    ? resource && resource.groupClass !== GroupClass.FILTER
+                        ? ContextMenuKind.PROJECT
+                        : ContextMenuKind.FILTER_GROUP
+                    : ContextMenuKind.READONLY_PROJECT;
             case ResourceKind.COLLECTION:
                 const c = getResource<CollectionResource>(uuid)(getState().resources);
-                if (c === undefined) { return; }
+                if (c === undefined) {
+                    return;
+                }
                 const isOldVersion = c.uuid !== c.currentVersionUuid;
                 const isTrashed = c.isTrashed;
                 return isOldVersion
                     ? ContextMenuKind.OLD_VERSION_COLLECTION
-                    : (isTrashed && isEditable)
-                        ? ContextMenuKind.TRASHED_COLLECTION
-                        : (isAdminUser && isEditable)
-                            ? ContextMenuKind.COLLECTION_ADMIN
-                            : isEditable
-                                ? ContextMenuKind.COLLECTION
-                                : ContextMenuKind.READONLY_COLLECTION;
+                    : isTrashed && isEditable
+                    ? ContextMenuKind.TRASHED_COLLECTION
+                    : isAdminUser && isEditable
+                    ? ContextMenuKind.COLLECTION_ADMIN
+                    : isEditable
+                    ? ContextMenuKind.COLLECTION
+                    : ContextMenuKind.READONLY_COLLECTION;
             case ResourceKind.PROCESS:
-                return (isAdminUser && isEditable)
-                    ? ContextMenuKind.PROCESS_ADMIN
+                return isAdminUser && isEditable
+                    ? resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process)
+                        ? ContextMenuKind.RUNNING_PROCESS_ADMIN
+                        : ContextMenuKind.PROCESS_ADMIN
                     : readonly
-                        ? ContextMenuKind.READONLY_PROCESS_RESOURCE
-                        : ContextMenuKind.PROCESS_RESOURCE;
+                    ? ContextMenuKind.READONLY_PROCESS_RESOURCE
+                    : resource && isProcessCancelable(getProcess(resource.uuid)(getState().resources) as Process)
+                    ? ContextMenuKind.RUNNING_PROCESS_RESOURCE
+                    : ContextMenuKind.PROCESS_RESOURCE;
             case ResourceKind.USER:
                 return ContextMenuKind.ROOT_PROJECT;
             case ResourceKind.LINK:
@@ -280,16 +309,18 @@ export const resourceUuidToContextMenuKind = (uuid: string, readonly = false) =>
         }
     };
 
-export const openSearchResultsContextMenu = (event: React.MouseEvent<HTMLElement>, uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
+export const openSearchResultsContextMenu =
+    (event: React.MouseEvent<HTMLElement>, uuid: string) => (dispatch: Dispatch, getState: () => RootState) => {
         const res = getResource<Resource>(uuid)(getState().resources);
         if (res) {
-            dispatch<any>(openContextMenu(event, {
-                name: '',
-                uuid: res.uuid,
-                ownerUuid: '',
-                kind: res.kind,
-                menuKind: ContextMenuKind.SEARCH_RESULTS,
-            }));
+            dispatch<any>(
+                openContextMenu(event, {
+                    name: "",
+                    uuid: res.uuid,
+                    ownerUuid: "",
+                    kind: res.kind,
+                    menuKind: ContextMenuKind.SEARCH_RESULTS,
+                })
+            );
         }
     };
index 4450cfc6bf5b0f5ae5a481b71a9a5f7eea3d8e7d..dfae5c2cf0101eb5bd0dad71504568f1168309e7 100644 (file)
@@ -6,4 +6,5 @@ export interface CopyFormDialogData {
     name: string;
     uuid: string;
     ownerUuid: string;
-}
\ No newline at end of file
+    fromContextMenu?: boolean;
+}
index 22b786fd186c69fdf3f962e3ea67fb471c1eab31..ea050e609f558a91decb73ac7badd65fe18f7d3f 100644 (file)
@@ -4,64 +4,51 @@
 
 import { unionize, ofType, UnionOf } from "common/unionize";
 import { DataColumns, DataTableFetchMode } from "components/data-table/data-table";
-import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
 
 export enum DataTableRequestState {
     IDLE,
     PENDING,
-    NEED_REFRESH
+    NEED_REFRESH,
 }
 
 export const dataExplorerActions = unionize({
     CLEAR: ofType<{ id: string }>(),
     RESET_PAGINATION: ofType<{ id: string }>(),
-    REQUEST_ITEMS: ofType<{ id: string, criteriaChanged?: boolean }>(),
-    REQUEST_STATE: ofType<{ id: string, criteriaChanged?: boolean }>(),
-    SET_FETCH_MODE: ofType<({ id: string, fetchMode: DataTableFetchMode })>(),
-    SET_COLUMNS: ofType<{ id: string, columns: DataColumns<any, any> }>(),
-    SET_FILTERS: ofType<{ id: string, columnName: string, filters: DataTableFilters }>(),
-    SET_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(),
-    APPEND_ITEMS: ofType<{ id: string, items: any[], page: number, rowsPerPage: number, itemsAvailable: number }>(),
-    SET_PAGE: ofType<{ id: string, page: number }>(),
-    SET_ROWS_PER_PAGE: ofType<{ id: string, rowsPerPage: number }>(),
-    TOGGLE_COLUMN: ofType<{ id: string, columnName: string }>(),
-    TOGGLE_SORT: ofType<{ id: string, columnName: string }>(),
-    SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string, searchValue: string }>(),
+    REQUEST_ITEMS: ofType<{ id: string; criteriaChanged?: boolean, background?: boolean }>(),
+    REQUEST_STATE: ofType<{ id: string; criteriaChanged?: boolean }>(),
+    SET_FETCH_MODE: ofType<{ id: string; fetchMode: DataTableFetchMode }>(),
+    SET_COLUMNS: ofType<{ id: string; columns: DataColumns<any, any> }>(),
+    SET_FILTERS: ofType<{ id: string; columnName: string; filters: DataTableFilters }>(),
+    SET_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(),
+    APPEND_ITEMS: ofType<{ id: string; items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }>(),
+    SET_PAGE: ofType<{ id: string; page: number }>(),
+    SET_ROWS_PER_PAGE: ofType<{ id: string; rowsPerPage: number }>(),
+    TOGGLE_COLUMN: ofType<{ id: string; columnName: string }>(),
+    TOGGLE_SORT: ofType<{ id: string; columnName: string }>(),
+    SET_EXPLORER_SEARCH_VALUE: ofType<{ id: string; searchValue: string }>(),
     RESET_EXPLORER_SEARCH_VALUE: ofType<{ id: string }>(),
-    SET_REQUEST_STATE: ofType<{ id: string, requestState: DataTableRequestState }>(),
+    SET_REQUEST_STATE: ofType<{ id: string; requestState: DataTableRequestState }>(),
 });
 
 export type DataExplorerAction = UnionOf<typeof dataExplorerActions>;
 
 export const bindDataExplorerActions = (id: string) => ({
-    CLEAR: () =>
-        dataExplorerActions.CLEAR({ id }),
-    RESET_PAGINATION: () =>
-        dataExplorerActions.RESET_PAGINATION({ id }),
-    REQUEST_ITEMS: (criteriaChanged?: boolean) =>
-        dataExplorerActions.REQUEST_ITEMS({ id, criteriaChanged }),
-    SET_FETCH_MODE: (payload: { fetchMode: DataTableFetchMode }) =>
-        dataExplorerActions.SET_FETCH_MODE({ ...payload, id }),
-    SET_COLUMNS: (payload: { columns: DataColumns<any, any> }) =>
-        dataExplorerActions.SET_COLUMNS({ ...payload, id }),
-    SET_FILTERS: (payload: { columnName: string, filters: DataTableFilters }) =>
-        dataExplorerActions.SET_FILTERS({ ...payload, id }),
-    SET_ITEMS: (payload: { items: any[], page: number, rowsPerPage: number, itemsAvailable: number }) =>
+    CLEAR: () => dataExplorerActions.CLEAR({ id }),
+    RESET_PAGINATION: () => dataExplorerActions.RESET_PAGINATION({ id }),
+    REQUEST_ITEMS: (criteriaChanged?: boolean, background?: boolean) => dataExplorerActions.REQUEST_ITEMS({ id, criteriaChanged, background }),
+    SET_FETCH_MODE: (payload: { fetchMode: DataTableFetchMode }) => dataExplorerActions.SET_FETCH_MODE({ ...payload, id }),
+    SET_COLUMNS: (payload: { columns: DataColumns<any, any> }) => dataExplorerActions.SET_COLUMNS({ ...payload, id }),
+    SET_FILTERS: (payload: { columnName: string; filters: DataTableFilters }) => dataExplorerActions.SET_FILTERS({ ...payload, id }),
+    SET_ITEMS: (payload: { items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }) =>
         dataExplorerActions.SET_ITEMS({ ...payload, id }),
-    APPEND_ITEMS: (payload: { items: any[], page: number, rowsPerPage: number, itemsAvailable: number }) =>
+    APPEND_ITEMS: (payload: { items: any[]; page: number; rowsPerPage: number; itemsAvailable: number }) =>
         dataExplorerActions.APPEND_ITEMS({ ...payload, id }),
-    SET_PAGE: (payload: { page: number }) =>
-        dataExplorerActions.SET_PAGE({ ...payload, id }),
-    SET_ROWS_PER_PAGE: (payload: { rowsPerPage: number }) =>
-        dataExplorerActions.SET_ROWS_PER_PAGE({ ...payload, id }),
-    TOGGLE_COLUMN: (payload: { columnName: string }) =>
-        dataExplorerActions.TOGGLE_COLUMN({ ...payload, id }),
-    TOGGLE_SORT: (payload: { columnName: string }) =>
-        dataExplorerActions.TOGGLE_SORT({ ...payload, id }),
-    SET_EXPLORER_SEARCH_VALUE: (payload: { searchValue: string }) =>
-        dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ ...payload, id }),
-    RESET_EXPLORER_SEARCH_VALUE: () =>
-        dataExplorerActions.RESET_EXPLORER_SEARCH_VALUE({ id }),
-    SET_REQUEST_STATE: (payload: { requestState: DataTableRequestState }) =>
-        dataExplorerActions.SET_REQUEST_STATE({ ...payload, id })
+    SET_PAGE: (payload: { page: number }) => dataExplorerActions.SET_PAGE({ ...payload, id }),
+    SET_ROWS_PER_PAGE: (payload: { rowsPerPage: number }) => dataExplorerActions.SET_ROWS_PER_PAGE({ ...payload, id }),
+    TOGGLE_COLUMN: (payload: { columnName: string }) => dataExplorerActions.TOGGLE_COLUMN({ ...payload, id }),
+    TOGGLE_SORT: (payload: { columnName: string }) => dataExplorerActions.TOGGLE_SORT({ ...payload, id }),
+    SET_EXPLORER_SEARCH_VALUE: (payload: { searchValue: string }) => dataExplorerActions.SET_EXPLORER_SEARCH_VALUE({ ...payload, id }),
+    RESET_EXPLORER_SEARCH_VALUE: () => dataExplorerActions.RESET_EXPLORER_SEARCH_VALUE({ id }),
+    SET_REQUEST_STATE: (payload: { requestState: DataTableRequestState }) => dataExplorerActions.SET_REQUEST_STATE({ ...payload, id }),
 });
index 01964fa48a9f260442205ab14e33a9f5dc9b09f2..6bb95a9a6c05b1e6d14bb3607b3dbfdded1b0e90 100644 (file)
@@ -33,7 +33,8 @@ export abstract class DataExplorerMiddlewareService {
 
     abstract requestItems(
         api: MiddlewareAPI<Dispatch, RootState>,
-        criteriaChanged?: boolean
+        criteriaChanged?: boolean,
+        background?: boolean
     ): Promise<void>;
 }
 
@@ -58,8 +59,10 @@ export const getOrder = <T extends Resource = Resource>(dataExplorer: DataExplor
             ? OrderDirection.ASC
             : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field)
+            .addOrder(OrderDirection.DESC, "createdAt")
             .getOrder();
     } else {
         return order.getOrder();
index f83b064641ee82f6d7922ba801f4c85fff5b29e4..3404b375a86b3e6a48c779a441a3c89043443754 100644 (file)
@@ -16,98 +16,98 @@ import { DataExplorerMiddlewareService } from './data-explorer-middleware-servic
 
 export const dataExplorerMiddleware =
     (service: DataExplorerMiddlewareService): Middleware =>
-    (api) =>
-    (next) => {
-        const actions = bindDataExplorerActions(service.getId());
+        (api) =>
+            (next) => {
+                const actions = bindDataExplorerActions(service.getId());
 
-        return (action) => {
-            const handleAction =
-                <T extends { id: string }>(handler: (data: T) => void) =>
-                    (data: T) => {
-                        next(action);
-                        if (data.id === service.getId()) {
-                            handler(data);
-                        }
-                    };
-            dataExplorerActions.match(action, {
-                SET_PAGE: handleAction(() => {
-                    api.dispatch(actions.REQUEST_ITEMS(false));
-                }),
-                SET_ROWS_PER_PAGE: handleAction(() => {
-                    api.dispatch(actions.REQUEST_ITEMS(true));
-                }),
-                SET_FILTERS: handleAction(() => {
-                    api.dispatch(actions.RESET_PAGINATION());
-                    api.dispatch(actions.REQUEST_ITEMS(true));
-                }),
-                TOGGLE_SORT: handleAction(() => {
-                    api.dispatch(actions.REQUEST_ITEMS(true));
-                }),
-                SET_EXPLORER_SEARCH_VALUE: handleAction(() => {
-                    api.dispatch(actions.RESET_PAGINATION());
-                    api.dispatch(actions.REQUEST_ITEMS(true));
-                }),
-                REQUEST_ITEMS: handleAction(({ criteriaChanged }) => {
-                    api.dispatch<any>(async (
-                        dispatch: Dispatch,
-                        getState: () => RootState,
-                        services: ServiceRepository
-                    ) => {
-                        while (true) {
-                            let de = getDataExplorer(
-                                getState().dataExplorer,
-                                service.getId()
-                            );
-                            switch (de.requestState) {
-                                case DataTableRequestState.IDLE:
-                                    // Start a new request.
-                                    try {
-                                        dispatch(
-                                            actions.SET_REQUEST_STATE({
-                                                requestState: DataTableRequestState.PENDING,
-                                            })
-                                        );
-                                        await service.requestItems(api, criteriaChanged);
-                                    } catch {
-                                        dispatch(
-                                            actions.SET_REQUEST_STATE({
-                                                requestState: DataTableRequestState.NEED_REFRESH,
-                                            })
-                                        );
-                                    }
-                                    // Now check if the state is still PENDING, if it moved to NEED_REFRESH
-                                    // then we need to reissue requestItems
-                                    de = getDataExplorer(
+                return (action) => {
+                    const handleAction =
+                        <T extends { id: string }>(handler: (data: T) => void) =>
+                            (data: T) => {
+                                next(action);
+                                if (data.id === service.getId()) {
+                                    handler(data);
+                                }
+                            };
+                    dataExplorerActions.match(action, {
+                        SET_PAGE: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(false));
+                        }),
+                        SET_ROWS_PER_PAGE: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        SET_FILTERS: handleAction(() => {
+                            api.dispatch(actions.RESET_PAGINATION());
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        TOGGLE_SORT: handleAction(() => {
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        SET_EXPLORER_SEARCH_VALUE: handleAction(() => {
+                            api.dispatch(actions.RESET_PAGINATION());
+                            api.dispatch(actions.REQUEST_ITEMS(true));
+                        }),
+                        REQUEST_ITEMS: handleAction(({ criteriaChanged, background }) => {
+                            api.dispatch<any>(async (
+                                dispatch: Dispatch,
+                                getState: () => RootState,
+                                services: ServiceRepository
+                            ) => {
+                                while (true) {
+                                    let de = getDataExplorer(
                                         getState().dataExplorer,
                                         service.getId()
                                     );
-                                    const complete =
-                                        de.requestState === DataTableRequestState.PENDING;
-                                    dispatch(
-                                        actions.SET_REQUEST_STATE({
-                                            requestState: DataTableRequestState.IDLE,
-                                        })
-                                    );
-                                    if (complete) {
-                                        return;
+                                    switch (de.requestState) {
+                                        case DataTableRequestState.IDLE:
+                                            // Start a new request.
+                                            try {
+                                                dispatch(
+                                                    actions.SET_REQUEST_STATE({
+                                                        requestState: DataTableRequestState.PENDING,
+                                                    })
+                                                );
+                                                await service.requestItems(api, criteriaChanged, background);
+                                            } catch {
+                                                dispatch(
+                                                    actions.SET_REQUEST_STATE({
+                                                        requestState: DataTableRequestState.NEED_REFRESH,
+                                                    })
+                                                );
+                                            }
+                                            // Now check if the state is still PENDING, if it moved to NEED_REFRESH
+                                            // then we need to reissue requestItems
+                                            de = getDataExplorer(
+                                                getState().dataExplorer,
+                                                service.getId()
+                                            );
+                                            const complete =
+                                                de.requestState === DataTableRequestState.PENDING;
+                                            dispatch(
+                                                actions.SET_REQUEST_STATE({
+                                                    requestState: DataTableRequestState.IDLE,
+                                                })
+                                            );
+                                            if (complete) {
+                                                return;
+                                            }
+                                            break;
+                                        case DataTableRequestState.PENDING:
+                                            // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one.
+                                            dispatch(
+                                                actions.SET_REQUEST_STATE({
+                                                    requestState: DataTableRequestState.NEED_REFRESH,
+                                                })
+                                            );
+                                            return;
+                                        case DataTableRequestState.NEED_REFRESH:
+                                            // Nothing to do right now.
+                                            return;
                                     }
-                                    break;
-                                case DataTableRequestState.PENDING:
-                                    // State is PENDING, move it to NEED_REFRESH so that when the current request finishes it starts a new one.
-                                    dispatch(
-                                        actions.SET_REQUEST_STATE({
-                                            requestState: DataTableRequestState.NEED_REFRESH,
-                                        })
-                                    );
-                                    return;
-                                case DataTableRequestState.NEED_REFRESH:
-                                    // Nothing to do right now.
-                                    return;
-                            }
-                        }
+                                }
+                            });
+                        }),
+                        default: () => next(action),
                     });
-                }),
-                default: () => next(action),
-            });
-        };
-    };
+                };
+            };
index 30368685064dfe6f9837d7c20d0cc4808b0cbf01..548d0a7897f99ba48a27c9826bf02bc18ad8d9ca 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { DialogAction, dialogActions } from "./dialog-actions";
+import { DialogAction, dialogActions } from './dialog-actions';
 
 export type DialogState = Record<string, Dialog<any>>;
 
@@ -12,16 +12,14 @@ export interface Dialog<T> {
 }
 
 export const dialogReducer = (state: DialogState = {}, action: DialogAction) =>
-
     dialogActions.match(action, {
         OPEN_DIALOG: ({ id, data }) => ({ ...state, [id]: { open: true, data } }),
         CLOSE_DIALOG: ({ id }) => ({
             ...state,
-            [id]: state[id] ? { ...state[id], open: false } : { open: false, data: {} }
+            [id]: state[id] ? { ...state[id], open: false } : { open: false, data: {} },
         }),
-        CLOSE_ALL_DIALOGS: () => ({ }),
+        CLOSE_ALL_DIALOGS: () => ({}),
         default: () => state,
     });
 
-export const getDialog = <T>(state: DialogState, id: string) =>
-    state[id] ? state[id] as Dialog<T> : undefined;
+export const getDialog = <T>(state: DialogState, id: string) => (state[id] ? (state[id] as Dialog<T>) : undefined);
index ea96ca0d7621b1959c38e3fb5bc25eaaded59a98..7a253860429b948779bb538552e0c8bac79aa61a 100644 (file)
@@ -18,7 +18,8 @@ export type WithDialogDispatchProps = {
 };
 
 export type WithDialogProps<T> = WithDialogStateProps<T> & WithDialogDispatchProps;
-export const withDialog = (id: string) =>
+export const withDialog =
+    (id: string) =>
     // TODO: How to make compiler happy with & P instead of & any?
     // eslint-disable-next-line
     <T, P>(component: React.ComponentType<WithDialogProps<T> & any>) =>
@@ -26,13 +27,17 @@ export const withDialog = (id: string) =>
 
 const emptyData = {};
 
-export const mapStateToProps = (id: string) => <T>(state: { dialog: DialogState }): WithDialogStateProps<T> => {
-    const dialog = state.dialog[id];
-    return dialog ? dialog : { open: false, data: emptyData };
-};
+export const mapStateToProps =
+    (id: string) =>
+    <T>(state: { dialog: DialogState }): WithDialogStateProps<T> => {
+        const dialog = state.dialog[id];
+        return dialog ? dialog : { open: false, data: emptyData };
+    };
 
-export const mapDispatchToProps = (id: string) => (dispatch: Dispatch): WithDialogDispatchProps => ({
-    closeDialog: () => {
-        dispatch(dialogActions.CLOSE_DIALOG({ id }));
-    }
-});
+export const mapDispatchToProps =
+    (id: string) =>
+    (dispatch: Dispatch): WithDialogDispatchProps => ({
+        closeDialog: () => {
+            dispatch(dialogActions.CLOSE_DIALOG({ id }));
+        },
+    });
index 3a58927aac7cd3da6a3c41d13aef2fa3161dc82a..507b4eb30fb2aaa1fdd5d38add18f7b56b03c32a 100644 (file)
@@ -13,6 +13,7 @@ import { updateResources } from 'store/resources/resources-actions';
 import { getCurrentGroupDetailsPanelUuid, GroupMembersPanelActions } from 'store/group-details-panel/group-details-panel-actions';
 import { LinkClass } from 'models/link';
 import { ResourceKind } from 'models/resource';
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 
 export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddlewareService {
 
@@ -28,6 +29,7 @@ export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddl
             return;
         } else {
             try {
+                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
                 const groupResource = await this.services.groupsService.get(groupUuid);
                 api.dispatch(updateResources([groupResource]));
 
@@ -65,6 +67,8 @@ export class GroupDetailsPanelMembersMiddlewareService extends DataExplorerMiddl
                 api.dispatch(updateResources(projectsIn.items));
             } catch (e) {
                 api.dispatch(couldNotFetchGroupDetailsContents());
+            } finally {
+                api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
             }
         }
     }
index 87bcba0cbc24cf182db6d1b72feb809c4d56474e..cc6ea8cf389ebdd09573630f419477920792997a 100644 (file)
@@ -12,6 +12,7 @@ import { updateResources } from 'store/resources/resources-actions';
 import { ListResults } from 'services/common-service/common-service';
 import { LinkResource } from 'models/link';
 import { linkPanelActions } from 'store/link-panel/link-panel-actions';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export class LinkMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -22,11 +23,14 @@ export class LinkMiddlewareService extends DataExplorerMiddlewareService {
         const state = api.getState();
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
         try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
             const response = await this.services.linkService.list(getParams(dataExplorer));
             api.dispatch(updateResources(response.items));
             api.dispatch(setItems(response));
         } catch {
             api.dispatch(couldNotFetchLinks());
+        } finally {
+            api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
         }
     }
 }
index 6261a795d9b1d1563755b5dc32eb5e10269ebc9b..e58f3984766cbb0f60989c1fdfe1da14af361308 100644 (file)
@@ -6,4 +6,5 @@ export interface MoveToFormDialogData {
     name: string;
     uuid: string;
     ownerUuid: string;
-}
\ No newline at end of file
+    fromContextMenu?: boolean;
+}
diff --git a/src/store/multiselect/multiselect-actions.tsx b/src/store/multiselect/multiselect-actions.tsx
new file mode 100644 (file)
index 0000000..1c329a9
--- /dev/null
@@ -0,0 +1,35 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { TCheckedList } from "components/data-table/data-table";
+
+export const multiselectActionContants = {
+    TOGGLE_VISIBLITY: "TOGGLE_VISIBLITY",
+    SET_CHECKEDLIST: "SET_CHECKEDLIST",
+    DESELECT_ONE: "DESELECT_ONE",
+};
+
+export const toggleMSToolbar = (isVisible: boolean) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.TOGGLE_VISIBLITY, payload: isVisible });
+    };
+};
+
+export const setCheckedListOnStore = (checkedList: TCheckedList) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.SET_CHECKEDLIST, payload: checkedList });
+    };
+};
+
+export const deselectOne = (uuid: string) => {
+    return dispatch => {
+        dispatch({ type: multiselectActionContants.DESELECT_ONE, payload: uuid });
+    };
+};
+
+export const multiselectActions = {
+    toggleMSToolbar,
+    setCheckedListOnStore,
+    deselectOne,
+};
diff --git a/src/store/multiselect/multiselect-reducer.tsx b/src/store/multiselect/multiselect-reducer.tsx
new file mode 100644 (file)
index 0000000..75c4b1f
--- /dev/null
@@ -0,0 +1,31 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { multiselectActionContants } from "./multiselect-actions";
+import { TCheckedList } from "components/data-table/data-table";
+
+type MultiselectToolbarState = {
+    isVisible: boolean;
+    checkedList: TCheckedList;
+};
+
+const multiselectToolbarInitialState = {
+    isVisible: false,
+    checkedList: {},
+};
+
+const { TOGGLE_VISIBLITY, SET_CHECKEDLIST, DESELECT_ONE } = multiselectActionContants;
+
+export const multiselectReducer = (state: MultiselectToolbarState = multiselectToolbarInitialState, action) => {
+    switch (action.type) {
+        case TOGGLE_VISIBLITY:
+            return { ...state, isVisible: action.payload };
+        case SET_CHECKEDLIST:
+            return { ...state, checkedList: action.payload };
+        case DESELECT_ONE:
+            return { ...state, checkedList: { ...state.checkedList, [action.payload]: false } };
+        default:
+            return state;
+    }
+};
index 55479188c12d91edc99792e8d03d8781d96d7deb..61720b56abfd5bbba55266a08fd95dd63fff8de8 100644 (file)
@@ -2,86 +2,83 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch, compose, AnyAction } from 'redux';
+import { Dispatch, compose, AnyAction } from "redux";
 import { push } from "react-router-redux";
-import { ResourceKind, extractUuidKind } from 'models/resource';
-import { SidePanelTreeCategory } from '../side-panel-tree/side-panel-tree-actions';
-import { Routes, getGroupUrl, getNavUrl, getUserProfileUrl } from 'routes/routes';
-import { RootState } from 'store/store';
-import { ServiceRepository } from 'services/services';
-import { pluginConfig } from 'plugins';
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL } from 'store/breadcrumbs/breadcrumbs-actions';
+import { ResourceKind, extractUuidKind } from "models/resource";
+import { SidePanelTreeCategory } from "../side-panel-tree/side-panel-tree-actions";
+import { Routes, getGroupUrl, getNavUrl, getUserProfileUrl } from "routes/routes";
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { pluginConfig } from "plugins";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { USERS_PANEL_LABEL, MY_ACCOUNT_PANEL_LABEL } from "store/breadcrumbs/breadcrumbs-actions";
 
 export const navigationNotAvailable = (id: string) =>
     snackbarActions.OPEN_SNACKBAR({
         message: `${id} not available`,
         hideDuration: 3000,
-        kind: SnackbarKind.ERROR
+        kind: SnackbarKind.ERROR,
     });
 
-export const navigateTo = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState) => {
-
-        for (const navToFn of pluginConfig.navigateToHandlers) {
-            if (navToFn(dispatch, getState, uuid)) {
-                return;
-            }
-        }
-
-        const kind = extractUuidKind(uuid);
-        switch (kind) {
-            case ResourceKind.PROJECT:
-            case ResourceKind.USER:
-            case ResourceKind.COLLECTION:
-            case ResourceKind.CONTAINER_REQUEST:
-                dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
-                return;
-            case ResourceKind.VIRTUAL_MACHINE:
-                dispatch<any>(navigateToAdminVirtualMachines);
-                return;
-            case ResourceKind.WORKFLOW:
-                dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
-                // dispatch<any>(openDetailsPanel(uuid));
-                return;
+export const navigateTo = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState) => {
+    for (const navToFn of pluginConfig.navigateToHandlers) {
+        if (navToFn(dispatch, getState, uuid)) {
+            return;
         }
+    }
 
-        switch (uuid) {
-            case SidePanelTreeCategory.PROJECTS:
-                const usr = getState().auth.user;
-                if (usr) {
-                    dispatch<any>(pushOrGoto(getNavUrl(usr.uuid, getState().auth)));
-                }
-                return;
-            case SidePanelTreeCategory.FAVORITES:
-                dispatch<any>(navigateToFavorites);
-                return;
-            case SidePanelTreeCategory.PUBLIC_FAVORITES:
-                dispatch(navigateToPublicFavorites);
-                return;
-            case SidePanelTreeCategory.SHARED_WITH_ME:
-                dispatch(navigateToSharedWithMe);
-                return;
-            case SidePanelTreeCategory.TRASH:
-                dispatch(navigateToTrash);
-                return;
-            case SidePanelTreeCategory.GROUPS:
-                dispatch(navigateToGroups);
-                return;
-            case SidePanelTreeCategory.ALL_PROCESSES:
-                dispatch(navigateToAllProcesses);
-                return;
-            case USERS_PANEL_LABEL:
-                dispatch(navigateToUsers);
-                return;
-            case MY_ACCOUNT_PANEL_LABEL:
-                dispatch(navigateToMyAccount);
-                return;
-        }
+    const kind = extractUuidKind(uuid);
+    switch (kind) {
+        case ResourceKind.PROJECT:
+        case ResourceKind.USER:
+        case ResourceKind.COLLECTION:
+        case ResourceKind.CONTAINER_REQUEST:
+            dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
+            return;
+        case ResourceKind.VIRTUAL_MACHINE:
+            dispatch<any>(navigateToAdminVirtualMachines);
+            return;
+        case ResourceKind.WORKFLOW:
+            dispatch<any>(pushOrGoto(getNavUrl(uuid, getState().auth)));
+            // dispatch<any>(openDetailsPanel(uuid));
+            return;
+    }
 
-        dispatch(navigationNotAvailable(uuid));
-    };
+    switch (uuid) {
+        case SidePanelTreeCategory.PROJECTS:
+            const usr = getState().auth.user;
+            if (usr) {
+                dispatch<any>(pushOrGoto(getNavUrl(usr.uuid, getState().auth)));
+            }
+            return;
+        case SidePanelTreeCategory.FAVORITES:
+            dispatch<any>(navigateToFavorites);
+            return;
+        case SidePanelTreeCategory.PUBLIC_FAVORITES:
+            dispatch(navigateToPublicFavorites);
+            return;
+        case SidePanelTreeCategory.SHARED_WITH_ME:
+            dispatch(navigateToSharedWithMe);
+            return;
+        case SidePanelTreeCategory.TRASH:
+            dispatch(navigateToTrash);
+            return;
+        case SidePanelTreeCategory.GROUPS:
+            dispatch(navigateToGroups);
+            return;
+        case SidePanelTreeCategory.ALL_PROCESSES:
+            dispatch(navigateToAllProcesses);
+            return;
+        case USERS_PANEL_LABEL:
+            dispatch(navigateToUsers);
+            return;
+        case MY_ACCOUNT_PANEL_LABEL:
+            dispatch(navigateToMyAccount);
+            return;
+    }
 
+    dispatch(navigationNotAvailable(uuid));
+};
 
 export const navigateToNotFound = push(Routes.NO_MATCH);
 
@@ -98,7 +95,7 @@ export const navigateToWorkflows = push(Routes.WORKFLOWS);
 export const pushOrGoto = (url: string): AnyAction => {
     if (url === "") {
         return { type: "noop" };
-    } else if (url[0] === '/') {
+    } else if (url[0] === "/") {
         return push(url);
     } else {
         window.location.href = url;
@@ -106,7 +103,6 @@ export const pushOrGoto = (url: string): AnyAction => {
     }
 };
 
-
 export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
     navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState);
 };
@@ -117,7 +113,7 @@ export const navigateToRunProcess = push(Routes.RUN_PROCESS);
 
 export const navigateToSearchResults = (searchValue: string) => {
     if (searchValue !== "") {
-        return push({ pathname: Routes.SEARCH_RESULTS, search: '?q=' + encodeURIComponent(searchValue) });
+        return push({ pathname: Routes.SEARCH_RESULTS, search: "?q=" + encodeURIComponent(searchValue) });
     } else {
         return push({ pathname: Routes.SEARCH_RESULTS });
     }
index 6b9db6a538e1322004f14138b4e45b0bd8b53a73..83055e32fcbd3750e50b648c4166f6d618b469de 100644 (file)
@@ -2,28 +2,39 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import copy from 'copy-to-clipboard';
-import { Dispatch } from 'redux';
-import { getNavUrl } from 'routes/routes';
-import { RootState } from 'store/store';
+import copy from "copy-to-clipboard";
+import { Dispatch } from "redux";
+import { getNavUrl } from "routes/routes";
+import { RootState } from "store/store";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
 export const openInNewTabAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => {
     const url = getNavUrl(resource.uuid, getState().auth);
 
-    if (url[0] === '/') {
-        window.open(`${window.location.origin}${url}`, '_blank');
+    if (url[0] === "/") {
+        window.open(`${window.location.origin}${url}`, "_blank");
     } else if (url.length) {
-        window.open(url, '_blank');
+        window.open(url, "_blank");
     }
 };
 
-export const copyToClipboardAction = (resource: any) => (dispatch: Dispatch, getState: () => RootState) => {
+export const copyToClipboardAction = (resources: Array<any>) => (dispatch: Dispatch, getState: () => RootState) => {
     // Copy to clipboard omits token to avoid accidental sharing
-    const url = getNavUrl(resource.uuid, getState().auth, false);
 
-    if (url[0] === '/') {
-        copy(`${window.location.origin}${url}`);
-    } else if (url.length) {
-        copy(url);
+    let url = getNavUrl(resources[0].uuid, getState().auth, false);
+    let wasCopied;
+
+    if (url[0] === "/") wasCopied = copy(`${window.location.origin}${url}`);
+    else if (url.length) {
+        wasCopied = copy(url);
     }
+
+    if (wasCopied)
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Copied",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
 };
index d8042ba5ef9942351df2a298635dae0f556bb300..03e36aac98fb837752c932df4a72c913cea5cc6c 100644 (file)
@@ -3,21 +3,21 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "common/unionize";
-import { getInputs, getOutputParameters, getRawInputs, getRawOutputs, loadProcess } from 'store/processes/processes-actions';
-import { Dispatch } from 'redux';
-import { ProcessStatus } from 'store/processes/process';
-import { RootState } from 'store/store';
+import { getInputs, getOutputParameters, getRawInputs, getRawOutputs, loadProcess } from "store/processes/processes-actions";
+import { Dispatch } from "redux";
+import { ProcessStatus } from "store/processes/process";
+import { RootState } from "store/store";
 import { ServiceRepository } from "services/services";
-import { navigateTo, navigateToWorkflows } from 'store/navigation/navigation-action';
-import { snackbarActions } from 'store/snackbar/snackbar-actions';
-import { SnackbarKind } from '../snackbar/snackbar-actions';
-import { showWorkflowDetails } from 'store/workflow-panel/workflow-panel-actions';
+import { navigateTo, navigateToWorkflows } from "store/navigation/navigation-action";
+import { snackbarActions } from "store/snackbar/snackbar-actions";
+import { SnackbarKind } from "../snackbar/snackbar-actions";
+import { showWorkflowDetails } from "store/workflow-panel/workflow-panel-actions";
 import { loadSubprocessPanel, subprocessPanelActions } from "../subprocess-panel/subprocess-panel-actions";
 import { initProcessLogsPanel, processLogsPanelActions } from "store/process-logs-panel/process-logs-panel-actions";
 import { CollectionFile } from "models/collection-file";
 import { ContainerRequestResource } from "models/container-request";
-import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
-import { CommandInputParameter, getIOParamId, WorkflowInputsData } from 'models/workflow';
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+import { CommandInputParameter, getIOParamId, WorkflowInputsData } from "models/workflow";
 import { getIOParamDisplayValue, ProcessIOParameter } from "views/process-panel/process-io-card";
 import { OutputDetails, NodeInstanceType, NodeInfo } from "./process-panel";
 import { AuthState } from "store/auth/auth-reducer";
@@ -39,45 +39,44 @@ export type ProcessPanelAction = UnionOf<typeof processPanelActions>;
 
 export const toggleProcessPanelFilter = processPanelActions.TOGGLE_PROCESS_PANEL_FILTER;
 
-export const loadProcessPanel = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState) => {
-        // Reset subprocess data explorer if navigating to new process
-        //  Avoids resetting pagination when refreshing same process
-        if (getState().processPanel.containerRequestUuid !== uuid) {
-            dispatch(subprocessPanelActions.CLEAR());
-        }
-        dispatch(processPanelActions.RESET_PROCESS_PANEL());
-        dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
-        dispatch<ProcessPanelAction>(processPanelActions.SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID(uuid));
-        await dispatch<any>(loadProcess(uuid));
-        dispatch(initProcessPanelFilters);
-        dispatch<any>(initProcessLogsPanel(uuid));
-        dispatch<any>(loadSubprocessPanel());
-    };
+export const loadProcessPanel = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState) => {
+    // Reset subprocess data explorer if navigating to new process
+    //  Avoids resetting pagination when refreshing same process
+    if (getState().processPanel.containerRequestUuid !== uuid) {
+        dispatch(subprocessPanelActions.CLEAR());
+    }
+    dispatch(processPanelActions.RESET_PROCESS_PANEL());
+    dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
+    dispatch<ProcessPanelAction>(processPanelActions.SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID(uuid));
+    await dispatch<any>(loadProcess(uuid));
+    dispatch(initProcessPanelFilters);
+    dispatch<any>(initProcessLogsPanel(uuid));
+    dispatch<any>(loadSubprocessPanel());
+};
 
-export const navigateToOutput = (uuid: string) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            await services.collectionService.get(uuid);
-            dispatch<any>(navigateTo(uuid));
-        } catch {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'This collection does not exists!', hideDuration: 2000, kind: SnackbarKind.ERROR }));
-        }
-    };
+export const navigateToOutput = (uuid: string) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        await services.collectionService.get(uuid);
+        dispatch<any>(navigateTo(uuid));
+    } catch {
+        dispatch(snackbarActions.OPEN_SNACKBAR({ message: "This collection does not exists!", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+    }
+};
 
-export const loadInputs = (containerRequest: ContainerRequestResource) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+export const loadInputs =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         dispatch<ProcessPanelAction>(processPanelActions.SET_INPUT_RAW(getRawInputs(containerRequest)));
         dispatch<ProcessPanelAction>(processPanelActions.SET_INPUT_PARAMS(formatInputData(getInputs(containerRequest), getState().auth)));
     };
 
-export const loadOutputs = (containerRequest: ContainerRequestResource) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+export const loadOutputs =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const noOutputs = { rawOutputs: {} };
+
         if (!containerRequest.outputUuid) {
-            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW(noOutputs));
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
             return;
-        };
+        }
         try {
             const propsOutputs = getRawOutputs(containerRequest);
             const filesPromise = services.collectionService.files(containerRequest.outputUuid);
@@ -86,48 +85,53 @@ export const loadOutputs = (containerRequest: ContainerRequestResource) =>
 
             // If has propsOutput, skip fetching cwl.output.json
             if (propsOutputs !== undefined) {
-                dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({
-                    rawOutputs: propsOutputs,
-                    pdh: collection.portableDataHash
-                }));
+                dispatch<ProcessPanelAction>(
+                    processPanelActions.SET_OUTPUT_RAW({
+                        rawOutputs: propsOutputs,
+                        pdh: collection.portableDataHash,
+                    })
+                );
             } else {
                 // Fetch outputs from keep
-                const outputFile = files.find((file) => file.name === 'cwl.output.json') as CollectionFile | undefined;
+                const outputFile = files.find(file => file.name === "cwl.output.json") as CollectionFile | undefined;
                 let outputData = outputFile ? await services.collectionService.getFileContents(outputFile) : undefined;
                 if (outputData && (outputData = JSON.parse(outputData)) && collection.portableDataHash) {
-                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({
-                        rawOutputs: outputData,
-                        pdh: collection.portableDataHash,
-                    }));
+                    dispatch<ProcessPanelAction>(
+                        processPanelActions.SET_OUTPUT_RAW({
+                            uuid: containerRequest.uuid,
+                            outputRaw: { rawOutputs: outputData, pdh: collection.portableDataHash },
+                        })
+                    );
                 } else {
-                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW(noOutputs));
+                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
                 }
             }
         } catch {
-            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW(noOutputs));
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({ uuid: containerRequest.uuid, outputRaw: noOutputs }));
         }
     };
 
-
-export const loadNodeJson = (containerRequest: ContainerRequestResource) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+export const loadNodeJson =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         const noLog = { nodeInfo: null };
         if (!containerRequest.logUuid) {
             dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
             return;
-        };
+        }
         try {
             const filesPromise = services.collectionService.files(containerRequest.logUuid);
             const collectionPromise = services.collectionService.get(containerRequest.logUuid);
             const [files] = await Promise.all([filesPromise, collectionPromise]);
 
             // Fetch node.json from keep
-            const nodeFile = files.find((file) => file.name === 'node.json') as CollectionFile | undefined;
+            const nodeFile = files.find(file => file.name === "node.json") as CollectionFile | undefined;
             let nodeData = nodeFile ? await services.collectionService.getFileContents(nodeFile) : undefined;
             if (nodeData && (nodeData = JSON.parse(nodeData))) {
-                dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO({
-                    nodeInfo: nodeData as NodeInstanceType
-                }));
+                dispatch<ProcessPanelAction>(
+                    processPanelActions.SET_NODE_INFO({
+                        nodeInfo: nodeData as NodeInstanceType,
+                    })
+                );
             } else {
                 dispatch<ProcessPanelAction>(processPanelActions.SET_NODE_INFO(noLog));
             }
@@ -136,28 +140,28 @@ export const loadNodeJson = (containerRequest: ContainerRequestResource) =>
         }
     };
 
-export const loadOutputDefinitions = (containerRequest: ContainerRequestResource) =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+export const loadOutputDefinitions =
+    (containerRequest: ContainerRequestResource) => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
         if (containerRequest && containerRequest.mounts) {
             dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_DEFINITIONS(getOutputParameters(containerRequest)));
         }
     };
 
-export const updateOutputParams = () =>
-    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const outputDefinitions = getState().processPanel.outputDefinitions;
-        const outputRaw = getState().processPanel.outputRaw;
+export const updateOutputParams = () => async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+    const outputDefinitions = getState().processPanel.outputDefinitions;
+    const outputRaw = getState().processPanel.outputRaw;
 
-        if (outputRaw !== null && outputRaw.rawOutputs) {
-            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_PARAMS(formatOutputData(outputDefinitions, outputRaw.rawOutputs, outputRaw.pdh, getState().auth)));
-        }
-    };
+    if (outputRaw && outputRaw.rawOutputs) {
+        dispatch<ProcessPanelAction>(
+            processPanelActions.SET_OUTPUT_PARAMS(formatOutputData(outputDefinitions, outputRaw.rawOutputs, outputRaw.pdh, getState().auth))
+        );
+    }
+};
 
-export const openWorkflow = (uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch<any>(navigateToWorkflows);
-        dispatch<any>(showWorkflowDetails(uuid));
-    };
+export const openWorkflow = (uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch<any>(navigateToWorkflows);
+    dispatch<any>(showWorkflowDetails(uuid));
+};
 
 export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FILTERS([
     ProcessStatus.QUEUED,
@@ -167,7 +171,7 @@ export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FIL
     ProcessStatus.ONHOLD,
     ProcessStatus.FAILING,
     ProcessStatus.WARNING,
-    ProcessStatus.CANCELLED
+    ProcessStatus.CANCELLED,
 ]);
 
 export const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => {
@@ -175,17 +179,22 @@ export const formatInputData = (inputs: CommandInputParameter[], auth: AuthState
         return {
             id: getIOParamId(input),
             label: input.label || "",
-            value: getIOParamDisplayValue(auth, input)
+            value: getIOParamDisplayValue(auth, input),
         };
     });
 };
 
-export const formatOutputData = (definitions: CommandOutputParameter[], values: any, pdh: string | undefined, auth: AuthState): ProcessIOParameter[] => {
+export const formatOutputData = (
+    definitions: CommandOutputParameter[],
+    values: any,
+    pdh: string | undefined,
+    auth: AuthState
+): ProcessIOParameter[] => {
     return definitions.map(output => {
         return {
             id: getIOParamId(output),
             label: output.label || "",
-            value: getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh)
+            value: getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh),
         };
     });
 };
index 8e190ead37aefa838b55c6cd960fb7f48af34ccb..ea6de66db415294d183fb208af0b4ee7f68acfc9 100644 (file)
@@ -2,8 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ProcessPanel } from 'store/process-panel/process-panel';
-import { ProcessPanelAction, processPanelActions } from 'store/process-panel/process-panel-actions';
+import { ProcessPanel } from "store/process-panel/process-panel";
+import { ProcessPanelAction, processPanelActions } from "store/process-panel/process-panel-actions";
 
 const initialState: ProcessPanel = {
     containerRequestUuid: "",
@@ -20,7 +20,8 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc
     processPanelActions.match(action, {
         RESET_PROCESS_PANEL: () => initialState,
         SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: containerRequestUuid => ({
-            ...state, containerRequestUuid
+            ...state,
+            containerRequestUuid,
         }),
         SET_PROCESS_PANEL_FILTERS: statuses => {
             const filters = statuses.reduce((filters, status) => ({ ...filters, [status]: true }), {});
@@ -48,8 +49,12 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc
                 return state;
             }
         },
-        SET_OUTPUT_RAW: outputRaw => {
-            return { ...state, outputRaw };
+        SET_OUTPUT_RAW: (data: any) => {
+            //never set output to {} unless initializing
+            if (state.outputRaw?.rawOutputs && Object.keys(state.outputRaw?.rawOutputs).length && state.containerRequestUuid === data.uuid) {
+                return state;
+            }
+            return { ...state, outputRaw: data.outputRaw };
         },
         SET_NODE_INFO: ({ nodeInfo }) => {
             return { ...state, nodeInfo };
@@ -57,7 +62,7 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc
         SET_OUTPUT_DEFINITIONS: outputDefinitions => {
             // Set output definitions is only additive to avoid clearing when mounts go temporarily missing
             if (outputDefinitions.length) {
-                return { ...state, outputDefinitions }
+                return { ...state, outputDefinitions };
             } else {
                 return state;
             }
index 3c55a9adddb2946f908d8ac43e4594779956aba1..36d73940b13d0a873fac46d5f3df6db1f0d8948d 100644 (file)
@@ -2,22 +2,23 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from "redux";
-import { dialogActions } from "store/dialog/dialog-actions";
+import { Dispatch } from 'redux';
+import { dialogActions } from 'store/dialog/dialog-actions';
 import { initialize, startSubmit } from 'redux-form';
 import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
 import { RootState } from 'store/store';
 import { ServiceRepository } from 'services/services';
 import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
 import { getProcess } from 'store/processes/process';
-import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
+import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
-import { ContainerRequestState } from "models/container-request";
+import { ContainerRequestState } from 'models/container-request';
 
 export const PROCESS_COPY_FORM_NAME = 'processCopyFormName';
+export const MULTI_PROCESS_COPY_FORM_NAME = 'multiProcessCopyFormName';
 
-export const openCopyProcessDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const openCopyProcessDialog =
+    (resource: { name: string; uuid: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getProcess(resource.uuid)(getState().resources);
         if (process) {
             dispatch<any>(resetPickerProjectTree());
@@ -30,57 +31,56 @@ export const openCopyProcessDialog = (resource: { name: string, uuid: string })
         }
     };
 
-export const copyProcess = (resource: CopyFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(PROCESS_COPY_FORM_NAME));
-        try {
-            const process = await services.containerRequestService.get(resource.uuid);
-            const {
-                command,
-                containerCountMax,
-                containerImage,
-                cwd,
-                description,
-                environment,
-                kind,
-                mounts,
-                outputName,
-                outputPath,
-                outputProperties,
-                outputStorageClasses,
-                outputTtl,
-                properties,
-                runtimeConstraints,
-                schedulingParameters,
-                useExisting,
-            } = process;
-            const newProcess = await services.containerRequestService.create({
-                command,
-                containerCountMax,
-                containerImage,
-                cwd,
-                description,
-                environment,
-                kind,
-                mounts,
-                name: resource.name,
-                outputName,
-                outputPath,
-                outputProperties,
-                outputStorageClasses,
-                outputTtl,
-                ownerUuid: resource.ownerUuid,
-                priority: 500,
-                properties,
-                runtimeConstraints,
-                schedulingParameters,
-                state: ContainerRequestState.UNCOMMITTED,
-                useExisting,
-            });
-            dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
-            return newProcess;
-        } catch (e) {
-            dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
-            throw new Error('Could not copy the process.');
-        }
-    };
+export const copyProcess = (resource: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(startSubmit(PROCESS_COPY_FORM_NAME));
+    try {
+        const process = await services.containerRequestService.get(resource.uuid);
+        const {
+            command,
+            containerCountMax,
+            containerImage,
+            cwd,
+            description,
+            environment,
+            kind,
+            mounts,
+            outputName,
+            outputPath,
+            outputProperties,
+            outputStorageClasses,
+            outputTtl,
+            properties,
+            runtimeConstraints,
+            schedulingParameters,
+            useExisting,
+        } = process;
+        const newProcess = await services.containerRequestService.create({
+            command,
+            containerCountMax,
+            containerImage,
+            cwd,
+            description,
+            environment,
+            kind,
+            mounts,
+            name: resource.name,
+            outputName,
+            outputPath,
+            outputProperties,
+            outputStorageClasses,
+            outputTtl,
+            ownerUuid: resource.ownerUuid,
+            priority: 500,
+            properties,
+            runtimeConstraints,
+            schedulingParameters,
+            state: ContainerRequestState.UNCOMMITTED,
+            useExisting,
+        });
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
+        return newProcess;
+    } catch (e) {
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_COPY_FORM_NAME }));
+        throw new Error('Could not copy the process.');
+    }
+};
index 78703e197ff7aeda35ae117624cdb39d23adca1a..c3ac75f99571cbae600d71a00d5064626a11b925 100644 (file)
@@ -4,21 +4,21 @@
 
 import { Dispatch } from "redux";
 import { dialogActions } from "store/dialog/dialog-actions";
-import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
-import { ServiceRepository } from 'services/services';
-import { RootState } from 'store/store';
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
-import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import { getProcess } from 'store/processes/process';
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { getProcess } from "store/processes/process";
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
 
-export const PROCESS_MOVE_FORM_NAME = 'processMoveFormName';
+export const PROCESS_MOVE_FORM_NAME = "processMoveFormName";
 
-export const openMoveProcessDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const openMoveProcessDialog =
+    (resource: { name: string; uuid: string }) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getProcess(resource.uuid)(getState().resources);
         if (process) {
             dispatch<any>(resetPickerProjectTree());
@@ -26,27 +26,28 @@ export const openMoveProcessDialog = (resource: { name: string, uuid: string })
             dispatch(initialize(PROCESS_MOVE_FORM_NAME, resource));
             dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_MOVE_FORM_NAME, data: {} }));
         } else {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process not found', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process not found", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
-export const moveProcess = (resource: MoveToFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(startSubmit(PROCESS_MOVE_FORM_NAME));
-        try {
-            const process = await services.containerRequestService.get(resource.uuid);
-            await services.containerRequestService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
-            dispatch(projectPanelActions.REQUEST_ITEMS());
+export const moveProcess = (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(startSubmit(PROCESS_MOVE_FORM_NAME));
+    try {
+        const process = await services.containerRequestService.get(resource.uuid);
+        await services.containerRequestService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
+        return process;
+    } catch (e) {
+        const error = getCommonResourceServiceError(e);
+        if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+            dispatch(
+                stopSubmit(PROCESS_MOVE_FORM_NAME, { ownerUuid: "A process with the same name already exists in the target project." } as FormErrors)
+            );
+        } else {
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
-            return process;
-        } catch (e) {
-            const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(PROCESS_MOVE_FORM_NAME, { ownerUuid: 'A process with the same name already exists in the target project.' } as FormErrors));
-            } else {
-                dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_MOVE_FORM_NAME }));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not move the process.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
-            }
-            return;
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not move the process.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
-    };
+        return;
+    }
+};
index c7fd1b55d4baf9ff5bde6f33dcae9b572c2bcf3c..c7bd2c7beeac61febd3d1747eacd26d025a86493 100644 (file)
@@ -3,14 +3,14 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from "redux";
-import { FormErrors, initialize, startSubmit, stopSubmit } from 'redux-form';
+import { FormErrors, initialize, startSubmit, stopSubmit } from "redux-form";
 import { RootState } from "store/store";
 import { dialogActions } from "store/dialog/dialog-actions";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
 import { ServiceRepository } from "services/services";
-import { getProcess } from 'store/processes/process';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import {snackbarActions, SnackbarKind} from 'store/snackbar/snackbar-actions';
+import { getProcess } from "store/processes/process";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
 export interface ProcessUpdateFormDialogData {
     uuid: string;
@@ -18,34 +18,37 @@ export interface ProcessUpdateFormDialogData {
     description?: string;
 }
 
-export const PROCESS_UPDATE_FORM_NAME = 'processUpdateFormName';
+export const PROCESS_UPDATE_FORM_NAME = "processUpdateFormName";
 
-export const openProcessUpdateDialog = (resource: ProcessUpdateFormDialogData) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const openProcessUpdateDialog =
+    (resource: ProcessUpdateFormDialogData) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getProcess(resource.uuid)(getState().resources);
-        if(process) {
+        if (process) {
             dispatch(initialize(PROCESS_UPDATE_FORM_NAME, { ...resource, name: process.containerRequest.name }));
             dispatch(dialogActions.OPEN_DIALOG({ id: PROCESS_UPDATE_FORM_NAME, data: {} }));
         } else {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process not found', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process not found", hideDuration: 2000, kind: SnackbarKind.ERROR }));
         }
     };
 
-export const updateProcess = (resource: ProcessUpdateFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const updateProcess =
+    (resource: ProcessUpdateFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch(startSubmit(PROCESS_UPDATE_FORM_NAME));
         try {
-            const updatedProcess = await services.containerRequestService.update(resource.uuid, { name: resource.name, description: resource.description });
+            const updatedProcess = await services.containerRequestService.update(resource.uuid, {
+                name: resource.name,
+                description: resource.description,
+            });
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
             return updatedProcess;
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(PROCESS_UPDATE_FORM_NAME, { name: 'Process with the same name already exists.' } as FormErrors));
+                dispatch(stopSubmit(PROCESS_UPDATE_FORM_NAME, { name: "Process with the same name already exists." } as FormErrors));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROCESS_UPDATE_FORM_NAME }));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Could not update the process.', hideDuration: 2000, kind: SnackbarKind.ERROR }));
+                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Could not update the process.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
             }
             return;
         }
index 526629cd024b759112df06e41782f990fb2d29ec..a31fd9eac8b12b9cf8eea5d3956d588d47f8cc79 100644 (file)
@@ -129,15 +129,23 @@ export const getProcessStatus = ({ containerRequest, container }: Process): Proc
         case containerRequest.containerUuid && !container:
             return ProcessStatus.UNKNOWN;
 
+        case containerRequest.state === ContainerRequestState.UNCOMMITTED:
+            return ProcessStatus.DRAFT;
+
+        case containerRequest.state === ContainerRequestState.FINAL &&
+            container?.state === ContainerState.RUNNING:
+            // It is about to be completed but we haven't
+            // gotten the updated container record yet,
+            // if we don't catch this and show it as "Running"
+            // it will flicker "Cancelled" briefly
+            return ProcessStatus.RUNNING;
+
         case containerRequest.state === ContainerRequestState.FINAL &&
             container?.state !== ContainerState.COMPLETE:
             // Request was finalized before its container started (or the
             // container was cancelled)
             return ProcessStatus.CANCELLED;
 
-        case containerRequest.state === ContainerRequestState.UNCOMMITTED:
-            return ProcessStatus.DRAFT;
-
         case container && container.state === ContainerState.COMPLETE:
             if (container?.exitCode === 0) {
                 if (containerRequest && container.finishedAt) {
index b6ff4b71acd55e25ee854421440203014971e8b6..eadb05e5e1460ff47db6b12d687b36394a9c624e 100644 (file)
@@ -3,20 +3,20 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from "redux";
-import { RootState } from 'store/store';
-import { ServiceRepository } from 'services/services';
-import { updateResources } from 'store/resources/resources-actions';
-import { Process } from './process';
-import { dialogActions } from 'store/dialog/dialog-actions';
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import { navigateToRunProcess } from 'store/navigation/navigation-action';
-import { goToStep, runProcessPanelActions } from 'store/run-process-panel/run-process-panel-actions';
-import { getResource } from 'store/resources/resources';
+import { RootState } from "store/store";
+import { ServiceRepository } from "services/services";
+import { updateResources } from "store/resources/resources-actions";
+import { Process } from "./process";
+import { dialogActions } from "store/dialog/dialog-actions";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { navigateToRunProcess } from "store/navigation/navigation-action";
+import { goToStep, runProcessPanelActions } from "store/run-process-panel/run-process-panel-actions";
+import { getResource } from "store/resources/resources";
 import { initialize } from "redux-form";
 import { RUN_PROCESS_BASIC_FORM, RunProcessBasicFormData } from "views/run-process-panel/run-process-basic-form";
 import { RunProcessAdvancedFormData, RUN_PROCESS_ADVANCED_FORM } from "views/run-process-panel/run-process-advanced-form";
-import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from 'models/process';
+import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from "models/process";
 import { CommandInputParameter, getWorkflow, getWorkflowInputs, getWorkflowOutputs, WorkflowInputsData } from "models/workflow";
 import { ProjectResource } from "models/project";
 import { UserResource } from "models/user";
@@ -24,9 +24,13 @@ import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParamet
 import { ContainerResource } from "models/container";
 import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
 import { FilterBuilder } from "services/api/filter-builder";
+import { selectedToArray } from "components/multiselect-toolbar/MultiselectToolbar";
+import { Resource, ResourceKind } from "models/resource";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
 import { CommonResourceServiceError, getCommonResourceServiceError } from "services/common-service/common-resource-service";
 
-export const loadProcess = (containerRequestUuid: string) =>
+export const loadProcess =
+    (containerRequestUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<Process | undefined> => {
         let containerRequest: ContainerRequestResource | undefined = undefined;
         try {
@@ -40,7 +44,7 @@ export const loadProcess = (containerRequestUuid: string) =>
             try {
                 const collection = await services.collectionService.get(containerRequest.outputUuid, false);
                 dispatch<any>(updateResources([collection]));
-            } catch { }
+            } catch {}
         }
 
         if (containerRequest.containerUuid) {
@@ -48,24 +52,25 @@ export const loadProcess = (containerRequestUuid: string) =>
             try {
                 container = await services.containerService.get(containerRequest.containerUuid, false);
                 dispatch<any>(updateResources([container]));
-            } catch { }
+            } catch {}
 
             try {
                 if (container && container.runtimeUserUuid) {
                     const runtimeUser = await services.userService.get(container.runtimeUserUuid, false);
                     dispatch<any>(updateResources([runtimeUser]));
                 }
-            } catch { }
+            } catch {}
 
             return { containerRequest, container };
         }
         return { containerRequest };
     };
 
-export const loadContainers = (containerUuids: string[], loadMounts: boolean = true) =>
+export const loadContainers =
+    (containerUuids: string[], loadMounts: boolean = true) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         let args: any = {
-            filters: new FilterBuilder().addIn('uuid', containerUuids).getFilters(),
+            filters: new FilterBuilder().addIn("uuid", containerUuids).getFilters(),
             limit: containerUuids.length,
         };
         if (!loadMounts) {
@@ -114,61 +119,60 @@ const containerFieldsNoMounts = [
     "state",
     "subrequests_cost",
     "uuid",
-]
+];
 
-export const cancelRunningWorkflow = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            const process = await services.containerRequestService.update(uuid, { priority: 0 });
-            dispatch<any>(updateResources([process]));
-            if (process.containerUuid) {
-                const container = await services.containerService.get(process.containerUuid, false);
-                dispatch<any>(updateResources([container]));
-            }
-            return process;
-        } catch (e) {
-            throw new Error('Could not cancel the process.');
+export const cancelRunningWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { priority: 0 });
+        dispatch<any>(updateResources([process]));
+        if (process.containerUuid) {
+            const container = await services.containerService.get(process.containerUuid, false);
+            dispatch<any>(updateResources([container]));
         }
-    };
+        return process;
+    } catch (e) {
+        throw new Error("Could not cancel the process.");
+    }
+};
 
-export const resumeOnHoldWorkflow = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            const process = await services.containerRequestService.update(uuid, { priority: 500 });
-            dispatch<any>(updateResources([process]));
-            if (process.containerUuid) {
-                const container = await services.containerService.get(process.containerUuid, false);
-                dispatch<any>(updateResources([container]));
-            }
-            return process;
-        } catch (e) {
-            throw new Error('Could not resume the process.');
+export const resumeOnHoldWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { priority: 500 });
+        dispatch<any>(updateResources([process]));
+        if (process.containerUuid) {
+            const container = await services.containerService.get(process.containerUuid, false);
+            dispatch<any>(updateResources([container]));
         }
-    };
+        return process;
+    } catch (e) {
+        throw new Error("Could not resume the process.");
+    }
+};
 
-export const startWorkflow = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        try {
-            const process = await services.containerRequestService.update(uuid, { state: ContainerRequestState.COMMITTED });
-            if (process) {
-                dispatch<any>(updateResources([process]));
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Process started', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
-            } else {
-                dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR }));
-            }
-        } catch (e) {
+export const startWorkflow = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await services.containerRequestService.update(uuid, { state: ContainerRequestState.COMMITTED });
+        if (process) {
+            dispatch<any>(updateResources([process]));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Process started", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+        } else {
             dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR }));
         }
-    };
+    } catch (e) {
+        dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to start process`, kind: SnackbarKind.ERROR }));
+    }
+};
 
-export const reRunProcess = (processUuid: string, workflowUuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const reRunProcess =
+    (processUuid: string, workflowUuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const process = getResource<any>(processUuid)(getState().resources);
         const workflows = getState().runProcessPanel.searchWorkflows;
         const workflow = workflows.find(workflow => workflow.uuid === workflowUuid);
         if (workflow && process) {
             const mainWf = getWorkflow(process.mounts[MOUNT_PATH_CWL_WORKFLOW]);
-            if (mainWf) { mainWf.inputs = getInputs(process); }
+            if (mainWf) {
+                mainWf.inputs = getInputs(process);
+            }
             const stringifiedDefinition = JSON.stringify(process.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
             const newWorkflow = { ...workflow, definition: stringifiedDefinition };
 
@@ -182,7 +186,7 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
                 ram: process.runtimeConstraints.ram,
                 vcpus: process.runtimeConstraints.vcpus,
                 keep_cache_ram: process.runtimeConstraints.keep_cache_ram,
-                acr_container_image: process.containerImage
+                acr_container_image: process.containerImage,
             };
             dispatch<any>(initialize(RUN_PROCESS_ADVANCED_FORM, advancedInitialData));
 
@@ -201,34 +205,40 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
  * Returns {} if inputs not found in mounts or props
  */
 export const getRawInputs = (data: any): WorkflowInputsData | undefined => {
-    if (!data) { return undefined; }
+    if (!data) {
+        return undefined;
+    }
     const mountInput = data.mounts?.[MOUNT_PATH_CWL_INPUT]?.content;
     const propsInput = data.properties?.cwl_input;
-    if (!mountInput && !propsInput) { return {}; }
-    return (mountInput || propsInput);
-}
+    if (!mountInput && !propsInput) {
+        return {};
+    }
+    return mountInput || propsInput;
+};
 
 export const getInputs = (data: any): CommandInputParameter[] => {
     // Definitions from mounts are needed so we return early if missing
-    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        return [];
+    }
     const content = getRawInputs(data) as any;
     // Only escape if content is falsy to allow displaying definitions if no inputs are present
     // (Don't check raw content length)
-    if (!content) { return []; }
+    if (!content) {
+        return [];
+    }
 
     const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
-    return inputs ? inputs.map(
-        (it: any) => (
-            {
-                type: it.type,
-                id: it.id,
-                label: it.label,
-                default: content[it.id],
-                value: content[it.id.split('/').pop()] || [],
-                doc: it.doc
-            }
-        )
-    ) : [];
+    return inputs
+        ? inputs.map((it: any) => ({
+              type: it.type,
+              id: it.id,
+              label: it.label,
+              default: content[it.id],
+              value: content[it.id.split("/").pop()] || [],
+              doc: it.doc,
+          }))
+        : [];
 };
 
 /*
@@ -236,25 +246,27 @@ export const getInputs = (data: any): CommandInputParameter[] => {
  * Assumes containerRequest is loaded
  */
 export const getRawOutputs = (data: any): CommandInputParameter[] | undefined => {
-    if (!data || !data.properties || !data.properties.cwl_output) { return undefined; }
-    return (data.properties.cwl_output);
-}
+    if (!data || !data.properties || !data.properties.cwl_output) {
+        return undefined;
+    }
+    return data.properties.cwl_output;
+};
 
 export type InputCollectionMount = {
     path: string;
     pdh: string;
-}
+};
 
 export const getInputCollectionMounts = (data: any): InputCollectionMount[] => {
-    if (!data || !data.mounts) { return []; }
+    if (!data || !data.mounts) {
+        return [];
+    }
     return Object.keys(data.mounts)
         .map(key => ({
             ...data.mounts[key],
             path: key,
         }))
-        .filter(mount => mount.kind === 'collection' &&
-            mount.portable_data_hash &&
-            mount.path)
+        .filter(mount => mount.kind === "collection" && mount.portable_data_hash && mount.path)
         .map(mount => ({
             path: mount.path,
             pdh: mount.portable_data_hash,
@@ -262,42 +274,63 @@ export const getInputCollectionMounts = (data: any): InputCollectionMount[] => {
 };
 
 export const getOutputParameters = (data: any): CommandOutputParameter[] => {
-    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) {
+        return [];
+    }
     const outputs = getWorkflowOutputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
-    return outputs ? outputs.map(
-        (it: any) => (
-            {
-                type: it.type,
-                id: it.id,
-                label: it.label,
-                doc: it.doc
-            }
-        )
-    ) : [];
+    return outputs
+        ? outputs.map((it: any) => ({
+              type: it.type,
+              id: it.id,
+              label: it.label,
+              doc: it.doc,
+          }))
+        : [];
 };
 
-export const openRemoveProcessDialog = (uuid: string) =>
-    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch(dialogActions.OPEN_DIALOG({
-            id: REMOVE_PROCESS_DIALOG,
-            data: {
-                title: 'Remove process permanently',
-                text: 'Are you sure you want to remove this process?',
-                confirmButtonLabel: 'Remove',
-                uuid
-            }
-        }));
+export const openRemoveProcessDialog =
+    (resource: ContextMenuResource, numOfProcesses: Number) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const confirmationText =
+            numOfProcesses === 1
+                ? "Are you sure you want to remove this process?"
+                : `Are you sure you want to remove these ${numOfProcesses} processes?`;
+        const titleText = numOfProcesses === 1 ? "Remove process permanently" : "Remove processes permanently";
+
+        dispatch(
+            dialogActions.OPEN_DIALOG({
+                id: REMOVE_PROCESS_DIALOG,
+                data: {
+                    title: titleText,
+                    text: confirmationText,
+                    confirmButtonLabel: "Remove",
+                    uuid: resource.uuid,
+                    resource,
+                },
+            })
+        );
     };
 
-export const REMOVE_PROCESS_DIALOG = 'removeProcessDialog';
+export const REMOVE_PROCESS_DIALOG = "removeProcessDialog";
 
-export const removeProcessPermanently = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const removeProcessPermanently = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const resource = getState().dialog.removeProcessDialog.data.resource;
+    const checkedList = getState().multiselect.checkedList;
+
+    const uuidsToRemove: string[] = resource.fromContextMenu ? [resource.uuid] : selectedToArray(checkedList);
+
+    //if no items in checkedlist, default to normal context menu behavior
+    if (!uuidsToRemove.length) uuidsToRemove.push(uuid);
+
+    const processesToRemove = uuidsToRemove
+        .map(uuid => getResource(uuid)(getState().resources) as Resource)
+        .filter(resource => resource.kind === ResourceKind.PROCESS);
+
+    for (const process of processesToRemove) {
         try {
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removing ...', kind: SnackbarKind.INFO }));
-            await services.containerRequestService.delete(uuid, false);
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removing ...", kind: SnackbarKind.INFO }));
+            await services.containerRequestService.delete(process.uuid, false);
             dispatch(projectPanelActions.REQUEST_ITEMS());
-            dispatch(snackbarActions.OPEN_SNACKBAR({ message: 'Removed.', hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Removed.", hideDuration: 2000, kind: SnackbarKind.SUCCESS }));
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.PERMISSION_ERROR_FORBIDDEN) {
@@ -306,4 +339,5 @@ export const removeProcessPermanently = (uuid: string) =>
                 dispatch(snackbarActions.OPEN_SNACKBAR({ message: `Deletion failed`, hideDuration: 2000, kind: SnackbarKind.ERROR }));
             }
         }
-    };
+    }
+};
diff --git a/src/store/project-panel/project-panel-action-bind.ts b/src/store/project-panel/project-panel-action-bind.ts
new file mode 100644 (file)
index 0000000..31a5f8d
--- /dev/null
@@ -0,0 +1,9 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+
+const PROJECT_PANEL_ID = "projectPanel";
+
+export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID);
index 7ad18b67bdb2f50b5baf1e4a3818abc3d4581d37..305799e820f6c070a1797d457989ae022ff591c5 100644 (file)
@@ -2,27 +2,24 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from 'redux';
-import { bindDataExplorerActions } from "store/data-explorer/data-explorer-action";
+import { Dispatch } from "redux";
 import { propertiesActions } from "store/properties/properties-actions";
-import { RootState } from 'store/store';
+import { RootState } from "store/store";
 import { getProperty } from "store/properties/properties";
 import { loadProject } from "store/workbench/workbench-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 
 export const PROJECT_PANEL_ID = "projectPanel";
 export const PROJECT_PANEL_CURRENT_UUID = "projectPanelCurrentUuid";
-export const IS_PROJECT_PANEL_TRASHED = 'isProjectPanelTrashed';
-export const projectPanelActions = bindDataExplorerActions(PROJECT_PANEL_ID);
+export const IS_PROJECT_PANEL_TRASHED = "isProjectPanelTrashed";
 
-export const openProjectPanel = (projectUuid: string) =>
-    async (dispatch: Dispatch) => {
-        await dispatch<any>(loadProject(projectUuid));
-        dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid }));
-        dispatch(projectPanelActions.RESET_EXPLORER_SEARCH_VALUE());
-        dispatch(projectPanelActions.REQUEST_ITEMS());
-    };
+export const openProjectPanel = (projectUuid: string) => async (dispatch: Dispatch) => {
+    await dispatch<any>(loadProject(projectUuid));
+    dispatch(propertiesActions.SET_PROPERTY({ key: PROJECT_PANEL_CURRENT_UUID, value: projectUuid }));
+    dispatch(projectPanelActions.RESET_EXPLORER_SEARCH_VALUE());
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+};
 
 export const getProjectPanelCurrentUuid = (state: RootState) => getProperty<string>(PROJECT_PANEL_CURRENT_UUID)(state.properties);
 
-export const setIsProjectPanelTrashed = (isTrashed: boolean) =>
-    propertiesActions.SET_PROPERTY({ key: IS_PROJECT_PANEL_TRASHED, value: isTrashed });
+export const setIsProjectPanelTrashed = (isTrashed: boolean) => propertiesActions.SET_PROPERTY({ key: IS_PROJECT_PANEL_TRASHED, value: isTrashed });
index c9aafd52300abb449f12dae82cd295ca185ee3e0..c0c0cd1873f262546b694bf2132a5e353f12cb14 100644 (file)
@@ -6,8 +6,8 @@ import {
     DataExplorerMiddlewareService,
     dataExplorerToListParams,
     getDataExplorerColumnFilters,
-    listResultsToDataExplorerItemsMeta
-} from 'store/data-explorer/data-explorer-middleware-service';
+    listResultsToDataExplorerItemsMeta,
+} from "store/data-explorer/data-explorer-middleware-service";
 import { ProjectPanelColumnNames } from "views/project-panel/project-panel";
 import { RootState } from "store/store";
 import { DataColumns } from "components/data-table/data-table";
@@ -17,37 +17,31 @@ import { OrderBuilder, OrderDirection } from "services/api/order-builder";
 import { FilterBuilder, joinFilters } from "services/api/filter-builder";
 import { GroupContentsResource, GroupContentsResourcePrefix } from "services/groups-service/groups-service";
 import { updateFavorites } from "store/favorites/favorites-actions";
-import {
-    IS_PROJECT_PANEL_TRASHED,
-    projectPanelActions,
-    getProjectPanelCurrentUuid
-} from 'store/project-panel/project-panel-action';
+import { IS_PROJECT_PANEL_TRASHED, getProjectPanelCurrentUuid } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 import { Dispatch, MiddlewareAPI } from "redux";
 import { ProjectResource } from "models/project";
 import { updateResources } from "store/resources/resources-actions";
 import { getProperty } from "store/properties/properties";
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
-import { DataExplorer, getDataExplorer } from 'store/data-explorer/data-explorer-reducer';
-import { ListResults } from 'services/common-service/common-service';
-import { loadContainers } from 'store/processes/processes-actions';
-import { ResourceKind } from 'models/resource';
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { DataExplorer, getDataExplorer } from "store/data-explorer/data-explorer-reducer";
+import { ListResults } from "services/common-service/common-service";
+import { loadContainers } from "store/processes/processes-actions";
+import { ResourceKind } from "models/resource";
 import { getSortColumn } from "store/data-explorer/data-explorer-reducer";
-import {
-    serializeResourceTypeFilters,
-    buildProcessStatusFilters
-} from 'store/resource-type-filters/resource-type-filters';
-import { updatePublicFavorites } from 'store/public-favorites/public-favorites-actions';
-import { selectedFieldsOfGroup } from 'models/group';
-import { defaultCollectionSelectedFields } from 'models/collection';
-import { containerRequestFieldsNoMounts } from 'models/container-request';
+import { serializeResourceTypeFilters, buildProcessStatusFilters } from "store/resource-type-filters/resource-type-filters";
+import { updatePublicFavorites } from "store/public-favorites/public-favorites-actions";
+import { selectedFieldsOfGroup } from "models/group";
+import { defaultCollectionSelectedFields } from "models/collection";
+import { containerRequestFieldsNoMounts } from "models/container-request";
 
 export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
         super(id);
     }
 
-    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean, background?: boolean) {
         const state = api.getState();
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
         const projectUuid = getProjectPanelCurrentUuid(state);
@@ -58,7 +52,7 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService
             api.dispatch(projectPanelDataExplorerIsNotSet());
         } else {
             try {
-                api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
                 const response = await this.services.groupsService.contents(projectUuid, getParams(dataExplorer, !!isProjectTrashed));
                 const resourceUuids = response.items.map(item => item.uuid);
                 api.dispatch<any>(updateFavorites(resourceUuids));
@@ -67,36 +61,32 @@ export class ProjectPanelMiddlewareService extends DataExplorerMiddlewareService
                 await api.dispatch<any>(loadMissingProcessesInformation(response.items));
                 api.dispatch(setItems(response));
             } catch (e) {
-                api.dispatch(projectPanelActions.SET_ITEMS({
-                    items: [],
-                    itemsAvailable: 0,
-                    page: 0,
-                    rowsPerPage: dataExplorer.rowsPerPage
-                }));
+                api.dispatch(
+                    projectPanelActions.SET_ITEMS({
+                        items: [],
+                        itemsAvailable: 0,
+                        page: 0,
+                        rowsPerPage: dataExplorer.rowsPerPage,
+                    })
+                );
                 api.dispatch(couldNotFetchProjectContents());
             } finally {
-                api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+                if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
             }
         }
     }
 }
 
-export const loadMissingProcessesInformation = (resources: GroupContentsResource[]) =>
-    async (dispatch: Dispatch) => {
-        const containerUuids = resources.reduce((uuids, resource) => {
-            return resource.kind === ResourceKind.CONTAINER_REQUEST &&
-                resource.containerUuid &&
-                !uuids.includes(resource.containerUuid)
-                ? [...uuids, resource.containerUuid]
-                : uuids;
-        }, [] as string[]);
-        if (containerUuids.length > 0) {
-            await dispatch<any>(loadContainers(
-                containerUuids,
-                false
-            ));
-        }
-    };
+export const loadMissingProcessesInformation = (resources: GroupContentsResource[]) => async (dispatch: Dispatch) => {
+    const containerUuids = resources.reduce((uuids, resource) => {
+        return resource.kind === ResourceKind.CONTAINER_REQUEST && resource.containerUuid && !uuids.includes(resource.containerUuid)
+            ? [...uuids, resource.containerUuid]
+            : uuids;
+    }, [] as string[]);
+    if (containerUuids.length > 0) {
+        await dispatch<any>(loadContainers(containerUuids, false));
+    }
+};
 
 export const setItems = (listResults: ListResults<GroupContentsResource>) =>
     projectPanelActions.SET_ITEMS({
@@ -109,16 +99,14 @@ export const getParams = (dataExplorer: DataExplorer, isProjectTrashed: boolean)
     order: getOrder(dataExplorer),
     filters: getFilters(dataExplorer),
     includeTrash: isProjectTrashed,
-    select: selectedFieldsOfGroup.concat(defaultCollectionSelectedFields, containerRequestFieldsNoMounts)
+    select: selectedFieldsOfGroup.concat(defaultCollectionSelectedFields, containerRequestFieldsNoMounts),
 });
 
 export const getFilters = (dataExplorer: DataExplorer) => {
     const columns = dataExplorer.columns as DataColumns<string, ProjectResource>;
     const typeFilters = serializeResourceTypeFilters(getDataExplorerColumnFilters(columns, ProjectPanelColumnNames.TYPE));
-    const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
-    const activeStatusFilter = Object.keys(statusColumnFilters).find(
-        filterName => statusColumnFilters[filterName].selected
-    );
+    const statusColumnFilters = getDataExplorerColumnFilters(columns, "Status");
+    const activeStatusFilter = Object.keys(statusColumnFilters).find(filterName => statusColumnFilters[filterName].selected);
 
     // TODO: Extract group contents name filter
     const nameFilters = new FilterBuilder()
@@ -128,30 +116,23 @@ export const getFilters = (dataExplorer: DataExplorer) => {
         .getFilters();
 
     // Filter by container status
-    const statusFilters = buildProcessStatusFilters(
-        new FilterBuilder(),
-        activeStatusFilter || '',
-        GroupContentsResourcePrefix.PROCESS).getFilters();
+    const statusFilters = buildProcessStatusFilters(new FilterBuilder(), activeStatusFilter || "", GroupContentsResourcePrefix.PROCESS).getFilters();
 
-    return joinFilters(
-        statusFilters,
-        typeFilters,
-        nameFilters,
-    );
+    return joinFilters(statusFilters, typeFilters, nameFilters);
 };
 
 const getOrder = (dataExplorer: DataExplorer) => {
     const sortColumn = getSortColumn<ProjectResource>(dataExplorer);
     const order = new OrderBuilder<ProjectResource>();
     if (sortColumn && sortColumn.sort) {
-        const sortDirection = sortColumn.sort.direction === SortDirection.ASC
-            ? OrderDirection.ASC
-            : OrderDirection.DESC;
+        const sortDirection = sortColumn.sort.direction === SortDirection.ASC ? OrderDirection.ASC : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
             .getOrder();
     } else {
         return order.getOrder();
@@ -160,18 +141,18 @@ const getOrder = (dataExplorer: DataExplorer) => {
 
 const projectPanelCurrentUuidIsNotSet = () =>
     snackbarActions.OPEN_SNACKBAR({
-        message: 'Project panel is not opened.',
-        kind: SnackbarKind.ERROR
+        message: "Project panel is not opened.",
+        kind: SnackbarKind.ERROR,
     });
 
 const couldNotFetchProjectContents = () =>
     snackbarActions.OPEN_SNACKBAR({
-        message: 'Could not fetch project contents.',
-        kind: SnackbarKind.ERROR
+        message: "Could not fetch project contents.",
+        kind: SnackbarKind.ERROR,
     });
 
 const projectPanelDataExplorerIsNotSet = () =>
     snackbarActions.OPEN_SNACKBAR({
-        message: 'Project panel is not ready.',
-        kind: SnackbarKind.ERROR
+        message: "Project panel is not ready.",
+        kind: SnackbarKind.ERROR,
     });
index 98ebb3849535352769cc32e50fbf0ddc954c0318..84f886d6db6026a9f3ce3636d75044451d4567d0 100644 (file)
@@ -4,31 +4,28 @@
 
 import { Dispatch } from "redux";
 import { ServiceRepository } from "services/services";
-import { projectPanelActions } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 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;
+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
-        });
+    const updatedProject = await services.projectService.update(uuid, {
+        frozenByUuid: userUUID,
+    });
 
-        dispatch(projectPanelActions.REQUEST_ITEMS());
-        dispatch<any>(loadResource(uuid, false));
-        return updatedProject;
-    };
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+    dispatch<any>(loadResource(uuid, false));
+    return updatedProject;
+};
 
-export const unfreezeProject = (uuid: string) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+export const unfreezeProject = (uuid: string) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const updatedProject = await services.projectService.update(uuid, {
+        frozenByUuid: null,
+    });
 
-        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
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+    dispatch<any>(loadResource(uuid, false));
+    return updatedProject;
+};
index 963070cad92c2fb8748a04630d056c532d3aad85..97cd5dbe71280b464387ed83436e7d4d3e98f2cd 100644 (file)
@@ -4,48 +4,53 @@
 
 import { Dispatch } from "redux";
 import { dialogActions } from "store/dialog/dialog-actions";
-import { startSubmit, stopSubmit, initialize, FormErrors } from 'redux-form';
-import { ServiceRepository } from 'services/services';
-import { RootState } from 'store/store';
+import { startSubmit, stopSubmit, initialize, FormErrors } from "redux-form";
+import { ServiceRepository } from "services/services";
+import { RootState } from "store/store";
 import { getUserUuid } from "common/getuser";
 import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { resetPickerProjectTree } from 'store/project-tree-picker/project-tree-picker-actions';
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
-import { loadSidePanelTreeProjects } from '../side-panel-tree/side-panel-tree-actions';
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { resetPickerProjectTree } from "store/project-tree-picker/project-tree-picker-actions";
+import { initProjectsTreePicker } from "store/tree-picker/tree-picker-actions";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { loadSidePanelTreeProjects } from "../side-panel-tree/side-panel-tree-actions";
 
-export const PROJECT_MOVE_FORM_NAME = 'projectMoveFormName';
+export const PROJECT_MOVE_FORM_NAME = "projectMoveFormName";
 
-export const openMoveProjectDialog = (resource: { name: string, uuid: string }) =>
-    (dispatch: Dispatch) => {
+export const openMoveProjectDialog = (resource: any) => {
+    return (dispatch: Dispatch) => {
         dispatch<any>(resetPickerProjectTree());
         dispatch<any>(initProjectsTreePicker(PROJECT_MOVE_FORM_NAME));
         dispatch(initialize(PROJECT_MOVE_FORM_NAME, resource));
         dispatch(dialogActions.OPEN_DIALOG({ id: PROJECT_MOVE_FORM_NAME, data: {} }));
     };
+};
 
-export const moveProject = (resource: MoveToFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const userUuid = getUserUuid(getState());
-        if (!userUuid) { return; }
-        dispatch(startSubmit(PROJECT_MOVE_FORM_NAME));
-        try {
-            const newProject = await services.projectService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
-            dispatch(projectPanelActions.REQUEST_ITEMS());
+export const moveProject = (resource: MoveToFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const userUuid = getUserUuid(getState());
+    if (!userUuid) {
+        return;
+    }
+    dispatch(startSubmit(PROJECT_MOVE_FORM_NAME));
+    try {
+        const newProject = await services.projectService.update(resource.uuid, { ownerUuid: resource.ownerUuid });
+        dispatch(projectPanelActions.REQUEST_ITEMS());
+
+        dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
+        await dispatch<any>(loadSidePanelTreeProjects(userUuid));
+        return newProject;
+    } catch (e) {
+        const error = getCommonResourceServiceError(e);
+        if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
+            dispatch(
+                stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: "A project with the same name already exists in the target project." } as FormErrors)
+            );
+        } else if (error === CommonResourceServiceError.OWNERSHIP_CYCLE) {
+            dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: "Cannot move a project into itself." } as FormErrors));
+        } else {
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
-            await dispatch<any>(loadSidePanelTreeProjects(userUuid));
-            return newProject;
-        } catch (e) {
-            const error = getCommonResourceServiceError(e);
-            if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'A project with the same name already exists in the target project.' } as FormErrors));
-            } else if (error === CommonResourceServiceError.OWNERSHIP_CYCLE) {
-                dispatch(stopSubmit(PROJECT_MOVE_FORM_NAME, { ownerUuid: 'Cannot move a project into itself.' } as FormErrors));
-            } else {
-                dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_MOVE_FORM_NAME }));
-                throw new Error('Could not move the project.');
-            }
-            return;
+            throw new Error("Could not move the project.");
         }
-    };
+        return;
+    }
+};
index 057c7cfac59794b95dc7259b66c965551df5c28e..812490319aadd47620a142cc345cbe808d15c9ec 100644 (file)
@@ -3,22 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from "redux";
-import {
-    FormErrors,
-    formValueSelector,
-    initialize,
-    reset,
-    startSubmit,
-    stopSubmit
-} from 'redux-form';
+import { FormErrors, formValueSelector, initialize, reset, startSubmit, stopSubmit } from "redux-form";
 import { RootState } from "store/store";
 import { dialogActions } from "store/dialog/dialog-actions";
-import {
-    getCommonResourceServiceError,
-    CommonResourceServiceError
-} from "services/common-service/common-resource-service";
+import { getCommonResourceServiceError, CommonResourceServiceError } from "services/common-service/common-resource-service";
 import { ServiceRepository } from "services/services";
-import { projectPanelActions } from 'store/project-panel/project-panel-action';
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 import { GroupClass } from "models/group";
 import { Participant } from "views-components/sharing-dialog/participant-select";
 import { ProjectProperties } from "./project-create-actions";
@@ -34,26 +24,27 @@ export interface ProjectUpdateFormDialogData {
     properties?: ProjectProperties;
 }
 
-export const PROJECT_UPDATE_FORM_NAME = 'projectUpdateFormName';
-export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = 'projectUpdatePropertiesFormName';
+export const PROJECT_UPDATE_FORM_NAME = "projectUpdateFormName";
+export const PROJECT_UPDATE_PROPERTIES_FORM_NAME = "projectUpdatePropertiesFormName";
 export const PROJECT_UPDATE_FORM_SELECTOR = formValueSelector(PROJECT_UPDATE_FORM_NAME);
 
-export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) =>
-    (dispatch: Dispatch, getState: () => RootState) => {
-        // Get complete project resource from store to handle consumers passing in partial resources
-        const project = getResource<ProjectResource>(resource.uuid)(getState().resources);
-        dispatch(initialize(PROJECT_UPDATE_FORM_NAME, project));
-        dispatch(dialogActions.OPEN_DIALOG({
+export const openProjectUpdateDialog = (resource: ProjectUpdateFormDialogData) => (dispatch: Dispatch, getState: () => RootState) => {
+    // Get complete project resource from store to handle consumers passing in partial resources
+    const project = getResource<ProjectResource>(resource.uuid)(getState().resources);
+    dispatch(initialize(PROJECT_UPDATE_FORM_NAME, project));
+    dispatch(
+        dialogActions.OPEN_DIALOG({
             id: PROJECT_UPDATE_FORM_NAME,
             data: {
                 sourcePanel: GroupClass.PROJECT,
-            }
-        }));
-    };
+            },
+        })
+    );
+};
 
-export const updateProject = (project: ProjectUpdateFormDialogData) =>
-    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        const uuid = project.uuid || '';
+export const updateProject =
+    (project: ProjectUpdateFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const uuid = project.uuid || "";
         dispatch(startSubmit(PROJECT_UPDATE_FORM_NAME));
         try {
             const updatedProject = await services.projectService.update(
@@ -63,7 +54,8 @@ export const updateProject = (project: ProjectUpdateFormDialogData) =>
                     description: project.description,
                     properties: project.properties,
                 },
-                false);
+                false
+            );
             dispatch(projectPanelActions.REQUEST_ITEMS());
             dispatch(reset(PROJECT_UPDATE_FORM_NAME));
             dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
@@ -71,16 +63,17 @@ export const updateProject = (project: ProjectUpdateFormDialogData) =>
         } catch (e) {
             const error = getCommonResourceServiceError(e);
             if (error === CommonResourceServiceError.UNIQUE_NAME_VIOLATION) {
-                dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: 'Project with the same name already exists.' } as FormErrors));
+                dispatch(stopSubmit(PROJECT_UPDATE_FORM_NAME, { name: "Project with the same name already exists." } as FormErrors));
             } else {
                 dispatch(dialogActions.CLOSE_DIALOG({ id: PROJECT_UPDATE_FORM_NAME }));
-                const errMsg = e.errors
-                    ? e.errors.join('')
-                    : 'There was an error while updating the project';
-                dispatch(snackbarActions.OPEN_SNACKBAR({
-                    message: errMsg,
-                    hideDuration: 2000,
-                    kind: SnackbarKind.ERROR }));
+                const errMsg = e.errors ? e.errors.join("") : "There was an error while updating the project";
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: errMsg,
+                        hideDuration: 2000,
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
             }
             return;
         }
index de231d66b45a526a9c9b50f24b1c653dc84dece1..216a59c72c2236ab3fcbb820e4d2ec836b0ecdf7 100644 (file)
@@ -35,7 +35,7 @@ describe("serializeResourceTypeFilters", () => {
     });
 
     it("should serialize all but collection filters", () => {
-        const filters = deselectNode(ObjectTypeFilter.COLLECTION)(getInitialResourceTypeFilters());
+        const filters = deselectNode(ObjectTypeFilter.COLLECTION, true)(getInitialResourceTypeFilters());
         const serializedFilters = serializeResourceTypeFilters(filters);
         expect(serializedFilters)
             .toEqual(`["uuid","is_a",["${ResourceKind.PROJECT}","${ResourceKind.WORKFLOW}","${ResourceKind.PROCESS}"]],["container_requests.requesting_container_uuid","=",null]`);
@@ -44,11 +44,11 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize output collections and projects", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION),
-            deselectNode(CollectionTypeFilter.LOG_COLLECTION),
-            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.LOG_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -59,11 +59,11 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize output collections and projects", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION),
-            deselectNode(CollectionTypeFilter.LOG_COLLECTION),
-            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.GENERAL_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.LOG_COLLECTION, true),
+            deselectNode(CollectionTypeFilter.INTERMEDIATE_COLLECTION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -74,10 +74,10 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize general collections", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.PROJECT),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(CollectionTypeFilter.OUTPUT_COLLECTION)
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(CollectionTypeFilter.OUTPUT_COLLECTION, true)
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -88,10 +88,10 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize only main processes", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.PROJECT),
-            deselectNode(ProcessTypeFilter.CHILD_PROCESS),
-            deselectNode(ObjectTypeFilter.COLLECTION),
-            deselectNode(ObjectTypeFilter.DEFINITION),
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ProcessTypeFilter.CHILD_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -102,12 +102,12 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize only child processes", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.PROJECT),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ObjectTypeFilter.COLLECTION),
+            deselectNode(ObjectTypeFilter.PROJECT, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
 
-            selectNode(ProcessTypeFilter.CHILD_PROCESS),
+            selectNode(ProcessTypeFilter.CHILD_PROCESS, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -118,9 +118,9 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize all project types", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(ObjectTypeFilter.COLLECTION),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -131,10 +131,10 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize filter groups", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(GroupTypeFilter.PROJECT),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(ObjectTypeFilter.COLLECTION),
+            deselectNode(GroupTypeFilter.PROJECT, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
@@ -145,10 +145,10 @@ describe("serializeResourceTypeFilters", () => {
     it("should serialize projects (normal)", () => {
         const filters = pipe(
             () => getInitialResourceTypeFilters(),
-            deselectNode(GroupTypeFilter.FILTER_GROUP),
-            deselectNode(ObjectTypeFilter.DEFINITION),
-            deselectNode(ProcessTypeFilter.MAIN_PROCESS),
-            deselectNode(ObjectTypeFilter.COLLECTION),
+            deselectNode(GroupTypeFilter.FILTER_GROUP, true),
+            deselectNode(ObjectTypeFilter.DEFINITION, true),
+            deselectNode(ProcessTypeFilter.MAIN_PROCESS, true),
+            deselectNode(ObjectTypeFilter.COLLECTION, true),
         )();
 
         const serializedFilters = serializeResourceTypeFilters(filters);
index e3fb2eb3d53b154b9e7cc254b9b7f406df1ff582..bf82fac12d13d7b608319f9e611c968ba97127fa 100644 (file)
@@ -4,7 +4,6 @@
 
 import { Resource, EditableResource } from "models/resource";
 import { ResourceKind } from 'models/resource';
-import { ProjectResource } from "models/project";
 import { GroupResource } from "models/group";
 
 export type ResourcesState = { [key: string]: Resource };
index 67a89e4c7710237b25c1534cb2e8e00ab50e6b30..000f0cd9758cc20d3e8f4ce2c3014ea341d7c8aa 100644 (file)
@@ -103,7 +103,6 @@ export const setWorkflow = (workflow: WorkflowResource, isWorkflowChanged = true
         const advancedFormValues = getWorkflowRunnerSettings(workflow);
 
         let owner = getResource<ProjectResource | UserResource>(getState().runProcessPanel.processOwnerUuid)(getState().resources);
-        const userUuid = getUserUuid(getState());
         if (!owner || !owner.canWrite) {
             owner = undefined;
         }
index c13092d4851e34a8c035de7662d5516a327f5300..00a69cd2e308f733b617ec5a5b35dadebe42c01c 100644 (file)
@@ -76,7 +76,7 @@ export class SearchResultsMiddlewareService extends DataExplorerMiddlewareServic
                 }).catch(() => {
                     api.dispatch(couldNotFetchSearchResults(session.clusterId));
                 });
-            }
+        }
         );
     }
 }
@@ -102,10 +102,12 @@ const getOrder = (dataExplorer: DataExplorer) => {
             ? OrderDirection.ASC
             : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
             .getOrder();
     } else {
         return order.getOrder();
index f2e402347e4c1b2bc3d3093c9198a5603c0d1df7..1a2bdabab3d7f0325579525dc41e66fa2028377a 100644 (file)
@@ -66,10 +66,12 @@ const getOrder = (dataExplorer: DataExplorer) => {
             ? OrderDirection.ASC
             : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROCESS)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
             .getOrder();
     } else {
         return order.getOrder();
index 913207c35769ddb6cb39608be5f6b988d0bb7499..daa9812e729900fd23fcb2bd04966f6997e764ae 100644 (file)
@@ -2,82 +2,89 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { createStore, applyMiddleware, Middleware, combineReducers, Store, Action, Dispatch } from 'redux';
+import { createStore, applyMiddleware, compose, Middleware, combineReducers, Store, Action, Dispatch } from "redux";
 import { routerMiddleware, routerReducer } from "react-router-redux";
-import thunkMiddleware from 'redux-thunk';
+import thunkMiddleware from "redux-thunk";
 import { History } from "history";
-import { handleRedirects } from '../common/redirect-to';
+import { handleRedirects } from "../common/redirect-to";
 
 import { authReducer } from "./auth/auth-reducer";
 import { authMiddleware } from "./auth/auth-middleware";
-import { dataExplorerReducer } from './data-explorer/data-explorer-reducer';
-import { detailsPanelReducer } from './details-panel/details-panel-reducer';
-import { contextMenuReducer } from './context-menu/context-menu-reducer';
-import { reducer as formReducer } from 'redux-form';
-import { favoritesReducer } from './favorites/favorites-reducer';
-import { snackbarReducer } from './snackbar/snackbar-reducer';
-import { collectionPanelFilesReducer } from './collection-panel/collection-panel-files/collection-panel-files-reducer';
+import { dataExplorerReducer } from "./data-explorer/data-explorer-reducer";
+import { detailsPanelReducer } from "./details-panel/details-panel-reducer";
+import { contextMenuReducer } from "./context-menu/context-menu-reducer";
+import { reducer as formReducer } from "redux-form";
+import { favoritesReducer } from "./favorites/favorites-reducer";
+import { snackbarReducer } from "./snackbar/snackbar-reducer";
+import { collectionPanelFilesReducer } from "./collection-panel/collection-panel-files/collection-panel-files-reducer";
 import { dataExplorerMiddleware } from "./data-explorer/data-explorer-middleware";
 import { FAVORITE_PANEL_ID } from "./favorite-panel/favorite-panel-action";
 import { PROJECT_PANEL_ID } from "./project-panel/project-panel-action";
 import { ProjectPanelMiddlewareService } from "./project-panel/project-panel-middleware-service";
 import { FavoritePanelMiddlewareService } from "./favorite-panel/favorite-panel-middleware-service";
 import { AllProcessesPanelMiddlewareService } from "./all-processes-panel/all-processes-panel-middleware-service";
-import { collectionPanelReducer } from './collection-panel/collection-panel-reducer';
-import { dialogReducer } from './dialog/dialog-reducer';
+import { collectionPanelReducer } from "./collection-panel/collection-panel-reducer";
+import { dialogReducer } from "./dialog/dialog-reducer";
 import { ServiceRepository } from "services/services";
-import { treePickerReducer, treePickerSearchReducer } from './tree-picker/tree-picker-reducer';
-import { treePickerSearchMiddleware } from './tree-picker/tree-picker-middleware';
-import { resourcesReducer } from 'store/resources/resources-reducer';
-import { propertiesReducer } from './properties/properties-reducer';
-import { fileUploaderReducer } from './file-uploader/file-uploader-reducer';
+import { treePickerReducer, treePickerSearchReducer } from "./tree-picker/tree-picker-reducer";
+import { treePickerSearchMiddleware } from "./tree-picker/tree-picker-middleware";
+import { resourcesReducer } from "store/resources/resources-reducer";
+import { propertiesReducer } from "./properties/properties-reducer";
+import { fileUploaderReducer } from "./file-uploader/file-uploader-reducer";
 import { TrashPanelMiddlewareService } from "store/trash-panel/trash-panel-middleware-service";
 import { TRASH_PANEL_ID } from "store/trash-panel/trash-panel-action";
-import { processLogsPanelReducer } from './process-logs-panel/process-logs-panel-reducer';
-import { processPanelReducer } from 'store/process-panel/process-panel-reducer';
-import { SHARED_WITH_ME_PANEL_ID } from 'store/shared-with-me-panel/shared-with-me-panel-actions';
-import { SharedWithMeMiddlewareService } from './shared-with-me-panel/shared-with-me-middleware-service';
-import { progressIndicatorReducer } from './progress-indicator/progress-indicator-reducer';
-import { runProcessPanelReducer } from 'store/run-process-panel/run-process-panel-reducer';
-import { WorkflowMiddlewareService } from './workflow-panel/workflow-middleware-service';
-import { WORKFLOW_PANEL_ID } from './workflow-panel/workflow-panel-actions';
-import { appInfoReducer } from 'store/app-info/app-info-reducer';
-import { searchBarReducer } from './search-bar/search-bar-reducer';
-import { SEARCH_RESULTS_PANEL_ID } from 'store/search-results-panel/search-results-panel-actions';
-import { SearchResultsMiddlewareService } from './search-results-panel/search-results-middleware-service';
+import { processLogsPanelReducer } from "./process-logs-panel/process-logs-panel-reducer";
+import { processPanelReducer } from "store/process-panel/process-panel-reducer";
+import { SHARED_WITH_ME_PANEL_ID } from "store/shared-with-me-panel/shared-with-me-panel-actions";
+import { SharedWithMeMiddlewareService } from "./shared-with-me-panel/shared-with-me-middleware-service";
+import { progressIndicatorReducer } from "./progress-indicator/progress-indicator-reducer";
+import { runProcessPanelReducer } from "store/run-process-panel/run-process-panel-reducer";
+import { WorkflowMiddlewareService } from "./workflow-panel/workflow-middleware-service";
+import { WORKFLOW_PANEL_ID } from "./workflow-panel/workflow-panel-actions";
+import { appInfoReducer } from "store/app-info/app-info-reducer";
+import { searchBarReducer } from "./search-bar/search-bar-reducer";
+import { SEARCH_RESULTS_PANEL_ID } from "store/search-results-panel/search-results-panel-actions";
+import { SearchResultsMiddlewareService } from "./search-results-panel/search-results-middleware-service";
 import { virtualMachinesReducer } from "store/virtual-machines/virtual-machines-reducer";
-import { repositoriesReducer } from 'store/repositories/repositories-reducer';
-import { keepServicesReducer } from 'store/keep-services/keep-services-reducer';
-import { UserMiddlewareService } from 'store/users/user-panel-middleware-service';
-import { USERS_PANEL_ID } from 'store/users/users-actions';
-import { UserProfileGroupsMiddlewareService } from 'store/user-profile/user-profile-groups-middleware-service';
-import { USER_PROFILE_PANEL_ID } from 'store/user-profile/user-profile-actions'
-import { GroupsPanelMiddlewareService } from 'store/groups-panel/groups-panel-middleware-service';
-import { GROUPS_PANEL_ID } from 'store/groups-panel/groups-panel-actions';
-import { GroupDetailsPanelMembersMiddlewareService } from 'store/group-details-panel/group-details-panel-members-middleware-service';
-import { GroupDetailsPanelPermissionsMiddlewareService } from 'store/group-details-panel/group-details-panel-permissions-middleware-service';
-import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from 'store/group-details-panel/group-details-panel-actions';
-import { LINK_PANEL_ID } from 'store/link-panel/link-panel-actions';
-import { LinkMiddlewareService } from 'store/link-panel/link-panel-middleware-service';
-import { API_CLIENT_AUTHORIZATION_PANEL_ID } from 'store/api-client-authorizations/api-client-authorizations-actions';
-import { ApiClientAuthorizationMiddlewareService } from 'store/api-client-authorizations/api-client-authorizations-middleware-service';
-import { PublicFavoritesMiddlewareService } from 'store/public-favorites-panel/public-favorites-middleware-service';
-import { PUBLIC_FAVORITE_PANEL_ID } from 'store/public-favorites-panel/public-favorites-action';
-import { publicFavoritesReducer } from 'store/public-favorites/public-favorites-reducer';
-import { linkAccountPanelReducer } from './link-account-panel/link-account-panel-reducer';
-import { CollectionsWithSameContentAddressMiddlewareService } from 'store/collections-content-address-panel/collections-content-address-middleware-service';
-import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from 'store/collections-content-address-panel/collections-content-address-panel-actions';
-import { ownerNameReducer } from 'store/owner-name/owner-name-reducer';
-import { SubprocessMiddlewareService } from 'store/subprocess-panel/subprocess-panel-middleware-service';
-import { SUBPROCESS_PANEL_ID } from 'store/subprocess-panel/subprocess-panel-actions';
-import { ALL_PROCESSES_PANEL_ID } from './all-processes-panel/all-processes-panel-action';
-import { Config } from 'common/config';
-import { pluginConfig } from 'plugins';
-import { MiddlewareListReducer } from 'common/plugintypes';
-import { tooltipsMiddleware } from './tooltips/tooltips-middleware';
-import { sidePanelReducer } from './side-panel/side-panel-reducer'
-import { bannerReducer } from './banner/banner-reducer';
-import { composeWithDevTools } from 'redux-devtools-extension';
+import { repositoriesReducer } from "store/repositories/repositories-reducer";
+import { keepServicesReducer } from "store/keep-services/keep-services-reducer";
+import { UserMiddlewareService } from "store/users/user-panel-middleware-service";
+import { USERS_PANEL_ID } from "store/users/users-actions";
+import { UserProfileGroupsMiddlewareService } from "store/user-profile/user-profile-groups-middleware-service";
+import { USER_PROFILE_PANEL_ID } from "store/user-profile/user-profile-actions";
+import { GroupsPanelMiddlewareService } from "store/groups-panel/groups-panel-middleware-service";
+import { GROUPS_PANEL_ID } from "store/groups-panel/groups-panel-actions";
+import { GroupDetailsPanelMembersMiddlewareService } from "store/group-details-panel/group-details-panel-members-middleware-service";
+import { GroupDetailsPanelPermissionsMiddlewareService } from "store/group-details-panel/group-details-panel-permissions-middleware-service";
+import { GROUP_DETAILS_MEMBERS_PANEL_ID, GROUP_DETAILS_PERMISSIONS_PANEL_ID } from "store/group-details-panel/group-details-panel-actions";
+import { LINK_PANEL_ID } from "store/link-panel/link-panel-actions";
+import { LinkMiddlewareService } from "store/link-panel/link-panel-middleware-service";
+import { API_CLIENT_AUTHORIZATION_PANEL_ID } from "store/api-client-authorizations/api-client-authorizations-actions";
+import { ApiClientAuthorizationMiddlewareService } from "store/api-client-authorizations/api-client-authorizations-middleware-service";
+import { PublicFavoritesMiddlewareService } from "store/public-favorites-panel/public-favorites-middleware-service";
+import { PUBLIC_FAVORITE_PANEL_ID } from "store/public-favorites-panel/public-favorites-action";
+import { publicFavoritesReducer } from "store/public-favorites/public-favorites-reducer";
+import { linkAccountPanelReducer } from "./link-account-panel/link-account-panel-reducer";
+import { CollectionsWithSameContentAddressMiddlewareService } from "store/collections-content-address-panel/collections-content-address-middleware-service";
+import { COLLECTIONS_CONTENT_ADDRESS_PANEL_ID } from "store/collections-content-address-panel/collections-content-address-panel-actions";
+import { ownerNameReducer } from "store/owner-name/owner-name-reducer";
+import { SubprocessMiddlewareService } from "store/subprocess-panel/subprocess-panel-middleware-service";
+import { SUBPROCESS_PANEL_ID } from "store/subprocess-panel/subprocess-panel-actions";
+import { ALL_PROCESSES_PANEL_ID } from "./all-processes-panel/all-processes-panel-action";
+import { Config } from "common/config";
+import { pluginConfig } from "plugins";
+import { MiddlewareListReducer } from "common/plugintypes";
+import { tooltipsMiddleware } from "./tooltips/tooltips-middleware";
+import { sidePanelReducer } from "./side-panel/side-panel-reducer";
+import { bannerReducer } from "./banner/banner-reducer";
+import { multiselectReducer } from "./multiselect/multiselect-reducer";
+import { composeWithDevTools } from "redux-devtools-extension";
+
+declare global {
+    interface Window {
+        __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose;
+    }
+}
 
 export type RootState = ReturnType<ReturnType<typeof createRootReducer>>;
 
@@ -86,57 +93,32 @@ export type RootStore = Store<RootState, Action> & { dispatch: Dispatch<any> };
 export function configureStore(history: History, services: ServiceRepository, config: Config): RootStore {
     const rootReducer = createRootReducer(services);
 
-    const projectPanelMiddleware = dataExplorerMiddleware(
-        new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID)
-    );
-    const favoritePanelMiddleware = dataExplorerMiddleware(
-        new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID)
-    );
-    const allProcessessPanelMiddleware = dataExplorerMiddleware(
-        new AllProcessesPanelMiddlewareService(services, ALL_PROCESSES_PANEL_ID)
-    );
-    const trashPanelMiddleware = dataExplorerMiddleware(
-        new TrashPanelMiddlewareService(services, TRASH_PANEL_ID)
-    );
-    const searchResultsPanelMiddleware = dataExplorerMiddleware(
-        new SearchResultsMiddlewareService(services, SEARCH_RESULTS_PANEL_ID)
-    );
-    const sharedWithMePanelMiddleware = dataExplorerMiddleware(
-        new SharedWithMeMiddlewareService(services, SHARED_WITH_ME_PANEL_ID)
-    );
-    const workflowPanelMiddleware = dataExplorerMiddleware(
-        new WorkflowMiddlewareService(services, WORKFLOW_PANEL_ID)
-    );
-    const userPanelMiddleware = dataExplorerMiddleware(
-        new UserMiddlewareService(services, USERS_PANEL_ID)
-    );
-    const userProfileGroupsMiddleware = dataExplorerMiddleware(
-        new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID)
-    );
-    const groupsPanelMiddleware = dataExplorerMiddleware(
-        new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID)
-    );
+    const projectPanelMiddleware = dataExplorerMiddleware(new ProjectPanelMiddlewareService(services, PROJECT_PANEL_ID));
+    const favoritePanelMiddleware = dataExplorerMiddleware(new FavoritePanelMiddlewareService(services, FAVORITE_PANEL_ID));
+    const allProcessessPanelMiddleware = dataExplorerMiddleware(new AllProcessesPanelMiddlewareService(services, ALL_PROCESSES_PANEL_ID));
+    const trashPanelMiddleware = dataExplorerMiddleware(new TrashPanelMiddlewareService(services, TRASH_PANEL_ID));
+    const searchResultsPanelMiddleware = dataExplorerMiddleware(new SearchResultsMiddlewareService(services, SEARCH_RESULTS_PANEL_ID));
+    const sharedWithMePanelMiddleware = dataExplorerMiddleware(new SharedWithMeMiddlewareService(services, SHARED_WITH_ME_PANEL_ID));
+    const workflowPanelMiddleware = dataExplorerMiddleware(new WorkflowMiddlewareService(services, WORKFLOW_PANEL_ID));
+    const userPanelMiddleware = dataExplorerMiddleware(new UserMiddlewareService(services, USERS_PANEL_ID));
+    const userProfileGroupsMiddleware = dataExplorerMiddleware(new UserProfileGroupsMiddlewareService(services, USER_PROFILE_PANEL_ID));
+    const groupsPanelMiddleware = dataExplorerMiddleware(new GroupsPanelMiddlewareService(services, GROUPS_PANEL_ID));
     const groupDetailsPanelMembersMiddleware = dataExplorerMiddleware(
         new GroupDetailsPanelMembersMiddlewareService(services, GROUP_DETAILS_MEMBERS_PANEL_ID)
     );
     const groupDetailsPanelPermissionsMiddleware = dataExplorerMiddleware(
         new GroupDetailsPanelPermissionsMiddlewareService(services, GROUP_DETAILS_PERMISSIONS_PANEL_ID)
     );
-    const linkPanelMiddleware = dataExplorerMiddleware(
-        new LinkMiddlewareService(services, LINK_PANEL_ID)
-    );
+    const linkPanelMiddleware = dataExplorerMiddleware(new LinkMiddlewareService(services, LINK_PANEL_ID));
     const apiClientAuthorizationMiddlewareService = dataExplorerMiddleware(
         new ApiClientAuthorizationMiddlewareService(services, API_CLIENT_AUTHORIZATION_PANEL_ID)
     );
-    const publicFavoritesMiddleware = dataExplorerMiddleware(
-        new PublicFavoritesMiddlewareService(services, PUBLIC_FAVORITE_PANEL_ID)
-    );
+    const publicFavoritesMiddleware = dataExplorerMiddleware(new PublicFavoritesMiddlewareService(services, PUBLIC_FAVORITE_PANEL_ID));
     const collectionsContentAddress = dataExplorerMiddleware(
         new CollectionsWithSameContentAddressMiddlewareService(services, COLLECTIONS_CONTENT_ADDRESS_PANEL_ID)
     );
-    const subprocessMiddleware = dataExplorerMiddleware(
-        new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID)
-    );
+    const subprocessMiddleware = dataExplorerMiddleware(new SubprocessMiddlewareService(services, SUBPROCESS_PANEL_ID));
+
     const redirectToMiddleware = (store: any) => (next: any) => (action: any) => {
         const state = store.getState();
 
@@ -169,47 +151,50 @@ export function configureStore(history: History, services: ServiceRepository, co
         publicFavoritesMiddleware,
         collectionsContentAddress,
         subprocessMiddleware,
-        treePickerSearchMiddleware
+        treePickerSearchMiddleware,
     ];
 
-    const reduceMiddlewaresFn: (a: Middleware[],
-        b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services);
+    const reduceMiddlewaresFn: (a: Middleware[], b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services);
 
     middlewares = pluginConfig.middlewares.reduce(reduceMiddlewaresFn, middlewares);
 
-    const enhancer = composeWithDevTools({/* options */ })(applyMiddleware(redirectToMiddleware, ...middlewares));
+    const enhancer = composeWithDevTools({
+        /* options */
+    })(applyMiddleware(redirectToMiddleware, ...middlewares));
     return createStore(rootReducer, enhancer);
 }
 
-const createRootReducer = (services: ServiceRepository) => combineReducers({
-    auth: authReducer(services),
-    banner: bannerReducer,
-    collectionPanel: collectionPanelReducer,
-    collectionPanelFiles: collectionPanelFilesReducer,
-    contextMenu: contextMenuReducer,
-    dataExplorer: dataExplorerReducer,
-    detailsPanel: detailsPanelReducer,
-    dialog: dialogReducer,
-    favorites: favoritesReducer,
-    ownerName: ownerNameReducer,
-    publicFavorites: publicFavoritesReducer,
-    form: formReducer,
-    processLogsPanel: processLogsPanelReducer,
-    properties: propertiesReducer,
-    resources: resourcesReducer,
-    router: routerReducer,
-    snackbar: snackbarReducer,
-    treePicker: treePickerReducer,
-    treePickerSearch: treePickerSearchReducer,
-    fileUploader: fileUploaderReducer,
-    processPanel: processPanelReducer,
-    progressIndicator: progressIndicatorReducer,
-    runProcessPanel: runProcessPanelReducer,
-    appInfo: appInfoReducer,
-    searchBar: searchBarReducer,
-    virtualMachines: virtualMachinesReducer,
-    repositories: repositoriesReducer,
-    keepServices: keepServicesReducer,
-    linkAccountPanel: linkAccountPanelReducer,
-    sidePanel: sidePanelReducer
-});
+const createRootReducer = (services: ServiceRepository) =>
+    combineReducers({
+        auth: authReducer(services),
+        banner: bannerReducer,
+        collectionPanel: collectionPanelReducer,
+        collectionPanelFiles: collectionPanelFilesReducer,
+        contextMenu: contextMenuReducer,
+        dataExplorer: dataExplorerReducer,
+        detailsPanel: detailsPanelReducer,
+        dialog: dialogReducer,
+        favorites: favoritesReducer,
+        ownerName: ownerNameReducer,
+        publicFavorites: publicFavoritesReducer,
+        form: formReducer,
+        processLogsPanel: processLogsPanelReducer,
+        properties: propertiesReducer,
+        resources: resourcesReducer,
+        router: routerReducer,
+        snackbar: snackbarReducer,
+        treePicker: treePickerReducer,
+        treePickerSearch: treePickerSearchReducer,
+        fileUploader: fileUploaderReducer,
+        processPanel: processPanelReducer,
+        progressIndicator: progressIndicatorReducer,
+        runProcessPanel: runProcessPanelReducer,
+        appInfo: appInfoReducer,
+        searchBar: searchBarReducer,
+        virtualMachines: virtualMachinesReducer,
+        repositories: repositoriesReducer,
+        keepServices: keepServicesReducer,
+        linkAccountPanel: linkAccountPanelReducer,
+        sidePanel: sidePanelReducer,
+        multiselect: multiselectReducer,
+    });
index 986c6ebde6c3b06d515a01d81dbab05129dc815d..5124c8346a6951fe656cf0ae255038a7cfc344bd 100644 (file)
@@ -26,19 +26,19 @@ export class SubprocessMiddlewareService extends DataExplorerMiddlewareService {
         super(id);
     }
 
-    async requestItems(api: MiddlewareAPI<Dispatch, RootState>) {
+    async requestItems(api: MiddlewareAPI<Dispatch, RootState>, criteriaChanged?: boolean, background?: boolean) {
         const state = api.getState();
         const parentContainerRequestUuid = state.processPanel.containerRequestUuid;
         if (parentContainerRequestUuid === "") { return; }
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
 
         try {
-            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
+            if (!background) { api.dispatch(progressIndicatorActions.START_WORKING(this.getId())); }
             const parentContainerRequest = await this.services.containerRequestService.get(parentContainerRequestUuid);
             if (parentContainerRequest.containerUuid) {
                 const containerRequests = await this.services.containerRequestService.list(
                     {
-                        ...getParams(dataExplorer, parentContainerRequest) ,
+                        ...getParams(dataExplorer, parentContainerRequest),
                         select: containerRequestFieldsNoMounts
                     });
                 api.dispatch(updateResources(containerRequests.items));
@@ -46,9 +46,9 @@ export class SubprocessMiddlewareService extends DataExplorerMiddlewareService {
                 // Populate the actual user view
                 api.dispatch(setItems(containerRequests));
             }
-            api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+            if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
         } catch {
-            api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId()));
+            if (!background) { api.dispatch(progressIndicatorActions.PERSIST_STOP_WORKING(this.getId())); }
             api.dispatch(couldNotFetchSubprocesses());
         }
     }
@@ -65,27 +65,27 @@ export const getParams = (
 export const getFilters = (
     dataExplorer: DataExplorer,
     parentContainerRequest: ContainerRequestResource) => {
-        const columns = dataExplorer.columns as DataColumns<string, ProcessResource>;
-        const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
-        const activeStatusFilter = Object.keys(statusColumnFilters).find(
-            filterName => statusColumnFilters[filterName].selected
-        ) || ProcessStatusFilter.ALL;
+    const columns = dataExplorer.columns as DataColumns<string, ProcessResource>;
+    const statusColumnFilters = getDataExplorerColumnFilters(columns, 'Status');
+    const activeStatusFilter = Object.keys(statusColumnFilters).find(
+        filterName => statusColumnFilters[filterName].selected
+    ) || ProcessStatusFilter.ALL;
 
-        // Get all the subprocess' container requests and containers.
-        const fb = new FilterBuilder().addEqual('requesting_container_uuid', parentContainerRequest.containerUuid);
-        const statusFilters = buildProcessStatusFilters(fb, activeStatusFilter).getFilters();
+    // Get all the subprocess' container requests and containers.
+    const fb = new FilterBuilder().addEqual('requesting_container_uuid', parentContainerRequest.containerUuid);
+    const statusFilters = buildProcessStatusFilters(fb, activeStatusFilter).getFilters();
 
-        const nameFilters = dataExplorer.searchValue
-            ? new FilterBuilder()
-                .addILike("name", dataExplorer.searchValue)
-                .getFilters()
-            : '';
+    const nameFilters = dataExplorer.searchValue
+        ? new FilterBuilder()
+            .addILike("name", dataExplorer.searchValue)
+            .getFilters()
+        : '';
 
-        return joinFilters(
-            nameFilters,
-            statusFilters
-        );
-    };
+    return joinFilters(
+        nameFilters,
+        statusFilters
+    );
+};
 
 export const setItems = (listResults: ListResults<ProcessResource>) =>
     subprocessPanelActions.SET_ITEMS({
index e63365103319c709de6a7405e594409662e1148b..d72b6ad7a1ab62ee59c83ce5e765ff6d5177de44 100644 (file)
@@ -95,9 +95,11 @@ const getOrder = (dataExplorer: DataExplorer) => {
             ? OrderDirection.ASC
             : OrderDirection.DESC;
 
+        // Use createdAt as a secondary sort column so we break ties consistently.
         return order
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.COLLECTION)
             .addOrder(sortDirection, sortColumn.sort.field, GroupContentsResourcePrefix.PROJECT)
+            .addOrder(OrderDirection.DESC, "createdAt", GroupContentsResourcePrefix.PROCESS)
             .getOrder();
     } else {
         return order.getOrder();
index 85ffd4a0bed5681424a39d24d948aa648bc11683..884293a90e57e4e914486fe864b9bb4ea9240473 100644 (file)
@@ -8,53 +8,71 @@ import { ServiceRepository } from "services/services";
 import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 import { trashPanelActions } from "store/trash-panel/trash-panel-action";
 import { activateSidePanelTreeItem, loadSidePanelTreeProjects } from "store/side-panel-tree/side-panel-tree-actions";
-import { projectPanelActions } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 import { ResourceKind } from "models/resource";
-import { navigateTo, navigateToTrash } from 'store/navigation/navigation-action';
-import { matchCollectionRoute } from 'routes/routes';
+import { navigateTo, navigateToTrash } from "store/navigation/navigation-action";
+import { matchCollectionRoute } from "routes/routes";
 
-export const toggleProjectTrashed = (uuid: string, ownerUuid: string, isTrashed: boolean) =>
+export const toggleProjectTrashed =
+    (uuid: string, ownerUuid: string, isTrashed: boolean, isMulti: boolean) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
-        let errorMessage = '';
-        let successMessage = '';
+        let errorMessage = "";
+        let successMessage = "";
+        let untrashedResource;
         try {
             if (isTrashed) {
                 errorMessage = "Could not restore project from trash";
-                successMessage = "Restored from trash";
-                await services.groupsService.untrash(uuid);
-                dispatch<any>(navigateTo(uuid));
+                successMessage = "Restored project from trash";
+                untrashedResource = await services.groupsService.untrash(uuid);
+                dispatch<any>(isMulti || !untrashedResource ? navigateToTrash : navigateTo(uuid));
                 dispatch<any>(activateSidePanelTreeItem(uuid));
             } else {
                 errorMessage = "Could not move project to trash";
-                successMessage = "Added to trash";
+                successMessage = "Added project to trash";
                 await services.groupsService.trash(uuid);
                 dispatch<any>(loadSidePanelTreeProjects(ownerUuid));
                 dispatch<any>(navigateTo(ownerUuid));
             }
+            if (untrashedResource) {
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: successMessage,
+                        hideDuration: 2000,
+                        kind: SnackbarKind.SUCCESS,
+                    })
+                );
+            }
         } catch (e) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: errorMessage,
-                kind: SnackbarKind.ERROR
-            }));
+            if (e.status === 422) {
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: "Could not restore project from trash: Duplicate name at destination",
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
+            } else {
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: errorMessage,
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
+            }
         }
-        dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: successMessage,
-            hideDuration: 2000,
-            kind: SnackbarKind.SUCCESS
-        }));
     };
 
-export const toggleCollectionTrashed = (uuid: string, isTrashed: boolean) =>
+export const toggleCollectionTrashed =
+    (uuid: string, isTrashed: boolean) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<any> => {
-        let errorMessage = '';
-        let successMessage = '';
+        let errorMessage = "";
+        let successMessage = "";
         try {
             if (isTrashed) {
                 const { location } = getState().router;
                 errorMessage = "Could not restore collection from trash";
                 successMessage = "Restored from trash";
                 await services.collectionService.untrash(uuid);
-                if (matchCollectionRoute(location ? location.pathname : '')) {
+                if (matchCollectionRoute(location ? location.pathname : "")) {
                     dispatch(navigateToTrash);
                 }
                 dispatch(trashPanelActions.REQUEST_ITEMS());
@@ -64,24 +82,36 @@ export const toggleCollectionTrashed = (uuid: string, isTrashed: boolean) =>
                 await services.collectionService.trash(uuid);
                 dispatch(projectPanelActions.REQUEST_ITEMS());
             }
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: successMessage,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                })
+            );
         } catch (e) {
-            dispatch(snackbarActions.OPEN_SNACKBAR({
-                message: errorMessage,
-                kind: SnackbarKind.ERROR
-            }));
+            if (e.status === 422) {
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: "Could not restore collection from trash: Duplicate name at destination",
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
+            } else {
+                dispatch(
+                    snackbarActions.OPEN_SNACKBAR({
+                        message: errorMessage,
+                        kind: SnackbarKind.ERROR,
+                    })
+                );
+            }
         }
-        dispatch(snackbarActions.OPEN_SNACKBAR({
-            message: successMessage,
-            hideDuration: 2000,
-            kind: SnackbarKind.SUCCESS
-        }));
     };
 
-export const toggleTrashed = (kind: ResourceKind, uuid: string, ownerUuid: string, isTrashed: boolean) =>
-    (dispatch: Dispatch) => {
-        if (kind === ResourceKind.PROJECT) {
-            dispatch<any>(toggleProjectTrashed(uuid, ownerUuid, isTrashed!!));
-        } else if (kind === ResourceKind.COLLECTION) {
-            dispatch<any>(toggleCollectionTrashed(uuid, isTrashed!!));
-        }
-    };
+export const toggleTrashed = (kind: ResourceKind, uuid: string, ownerUuid: string, isTrashed: boolean) => (dispatch: Dispatch) => {
+    if (kind === ResourceKind.PROJECT) {
+        dispatch<any>(toggleProjectTrashed(uuid, ownerUuid, isTrashed!!, false));
+    } else if (kind === ResourceKind.COLLECTION) {
+        dispatch<any>(toggleCollectionTrashed(uuid, isTrashed!!));
+    }
+};
index b0d5e353eff3446d99c5371cc76385de6185671e..5734ad70c61df76f1f99f25314003ed07ae2fab3 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
+import React from "react";
 
 export interface PickerIdProp {
     pickerId: string;
@@ -10,7 +10,12 @@ export interface PickerIdProp {
 
 export const pickerId =
     (id: string) =>
-        <P extends PickerIdProp>(Component: React.ComponentType<P>) =>
-            (props: P) =>
-                <Component {...props} pickerId={id} />;
-                
\ No newline at end of file
+    <P extends PickerIdProp>(Component: React.ComponentType<P>) =>
+    (props: P) => {
+        return (
+            <Component
+                {...props}
+                pickerId={id}
+            />
+        );
+    };
diff --git a/src/store/tree-picker/tree-picker-actions.test.ts b/src/store/tree-picker/tree-picker-actions.test.ts
new file mode 100644 (file)
index 0000000..9622282
--- /dev/null
@@ -0,0 +1,192 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ServiceRepository, createServices } from "services/services";
+import { configureStore, RootStore } from "../store";
+import { createBrowserHistory } from "history";
+import { mockConfig } from 'common/config';
+import { ApiActions } from "services/api/api-actions";
+import Axios from "axios";
+import MockAdapter from "axios-mock-adapter";
+import { ResourceKind } from 'models/resource';
+import { SHARED_PROJECT_ID, initProjectsTreePicker } from "./tree-picker-actions";
+import { CollectionResource } from "models/collection";
+import { GroupResource } from "models/group";
+import { CollectionDirectory, CollectionFile, CollectionFileType } from "models/collection-file";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { ListResults } from "services/common-service/common-service";
+
+describe('tree-picker-actions', () => {
+    const axiosInst = Axios.create({ headers: {} });
+    const axiosMock = new MockAdapter(axiosInst);
+
+    let store: RootStore;
+    let services: ServiceRepository;
+    const config: any = {
+
+
+    };
+    const actions: ApiActions = {
+        progressFn: (id: string, working: boolean) => { },
+        errorFn: (id: string, message: string) => { }
+    };
+    let importMocks: any[];
+
+    beforeEach(() => {
+        axiosMock.reset();
+        services = createServices(mockConfig({}), actions, axiosInst);
+        store = configureStore(createBrowserHistory(), services, config);
+        localStorage.clear();
+        importMocks = [];
+    });
+
+    afterEach(() => {
+        importMocks.map(m => m.restore());
+    });
+
+    it('initializes preselected tree picker nodes', async () => {
+        const dispatchMock = jest.fn();
+        const dispatchWrapper = (action: any) => {
+            dispatchMock(action);
+            return store.dispatch(action);
+        };
+
+        const emptyCollectionUuid = "zzzzz-4zz18-000000000000000";
+        const collectionUuid = "zzzzz-4zz18-111111111111111";
+        const parentProjectUuid = "zzzzz-j7d0g-000000000000000";
+        const childCollectionUuid = "zzzzz-4zz18-222222222222222";
+
+        const fakeResources = {
+            [emptyCollectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: '',
+                files: [],
+            },
+            [collectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: '',
+                files: [{
+                    id: `${collectionUuid}/directory`,
+                    name: "directory",
+                    path: "",
+                    type: CollectionFileType.DIRECTORY,
+                    url: `/c=${collectionUuid}/directory/`,
+                }]
+            },
+            [parentProjectUuid]: {
+                kind: ResourceKind.GROUP,
+                ownerUuid: '',
+            },
+            [childCollectionUuid]: {
+                kind: ResourceKind.COLLECTION,
+                ownerUuid: parentProjectUuid,
+                files: [
+                    {
+                        id: `${childCollectionUuid}/mainDir`,
+                        name: "mainDir",
+                        path: "",
+                        type: CollectionFileType.DIRECTORY,
+                        url: `/c=${childCollectionUuid}/mainDir/`,
+                    },
+                    {
+                        id: `${childCollectionUuid}/mainDir/subDir`,
+                        name: "subDir",
+                        path: "/mainDir",
+                        type: CollectionFileType.DIRECTORY,
+                        url: `/c=${childCollectionUuid}/mainDir/subDir`,
+                    }
+                ],
+            },
+        };
+
+        services.ancestorsService.ancestors = jest.fn(async (startUuid, endUuid) => {
+            let ancestors: (GroupResource | CollectionResource)[] = [];
+            let uuid = startUuid;
+            while (uuid?.length && fakeResources[uuid]) {
+                const resource = fakeResources[uuid];
+                if (resource.kind === ResourceKind.COLLECTION) {
+                    ancestors.unshift({
+                        uuid, kind: resource.kind,
+                        ownerUuid: resource.ownerUuid,
+                    } as CollectionResource);
+                } else if (resource.kind === ResourceKind.GROUP) {
+                    ancestors.unshift({
+                        uuid, kind: resource.kind,
+                        ownerUuid: resource.ownerUuid,
+                    } as GroupResource);
+                }
+                uuid = resource.ownerUuid;
+            }
+            return ancestors;
+        });
+
+        services.collectionService.files = jest.fn(async (uuid): Promise<(CollectionDirectory | CollectionFile)[]> => {
+            return fakeResources[uuid]?.files || [];
+        });
+
+        services.groupsService.contents = jest.fn(async (uuid, args) => {
+            const items = Object.keys(fakeResources).map(uuid => ({...fakeResources[uuid], uuid})).filter(item => item.ownerUuid === uuid);
+            return {items: items as GroupContentsResource[], itemsAvailable: items.length} as ListResults<GroupContentsResource>;
+        });
+
+        const pickerId = "pickerId";
+
+        // When collection preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [emptyCollectionUuid],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(emptyCollectionUuid, '');
+        // Expect top level to be expanded and node to be selected
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][emptyCollectionUuid].selected).toBe(true);
+
+
+        // When collection subdirectory is preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [`${collectionUuid}/directory`],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(collectionUuid, '');
+        // Expect top level to be expanded and node to be selected
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][collectionUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][collectionUuid].selected).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][`${collectionUuid}/directory`].selected).toBe(true);
+
+
+        // When subdirectory of collection inside project is preselected
+        await initProjectsTreePicker(pickerId, {
+            selectedItemUuids: [`${childCollectionUuid}/mainDir/subDir`],
+            includeDirectories: true,
+            includeFiles: false,
+            multi: true,
+        })(dispatchWrapper, store.getState, services);
+
+        // Expect ancestor service to be called
+        expect(services.ancestorsService.ancestors).toHaveBeenCalledWith(childCollectionUuid, '');
+        // Expect parent project and collection to be expanded
+        expect(store.getState().treePicker["pickerId_shared"][SHARED_PROJECT_ID].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][parentProjectUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][parentProjectUuid].selected).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][childCollectionUuid].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][childCollectionUuid].selected).toBe(false);
+        // Expect main directory to be expanded
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir`].expanded).toBe(true);
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir`].selected).toBe(false);
+        // Expect sub directory to be selected
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir/subDir`].expanded).toBe(false);
+        expect(store.getState().treePicker["pickerId_shared"][`${childCollectionUuid}/mainDir/subDir`].selected).toBe(true);
+
+
+    });
+});
index 72d1cb65d969de803ffc93d162b69dfc250fb56b..883847d85464e7f374504118c50bba935872de4f 100644 (file)
@@ -23,6 +23,9 @@ import { mapTreeValues } from "models/tree";
 import { sortFilesTree } from "services/collection-service/collection-service-files-response";
 import { GroupClass, GroupResource } from "models/group";
 import { CollectionResource } from "models/collection";
+import { getResource } from "store/resources/resources";
+import { updateResources } from "store/resources/resources-actions";
+import { SnackbarKind, snackbarActions } from "store/snackbar/snackbar-actions";
 
 export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
@@ -30,11 +33,12 @@ export const treePickerActions = unionize({
     APPEND_TREE_PICKER_NODE_SUBTREE: ofType<{ id: string, subtree: Tree<any>, pickerId: string }>(),
     TOGGLE_TREE_PICKER_NODE_COLLAPSE: ofType<{ id: string, pickerId: string }>(),
     EXPAND_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
+    EXPAND_TREE_PICKER_NODE_ANCESTORS: ofType<{ id: string, pickerId: string }>(),
     ACTIVATE_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string, relatedTreePickers?: string[] }>(),
     DEACTIVATE_TREE_PICKER_NODE: ofType<{ pickerId: string }>(),
-    TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string }>(),
-    SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
-    DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string }>(),
+    TOGGLE_TREE_PICKER_NODE_SELECTION: ofType<{ id: string, pickerId: string, cascade: boolean }>(),
+    SELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
+    DESELECT_TREE_PICKER_NODE: ofType<{ id: string | string[], pickerId: string, cascade: boolean }>(),
     EXPAND_TREE_PICKER_NODES: ofType<{ ids: string[], pickerId: string }>(),
     RESET_TREE_PICKER: ofType<{ pickerId: string }>()
 });
@@ -89,7 +93,14 @@ export const getAllNodes = <Value>(pickerId: string, filter = (node: TreeNode<Va
 export const getSelectedNodes = <Value>(pickerId: string) => (state: TreePicker) =>
     getAllNodes<Value>(pickerId, node => node.selected)(state);
 
-export const initProjectsTreePicker = (pickerId: string, selectedItemUuid?: string) =>
+interface TreePickerPreloadParams {
+    selectedItemUuids: string[];
+    includeDirectories: boolean;
+    includeFiles: boolean;
+    multi: boolean;
+}
+
+export const initProjectsTreePicker = (pickerId: string, preloadParams?: TreePickerPreloadParams) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
         dispatch<any>(initUserProject(home));
@@ -98,8 +109,14 @@ export const initProjectsTreePicker = (pickerId: string, selectedItemUuid?: stri
         dispatch<any>(initPublicFavoritesProject(publicFavorites));
         dispatch<any>(initSearchProject(search));
 
-        if (selectedItemUuid) {
-            dispatch<any>(loadInitialValue(selectedItemUuid, pickerId));
+        if (preloadParams && preloadParams.selectedItemUuids.length) {
+            await dispatch<any>(loadInitialValue(
+                preloadParams.selectedItemUuids,
+                pickerId,
+                preloadParams.includeDirectories,
+                preloadParams.includeFiles,
+                preloadParams.multi
+            ));
         }
     };
 
@@ -128,6 +145,10 @@ interface LoadProjectParamsWithId extends LoadProjectParams {
     searchProjects?: boolean;
 }
 
+/**
+ * loadProject is used to load or refresh a project node in a tree picker
+ *   Errors are caught and a toast is shown if the project fails to load
+ */
 export const loadProject = (params: LoadProjectParamsWithId) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const {
@@ -166,56 +187,62 @@ export const loadProject = (params: LoadProjectParamsWithId) =>
 
         const itemLimit = 200;
 
-        const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
-
-        if (itemsAvailable > itemLimit) {
-            items.push({
-                uuid: "more-items-available",
-                kind: ResourceKind.WORKFLOW,
-                name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
-                description: "",
-                definition: "",
-                ownerUuid: "",
-                createdAt: "",
-                modifiedByClientUuid: "",
-                modifiedByUserUuid: "",
-                modifiedAt: "",
-                href: "",
-                etag: ""
-            });
-        }
-
-        dispatch<any>(receiveTreePickerData<GroupContentsResource>({
-            id,
-            pickerId,
-            data: items.filter((item) => {
-                if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
-                    return false;
-                }
+        try {
+            const { items, itemsAvailable } = await services.groupsService.contents((loadShared || searchProjects) ? '' : id, { filters, excludeHomeProject: loadShared || undefined, limit: itemLimit });
+            dispatch<any>(updateResources(items));
+
+            if (itemsAvailable > itemLimit) {
+                items.push({
+                    uuid: "more-items-available",
+                    kind: ResourceKind.WORKFLOW,
+                    name: `*** Not all items listed (${items.length} out of ${itemsAvailable}), reduce item count with search or filter ***`,
+                    description: "",
+                    definition: "",
+                    ownerUuid: "",
+                    createdAt: "",
+                    modifiedByClientUuid: "",
+                    modifiedByUserUuid: "",
+                    modifiedAt: "",
+                    href: "",
+                    etag: ""
+                });
+            }
 
-                if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
-                    return false;
-                }
+            dispatch<any>(receiveTreePickerData<GroupContentsResource>({
+                id,
+                pickerId,
+                data: items.filter((item) => {
+                    if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
+                        return false;
+                    }
 
-                return true;
-            }),
-            extractNodeData: item => (
-                item.uuid === "more-items-available" ?
-                    {
-                        id: item.uuid,
-                        value: item,
-                        status: TreeNodeStatus.LOADED
+                    if (options && options.showOnlyWritable && item.hasOwnProperty('frozenByUuid') && (item as ProjectResource).frozenByUuid) {
+                        return false;
                     }
-                    : {
-                        id: item.uuid,
-                        value: item,
-                        status: item.kind === ResourceKind.PROJECT
-                            ? TreeNodeStatus.INITIAL
-                            : includeDirectories || includeFiles
+
+                    return true;
+                }),
+                extractNodeData: item => (
+                    item.uuid === "more-items-available" ?
+                        {
+                            id: item.uuid,
+                            value: item,
+                            status: TreeNodeStatus.LOADED
+                        }
+                        : {
+                            id: item.uuid,
+                            value: item,
+                            status: item.kind === ResourceKind.PROJECT
                                 ? TreeNodeStatus.INITIAL
-                                : TreeNodeStatus.LOADED
-                    }),
-        }));
+                                : includeDirectories || includeFiles
+                                    ? TreeNodeStatus.INITIAL
+                                    : TreeNodeStatus.LOADED
+                        }),
+            }));
+        } catch(e) {
+            console.error("Failed to load project into tree picker:", e);;
+            dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Failed to load project`, kind: SnackbarKind.ERROR }));
+        }
     };
 
 export const loadCollection = (id: string, pickerId: string, includeDirectories?: boolean, includeFiles?: boolean) =>
@@ -236,13 +263,15 @@ export const loadCollection = (id: string, pickerId: string, includeDirectories?
                 const sorted = sortFilesTree(tree);
                 const filesTree = mapTreeValues(services.collectionService.extendFileURL)(sorted);
 
-                dispatch(
+                // await tree modifications so that consumers can guarantee node presence
+                await dispatch(
                     treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
                         id,
                         pickerId,
                         subtree: mapTree(node => ({ ...node, status: TreeNodeStatus.LOADED }))(filesTree)
                     }));
 
+                // Expand collection root node
                 dispatch(treePickerActions.TOGGLE_TREE_PICKER_NODE_COLLAPSE({ id, pickerId }));
             }
         }
@@ -288,35 +317,134 @@ export const initSharedProject = (pickerId: string) =>
         }));
     };
 
-export const loadInitialValue = (initialValue: string, pickerId: string) =>
+type PickerItemPreloadData = {
+    itemId: string;
+    mainItemUuid: string;
+    ancestors: (GroupResource | CollectionResource)[];
+    isHomeProjectItem: boolean;
+}
+
+type PickerTreePreloadData = {
+    tree: Tree<GroupResource | CollectionResource>;
+    pickerTreeId: string;
+    pickerTreeRootUuid: string;
+};
+
+export const loadInitialValue = (pickerItemIds: string[], pickerId: string, includeDirectories: boolean, includeFiles: boolean, multi: boolean,) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
-        const { home, shared } = getProjectsTreePickerIds(pickerId);
         const homeUuid = getUserUuid(getState());
-        const ancestors = (await services.ancestorsService.ancestors(initialValue, ''))
+
+        // Request ancestor trees in paralell and save home project status
+        const pickerItemsData: PickerItemPreloadData[] = await Promise.allSettled(pickerItemIds.map(async itemId => {
+            const mainItemUuid = itemId.includes('/') ? itemId.split('/')[0] : itemId;
+
+            const ancestors = (await services.ancestorsService.ancestors(mainItemUuid, ''))
             .filter(item =>
                 item.kind === ResourceKind.GROUP ||
                 item.kind === ResourceKind.COLLECTION
             ) as (GroupResource | CollectionResource)[];
 
-        if (ancestors.length) {
-            const isUserHomeProject = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
-            const pickerTreeId = isUserHomeProject ? home : shared;
-            const pickerTreeRootUuid: string = (homeUuid && isUserHomeProject) ? homeUuid : SHARED_PROJECT_ID;
+            if (ancestors.length === 0) {
+                return Promise.reject({item: itemId});
+            }
+
+            const isHomeProjectItem = !!(homeUuid && ancestors.some(item => item.ownerUuid === homeUuid));
 
-            ancestors[0].ownerUuid = '';
-            const tree = createInitialLocationTree(ancestors, initialValue);
+            return {
+                itemId,
+                mainItemUuid,
+                ancestors,
+                isHomeProjectItem,
+            };
+        })).then((res) => {
+            // Show toast if any selections failed to restore
+            const rejectedPromises = res.filter((promiseResult): promiseResult is PromiseRejectedResult => (promiseResult.status === 'rejected'));
+            if (rejectedPromises.length) {
+                rejectedPromises.forEach(item => {
+                    console.error("The following item failed to load into the tree picker", item.reason);
+                });
+                dispatch<any>(snackbarActions.OPEN_SNACKBAR({ message: `Some selections failed to load and were removed. See console for details.`, kind: SnackbarKind.ERROR }));
+            }
+            // Filter out any failed promises and map to resulting preload data with ancestors
+            return res.filter((promiseResult): promiseResult is PromiseFulfilledResult<PickerItemPreloadData> => (
+                promiseResult.status === 'fulfilled'
+            )).map(res => res.value)
+        });
+
+        // Group items to preload / ancestor data by home/shared picker and create initial Trees to preload
+        const initialTreePreloadData: PickerTreePreloadData[] = [
+            pickerItemsData.filter((item) => item.isHomeProjectItem),
+            pickerItemsData.filter((item) => !item.isHomeProjectItem),
+        ]
+            .filter((items) => items.length > 0)
+            .map((itemGroup) =>
+                itemGroup.reduce(
+                    (preloadTree, itemData) => ({
+                        tree: createInitialPickerTree(
+                            itemData.ancestors,
+                            itemData.mainItemUuid,
+                            preloadTree.tree
+                        ),
+                        pickerTreeId: getPickerItemTreeId(itemData, homeUuid, pickerId),
+                        pickerTreeRootUuid: getPickerItemRootUuid(itemData, homeUuid),
+                    }),
+                    {
+                        tree: createTree<GroupResource | CollectionResource>(),
+                        pickerTreeId: '',
+                        pickerTreeRootUuid: '',
+                    } as PickerTreePreloadData
+                )
+            );
+
+        // Load initial trees into corresponding picker store
+        await Promise.all(initialTreePreloadData.map(preloadTree => (
             dispatch(
                 treePickerActions.APPEND_TREE_PICKER_NODE_SUBTREE({
-                    id: pickerTreeRootUuid,
-                    pickerId: pickerTreeId,
-                    subtree: tree
-                }));
-            dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: initialValue, pickerId: pickerTreeId }));
-            dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: pickerTreeId }));
-        }
+                    id: preloadTree.pickerTreeRootUuid,
+                    pickerId: preloadTree.pickerTreeId,
+                    subtree: preloadTree.tree,
+                })
+            )
+        )));
+
+        // Await loading collection before attempting to select items
+        await Promise.all(pickerItemsData.map(async itemData => {
+            const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
+
+            // Selected item resides in collection subpath
+            if (itemData.itemId.includes('/')) {
+                // Load collection into tree
+                // loadCollection includes more than dispatched actions and must be awaited
+                await dispatch(loadCollection(itemData.mainItemUuid, pickerTreeId, includeDirectories, includeFiles));
+            }
+            // Expand nodes down to destination
+            dispatch(treePickerActions.EXPAND_TREE_PICKER_NODE_ANCESTORS({ id: itemData.itemId, pickerId: pickerTreeId }));
+        }));
 
+        // Select or activate nodes
+        pickerItemsData.forEach(itemData => {
+            const pickerTreeId = getPickerItemTreeId(itemData, homeUuid, pickerId);
+
+            if (multi) {
+                dispatch(treePickerActions.SELECT_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId, cascade: false}));
+            } else {
+                dispatch(treePickerActions.ACTIVATE_TREE_PICKER_NODE({ id: itemData.itemId, pickerId: pickerTreeId }));
+            }
+        });
+
+        // Refresh triggers loading in all adjacent items that were not included in the ancestor tree
+        await initialTreePreloadData.map(preloadTree => dispatch(treePickerSearchActions.REFRESH_TREE_PICKER({ pickerId: preloadTree.pickerTreeId })));
     }
 
+const getPickerItemTreeId = (itemData: PickerItemPreloadData, homeUuid: string | undefined, pickerId: string) => {
+    const { home, shared } = getProjectsTreePickerIds(pickerId);
+    return ((itemData.isHomeProjectItem && homeUuid) ? home : shared);
+};
+
+const getPickerItemRootUuid = (itemData: PickerItemPreloadData, homeUuid: string | undefined) => {
+    return (itemData.isHomeProjectItem && homeUuid) ? homeUuid : SHARED_PROJECT_ID;
+};
+
 export const FAVORITES_PROJECT_ID = 'Favorites';
 export const initFavoritesProject = (pickerId: string) =>
     async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
@@ -523,44 +651,68 @@ const buildParams = (ownerUuid: string) => {
  *   if the item represents a valid target/destination location
  */
 export type FileOperationLocation = {
+    name: string;
     uuid: string;
-    path: string;
+    pdh?: string;
+    subpath: string;
 }
-export const getFileOperationLocation = (item: ProjectsTreePickerItem): FileOperationLocation | undefined => {
-    if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
-        return {
-            uuid: item.uuid,
-            path: '/'
-        };
-    } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
-        const uuid = getCollectionResourceCollectionUuid(item.id);
-        if (uuid) {
+export const getFileOperationLocation = (item: ProjectsTreePickerItem) =>
+    async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<FileOperationLocation | undefined> => {
+        if ('kind' in item && item.kind === ResourceKind.COLLECTION) {
             return {
-                uuid,
-                path: [item.path, item.name].join('/')
+                name: item.name,
+                uuid: item.uuid,
+                pdh: item.portableDataHash,
+                subpath: '/',
             };
-        } else {
-            return undefined;
+        } else if ('type' in item && item.type === CollectionFileType.DIRECTORY) {
+            const uuid = getCollectionResourceCollectionUuid(item.id);
+            if (uuid) {
+                const collection = getResource<CollectionResource>(uuid)(getState().resources);
+                if (collection) {
+                    const itemPath = [item.path, item.name].join('/');
+
+                    return {
+                        name: item.name,
+                        uuid,
+                        pdh: collection.portableDataHash,
+                        subpath: itemPath,
+                    };
+                }
+            }
         }
-    } else {
         return undefined;
-    }
-};
+    };
 
 /**
  * Create an expanded tree picker subtree from array of nested projects/collection
- *   Assumes the root item of the subtree already has an empty string ownerUuid
+ *   First item is assumed to be root and gets empty parent id
+ *   Nodes must be sorted from top down to prevent orphaned nodes
  */
-export const createInitialLocationTree = (data: Array<GroupResource | CollectionResource>, tailUuid: string) => {
-    return data
-        .reduce((tree, item) => setNode({
-            children: [],
-            id: item.uuid,
-            parent: item.ownerUuid,
-            value: item,
-            active: false,
-            selected: false,
-            expanded: false,
-            status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
-        })(tree), createTree<GroupResource | CollectionResource>());
+export const createInitialPickerTree = (sortedAncestors: Array<GroupResource | CollectionResource>, tailUuid: string, initialTree: Tree<GroupResource | CollectionResource>) => {
+    return sortedAncestors
+        .reduce((tree, item, index) => {
+            if (getNode(item.uuid)(tree)) {
+                return tree;
+            } else {
+                return setNode({
+                    children: [],
+                    id: item.uuid,
+                    parent: index === 0 ? '' : item.ownerUuid,
+                    value: item,
+                    active: false,
+                    selected: false,
+                    expanded: false,
+                    status: item.uuid !== tailUuid ? TreeNodeStatus.LOADED : TreeNodeStatus.INITIAL,
+                })(tree);
+            }
+        }, initialTree);
 };
+
+export const fileOperationLocationToPickerId = (location: FileOperationLocation): string => {
+    let id = location.uuid;
+    if (location.subpath.length && location.subpath !== '/') {
+        id = id + location.subpath;
+    }
+    return id;
+}
index 25973bf6b5945e1505bc15a527dc8827feafcb83..2a5229ca5b567707665c9c189452370fcdda221e 100644 (file)
@@ -93,7 +93,7 @@ describe('TreePickerReducer', () => {
         const newState = pipe(
             (state: TreePicker) => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '', nodes: [node], pickerId: "projects" })),
             state => treePickerReducer(state, treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({ id: '1', nodes: [subNode], pickerId: "projects" })),
-            state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: '1.1', pickerId: "projects" })),
+            state => treePickerReducer(state, treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: '1.1', pickerId: "projects", cascade: true })),
         )({ projects: createTree<{}>() });
         expect(getNode('1')(newState.projects)).toEqual({
             ...initTreeNode({ id: '1', value: '1' }),
index df0ee0ad167376af2eec2a81293ade246714ba96..84d5ed0ca729013f9d9215d1c7dcffd21f1730ed 100644 (file)
@@ -5,7 +5,7 @@
 import {
     createTree, TreeNode, setNode, Tree, TreeNodeStatus, setNodeStatus,
     expandNode, deactivateNode, selectNodes, deselectNodes,
-    activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree
+    activateNode, getNode, toggleNodeCollapse, toggleNodeSelection, appendSubtree, expandNodeAncestors
 } from 'models/tree';
 import { TreePicker } from "./tree-picker";
 import { treePickerActions, treePickerSearchActions, TreePickerAction, TreePickerSearchAction, LoadProjectParams } from "./tree-picker-actions";
@@ -29,6 +29,9 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi
         EXPAND_TREE_PICKER_NODE: ({ id, pickerId }) =>
             updateOrCreatePicker(state, pickerId, expandNode(id)),
 
+        EXPAND_TREE_PICKER_NODE_ANCESTORS: ({ id, pickerId }) =>
+            updateOrCreatePicker(state, pickerId, expandNodeAncestors(id)),
+
         ACTIVATE_TREE_PICKER_NODE: ({ id, pickerId, relatedTreePickers = [] }) =>
             pipe(
                 () => relatedTreePickers.reduce(
@@ -41,14 +44,14 @@ export const treePickerReducer = (state: TreePicker = {}, action: TreePickerActi
         DEACTIVATE_TREE_PICKER_NODE: ({ pickerId }) =>
             updateOrCreatePicker(state, pickerId, deactivateNode),
 
-        TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId }) =>
-            updateOrCreatePicker(state, pickerId, toggleNodeSelection(id)),
+        TOGGLE_TREE_PICKER_NODE_SELECTION: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, toggleNodeSelection(id, cascade)),
 
-        SELECT_TREE_PICKER_NODE: ({ id, pickerId }) =>
-            updateOrCreatePicker(state, pickerId, selectNodes(id)),
+        SELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, selectNodes(id, cascade)),
 
-        DESELECT_TREE_PICKER_NODE: ({ id, pickerId }) =>
-            updateOrCreatePicker(state, pickerId, deselectNodes(id)),
+        DESELECT_TREE_PICKER_NODE: ({ id, pickerId, cascade }) =>
+            updateOrCreatePicker(state, pickerId, deselectNodes(id, cascade)),
 
         RESET_TREE_PICKER: ({ pickerId }) =>
             updateOrCreatePicker(state, pickerId, createTree),
index e965cd00580f85ce20944d12d760f089b7984aec..b8b914c93e0c234dc354df2cb8d5242cf48701e9 100644 (file)
@@ -19,6 +19,7 @@ import { UserResource } from 'models/user';
 import { UserPanelColumnNames } from 'views/user-panel/user-panel';
 import { BuiltinGroups, getBuiltinGroupUuid } from 'models/group';
 import { LinkClass } from 'models/link';
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
 
 export class UserMiddlewareService extends DataExplorerMiddlewareService {
     constructor(private services: ServiceRepository, id: string) {
@@ -29,6 +30,7 @@ export class UserMiddlewareService extends DataExplorerMiddlewareService {
         const state = api.getState();
         const dataExplorer = getDataExplorer(state.dataExplorer, this.getId());
         try {
+            api.dispatch(progressIndicatorActions.START_WORKING(this.getId()));
             const users = await this.services.userService.list(getParams(dataExplorer));
             api.dispatch(updateResources(users.items));
             api.dispatch(setItems(users));
@@ -44,6 +46,8 @@ export class UserMiddlewareService extends DataExplorerMiddlewareService {
             api.dispatch(updateResources(allUserMemberships.items));
         } catch {
             api.dispatch(couldNotFetchUsers());
+        } finally {
+            api.dispatch(progressIndicatorActions.STOP_WORKING(this.getId()));
         }
     }
 }
@@ -70,6 +74,9 @@ const getOrder = (dataExplorer: DataExplorer) => {
         } else {
             order.addOrder(sortDirection, sortColumn.sort.field);
         }
+
+        // Use createdAt as a secondary sort column so we break ties consistently.
+        order.addOrder(OrderDirection.DESC, "createdAt");
     }
     return order.getOrder();
 };
index bd07efb6409f3f09368b10eaf60c479234257bdb..12172e7fe32d5739a0c78fee6cdb13a4b07cb10a 100644 (file)
@@ -19,6 +19,7 @@ import { deleteResources, updateResources } from 'store/resources/resources-acti
 import { Participant } from "views-components/sharing-dialog/participant-select";
 import { initialize, reset } from "redux-form";
 import { getUserDisplayName, UserResource } from "models/user";
+import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
 
 export const virtualMachinesActions = unionize({
     SET_REQUESTED_DATE: ofType<string>(),
@@ -72,50 +73,61 @@ const loadRequestedDate = () =>
 
 export const loadVirtualMachinesAdminData = () =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-        dispatch<any>(loadRequestedDate());
-
-        const virtualMachines = await services.virtualMachineService.list();
-        dispatch(updateResources(virtualMachines.items));
-        dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
-
-
-        const logins = await services.permissionService.list({
-            filters: new FilterBuilder()
-            .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
-            .addEqual('name', PermissionLevel.CAN_LOGIN)
-            .getFilters(),
-            limit: 1000
-        });
-        dispatch(updateResources(logins.items));
-        dispatch(virtualMachinesActions.SET_LINKS(logins));
-
-        const users = await services.userService.list({
-            filters: new FilterBuilder()
-            .addIn('uuid', logins.items.map(item => item.tailUuid))
-            .getFilters(),
-            count: "none", // Necessary for federated queries
-            limit: 1000
-        });
-        dispatch(updateResources(users.items));
-
-        const getAllLogins = await services.virtualMachineService.getAllLogins();
-        dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING("virtual-machines-admin"));
+            dispatch<any>(loadRequestedDate());
+
+            const virtualMachines = await services.virtualMachineService.list();
+            dispatch(updateResources(virtualMachines.items));
+            dispatch(virtualMachinesActions.SET_VIRTUAL_MACHINES(virtualMachines));
+
+
+            const logins = await services.permissionService.list({
+                filters: new FilterBuilder()
+                    .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
+                    .addEqual('name', PermissionLevel.CAN_LOGIN)
+                    .getFilters(),
+                limit: 1000
+            });
+            dispatch(updateResources(logins.items));
+            dispatch(virtualMachinesActions.SET_LINKS(logins));
+
+            const users = await services.userService.list({
+                filters: new FilterBuilder()
+                    .addIn('uuid', logins.items.map(item => item.tailUuid))
+                    .getFilters(),
+                count: "none", // Necessary for federated queries
+                limit: 1000
+            });
+            dispatch(updateResources(users.items));
+
+            const getAllLogins = await services.virtualMachineService.getAllLogins();
+            dispatch(virtualMachinesActions.SET_LOGINS(getAllLogins));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING("virtual-machines-admin"));
+        }
     };
 
 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));
-        dispatch(virtualMachinesActions.SET_LINKS(links));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING("virtual-machines-user"));
+
+            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));
+            dispatch(virtualMachinesActions.SET_LINKS(links));
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING("virtual-machines-user"));
+        }
     };
 
 export const openAddVirtualMachineLoginDialog = (vmUuid: string) =>
@@ -125,17 +137,17 @@ export const openAddVirtualMachineLoginDialog = (vmUuid: string) =>
         dispatch(updateResources(virtualMachines.items));
         const logins = await services.permissionService.list({
             filters: new FilterBuilder()
-            .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
-            .addEqual('name', PermissionLevel.CAN_LOGIN)
-            .getFilters()
+                .addIn('head_uuid', virtualMachines.items.map(item => item.uuid))
+                .addEqual('name', PermissionLevel.CAN_LOGIN)
+                .getFilters()
         });
         dispatch(updateResources(logins.items));
 
         dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {
-                [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid,
-                [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [],
-            }));
-        dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {excludedParticipants: logins.items.map(it => it.tailUuid)}} ));
+            [VIRTUAL_MACHINE_ADD_LOGIN_VM_FIELD]: vmUuid,
+            [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: [],
+        }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { excludedParticipants: logins.items.map(it => it.tailUuid) } }));
     }
 
 export const openEditVirtualMachineLoginDialog = (permissionUuid: string) =>
@@ -143,11 +155,11 @@ export const openEditVirtualMachineLoginDialog = (permissionUuid: string) =>
         const login = await services.permissionService.get(permissionUuid);
         const user = await services.userService.get(login.tailUuid);
         dispatch(initialize(VIRTUAL_MACHINE_ADD_LOGIN_FORM, {
-                [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid,
-                [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: {name: getUserDisplayName(user, true, true), uuid: login.tailUuid},
-                [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups,
-            }));
-        dispatch(dialogActions.OPEN_DIALOG( {id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: {updating: true}} ));
+            [VIRTUAL_MACHINE_UPDATE_LOGIN_UUID_FIELD]: permissionUuid,
+            [VIRTUAL_MACHINE_ADD_LOGIN_USER_FIELD]: { name: getUserDisplayName(user, true, true), uuid: login.tailUuid },
+            [VIRTUAL_MACHINE_ADD_LOGIN_GROUPS_FIELD]: login.properties.groups,
+        }));
+        dispatch(dialogActions.OPEN_DIALOG({ id: VIRTUAL_MACHINE_ADD_LOGIN_DIALOG, data: { updating: true } }));
     }
 
 export interface AddLoginFormData {
@@ -158,15 +170,15 @@ export interface AddLoginFormData {
 }
 
 
-export const addUpdateVirtualMachineLogin = ({uuid, vmUuid, user, groups}: AddLoginFormData) =>
+export const addUpdateVirtualMachineLogin = ({ uuid, vmUuid, user, groups }: AddLoginFormData) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         let userResource: UserResource | undefined = undefined;
         try {
             // Get user
             userResource = await services.userService.get(user.uuid, false);
         } catch (e) {
-                dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
-                return;
+            dispatch(snackbarActions.OPEN_SNACKBAR({ message: "Failed to get user details.", hideDuration: 2000, kind: SnackbarKind.ERROR }));
+            return;
         }
         try {
             if (uuid) {
index a3c3a0969b2185224b340e7eabe2c9211a629568..b03400d5ae28c7abc55b86ac5e0f01e99668e95a 100644 (file)
@@ -2,30 +2,24 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { Dispatch } from 'redux';
-import { RootState } from 'store/store';
-import { getUserUuid } from 'common/getuser';
-import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
-import {
-    favoritePanelActions,
-    loadFavoritePanel,
-} from 'store/favorite-panel/favorite-panel-action';
-import {
-    getProjectPanelCurrentUuid,
-    projectPanelActions,
-    setIsProjectPanelTrashed,
-} from 'store/project-panel/project-panel-action';
+import { Dispatch } from "redux";
+import { RootState } from "store/store";
+import { getUserUuid } from "common/getuser";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
+import { favoritePanelActions, loadFavoritePanel } from "store/favorite-panel/favorite-panel-action";
+import { getProjectPanelCurrentUuid, setIsProjectPanelTrashed } from "store/project-panel/project-panel-action";
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
 import {
     activateSidePanelTreeItem,
     initSidePanelTree,
     loadSidePanelTreeProjects,
     SidePanelTreeCategory,
-} from 'store/side-panel-tree/side-panel-tree-actions';
-import { updateResources } from 'store/resources/resources-actions';
-import { projectPanelColumns } from 'views/project-panel/project-panel';
-import { favoritePanelColumns } from 'views/favorite-panel/favorite-panel';
-import { matchRootRoute } from 'routes/routes';
+} from "store/side-panel-tree/side-panel-tree-actions";
+import { updateResources } from "store/resources/resources-actions";
+import { projectPanelColumns } from "views/project-panel/project-panel";
+import { favoritePanelColumns } from "views/favorite-panel/favorite-panel";
+import { matchRootRoute } from "routes/routes";
 import {
     setBreadcrumbs,
     setGroupDetailsBreadcrumbs,
@@ -37,223 +31,170 @@ import {
     setUsersBreadcrumbs,
     setMyAccountBreadcrumbs,
     setUserProfileBreadcrumbs,
-} from 'store/breadcrumbs/breadcrumbs-actions';
-import {
-    navigateTo,
-    navigateToRootProject,
-} from 'store/navigation/navigation-action';
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { ServiceRepository } from 'services/services';
-import { getResource } from 'store/resources/resources';
-import * as projectCreateActions from 'store/projects/project-create-actions';
-import * as projectMoveActions from 'store/projects/project-move-actions';
-import * as projectUpdateActions from 'store/projects/project-update-actions';
-import * as collectionCreateActions from 'store/collections/collection-create-actions';
-import * as collectionCopyActions from 'store/collections/collection-copy-actions';
-import * as collectionMoveActions from 'store/collections/collection-move-actions';
-import * as processesActions from 'store/processes/processes-actions';
-import * as processMoveActions from 'store/processes/process-move-actions';
-import * as processUpdateActions from 'store/processes/process-update-actions';
-import * as processCopyActions from 'store/processes/process-copy-actions';
-import { trashPanelColumns } from 'views/trash-panel/trash-panel';
-import {
-    loadTrashPanel,
-    trashPanelActions,
-} from 'store/trash-panel/trash-panel-action';
-import { loadProcessPanel } from 'store/process-panel/process-panel-actions';
-import {
-    loadSharedWithMePanel,
-    sharedWithMePanelActions,
-} from 'store/shared-with-me-panel/shared-with-me-panel-actions';
-import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
-import { workflowPanelActions } from 'store/workflow-panel/workflow-panel-actions';
-import { loadSshKeysPanel } from 'store/auth/auth-action-ssh';
-import {
-    loadLinkAccountPanel,
-    linkAccountPanelActions,
-} from 'store/link-account-panel/link-account-panel-actions';
-import { loadSiteManagerPanel } from 'store/auth/auth-action-session';
-import { workflowPanelColumns } from 'views/workflow-panel/workflow-panel-view';
-import { progressIndicatorActions } from 'store/progress-indicator/progress-indicator-actions';
-import { getProgressIndicator } from 'store/progress-indicator/progress-indicator-reducer';
-import { extractUuidKind, ResourceKind } from 'models/resource';
-import { FilterBuilder } from 'services/api/filter-builder';
-import { GroupContentsResource } from 'services/groups-service/groups-service';
-import { MatchCases, ofType, unionize, UnionOf } from 'common/unionize';
-import { loadRunProcessPanel } from 'store/run-process-panel/run-process-panel-actions';
-import {
-    collectionPanelActions,
-    loadCollectionPanel,
-} from 'store/collection-panel/collection-panel-action';
-import { CollectionResource } from 'models/collection';
-import { WorkflowResource } from 'models/workflow';
-import {
-    loadSearchResultsPanel,
-    searchResultsPanelActions,
-} from 'store/search-results-panel/search-results-panel-actions';
-import { searchResultsPanelColumns } from 'views/search-results-panel/search-results-panel-view';
-import { loadVirtualMachinesPanel } from 'store/virtual-machines/virtual-machines-actions';
-import { loadRepositoriesPanel } from 'store/repositories/repositories-actions';
-import { loadKeepServicesPanel } from 'store/keep-services/keep-services-actions';
-import { loadUsersPanel, userBindedActions } from 'store/users/users-actions';
-import * as userProfilePanelActions from 'store/user-profile/user-profile-actions';
-import {
-    linkPanelActions,
-    loadLinkPanel,
-} from 'store/link-panel/link-panel-actions';
-import { linkPanelColumns } from 'views/link-panel/link-panel-root';
-import { userPanelColumns } from 'views/user-panel/user-panel';
-import {
-    loadApiClientAuthorizationsPanel,
-    apiClientAuthorizationsActions,
-} from 'store/api-client-authorizations/api-client-authorizations-actions';
-import { apiClientAuthorizationPanelColumns } from 'views/api-client-authorization-panel/api-client-authorization-panel-root';
-import * as groupPanelActions from 'store/groups-panel/groups-panel-actions';
-import { groupsPanelColumns } from 'views/groups-panel/groups-panel';
-import * as groupDetailsPanelActions from 'store/group-details-panel/group-details-panel-actions';
-import {
-    groupDetailsMembersPanelColumns,
-    groupDetailsPermissionsPanelColumns,
-} from 'views/group-details-panel/group-details-panel';
-import { DataTableFetchMode } from 'components/data-table/data-table';
-import {
-    loadPublicFavoritePanel,
-    publicFavoritePanelActions,
-} from 'store/public-favorites-panel/public-favorites-action';
-import { publicFavoritePanelColumns } from 'views/public-favorites-panel/public-favorites-panel';
+} from "store/breadcrumbs/breadcrumbs-actions";
+import { navigateTo, navigateToRootProject } from "store/navigation/navigation-action";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { ServiceRepository } from "services/services";
+import { getResource } from "store/resources/resources";
+import * as projectCreateActions from "store/projects/project-create-actions";
+import * as projectMoveActions from "store/projects/project-move-actions";
+import * as projectUpdateActions from "store/projects/project-update-actions";
+import * as collectionCreateActions from "store/collections/collection-create-actions";
+import * as collectionCopyActions from "store/collections/collection-copy-actions";
+import * as collectionMoveActions from "store/collections/collection-move-actions";
+import * as processesActions from "store/processes/processes-actions";
+import * as processMoveActions from "store/processes/process-move-actions";
+import * as processUpdateActions from "store/processes/process-update-actions";
+import * as processCopyActions from "store/processes/process-copy-actions";
+import { trashPanelColumns } from "views/trash-panel/trash-panel";
+import { loadTrashPanel, trashPanelActions } from "store/trash-panel/trash-panel-action";
+import { loadProcessPanel } from "store/process-panel/process-panel-actions";
+import { loadSharedWithMePanel, sharedWithMePanelActions } from "store/shared-with-me-panel/shared-with-me-panel-actions";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { workflowPanelActions } from "store/workflow-panel/workflow-panel-actions";
+import { loadSshKeysPanel } from "store/auth/auth-action-ssh";
+import { loadLinkAccountPanel, linkAccountPanelActions } from "store/link-account-panel/link-account-panel-actions";
+import { loadSiteManagerPanel } from "store/auth/auth-action-session";
+import { workflowPanelColumns } from "views/workflow-panel/workflow-panel-view";
+import { progressIndicatorActions } from "store/progress-indicator/progress-indicator-actions";
+import { getProgressIndicator } from "store/progress-indicator/progress-indicator-reducer";
+import { extractUuidKind, Resource, ResourceKind } from "models/resource";
+import { FilterBuilder } from "services/api/filter-builder";
+import { GroupContentsResource } from "services/groups-service/groups-service";
+import { MatchCases, ofType, unionize, UnionOf } from "common/unionize";
+import { loadRunProcessPanel } from "store/run-process-panel/run-process-panel-actions";
+import { collectionPanelActions, loadCollectionPanel } from "store/collection-panel/collection-panel-action";
+import { CollectionResource } from "models/collection";
+import { WorkflowResource } from "models/workflow";
+import { loadSearchResultsPanel, searchResultsPanelActions } from "store/search-results-panel/search-results-panel-actions";
+import { searchResultsPanelColumns } from "views/search-results-panel/search-results-panel-view";
+import { loadVirtualMachinesPanel } from "store/virtual-machines/virtual-machines-actions";
+import { loadRepositoriesPanel } from "store/repositories/repositories-actions";
+import { loadKeepServicesPanel } from "store/keep-services/keep-services-actions";
+import { loadUsersPanel, userBindedActions } from "store/users/users-actions";
+import * as userProfilePanelActions from "store/user-profile/user-profile-actions";
+import { linkPanelActions, loadLinkPanel } from "store/link-panel/link-panel-actions";
+import { linkPanelColumns } from "views/link-panel/link-panel-root";
+import { userPanelColumns } from "views/user-panel/user-panel";
+import { loadApiClientAuthorizationsPanel, apiClientAuthorizationsActions } from "store/api-client-authorizations/api-client-authorizations-actions";
+import { apiClientAuthorizationPanelColumns } from "views/api-client-authorization-panel/api-client-authorization-panel-root";
+import * as groupPanelActions from "store/groups-panel/groups-panel-actions";
+import { groupsPanelColumns } from "views/groups-panel/groups-panel";
+import * as groupDetailsPanelActions from "store/group-details-panel/group-details-panel-actions";
+import { groupDetailsMembersPanelColumns, groupDetailsPermissionsPanelColumns } from "views/group-details-panel/group-details-panel";
+import { DataTableFetchMode } from "components/data-table/data-table";
+import { loadPublicFavoritePanel, publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { publicFavoritePanelColumns } from "views/public-favorites-panel/public-favorites-panel";
 import {
     loadCollectionsContentAddressPanel,
     collectionsContentAddressActions,
-} from 'store/collections-content-address-panel/collections-content-address-panel-actions';
-import { collectionContentAddressPanelColumns } from 'views/collection-content-address-panel/collection-content-address-panel';
-import { subprocessPanelActions } from 'store/subprocess-panel/subprocess-panel-actions';
-import { subprocessPanelColumns } from 'views/subprocess-panel/subprocess-panel-root';
-import {
-    loadAllProcessesPanel,
-    allProcessesPanelActions,
-} from '../all-processes-panel/all-processes-panel-action';
-import { allProcessesPanelColumns } from 'views/all-processes-panel/all-processes-panel';
-import { AdminMenuIcon } from 'components/icon/icon';
-import { userProfileGroupsColumns } from 'views/user-profile-panel/user-profile-panel-root';
-
-export const WORKBENCH_LOADING_SCREEN = 'workbenchLoadingScreen';
+} from "store/collections-content-address-panel/collections-content-address-panel-actions";
+import { collectionContentAddressPanelColumns } from "views/collection-content-address-panel/collection-content-address-panel";
+import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-actions";
+import { subprocessPanelColumns } from "views/subprocess-panel/subprocess-panel-root";
+import { loadAllProcessesPanel, allProcessesPanelActions } from "../all-processes-panel/all-processes-panel-action";
+import { allProcessesPanelColumns } from "views/all-processes-panel/all-processes-panel";
+import { AdminMenuIcon } from "components/icon/icon";
+import { userProfileGroupsColumns } from "views/user-profile-panel/user-profile-panel-root";
+import { selectedToArray, selectedToKindSet } from "components/multiselect-toolbar/MultiselectToolbar";
+import { multiselectActions } from "store/multiselect/multiselect-actions";
+
+export const WORKBENCH_LOADING_SCREEN = "workbenchLoadingScreen";
 
 export const isWorkbenchLoading = (state: RootState) => {
-    const progress = getProgressIndicator(WORKBENCH_LOADING_SCREEN)(
-        state.progressIndicator
-    );
+    const progress = getProgressIndicator(WORKBENCH_LOADING_SCREEN)(state.progressIndicator);
     return progress ? progress.working : false;
 };
 
-export const handleFirstTimeLoad =
-    (action: any) =>
-        async (dispatch: Dispatch<any>, getState: () => RootState) => {
-            try {
-                await dispatch(action);
-            } finally {
-                if (isWorkbenchLoading(getState())) {
-                    dispatch(
-                        progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN)
-                    );
-                }
-            }
-        };
+export const handleFirstTimeLoad = (action: any) => async (dispatch: Dispatch<any>, getState: () => RootState) => {
+    try {
+        await dispatch(action);
+    } finally {
+        if (isWorkbenchLoading(getState())) {
+            dispatch(progressIndicatorActions.STOP_WORKING(WORKBENCH_LOADING_SCREEN));
+        }
+    }
+};
 
-export const loadWorkbench =
-    () =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
-            const { auth, router } = getState();
-            const { user } = auth;
-            if (user) {
-                dispatch(
-                    projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns })
-                );
-                dispatch(
-                    favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns })
-                );
-                dispatch(
-                    allProcessesPanelActions.SET_COLUMNS({
-                        columns: allProcessesPanelColumns,
-                    })
-                );
-                dispatch(
-                    publicFavoritePanelActions.SET_COLUMNS({
-                        columns: publicFavoritePanelColumns,
-                    })
-                );
-                dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns }));
-                dispatch(
-                    sharedWithMePanelActions.SET_COLUMNS({ columns: projectPanelColumns })
-                );
-                dispatch(
-                    workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns })
-                );
-                dispatch(
-                    searchResultsPanelActions.SET_FETCH_MODE({
-                        fetchMode: DataTableFetchMode.INFINITE,
-                    })
-                );
-                dispatch(
-                    searchResultsPanelActions.SET_COLUMNS({
-                        columns: searchResultsPanelColumns,
-                    })
-                );
-                dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns }));
-                dispatch(
-                    groupPanelActions.GroupsPanelActions.SET_COLUMNS({
-                        columns: groupsPanelColumns,
-                    })
-                );
-                dispatch(
-                    groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({
-                        columns: groupDetailsMembersPanelColumns,
-                    })
-                );
-                dispatch(
-                    groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({
-                        columns: groupDetailsPermissionsPanelColumns,
-                    })
-                );
-                dispatch(
-                    userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({
-                        columns: userProfileGroupsColumns,
-                    })
-                );
-                dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
-                dispatch(
-                    apiClientAuthorizationsActions.SET_COLUMNS({
-                        columns: apiClientAuthorizationPanelColumns,
-                    })
-                );
-                dispatch(
-                    collectionsContentAddressActions.SET_COLUMNS({
-                        columns: collectionContentAddressPanelColumns,
-                    })
-                );
-                dispatch(
-                    subprocessPanelActions.SET_COLUMNS({ columns: subprocessPanelColumns })
-                );
+export const loadWorkbench = () => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    dispatch(progressIndicatorActions.START_WORKING(WORKBENCH_LOADING_SCREEN));
+    const { auth, router } = getState();
+    const { user } = auth;
+    if (user) {
+        dispatch(projectPanelActions.SET_COLUMNS({ columns: projectPanelColumns }));
+        dispatch(favoritePanelActions.SET_COLUMNS({ columns: favoritePanelColumns }));
+        dispatch(
+            allProcessesPanelActions.SET_COLUMNS({
+                columns: allProcessesPanelColumns,
+            })
+        );
+        dispatch(
+            publicFavoritePanelActions.SET_COLUMNS({
+                columns: publicFavoritePanelColumns,
+            })
+        );
+        dispatch(trashPanelActions.SET_COLUMNS({ columns: trashPanelColumns }));
+        dispatch(sharedWithMePanelActions.SET_COLUMNS({ columns: projectPanelColumns }));
+        dispatch(workflowPanelActions.SET_COLUMNS({ columns: workflowPanelColumns }));
+        dispatch(
+            searchResultsPanelActions.SET_FETCH_MODE({
+                fetchMode: DataTableFetchMode.INFINITE,
+            })
+        );
+        dispatch(
+            searchResultsPanelActions.SET_COLUMNS({
+                columns: searchResultsPanelColumns,
+            })
+        );
+        dispatch(userBindedActions.SET_COLUMNS({ columns: userPanelColumns }));
+        dispatch(
+            groupPanelActions.GroupsPanelActions.SET_COLUMNS({
+                columns: groupsPanelColumns,
+            })
+        );
+        dispatch(
+            groupDetailsPanelActions.GroupMembersPanelActions.SET_COLUMNS({
+                columns: groupDetailsMembersPanelColumns,
+            })
+        );
+        dispatch(
+            groupDetailsPanelActions.GroupPermissionsPanelActions.SET_COLUMNS({
+                columns: groupDetailsPermissionsPanelColumns,
+            })
+        );
+        dispatch(
+            userProfilePanelActions.UserProfileGroupsActions.SET_COLUMNS({
+                columns: userProfileGroupsColumns,
+            })
+        );
+        dispatch(linkPanelActions.SET_COLUMNS({ columns: linkPanelColumns }));
+        dispatch(
+            apiClientAuthorizationsActions.SET_COLUMNS({
+                columns: apiClientAuthorizationPanelColumns,
+            })
+        );
+        dispatch(
+            collectionsContentAddressActions.SET_COLUMNS({
+                columns: collectionContentAddressPanelColumns,
+            })
+        );
+        dispatch(subprocessPanelActions.SET_COLUMNS({ columns: subprocessPanelColumns }));
 
-                if (services.linkAccountService.getAccountToLink()) {
-                    dispatch(linkAccountPanelActions.HAS_SESSION_DATA());
-                }
+        if (services.linkAccountService.getAccountToLink()) {
+            dispatch(linkAccountPanelActions.HAS_SESSION_DATA());
+        }
 
-                dispatch<any>(initSidePanelTree());
-                if (router.location) {
-                    const match = matchRootRoute(router.location.pathname);
-                    if (match) {
-                        dispatch<any>(navigateToRootProject);
-                    }
-                }
-            } else {
-                dispatch(userIsNotAuthenticated);
+        dispatch<any>(initSidePanelTree());
+        if (router.location) {
+            const match = matchRootRoute(router.location.pathname);
+            if (match) {
+                dispatch<any>(navigateToRootProject);
             }
-        };
+        }
+    } else {
+        dispatch(userIsNotAuthenticated);
+    }
+};
 
 export const loadFavorites = () =>
     handleFirstTimeLoad((dispatch: Dispatch) => {
@@ -262,11 +203,9 @@ export const loadFavorites = () =>
         dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.FAVORITES));
     });
 
-export const loadCollectionContentAddress = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadCollectionsContentAddressPanel());
-    }
-);
+export const loadCollectionContentAddress = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadCollectionsContentAddressPanel());
+});
 
 export const loadTrash = () =>
     handleFirstTimeLoad((dispatch: Dispatch) => {
@@ -277,25 +216,20 @@ export const loadTrash = () =>
 
 export const loadAllProcesses = () =>
     handleFirstTimeLoad((dispatch: Dispatch) => {
-        dispatch<any>(
-            activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES)
-        );
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.ALL_PROCESSES));
         dispatch<any>(loadAllProcessesPanel());
         dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.ALL_PROCESSES));
     });
 
 export const loadProject = (uuid: string) =>
-    handleFirstTimeLoad(
-        async (
-            dispatch: Dispatch<any>,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            const userUuid = getUserUuid(getState());
-            dispatch(setIsProjectPanelTrashed(false));
-            if (!userUuid) {
-                return;
-            }
+    handleFirstTimeLoad(async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        dispatch(setIsProjectPanelTrashed(false));
+        if (!userUuid) {
+            return;
+        }
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
             if (extractUuidKind(uuid) === ResourceKind.USER && userUuid !== uuid) {
                 // Load another users home projects
                 dispatch(finishLoadingProject(uuid));
@@ -316,9 +250,7 @@ export const loadProject = (uuid: string) =>
                         dispatch<any>(setSharedWithMeBreadcrumbs(uuid));
                     },
                     TRASHED: async () => {
-                        await dispatch(
-                            activateSidePanelTreeItem(SidePanelTreeCategory.TRASH)
-                        );
+                        await dispatch(activateSidePanelTreeItem(SidePanelTreeCategory.TRASH));
                         dispatch<any>(setTrashBreadcrumbs(uuid));
                         dispatch(setIsProjectPanelTrashed(true));
                     },
@@ -328,124 +260,120 @@ export const loadProject = (uuid: string) =>
                 await dispatch(activateSidePanelTreeItem(userUuid));
                 dispatch<any>(setSidePanelBreadcrumbs(userUuid));
             }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
         }
-    );
+    });
 
-export const createProject =
-    (data: projectCreateActions.ProjectCreateFormDialogData) =>
-        async (dispatch: Dispatch) => {
-            const newProject = await dispatch<any>(
-                projectCreateActions.createProject(data)
-            );
-            if (newProject) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Project has been successfully created.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-                await dispatch<any>(loadSidePanelTreeProjects(newProject.ownerUuid));
-                dispatch<any>(navigateTo(newProject.uuid));
-            }
-        };
+export const createProject = (data: projectCreateActions.ProjectCreateFormDialogData) => async (dispatch: Dispatch) => {
+    const newProject = await dispatch<any>(projectCreateActions.createProject(data));
+    if (newProject) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Project has been successfully created.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(newProject.ownerUuid));
+        dispatch<any>(navigateTo(newProject.uuid));
+    }
+};
 
 export const moveProject =
-    (data: MoveToFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const oldProject = getResource(data.uuid)(getState().resources);
-                const oldOwnerUuid = oldProject ? oldProject.ownerUuid : '';
-                const movedProject = await dispatch<any>(
-                    projectMoveActions.moveProject(data)
-                );
-                if (movedProject) {
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const sourceUuid = getResource(data.uuid)(getState().resources)?.ownerUuid;
+            const destinationUuid = data.ownerUuid;
+
+            const projectsToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.PROJECT);
+
+            for (const project of projectsToMove) {
+                await moveSingleProject(project);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.PROJECT);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleProject(project: MoveableResource) {
+                try {
+                    const oldProject: MoveToFormDialogData = { name: project.name, uuid: project.uuid, ownerUuid: data.ownerUuid };
+                    const oldOwnerUuid = oldProject ? oldProject.ownerUuid : "";
+                    const movedProject = await dispatch<any>(projectMoveActions.moveProject(oldProject));
+                    if (movedProject) {
+                        dispatch(
+                            snackbarActions.OPEN_SNACKBAR({
+                                message: "Project has been moved",
+                                hideDuration: 2000,
+                                kind: SnackbarKind.SUCCESS,
+                            })
+                        );
+                        await dispatch<any>(reloadProjectMatchingUuid([oldOwnerUuid, movedProject.ownerUuid, movedProject.uuid]));
+                    }
+                } catch (e) {
                     dispatch(
                         snackbarActions.OPEN_SNACKBAR({
-                            message: 'Project has been moved',
+                            message: e.message,
                             hideDuration: 2000,
-                            kind: SnackbarKind.SUCCESS,
+                            kind: SnackbarKind.ERROR,
                         })
                     );
-                    if (oldProject) {
-                        await dispatch<any>(loadSidePanelTreeProjects(oldProject.ownerUuid));
-                    }
-                    dispatch<any>(
-                        reloadProjectMatchingUuid([
-                            oldOwnerUuid,
-                            movedProject.ownerUuid,
-                            movedProject.uuid,
-                        ])
-                    );
                 }
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
             }
+            if (sourceUuid) await dispatch<any>(loadSidePanelTreeProjects(sourceUuid));
+            await dispatch<any>(loadSidePanelTreeProjects(destinationUuid));
         };
 
-export const updateProject =
-    (data: projectUpdateActions.ProjectUpdateFormDialogData) =>
-        async (dispatch: Dispatch) => {
-            const updatedProject = await dispatch<any>(
-                projectUpdateActions.updateProject(data)
-            );
-            if (updatedProject) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Project has been successfully updated.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-                await dispatch<any>(loadSidePanelTreeProjects(updatedProject.ownerUuid));
-                dispatch<any>(
-                    reloadProjectMatchingUuid([
-                        updatedProject.ownerUuid,
-                        updatedProject.uuid,
-                    ])
-                );
-            }
-        };
+export const updateProject = (data: projectUpdateActions.ProjectUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    const updatedProject = await dispatch<any>(projectUpdateActions.updateProject(data));
+    if (updatedProject) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Project has been successfully updated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(updatedProject.ownerUuid));
+        dispatch<any>(reloadProjectMatchingUuid([updatedProject.ownerUuid, updatedProject.uuid]));
+    }
+};
 
-export const updateGroup =
-    (data: projectUpdateActions.ProjectUpdateFormDialogData) =>
-        async (dispatch: Dispatch) => {
-            const updatedGroup = await dispatch<any>(
-                groupPanelActions.updateGroup(data)
-            );
-            if (updatedGroup) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Group has been successfully updated.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-                await dispatch<any>(loadSidePanelTreeProjects(updatedGroup.ownerUuid));
-                dispatch<any>(
-                    reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid])
-                );
-            }
-        };
+export const updateGroup = (data: projectUpdateActions.ProjectUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    const updatedGroup = await dispatch<any>(groupPanelActions.updateGroup(data));
+    if (updatedGroup) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Group has been successfully updated.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        await dispatch<any>(loadSidePanelTreeProjects(updatedGroup.ownerUuid));
+        dispatch<any>(reloadProjectMatchingUuid([updatedGroup.ownerUuid, updatedGroup.uuid]));
+    }
+};
 
 export const loadCollection = (uuid: string) =>
-    handleFirstTimeLoad(
-        async (
-            dispatch: Dispatch<any>,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            const userUuid = getUserUuid(getState());
+    handleFirstTimeLoad(async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const userUuid = getUserUuid(getState());
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
             if (userUuid) {
                 const match = await loadGroupContentsResource({
                     uuid,
@@ -453,23 +381,25 @@ export const loadCollection = (uuid: string) =>
                     services,
                 });
                 let collection: CollectionResource | undefined;
-                let breadcrumbfunc: ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>) | undefined;
+                let breadcrumbfunc:
+                    | ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>)
+                    | undefined;
                 let sidepanel: string | undefined;
                 match({
-                    OWNED: (thecollection) => {
+                    OWNED: thecollection => {
                         collection = thecollection as CollectionResource;
                         sidepanel = collection.ownerUuid;
                         breadcrumbfunc = setSidePanelBreadcrumbs;
                     },
-                    SHARED: (thecollection) => {
+                    SHARED: thecollection => {
                         collection = thecollection as CollectionResource;
                         sidepanel = collection.ownerUuid;
                         breadcrumbfunc = setSharedWithMeBreadcrumbs;
                     },
-                    TRASHED: (thecollection) => {
+                    TRASHED: thecollection => {
                         collection = thecollection as CollectionResource;
                         sidepanel = SidePanelTreeCategory.TRASH;
-                        breadcrumbfunc = () => setTrashBreadcrumbs('');
+                        breadcrumbfunc = () => setTrashBreadcrumbs("");
                     },
                 });
                 if (collection && breadcrumbfunc && sidepanel) {
@@ -481,112 +411,147 @@ export const loadCollection = (uuid: string) =>
                     dispatch(loadCollectionPanel(collection.uuid));
                 }
             }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
         }
-    );
+    });
 
-export const createCollection =
-    (data: collectionCreateActions.CollectionCreateFormDialogData) =>
-        async (dispatch: Dispatch) => {
+export const createCollection = (data: collectionCreateActions.CollectionCreateFormDialogData) => async (dispatch: Dispatch) => {
+    const collection = await dispatch<any>(collectionCreateActions.createCollection(data));
+    if (collection) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Collection has been successfully created.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        dispatch<any>(updateResources([collection]));
+        dispatch<any>(navigateTo(collection.uuid));
+    }
+};
+
+export const copyCollection = (data: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    const checkedList = getState().multiselect.checkedList;
+    const uuidsToCopy: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+    //if no items in checkedlist && no items passed in, default to normal context menu behavior
+    if (!uuidsToCopy.length) uuidsToCopy.push(data.uuid);
+
+    const collectionsToCopy: CollectionCopyResource[] = uuidsToCopy
+        .map(uuid => getResource(uuid)(getState().resources) as CollectionCopyResource)
+        .filter(resource => resource.kind === ResourceKind.COLLECTION);
+
+    for (const collection of collectionsToCopy) {
+        await copySingleCollection({ ...collection, ownerUuid: data.ownerUuid } as CollectionCopyResource);
+    }
+
+    async function copySingleCollection(copyToProject: CollectionCopyResource) {
+        const newName = data.fromContextMenu || collectionsToCopy.length === 1 ? data.name : `Copy of: ${copyToProject.name}`;
+        try {
             const collection = await dispatch<any>(
-                collectionCreateActions.createCollection(data)
+                collectionCopyActions.copyCollection({
+                    ...copyToProject,
+                    name: newName,
+                    fromContextMenu: collectionsToCopy.length === 1 ? true : data.fromContextMenu,
+                })
             );
-            if (collection) {
+            if (copyToProject && collection) {
+                await dispatch<any>(reloadProjectMatchingUuid([copyToProject.uuid]));
                 dispatch(
                     snackbarActions.OPEN_SNACKBAR({
-                        message: 'Collection has been successfully created.',
-                        hideDuration: 2000,
+                        message: "Collection has been copied.",
+                        hideDuration: 3000,
                         kind: SnackbarKind.SUCCESS,
+                        link: collection.ownerUuid,
                     })
                 );
-                dispatch<any>(updateResources([collection]));
-                dispatch<any>(navigateTo(collection.uuid));
+                dispatch<any>(multiselectActions.deselectOne(copyToProject.uuid));
             }
-        };
+        } catch (e) {
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: e.message,
+                    hideDuration: 2000,
+                    kind: SnackbarKind.ERROR,
+                })
+            );
+        }
+    }
+    dispatch(projectPanelActions.REQUEST_ITEMS());
+};
 
-export const copyCollection =
-    (data: CopyFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const copyToProject = getResource(data.ownerUuid)(getState().resources);
-                const collection = await dispatch<any>(
-                    collectionCopyActions.copyCollection(data)
-                );
-                if (copyToProject && collection) {
-                    dispatch<any>(reloadProjectMatchingUuid([copyToProject.uuid]));
+export const moveCollection =
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist && no items passed in, default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const collectionsToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.COLLECTION);
+
+            for (const collection of collectionsToMove) {
+                await moveSingleCollection(collection);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.COLLECTION);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleCollection(collection: MoveableResource) {
+                try {
+                    const oldCollection: MoveToFormDialogData = { name: collection.name, uuid: collection.uuid, ownerUuid: data.ownerUuid };
+                    const movedCollection = await dispatch<any>(collectionMoveActions.moveCollection(oldCollection));
+                    dispatch<any>(updateResources([movedCollection]));
+                    dispatch<any>(reloadProjectMatchingUuid([movedCollection.ownerUuid]));
                     dispatch(
                         snackbarActions.OPEN_SNACKBAR({
-                            message: 'Collection has been copied.',
-                            hideDuration: 3000,
+                            message: "Collection has been moved.",
+                            hideDuration: 2000,
                             kind: SnackbarKind.SUCCESS,
-                            link: collection.ownerUuid,
+                        })
+                    );
+                } catch (e) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: e.message,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.ERROR,
                         })
                     );
                 }
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
-            }
-        };
-
-export const moveCollection =
-    (data: MoveToFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const collection = await dispatch<any>(
-                    collectionMoveActions.moveCollection(data)
-                );
-                dispatch<any>(updateResources([collection]));
-                dispatch<any>(reloadProjectMatchingUuid([collection.ownerUuid]));
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Collection has been moved.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
             }
         };
 
 export const loadProcess = (uuid: string) =>
     handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState) => {
-        dispatch<any>(loadProcessPanel(uuid));
-        const process = await dispatch<any>(processesActions.loadProcess(uuid));
-        if (process) {
-            await dispatch<any>(finishLoadingProject(process.containerRequest.ownerUuid));
-            await dispatch<any>(
-                activateSidePanelTreeItem(process.containerRequest.ownerUuid)
-            );
-            dispatch<any>(setProcessBreadcrumbs(uuid));
-            dispatch<any>(loadDetailsPanel(uuid));
+        try {
+            dispatch(progressIndicatorActions.START_WORKING(uuid));
+            dispatch<any>(loadProcessPanel(uuid));
+            const process = await dispatch<any>(processesActions.loadProcess(uuid));
+            if (process) {
+                await dispatch<any>(finishLoadingProject(process.containerRequest.ownerUuid));
+                await dispatch<any>(activateSidePanelTreeItem(process.containerRequest.ownerUuid));
+                dispatch<any>(setProcessBreadcrumbs(uuid));
+                dispatch<any>(loadDetailsPanel(uuid));
+            }
+        } finally {
+            dispatch(progressIndicatorActions.STOP_WORKING(uuid));
         }
     });
 
 export const loadRegisteredWorkflow = (uuid: string) =>
-    handleFirstTimeLoad(async (dispatch: Dispatch,
-        getState: () => RootState,
-        services: ServiceRepository) => {
-
+    handleFirstTimeLoad(async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const userUuid = getUserUuid(getState());
         if (userUuid) {
             const match = await loadGroupContentsResource({
@@ -595,17 +560,19 @@ export const loadRegisteredWorkflow = (uuid: string) =>
                 services,
             });
             let workflow: WorkflowResource | undefined;
-            let breadcrumbfunc: ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>) | undefined;
+            let breadcrumbfunc:
+                | ((uuid: string) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>)
+                | undefined;
             match({
-                OWNED: async (theworkflow) => {
+                OWNED: async theworkflow => {
                     workflow = theworkflow as WorkflowResource;
                     breadcrumbfunc = setSidePanelBreadcrumbs;
                 },
-                SHARED: async (theworkflow) => {
+                SHARED: async theworkflow => {
                     workflow = theworkflow as WorkflowResource;
                     breadcrumbfunc = setSharedWithMeBreadcrumbs;
                 },
-                TRASHED: () => { }
+                TRASHED: () => { },
             });
             if (workflow && breadcrumbfunc) {
                 dispatch(updateResources([workflow]));
@@ -616,93 +583,106 @@ export const loadRegisteredWorkflow = (uuid: string) =>
         }
     });
 
-export const updateProcess =
-    (data: processUpdateActions.ProcessUpdateFormDialogData) =>
-        async (dispatch: Dispatch) => {
-            try {
-                const process = await dispatch<any>(
-                    processUpdateActions.updateProcess(data)
-                );
-                if (process) {
+export const updateProcess = (data: processUpdateActions.ProcessUpdateFormDialogData) => async (dispatch: Dispatch) => {
+    try {
+        const process = await dispatch<any>(processUpdateActions.updateProcess(data));
+        if (process) {
+            dispatch(
+                snackbarActions.OPEN_SNACKBAR({
+                    message: "Process has been successfully updated.",
+                    hideDuration: 2000,
+                    kind: SnackbarKind.SUCCESS,
+                })
+            );
+            dispatch<any>(updateResources([process]));
+            dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
+        }
+    } catch (e) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: e.message,
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR,
+            })
+        );
+    }
+};
+
+export const moveProcess =
+    (data: MoveToFormDialogData, isSecondaryMove = false) =>
+        async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+            const checkedList = getState().multiselect.checkedList;
+            const uuidsToMove: string[] = data.fromContextMenu ? [data.uuid] : selectedToArray(checkedList);
+
+            //if no items in checkedlist && no items passed in, default to normal context menu behavior
+            if (!isSecondaryMove && !uuidsToMove.length) uuidsToMove.push(data.uuid);
+
+            const processesToMove: MoveableResource[] = uuidsToMove
+                .map(uuid => getResource(uuid)(getState().resources) as MoveableResource)
+                .filter(resource => resource.kind === ResourceKind.PROCESS);
+
+            for (const process of processesToMove) {
+                await moveSingleProcess(process);
+            }
+
+            //omly propagate if this call is the original
+            if (!isSecondaryMove) {
+                const kindsToMove: Set<string> = selectedToKindSet(checkedList);
+                kindsToMove.delete(ResourceKind.PROCESS);
+
+                kindsToMove.forEach(kind => {
+                    secondaryMove[kind](data, true)(dispatch, getState, services);
+                });
+            }
+
+            async function moveSingleProcess(process: MoveableResource) {
+                try {
+                    const oldProcess: MoveToFormDialogData = { name: process.name, uuid: process.uuid, ownerUuid: data.ownerUuid };
+                    const movedProcess = await dispatch<any>(processMoveActions.moveProcess(oldProcess));
+                    dispatch<any>(updateResources([movedProcess]));
+                    dispatch<any>(reloadProjectMatchingUuid([movedProcess.ownerUuid]));
                     dispatch(
                         snackbarActions.OPEN_SNACKBAR({
-                            message: 'Process has been successfully updated.',
+                            message: "Process has been moved.",
                             hideDuration: 2000,
                             kind: SnackbarKind.SUCCESS,
                         })
                     );
-                    dispatch<any>(updateResources([process]));
-                    dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
+                } catch (e) {
+                    dispatch(
+                        snackbarActions.OPEN_SNACKBAR({
+                            message: e.message,
+                            hideDuration: 2000,
+                            kind: SnackbarKind.ERROR,
+                        })
+                    );
                 }
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
-            }
-        };
-
-export const moveProcess =
-    (data: MoveToFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const process = await dispatch<any>(processMoveActions.moveProcess(data));
-                dispatch<any>(updateResources([process]));
-                dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Process has been moved.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
             }
         };
 
-export const copyProcess =
-    (data: CopyFormDialogData) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            try {
-                const process = await dispatch<any>(processCopyActions.copyProcess(data));
-                dispatch<any>(updateResources([process]));
-                dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: 'Process has been copied.',
-                        hideDuration: 2000,
-                        kind: SnackbarKind.SUCCESS,
-                    })
-                );
-                dispatch<any>(navigateTo(process.uuid));
-            } catch (e) {
-                dispatch(
-                    snackbarActions.OPEN_SNACKBAR({
-                        message: e.message,
-                        hideDuration: 2000,
-                        kind: SnackbarKind.ERROR,
-                    })
-                );
-            }
-        };
+export const copyProcess = (data: CopyFormDialogData) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+    try {
+        const process = await dispatch<any>(processCopyActions.copyProcess(data));
+        dispatch<any>(updateResources([process]));
+        dispatch<any>(reloadProjectMatchingUuid([process.ownerUuid]));
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: "Process has been copied.",
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
+        dispatch<any>(navigateTo(process.uuid));
+    } catch (e) {
+        dispatch(
+            snackbarActions.OPEN_SNACKBAR({
+                message: e.message,
+                hideDuration: 2000,
+                kind: SnackbarKind.ERROR,
+            })
+        );
+    }
+};
 
 export const resourceIsNotLoaded = (uuid: string) =>
     snackbarActions.OPEN_SNACKBAR({
@@ -711,106 +691,70 @@ export const resourceIsNotLoaded = (uuid: string) =>
     });
 
 export const userIsNotAuthenticated = snackbarActions.OPEN_SNACKBAR({
-    message: 'User is not authenticated',
+    message: "User is not authenticated",
     kind: SnackbarKind.ERROR,
 });
 
 export const couldNotLoadUser = snackbarActions.OPEN_SNACKBAR({
-    message: 'Could not load user',
+    message: "Could not load user",
     kind: SnackbarKind.ERROR,
 });
 
 export const reloadProjectMatchingUuid =
-    (matchingUuids: string[]) =>
-        async (
-            dispatch: Dispatch,
-            getState: () => RootState,
-            services: ServiceRepository
-        ) => {
-            const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState());
-            if (
-                currentProjectPanelUuid &&
-                matchingUuids.some((uuid) => uuid === currentProjectPanelUuid)
-            ) {
-                dispatch<any>(loadProject(currentProjectPanelUuid));
-            }
-        };
+    (matchingUuids: string[]) => async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        const currentProjectPanelUuid = getProjectPanelCurrentUuid(getState());
+        if (currentProjectPanelUuid && matchingUuids.some(uuid => uuid === currentProjectPanelUuid)) {
+            dispatch<any>(loadProject(currentProjectPanelUuid));
+        }
+    };
 
-export const loadSharedWithMe = handleFirstTimeLoad(
-    async (dispatch: Dispatch) => {
-        dispatch<any>(loadSharedWithMePanel());
-        await dispatch<any>(
-            activateSidePanelTreeItem(SidePanelTreeCategory.SHARED_WITH_ME)
-        );
-        await dispatch<any>(
-            setSidePanelBreadcrumbs(SidePanelTreeCategory.SHARED_WITH_ME)
-        );
-    }
-);
+export const loadSharedWithMe = handleFirstTimeLoad(async (dispatch: Dispatch) => {
+    dispatch<any>(loadSharedWithMePanel());
+    await dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.SHARED_WITH_ME));
+    await dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.SHARED_WITH_ME));
+});
 
-export const loadRunProcess = handleFirstTimeLoad(
-    async (dispatch: Dispatch) => {
-        await dispatch<any>(loadRunProcessPanel());
-    }
-);
+export const loadRunProcess = handleFirstTimeLoad(async (dispatch: Dispatch) => {
+    await dispatch<any>(loadRunProcessPanel());
+});
 
 export const loadPublicFavorites = () =>
     handleFirstTimeLoad((dispatch: Dispatch) => {
-        dispatch<any>(
-            activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES)
-        );
+        dispatch<any>(activateSidePanelTreeItem(SidePanelTreeCategory.PUBLIC_FAVORITES));
         dispatch<any>(loadPublicFavoritePanel());
-        dispatch<any>(
-            setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES)
-        );
+        dispatch<any>(setSidePanelBreadcrumbs(SidePanelTreeCategory.PUBLIC_FAVORITES));
     });
 
-export const loadSearchResults = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSearchResultsPanel());
-    }
-);
+export const loadSearchResults = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSearchResultsPanel());
+});
 
-export const loadLinks = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadLinkPanel());
-    }
-);
+export const loadLinks = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadLinkPanel());
+});
 
-export const loadVirtualMachines = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadVirtualMachinesPanel());
-        dispatch(setBreadcrumbs([{ label: 'Virtual Machines' }]));
-    }
-);
+export const loadVirtualMachines = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadVirtualMachinesPanel());
+    dispatch(setBreadcrumbs([{ label: "Virtual Machines" }]));
+});
 
-export const loadVirtualMachinesAdmin = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadVirtualMachinesPanel());
-        dispatch(
-            setBreadcrumbs([{ label: 'Virtual Machines Admin', icon: AdminMenuIcon }])
-        );
-    }
-);
+export const loadVirtualMachinesAdmin = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadVirtualMachinesPanel());
+    dispatch(setBreadcrumbs([{ label: "Virtual Machines Admin", icon: AdminMenuIcon }]));
+});
 
-export const loadRepositories = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadRepositoriesPanel());
-        dispatch(setBreadcrumbs([{ label: 'Repositories' }]));
-    }
-);
+export const loadRepositories = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadRepositoriesPanel());
+    dispatch(setBreadcrumbs([{ label: "Repositories" }]));
+});
 
-export const loadSshKeys = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSshKeysPanel());
-    }
-);
+export const loadSshKeys = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSshKeysPanel());
+});
 
-export const loadSiteManager = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadSiteManagerPanel());
-    }
-);
+export const loadSiteManager = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadSiteManagerPanel());
+});
 
 export const loadUserProfile = (userUuid?: string) =>
     handleFirstTimeLoad((dispatch: Dispatch<any>) => {
@@ -823,37 +767,27 @@ export const loadUserProfile = (userUuid?: string) =>
         }
     });
 
-export const loadLinkAccount = handleFirstTimeLoad(
-    (dispatch: Dispatch<any>) => {
-        dispatch(loadLinkAccountPanel());
-    }
-);
+export const loadLinkAccount = handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+    dispatch(loadLinkAccountPanel());
+});
 
-export const loadKeepServices = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadKeepServicesPanel());
-    }
-);
+export const loadKeepServices = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadKeepServicesPanel());
+});
 
-export const loadUsers = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadUsersPanel());
-        dispatch(setUsersBreadcrumbs());
-    }
-);
+export const loadUsers = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadUsersPanel());
+    dispatch(setUsersBreadcrumbs());
+});
 
-export const loadApiClientAuthorizations = handleFirstTimeLoad(
-    async (dispatch: Dispatch<any>) => {
-        await dispatch(loadApiClientAuthorizationsPanel());
-    }
-);
+export const loadApiClientAuthorizations = handleFirstTimeLoad(async (dispatch: Dispatch<any>) => {
+    await dispatch(loadApiClientAuthorizationsPanel());
+});
 
-export const loadGroupsPanel = handleFirstTimeLoad(
-    (dispatch: Dispatch<any>) => {
-        dispatch(setGroupsBreadcrumbs());
-        dispatch(groupPanelActions.loadGroupsPanel());
-    }
-);
+export const loadGroupsPanel = handleFirstTimeLoad((dispatch: Dispatch<any>) => {
+    dispatch(setGroupsBreadcrumbs());
+    dispatch(groupPanelActions.loadGroupsPanel());
+});
 
 export const loadGroupDetailsPanel = (groupUuid: string) =>
     handleFirstTimeLoad((dispatch: Dispatch<any>) => {
@@ -861,39 +795,26 @@ export const loadGroupDetailsPanel = (groupUuid: string) =>
         dispatch(groupDetailsPanelActions.loadGroupDetailsPanel(groupUuid));
     });
 
-const finishLoadingProject =
-    (project: GroupContentsResource | string) =>
-        async (dispatch: Dispatch<any>) => {
-            const uuid = typeof project === 'string' ? project : project.uuid;
-            dispatch(loadDetailsPanel(uuid));
-            if (typeof project !== 'string') {
-                dispatch(updateResources([project]));
-            }
-        };
+const finishLoadingProject = (project: GroupContentsResource | string) => async (dispatch: Dispatch<any>) => {
+    const uuid = typeof project === "string" ? project : project.uuid;
+    dispatch(loadDetailsPanel(uuid));
+    if (typeof project !== "string") {
+        dispatch(updateResources([project]));
+    }
+};
 
-const loadGroupContentsResource = async (params: {
-    uuid: string;
-    userUuid: string;
-    services: ServiceRepository;
-}) => {
-    const filters = new FilterBuilder()
-        .addEqual('uuid', params.uuid)
-        .getFilters();
-    const { items } = await params.services.groupsService.contents(
-        params.userUuid,
-        {
-            filters,
-            recursive: true,
-            includeTrash: true,
-        }
-    );
+const loadGroupContentsResource = async (params: { uuid: string; userUuid: string; services: ServiceRepository }) => {
+    const filters = new FilterBuilder().addEqual("uuid", params.uuid).getFilters();
+    const { items } = await params.services.groupsService.contents(params.userUuid, {
+        filters,
+        recursive: true,
+        includeTrash: true,
+    });
     const resource = items.shift();
     let handler: GroupContentsHandler;
     if (resource) {
         handler =
-            (resource.kind === ResourceKind.COLLECTION ||
-                resource.kind === ResourceKind.PROJECT) &&
-                resource.isTrashed
+            (resource.kind === ResourceKind.COLLECTION || resource.kind === ResourceKind.PROJECT) && resource.isTrashed
                 ? groupContentsHandlers.TRASHED(resource)
                 : groupContentsHandlers.OWNED(resource);
     } else {
@@ -908,17 +829,11 @@ const loadGroupContentsResource = async (params: {
         } else if (kind === ResourceKind.CONTAINER_REQUEST) {
             resource = await params.services.containerRequestService.get(params.uuid);
         } else {
-            throw new Error("loadGroupContentsResource unsupported kind " + kind)
+            throw new Error("loadGroupContentsResource unsupported kind " + kind);
         }
         handler = groupContentsHandlers.SHARED(resource);
     }
-    return (
-        cases: MatchCases<
-            typeof groupContentsHandlersRecord,
-            GroupContentsHandler,
-            void
-        >
-    ) => groupContentsHandlers.match(handler, cases);
+    return (cases: MatchCases<typeof groupContentsHandlersRecord, GroupContentsHandler, void>) => groupContentsHandlers.match(handler, cases);
 };
 
 const groupContentsHandlersRecord = {
@@ -930,3 +845,18 @@ const groupContentsHandlersRecord = {
 const groupContentsHandlers = unionize(groupContentsHandlersRecord);
 
 type GroupContentsHandler = UnionOf<typeof groupContentsHandlers>;
+
+type CollectionCopyResource = Resource & { name: string; fromContextMenu: boolean };
+
+type MoveableResource = Resource & { name: string };
+
+type MoveFunc = (
+    data: MoveToFormDialogData,
+    isSecondaryMove?: boolean
+) => (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => Promise<void>;
+
+const secondaryMove: Record<string, MoveFunc> = {
+    [ResourceKind.PROJECT]: moveProject,
+    [ResourceKind.PROCESS]: moveProcess,
+    [ResourceKind.COLLECTION]: moveCollection,
+};
index 94b35078b43c9649d2e97001decf3ac9eea0b9a3..d8c3b6514135414404e6b1132be5d9302483173e 100644 (file)
@@ -23,7 +23,6 @@ import { RUN_PROCESS_ADVANCED_FORM } from 'views/run-process-panel/run-process-a
 import { getResource } from 'store/resources/resources';
 import { ProjectResource } from 'models/project';
 import { UserResource } from 'models/user';
-import { getUserUuid } from "common/getuser";
 import { getWorkflowInputs, parseWorkflowDefinition } from 'models/workflow';
 
 export const WORKFLOW_PANEL_ID = "workflowPanel";
@@ -63,7 +62,6 @@ export const openRunProcess = (workflowUuid: string, ownerUuid?: string, name?:
             let owner;
             if (ownerUuid) {
                 // Must be writable.
-                const userUuid = getUserUuid(getState());
                 owner = getResource<ProjectResource | UserResource>(ownerUuid)(getState().resources);
                 if (!owner || !owner.canWrite) {
                     owner = undefined;
index aeaa6a22f85e50c3a6f39cfea891507c03114b63..8e75d22f6714d97853f2d7061aa9167dc793a072 100644 (file)
@@ -4,28 +4,34 @@
 
 import {
     openApiClientAuthorizationAttributesDialog,
-    openApiClientAuthorizationRemoveDialog
-} from 'store/api-client-authorizations/api-client-authorizations-actions';
-import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+    openApiClientAuthorizationRemoveDialog,
+} from "store/api-client-authorizations/api-client-authorizations-actions";
+import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
 import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
 
-export const apiClientAuthorizationActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openApiClientAuthorizationAttributesDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openApiClientAuthorizationRemoveDialog(uuid));
-    }
-}]];
+export const apiClientAuthorizationActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: "Attributes",
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openApiClientAuthorizationAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: "API Details",
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: "Remove",
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                    dispatch<any>(openApiClientAuthorizationRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index edfaa3cdf0b84d609eab951ac380dabaca73c5ce..95aec9c7c94f476be3de1aa2f595040211b8d6b6 100644 (file)
@@ -2,10 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import {
-    ContextMenuAction,
-    ContextMenuActionSet
-} from "../context-menu-action-set";
+import { ContextMenuAction, ContextMenuActionSet } from "../context-menu-action-set";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "store/favorites/favorites-actions";
 import {
@@ -18,84 +15,90 @@ import {
     OpenIcon,
     Link,
     RestoreVersionIcon,
-    FolderSharedIcon
+    FolderSharedIcon,
 } from "components/icon/icon";
 import { openCollectionUpdateDialog } from "store/collections/collection-update-actions";
 import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
-import { openMoveCollectionDialog } from 'store/collections/collection-move-actions';
-import { openCollectionCopyDialog } from "store/collections/collection-copy-actions";
+import { openMoveCollectionDialog } from "store/collections/collection-move-actions";
+import { openCollectionCopyDialog, openMultiCollectionCopyDialog } from "store/collections/collection-copy-actions";
 import { openWebDavS3InfoDialog } from "store/collections/collection-info-actions";
 import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
 import { toggleCollectionTrashed } from "store/trash/trash-actions";
-import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
+import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
-import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
 import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
 import { openRestoreCollectionVersionDialog } from "store/collections/collection-version-actions";
 import { TogglePublicFavoriteAction } from "../actions/public-favorite-action";
 import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
 import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
 
 const toggleFavoriteAction: ContextMenuAction = {
     component: ToggleFavoriteAction,
-    name: 'ToggleFavoriteAction',
-    execute: (dispatch, resource) => {
-        dispatch<any>(toggleFavorite(resource)).then(() => {
-            dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
-        });
-    }
-};
-
-const commonActionSet: ContextMenuActionSet = [[
-    {
-        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: CopyIcon,
-        name: "Make a copy",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionCopyDialog(resource));
-        }
-
-    },
-    {
-        icon: DetailsIcon,
-        name: "View details",
-        execute: dispatch => {
-            dispatch<any>(toggleDetailsPanel());
-        }
-    },
-    {
-        icon: AdvancedIcon,
-        name: "API Details",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
+    name: "ToggleFavoriteAction",
+    execute: (dispatch, resources) => {
+        for (const resource of [...resources]) {
+            dispatch<any>(toggleFavorite(resource)).then(() => {
+                dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+            });
         }
     },
-]];
+};
+const commonActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: OpenIcon,
+            name: "Open in new tab",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: Link,
+            name: "Copy to clipboard",
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
+        },
+        {
+            icon: CopyIcon,
+            name: "Make a copy",
+            execute: (dispatch, resources) => {
+                if (resources[0].fromContextMenu || resources.length === 1) dispatch<any>(openCollectionCopyDialog(resources[0]));
+                else dispatch<any>(openMultiCollectionCopyDialog(resources[0]));
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: "View details",
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: "API Details",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
 
-export const readOnlyCollectionActionSet: ContextMenuActionSet = [[
-    ...commonActionSet.reduce((prev, next) => prev.concat(next), []),
-    toggleFavoriteAction,
-    {
-        icon: FolderSharedIcon,
-        name: "Open with 3rd party client",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openWebDavS3InfoDialog(resource.uuid));
-        }
-    },
-]];
+export const readOnlyCollectionActionSet: ContextMenuActionSet = [
+    [
+        ...commonActionSet.reduce((prev, next) => prev.concat(next), []),
+        toggleFavoriteAction,
+        {
+            icon: FolderSharedIcon,
+            name: "Open with 3rd party client",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openWebDavS3InfoDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
 
 export const collectionActionSet: ContextMenuActionSet = [
     [
@@ -103,30 +106,32 @@ export const collectionActionSet: ContextMenuActionSet = [
         {
             icon: RenameIcon,
             name: "Edit collection",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openCollectionUpdateDialog(resource));
-            }
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionUpdateDialog(resources[0]));
+            },
         },
         {
             icon: ShareIcon,
             name: "Share",
-            execute: (dispatch, { uuid }) => {
-                dispatch<any>(openSharingDialog(uuid));
-            }
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSharingDialog(resources[0].uuid));
+            },
         },
         {
             icon: MoveToIcon,
             name: "Move to",
-            execute: (dispatch, resource) => dispatch<any>(openMoveCollectionDialog(resource))
+            execute: (dispatch, resources) => dispatch<any>(openMoveCollectionDialog(resources[0])),
         },
         {
             component: ToggleTrashAction,
-            name: 'ToggleTrashAction',
-            execute: (dispatch, resource) => {
-                dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
-            }
+            name: "ToggleTrashAction",
+            execute: (dispatch, resources: ContextMenuResource[]) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
+                }
+            },
         },
-    ]
+    ],
 ];
 
 export const collectionAdminActionSet: ContextMenuActionSet = [
@@ -134,14 +139,16 @@ export const collectionAdminActionSet: ContextMenuActionSet = [
         ...collectionActionSet.reduce((prev, next) => prev.concat(next), []),
         {
             component: TogglePublicFavoriteAction,
-            name: 'TogglePublicFavoriteAction',
-            execute: (dispatch, resource) => {
-                dispatch<any>(togglePublicFavorite(resource)).then(() => {
-                    dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
-                });
-            }
+            name: "TogglePublicFavoriteAction",
+            execute: (dispatch, resources) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(togglePublicFavorite(resource)).then(() => {
+                        dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+                    });
+                }
+            },
         },
-    ]
+    ],
 ];
 
 export const oldCollectionVersionActionSet: ContextMenuActionSet = [
@@ -149,10 +156,12 @@ export const oldCollectionVersionActionSet: ContextMenuActionSet = [
         ...commonActionSet.reduce((prev, next) => prev.concat(next), []),
         {
             icon: RestoreVersionIcon,
-            name: 'Restore version',
-            execute: (dispatch, { uuid }) => {
-                dispatch<any>(openRestoreCollectionVersionDialog(uuid));
-            }
+            name: "Restore version",
+            execute: (dispatch, resources) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(openRestoreCollectionVersionDialog(resource.uuid));
+                }
+            },
         },
-    ]
+    ],
 ];
index d0758d700a578d98caf1b437ddc8dcab829fb2d4..fb158a826d58904dd055d8b5b1e4b22aa3f2e469 100644 (file)
@@ -5,78 +5,99 @@
 import { ContextMenuActionSet } from "../context-menu-action-set";
 import { FileCopyIcon, FileMoveIcon, RemoveIcon, RenameIcon } from "components/icon/icon";
 import { DownloadCollectionFileAction } from "../actions/download-collection-file-action";
-import { openFileRemoveDialog, openRenameFileDialog } from 'store/collection-panel/collection-panel-files/collection-panel-files-actions';
-import { CollectionFileViewerAction } from 'views-components/context-menu/actions/collection-file-viewer-action';
+import { openFileRemoveDialog, openRenameFileDialog } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { CollectionFileViewerAction } from "views-components/context-menu/actions/collection-file-viewer-action";
 import { CollectionCopyToClipboardAction } from "../actions/collection-copy-to-clipboard-action";
-import { openCollectionPartialMoveToExistingCollectionDialog, openCollectionPartialMoveToNewCollectionDialog } from "store/collections/collection-partial-move-actions";
-import { openCollectionPartialCopyToExistingCollectionDialog, openCollectionPartialCopyToNewCollectionDialog } from "store/collections/collection-partial-copy-actions";
+import {
+    openCollectionPartialMoveToExistingCollectionDialog,
+    openCollectionPartialMoveToNewCollectionDialog,
+} from "store/collections/collection-partial-move-actions";
+import {
+    openCollectionPartialCopyToExistingCollectionDialog,
+    openCollectionPartialCopyToNewCollectionDialog,
+} from "store/collections/collection-partial-copy-actions";
 
-export const readOnlyCollectionDirectoryItemActionSet: ContextMenuActionSet = [[
-    {
-        name: "Copy item into new collection",
-        icon: FileCopyIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionPartialCopyToNewCollectionDialog(resource));
-        }
-    },
-    {
-        name: "Copy item into existing collection",
-        icon: FileCopyIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionPartialCopyToExistingCollectionDialog(resource));
-        }
-    },
-    {
-        component: CollectionFileViewerAction,
-        execute: () => { return; },
-    },
-    {
-        component: CollectionCopyToClipboardAction,
-        execute: () => { return; },
-    }
-]];
+export const readOnlyCollectionDirectoryItemActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: "Copy item into new collection",
+            icon: FileCopyIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialCopyToNewCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: "Copy item into existing collection",
+            icon: FileCopyIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialCopyToExistingCollectionDialog(resources[0]));
+            },
+        },
+        {
+            component: CollectionFileViewerAction,
+            execute: () => {
+                return;
+            },
+        },
+        {
+            component: CollectionCopyToClipboardAction,
+            execute: () => {
+                return;
+            },
+        },
+    ],
+];
 
-export const readOnlyCollectionFileItemActionSet: ContextMenuActionSet = [[
-    {
-        component: DownloadCollectionFileAction,
-        execute: () => { return; }
-    },
-    ...readOnlyCollectionDirectoryItemActionSet.reduce((prev, next) => prev.concat(next), []),
-]];
+export const readOnlyCollectionFileItemActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: DownloadCollectionFileAction,
+            execute: () => {
+                return;
+            },
+        },
+        ...readOnlyCollectionDirectoryItemActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
 
-const writableActionSet: ContextMenuActionSet = [[
-    {
-        name: "Move item into new collection",
-        icon: FileMoveIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionPartialMoveToNewCollectionDialog(resource));
-        }
-    },
-    {
-        name: "Move item into existing collection",
-        icon: FileMoveIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionPartialMoveToExistingCollectionDialog(resource));
-        }
-    },
-    {
-        name: "Rename",
-        icon: RenameIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openRenameFileDialog({
-                name: resource.name,
-                id: resource.uuid,
-                path: resource.uuid.split('/').slice(1).join('/') }));
-        }
-    },
-    {
-        name: "Remove",
-        icon: RemoveIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openFileRemoveDialog(resource.uuid));
-        }
-    }
-]];
+const writableActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: "Move item into new collection",
+            icon: FileMoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialMoveToNewCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: "Move item into existing collection",
+            icon: FileMoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCollectionPartialMoveToExistingCollectionDialog(resources[0]));
+            },
+        },
+        {
+            name: "Rename",
+            icon: RenameIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(
+                    openRenameFileDialog({
+                        name: resources[0].name,
+                        id: resources[0].uuid,
+                        path: resources[0].uuid.split("/").slice(1).join("/"),
+                    })
+                );
+            },
+        },
+        {
+            name: "Remove",
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openFileRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
 
 export const collectionDirectoryItemActionSet: ContextMenuActionSet = readOnlyCollectionDirectoryItemActionSet.concat(writableActionSet);
 
index ee012fb185991ede5950601f68fac37542f46693..bdc4b07a2452658569830103a99b1a58134ca982 100644 (file)
@@ -2,16 +2,22 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
-import { ToggleFavoriteAction } from "../actions/favorite-action";
-import { toggleFavorite } from "store/favorites/favorites-actions";
-import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
+import { ContextMenuActionSet } from '../context-menu-action-set';
+import { ToggleFavoriteAction } from '../actions/favorite-action';
+import { toggleFavorite } from 'store/favorites/favorites-actions';
+import { favoritePanelActions } from 'store/favorite-panel/favorite-panel-action';
 
-export const favoriteActionSet: ContextMenuActionSet = [[{
-    component: ToggleFavoriteAction,
-    execute: (dispatch, resource) => {
-        dispatch<any>(toggleFavorite(resource)).then(() => {
-            dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
-        });
-    }
-}]];
+export const favoriteActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) =>
+                    dispatch<any>(toggleFavorite(resource)).then(() => {
+                        dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+                    })
+                );
+            },
+        },
+    ],
+];
index f573af69a7807450dcff03fdb663d27a3322ac5a..816583faa9f05e3c2d5291362dc12449b503dc92 100644 (file)
@@ -2,33 +2,40 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { RenameIcon, AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
-import { openGroupAttributes, openRemoveGroupDialog, openGroupUpdateDialog } from "store/groups-panel/groups-panel-actions";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { RenameIcon, AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openGroupAttributes, openRemoveGroupDialog, openGroupUpdateDialog } from 'store/groups-panel/groups-panel-actions';
 
-export const groupActionSet: ContextMenuActionSet = [[{
-    name: "Rename",
-    icon: RenameIcon,
-    execute: (dispatch, resource) => {
-        dispatch<any>(openGroupUpdateDialog(resource));
-    }
-}, {
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openGroupAttributes(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, resource) => {
-        dispatch<any>(openAdvancedTabDialog(resource.uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRemoveGroupDialog(uuid));
-    }
-}]];
+export const groupActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Rename',
+            icon: RenameIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openGroupUpdateDialog(resources[0]))
+            },
+        },
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openGroupAttributes(resources[0].uuid))
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveGroupDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 37aa35c0fd26b0ba2f71520d9c19901a263e294b..ad1ce97c2dcb8ba3ee238d7ee10b1a39809f81e7 100644 (file)
@@ -2,27 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
 import { openGroupMemberAttributes, openRemoveGroupMemberDialog } from 'store/group-details-panel/group-details-panel-actions';
 
-export const groupMemberActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openGroupMemberAttributes(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, resource) => {
-        dispatch<any>(openAdvancedTabDialog(resource.uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRemoveGroupMemberDialog(uuid));
-    }
-}]];
+export const groupMemberActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openGroupMemberAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRemoveGroupMemberDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 820d1978fd37b9a16ff0796827a0ff8801fadeba..2957f008cd055f04fea2533e3a5e9e28ee96dfac 100644 (file)
@@ -4,25 +4,31 @@
 
 import { openKeepServiceAttributesDialog, openKeepServiceRemoveDialog } from 'store/keep-services/keep-services-actions';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
 
-export const keepServiceActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openKeepServiceAttributesDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openKeepServiceRemoveDialog(uuid));
-    }
-}]];
+export const keepServiceActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openKeepServiceAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openKeepServiceRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 929a65a974e32724af766ffb9bbc16a72459c315..86458423c2e09e489016d3dd8d226c72dd370b8e 100644 (file)
@@ -4,25 +4,31 @@
 
 import { openLinkAttributesDialog, openLinkRemoveDialog } from 'store/link-panel/link-panel-actions';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
 
-export const linkActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openLinkAttributesDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openLinkRemoveDialog(uuid));
-    }
-}]];
+export const linkActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openLinkAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openLinkRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 8663d3c7a061ceeb5cf37eabb94a36da310cbae2..4b6950ee24e17b1019afb72bfdd84fc314ca3176 100644 (file)
@@ -2,27 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { CanReadIcon, CanManageIcon, CanWriteIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { CanReadIcon, CanManageIcon, CanWriteIcon } from 'components/icon/icon';
 import { editPermissionLevel } from 'store/group-details-panel/group-details-panel-actions';
-import { PermissionLevel } from "models/permission";
+import { PermissionLevel } from 'models/permission';
 
-export const permissionEditActionSet: ContextMenuActionSet = [[{
-    name: "Read",
-    icon: CanReadIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_READ));
-    }
-}, {
-    name: "Write",
-    icon: CanWriteIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_WRITE));
-    }
-}, {
-    name: "Manage",
-    icon: CanManageIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(editPermissionLevel(uuid, PermissionLevel.CAN_MANAGE));
-    }
-}]];
+export const permissionEditActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Read',
+            icon: CanReadIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_READ)));
+            },
+        },
+        {
+            name: 'Write',
+            icon: CanWriteIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_WRITE)));
+            },
+        },
+        {
+            name: 'Manage',
+            icon: CanManageIcon,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(editPermissionLevel(resource.uuid, PermissionLevel.CAN_MANAGE)));
+            },
+        },
+    ],
+];
index 7d593ee4b4f72978b1f5c7aa285d435c7df0cf6e..64b90ff45c5d84a57b7eb832831b5bf0667dee45 100644 (file)
@@ -6,114 +6,153 @@ import { ContextMenuActionSet } from "../context-menu-action-set";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "store/favorites/favorites-actions";
 import {
-    RenameIcon, ShareIcon, MoveToIcon, DetailsIcon,
-    RemoveIcon, ReRunProcessIcon, OutputIcon,
+    RenameIcon,
+    ShareIcon,
+    MoveToIcon,
+    DetailsIcon,
+    RemoveIcon,
+    ReRunProcessIcon,
+    OutputIcon,
     AdvancedIcon,
-    OpenIcon
+    OpenIcon,
+    StopIcon,
 } from "components/icon/icon";
 import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
-import { openMoveProcessDialog } from 'store/processes/process-move-actions';
+import { openMoveProcessDialog } from "store/processes/process-move-actions";
 import { openProcessUpdateDialog } from "store/processes/process-update-actions";
-import { openCopyProcessDialog } from 'store/processes/process-copy-actions';
+import { openCopyProcessDialog } from "store/processes/process-copy-actions";
 import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
 import { openRemoveProcessDialog } from "store/processes/processes-actions";
-import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
 import { navigateToOutput } from "store/process-panel/process-panel-actions";
 import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 import { TogglePublicFavoriteAction } from "../actions/public-favorite-action";
 import { togglePublicFavorite } from "store/public-favorites/public-favorites-actions";
 import { publicFavoritePanelActions } from "store/public-favorites-panel/public-favorites-action";
 import { openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+import { cancelRunningWorkflow } from "store/processes/processes-actions";
 
-export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
-    {
-        component: 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: ReRunProcessIcon,
-        name: "Copy and re-run process",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCopyProcessDialog(resource));
-        }
-    },
-    {
-        icon: OutputIcon,
-        name: "Outputs",
-        execute: (dispatch, resource) => {
-            if(resource.outputUuid){
-                dispatch<any>(navigateToOutput(resource.outputUuid));
-            }
-        }
-    },
-    {
-        icon: DetailsIcon,
-        name: "View details",
-        execute: dispatch => {
-            dispatch<any>(toggleDetailsPanel());
-        }
-    },
-    {
-        icon: AdvancedIcon,
-        name: "API Details",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
-        }
-    },
-]];
+export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            execute: (dispatch, resources) => {
+                dispatch<any>(toggleFavorite(resources[0])).then(() => {
+                    dispatch<any>(favoritePanelActions.REQUEST_ITEMS());
+                });
+            },
+        },
+        {
+            icon: OpenIcon,
+            name: "Open in new tab",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: ReRunProcessIcon,
+            name: "Copy and re-run process",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openCopyProcessDialog(resources[0]));
+            },
+        },
+        {
+            icon: OutputIcon,
+            name: "Outputs",
+            execute: (dispatch, resources) => {
+                if (resources[0].outputUuid) {
+                    dispatch<any>(navigateToOutput(resources[0].outputUuid));
+                }
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: "View details",
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: "API Details",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
 
-export const processResourceActionSet: ContextMenuActionSet = [[
-    ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []),
-    {
-        icon: RenameIcon,
-        name: "Edit process",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openProcessUpdateDialog(resource));
-        }
-    },
-    {
-        icon: ShareIcon,
-        name: "Share",
-        execute: (dispatch, { uuid }) => {
-            dispatch<any>(openSharingDialog(uuid));
-        }
-    },
-    {
-        icon: MoveToIcon,
-        name: "Move to",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openMoveProcessDialog(resource));
-        }
-    },
-    {
-        name: "Remove",
-        icon: RemoveIcon,
-        execute: (dispatch, resource) => {
-            dispatch<any>(openRemoveProcessDialog(resource.uuid));
-        }
-    }
-]];
+export const processResourceActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: RenameIcon,
+            name: "Edit process",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openProcessUpdateDialog(resources[0]));
+            },
+        },
+        {
+            icon: ShareIcon,
+            name: "Share",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSharingDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: MoveToIcon,
+            name: "Move to",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openMoveProcessDialog(resources[0]));
+            },
+        },
+        {
+            name: "Remove",
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveProcessDialog(resources[0], resources.length));
+            },
+        },
+    ],
+];
 
-export const processResourceAdminActionSet: ContextMenuActionSet = [[
-    ...processResourceActionSet.reduce((prev, next) => prev.concat(next), []),
-    {
-        component: TogglePublicFavoriteAction,
-        name: "Add to public favorites",
-        execute: (dispatch, resource) => {
-            dispatch<any>(togglePublicFavorite(resource)).then(() => {
-                dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
-            });
-        }
-    },
-]];
+const runningProcessOnlyActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: "CANCEL",
+            icon: StopIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(cancelRunningWorkflow(resources[0].uuid));
+            },
+        },
+    ]
+];
+
+export const processResourceAdminActionSet: ContextMenuActionSet = [
+    [
+        ...processResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            component: TogglePublicFavoriteAction,
+            name: "Add to public favorites",
+            execute: (dispatch, resources) => {
+                dispatch<any>(togglePublicFavorite(resources[0])).then(() => {
+                    dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+                });
+            },
+        },
+    ],
+];
+
+export const runningProcessResourceActionSet = [
+    [
+        ...processResourceActionSet.reduce((prev, next) => prev.concat(next), []),
+        ...runningProcessOnlyActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
+
+export const runningProcessResourceAdminActionSet: ContextMenuActionSet = [
+    [
+        ...processResourceAdminActionSet.reduce((prev, next) => prev.concat(next), []),
+        ...runningProcessOnlyActionSet.reduce((prev, next) => prev.concat(next), []),
+    ],
+];
index 8181045ca3346d1e378aeffedd2c014a90545a35..2706315179b718124d61bb0eaf3b1bb708c13607 100644 (file)
@@ -3,19 +3,19 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { ContextMenuActionSet } from "../context-menu-action-set";
-import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from 'components/icon/icon';
+import { NewProjectIcon, RenameIcon, MoveToIcon, DetailsIcon, AdvancedIcon, OpenIcon, Link, FolderSharedIcon } from "components/icon/icon";
 import { ToggleFavoriteAction } from "../actions/favorite-action";
 import { toggleFavorite } from "store/favorites/favorites-actions";
 import { favoritePanelActions } from "store/favorite-panel/favorite-panel-action";
-import { openMoveProjectDialog } from 'store/projects/project-move-actions';
-import { openProjectCreateDialog } from 'store/projects/project-create-actions';
-import { openProjectUpdateDialog } from 'store/projects/project-update-actions';
+import { openMoveProjectDialog } from "store/projects/project-move-actions";
+import { openProjectCreateDialog } from "store/projects/project-create-actions";
+import { openProjectUpdateDialog } from "store/projects/project-update-actions";
 import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
 import { toggleProjectTrashed } from "store/trash/trash-actions";
-import { ShareIcon } from 'components/icon/icon';
+import { ShareIcon } from "components/icon/icon";
 import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
 import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
-import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+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";
@@ -23,28 +23,28 @@ import { freezeProject, unfreezeProject } from "store/projects/project-lock-acti
 
 export const toggleFavoriteAction = {
     component: ToggleFavoriteAction,
-    name: 'ToggleFavoriteAction',
-    execute: (dispatch, resource) => {
-        dispatch(toggleFavorite(resource)).then(() => {
+    name: "ToggleFavoriteAction",
+    execute: (dispatch, resources) => {
+        dispatch(toggleFavorite(resources[0])).then(() => {
             dispatch(favoritePanelActions.REQUEST_ITEMS());
         });
-    }
+    },
 };
 
 export const openInNewTabMenuAction = {
     icon: OpenIcon,
     name: "Open in new tab",
-    execute: (dispatch, resource) => {
-        dispatch(openInNewTabAction(resource));
-    }
+    execute: (dispatch, resources) => {
+        dispatch(openInNewTabAction(resources[0]));
+    },
 };
 
 export const copyToClipboardMenuAction = {
     icon: Link,
     name: "Copy to clipboard",
-    execute: (dispatch, resource) => {
-        dispatch(copyToClipboardAction(resource));
-    }
+    execute: (dispatch, resources) => {
+        dispatch(copyToClipboardAction(resources));
+    },
 };
 
 export const viewDetailsAction = {
@@ -52,121 +52,122 @@ export const viewDetailsAction = {
     name: "View details",
     execute: dispatch => {
         dispatch(toggleDetailsPanel());
-    }
-}
+    },
+};
 
 export const advancedAction = {
     icon: AdvancedIcon,
     name: "API Details",
-    execute: (dispatch, resource) => {
-        dispatch(openAdvancedTabDialog(resource.uuid));
-    }
-}
+    execute: (dispatch, resources) => {
+        dispatch(openAdvancedTabDialog(resources[0].uuid));
+    },
+};
 
 export const openWith3rdPartyClientAction = {
     icon: FolderSharedIcon,
     name: "Open with 3rd party client",
-    execute: (dispatch, resource) => {
-        dispatch(openWebDavS3InfoDialog(resource.uuid));
-    }
-}
+    execute: (dispatch, resources) => {
+        dispatch(openWebDavS3InfoDialog(resources[0].uuid));
+    },
+};
 
 export const editProjectAction = {
     icon: RenameIcon,
     name: "Edit project",
-    execute: (dispatch, resource) => {
-        dispatch(openProjectUpdateDialog(resource));
-    }
-}
+    execute: (dispatch, resources) => {
+        dispatch(openProjectUpdateDialog(resources[0]));
+    },
+};
 
 export const shareAction = {
     icon: ShareIcon,
     name: "Share",
-    execute: (dispatch, { uuid }) => {
-        dispatch(openSharingDialog(uuid));
-    }
-}
+    execute: (dispatch, resources) => {
+        dispatch(openSharingDialog(resources[0].uuid));
+    },
+};
 
 export const moveToAction = {
     icon: MoveToIcon,
     name: "Move to",
     execute: (dispatch, resource) => {
-        dispatch(openMoveProjectDialog(resource));
-    }
-}
+        dispatch(openMoveProjectDialog(resource[0]));
+    },
+};
 
 export const toggleTrashAction = {
     component: ToggleTrashAction,
-    name: 'ToggleTrashAction',
-    execute: (dispatch, resource) => {
-        dispatch(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!));
-    }
-}
+    name: "ToggleTrashAction",
+    execute: (dispatch, resources) => {
+        dispatch(toggleProjectTrashed(resources[0].uuid, resources[0].ownerUuid, resources[0].isTrashed!!, resources.length > 1));
+    },
+};
 
 export const freezeProjectAction = {
     component: ToggleLockAction,
-    name: 'ToggleLockAction',
-    execute: (dispatch, resource) => {
-        if (resource.isFrozen) {
-            dispatch(unfreezeProject(resource.uuid));
+    name: "ToggleLockAction",
+    execute: (dispatch, resources) => {
+        if (resources[0].isFrozen) {
+            dispatch(unfreezeProject(resources[0].uuid));
         } else {
-            dispatch(freezeProject(resource.uuid));
+            dispatch(freezeProject(resources[0].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 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,
-]];
+    },
+};
+
+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 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 3faf675d94259f762e22d823201d88d15f0c63c3..490bf3e30a9e649f85165a988751aff4357be40f 100644 (file)
@@ -7,56 +7,75 @@ 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 { 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 = [[
+import {
+    shareAction,
     toggleFavoriteAction,
     openInNewTabMenuAction,
     copyToClipboardMenuAction,
     viewDetailsAction,
     advancedAction,
     openWith3rdPartyClientAction,
+    freezeProjectAction,
     editProjectAction,
-    shareAction,
     moveToAction,
     toggleTrashAction,
     newProjectAction,
-    freezeProjectAction,
-    togglePublicFavoriteAction
-]];
+} from "views-components/context-menu/action-sets/project-action-set";
 
-export const filterGroupAdminActionSet: ContextMenuActionSet = [[
-    toggleFavoriteAction,
-    openInNewTabMenuAction,
-    copyToClipboardMenuAction,
-    viewDetailsAction,
-    advancedAction,
-    openWith3rdPartyClientAction,
-    editProjectAction,
-    shareAction,
-    moveToAction,
-    toggleTrashAction,
-    togglePublicFavoriteAction
-]];
+export const togglePublicFavoriteAction = {
+    component: TogglePublicFavoriteAction,
+    name: "TogglePublicFavoriteAction",
+    execute: (dispatch, resources) => {
+        dispatch(togglePublicFavorite(resources[0])).then(() => {
+            dispatch(publicFavoritePanelActions.REQUEST_ITEMS());
+        });
+    },
+};
 
+export const projectAdminActionSet: ContextMenuActionSet = [
+    [
+        toggleFavoriteAction,
+        openInNewTabMenuAction,
+        copyToClipboardMenuAction,
+        viewDetailsAction,
+        advancedAction,
+        openWith3rdPartyClientAction,
+        editProjectAction,
+        shareAction,
+        moveToAction,
+        toggleTrashAction,
+        newProjectAction,
+        freezeProjectAction,
+        togglePublicFavoriteAction,
+    ],
+];
 
-export const frozenAdminActionSet: ContextMenuActionSet = [[
-    shareAction,
-    togglePublicFavoriteAction,
-    toggleFavoriteAction,
-    openInNewTabMenuAction,
-    copyToClipboardMenuAction,
-    viewDetailsAction,
-    advancedAction,
-    openWith3rdPartyClientAction,
-    freezeProjectAction
-]];
+export const filterGroupAdminActionSet: ContextMenuActionSet = [
+    [
+        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 12fec7c4024611b82ace644afc27ea7101f44a7a..cbdcd004288780cbdbd3c5cfe2e41449d966fae5 100644 (file)
@@ -2,34 +2,41 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, ShareIcon, AttributesIcon } from "components/icon/icon";
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
-import { openRepositoryAttributes, openRemoveRepositoryDialog } from "store/repositories/repositories-actions";
-import { openSharingDialog } from "store/sharing-dialog/sharing-dialog-actions";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, ShareIcon, AttributesIcon } from 'components/icon/icon';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
+import { openRepositoryAttributes, openRemoveRepositoryDialog } from 'store/repositories/repositories-actions';
+import { openSharingDialog } from 'store/sharing-dialog/sharing-dialog-actions';
 
-export const repositoryActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRepositoryAttributes(uuid));
-    }
-}, {
-    name: "Share",
-    icon: ShareIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openSharingDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, resource) => {
-        dispatch<any>(openAdvancedTabDialog(resource.uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRemoveRepositoryDialog(uuid));
-    }
-}]];
+export const repositoryActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRepositoryAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Share',
+            icon: ShareIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openSharingDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openRemoveRepositoryDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index ea8c53c5e59ae1fb4d56983ca99e8c933b8f0628..401e9634d93c3db56533f4450298f6e7f9acb380 100644 (file)
@@ -2,13 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
-import { ToggleFavoriteAction } from "../actions/favorite-action";
-import { toggleFavorite } from "store/favorites/favorites-actions";
+import { ContextMenuActionSet } from '../context-menu-action-set';
+import { ToggleFavoriteAction } from '../actions/favorite-action';
+import { toggleFavorite } from 'store/favorites/favorites-actions';
 
-export const resourceActionSet: ContextMenuActionSet = [[{
-    component: ToggleFavoriteAction,
-    execute: (dispatch, resource) => {
-        dispatch<any>(toggleFavorite(resource));
-    }
-}]];
+export const resourceActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleFavoriteAction,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleFavorite(resource)));
+            },
+        },
+    ],
+];
index 9cf5bf031a4718943e60a7f2e6943ade98e87b23..a779d1eb2967877c766d7166b63ae00697a4bfb4 100644 (file)
@@ -2,24 +2,26 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ContextMenuActionSet } from '../context-menu-action-set';
 import { openCollectionCreateDialog } from 'store/collections/collection-create-actions';
-import { NewProjectIcon, CollectionIcon } from "components/icon/icon";
+import { NewProjectIcon, CollectionIcon } from 'components/icon/icon';
 import { openProjectCreateDialog } from 'store/projects/project-create-actions';
 
-export const rootProjectActionSet: ContextMenuActionSet =  [[
-    {
-        icon: NewProjectIcon,
-        name: "New project",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openProjectCreateDialog(resource.uuid));
-        }
-    },
-    {
-        icon: CollectionIcon,
-        name: "New Collection",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openCollectionCreateDialog(resource.uuid));
-        }
-    }
-]];
+export const rootProjectActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: NewProjectIcon,
+            name: 'New project',
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openProjectCreateDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: CollectionIcon,
+            name: 'New Collection',
+            execute: (dispatch, resources) => {
+                 dispatch<any>(openCollectionCreateDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index aeb6d15501f782e20c29d22ac65b56cfb5f37a8c..dcc9eae20700160c3fdd3bf72224754b9bdded81 100644 (file)
@@ -2,41 +2,41 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ContextMenuActionSet } from '../context-menu-action-set';
 import { DetailsIcon, AdvancedIcon, OpenIcon, Link } from 'components/icon/icon';
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+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 { copyToClipboardAction, openInNewTabAction } from 'store/open-in-new-tab/open-in-new-tab.actions';
 
 export const searchResultsActionSet: ContextMenuActionSet = [
     [
         {
             icon: OpenIcon,
-            name: "Open in new tab",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openInNewTabAction(resource));
-            }
+            name: 'Open in new tab',
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(openInNewTabAction(resource)));
+            },
         },
         {
             icon: Link,
-            name: "Copy to clipboard",
-            execute: (dispatch, resource) => {
-                dispatch<any>(copyToClipboardAction(resource));
-            }
+            name: 'Copy to clipboard',
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
         },
         {
             icon: DetailsIcon,
-            name: "View details",
-            execute: dispatch => {
+            name: 'View details',
+            execute: (dispatch) => {
                 dispatch<any>(toggleDetailsPanel());
-            }
+            },
         },
         {
             icon: AdvancedIcon,
-            name: "API Details",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openAdvancedTabDialog(resource.uuid));
-            }
+            name: 'API Details',
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
         },
-    ]
+    ],
 ];
index d1a94cd311318e76cdbf0bfd9f61fada1a549838..c31e1681a4f88bd861c12ae6158837db982ccdbb 100644 (file)
@@ -2,27 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
 import { openSshKeyRemoveDialog, openSshKeyAttributesDialog } from 'store/auth/auth-action-ssh';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
 
-export const sshKeyActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openSshKeyAttributesDialog(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openSshKeyRemoveDialog(uuid));
-    }
-}]];
+export const sshKeyActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSshKeyAttributesDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSshKeyRemoveDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index c0afd36af98b234f6872027e027a9143301d3716..82e00df6cbbb9fbf26229f012370f8dba568c242 100644 (file)
@@ -2,15 +2,17 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
-import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
-import { toggleTrashed } from "store/trash/trash-actions";
+import { ContextMenuActionSet } from '../context-menu-action-set';
+import { ToggleTrashAction } from 'views-components/context-menu/actions/trash-action';
+import { toggleTrashed } from 'store/trash/trash-actions';
 
-export const trashActionSet: ContextMenuActionSet = [[
-    {
-        component: ToggleTrashAction,
-        execute: (dispatch, resource) => {
-            dispatch<any>(toggleTrashed(resource.kind, resource.uuid, resource.ownerUuid, resource.isTrashed!!));
-        }
-    },
-]];
+export const trashActionSet: ContextMenuActionSet = [
+    [
+        {
+            component: ToggleTrashAction,
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleTrashed(resource.kind, resource.uuid, resource.ownerUuid, resource.isTrashed!!)));
+            },
+        },
+    ],
+];
index 020ff5c747487c776ba11a040419853d656e9931..3e8f0cb647e38e8f73b35e33faf562ccc9caa155 100644 (file)
@@ -2,39 +2,41 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "../context-menu-action-set";
+import { ContextMenuActionSet } from '../context-menu-action-set';
 import { DetailsIcon, ProvenanceGraphIcon, AdvancedIcon, RestoreFromTrashIcon } from 'components/icon/icon';
-import { toggleCollectionTrashed } from "store/trash/trash-actions";
-import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
+import { toggleCollectionTrashed } from 'store/trash/trash-actions';
+import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
 import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
 
-export const trashedCollectionActionSet: ContextMenuActionSet = [[
-    {
-        icon: DetailsIcon,
-        name: "View details",
-        execute: dispatch => {
-            dispatch<any>(toggleDetailsPanel());
-        }
-    },
-    {
-        icon: ProvenanceGraphIcon,
-        name: "Provenance graph",
-        execute: (dispatch, resource) => {
-            // add code
-        }
-    },
-    {
-        icon: AdvancedIcon,
-        name: "API Details",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
-        }
-    },
-    {
-        icon: RestoreFromTrashIcon,
-        name: "Restore",
-        execute: (dispatch, resource) => {
-            dispatch<any>(toggleCollectionTrashed(resource.uuid, true));
-        }
-    },
-]];
+export const trashedCollectionActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: DetailsIcon,
+            name: 'View details',
+            execute: (dispatch) => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: ProvenanceGraphIcon,
+            name: 'Provenance graph',
+            execute: (dispatch, resource) => {
+                // add code
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: 'API Details',
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: RestoreFromTrashIcon,
+            name: 'Restore',
+            execute: (dispatch, resources) => {
+                resources.forEach((resource) => dispatch<any>(toggleCollectionTrashed(resource.uuid, true)));
+            },
+        },
+    ],
+];
index c00b7f1f285c157bd95e2d2ef5e386a0e94fb7d8..0108ff7e50ec1a3cf164ba77019449b1039a0fb2 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
 import {
     AdvancedIcon,
     ProjectIcon,
@@ -12,76 +12,84 @@ import {
     LoginAsIcon,
     AdminMenuIcon,
     ActiveIcon,
-} from "components/icon/icon";
+} from 'components/icon/icon';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { loginAs, openUserAttributes, openUserProjects } from "store/users/users-actions";
-import { openSetupDialog, openDeactivateDialog, openActivateDialog } from "store/user-profile/user-profile-actions";
-import { navigateToUserProfile } from "store/navigation/navigation-action";
-import { canActivateUser, canDeactivateUser, canSetupUser, isAdmin, needsUserProfileLink, isOtherUser } from "store/context-menu/context-menu-filters";
+import { loginAs, openUserAttributes, openUserProjects } from 'store/users/users-actions';
+import { openSetupDialog, openDeactivateDialog, openActivateDialog } from 'store/user-profile/user-profile-actions';
+import { navigateToUserProfile } from 'store/navigation/navigation-action';
+import {
+    canActivateUser,
+    canDeactivateUser,
+    canSetupUser,
+    isAdmin,
+    needsUserProfileLink,
+    isOtherUser,
+} from 'store/context-menu/context-menu-filters';
 
-export const userActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openUserAttributes(uuid));
-    }
-}, {
-    name: "Project",
-    icon: ProjectIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openUserProjects(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Account Settings",
-    icon: UserPanelIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(navigateToUserProfile(uuid));
-    },
-    filters: [needsUserProfileLink]
-}],[{
-    name: "Activate User",
-    icon: ActiveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openActivateDialog(uuid));
-    },
-    filters: [
-        isAdmin,
-        canActivateUser,
-    ],
-}, {
-    name: "Setup User",
-    icon: AdminMenuIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openSetupDialog(uuid));
-    },
-    filters: [
-        isAdmin,
-        canSetupUser,
-    ],
-}, {
-    name: "Deactivate User",
-    icon: DeactivateUserIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openDeactivateDialog(uuid));
-    },
-    filters: [
-        isAdmin,
-        canDeactivateUser,
+export const userActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openUserAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Project',
+            icon: ProjectIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openUserProjects(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Account Settings',
+            icon: UserPanelIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(navigateToUserProfile(resources[0].uuid));
+            },
+            filters: [needsUserProfileLink],
+        },
     ],
-}, {
-    name: "Login As User",
-    icon: LoginAsIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(loginAs(uuid));
-    },
-    filters: [
-        isAdmin,
-        isOtherUser,
+    [
+        {
+            name: 'Activate User',
+            icon: ActiveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openActivateDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canActivateUser],
+        },
+        {
+            name: 'Setup User',
+            icon: AdminMenuIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openSetupDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canSetupUser],
+        },
+        {
+            name: 'Deactivate User',
+            icon: DeactivateUserIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openDeactivateDialog(resources[0].uuid));
+            },
+            filters: [isAdmin, canDeactivateUser],
+        },
+        {
+            name: 'Login As User',
+            icon: LoginAsIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(loginAs(resources[0].uuid));
+            },
+            filters: [isAdmin, isOtherUser],
+        },
     ],
-}]];
+];
index be9567cd035a2a244352f392d955ea915b61e3a2..a26cbe1368d4aa0c8b435af7db26f65b8c138b19 100644 (file)
@@ -2,27 +2,33 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
-import { AdvancedIcon, RemoveIcon, AttributesIcon } from "components/icon/icon";
+import { ContextMenuActionSet } from 'views-components/context-menu/context-menu-action-set';
+import { AdvancedIcon, RemoveIcon, AttributesIcon } from 'components/icon/icon';
 import { openAdvancedTabDialog } from 'store/advanced-tab/advanced-tab';
-import { openVirtualMachineAttributes, openRemoveVirtualMachineDialog } from "store/virtual-machines/virtual-machines-actions";
+import { openVirtualMachineAttributes, openRemoveVirtualMachineDialog } from 'store/virtual-machines/virtual-machines-actions';
 
-export const virtualMachineActionSet: ContextMenuActionSet = [[{
-    name: "Attributes",
-    icon: AttributesIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openVirtualMachineAttributes(uuid));
-    }
-}, {
-    name: "API Details",
-    icon: AdvancedIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openAdvancedTabDialog(uuid));
-    }
-}, {
-    name: "Remove",
-    icon: RemoveIcon,
-    execute: (dispatch, { uuid }) => {
-        dispatch<any>(openRemoveVirtualMachineDialog(uuid));
-    }
-}]];
+export const virtualMachineActionSet: ContextMenuActionSet = [
+    [
+        {
+            name: 'Attributes',
+            icon: AttributesIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openVirtualMachineAttributes(resources[0].uuid));
+            },
+        },
+        {
+            name: 'API Details',
+            icon: AdvancedIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            name: 'Remove',
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveVirtualMachineDialog(resources[0].uuid));
+            },
+        },
+    ],
+];
index 1baf04228ce225fcc3b3c85d45d5259dd72ddab7..4a1460bfc94f81552283595f7d31ffd08d97517a 100644 (file)
@@ -4,63 +4,60 @@
 
 import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
 import { openRunProcess, deleteWorkflow } from "store/workflow-panel/workflow-panel-actions";
-import {
-    DetailsIcon,
-    AdvancedIcon,
-    OpenIcon,
-    Link,
-    StartIcon,
-    TrashIcon
-} from "components/icon/icon";
+import { DetailsIcon, AdvancedIcon, OpenIcon, Link, StartIcon, TrashIcon } from "components/icon/icon";
 import { copyToClipboardAction, openInNewTabAction } from "store/open-in-new-tab/open-in-new-tab.actions";
-import { toggleDetailsPanel } from 'store/details-panel/details-panel-action';
+import { toggleDetailsPanel } from "store/details-panel/details-panel-action";
 import { openAdvancedTabDialog } from "store/advanced-tab/advanced-tab";
 
-export const readOnlyWorkflowActionSet: ContextMenuActionSet = [[
-    {
-        icon: OpenIcon,
-        name: "Open in new tab",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openInNewTabAction(resource));
-        }
-    },
-    {
-        icon: Link,
-        name: "Copy to clipboard",
-        execute: (dispatch, resource) => {
-            dispatch<any>(copyToClipboardAction(resource));
-        }
-    },
-    {
-        icon: DetailsIcon,
-        name: "View details",
-        execute: dispatch => {
-            dispatch<any>(toggleDetailsPanel());
-        }
-    },
-    {
-        icon: AdvancedIcon,
-        name: "API Details",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openAdvancedTabDialog(resource.uuid));
-        }
-    },
-    {
-        icon: StartIcon,
-        name: "Run Workflow",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openRunProcess(resource.uuid, resource.ownerUuid, resource.name));
-        }
-    }
-]];
+export const readOnlyWorkflowActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: OpenIcon,
+            name: "Open in new tab",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openInNewTabAction(resources[0]));
+            },
+        },
+        {
+            icon: Link,
+            name: "Copy to clipboard",
+            execute: (dispatch, resources) => {
+                dispatch<any>(copyToClipboardAction(resources));
+            },
+        },
+        {
+            icon: DetailsIcon,
+            name: "View details",
+            execute: dispatch => {
+                dispatch<any>(toggleDetailsPanel());
+            },
+        },
+        {
+            icon: AdvancedIcon,
+            name: "API Details",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openAdvancedTabDialog(resources[0].uuid));
+            },
+        },
+        {
+            icon: StartIcon,
+            name: "Run Workflow",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRunProcess(resources[0].uuid, resources[0].ownerUuid, resources[0].name));
+            },
+        },
+    ],
+];
 
-export const workflowActionSet: ContextMenuActionSet = [[
-    ...readOnlyWorkflowActionSet[0],
-    {
-        icon: TrashIcon,
-        name: "Delete Workflow",
-        execute: (dispatch, resource) => {
-            dispatch<any>(deleteWorkflow(resource.uuid, resource.ownerUuid));
-        }
-    },
-]];
+export const workflowActionSet: ContextMenuActionSet = [
+    [
+        ...readOnlyWorkflowActionSet[0],
+        {
+            icon: TrashIcon,
+            name: "Delete Workflow",
+            execute: (dispatch, resources) => {
+                dispatch<any>(deleteWorkflow(resources[0].uuid, resources[0].ownerUuid));
+            },
+        },
+    ],
+];
index abef7ec0d47e711fa48adb109f4d5b60e629fffb..a953500b3ae7a49f9216bce9544b24b3771a9982 100644 (file)
@@ -5,10 +5,9 @@
 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, state?: any): void;
+    execute(dispatch: Dispatch, resources: ContextMenuResource[], state?: any): void;
 }
 
 export type ContextMenuActionSet = Array<Array<ContextMenuAction>>;
index 1b4610eff6e5fdfe1359569edf0dcd210234335a..2a5cccc0a549231d2860ec93bb5beec9d5435c1e 100644 (file)
@@ -9,26 +9,30 @@ import { ContextMenu as ContextMenuComponent, ContextMenuProps, ContextMenuItem
 import { createAnchorAt } from "components/popover/helpers";
 import { ContextMenuActionSet, ContextMenuAction } from "./context-menu-action-set";
 import { Dispatch } from "redux";
-import { memoize } from 'lodash';
+import { memoize } from "lodash";
 import { sortByProperty } from "common/array-utils";
+
 type DataProps = Pick<ContextMenuProps, "anchorEl" | "items" | "open"> & { resource?: ContextMenuResource };
+
 const mapStateToProps = (state: RootState): DataProps => {
     const { open, position, resource } = state.contextMenu;
 
-    const filteredItems = getMenuActionSet(resource).map((group) => (group.filter((item) => {
-        if (resource && item.filters) {
-            // Execute all filters on this item, every returns true IFF all filters return true
-            return item.filters.every((filter) => filter(state, resource));
-        } else {
-            return true;
-        }
-    })));
+    const filteredItems = getMenuActionSet(resource).map(group =>
+        group.filter(item => {
+            if (resource && item.filters) {
+                // Execute all filters on this item, every returns true IFF all filters return true
+                return item.filters.every(filter => filter(state, resource));
+            } else {
+                return true;
+            }
+        })
+    );
 
     return {
         anchorEl: resource ? createAnchorAt(position) : undefined,
         items: filteredItems,
         open,
-        resource
+        resource,
     };
 };
 
@@ -40,47 +44,45 @@ const mapDispatchToProps = (dispatch: Dispatch): ActionProps => ({
     onItemClick: (action: ContextMenuAction, resource?: ContextMenuResource) => {
         dispatch(contextMenuActions.CLOSE_CONTEXT_MENU());
         if (resource) {
-            action.execute(dispatch, resource);
+            action.execute(dispatch, [resource]);
         }
-    }
+    },
 });
 
 const handleItemClick = memoize(
-    (resource: DataProps['resource'], onItemClick: ActionProps['onItemClick']): ContextMenuProps['onItemClick'] =>
+    (resource: DataProps["resource"], onItemClick: ActionProps["onItemClick"]): ContextMenuProps["onItemClick"] =>
         item => {
-            onItemClick(item, resource);
+            onItemClick(item, { ...resource, fromContextMenu: true } as ContextMenuResource);
         }
 );
 
 const mergeProps = ({ resource, ...dataProps }: DataProps, actionProps: ActionProps): ContextMenuProps => ({
     ...dataProps,
     ...actionProps,
-    onItemClick: handleItemClick(resource, actionProps.onItemClick)
+    onItemClick: handleItemClick(resource, actionProps.onItemClick),
 });
 
-
 export const ContextMenu = connect(mapStateToProps, mapDispatchToProps, mergeProps)(ContextMenuComponent);
 
 const menuActionSets = new Map<string, ContextMenuActionSet>();
 
 export const addMenuActionSet = (name: string, itemSet: ContextMenuActionSet) => {
-    const sorted = itemSet.map(items => items.sort(sortByProperty('name')));
+    const sorted = itemSet.map(items => items.sort(sortByProperty("name")));
     menuActionSets.set(name, sorted);
 };
 
 const emptyActionSet: ContextMenuActionSet = [];
-const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet => (
-    resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet
-);
+const getMenuActionSet = (resource?: ContextMenuResource): ContextMenuActionSet =>
+    resource ? menuActionSets.get(resource.menuKind) || emptyActionSet : emptyActionSet;
 
 export enum ContextMenuKind {
     API_CLIENT_AUTHORIZATION = "ApiClientAuthorization",
     ROOT_PROJECT = "RootProject",
     PROJECT = "Project",
     FILTER_GROUP = "FilterGroup",
-    READONLY_PROJECT = 'ReadOnlyProject',
-    FROZEN_PROJECT = 'FrozenProject',
-    FROZEN_PROJECT_ADMIN = 'FrozenProjectAdmin',
+    READONLY_PROJECT = "ReadOnlyProject",
+    FROZEN_PROJECT = "FrozenProject",
+    FROZEN_PROJECT_ADMIN = "FrozenProjectAdmin",
     PROJECT_ADMIN = "ProjectAdmin",
     FILTER_GROUP_ADMIN = "FilterGroupAdmin",
     RESOURCE = "Resource",
@@ -95,15 +97,17 @@ export enum ContextMenuKind {
     COLLECTION_DIRECTORY_ITEM = "CollectionDirectoryItem",
     READONLY_COLLECTION_FILE_ITEM = "ReadOnlyCollectionFileItem",
     READONLY_COLLECTION_DIRECTORY_ITEM = "ReadOnlyCollectionDirectoryItem",
-    COLLECTION = 'Collection',
-    COLLECTION_ADMIN = 'CollectionAdmin',
-    READONLY_COLLECTION = 'ReadOnlyCollection',
-    OLD_VERSION_COLLECTION = 'OldVersionCollection',
-    TRASHED_COLLECTION = 'TrashedCollection',
+    COLLECTION = "Collection",
+    COLLECTION_ADMIN = "CollectionAdmin",
+    READONLY_COLLECTION = "ReadOnlyCollection",
+    OLD_VERSION_COLLECTION = "OldVersionCollection",
+    TRASHED_COLLECTION = "TrashedCollection",
     PROCESS = "Process",
-    PROCESS_ADMIN = 'ProcessAdmin',
-    PROCESS_RESOURCE = 'ProcessResource',
-    READONLY_PROCESS_RESOURCE = 'ReadOnlyProcessResource',
+    RUNNING_PROCESS_ADMIN = "RunningProcessAdmin",
+    PROCESS_ADMIN = "ProcessAdmin",
+    RUNNING_PROCESS_RESOURCE = "RunningProcessResource",
+    PROCESS_RESOURCE = "ProcessResource",
+    READONLY_PROCESS_RESOURCE = "ReadOnlyProcessResource",
     PROCESS_LOGS = "ProcessLogs",
     REPOSITORY = "Repository",
     SSH_KEY = "SshKey",
@@ -116,5 +120,5 @@ export enum ContextMenuKind {
     LINK = "Link",
     WORKFLOW = "Workflow",
     READONLY_WORKFLOW = "ReadOnlyWorkflow",
-    SEARCH_RESULTS = "SearchResults"
+    SEARCH_RESULTS = "SearchResults",
 }
index 59c389ac573cbff0b90634aec8777a1ef9d4cf80..d5a9977a71b4caad93d464d43d580ca457399e6c 100644 (file)
@@ -9,9 +9,10 @@ import { getDataExplorer } from "store/data-explorer/data-explorer-reducer";
 import { Dispatch } from "redux";
 import { dataExplorerActions } from "store/data-explorer/data-explorer-action";
 import { DataColumn } from "components/data-table/data-column";
-import { DataColumns } from "components/data-table/data-table";
-import { DataTableFilters } from 'components/data-table-filters/data-table-filters-tree';
+import { DataColumns, TCheckedList } from "components/data-table/data-table";
+import { DataTableFilters } from "components/data-table-filters/data-table-filters-tree";
 import { LAST_REFRESH_TIMESTAMP } from "components/refresh-button/refresh-button";
+import { toggleMSToolbar, setCheckedListOnStore } from "store/multiselect/multiselect-actions";
 
 interface Props {
     id: string;
@@ -24,9 +25,10 @@ interface Props {
 const mapStateToProps = (state: RootState, { id }: Props) => {
     const progress = state.progressIndicator.find(p => p.id === id);
     const dataExplorerState = getDataExplorer(state.dataExplorer, id);
-    const currentRoute = state.router.location ? state.router.location.pathname : '';
-    const currentRefresh = localStorage.getItem(LAST_REFRESH_TIMESTAMP) || '';
-    const currentItemUuid = currentRoute === '/workflows' ? state.properties.workflowPanelDetailsUuid : state.detailsPanel.resourceUuid;
+    const currentRoute = state.router.location ? state.router.location.pathname : "";
+    const currentRefresh = localStorage.getItem(LAST_REFRESH_TIMESTAMP) || "";
+    const currentItemUuid = currentRoute === "/workflows" ? state.properties.workflowPanelDetailsUuid : state.detailsPanel.resourceUuid;
+    const isMSToolbarVisible = state.multiselect.isVisible;
     return {
         ...dataExplorerState,
         working: !!progress?.working,
@@ -34,10 +36,12 @@ const mapStateToProps = (state: RootState, { id }: Props) => {
         currentRoute: currentRoute,
         paperKey: currentRoute,
         currentItemUuid,
+        isMSToolbarVisible,
+        checkedList: state.multiselect.checkedList,
     };
 };
 
-const mapDispatchToProps = () => {
+const mapDispatchToProps = dispatchFn => {
     return (dispatch: Dispatch, { id, onRowClick, onRowDoubleClick, onContextMenu }: Props) => ({
         onSetColumns: (columns: DataColumns<any, any>) => {
             dispatch(dataExplorerActions.SET_COLUMNS({ id, columns }));
@@ -71,6 +75,14 @@ const mapDispatchToProps = () => {
             dispatch(dataExplorerActions.SET_PAGE({ id, page }));
         },
 
+        toggleMSToolbar: (isVisible: boolean) => {
+            dispatch<any>(toggleMSToolbar(isVisible));
+        },
+
+        setCheckedListOnStore: (checkedList: TCheckedList) => {
+            dispatch<any>(setCheckedListOnStore(checkedList));
+        },
+
         onRowClick,
 
         onRowDoubleClick,
index a3e301195493be96d99b9b0c3c96648b2b6828e8..71d0dab34b14645345d91a8b925311de0363d34d 100644 (file)
@@ -3,37 +3,62 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from "react";
-import { memoize } from 'lodash/fp';
-import { InjectedFormProps, Field } from 'redux-form';
-import { WithDialogProps } from 'store/dialog/with-dialog';
-import { FormDialog } from 'components/form-dialog/form-dialog';
-import { ProjectTreePickerField } from 'views-components/projects-tree-picker/tree-picker-field';
-import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from 'validators/validators';
+import { memoize } from "lodash/fp";
+import { InjectedFormProps, Field } from "redux-form";
+import { WithDialogProps } from "store/dialog/with-dialog";
+import { FormDialog } from "components/form-dialog/form-dialog";
+import { ProjectTreePickerField } from "views-components/projects-tree-picker/tree-picker-field";
+import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from "validators/validators";
 import { TextField } from "components/text-field/text-field";
-import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
-import { PickerIdProp } from 'store/tree-picker/picker-id';
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { PickerIdProp } from "store/tree-picker/picker-id";
 
 type CopyFormDialogProps = WithDialogProps<string> & InjectedFormProps<CopyFormDialogData>;
 
-export const DialogCopy = (props: CopyFormDialogProps & PickerIdProp) =>
-    <FormDialog
-        dialogTitle='Make a copy'
-        formFields={CopyDialogFields(props.pickerId)}
-        submitLabel='Copy'
-        {...props}
-    />;
+export const DialogCopy = (props: CopyFormDialogProps & PickerIdProp) => {
+    return (
+        <FormDialog
+            dialogTitle="Make a copy"
+            formFields={CopyDialogFields(props.pickerId)}
+            submitLabel="Copy"
+            {...props}
+        />
+    );
+};
 
-const CopyDialogFields = memoize((pickerId: string) =>
-    () =>
-        <>
-            <Field
-                name='name'
-                component={TextField as any}
-                validate={COPY_NAME_VALIDATION}
-                label="Enter a new name for the copy" />
-            <Field
-                name="ownerUuid"
-                component={ProjectTreePickerField}
-                validate={COPY_FILE_VALIDATION}
-                pickerId={pickerId}/>
-        </>);
+const CopyDialogFields = memoize((pickerId: string) => () => (
+    <>
+        <Field
+            name="name"
+            component={TextField as any}
+            validate={COPY_NAME_VALIDATION}
+            label="Enter a new name for the copy"
+        />
+        <Field
+            name="ownerUuid"
+            component={ProjectTreePickerField}
+            validate={COPY_FILE_VALIDATION}
+            pickerId={pickerId}
+        />
+    </>
+));
+
+export const DialogMultiCopy = (props: CopyFormDialogProps & PickerIdProp) => {
+    return (
+        <FormDialog
+            dialogTitle="Make Copies"
+            formFields={CopyMultiDialogFields(props.pickerId)}
+            submitLabel="Copy"
+            {...props}
+        />
+    );
+};
+
+const CopyMultiDialogFields = memoize((pickerId: string) => () => (
+    <Field
+        name="ownerUuid"
+        component={ProjectTreePickerField}
+        validate={COPY_FILE_VALIDATION}
+        pickerId={pickerId}
+    />
+));
index 9f97b1accd8cca463ebc06cb67c90bd518135408..a5d8f3a0f8abde8587f4ff3665c2addd5cb332c7 100644 (file)
@@ -2,38 +2,26 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from "react";
+import React from 'react';
 import { memoize } from 'lodash/fp';
 import { InjectedFormProps, Field } from 'redux-form';
 import { WithDialogProps } from 'store/dialog/with-dialog';
 import { FormDialog } from 'components/form-dialog/form-dialog';
 import { ProjectTreePickerField } from 'views-components/projects-tree-picker/tree-picker-field';
 import { COPY_NAME_VALIDATION, COPY_FILE_VALIDATION } from 'validators/validators';
-import { TextField } from "components/text-field/text-field";
+import { TextField } from 'components/text-field/text-field';
 import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
 import { PickerIdProp } from 'store/tree-picker/picker-id';
 
 type ProcessRerunFormDialogProps = WithDialogProps<string> & InjectedFormProps<CopyFormDialogData>;
 
-export const DialogProcessRerun = (props: ProcessRerunFormDialogProps & PickerIdProp) =>
-    <FormDialog
-        dialogTitle='Choose location for re-run'
-        formFields={CopyDialogFields(props.pickerId)}
-        submitLabel='Copy'
-        {...props}
-    />;
+export const DialogProcessRerun = (props: ProcessRerunFormDialogProps & PickerIdProp) => (
+    <FormDialog dialogTitle='Choose location for re-run' formFields={CopyDialogFields(props.pickerId)} submitLabel='Copy' {...props} />
+);
 
-const CopyDialogFields = memoize((pickerId: string) =>
-    () =>
-        <>
-            <Field
-                name='name'
-                component={TextField as any}
-                validate={COPY_NAME_VALIDATION}
-                label="Enter a new name for the copy" />
-            <Field
-                name="ownerUuid"
-                component={ProjectTreePickerField}
-                validate={COPY_FILE_VALIDATION}
-                pickerId={pickerId}/>
-        </>);
+const CopyDialogFields = memoize((pickerId: string) => () => (
+    <>
+        <Field name='name' component={TextField as any} validate={COPY_NAME_VALIDATION} label='Enter a new name for the copy' />
+        <Field name='ownerUuid' component={ProjectTreePickerField} validate={COPY_FILE_VALIDATION} pickerId={pickerId} />
+    </>
+));
index a1c822cf1f6a9d9e741b0788450d0a2f617b3f4a..220b5a2c4d6d97a743c8ffd11fcfe05029f74e6f 100644 (file)
@@ -4,12 +4,12 @@
 
 import { compose } from "redux";
 import { withDialog } from "store/dialog/with-dialog";
-import { reduxForm } from 'redux-form';
-import { COLLECTION_COPY_FORM_NAME } from 'store/collections/collection-copy-actions';
-import { DialogCopy } from "views-components/dialog-copy/dialog-copy";
-import { copyCollection } from 'store/workbench/workbench-actions';
-import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
-import { pickerId } from 'store/tree-picker/picker-id';
+import { reduxForm } from "redux-form";
+import { COLLECTION_COPY_FORM_NAME, COLLECTION_MULTI_COPY_FORM_NAME } from "store/collections/collection-copy-actions";
+import { DialogCopy, DialogMultiCopy } from "views-components/dialog-copy/dialog-copy";
+import { copyCollection } from "store/workbench/workbench-actions";
+import { CopyFormDialogData } from "store/copy-dialog/copy-dialog";
+import { pickerId } from "store/tree-picker/picker-id";
 
 export const CopyCollectionDialog = compose(
     withDialog(COLLECTION_COPY_FORM_NAME),
@@ -18,7 +18,19 @@ export const CopyCollectionDialog = compose(
         touchOnChange: true,
         onSubmit: (data, dispatch) => {
             dispatch(copyCollection(data));
-        }
+        },
     }),
-    pickerId(COLLECTION_COPY_FORM_NAME),
-)(DialogCopy);
\ No newline at end of file
+    pickerId(COLLECTION_COPY_FORM_NAME)
+)(DialogCopy);
+
+export const CopyMultiCollectionDialog = compose(
+    withDialog(COLLECTION_MULTI_COPY_FORM_NAME),
+    reduxForm<CopyFormDialogData>({
+        form: COLLECTION_MULTI_COPY_FORM_NAME,
+        touchOnChange: true,
+        onSubmit: (data, dispatch) => {
+            dispatch(copyCollection(data));
+        },
+    }),
+    pickerId(COLLECTION_MULTI_COPY_FORM_NAME)
+)(DialogMultiCopy);
index 6a79b62613d34b435a8c3a04f7bd3ad78e92602d..8afa58dde9583b3cee6f5a1c72651cd13fe952d3 100644 (file)
@@ -2,14 +2,14 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { compose } from "redux";
-import { withDialog } from "store/dialog/with-dialog";
+import { compose } from 'redux';
+import { withDialog } from 'store/dialog/with-dialog';
 import { reduxForm } from 'redux-form';
 import { PROCESS_COPY_FORM_NAME } from 'store/processes/process-copy-actions';
-import { DialogProcessRerun } from "views-components/dialog-copy/dialog-process-rerun";
+import { DialogProcessRerun } from 'views-components/dialog-copy/dialog-process-rerun';
 import { copyProcess } from 'store/workbench/workbench-actions';
 import { CopyFormDialogData } from 'store/copy-dialog/copy-dialog';
-import { pickerId } from "store/tree-picker/picker-id";
+import { pickerId } from 'store/tree-picker/picker-id';
 
 export const CopyProcessDialog = compose(
     withDialog(PROCESS_COPY_FORM_NAME),
@@ -17,7 +17,7 @@ export const CopyProcessDialog = compose(
         form: PROCESS_COPY_FORM_NAME,
         onSubmit: (data, dispatch) => {
             dispatch(copyProcess(data));
-        }
+        },
     }),
-    pickerId(PROCESS_COPY_FORM_NAME),
+    pickerId(PROCESS_COPY_FORM_NAME)
 )(DialogProcessRerun);
index 0729e29c0f4e1c2b31b8146bcf0a92beddeabd2a..345040d5a53b8816957cff6a78a24bd85b861f73 100644 (file)
@@ -4,12 +4,12 @@
 
 import { compose } from "redux";
 import { withDialog } from "store/dialog/with-dialog";
-import { reduxForm } from 'redux-form';
-import { PROJECT_MOVE_FORM_NAME } from 'store/projects/project-move-actions';
-import { MoveToFormDialogData } from 'store/move-to-dialog/move-to-dialog';
-import { DialogMoveTo } from 'views-components/dialog-move/dialog-move-to';
-import { moveProject } from 'store/workbench/workbench-actions';
-import { pickerId } from 'store/tree-picker/picker-id';
+import { reduxForm } from "redux-form";
+import { PROJECT_MOVE_FORM_NAME } from "store/projects/project-move-actions";
+import { MoveToFormDialogData } from "store/move-to-dialog/move-to-dialog";
+import { DialogMoveTo } from "views-components/dialog-move/dialog-move-to";
+import { moveProject } from "store/workbench/workbench-actions";
+import { pickerId } from "store/tree-picker/picker-id";
 
 export const MoveProjectDialog = compose(
     withDialog(PROJECT_MOVE_FORM_NAME),
@@ -17,8 +17,7 @@ export const MoveProjectDialog = compose(
         form: PROJECT_MOVE_FORM_NAME,
         onSubmit: (data, dispatch) => {
             dispatch(moveProject(data));
-        }
+        },
     }),
-    pickerId(PROJECT_MOVE_FORM_NAME),
+    pickerId(PROJECT_MOVE_FORM_NAME)
 )(DialogMoveTo);
-
index 23a44965c0eac305a09da1c4545b44b772a883bc..7d5fcf8035ca976270140ea638b0788279b9c439 100644 (file)
@@ -65,7 +65,7 @@ export const DirectoryPickerField = (props: PickerIdProp) =>
     <Field
         name="destination"
         pickerId={props.pickerId}
-        component={DirectoryTreePickerField}
+        component={DirectoryTreePickerField as any}
         validate={validateDirectory} />;
 
 interface StorageClassesProps {
index 15e008d1e89d64961af348b6cdd4a7fd3a98d056..c2cc0e2a47c772502d35f46add124bd62273fbf6 100644 (file)
@@ -39,16 +39,6 @@ const mapStateToProps = (state: RootState): AccountMenuProps => ({
     localCluster: state.auth.localCluster
 });
 
-const wb1URL = (route: string) => {
-    const r = route.replace(/^\//, "");
-    if (r.match(/^(projects|collections)\//)) {
-        return r;
-    } else if (r.match(/^processes\//)) {
-        return r.replace(/^processes/, "container_requests");
-    }
-    return "";
-};
-
 type CssRules = 'link';
 
 const styles: StyleRulesCallback<CssRules> = () => ({
diff --git a/src/views-components/multiselect-toolbar/ms-collection-action-set.ts b/src/views-components/multiselect-toolbar/ms-collection-action-set.ts
new file mode 100644 (file)
index 0000000..b0a2a1b
--- /dev/null
@@ -0,0 +1,38 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { MoveToIcon, CopyIcon } from "components/icon/icon";
+import { openMoveCollectionDialog } from "store/collections/collection-move-actions";
+import { openCollectionCopyDialog, openMultiCollectionCopyDialog } from "store/collections/collection-copy-actions";
+import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
+import { toggleCollectionTrashed } from "store/trash/trash-actions";
+import { ContextMenuResource } from "store/context-menu/context-menu-actions";
+
+export const msCollectionActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: CopyIcon,
+            name: "Make a copy",
+            execute: (dispatch, [...resources]) => {
+                if (resources[0].fromContextMenu || resources.length === 1) dispatch<any>(openCollectionCopyDialog(resources[0]));
+                else dispatch<any>(openMultiCollectionCopyDialog(resources[0]));
+            },
+        },
+        {
+            icon: MoveToIcon,
+            name: "Move to",
+            execute: (dispatch, resources) => dispatch<any>(openMoveCollectionDialog(resources[0])),
+        },
+        {
+            component: ToggleTrashAction,
+            name: "ToggleTrashAction",
+            execute: (dispatch, resources: ContextMenuResource[]) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(toggleCollectionTrashed(resource.uuid, resource.isTrashed!!));
+                }
+            },
+        },
+    ],
+];
diff --git a/src/views-components/multiselect-toolbar/ms-process-action-set.ts b/src/views-components/multiselect-toolbar/ms-process-action-set.ts
new file mode 100644 (file)
index 0000000..820fc79
--- /dev/null
@@ -0,0 +1,37 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { MoveToIcon, RemoveIcon, ReRunProcessIcon } from "components/icon/icon";
+import { openMoveProcessDialog } from "store/processes/process-move-actions";
+import { openCopyProcessDialog } from "store/processes/process-copy-actions";
+import { openRemoveProcessDialog } from "store/processes/processes-actions";
+
+export const msProcessActionSet: ContextMenuActionSet = [
+    [
+        {
+            icon: ReRunProcessIcon,
+            name: "Copy and re-run process",
+            execute: (dispatch, resources) => {
+                for (const resource of [...resources]) {
+                    dispatch<any>(openCopyProcessDialog(resource));
+                }
+            },
+        },
+        {
+            icon: MoveToIcon,
+            name: "Move to",
+            execute: (dispatch, resources) => {
+                dispatch<any>(openMoveProcessDialog(resources[0]));
+            },
+        },
+        {
+            name: "Remove",
+            icon: RemoveIcon,
+            execute: (dispatch, resources) => {
+                dispatch<any>(openRemoveProcessDialog(resources[0], resources.length));
+            },
+        },
+    ],
+];
diff --git a/src/views-components/multiselect-toolbar/ms-project-action-set.ts b/src/views-components/multiselect-toolbar/ms-project-action-set.ts
new file mode 100644 (file)
index 0000000..2b5dfa2
--- /dev/null
@@ -0,0 +1,38 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { ContextMenuActionSet } from "views-components/context-menu/context-menu-action-set";
+import { MoveToIcon, Link } from "components/icon/icon";
+import { openMoveProjectDialog } from "store/projects/project-move-actions";
+import { ToggleTrashAction } from "views-components/context-menu/actions/trash-action";
+import { toggleProjectTrashed } from "store/trash/trash-actions";
+import { copyToClipboardAction } from "store/open-in-new-tab/open-in-new-tab.actions";
+
+export const msCopyToClipboardMenuAction = {
+    icon: Link,
+    name: "Copy to clipboard",
+    execute: (dispatch, resources) => {
+        dispatch(copyToClipboardAction(resources));
+    },
+};
+
+export const msMoveToAction = {
+    icon: MoveToIcon,
+    name: "Move to",
+    execute: (dispatch, resource) => {
+        dispatch(openMoveProjectDialog(resource[0]));
+    },
+};
+
+export const msToggleTrashAction = {
+    component: ToggleTrashAction,
+    name: "ToggleTrashAction",
+    execute: (dispatch, resources) => {
+        for (const resource of [...resources]) {
+            dispatch(toggleProjectTrashed(resource.uuid, resource.ownerUuid, resource.isTrashed!!, resources.length > 1));
+        }
+    },
+};
+
+export const msProjectActionSet: ContextMenuActionSet = [[msCopyToClipboardMenuAction, msMoveToAction, msToggleTrashAction]];
index 1ed2a5511def60b3cd00cdce63fbfce9c5070ba9..70797f3165eace32a1a6cef81d409f753fc4fe5d 100644 (file)
@@ -21,6 +21,7 @@ import { CollectionFileType } from 'models/collection-file';
 type PickedTreePickerProps = Pick<TreePickerProps<ProjectsTreePickerItem>, 'onContextMenu' | 'toggleItemActive' | 'toggleItemOpen' | 'toggleItemSelection'>;
 
 export interface ProjectsTreePickerDataProps {
+    cascadeSelection: boolean;
     includeCollections?: boolean;
     includeDirectories?: boolean;
     includeFiles?: boolean;
@@ -35,9 +36,9 @@ export interface ProjectsTreePickerDataProps {
 
 export type ProjectsTreePickerProps = ProjectsTreePickerDataProps & Partial<PickedTreePickerProps>;
 
-const mapStateToProps = (_: any, { rootItemIcon, showSelection }: ProjectsTreePickerProps) => ({
+const mapStateToProps = (_: any, { rootItemIcon, showSelection, cascadeSelection }: ProjectsTreePickerProps) => ({
     render: renderTreeItem(rootItemIcon),
-    showSelection: isSelectionVisible(showSelection),
+    showSelection: isSelectionVisible(showSelection, cascadeSelection),
 });
 
 const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollections, includeDirectories, includeFiles, relatedTreePickers, options, ...props }: ProjectsTreePickerProps): PickedTreePickerProps => ({
@@ -71,7 +72,7 @@ const mapDispatchToProps = (dispatch: Dispatch, { loadRootItem, includeCollectio
         }
     },
     toggleItemSelection: (event, item, pickerId) => {
-        dispatch<any>(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: item.id, pickerId }));
+        dispatch<any>(treePickerActions.TOGGLE_TREE_PICKER_NODE_SELECTION({ id: item.id, pickerId, cascade: props.cascadeSelection }));
         if (props.toggleItemSelection) {
             props.toggleItemSelection(event, item, pickerId);
         }
@@ -108,11 +109,14 @@ const getProjectPickerIcon = ({ data }: TreeItem<ProjectsTreePickerItem>, rootIc
     }
 };
 
-const isSelectionVisible = (shouldBeVisible?: boolean) =>
-    ({ status, items }: TreeItem<ProjectsTreePickerItem>): boolean => {
+const isSelectionVisible = (shouldBeVisible: boolean | undefined, cascadeSelection: boolean) =>
+    ({ status, items, data }: TreeItem<ProjectsTreePickerItem>): boolean => {
         if (shouldBeVisible) {
-            if (items && items.length > 0) {
-                return items.every(isSelectionVisible(shouldBeVisible));
+            if (!cascadeSelection && 'kind' in data && data.kind === ResourceKind.COLLECTION) {
+                // In non-casecade mode collections are selectable without being loaded
+                return true;
+            } else if (items && items.length > 0) {
+                return items.every(isSelectionVisible(shouldBeVisible, cascadeSelection));
             }
             return status === TreeItemStatus.LOADED;
         }
index 773230d351fe1e491faa5f4dab49a81f375aff84..16f6cceb71ce44b711c5214157c48ddaff4d3061 100644 (file)
@@ -23,8 +23,9 @@ import { withStyles, StyleRulesCallback, WithStyles } from '@material-ui/core';
 import { ArvadosTheme } from 'common/custom-theme';
 
 export interface ToplevelPickerProps {
-    currentUuid?: string;
+    currentUuids?: string[];
     pickerId: string;
+    cascadeSelection: boolean;
     includeCollections?: boolean;
     includeDirectories?: boolean;
     includeFiles?: boolean;
@@ -107,7 +108,13 @@ export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)(
             componentDidMount() {
                 const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(this.props.pickerId);
 
-                this.props.dispatch<any>(initProjectsTreePicker(this.props.pickerId, this.props.currentUuid));
+                const preloadParams = this.props.currentUuids ? {
+                    selectedItemUuids: this.props.currentUuids,
+                    includeDirectories: !!this.props.includeDirectories,
+                    includeFiles: !!this.props.includeFiles,
+                    multi: !!this.props.showSelection,
+                } : undefined;
+                this.props.dispatch<any>(initProjectsTreePicker(this.props.pickerId, preloadParams));
 
                 this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_PROJECT_SEARCH({ pickerId: search, projectSearchValue: "" }));
                 this.props.dispatch(treePickerSearchActions.SET_TREE_PICKER_COLLECTION_FILTER({ pickerId: search, collectionFilterValue: "" }));
@@ -135,6 +142,7 @@ export const ProjectsTreePicker = connect(mapStateToProps, mapDispatchToProps)(
                 const { home, shared, favorites, publicFavorites, search } = getProjectsTreePickerIds(pickerId);
                 const relatedTreePickers = getRelatedTreePickers(pickerId);
                 const p = {
+                    cascadeSelection: this.props.cascadeSelection,
                     includeCollections: this.props.includeCollections,
                     includeDirectories: this.props.includeDirectories,
                     includeFiles: this.props.includeFiles,
index 17417bf554f7bafcaf92c2fa1afbc03fcd3f6a2d..75cf40c641bbe195e0c8ef02c4c2875d3adbb625 100644 (file)
@@ -9,7 +9,9 @@ import { WrappedFieldProps } from 'redux-form';
 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
 import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { PickerIdProp } from 'store/tree-picker/picker-id';
-import { getFileOperationLocation } from "store/tree-picker/tree-picker-actions";
+import { FileOperationLocation, getFileOperationLocation } from "store/tree-picker/tree-picker-actions";
+import { connect } from "react-redux";
+import { Dispatch } from "redux";
 
 export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
     <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
@@ -17,6 +19,7 @@ export const ProjectTreePickerField = (props: WrappedFieldProps & PickerIdProp)
             <ProjectsTreePicker
                 pickerId={props.pickerId}
                 toggleItemActive={handleChange(props)}
+                cascadeSelection={false}
                 options={{ showOnlyOwned: false, showOnlyWritable: true }} />
             {props.meta.dirty && props.meta.error &&
                 <Typography variant='caption' color='error'>
@@ -35,6 +38,7 @@ export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdPro
             <ProjectsTreePicker
                 pickerId={props.pickerId}
                 toggleItemActive={handleChange(props)}
+                cascadeSelection={false}
                 options={{ showOnlyOwned: false, showOnlyWritable: true }}
                 includeCollections />
             {props.meta.dirty && props.meta.error &&
@@ -44,24 +48,41 @@ export const CollectionTreePickerField = (props: WrappedFieldProps & PickerIdPro
         </div>
     </div>;
 
-const handleDirectoryChange = (props: WrappedFieldProps) =>
-    (_: any, { data }: TreeItem<ProjectsTreePickerItem>) => {
-        props.input.onChange(getFileOperationLocation(data) || '');
-    }
+type ProjectsTreePickerActionProps = {
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
 
-export const DirectoryTreePickerField = (props: WrappedFieldProps & PickerIdProp) =>
-    <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
-        <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
-            <ProjectsTreePicker
-                currentUuid={props.input.value.uuid}
-                pickerId={props.pickerId}
-                toggleItemActive={handleDirectoryChange(props)}
-                options={{ showOnlyOwned: false, showOnlyWritable: true }}
-                includeCollections
-                includeDirectories />
-            {props.meta.dirty && props.meta.error &&
-                <Typography variant='caption' color='error'>
-                    {props.meta.error}
-                </Typography>}
-        </div>
-    </div>;
+const projectsTreePickerMapDispatchToProps = (dispatch: Dispatch): ProjectsTreePickerActionProps => ({
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+type ProjectsTreePickerCombinedProps = ProjectsTreePickerActionProps & WrappedFieldProps & PickerIdProp;
+
+export const DirectoryTreePickerField = connect(null, projectsTreePickerMapDispatchToProps)(
+    class DirectoryTreePickerFieldComponent extends React.Component<ProjectsTreePickerCombinedProps> {
+
+        handleDirectoryChange = (props: WrappedFieldProps) =>
+            async (_: any, { data }: TreeItem<ProjectsTreePickerItem>) => {
+                const location = await this.props.getFileOperationLocation(data);
+                props.input.onChange(location || '');
+            }
+
+        render() {
+            return <div style={{ display: 'flex', minHeight: 0, flexDirection: 'column' }}>
+                <div style={{ flexBasis: '275px', flexShrink: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
+                    <ProjectsTreePicker
+                        currentUuids={[this.props.input.value.uuid]}
+                        pickerId={this.props.pickerId}
+                        toggleItemActive={this.handleDirectoryChange(this.props)}
+                        cascadeSelection={false}
+                        options={{ showOnlyOwned: false, showOnlyWritable: true }}
+                        includeCollections
+                        includeDirectories />
+                    {this.props.meta.dirty && this.props.meta.error &&
+                        <Typography variant='caption' color='error'>
+                            {this.props.meta.error}
+                        </Typography>}
+                </div>
+            </div>;
+        }
+    });
index 284083477f00e2f4c13f1d13cee483367bbbf630..eba281c9f0854c275454f61ee91142d7d59ab110 100644 (file)
@@ -2,73 +2,61 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { compose } from 'redux';
-import {
-    IconButton,
-    Paper,
-    StyleRulesCallback,
-    withStyles,
-    WithStyles,
-    Tooltip,
-    InputAdornment, Input,
-} from '@material-ui/core';
-import SearchIcon from '@material-ui/icons/Search';
-import ArrowDropDownIcon from '@material-ui/icons/ArrowDropDown';
-import { ArvadosTheme } from 'common/custom-theme';
-import { SearchView } from 'store/search-bar/search-bar-reducer';
-import {
-    SearchBarBasicView,
-    SearchBarBasicViewDataProps,
-    SearchBarBasicViewActionProps
-} from 'views-components/search-bar/search-bar-basic-view';
+import React from "react";
+import { compose } from "redux";
+import { IconButton, Paper, StyleRulesCallback, withStyles, WithStyles, Tooltip, InputAdornment, Input } from "@material-ui/core";
+import SearchIcon from "@material-ui/icons/Search";
+import ArrowDropDownIcon from "@material-ui/icons/ArrowDropDown";
+import { ArvadosTheme } from "common/custom-theme";
+import { SearchView } from "store/search-bar/search-bar-reducer";
+import { SearchBarBasicView, SearchBarBasicViewDataProps, SearchBarBasicViewActionProps } from "views-components/search-bar/search-bar-basic-view";
 import {
     SearchBarAutocompleteView,
     SearchBarAutocompleteViewDataProps,
-    SearchBarAutocompleteViewActionProps
-} from 'views-components/search-bar/search-bar-autocomplete-view';
+    SearchBarAutocompleteViewActionProps,
+} from "views-components/search-bar/search-bar-autocomplete-view";
 import {
     SearchBarAdvancedView,
     SearchBarAdvancedViewDataProps,
-    SearchBarAdvancedViewActionProps
-} from 'views-components/search-bar/search-bar-advanced-view';
+    SearchBarAdvancedViewActionProps,
+} from "views-components/search-bar/search-bar-advanced-view";
 import { KEY_CODE_DOWN, KEY_CODE_ESC, KEY_CODE_UP, KEY_ENTER } from "common/codes";
-import { debounce } from 'debounce';
-import { Vocabulary } from 'models/vocabulary';
-import { connectVocabulary } from '../resource-properties-form/property-field-common';
+import { debounce } from "debounce";
+import { Vocabulary } from "models/vocabulary";
+import { connectVocabulary } from "../resource-properties-form/property-field-common";
 
-type CssRules = 'container' | 'containerSearchViewOpened' | 'input' | 'view';
+type CssRules = "container" | "containerSearchViewOpened" | "input" | "view";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => {
     return {
         container: {
-            position: 'relative',
-            width: '100%',
+            position: "relative",
+            width: "100%",
             borderRadius: theme.spacing.unit / 2,
             zIndex: theme.zIndex.modal,
         },
         containerSearchViewOpened: {
-            position: 'relative',
-            width: '100%',
+            position: "relative",
+            width: "100%",
             borderRadius: `${theme.spacing.unit / 2}px ${theme.spacing.unit / 2}px 0 0`,
             zIndex: theme.zIndex.modal,
         },
         input: {
-            border: 'none',
-            padding: `0`
+            border: "none",
+            padding: `0`,
         },
         view: {
-            position: 'absolute',
-            width: '100%',
-            zIndex: 1
-        }
+            position: "absolute",
+            width: "100%",
+            zIndex: 1,
+        },
     };
 };
 
-export type SearchBarDataProps = SearchBarViewDataProps
-    & SearchBarAutocompleteViewDataProps
-    & SearchBarAdvancedViewDataProps
-    SearchBarBasicViewDataProps;
+export type SearchBarDataProps = SearchBarViewDataProps &
+    SearchBarAutocompleteViewDataProps &
+    SearchBarAdvancedViewDataProps &
+    SearchBarBasicViewDataProps;
 
 interface SearchBarViewDataProps {
     searchValue: string;
@@ -78,10 +66,10 @@ interface SearchBarViewDataProps {
     vocabulary?: Vocabulary;
 }
 
-export type SearchBarActionProps = SearchBarViewActionProps
-    & SearchBarAutocompleteViewActionProps
-    & SearchBarAdvancedViewActionProps
-    SearchBarBasicViewActionProps;
+export type SearchBarActionProps = SearchBarViewActionProps &
+    SearchBarAutocompleteViewActionProps &
+    SearchBarAdvancedViewActionProps &
+    SearchBarBasicViewActionProps;
 
 interface SearchBarViewActionProps {
     onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
@@ -144,9 +132,11 @@ const handleDropdownClick = (e: React.MouseEvent, props: SearchBarViewProps) =>
     }
 };
 
-export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
+export const SearchBarView = compose(
+    connectVocabulary,
+    withStyles(styles)
+)(
     class extends React.Component<SearchBarViewProps> {
-
         debouncedSearch = debounce(() => {
             this.props.onSearch(this.props.searchValue);
         }, 1000);
@@ -154,12 +144,12 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
         handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
             this.debouncedSearch();
             this.props.onChange(event);
-        }
+        };
 
         handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
             this.debouncedSearch.clear();
             this.props.onSubmit(event);
-        }
+        };
 
         componentWillUnmount() {
             this.debouncedSearch.clear();
@@ -170,14 +160,14 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
             const { classes, isPopoverOpen } = this.props;
             return (
                 <>
+                    {isPopoverOpen && <Backdrop onClick={props.closeView} />}
 
-                    {isPopoverOpen &&
-                        <Backdrop onClick={props.closeView} />}
-
-                    <Paper className={isPopoverOpen ? classes.containerSearchViewOpened : classes.container} >
-                        <form onSubmit={this.handleSubmit}>
+                    <Paper className={isPopoverOpen ? classes.containerSearchViewOpened : classes.container}>
+                        <form
+                            data-cy="searchbar-parent-form"
+                            onSubmit={this.handleSubmit}>
                             <Input
-                                data-cy='searchbar-input-field'
+                                data-cy="searchbar-input-field"
                                 className={classes.input}
                                 onChange={this.handleChange}
                                 placeholder="Search"
@@ -188,7 +178,7 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
                                 onKeyDown={e => handleKeyDown(e, props)}
                                 startAdornment={
                                     <InputAdornment position="start">
-                                        <Tooltip title='Search'>
+                                        <Tooltip title="Search">
                                             <IconButton type="submit">
                                                 <SearchIcon />
                                             </IconButton>
@@ -197,57 +187,69 @@ export const SearchBarView = compose(connectVocabulary, withStyles(styles))(
                                 }
                                 endAdornment={
                                     <InputAdornment position="end">
-                                        <Tooltip title='Advanced search'>
+                                        <Tooltip title="Advanced search">
                                             <IconButton onClick={e => handleDropdownClick(e, props)}>
                                                 <ArrowDropDownIcon />
                                             </IconButton>
                                         </Tooltip>
                                     </InputAdornment>
-                                } />
+                                }
+                            />
                         </form>
-                        <div className={classes.view}>
-                            {isPopoverOpen && getView({ ...props })}
-                        </div>
-                    </Paper >
+                        <div className={classes.view}>{isPopoverOpen && getView({ ...props })}</div>
+                    </Paper>
                 </>
             );
         }
-    });
+    }
+);
 
 const getView = (props: SearchBarViewProps) => {
     switch (props.currentView) {
         case SearchView.AUTOCOMPLETE:
-            return <SearchBarAutocompleteView
-                navigateTo={props.navigateTo}
-                searchResults={props.searchResults}
-                searchValue={props.searchValue}
-                selectedItem={props.selectedItem} />;
+            return (
+                <SearchBarAutocompleteView
+                    navigateTo={props.navigateTo}
+                    searchResults={props.searchResults}
+                    searchValue={props.searchValue}
+                    selectedItem={props.selectedItem}
+                />
+            );
         case SearchView.ADVANCED:
-            return <SearchBarAdvancedView
-                closeAdvanceView={props.closeAdvanceView}
-                tags={props.tags}
-                saveQuery={props.saveQuery} />;
+            return (
+                <SearchBarAdvancedView
+                    closeAdvanceView={props.closeAdvanceView}
+                    tags={props.tags}
+                    saveQuery={props.saveQuery}
+                />
+            );
         default:
-            return <SearchBarBasicView
-                onSetView={props.onSetView}
-                onSearch={props.onSearch}
-                loadRecentQueries={props.loadRecentQueries}
-                savedQueries={props.savedQueries}
-                deleteSavedQuery={props.deleteSavedQuery}
-                editSavedQuery={props.editSavedQuery}
-                selectedItem={props.selectedItem} />;
+            return (
+                <SearchBarBasicView
+                    onSetView={props.onSetView}
+                    onSearch={props.onSearch}
+                    loadRecentQueries={props.loadRecentQueries}
+                    savedQueries={props.savedQueries}
+                    deleteSavedQuery={props.deleteSavedQuery}
+                    editSavedQuery={props.editSavedQuery}
+                    selectedItem={props.selectedItem}
+                />
+            );
     }
 };
 
-const Backdrop = withStyles<'backdrop'>(theme => ({
+const Backdrop = withStyles<"backdrop">(theme => ({
     backdrop: {
-        position: 'fixed',
+        position: "fixed",
         top: 0,
         right: 0,
         bottom: 0,
         left: 0,
-        zIndex: theme.zIndex.modal
-    }
-}))(
-    ({ classes, ...props }: WithStyles<'backdrop'> & React.HTMLProps<HTMLDivElement>) =>
-        <div className={classes.backdrop} {...props} />);
+        zIndex: theme.zIndex.modal,
+    },
+}))(({ classes, ...props }: WithStyles<"backdrop"> & React.HTMLProps<HTMLDivElement>) => (
+    <div
+        className={classes.backdrop}
+        {...props}
+    />
+));
index 4914da6233180bc04ac7b32405724d8057e5b4e5..ee53f99c3fe94e39c4fa2da096d8e7175d774223 100644 (file)
@@ -2,49 +2,49 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
+import React from "react";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
 import { DataExplorer } from "views-components/data-explorer/data-explorer";
-import { connect, DispatchProp } from 'react-redux';
-import { DataColumns } from 'components/data-table/data-table';
-import { RouteComponentProps } from 'react-router';
-import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
-import { SortDirection } from 'components/data-table/data-column';
-import { ResourceKind } from 'models/resource';
-import { ArvadosTheme } from 'common/custom-theme';
-import { ALL_PROCESSES_PANEL_ID } from 'store/all-processes-panel/all-processes-panel-action';
+import { connect, DispatchProp } from "react-redux";
+import { DataColumns } from "components/data-table/data-table";
+import { RouteComponentProps } from "react-router";
+import { DataTableFilterItem } from "components/data-table-filters/data-table-filters";
+import { SortDirection } from "components/data-table/data-column";
+import { ResourceKind } from "models/resource";
+import { ArvadosTheme } from "common/custom-theme";
+import { ALL_PROCESSES_PANEL_ID } from "store/all-processes-panel/all-processes-panel-action";
 import {
     ProcessStatus,
     ResourceName,
     ResourceOwnerWithName,
     ResourceType,
     ContainerRunTime,
-    ResourceCreatedAtDate
-} from 'views-components/data-explorer/renderers';
-import { ProcessIcon } from 'components/icon/icon';
-import { openProcessContextMenu } from 'store/context-menu/context-menu-actions';
-import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
-import { navigateTo } from 'store/navigation/navigation-action';
+    ResourceCreatedAtDate,
+} from "views-components/data-explorer/renderers";
+import { ProcessIcon } from "components/icon/icon";
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { loadDetailsPanel } from "store/details-panel/details-panel-action";
+import { navigateTo } from "store/navigation/navigation-action";
 import { ContainerRequestResource, ContainerRequestState } from "models/container-request";
-import { RootState } from 'store/store';
-import { createTree } from 'models/tree';
-import { getInitialProcessStatusFilters, getInitialProcessTypeFilters } from 'store/resource-type-filters/resource-type-filters';
-import { getProcess } from 'store/processes/process';
-import { ResourcesState } from 'store/resources/resources';
+import { RootState } from "store/store";
+import { createTree } from "models/tree";
+import { getInitialProcessStatusFilters, getInitialProcessTypeFilters } from "store/resource-type-filters/resource-type-filters";
+import { getProcess } from "store/processes/process";
+import { ResourcesState } from "store/resources/resources";
 
 type CssRules = "toolbar" | "button" | "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     toolbar: {
         paddingBottom: theme.spacing.unit * 3,
-        textAlign: "right"
+        textAlign: "right",
     },
     button: {
-        marginLeft: theme.spacing.unit
+        marginLeft: theme.spacing.unit,
     },
     root: {
-        width: '100%',
-    }
+        width: "100%",
+    },
 });
 
 export enum AllProcessesPanelColumnNames {
@@ -53,7 +53,7 @@ export enum AllProcessesPanelColumnNames {
     TYPE = "Type",
     OWNER = "Owner",
     CREATED_AT = "Created at",
-    RUNTIME = "Run Time"
+    RUNTIME = "Run Time",
 }
 
 export interface AllProcessesPanelFilter extends DataTableFilterItem {
@@ -65,9 +65,9 @@ export const allProcessesPanelColumns: DataColumns<string, ContainerRequestResou
         name: AllProcessesPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "name"},
+        sort: { direction: SortDirection.NONE, field: "name" },
         filters: createTree(),
-        render: uuid => <ResourceName uuid={uuid} />
+        render: uuid => <ResourceName uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.STATUS,
@@ -75,37 +75,37 @@ export const allProcessesPanelColumns: DataColumns<string, ContainerRequestResou
         configurable: true,
         mutuallyExclusiveFilters: true,
         filters: getInitialProcessStatusFilters(),
-        render: uuid => <ProcessStatus uuid={uuid} />
+        render: uuid => <ProcessStatus uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.TYPE,
         selected: true,
         configurable: true,
         filters: getInitialProcessTypeFilters(),
-        render: uuid => <ResourceType uuid={uuid} />
+        render: uuid => <ResourceType uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.OWNER,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceOwnerWithName uuid={uuid} />
+        render: uuid => <ResourceOwnerWithName uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.CREATED_AT,
         selected: true,
         configurable: true,
-        sort: {direction: SortDirection.DESC, field: "createdAt"},
+        sort: { direction: SortDirection.DESC, field: "createdAt" },
         filters: createTree(),
-        render: uuid => <ResourceCreatedAtDate uuid={uuid} />
+        render: uuid => <ResourceCreatedAtDate uuid={uuid} />,
     },
     {
         name: AllProcessesPanelColumnNames.RUNTIME,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ContainerRunTime uuid={uuid} />
-    }
+        render: uuid => <ContainerRunTime uuid={uuid} />,
+    },
 ];
 
 interface AllProcessesPanelDataProps {
@@ -117,12 +117,15 @@ interface AllProcessesPanelActionProps {
     onDialogOpen: (ownerUuid: string) => void;
     onItemDoubleClick: (item: string) => void;
 }
-const mapStateToProps = (state : RootState): AllProcessesPanelDataProps => ({
-    resources: state.resources
+const mapStateToProps = (state: RootState): AllProcessesPanelDataProps => ({
+    resources: state.resources,
 });
 
-type AllProcessesPanelProps = AllProcessesPanelDataProps & AllProcessesPanelActionProps & DispatchProp
-    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
+type AllProcessesPanelProps = AllProcessesPanelDataProps &
+    AllProcessesPanelActionProps &
+    DispatchProp &
+    WithStyles<CssRules> &
+    RouteComponentProps<{ id: string }>;
 
 export const AllProcessesPanel = withStyles(styles)(
     connect(mapStateToProps)(
@@ -133,26 +136,30 @@ export const AllProcessesPanel = withStyles(styles)(
                     this.props.dispatch<any>(openProcessContextMenu(event, process));
                 }
                 this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
-            }
+            };
 
             handleRowDoubleClick = (uuid: string) => {
                 this.props.dispatch<any>(navigateTo(uuid));
-            }
+            };
 
             handleRowClick = (uuid: string) => {
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
-            }
+            };
 
             render() {
-                return <div className={this.props.classes.root}><DataExplorer
-                    id={ALL_PROCESSES_PANEL_ID}
-                    onRowClick={this.handleRowClick}
-                    onRowDoubleClick={this.handleRowDoubleClick}
-                    onContextMenu={this.handleContextMenu}
-                    contextMenuColumn={true}
-                    defaultViewIcon={ProcessIcon}
-                    defaultViewMessages={['Processes list empty.']} />
-                </div>
+                return (
+                    <div className={this.props.classes.root}>
+                        <DataExplorer
+                            id={ALL_PROCESSES_PANEL_ID}
+                            onRowClick={this.handleRowClick}
+                            onRowDoubleClick={this.handleRowDoubleClick}
+                            onContextMenu={this.handleContextMenu}
+                            contextMenuColumn={true}
+                            defaultViewIcon={ProcessIcon}
+                            defaultViewMessages={["Processes list empty."]}
+                        />
+                    </div>
+                );
             }
         }
     )
index 607bdeb763a95ae84d0d82a4b00209ef8e906e6c..b5afbf6545ed19f2eb84156f02534c3fa09ab3f8 100644 (file)
@@ -2,8 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React, { ReactElement, memo, useState } from 'react';
-import { Dispatch } from 'redux';
+import React, { ReactElement, memo, useState } from "react";
+import { Dispatch } from "redux";
 import {
     StyleRulesCallback,
     WithStyles,
@@ -25,19 +25,10 @@ import {
     Grid,
     Chip,
     CircularProgress,
-} from '@material-ui/core';
-import { ArvadosTheme } from 'common/custom-theme';
-import {
-    CloseIcon,
-    ImageIcon,
-    InputIcon,
-    ImageOffIcon,
-    OutputIcon,
-    MaximizeIcon,
-    UnMaximizeIcon,
-    InfoIcon
-} from 'components/icon/icon';
-import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+} from "@material-ui/core";
+import { ArvadosTheme } from "common/custom-theme";
+import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon";
+import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
 import {
     BooleanCommandInputParameter,
     CommandInputParameter,
@@ -56,26 +47,26 @@ import {
     isPrimitiveOfType,
     StringArrayCommandInputParameter,
     StringCommandInputParameter,
-    getEnumType
+    getEnumType,
 } from "models/workflow";
-import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
-import { File } from 'models/workflow';
-import { getInlineFileUrl } from 'views-components/context-menu/actions/helpers';
-import { AuthState } from 'store/auth/auth-reducer';
-import mime from 'mime';
-import { DefaultView } from 'components/default-view/default-view';
-import { getNavUrl } from 'routes/routes';
-import { Link as RouterLink } from 'react-router-dom';
-import { Link as MuiLink } from '@material-ui/core';
-import { InputCollectionMount } from 'store/processes/processes-actions';
-import { connect } from 'react-redux';
-import { RootState } from 'store/store';
-import { ProcessOutputCollectionFiles } from './process-output-collection-files';
-import { Process } from 'store/processes/process';
-import { navigateTo } from 'store/navigation/navigation-action';
-import classNames from 'classnames';
-import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet';
-import { KEEP_URL_REGEX } from 'models/resource';
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+import { File } from "models/workflow";
+import { getInlineFileUrl } from "views-components/context-menu/actions/helpers";
+import { AuthState } from "store/auth/auth-reducer";
+import mime from "mime";
+import { DefaultView } from "components/default-view/default-view";
+import { getNavUrl } from "routes/routes";
+import { Link as RouterLink } from "react-router-dom";
+import { Link as MuiLink } from "@material-ui/core";
+import { InputCollectionMount } from "store/processes/processes-actions";
+import { connect } from "react-redux";
+import { RootState } from "store/store";
+import { ProcessOutputCollectionFiles } from "./process-output-collection-files";
+import { Process } from "store/processes/process";
+import { navigateTo } from "store/navigation/navigation-action";
+import classNames from "classnames";
+import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet";
+import { KEEP_URL_REGEX } from "models/resource";
 
 type CssRules =
     | "card"
@@ -102,124 +93,124 @@ type CssRules =
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     card: {
-        height: '100%'
+        height: "100%",
     },
     header: {
         paddingTop: theme.spacing.unit,
         paddingBottom: 0,
     },
     iconHeader: {
-        fontSize: '1.875rem',
+        fontSize: "1.875rem",
         color: theme.customs.colors.greyL,
     },
     avatar: {
-        alignSelf: 'flex-start',
-        paddingTop: theme.spacing.unit * 0.5
+        alignSelf: "flex-start",
+        paddingTop: theme.spacing.unit * 0.5,
     },
     content: {
         height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`,
         padding: theme.spacing.unit * 1.0,
         paddingTop: 0,
-        '&:last-child': {
+        "&:last-child": {
             paddingBottom: theme.spacing.unit * 1,
-        }
+        },
     },
     title: {
-        overflow: 'hidden',
+        overflow: "hidden",
         paddingTop: theme.spacing.unit * 0.5,
         color: theme.customs.colors.greyD,
-        fontSize: '1.875rem'
+        fontSize: "1.875rem",
     },
     tableWrapper: {
-        height: 'auto',
+        height: "auto",
         maxHeight: `calc(100% - ${theme.spacing.unit * 4.5}px)`,
-        overflow: 'auto',
+        overflow: "auto",
     },
     tableRoot: {
-        width: '100%',
-        '& thead th': {
-            verticalAlign: 'bottom',
-            paddingBottom: '10px',
+        width: "100%",
+        "& thead th": {
+            verticalAlign: "bottom",
+            paddingBottom: "10px",
+        },
+        "& td, & th": {
+            paddingRight: "25px",
         },
-        '& td, & th': {
-            paddingRight: '25px',
-        }
     },
     paramValue: {
-        display: 'flex',
-        alignItems: 'flex-start',
-        flexDirection: 'column',
+        display: "flex",
+        alignItems: "flex-start",
+        flexDirection: "column",
     },
     keepLink: {
         color: theme.palette.primary.main,
-        textDecoration: 'none',
-        overflowWrap: 'break-word',
-        cursor: 'pointer',
+        textDecoration: "none",
+        overflowWrap: "break-word",
+        cursor: "pointer",
     },
     collectionLink: {
-        margin: '10px',
-        '& a': {
+        margin: "10px",
+        "& a": {
             color: theme.palette.primary.main,
-            textDecoration: 'none',
-            overflowWrap: 'break-word',
-            cursor: 'pointer',
-        }
+            textDecoration: "none",
+            overflowWrap: "break-word",
+            cursor: "pointer",
+        },
     },
     imagePreview: {
-        maxHeight: '15em',
-        maxWidth: '15em',
+        maxHeight: "15em",
+        maxWidth: "15em",
         marginBottom: theme.spacing.unit,
     },
     valArray: {
-        display: 'flex',
-        gap: '10px',
-        flexWrap: 'wrap',
-        '& span': {
-            display: 'inline',
-        }
+        display: "flex",
+        gap: "10px",
+        flexWrap: "wrap",
+        "& span": {
+            display: "inline",
+        },
     },
     secondaryVal: {
-        paddingLeft: '20px',
+        paddingLeft: "20px",
     },
     secondaryRow: {
-        height: '29px',
-        verticalAlign: 'top',
-        position: 'relative',
-        top: '-9px',
+        height: "29px",
+        verticalAlign: "top",
+        position: "relative",
+        top: "-9px",
     },
     emptyValue: {
         color: theme.customs.colors.grey700,
     },
     noBorderRow: {
-        '& td': {
-            borderBottom: 'none',
-        }
+        "& td": {
+            borderBottom: "none",
+        },
     },
     symmetricTabs: {
-        '& button': {
-            flexBasis: '0',
-        }
+        "& button": {
+            flexBasis: "0",
+        },
     },
     imagePlaceholder: {
-        width: '60px',
-        height: '60px',
-        display: 'flex',
-        alignItems: 'center',
-        justifyContent: 'center',
-        backgroundColor: '#cecece',
-        borderRadius: '10px',
+        width: "60px",
+        height: "60px",
+        display: "flex",
+        alignItems: "center",
+        justifyContent: "center",
+        backgroundColor: "#cecece",
+        borderRadius: "10px",
     },
     rowWithPreview: {
-        verticalAlign: 'bottom',
+        verticalAlign: "bottom",
     },
     labelColumn: {
-        minWidth: '120px',
+        minWidth: "120px",
     },
 });
 
 export enum ProcessIOCardType {
-    INPUT = 'Inputs',
-    OUTPUT = 'Outputs',
+    INPUT = "Inputs",
+    OUTPUT = "Outputs",
 }
 export interface ProcessIOCardDataProps {
     process?: Process;
@@ -236,146 +227,271 @@ export interface ProcessIOCardActionProps {
 }
 
 const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
-    navigateTo: (uuid) => dispatch<any>(navigateTo(uuid)),
+    navigateTo: uuid => dispatch<any>(navigateTo(uuid)),
 });
 
 type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
 
-export const ProcessIOCard = withStyles(styles)(connect(null, mapDispatchToProps)(
-    ({ classes, label, params, raw, mounts, outputUuid, doHidePanel, doMaximizePanel,
-        doUnMaximizePanel, panelMaximized, panelName, process, navigateTo, showParams }: ProcessIOCardProps) => {
-        const [mainProcTabState, setMainProcTabState] = useState(0);
-        const [subProcTabState, setSubProcTabState] = useState(0);
-        const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
-            setMainProcTabState(value);
-        }
-        const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
-            setSubProcTabState(value);
-        }
-
-        const [showImagePreview, setShowImagePreview] = useState(false);
-
-        const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
-        const mainProcess = !(process && process!.containerRequest.requestingContainerUuid);
-
-        const loading = raw === null || raw === undefined || params === null;
-        const hasRaw = !!(raw && Object.keys(raw).length > 0);
-        const hasParams = !!(params && params.length > 0);
-
-        // Subprocess
-        const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
-        const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
-
-        return <Card className={classes.card} data-cy="process-io-card">
-            <CardHeader
-                className={classes.header}
-                classes={{
-                    content: classes.title,
-                    avatar: classes.avatar,
-                }}
-                avatar={<PanelIcon className={classes.iconHeader} />}
-                title={
-                    <Typography noWrap variant='h6' color='inherit'>
-                        {label}
-                    </Typography>
-                }
-                action={
-                    <div>
-                        {mainProcess && <Tooltip title={"Toggle Image Preview"} disableFocusListener>
-                            <IconButton data-cy="io-preview-image-toggle" onClick={() => { setShowImagePreview(!showImagePreview) }}>{showImagePreview ? <ImageIcon /> : <ImageOffIcon />}</IconButton>
-                        </Tooltip>}
-                        {doUnMaximizePanel && panelMaximized &&
-                            <Tooltip title={`Unmaximize ${panelName || 'panel'}`} disableFocusListener>
-                                <IconButton onClick={doUnMaximizePanel}><UnMaximizeIcon /></IconButton>
-                            </Tooltip>}
-                        {doMaximizePanel && !panelMaximized &&
-                            <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
-                                <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
-                            </Tooltip>}
-                        {doHidePanel &&
-                            <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
-                                <IconButton disabled={panelMaximized} onClick={doHidePanel}><CloseIcon /></IconButton>
-                            </Tooltip>}
-                    </div>
-                } />
-            <CardContent className={classes.content}>
-                {(mainProcess || showParams) ?
-                    (<>
-                        {/* raw is undefined until params are loaded */}
-                        {loading && <Grid container item alignItems='center' justify='center'>
-                            <CircularProgress />
-                        </Grid>}
-                        {/* Once loaded, either raw or params may still be empty
-                         *   Raw when all params are empty
-                         *   Params when raw is provided by containerRequest properties but workflow mount is absent for preview
-                         */}
-                        {(!loading && (hasRaw || hasParams)) &&
+export const ProcessIOCard = withStyles(styles)(
+    connect(
+        null,
+        mapDispatchToProps
+    )(
+        ({
+            classes,
+            label,
+            params,
+            raw,
+            mounts,
+            outputUuid,
+            doHidePanel,
+            doMaximizePanel,
+            doUnMaximizePanel,
+            panelMaximized,
+            panelName,
+            process,
+            navigateTo,
+            showParams,
+        }: ProcessIOCardProps) => {
+            const [mainProcTabState, setMainProcTabState] = useState(0);
+            const [subProcTabState, setSubProcTabState] = useState(0);
+            const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+                setMainProcTabState(value);
+            };
+            const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+                setSubProcTabState(value);
+            };
+
+            const [showImagePreview, setShowImagePreview] = useState(false);
+
+            const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
+            const mainProcess = !(process && process!.containerRequest.requestingContainerUuid);
+
+            const loading = raw === null || raw === undefined || params === null;
+            const hasRaw = !!(raw && Object.keys(raw).length > 0);
+            const hasParams = !!(params && params.length > 0);
+
+            // Subprocess
+            const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
+            const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
+
+            return (
+                <Card
+                    className={classes.card}
+                    data-cy="process-io-card"
+                >
+                    <CardHeader
+                        className={classes.header}
+                        classes={{
+                            content: classes.title,
+                            avatar: classes.avatar,
+                        }}
+                        avatar={<PanelIcon className={classes.iconHeader} />}
+                        title={
+                            <Typography
+                                noWrap
+                                variant="h6"
+                                color="inherit"
+                            >
+                                {label}
+                            </Typography>
+                        }
+                        action={
+                            <div>
+                                {mainProcess && (
+                                    <Tooltip
+                                        title={"Toggle Image Preview"}
+                                        disableFocusListener
+                                    >
+                                        <IconButton
+                                            data-cy="io-preview-image-toggle"
+                                            onClick={() => {
+                                                setShowImagePreview(!showImagePreview);
+                                            }}
+                                        >
+                                            {showImagePreview ? <ImageIcon /> : <ImageOffIcon />}
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                                {doUnMaximizePanel && panelMaximized && (
+                                    <Tooltip
+                                        title={`Unmaximize ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton onClick={doUnMaximizePanel}>
+                                            <UnMaximizeIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                                {doMaximizePanel && !panelMaximized && (
+                                    <Tooltip
+                                        title={`Maximize ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton onClick={doMaximizePanel}>
+                                            <MaximizeIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                                {doHidePanel && (
+                                    <Tooltip
+                                        title={`Close ${panelName || "panel"}`}
+                                        disableFocusListener
+                                    >
+                                        <IconButton
+                                            disabled={panelMaximized}
+                                            onClick={doHidePanel}
+                                        >
+                                            <CloseIcon />
+                                        </IconButton>
+                                    </Tooltip>
+                                )}
+                            </div>
+                        }
+                    />
+                    <CardContent className={classes.content}>
+                        {mainProcess || showParams ? (
                             <>
-                                <Tabs value={mainProcTabState} onChange={handleMainProcTabChange} variant="fullWidth" className={classes.symmetricTabs}>
-                                    {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
-                                    {hasParams && <Tab label="Parameters" />}
-                                    {!showParams && <Tab label="JSON" />}
-                                </Tabs>
-                                {(mainProcTabState === 0 && params && hasParams) && <div className={classes.tableWrapper}>
-                                    <ProcessIOPreview data={params} showImagePreview={showImagePreview} valueLabel={showParams ? "Default value" : "Value"} />
-                                </div>}
-                                {(mainProcTabState === 1 || !hasParams) && <div className={classes.tableWrapper}>
-                                    <ProcessIORaw data={raw} />
-                                </div>}
-                            </>}
-                        {!loading && !hasRaw && !hasParams && <Grid container item alignItems='center' justify='center'>
-                            <DefaultView messages={["No parameters found"]} />
-                        </Grid>}
-                    </>) :
-                    // Subprocess
-                    (<>
-                        {loading && <Grid container item alignItems='center' justify='center'>
-                            <CircularProgress />
-                        </Grid>}
-                        {!loading && (hasInputMounts || hasOutputCollecton || hasRaw) ?
+                                {/* raw is undefined until params are loaded */}
+                                {loading && (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <CircularProgress />
+                                    </Grid>
+                                )}
+                                {/* Once loaded, either raw or params may still be empty
+                                 *   Raw when all params are empty
+                                 *   Params when raw is provided by containerRequest properties but workflow mount is absent for preview
+                                 */}
+                                {!loading && (hasRaw || hasParams) && (
+                                    <>
+                                        <Tabs
+                                            value={mainProcTabState}
+                                            onChange={handleMainProcTabChange}
+                                            variant="fullWidth"
+                                            className={classes.symmetricTabs}
+                                        >
+                                            {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
+                                            {hasParams && <Tab label="Parameters" />}
+                                            {!showParams && <Tab label="JSON" />}
+                                        </Tabs>
+                                        {mainProcTabState === 0 && params && hasParams && (
+                                            <div className={classes.tableWrapper}>
+                                                <ProcessIOPreview
+                                                    data={params}
+                                                    showImagePreview={showImagePreview}
+                                                    valueLabel={showParams ? "Default value" : "Value"}
+                                                />
+                                            </div>
+                                        )}
+                                        {(mainProcTabState === 1 || !hasParams) && (
+                                            <div className={classes.tableWrapper}>
+                                                <ProcessIORaw data={raw} />
+                                            </div>
+                                        )}
+                                    </>
+                                )}
+                                {!loading && !hasRaw && !hasParams && (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <DefaultView messages={["No parameters found"]} />
+                                    </Grid>
+                                )}
+                            </>
+                        ) : (
+                            // Subprocess
                             <>
-                                <Tabs value={subProcTabState} onChange={handleSubProcTabChange} variant="fullWidth" className={classes.symmetricTabs}>
-                                    {hasInputMounts && <Tab label="Collections" />}
-                                    {hasOutputCollecton && <Tab label="Collection" />}
-                                    <Tab label="JSON" />
-                                </Tabs>
-                                <div className={classes.tableWrapper}>
-                                    {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
-                                    {subProcTabState === 0 && hasOutputCollecton && <>
-                                        {outputUuid && <Typography className={classes.collectionLink}>
-                                            Output Collection: <MuiLink className={classes.keepLink} onClick={() => { navigateTo(outputUuid || "") }}>
-                                                {outputUuid}
-                                            </MuiLink></Typography>}
-                                        <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={outputUuid} />
-                                    </>}
-                                    {(subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && <div className={classes.tableWrapper}>
-                                        <ProcessIORaw data={raw} />
-                                    </div>}
-                                </div>
-                            </> :
-                            <Grid container item alignItems='center' justify='center'>
-                                <DefaultView messages={["No data to display"]} />
-                            </Grid>
-                        }
-                    </>)
-                }
-            </CardContent>
-        </Card>;
-    }
-));
+                                {loading && (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <CircularProgress />
+                                    </Grid>
+                                )}
+                                {!loading && (hasInputMounts || hasOutputCollecton || hasRaw) ? (
+                                    <>
+                                        <Tabs
+                                            value={subProcTabState}
+                                            onChange={handleSubProcTabChange}
+                                            variant="fullWidth"
+                                            className={classes.symmetricTabs}
+                                        >
+                                            {hasInputMounts && <Tab label="Collections" />}
+                                            {hasOutputCollecton && <Tab label="Collection" />}
+                                            <Tab label="JSON" />
+                                        </Tabs>
+                                        <div className={classes.tableWrapper}>
+                                            {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
+                                            {subProcTabState === 0 && hasOutputCollecton && (
+                                                <>
+                                                    {outputUuid && (
+                                                        <Typography className={classes.collectionLink}>
+                                                            Output Collection:{" "}
+                                                            <MuiLink
+                                                                className={classes.keepLink}
+                                                                onClick={() => {
+                                                                    navigateTo(outputUuid || "");
+                                                                }}
+                                                            >
+                                                                {outputUuid}
+                                                            </MuiLink>
+                                                        </Typography>
+                                                    )}
+                                                    <ProcessOutputCollectionFiles
+                                                        isWritable={false}
+                                                        currentItemUuid={outputUuid}
+                                                    />
+                                                </>
+                                            )}
+                                            {(subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
+                                                <div className={classes.tableWrapper}>
+                                                    <ProcessIORaw data={raw} />
+                                                </div>
+                                            )}
+                                        </div>
+                                    </>
+                                ) : (
+                                    <Grid
+                                        container
+                                        item
+                                        alignItems="center"
+                                        justify="center"
+                                    >
+                                        <DefaultView messages={["No data to display"]} />
+                                    </Grid>
+                                )}
+                            </>
+                        )}
+                    </CardContent>
+                </Card>
+            );
+        }
+    )
+);
 
 export type ProcessIOValue = {
     display: ReactElement<any, any>;
     imageUrl?: string;
     collection?: ReactElement<any, any>;
     secondary?: boolean;
-}
+};
 
 export type ProcessIOParameter = {
     id: string;
     label: string;
     value: ProcessIOValue[];
-}
+};
 
 interface ProcessIOPreviewDataProps {
     data: ProcessIOParameter[];
@@ -385,91 +501,116 @@ interface ProcessIOPreviewDataProps {
 
 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
 
-const ProcessIOPreview = memo(withStyles(styles)(
-    ({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
+const ProcessIOPreview = memo(
+    withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
         const showLabel = data.some((param: ProcessIOParameter) => param.label);
-        return <Table className={classes.tableRoot} aria-label="Process IO Preview">
-            <TableHead>
-                <TableRow>
-                    <TableCell>Name</TableCell>
-                    {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
-                    <TableCell>{valueLabel}</TableCell>
-                    <TableCell>Collection</TableCell>
-                </TableRow>
-            </TableHead>
-            <TableBody>
-                {data.map((param: ProcessIOParameter) => {
-                    const firstVal = param.value.length > 0 ? param.value[0] : undefined;
-                    const rest = param.value.slice(1);
-                    const mainRowClasses = {
-                        [classes.noBorderRow]: (rest.length > 0),
-                    };
-
-                    return <React.Fragment key={param.id}>
-                        <TableRow className={classNames(mainRowClasses)} data-cy="process-io-param">
-                            <TableCell>
-                                {param.id}
-                            </TableCell>
-                            {showLabel && <TableCell >{param.label}</TableCell>}
-                            <TableCell>
-                                {firstVal && <ProcessValuePreview value={firstVal} showImagePreview={showImagePreview} />}
-                            </TableCell>
-                            <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
-                                <Typography className={classes.paramValue}>
-                                    {firstVal?.collection}
-                                </Typography>
-                            </TableCell>
-                        </TableRow>
-                        {rest.map((val, i) => {
-                            const rowClasses = {
-                                [classes.noBorderRow]: (i < rest.length - 1),
-                                [classes.secondaryRow]: val.secondary,
-                            };
-                            return <TableRow className={classNames(rowClasses)} key={i}>
-                                <TableCell />
-                                {showLabel && <TableCell />}
-                                <TableCell>
-                                    <ProcessValuePreview value={val} showImagePreview={showImagePreview} />
-                                </TableCell>
-                                <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
-                                    <Typography className={classes.paramValue}>
-                                        {val.collection}
-                                    </Typography>
-                                </TableCell>
-                            </TableRow>
-                        })}
-                    </React.Fragment>;
-                })}
-            </TableBody>
-        </Table >;
-    }));
+        return (
+            <Table
+                className={classes.tableRoot}
+                aria-label="Process IO Preview"
+            >
+                <TableHead>
+                    <TableRow>
+                        <TableCell>Name</TableCell>
+                        {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
+                        <TableCell>{valueLabel}</TableCell>
+                        <TableCell>Collection</TableCell>
+                    </TableRow>
+                </TableHead>
+                <TableBody>
+                    {data.map((param: ProcessIOParameter) => {
+                        const firstVal = param.value.length > 0 ? param.value[0] : undefined;
+                        const rest = param.value.slice(1);
+                        const mainRowClasses = {
+                            [classes.noBorderRow]: rest.length > 0,
+                        };
+
+                        return (
+                            <React.Fragment key={param.id}>
+                                <TableRow
+                                    className={classNames(mainRowClasses)}
+                                    data-cy="process-io-param"
+                                >
+                                    <TableCell>{param.id}</TableCell>
+                                    {showLabel && <TableCell>{param.label}</TableCell>}
+                                    <TableCell>
+                                        {firstVal && (
+                                            <ProcessValuePreview
+                                                value={firstVal}
+                                                showImagePreview={showImagePreview}
+                                            />
+                                        )}
+                                    </TableCell>
+                                    <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
+                                        <Typography className={classes.paramValue}>{firstVal?.collection}</Typography>
+                                    </TableCell>
+                                </TableRow>
+                                {rest.map((val, i) => {
+                                    const rowClasses = {
+                                        [classes.noBorderRow]: i < rest.length - 1,
+                                        [classes.secondaryRow]: val.secondary,
+                                    };
+                                    return (
+                                        <TableRow
+                                            className={classNames(rowClasses)}
+                                            key={i}
+                                        >
+                                            <TableCell />
+                                            {showLabel && <TableCell />}
+                                            <TableCell>
+                                                <ProcessValuePreview
+                                                    value={val}
+                                                    showImagePreview={showImagePreview}
+                                                />
+                                            </TableCell>
+                                            <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
+                                                <Typography className={classes.paramValue}>{val.collection}</Typography>
+                                            </TableCell>
+                                        </TableRow>
+                                    );
+                                })}
+                            </React.Fragment>
+                        );
+                    })}
+                </TableBody>
+            </Table>
+        );
+    })
+);
 
 interface ProcessValuePreviewProps {
     value: ProcessIOValue;
     showImagePreview: boolean;
 }
 
-const ProcessValuePreview = withStyles(styles)(
-    ({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) =>
-        <Typography className={classes.paramValue}>
-            {value.imageUrl && showImagePreview ? <img className={classes.imagePreview} src={value.imageUrl} alt="Inline Preview" /> : ""}
-            {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
-            <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>
-                {value.display}
-            </span>
-        </Typography>
-)
+const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
+    <Typography className={classes.paramValue}>
+        {value.imageUrl && showImagePreview ? (
+            <img
+                className={classes.imagePreview}
+                src={value.imageUrl}
+                alt="Inline Preview"
+            />
+        ) : (
+            ""
+        )}
+        {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
+        <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>{value.display}</span>
+    </Typography>
+));
 
 interface ProcessIORawDataProps {
     data: ProcessIOParameter[];
 }
 
-const ProcessIORaw = withStyles(styles)(
-    ({ data }: ProcessIORawDataProps) =>
-        <Paper elevation={0}>
-            <DefaultCodeSnippet lines={[JSON.stringify(data, null, 2)]} linked />
-        </Paper>
-);
+const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
+    <Paper elevation={0}>
+        <DefaultCodeSnippet
+            lines={[JSON.stringify(data, null, 2)]}
+            linked
+        />
+    </Paper>
+));
 
 interface ProcessInputMountsDataProps {
     mounts: InputCollectionMount[];
@@ -477,136 +618,129 @@ interface ProcessInputMountsDataProps {
 
 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
 
-const ProcessInputMounts = withStyles(styles)(connect((state: RootState) => ({
-    auth: state.auth,
-}))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
-    <Table className={classes.tableRoot} aria-label="Process Input Mounts">
-        <TableHead>
-            <TableRow>
-                <TableCell>Path</TableCell>
-                <TableCell>Portable Data Hash</TableCell>
-            </TableRow>
-        </TableHead>
-        <TableBody>
-            {mounts.map(mount => (
-                <TableRow key={mount.path}>
-                    <TableCell><pre>{mount.path}</pre></TableCell>
-                    <TableCell>
-                        <RouterLink to={getNavUrl(mount.pdh, auth)} className={classes.keepLink}>{mount.pdh}</RouterLink>
-                    </TableCell>
+const ProcessInputMounts = withStyles(styles)(
+    connect((state: RootState) => ({
+        auth: state.auth,
+    }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
+        <Table
+            className={classes.tableRoot}
+            aria-label="Process Input Mounts"
+        >
+            <TableHead>
+                <TableRow>
+                    <TableCell>Path</TableCell>
+                    <TableCell>Portable Data Hash</TableCell>
                 </TableRow>
-            ))}
-        </TableBody>
-    </Table>
-)));
+            </TableHead>
+            <TableBody>
+                {mounts.map(mount => (
+                    <TableRow key={mount.path}>
+                        <TableCell>
+                            <pre>{mount.path}</pre>
+                        </TableCell>
+                        <TableCell>
+                            <RouterLink
+                                to={getNavUrl(mount.pdh, auth)}
+                                className={classes.keepLink}
+                            >
+                                {mount.pdh}
+                            </RouterLink>
+                        </TableCell>
+                    </TableRow>
+                ))}
+            </TableBody>
+        </Table>
+    ))
+);
 
 type FileWithSecondaryFiles = {
     secondaryFiles: File[];
-}
+};
 
 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
     switch (true) {
         case isPrimitiveOfType(input, CWLType.BOOLEAN):
             const boolValue = (input as BooleanCommandInputParameter).value;
-            return boolValue !== undefined &&
-                !(Array.isArray(boolValue) && boolValue.length === 0) ?
-                [{ display: renderPrimitiveValue(boolValue, false) }] :
-                [{ display: <EmptyValue /> }];
+            return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
+                ? [{ display: renderPrimitiveValue(boolValue, false) }]
+                : [{ display: <EmptyValue /> }];
 
         case isPrimitiveOfType(input, CWLType.INT):
         case isPrimitiveOfType(input, CWLType.LONG):
             const intValue = (input as IntCommandInputParameter).value;
             return intValue !== undefined &&
                 // Missing values are empty array
-                !(Array.isArray(intValue) && intValue.length === 0) ?
-                [{ display: renderPrimitiveValue(intValue, false) }]
+                !(Array.isArray(intValue) && intValue.length === 0)
+                [{ display: renderPrimitiveValue(intValue, false) }]
                 : [{ display: <EmptyValue /> }];
 
         case isPrimitiveOfType(input, CWLType.FLOAT):
         case isPrimitiveOfType(input, CWLType.DOUBLE):
             const floatValue = (input as FloatCommandInputParameter).value;
-            return floatValue !== undefined &&
-                !(Array.isArray(floatValue) && floatValue.length === 0) ?
-                [{ display: renderPrimitiveValue(floatValue, false) }] :
-                [{ display: <EmptyValue /> }];
+            return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
+                ? [{ display: renderPrimitiveValue(floatValue, false) }]
+                : [{ display: <EmptyValue /> }];
 
         case isPrimitiveOfType(input, CWLType.STRING):
             const stringValue = (input as StringCommandInputParameter).value || undefined;
-            return stringValue !== undefined &&
-                !(Array.isArray(stringValue) && stringValue.length === 0) ?
-                [{ display: renderPrimitiveValue(stringValue, false) }] :
-                [{ display: <EmptyValue /> }];
+            return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
+                ? [{ display: renderPrimitiveValue(stringValue, false) }]
+                : [{ display: <EmptyValue /> }];
 
         case isPrimitiveOfType(input, CWLType.FILE):
             const mainFile = (input as FileCommandInputParameter).value;
             // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
-            const secondaryFiles = ((mainFile as unknown) as FileWithSecondaryFiles)?.secondaryFiles || [];
-            const files = [
-                ...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []),
-                ...secondaryFiles
-            ];
+            const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
+            const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
             const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
-            return files.length ?
-                files.map((file, i) => fileToProcessIOValue(file, (i > 0), auth, pdh, (i > 0 ? mainFilePdhUrl : ""))) :
-                [{ display: <EmptyValue /> }];
+            return files.length
+                ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
+                [{ display: <EmptyValue /> }];
 
         case isPrimitiveOfType(input, CWLType.DIRECTORY):
             const directory = (input as DirectoryCommandInputParameter).value;
-            return directory !== undefined &&
-                !(Array.isArray(directory) && directory.length === 0) ?
-                [directoryToProcessIOValue(directory, auth, pdh)] :
-                [{ display: <EmptyValue /> }];
+            return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
+                ? [directoryToProcessIOValue(directory, auth, pdh)]
+                : [{ display: <EmptyValue /> }];
 
         case getEnumType(input) !== null:
             const enumValue = (input as EnumCommandInputParameter).value;
-            return enumValue !== undefined && enumValue ?
-                [{ display: <pre>{enumValue}</pre> }] :
-                [{ display: <EmptyValue /> }];
+            return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
 
         case isArrayOfType(input, CWLType.STRING):
             const strArray = (input as StringArrayCommandInputParameter).value || [];
-            return strArray.length ?
-                [{ display: <>{strArray.map((val) => renderPrimitiveValue(val, true))}</> }] :
-                [{ display: <EmptyValue /> }];
+            return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
 
         case isArrayOfType(input, CWLType.INT):
         case isArrayOfType(input, CWLType.LONG):
             const intArray = (input as IntArrayCommandInputParameter).value || [];
-            return intArray.length ?
-                [{ display: <>{intArray.map((val) => renderPrimitiveValue(val, true))}</> }] :
-                [{ display: <EmptyValue /> }];
+            return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
 
         case isArrayOfType(input, CWLType.FLOAT):
         case isArrayOfType(input, CWLType.DOUBLE):
             const floatArray = (input as FloatArrayCommandInputParameter).value || [];
-            return floatArray.length ?
-                [{ display: <>{floatArray.map((val) => renderPrimitiveValue(val, true))}</> }] :
-                [{ display: <EmptyValue /> }];
+            return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
 
         case isArrayOfType(input, CWLType.FILE):
-            const fileArrayMainFiles = ((input as FileArrayCommandInputParameter).value || []);
-            const firstMainFilePdh = (fileArrayMainFiles.length > 0 && fileArrayMainFiles[0]) ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
+            const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
+            const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
 
             // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
             let fileArrayValues: ProcessIOValue[] = [];
             for (let i = 0; i < fileArrayMainFiles.length; i++) {
-                const secondaryFiles = ((fileArrayMainFiles[i] as unknown) as FileWithSecondaryFiles)?.secondaryFiles || [];
+                const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
                 fileArrayValues.push(
                     // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
                     ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
-                    ...(secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh)))
+                    ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
                 );
             }
 
-            return fileArrayValues.length ?
-                fileArrayValues :
-                [{ display: <EmptyValue /> }];
+            return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
 
         case isArrayOfType(input, CWLType.DIRECTORY):
             const directories = (input as DirectoryArrayCommandInputParameter).value || [];
-            return directories.length ?
-                directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) :
-                [{ display: <EmptyValue /> }];
+            return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
 
         default:
             return [{ display: <UnsupportedValue /> }];
@@ -614,9 +748,16 @@ export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParam
 };
 
 const renderPrimitiveValue = (value: any, asChip: boolean) => {
-    const isObject = typeof value === 'object';
+    const isObject = typeof value === "object";
     if (!isObject) {
-        return asChip ? <Chip label={String(value)} /> : <pre>{String(value)}</pre>;
+        return asChip ? (
+            <Chip
+                key={value}
+                label={String(value)}
+            />
+        ) : (
+            <pre key={value}>{String(value)}</pre>
+        );
     } else {
         return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
     }
@@ -626,11 +767,9 @@ const renderPrimitiveValue = (value: any, asChip: boolean) => {
  * @returns keep url without keep: prefix
  */
 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
-    const isKeepUrl = file.location?.startsWith('keep:') || false;
-    const keepUrl = isKeepUrl ?
-        file.location?.replace('keep:', '') :
-        pdh ? `${pdh}/${file.location}` : file.location;
-    return keepUrl || '';
+    const isKeepUrl = file.location?.startsWith("keep:") || false;
+    const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
+    return keepUrl || "";
 };
 
 interface KeepUrlProps {
@@ -641,47 +780,73 @@ interface KeepUrlProps {
 
 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
     const keepUrl = getKeepUrl(res, pdh);
-    return keepUrl ? keepUrl.split('/').slice(0, 1)[0] : '';
+    return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
 };
 
 const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
     const pdhUrl = getResourcePdhUrl(res, pdh);
     // Passing a pdh always returns a relative wb2 collection url
     const pdhWbPath = getNavUrl(pdhUrl, auth);
-    return pdhUrl && pdhWbPath ?
-        <Tooltip title={"View collection in Workbench"}><RouterLink to={pdhWbPath} className={classes.keepLink}>{pdhUrl}</RouterLink></Tooltip> :
-        <></>;
+    return pdhUrl && pdhWbPath ? (
+        <Tooltip title={"View collection in Workbench"}>
+            <RouterLink
+                to={pdhWbPath}
+                className={classes.keepLink}
+            >
+                {pdhUrl}
+            </RouterLink>
+        </Tooltip>
+    ) : (
+        <></>
+    );
 });
 
 const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
     const keepUrl = getKeepUrl(res, pdh);
-    const keepUrlParts = keepUrl ? keepUrl.split('/') : [];
-    const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join('/') : '';
+    const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
+    const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
 
     const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
-    return keepUrlPathNav ?
-        <Tooltip title={"View in keep-web"}><a className={classes.keepLink} href={keepUrlPathNav} target="_blank" rel="noopener noreferrer">{keepUrlPath || '/'}</a></Tooltip> :
-        <EmptyValue />;
+    return keepUrlPathNav ? (
+        <Tooltip title={"View in keep-web"}>
+            <a
+                className={classes.keepLink}
+                href={keepUrlPathNav}
+                target="_blank"
+                rel="noopener noreferrer"
+            >
+                {keepUrlPath || "/"}
+            </a>
+        </Tooltip>
+    ) : (
+        <EmptyValue />
+    );
 });
 
 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
     let keepUrl = getKeepUrl(file, pdh);
-    return (getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl));
+    return getInlineFileUrl(
+        `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
+        auth.config.keepWebServiceUrl,
+        auth.config.keepWebInlineServiceUrl
+    );
 };
 
 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
     const keepUrl = getKeepUrl(file, pdh);
-    return getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl);
+    return getInlineFileUrl(
+        `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
+        auth.config.keepWebServiceUrl,
+        auth.config.keepWebInlineServiceUrl
+    );
 };
 
 const isFileImage = (basename?: string): boolean => {
-    return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
+    return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
 };
 
-const isFileUrl = (location?: string): boolean => (
-    !!location && !KEEP_URL_REGEX.exec(location) &&
-    (location.startsWith("http://") || location.startsWith("https://"))
-);
+const isFileUrl = (location?: string): boolean =>
+    !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
 
 const normalizeDirectoryLocation = (directory: Directory): Directory => {
     if (!directory.location) {
@@ -689,55 +854,92 @@ const normalizeDirectoryLocation = (directory: Directory): Directory => {
     }
     return {
         ...directory,
-        location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
+        location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
     };
 };
 
 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
-    if (isExternalValue(directory)) { return { display: <UnsupportedValue /> } }
+    if (isExternalValue(directory)) {
+        return { display: <UnsupportedValue /> };
+    }
 
     const normalizedDirectory = normalizeDirectoryLocation(directory);
     return {
-        display: <KeepUrlPath auth={auth} res={normalizedDirectory} pdh={pdh} />,
-        collection: <KeepUrlBase auth={auth} res={normalizedDirectory} pdh={pdh} />,
+        display: (
+            <KeepUrlPath
+                auth={auth}
+                res={normalizedDirectory}
+                pdh={pdh}
+            />
+        ),
+        collection: (
+            <KeepUrlBase
+                auth={auth}
+                res={normalizedDirectory}
+                pdh={pdh}
+            />
+        ),
     };
 };
 
 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
-    if (isExternalValue(file)) { return { display: <UnsupportedValue /> } }
+    if (isExternalValue(file)) {
+        return { display: <UnsupportedValue /> };
+    }
 
     if (isFileUrl(file.location)) {
         return {
-            display: <MuiLink href={file.location} target="_blank">{file.location}</MuiLink>,
+            display: (
+                <MuiLink
+                    href={file.location}
+                    target="_blank"
+                >
+                    {file.location}
+                </MuiLink>
+            ),
             secondary,
         };
     }
 
     const resourcePdh = getResourcePdhUrl(file, pdh);
     return {
-        display: <KeepUrlPath auth={auth} res={file} pdh={pdh} />,
+        display: (
+            <KeepUrlPath
+                auth={auth}
+                res={file}
+                pdh={pdh}
+            />
+        ),
         secondary,
         imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
-        collection: (resourcePdh !== mainFilePdh) ? <KeepUrlBase auth={auth} res={file} pdh={pdh} /> : <></>,
-    }
+        collection:
+            resourcePdh !== mainFilePdh ? (
+                <KeepUrlBase
+                    auth={auth}
+                    res={file}
+                    pdh={pdh}
+                />
+            ) : (
+                <></>
+            ),
+    };
 };
 
-const isExternalValue = (val: any) =>
-    Object.keys(val).includes('$import') ||
-    Object.keys(val).includes('$include')
+const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
 
-export const EmptyValue = withStyles(styles)(
-    ({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>
-);
+export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
 
-const UnsupportedValue = withStyles(styles)(
-    ({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>
-);
+const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
 
-const UnsupportedValueChip = withStyles(styles)(
-    ({ classes }: WithStyles<CssRules>) => <Chip icon={<InfoIcon />} label={"Cannot display value"} />
-);
+const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
+    <Chip
+        icon={<InfoIcon />}
+        label={"Cannot display value"}
+    />
+));
 
-const ImagePlaceholder = withStyles(styles)(
-    ({ classes }: WithStyles<CssRules>) => <span className={classes.imagePlaceholder}><ImageIcon /></span>
-);
+const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
+    <span className={classes.imagePlaceholder}>
+        <ImageIcon />
+    </span>
+));
index c04cf62afc41db0a492d5d460ce27536b53faa16..d019d1418fcf0c8841a94989e1211daffaf864b6 100644 (file)
@@ -2,34 +2,34 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { Grid, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
-import { DefaultView } from 'components/default-view/default-view';
-import { ProcessIcon } from 'components/icon/icon';
-import { Process } from 'store/processes/process';
-import { SubprocessPanel } from 'views/subprocess-panel/subprocess-panel';
-import { SubprocessFilterDataProps } from 'components/subprocess-filter/subprocess-filter';
-import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
-import { ArvadosTheme } from 'common/custom-theme';
-import { ProcessDetailsCard } from './process-details-card';
-import { ProcessIOCard, ProcessIOCardType, ProcessIOParameter } from './process-io-card';
-import { ProcessResourceCard } from './process-resource-card';
-import { getProcessPanelLogs, ProcessLogsPanel } from 'store/process-logs-panel/process-logs-panel';
-import { ProcessLogsCard } from './process-log-card';
-import { FilterOption } from 'views/process-panel/process-log-form';
-import { getInputCollectionMounts } from 'store/processes/processes-actions';
-import { WorkflowInputsData } from 'models/workflow';
-import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
-import { AuthState } from 'store/auth/auth-reducer';
-import { ProcessCmdCard } from './process-cmd-card';
-import { ContainerRequestResource } from 'models/container-request';
-import { OutputDetails, NodeInstanceType } from 'store/process-panel/process-panel';
+import React from "react";
+import { Grid, StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core";
+import { DefaultView } from "components/default-view/default-view";
+import { ProcessIcon } from "components/icon/icon";
+import { Process } from "store/processes/process";
+import { SubprocessPanel } from "views/subprocess-panel/subprocess-panel";
+import { SubprocessFilterDataProps } from "components/subprocess-filter/subprocess-filter";
+import { MPVContainer, MPVPanelContent, MPVPanelState } from "components/multi-panel-view/multi-panel-view";
+import { ArvadosTheme } from "common/custom-theme";
+import { ProcessDetailsCard } from "./process-details-card";
+import { ProcessIOCard, ProcessIOCardType, ProcessIOParameter } from "./process-io-card";
+import { ProcessResourceCard } from "./process-resource-card";
+import { getProcessPanelLogs, ProcessLogsPanel } from "store/process-logs-panel/process-logs-panel";
+import { ProcessLogsCard } from "./process-log-card";
+import { FilterOption } from "views/process-panel/process-log-form";
+import { getInputCollectionMounts } from "store/processes/processes-actions";
+import { WorkflowInputsData } from "models/workflow";
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
+import { AuthState } from "store/auth/auth-reducer";
+import { ProcessCmdCard } from "./process-cmd-card";
+import { ContainerRequestResource } from "models/container-request";
+import { OutputDetails, NodeInstanceType } from "store/process-panel/process-panel";
 
-type CssRules = 'root';
+type CssRules = "root";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
-        width: '100%',
+        width: "100%",
     },
 });
 
@@ -94,7 +94,6 @@ export const ProcessPanelRoot = withStyles(styles)(
         updateOutputParams,
         ...props
     }: ProcessPanelRootProps) => {
-
         const outputUuid = process?.containerRequest.outputUuid;
         const containerRequest = process?.containerRequest;
         const inputMounts = getInputCollectionMounts(process?.containerRequest);
@@ -118,9 +117,18 @@ export const ProcessPanelRoot = withStyles(styles)(
             updateOutputParams();
         }, [outputRaw, outputDefinitions, updateOutputParams]);
 
-        return process
-            ? <MPVContainer className={props.classes.root} spacing={8} panelStates={panelsData} justify-content="flex-start" direction="column" wrap="nowrap">
-                <MPVPanelContent forwardProps xs="auto" data-cy="process-details">
+        return process ? (
+            <MPVContainer
+                className={props.classes.root}
+                spacing={8}
+                panelStates={panelsData}
+                justify-content="flex-start"
+                direction="column"
+                wrap="nowrap">
+                <MPVPanelContent
+                    forwardProps
+                    xs="auto"
+                    data-cy="process-details">
                     <ProcessDetailsCard
                         process={process}
                         onContextMenu={event => props.onContextMenu(event, process)}
@@ -129,29 +137,39 @@ export const ProcessPanelRoot = withStyles(styles)(
                         resumeOnHoldWorkflow={props.resumeOnHoldWorkflow}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs="auto" data-cy="process-cmd">
+                <MPVPanelContent
+                    forwardProps
+                    xs="auto"
+                    data-cy="process-cmd">
                     <ProcessCmdCard
                         onCopy={props.onCopyToClipboard}
-                        process={process} />
+                        process={process}
+                    />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs minHeight='50%' data-cy="process-logs">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    minHeight="50%"
+                    data-cy="process-logs">
                     <ProcessLogsCard
                         onCopy={props.onCopyToClipboard}
                         process={process}
                         lines={getProcessPanelLogs(processLogsPanel)}
                         selectedFilter={{
                             label: processLogsPanel.selectedFilter,
-                            value: processLogsPanel.selectedFilter
+                            value: processLogsPanel.selectedFilter,
                         }}
-                        filters={processLogsPanel.filters.map(
-                            filter => ({ label: filter, value: filter })
-                        )}
+                        filters={processLogsPanel.filters.map(filter => ({ label: filter, value: filter }))}
                         onLogFilterChange={props.onLogFilterChange}
                         navigateToLog={props.navigateToLog}
                         pollProcessLogs={props.pollProcessLogs}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-inputs">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight="50%"
+                    data-cy="process-inputs">
                     <ProcessIOCard
                         label={ProcessIOCardType.INPUT}
                         process={process}
@@ -160,7 +178,11 @@ export const ProcessPanelRoot = withStyles(styles)(
                         mounts={inputMounts}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-outputs">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight="50%"
+                    data-cy="process-outputs">
                     <ProcessIOCard
                         label={ProcessIOCardType.OUTPUT}
                         process={process}
@@ -169,23 +191,34 @@ export const ProcessPanelRoot = withStyles(styles)(
                         outputUuid={outputUuid || ""}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs data-cy="process-resources">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    data-cy="process-resources">
                     <ProcessResourceCard
                         process={process}
                         nodeInfo={nodeInfo}
                     />
                 </MPVPanelContent>
-                <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-children">
+                <MPVPanelContent
+                    forwardProps
+                    xs
+                    maxHeight="50%"
+                    data-cy="process-children">
                     <SubprocessPanel />
                 </MPVPanelContent>
             </MPVContainer>
-            : <Grid container
-                alignItems='center'
-                justify='center'
-                style={{ minHeight: '100%' }}>
+        ) : (
+            <Grid
+                container
+                alignItems="center"
+                justify="center"
+                style={{ minHeight: "100%" }}>
                 <DefaultView
                     icon={ProcessIcon}
-                    messages={['Process not found']} />
-            </Grid>;
+                    messages={["Process not found"]}
+                />
+            </Grid>
+        );
     }
 );
index 575c6591ef0481902a42797fa490a441180e7bc2..4a6b5fd33344600e1a5e6af1d71e4ecbd09b0a29 100644 (file)
@@ -2,35 +2,28 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { RootState } from 'store/store';
-import { connect } from 'react-redux';
-import { getProcess, getSubprocesses, Process, getProcessStatus } from 'store/processes/process';
-import { Dispatch } from 'redux';
-import { openProcessContextMenu } from 'store/context-menu/context-menu-actions';
-import {
-    ProcessPanelRootDataProps,
-    ProcessPanelRootActionProps,
-    ProcessPanelRoot
-} from './process-panel-root';
-import {
-    getProcessPanelCurrentUuid,
-    ProcessPanel as ProcessPanelState
-} from 'store/process-panel/process-panel';
-import { groupBy } from 'lodash';
+import { RootState } from "store/store";
+import { connect } from "react-redux";
+import { getProcess, getSubprocesses, Process, getProcessStatus } from "store/processes/process";
+import { Dispatch } from "redux";
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { ProcessPanelRootDataProps, ProcessPanelRootActionProps, ProcessPanelRoot } from "./process-panel-root";
+import { getProcessPanelCurrentUuid, ProcessPanel as ProcessPanelState } from "store/process-panel/process-panel";
+import { groupBy } from "lodash";
 import {
     loadInputs,
     loadOutputDefinitions,
     loadOutputs,
     toggleProcessPanelFilter,
     updateOutputParams,
-    loadNodeJson
-} from 'store/process-panel/process-panel-actions';
-import { cancelRunningWorkflow, resumeOnHoldWorkflow, startWorkflow } from 'store/processes/processes-actions';
-import { navigateToLogCollection, pollProcessLogs, setProcessLogsPanelFilter } from 'store/process-logs-panel/process-logs-panel-actions';
-import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
+    loadNodeJson,
+} from "store/process-panel/process-panel-actions";
+import { cancelRunningWorkflow, resumeOnHoldWorkflow, startWorkflow } from "store/processes/processes-actions";
+import { navigateToLogCollection, pollProcessLogs, setProcessLogsPanelFilter } from "store/process-logs-panel/process-logs-panel-actions";
+import { snackbarActions, SnackbarKind } from "store/snackbar/snackbar-actions";
 
 const mapStateToProps = ({ router, auth, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
-    const uuid = getProcessPanelCurrentUuid(router) || '';
+    const uuid = getProcessPanelCurrentUuid(router) || "";
     const subprocesses = getSubprocesses(uuid)(resources);
     return {
         process: getProcess(uuid)(resources),
@@ -49,41 +42,43 @@ const mapStateToProps = ({ router, auth, resources, processPanel, processLogsPan
 
 const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps => ({
     onCopyToClipboard: (message: string) => {
-        dispatch<any>(snackbarActions.OPEN_SNACKBAR({
-            message,
-            hideDuration: 2000,
-            kind: SnackbarKind.SUCCESS,
-        }));
+        dispatch<any>(
+            snackbarActions.OPEN_SNACKBAR({
+                message,
+                hideDuration: 2000,
+                kind: SnackbarKind.SUCCESS,
+            })
+        );
     },
     onContextMenu: (event, process) => {
-        dispatch<any>(openProcessContextMenu(event, process));
+        if (process) {
+            dispatch<any>(openProcessContextMenu(event, process));
+        }
     },
     onToggle: status => {
         dispatch<any>(toggleProcessPanelFilter(status));
     },
-    cancelProcess: (uuid) => dispatch<any>(cancelRunningWorkflow(uuid)),
-    startProcess: (uuid) => dispatch<any>(startWorkflow(uuid)),
-    resumeOnHoldWorkflow: (uuid) => dispatch<any>(resumeOnHoldWorkflow(uuid)),
-    onLogFilterChange: (filter) => dispatch(setProcessLogsPanelFilter(filter.value)),
-    navigateToLog: (uuid) => dispatch<any>(navigateToLogCollection(uuid)),
-    loadInputs: (containerRequest) => dispatch<any>(loadInputs(containerRequest)),
-    loadOutputs: (containerRequest) => dispatch<any>(loadOutputs(containerRequest)),
-    loadOutputDefinitions: (containerRequest) => dispatch<any>(loadOutputDefinitions(containerRequest)),
+    cancelProcess: uuid => dispatch<any>(cancelRunningWorkflow(uuid)),
+    startProcess: uuid => dispatch<any>(startWorkflow(uuid)),
+    resumeOnHoldWorkflow: uuid => dispatch<any>(resumeOnHoldWorkflow(uuid)),
+    onLogFilterChange: filter => dispatch(setProcessLogsPanelFilter(filter.value)),
+    navigateToLog: uuid => dispatch<any>(navigateToLogCollection(uuid)),
+    loadInputs: containerRequest => dispatch<any>(loadInputs(containerRequest)),
+    loadOutputs: containerRequest => dispatch<any>(loadOutputs(containerRequest)),
+    loadOutputDefinitions: containerRequest => dispatch<any>(loadOutputDefinitions(containerRequest)),
     updateOutputParams: () => dispatch<any>(updateOutputParams()),
-    loadNodeJson: (containerRequest) => dispatch<any>(loadNodeJson(containerRequest)),
-    pollProcessLogs: (processUuid) => dispatch<any>(pollProcessLogs(processUuid)),
+    loadNodeJson: containerRequest => dispatch<any>(loadNodeJson(containerRequest)),
+    pollProcessLogs: processUuid => dispatch<any>(pollProcessLogs(processUuid)),
 });
 
 const getFilters = (processPanel: ProcessPanelState, processes: Process[]) => {
     const grouppedProcesses = groupBy(processes, getProcessStatus);
-    return Object
-        .keys(processPanel.filters)
-        .map(filter => ({
-            label: filter,
-            value: (grouppedProcesses[filter] || []).length,
-            checked: processPanel.filters[filter],
-            key: filter,
-        }));
+    return Object.keys(processPanel.filters).map(filter => ({
+        label: filter,
+        value: (grouppedProcesses[filter] || []).length,
+        checked: processPanel.filters[filter],
+        key: filter,
+    }));
 };
 
 export const ProcessPanel = connect(mapStateToProps, mapDispatchToProps)(ProcessPanelRoot);
index 684fd448443b7102042b3527cebbb5d001ecd3ae..4c94ab8d2dd05f87fd970f4c9b5a8369f4729cef 100644 (file)
@@ -3,12 +3,12 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import withStyles from "@material-ui/core/styles/withStyles";
+import withStyles from '@material-ui/core/styles/withStyles';
 import { DispatchProp, connect } from 'react-redux';
 import { RouteComponentProps } from 'react-router';
-import { StyleRulesCallback, WithStyles } from "@material-ui/core";
+import { StyleRulesCallback, WithStyles } from '@material-ui/core';
 
-import { DataExplorer } from "views-components/data-explorer/data-explorer";
+import { DataExplorer } from 'views-components/data-explorer/data-explorer';
 import { DataColumns } from 'components/data-table/data-table';
 import { RootState } from 'store/store';
 import { DataTableFilterItem } from 'components/data-table-filters/data-table-filters';
@@ -37,61 +37,52 @@ import {
     ResourceDeleteDate,
 } from 'views-components/data-explorer/renderers';
 import { ProjectIcon } from 'components/icon/icon';
-import {
-    ResourcesState,
-    getResource
-} from 'store/resources/resources';
+import { ResourcesState, getResource } from 'store/resources/resources';
 import { loadDetailsPanel } from 'store/details-panel/details-panel-action';
-import {
-    openContextMenu,
-    resourceUuidToContextMenuKind
-} from 'store/context-menu/context-menu-actions';
+import { openContextMenu, resourceUuidToContextMenuKind } from 'store/context-menu/context-menu-actions';
 import { navigateTo } from 'store/navigation/navigation-action';
 import { getProperty } from 'store/properties/properties';
 import { PROJECT_PANEL_CURRENT_UUID } from 'store/project-panel/project-panel-action';
-import { ArvadosTheme } from "common/custom-theme";
+import { ArvadosTheme } from 'common/custom-theme';
 import { createTree } from 'models/tree';
-import {
-    getInitialResourceTypeFilters,
-    getInitialProcessStatusFilters
-} from 'store/resource-type-filters/resource-type-filters';
+import { getInitialResourceTypeFilters, getInitialProcessStatusFilters } from 'store/resource-type-filters/resource-type-filters';
 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';
 import { ProjectResource } from 'models/project';
 
-type CssRules = 'root' | "button";
+type CssRules = 'root' | 'button';
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         width: '100%',
     },
     button: {
-        marginLeft: theme.spacing.unit
+        marginLeft: theme.spacing.unit,
     },
 });
 
 export enum ProjectPanelColumnNames {
-    NAME = "Name",
-    STATUS = "Status",
-    TYPE = "Type",
-    OWNER = "Owner",
-    PORTABLE_DATA_HASH = "Portable Data Hash",
-    FILE_SIZE = "File Size",
-    FILE_COUNT = "File Count",
-    UUID = "UUID",
-    CONTAINER_UUID = "Container UUID",
-    RUNTIME = "Runtime",
-    OUTPUT_UUID = "Output UUID",
-    LOG_UUID = "Log UUID",
+    NAME = 'Name',
+    STATUS = 'Status',
+    TYPE = 'Type',
+    OWNER = 'Owner',
+    PORTABLE_DATA_HASH = 'Portable Data Hash',
+    FILE_SIZE = 'File Size',
+    FILE_COUNT = 'File Count',
+    UUID = 'UUID',
+    CONTAINER_UUID = 'Container UUID',
+    RUNTIME = 'Runtime',
+    OUTPUT_UUID = 'Output UUID',
+    LOG_UUID = 'Log UUID',
     PARENT_PROCESS = 'Parent Process UUID',
     MODIFIED_BY_USER_UUID = 'Modified by User UUID',
-    VERSION = "Version",
-    CREATED_AT = "Date Created",
-    LAST_MODIFIED = "Last Modified",
-    TRASH_AT = "Trash at",
-    DELETE_AT = "Delete at",
+    VERSION = 'Version',
+    CREATED_AT = 'Date Created',
+    LAST_MODIFIED = 'Last Modified',
+    TRASH_AT = 'Trash at',
+    DELETE_AT = 'Delete at',
 }
 
 export interface ProjectPanelFilter extends DataTableFilterItem {
@@ -103,9 +94,9 @@ export const projectPanelColumns: DataColumns<string, ProjectResource> = [
         name: ProjectPanelColumnNames.NAME,
         selected: true,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "name"},
+        sort: { direction: SortDirection.NONE, field: 'name' },
         filters: createTree(),
-        render: uuid => <ResourceName uuid={uuid} />
+        render: (uuid) => <ResourceName uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.STATUS,
@@ -113,140 +104,136 @@ export const projectPanelColumns: DataColumns<string, ProjectResource> = [
         configurable: true,
         mutuallyExclusiveFilters: true,
         filters: getInitialProcessStatusFilters(),
-        render: uuid => <ResourceStatus uuid={uuid} />,
+        render: (uuid) => <ResourceStatus uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.TYPE,
         selected: true,
         configurable: true,
         filters: getInitialResourceTypeFilters(),
-        render: uuid => <ResourceType uuid={uuid} />
+        render: (uuid) => <ResourceType uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.OWNER,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceOwnerWithName uuid={uuid} />
+        render: (uuid) => <ResourceOwnerWithName uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.PORTABLE_DATA_HASH,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourcePortableDataHash uuid={uuid} />
+        render: (uuid) => <ResourcePortableDataHash uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.FILE_SIZE,
         selected: true,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceFileSize uuid={uuid} />
+        render: (uuid) => <ResourceFileSize uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.FILE_COUNT,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceFileCount uuid={uuid} />
+        render: (uuid) => <ResourceFileCount uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceUUID uuid={uuid} />
+        render: (uuid) => <ResourceUUID uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.CONTAINER_UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceContainerUuid uuid={uuid} />
+        render: (uuid) => <ResourceContainerUuid uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.RUNTIME,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ContainerRunTime uuid={uuid} />
+        render: (uuid) => <ContainerRunTime uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.OUTPUT_UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceOutputUuid uuid={uuid} />
+        render: (uuid) => <ResourceOutputUuid uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.LOG_UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceLogUuid uuid={uuid} />
+        render: (uuid) => <ResourceLogUuid uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.PARENT_PROCESS,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceParentProcess uuid={uuid} />
+        render: (uuid) => <ResourceParentProcess uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.MODIFIED_BY_USER_UUID,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceModifiedByUserUuid uuid={uuid} />
+        render: (uuid) => <ResourceModifiedByUserUuid uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.VERSION,
         selected: false,
         configurable: true,
         filters: createTree(),
-        render: uuid => <ResourceVersion uuid={uuid} />
+        render: (uuid) => <ResourceVersion uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.CREATED_AT,
         selected: false,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "createdAt"},
+        sort: { direction: SortDirection.NONE, field: 'createdAt' },
         filters: createTree(),
-        render: uuid => <ResourceCreatedAtDate uuid={uuid} />
+        render: (uuid) => <ResourceCreatedAtDate uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.LAST_MODIFIED,
         selected: true,
         configurable: true,
-        sort: {direction: SortDirection.DESC, field: "modifiedAt"},
+        sort: { direction: SortDirection.DESC, field: 'modifiedAt' },
         filters: createTree(),
-        render: uuid => <ResourceLastModifiedDate uuid={uuid} />
+        render: (uuid) => <ResourceLastModifiedDate uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.TRASH_AT,
         selected: false,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "trashAt"},
+        sort: { direction: SortDirection.NONE, field: 'trashAt' },
         filters: createTree(),
-        render: uuid => <ResourceTrashDate uuid={uuid} />
+        render: (uuid) => <ResourceTrashDate uuid={uuid} />,
     },
     {
         name: ProjectPanelColumnNames.DELETE_AT,
         selected: false,
         configurable: true,
-        sort: {direction: SortDirection.NONE, field: "deleteAt"},
+        sort: { direction: SortDirection.NONE, field: 'deleteAt' },
         filters: createTree(),
-        render: uuid => <ResourceDeleteDate uuid={uuid} />
+        render: (uuid) => <ResourceDeleteDate uuid={uuid} />,
     },
-
 ];
 
-export const PROJECT_PANEL_ID = "projectPanel";
+export const PROJECT_PANEL_ID = 'projectPanel';
 
-const DEFAULT_VIEW_MESSAGES = [
-    'Your project is empty.',
-    'Please create a project or create a collection and upload a data.',
-];
+const DEFAULT_VIEW_MESSAGES = ['Your project is empty.', 'Please create a project or create a collection and upload a data.'];
 
 interface ProjectPanelDataProps {
     currentItemId: string;
@@ -256,36 +243,36 @@ interface ProjectPanelDataProps {
     dataExplorerItems: any;
 }
 
-type ProjectPanelProps = ProjectPanelDataProps & DispatchProp
-    & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
-
+type ProjectPanelProps = ProjectPanelDataProps & DispatchProp & WithStyles<CssRules> & RouteComponentProps<{ id: string }>;
 
 export const ProjectPanel = withStyles(styles)(
     connect((state: RootState) => ({
         currentItemId: getProperty(PROJECT_PANEL_CURRENT_UUID)(state.properties),
         resources: state.resources,
-        userUuid: state.auth.user!.uuid
+        userUuid: state.auth.user!.uuid,
     }))(
         class extends React.Component<ProjectPanelProps> {
             render() {
                 const { classes } = this.props;
 
-                return <div data-cy='project-panel' className={classes.root}>
-                    <DataExplorer
-                        id={PROJECT_PANEL_ID}
-                        onRowClick={this.handleRowClick}
-                        onRowDoubleClick={this.handleRowDoubleClick}
-                        onContextMenu={this.handleContextMenu}
-                        contextMenuColumn={true}
-                        defaultViewIcon={ProjectIcon}
-                        defaultViewMessages={DEFAULT_VIEW_MESSAGES}
-                    />
-                </div>;
+                return (
+                    <div data-cy='project-panel' className={classes.root}>
+                        <DataExplorer
+                            id={PROJECT_PANEL_ID}
+                            onRowClick={this.handleRowClick}
+                            onRowDoubleClick={this.handleRowDoubleClick}
+                            onContextMenu={this.handleContextMenu}
+                            contextMenuColumn={true}
+                            defaultViewIcon={ProjectIcon}
+                            defaultViewMessages={DEFAULT_VIEW_MESSAGES}
+                        />
+                    </div>
+                );
             }
 
             isCurrentItemChild = (resource: Resource) => {
                 return resource.ownerUuid === this.props.currentItemId;
-            }
+            };
 
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
                 const { resources, isAdmin } = this.props;
@@ -299,31 +286,32 @@ export const ProjectPanel = withStyles(styles)(
 
                 const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid, readonly));
                 if (menuKind && resource) {
-                    this.props.dispatch<any>(openContextMenu(event, {
-                        name: resource.name,
-                        uuid: resource.uuid,
-                        ownerUuid: resource.ownerUuid,
-                        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 : {},
-                    }));
+                    this.props.dispatch<any>(
+                        openContextMenu(event, {
+                            name: resource.name,
+                            uuid: resource.uuid,
+                            ownerUuid: resource.ownerUuid,
+                            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 : {},
+                        })
+                    );
                 }
                 this.props.dispatch<any>(loadDetailsPanel(resourceUuid));
-            }
+            };
 
             handleRowDoubleClick = (uuid: string) => {
                 this.props.dispatch<any>(navigateTo(uuid));
-            }
+            };
 
             handleRowClick = (uuid: string) => {
                 this.props.dispatch<any>(loadDetailsPanel(uuid));
-            }
-
+            };
         }
     )
 );
index 27255bd961e99f9db306ff5e2a637c921352a2b6..dd5bb2f8ea982da4e36b9078fc8e893c092d7bfb 100644 (file)
@@ -15,7 +15,7 @@ import { Input, Dialog, DialogTitle, DialogContent, DialogActions, Button, Divid
 import { GenericInputProps, GenericInput } from './generic-input';
 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
 import { connect, DispatchProp } from 'react-redux';
-import { initProjectsTreePicker, getSelectedNodes, treePickerActions, getProjectsTreePickerIds, getAllNodes } from 'store/tree-picker/tree-picker-actions';
+import { initProjectsTreePicker, getSelectedNodes, treePickerActions, getProjectsTreePickerIds, FileOperationLocation, getFileOperationLocation, fileOperationLocationToPickerId } from 'store/tree-picker/tree-picker-actions';
 import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
 import { createSelector, createStructuredSelector } from 'reselect';
 import { ChipsInput } from 'components/chips-input/chips-input';
@@ -26,8 +26,11 @@ import { RootState } from 'store/store';
 import { Chips } from 'components/chips/chips';
 import withStyles, { StyleRulesCallback } from '@material-ui/core/styles/withStyles';
 import { CollectionResource } from 'models/collection';
-import { ResourceKind } from 'models/resource';
+import { PORTABLE_DATA_HASH_PATTERN, ResourceKind } from 'models/resource';
+import { Dispatch } from 'redux';
+import { CollectionDirectory, CollectionFileType } from 'models/collection-file';
 
+const LOCATION_REGEX = new RegExp("^(?:keep:)?(" + PORTABLE_DATA_HASH_PATTERN + ")(/.*)?$");
 export interface DirectoryArrayInputProps {
     input: DirectoryArrayCommandInputParameter;
     options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
@@ -45,26 +48,35 @@ export const DirectoryArrayInput = ({ input }: DirectoryArrayInputProps) =>
 interface FormattedDirectory {
     name: string;
     portableDataHash: string;
+    subpath: string;
 }
 
-const parseDirectories = (directories: CollectionResource[] | string) =>
+const parseDirectories = (directories: FileOperationLocation[] | string) =>
     typeof directories === 'string'
         ? undefined
         : directories.map(parse);
 
-const parse = (directory: CollectionResource): Directory => ({
+const parse = (directory: FileOperationLocation): Directory => ({
     class: CWLType.DIRECTORY,
     basename: directory.name,
-    location: `keep:${directory.portableDataHash}`,
+    location: `keep:${directory.pdh}${directory.subpath}`,
 });
 
-const formatDirectories = (directories: Directory[] = []) =>
-    directories ? directories.map(format) : [];
+const formatDirectories = (directories: Directory[] = []): FormattedDirectory[] =>
+    directories ? directories.map(format).filter((dir): dir is FormattedDirectory => Boolean(dir)) : [];
 
-const format = ({ location = '', basename = '' }: Directory): FormattedDirectory => ({
-    portableDataHash: location.replace('keep:', ''),
-    name: basename,
-});
+const format = ({ location = '', basename = '' }: Directory): FormattedDirectory | undefined => {
+    const match = LOCATION_REGEX.exec(location);
+
+    if (match) {
+        return {
+            portableDataHash: match[1],
+            subpath: match[2],
+            name: basename,
+        };
+    }
+    return undefined;
+};
 
 const validationSelector = createSelector(
     isRequiredInput,
@@ -79,11 +91,10 @@ const required = (value?: Directory[]) =>
         : ERROR_MESSAGE;
 interface DirectoryArrayInputComponentState {
     open: boolean;
-    directories: CollectionResource[];
-    prevDirectories: CollectionResource[];
+    directories: FileOperationLocation[];
 }
 
-interface DirectoryArrayInputComponentProps {
+interface DirectoryArrayInputDataProps {
     treePickerState: TreePicker;
 }
 
@@ -93,21 +104,39 @@ const mapStateToProps = createStructuredSelector({
     treePickerState: treePickerSelector,
 });
 
-const DirectoryArrayInputComponent = connect(mapStateToProps)(
-    class DirectoryArrayInputComponent extends React.Component<DirectoryArrayInputComponentProps & GenericInputProps & DispatchProp & {
+interface DirectoryArrayInputActionProps {
+    initProjectsTreePicker: (pickerId: string) => void;
+    selectTreePickerNode: (pickerId: string, id: string | string[]) => void;
+    deselectTreePickerNode: (pickerId: string, id: string | string[]) => void;
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): DirectoryArrayInputActionProps => ({
+    initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)),
+    selectTreePickerNode: (pickerId: string, id: string | string[]) =>
+        dispatch<any>(treePickerActions.SELECT_TREE_PICKER_NODE({
+            pickerId, id, cascade: false
+        })),
+    deselectTreePickerNode: (pickerId: string, id: string | string[]) =>
+        dispatch<any>(treePickerActions.DESELECT_TREE_PICKER_NODE({
+            pickerId, id, cascade: false
+        })),
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+const DirectoryArrayInputComponent = connect(mapStateToProps, mapDispatchToProps)(
+    class DirectoryArrayInputComponent extends React.Component<GenericInputProps & DirectoryArrayInputDataProps & DirectoryArrayInputActionProps & DispatchProp & {
         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
     }, DirectoryArrayInputComponentState> {
         state: DirectoryArrayInputComponentState = {
             open: false,
             directories: [],
-            prevDirectories: [],
         };
 
         directoryRefreshTimeout = -1;
 
         componentDidMount() {
-            this.props.dispatch<any>(
-                initProjectsTreePicker(this.props.commandInput.id));
+            this.props.initProjectsTreePicker(this.props.commandInput.id);
         }
 
         render() {
@@ -118,7 +147,6 @@ const DirectoryArrayInputComponent = connect(mapStateToProps)(
         }
 
         openDialog = () => {
-            this.setDirectoriesFromProps(this.props.input.value);
             this.setState({ open: true });
         }
 
@@ -131,82 +159,52 @@ const DirectoryArrayInputComponent = connect(mapStateToProps)(
             this.props.input.onChange(this.state.directories);
         }
 
-        setDirectories = (directories: CollectionResource[]) => {
+        setDirectoriesFromResources = async (directories: (CollectionResource | CollectionDirectory)[]) => {
+            const locations = (await Promise.all(
+                directories.map(directory => (this.props.getFileOperationLocation(directory)))
+            )).filter((location): location is FileOperationLocation => (
+                location !== undefined
+            ));
 
-            const deletedDirectories = this.state.directories
-                .reduce((deletedDirectories, directory) =>
-                    directories.some(({ uuid }) => uuid === directory.uuid)
-                        ? deletedDirectories
-                        : [...deletedDirectories, directory]
-                    , []);
-
-            this.setState({ directories });
-
-            const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
-            ids.forEach(pickerId => {
-                this.props.dispatch(
-                    treePickerActions.DESELECT_TREE_PICKER_NODE({
-                        pickerId, id: deletedDirectories.map(({ uuid }) => uuid),
-                    })
-                );
-            });
+            this.setDirectories(locations);
+        }
 
+        refreshDirectories = () => {
+            clearTimeout(this.directoryRefreshTimeout);
+            this.directoryRefreshTimeout = window.setTimeout(this.setDirectoriesFromTree);
         }
 
-        setDirectoriesFromProps = (formattedDirectories: FormattedDirectory[]) => {
-            const nodes = getAllNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
-            const initialDirectories: CollectionResource[] = [];
+        setDirectoriesFromTree = () => {
+            const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
+            const initialDirectories: (CollectionResource | CollectionDirectory)[] = [];
             const directories = nodes
                 .reduce((directories, { value }) =>
-                    'kind' in value &&
-                        value.kind === ResourceKind.COLLECTION &&
-                        formattedDirectories.find(({ portableDataHash, name }) => value.portableDataHash === portableDataHash && value.name === name)
+                    (('kind' in value && value.kind === ResourceKind.COLLECTION) ||
+                    ('type' in value && value.type === CollectionFileType.DIRECTORY))
                         ? directories.concat(value)
                         : directories, initialDirectories);
+            this.setDirectoriesFromResources(directories);
+        }
+
+        setDirectories = (locations: FileOperationLocation[]) => {
+            const deletedDirectories = this.state.directories
+                .reduce((deletedDirectories, directory) =>
+                    locations.some(({ uuid, subpath }) => uuid === directory.uuid && subpath === directory.subpath)
+                        ? deletedDirectories
+                        : [...deletedDirectories, directory]
+                    , [] as FileOperationLocation[]);
 
-            const addedDirectories = directories
-                .reduce((addedDirectories, directory) =>
-                    this.state.directories.find(({ uuid }) =>
-                        uuid === directory.uuid)
-                        ? addedDirectories
-                        : [...addedDirectories, directory]
-                    , []);
+            this.setState({ directories: locations });
 
             const ids = values(getProjectsTreePickerIds(this.props.commandInput.id));
             ids.forEach(pickerId => {
-                this.props.dispatch(
-                    treePickerActions.SELECT_TREE_PICKER_NODE({
-                        pickerId, id: addedDirectories.map(({ uuid }) => uuid),
-                    })
+                this.props.deselectTreePickerNode(
+                    pickerId,
+                    deletedDirectories.map(fileOperationLocationToPickerId)
                 );
             });
+        };
 
-            const orderedDirectories = formattedDirectories.reduce((dirs, formattedDir) => {
-                const dir = directories.find(({ portableDataHash, name }) => portableDataHash === formattedDir.portableDataHash && name === formattedDir.name);
-                return dir
-                    ? [...dirs, dir]
-                    : dirs;
-            }, []);
-
-            this.setDirectories(orderedDirectories);
-
-        }
-
-        refreshDirectories = () => {
-            clearTimeout(this.directoryRefreshTimeout);
-            this.directoryRefreshTimeout = window.setTimeout(this.setSelectedFiles);
-        }
-
-        setSelectedFiles = () => {
-            const nodes = getSelectedNodes<ProjectsTreePickerItem>(this.props.commandInput.id)(this.props.treePickerState);
-            const initialDirectories: CollectionResource[] = [];
-            const directories = nodes
-                .reduce((directories, { value }) =>
-                    'kind' in value && value.kind === ResourceKind.COLLECTION
-                        ? directories.concat(value)
-                        : directories, initialDirectories);
-            this.setDirectories(directories);
-        }
         input = () =>
             <GenericInput
                 component={this.chipsInput}
@@ -265,14 +263,17 @@ const DirectoryArrayInputComponent = connect(mapStateToProps)(
                     onClose={this.closeDialog}
                     fullWidth
                     maxWidth='md' >
-                    <DialogTitle>Choose collections</DialogTitle>
+                    <DialogTitle>Choose directories</DialogTitle>
                     <DialogContent className={classes.root}>
                         <div className={classes.pickerWrapper}>
                             <div className={classes.tree}>
                                 <ProjectsTreePicker
                                     pickerId={this.props.commandInput.id}
+                                    currentUuids={this.state.directories.map(dir => fileOperationLocationToPickerId(dir))}
                                     includeCollections
+                                    includeDirectories
                                     showSelection
+                                    cascadeSelection={false}
                                     options={this.props.options}
                                     toggleItemSelection={this.refreshDirectories} />
                             </div>
index 5348cc2b76ca93a4e24fc1d9474702bc327867e2..63c990fa9f2cb513759bc1d87caa52c1b440b220 100644 (file)
@@ -15,12 +15,11 @@ import {
 } from 'models/workflow';
 import { GenericInputProps, GenericInput } from './generic-input';
 import { ProjectsTreePicker } from 'views-components/projects-tree-picker/projects-tree-picker';
-import { initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
+import { FileOperationLocation, getFileOperationLocation, initProjectsTreePicker } from 'store/tree-picker/tree-picker-actions';
 import { TreeItem } from 'components/tree/tree';
 import { ProjectsTreePickerItem } from 'store/tree-picker/tree-picker-middleware';
-import { CollectionResource } from 'models/collection';
-import { ResourceKind } from 'models/resource';
 import { ERROR_MESSAGE } from 'validators/require';
+import { Dispatch } from 'redux';
 
 export interface DirectoryInputProps {
     input: DirectoryCommandInputParameter;
@@ -43,9 +42,9 @@ export const DirectoryInput = ({ input, options }: DirectoryInputProps) =>
 
 const format = (value?: Directory) => value ? value.basename : '';
 
-const parse = (directory: CollectionResource): Directory => ({
+const parse = (directory: FileOperationLocation): Directory => ({
     class: CWLType.DIRECTORY,
-    location: `keep:${directory.portableDataHash}`,
+    location: `keep:${directory.pdh}${directory.subpath}`,
     basename: directory.name,
 });
 
@@ -59,11 +58,21 @@ const getValidation = memoize(
 
 interface DirectoryInputComponentState {
     open: boolean;
-    directory?: CollectionResource;
+    directory?: FileOperationLocation;
 }
 
-const DirectoryInputComponent = connect()(
-    class FileInputComponent extends React.Component<GenericInputProps & DispatchProp & {
+interface DirectoryInputActionProps {
+    initProjectsTreePicker: (pickerId: string) => void;
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => Promise<FileOperationLocation | undefined>;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): DirectoryInputActionProps => ({
+    initProjectsTreePicker: (pickerId: string) => dispatch<any>(initProjectsTreePicker(pickerId)),
+    getFileOperationLocation: (item: ProjectsTreePickerItem) => dispatch<any>(getFileOperationLocation(item)),
+});
+
+const DirectoryInputComponent = connect(null, mapDispatchToProps)(
+    class FileInputComponent extends React.Component<GenericInputProps & DirectoryInputActionProps & DispatchProp & {
         options?: { showOnlyOwned: boolean, showOnlyWritable: boolean };
     }, DirectoryInputComponentState> {
         state: DirectoryInputComponentState = {
@@ -71,8 +80,7 @@ const DirectoryInputComponent = connect()(
         };
 
         componentDidMount() {
-            this.props.dispatch<any>(
-                initProjectsTreePicker(this.props.commandInput.id));
+            this.props.initProjectsTreePicker(this.props.commandInput.id);
         }
 
         render() {
@@ -95,12 +103,9 @@ const DirectoryInputComponent = connect()(
             this.props.input.onChange(this.state.directory);
         }
 
-        setDirectory = (_: {}, { data }: TreeItem<ProjectsTreePickerItem>) => {
-            if ('kind' in data && data.kind === ResourceKind.COLLECTION) {
-                this.setState({ directory: data });
-            } else {
-                this.setState({ directory: undefined });
-            }
+        setDirectory = async (_: {}, { data: item }: TreeItem<ProjectsTreePickerItem>) => {
+            const location = await this.props.getFileOperationLocation(item);
+            this.setState({ directory: location });
         }
 
         renderInput() {
@@ -143,6 +148,8 @@ const DirectoryInputComponent = connect()(
                             <ProjectsTreePicker
                                 pickerId={this.props.commandInput.id}
                                 includeCollections
+                                includeDirectories
+                                cascadeSelection={false}
                                 options={this.props.options}
                                 toggleItemActive={this.setDirectory} />
                         </div>
index 1e1a42998f50d11e3cc0151a241f90f443b069e7..99338738fa5e03c67b62482c4a25f28f68bd5c6e 100644 (file)
@@ -144,7 +144,9 @@ const FileArrayInputComponent = connect(mapStateToProps)(
             ids.forEach(pickerId => {
                 this.props.dispatch(
                     treePickerActions.DESELECT_TREE_PICKER_NODE({
-                        pickerId, id: deletedFiles.map(({ id }) => id),
+                        pickerId,
+                        id: deletedFiles.map(({ id }) => id),
+                        cascade: true,
                     })
                 );
             });
@@ -164,7 +166,9 @@ const FileArrayInputComponent = connect(mapStateToProps)(
             ids.forEach(pickerId => {
                 this.props.dispatch(
                     treePickerActions.SELECT_TREE_PICKER_NODE({
-                        pickerId, id: addedFiles.map(({ id }) => id),
+                        pickerId,
+                        id: addedFiles.map(({ id }) => id),
+                        cascade: true,
                     })
                 );
             });
@@ -257,6 +261,7 @@ const FileArrayInputComponent = connect(mapStateToProps)(
                                     includeDirectories
                                     includeFiles
                                     showSelection
+                                    cascadeSelection={true}
                                     options={this.props.options}
                                     toggleItemSelection={this.refreshFiles} />
                             </div>
index 5f48f83784bf1d9196baf36835bf20027f8cbfde..6970e2a5b531c9cb1af50a410075845ded643578 100644 (file)
@@ -144,6 +144,7 @@ const FileInputComponent = connect()(
                                 includeCollections
                                 includeDirectories
                                 includeFiles
+                                cascadeSelection={false}
                                 options={this.props.options}
                                 toggleItemActive={this.setFile} />
                         </div>
index d91a6b8483f2265f2685e372c09585ab1286071c..438bbe8e7e40163b55b8d363a53b82dc5f23ab01 100644 (file)
@@ -140,6 +140,7 @@ export const ProjectInputComponent = connect(mapStateToProps)(
                         <div className={classes.pickerWrapper}>
                             <ProjectsTreePicker
                                 pickerId={this.props.commandInput.id}
+                                cascadeSelection={false}
                                 options={this.props.options}
                                 toggleItemActive={this.setProject} />
                         </div>
index c46a1c52e26125be63a7c8fd50b630477eac01d0..0aa02d52701824b52d4f9611ebf2d5b49beee131 100644 (file)
@@ -4,8 +4,8 @@
 
 import { Dispatch } from "redux";
 import { connect } from "react-redux";
-import { openProcessContextMenu } from 'store/context-menu/context-menu-actions';
-import { SubprocessPanelRoot, SubprocessPanelActionProps, SubprocessPanelDataProps } from 'views/subprocess-panel/subprocess-panel-root';
+import { openProcessContextMenu } from "store/context-menu/context-menu-actions";
+import { SubprocessPanelRoot, SubprocessPanelActionProps, SubprocessPanelDataProps } from "views/subprocess-panel/subprocess-panel-root";
 import { RootState } from "store/store";
 import { navigateTo } from "store/navigation/navigation-action";
 import { loadDetailsPanel } from "store/details-panel/details-panel-action";
@@ -23,11 +23,11 @@ const mapDispatchToProps = (dispatch: Dispatch): SubprocessPanelActionProps => (
     },
     onItemDoubleClick: uuid => {
         dispatch<any>(navigateTo(uuid));
-    }
+    },
 });
 
 const mapStateToProps = (state: RootState): SubprocessPanelDataProps => ({
-    resources: state.resources
+    resources: state.resources,
 });
 
-export const SubprocessPanel = connect(mapStateToProps, mapDispatchToProps)(SubprocessPanelRoot);
\ No newline at end of file
+export const SubprocessPanel = connect(mapStateToProps, mapDispatchToProps)(SubprocessPanelRoot);
index ce9307465e19ebdcec22c46cbbe9236312649603..be2542515bb6e478103e4fb38802ed32d2dec7c6 100644 (file)
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
-import { StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core/styles';
+import React from "react";
+import { StyleRulesCallback, WithStyles, withStyles } from "@material-ui/core/styles";
 import { Route, Switch } from "react-router";
 import { ProjectPanel } from "views/project-panel/project-panel";
-import { DetailsPanel } from 'views-components/details-panel/details-panel';
-import { ArvadosTheme } from 'common/custom-theme';
+import { DetailsPanel } from "views-components/details-panel/details-panel";
+import { ArvadosTheme } from "common/custom-theme";
 import { ContextMenu } from "views-components/context-menu/context-menu";
 import { FavoritePanel } from "../favorite-panel/favorite-panel";
-import { TokenDialog } from 'views-components/token-dialog/token-dialog';
-import { RichTextEditorDialog } from 'views-components/rich-text-editor-dialog/rich-text-editor-dialog';
-import { Snackbar } from 'views-components/snackbar/snackbar';
-import { CollectionPanel } from '../collection-panel/collection-panel';
-import { RenameFileDialog } from 'views-components/rename-file-dialog/rename-file-dialog';
-import { FileRemoveDialog } from 'views-components/file-remove-dialog/file-remove-dialog';
-import { MultipleFilesRemoveDialog } from 'views-components/file-remove-dialog/multiple-files-remove-dialog';
-import { Routes } from 'routes/routes';
-import { SidePanel } from 'views-components/side-panel/side-panel';
-import { ProcessPanel } from 'views/process-panel/process-panel';
-import { ChangeWorkflowDialog } from 'views-components/run-process-dialog/change-workflow-dialog';
-import { CreateProjectDialog } from 'views-components/dialog-forms/create-project-dialog';
-import { CreateCollectionDialog } from 'views-components/dialog-forms/create-collection-dialog';
-import { CopyCollectionDialog } from 'views-components/dialog-forms/copy-collection-dialog';
-import { CopyProcessDialog } from 'views-components/dialog-forms/copy-process-dialog';
-import { UpdateCollectionDialog } from 'views-components/dialog-forms/update-collection-dialog';
-import { UpdateProcessDialog } from 'views-components/dialog-forms/update-process-dialog';
-import { UpdateProjectDialog } from 'views-components/dialog-forms/update-project-dialog';
-import { MoveProcessDialog } from 'views-components/dialog-forms/move-process-dialog';
-import { MoveProjectDialog } from 'views-components/dialog-forms/move-project-dialog';
-import { MoveCollectionDialog } from 'views-components/dialog-forms/move-collection-dialog';
-import { FilesUploadCollectionDialog } from 'views-components/dialog-forms/files-upload-collection-dialog';
-import { PartialCopyToNewCollectionDialog } from 'views-components/dialog-forms/partial-copy-to-new-collection-dialog';
-import { PartialCopyToExistingCollectionDialog } from 'views-components/dialog-forms/partial-copy-to-existing-collection-dialog';
-import { PartialCopyToSeparateCollectionsDialog } from 'views-components/dialog-forms/partial-copy-to-separate-collections-dialog';
-import { PartialMoveToNewCollectionDialog } from 'views-components/dialog-forms/partial-move-to-new-collection-dialog';
-import { PartialMoveToExistingCollectionDialog } from 'views-components/dialog-forms/partial-move-to-existing-collection-dialog';
-import { PartialMoveToSeparateCollectionsDialog } from 'views-components/dialog-forms/partial-move-to-separate-collections-dialog';
-import { RemoveProcessDialog } from 'views-components/process-remove-dialog/process-remove-dialog';
-import { MainContentBar } from 'views-components/main-content-bar/main-content-bar';
-import { Grid } from '@material-ui/core';
+import { TokenDialog } from "views-components/token-dialog/token-dialog";
+import { RichTextEditorDialog } from "views-components/rich-text-editor-dialog/rich-text-editor-dialog";
+import { Snackbar } from "views-components/snackbar/snackbar";
+import { CollectionPanel } from "../collection-panel/collection-panel";
+import { RenameFileDialog } from "views-components/rename-file-dialog/rename-file-dialog";
+import { FileRemoveDialog } from "views-components/file-remove-dialog/file-remove-dialog";
+import { MultipleFilesRemoveDialog } from "views-components/file-remove-dialog/multiple-files-remove-dialog";
+import { Routes } from "routes/routes";
+import { SidePanel } from "views-components/side-panel/side-panel";
+import { ProcessPanel } from "views/process-panel/process-panel";
+import { ChangeWorkflowDialog } from "views-components/run-process-dialog/change-workflow-dialog";
+import { CreateProjectDialog } from "views-components/dialog-forms/create-project-dialog";
+import { CreateCollectionDialog } from "views-components/dialog-forms/create-collection-dialog";
+import { CopyCollectionDialog, CopyMultiCollectionDialog } from "views-components/dialog-forms/copy-collection-dialog";
+import { CopyProcessDialog } from "views-components/dialog-forms/copy-process-dialog";
+import { UpdateCollectionDialog } from "views-components/dialog-forms/update-collection-dialog";
+import { UpdateProcessDialog } from "views-components/dialog-forms/update-process-dialog";
+import { UpdateProjectDialog } from "views-components/dialog-forms/update-project-dialog";
+import { MoveProcessDialog } from "views-components/dialog-forms/move-process-dialog";
+import { MoveProjectDialog } from "views-components/dialog-forms/move-project-dialog";
+import { MoveCollectionDialog } from "views-components/dialog-forms/move-collection-dialog";
+import { FilesUploadCollectionDialog } from "views-components/dialog-forms/files-upload-collection-dialog";
+import { PartialCopyToNewCollectionDialog } from "views-components/dialog-forms/partial-copy-to-new-collection-dialog";
+import { PartialCopyToExistingCollectionDialog } from "views-components/dialog-forms/partial-copy-to-existing-collection-dialog";
+import { PartialCopyToSeparateCollectionsDialog } from "views-components/dialog-forms/partial-copy-to-separate-collections-dialog";
+import { PartialMoveToNewCollectionDialog } from "views-components/dialog-forms/partial-move-to-new-collection-dialog";
+import { PartialMoveToExistingCollectionDialog } from "views-components/dialog-forms/partial-move-to-existing-collection-dialog";
+import { PartialMoveToSeparateCollectionsDialog } from "views-components/dialog-forms/partial-move-to-separate-collections-dialog";
+import { RemoveProcessDialog } from "views-components/process-remove-dialog/process-remove-dialog";
+import { MainContentBar } from "views-components/main-content-bar/main-content-bar";
+import { Grid } from "@material-ui/core";
 import { TrashPanel } from "views/trash-panel/trash-panel";
-import { SharedWithMePanel } from 'views/shared-with-me-panel/shared-with-me-panel';
-import { RunProcessPanel } from 'views/run-process-panel/run-process-panel';
-import SplitterLayout from 'react-splitter-layout';
-import { WorkflowPanel } from 'views/workflow-panel/workflow-panel';
-import { RegisteredWorkflowPanel } from 'views/workflow-panel/registered-workflow-panel';
-import { SearchResultsPanel } from 'views/search-results-panel/search-results-panel';
-import { SshKeyPanel } from 'views/ssh-key-panel/ssh-key-panel';
-import { SshKeyAdminPanel } from 'views/ssh-key-panel/ssh-key-admin-panel';
+import { SharedWithMePanel } from "views/shared-with-me-panel/shared-with-me-panel";
+import { RunProcessPanel } from "views/run-process-panel/run-process-panel";
+import SplitterLayout from "react-splitter-layout";
+import { WorkflowPanel } from "views/workflow-panel/workflow-panel";
+import { RegisteredWorkflowPanel } from "views/workflow-panel/registered-workflow-panel";
+import { SearchResultsPanel } from "views/search-results-panel/search-results-panel";
+import { SshKeyPanel } from "views/ssh-key-panel/ssh-key-panel";
+import { SshKeyAdminPanel } from "views/ssh-key-panel/ssh-key-admin-panel";
 import { SiteManagerPanel } from "views/site-manager-panel/site-manager-panel";
-import { UserProfilePanel } from 'views/user-profile-panel/user-profile-panel';
-import { SharingDialog } from 'views-components/sharing-dialog/sharing-dialog';
-import { NotFoundDialog } from 'views-components/not-found-dialog/not-found-dialog';
-import { AdvancedTabDialog } from 'views-components/advanced-tab-dialog/advanced-tab-dialog';
-import { ProcessInputDialog } from 'views-components/process-input-dialog/process-input-dialog';
-import { VirtualMachineUserPanel } from 'views/virtual-machine-panel/virtual-machine-user-panel';
-import { VirtualMachineAdminPanel } from 'views/virtual-machine-panel/virtual-machine-admin-panel';
-import { RepositoriesPanel } from 'views/repositories-panel/repositories-panel';
-import { KeepServicePanel } from 'views/keep-service-panel/keep-service-panel';
-import { ApiClientAuthorizationPanel } from 'views/api-client-authorization-panel/api-client-authorization-panel';
-import { LinkPanel } from 'views/link-panel/link-panel';
-import { RepositoriesSampleGitDialog } from 'views-components/repositories-sample-git-dialog/repositories-sample-git-dialog';
-import { RepositoryAttributesDialog } from 'views-components/repository-attributes-dialog/repository-attributes-dialog';
-import { CreateRepositoryDialog } from 'views-components/dialog-forms/create-repository-dialog';
-import { RemoveRepositoryDialog } from 'views-components/repository-remove-dialog/repository-remove-dialog';
-import { CreateSshKeyDialog } from 'views-components/dialog-forms/create-ssh-key-dialog';
-import { PublicKeyDialog } from 'views-components/ssh-keys-dialog/public-key-dialog';
-import { RemoveApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/remove-dialog';
-import { RemoveKeepServiceDialog } from 'views-components/keep-services-dialog/remove-dialog';
-import { RemoveLinkDialog } from 'views-components/links-dialog/remove-dialog';
-import { RemoveSshKeyDialog } from 'views-components/ssh-keys-dialog/remove-dialog';
-import { VirtualMachineAttributesDialog } from 'views-components/virtual-machines-dialog/attributes-dialog';
-import { RemoveVirtualMachineDialog } from 'views-components/virtual-machines-dialog/remove-dialog';
-import { RemoveVirtualMachineLoginDialog } from 'views-components/virtual-machines-dialog/remove-login-dialog';
-import { VirtualMachineAddLoginDialog } from 'views-components/virtual-machines-dialog/add-login-dialog';
-import { AttributesApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/attributes-dialog';
-import { AttributesKeepServiceDialog } from 'views-components/keep-services-dialog/attributes-dialog';
-import { AttributesLinkDialog } from 'views-components/links-dialog/attributes-dialog';
-import { AttributesSshKeyDialog } from 'views-components/ssh-keys-dialog/attributes-dialog';
-import { UserPanel } from 'views/user-panel/user-panel';
-import { UserAttributesDialog } from 'views-components/user-dialog/attributes-dialog';
-import { CreateUserDialog } from 'views-components/dialog-forms/create-user-dialog';
-import { HelpApiClientAuthorizationDialog } from 'views-components/api-client-authorizations-dialog/help-dialog';
-import { DeactivateDialog } from 'views-components/user-dialog/deactivate-dialog';
-import { ActivateDialog } from 'views-components/user-dialog/activate-dialog';
-import { SetupDialog } from 'views-components/user-dialog/setup-dialog';
-import { GroupsPanel } from 'views/groups-panel/groups-panel';
-import { RemoveGroupDialog } from 'views-components/groups-dialog/remove-dialog';
-import { GroupAttributesDialog } from 'views-components/groups-dialog/attributes-dialog';
-import { GroupDetailsPanel } from 'views/group-details-panel/group-details-panel';
-import { RemoveGroupMemberDialog } from 'views-components/groups-dialog/member-remove-dialog';
-import { GroupMemberAttributesDialog } from 'views-components/groups-dialog/member-attributes-dialog';
-import { PublicFavoritePanel } from 'views/public-favorites-panel/public-favorites-panel';
-import { LinkAccountPanel } from 'views/link-account-panel/link-account-panel';
-import { FedLogin } from './fed-login';
-import { CollectionsContentAddressPanel } from 'views/collection-content-address-panel/collection-content-address-panel';
-import { AllProcessesPanel } from '../all-processes-panel/all-processes-panel';
-import { NotFoundPanel } from '../not-found-panel/not-found-panel';
-import { AutoLogout } from 'views-components/auto-logout/auto-logout';
-import { RestoreCollectionVersionDialog } from 'views-components/collections-dialog/restore-version-dialog';
-import { WebDavS3InfoDialog } from 'views-components/webdav-s3-dialog/webdav-s3-dialog';
-import { pluginConfig } from 'plugins';
-import { ElementListReducer } from 'common/plugintypes';
-import { COLLAPSE_ICON_SIZE } from 'views-components/side-panel-toggle/side-panel-toggle'
-import { Banner } from 'views-components/baner/banner';
+import { UserProfilePanel } from "views/user-profile-panel/user-profile-panel";
+import { SharingDialog } from "views-components/sharing-dialog/sharing-dialog";
+import { NotFoundDialog } from "views-components/not-found-dialog/not-found-dialog";
+import { AdvancedTabDialog } from "views-components/advanced-tab-dialog/advanced-tab-dialog";
+import { ProcessInputDialog } from "views-components/process-input-dialog/process-input-dialog";
+import { VirtualMachineUserPanel } from "views/virtual-machine-panel/virtual-machine-user-panel";
+import { VirtualMachineAdminPanel } from "views/virtual-machine-panel/virtual-machine-admin-panel";
+import { RepositoriesPanel } from "views/repositories-panel/repositories-panel";
+import { KeepServicePanel } from "views/keep-service-panel/keep-service-panel";
+import { ApiClientAuthorizationPanel } from "views/api-client-authorization-panel/api-client-authorization-panel";
+import { LinkPanel } from "views/link-panel/link-panel";
+import { RepositoriesSampleGitDialog } from "views-components/repositories-sample-git-dialog/repositories-sample-git-dialog";
+import { RepositoryAttributesDialog } from "views-components/repository-attributes-dialog/repository-attributes-dialog";
+import { CreateRepositoryDialog } from "views-components/dialog-forms/create-repository-dialog";
+import { RemoveRepositoryDialog } from "views-components/repository-remove-dialog/repository-remove-dialog";
+import { CreateSshKeyDialog } from "views-components/dialog-forms/create-ssh-key-dialog";
+import { PublicKeyDialog } from "views-components/ssh-keys-dialog/public-key-dialog";
+import { RemoveApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/remove-dialog";
+import { RemoveKeepServiceDialog } from "views-components/keep-services-dialog/remove-dialog";
+import { RemoveLinkDialog } from "views-components/links-dialog/remove-dialog";
+import { RemoveSshKeyDialog } from "views-components/ssh-keys-dialog/remove-dialog";
+import { VirtualMachineAttributesDialog } from "views-components/virtual-machines-dialog/attributes-dialog";
+import { RemoveVirtualMachineDialog } from "views-components/virtual-machines-dialog/remove-dialog";
+import { RemoveVirtualMachineLoginDialog } from "views-components/virtual-machines-dialog/remove-login-dialog";
+import { VirtualMachineAddLoginDialog } from "views-components/virtual-machines-dialog/add-login-dialog";
+import { AttributesApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/attributes-dialog";
+import { AttributesKeepServiceDialog } from "views-components/keep-services-dialog/attributes-dialog";
+import { AttributesLinkDialog } from "views-components/links-dialog/attributes-dialog";
+import { AttributesSshKeyDialog } from "views-components/ssh-keys-dialog/attributes-dialog";
+import { UserPanel } from "views/user-panel/user-panel";
+import { UserAttributesDialog } from "views-components/user-dialog/attributes-dialog";
+import { CreateUserDialog } from "views-components/dialog-forms/create-user-dialog";
+import { HelpApiClientAuthorizationDialog } from "views-components/api-client-authorizations-dialog/help-dialog";
+import { DeactivateDialog } from "views-components/user-dialog/deactivate-dialog";
+import { ActivateDialog } from "views-components/user-dialog/activate-dialog";
+import { SetupDialog } from "views-components/user-dialog/setup-dialog";
+import { GroupsPanel } from "views/groups-panel/groups-panel";
+import { RemoveGroupDialog } from "views-components/groups-dialog/remove-dialog";
+import { GroupAttributesDialog } from "views-components/groups-dialog/attributes-dialog";
+import { GroupDetailsPanel } from "views/group-details-panel/group-details-panel";
+import { RemoveGroupMemberDialog } from "views-components/groups-dialog/member-remove-dialog";
+import { GroupMemberAttributesDialog } from "views-components/groups-dialog/member-attributes-dialog";
+import { PublicFavoritePanel } from "views/public-favorites-panel/public-favorites-panel";
+import { LinkAccountPanel } from "views/link-account-panel/link-account-panel";
+import { FedLogin } from "./fed-login";
+import { CollectionsContentAddressPanel } from "views/collection-content-address-panel/collection-content-address-panel";
+import { AllProcessesPanel } from "../all-processes-panel/all-processes-panel";
+import { NotFoundPanel } from "../not-found-panel/not-found-panel";
+import { AutoLogout } from "views-components/auto-logout/auto-logout";
+import { RestoreCollectionVersionDialog } from "views-components/collections-dialog/restore-version-dialog";
+import { WebDavS3InfoDialog } from "views-components/webdav-s3-dialog/webdav-s3-dialog";
+import { pluginConfig } from "plugins";
+import { ElementListReducer } from "common/plugintypes";
+import { COLLAPSE_ICON_SIZE } from "views-components/side-panel-toggle/side-panel-toggle";
+import { Banner } from "views-components/baner/banner";
 
-type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
+type CssRules = "root" | "container" | "splitter" | "asidePanel" | "contentWrapper" | "content";
 
 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
     root: {
         paddingTop: theme.spacing.unit * 7,
-        background: theme.palette.background.default
+        background: theme.palette.background.default,
     },
     container: {
-        position: 'relative'
+        position: "relative",
     },
     splitter: {
-        '& > .layout-splitter': {
-            width: '2px',
+        "& > .layout-splitter": {
+            width: "2px",
+        },
+        "& > .layout-splitter-disabled": {
+            pointerEvents: "none",
+            cursor: "pointer",
         },
-        '& > .layout-splitter-disabled': {
-            pointerEvents: 'none',
-            cursor: 'pointer'
-        }
     },
     asidePanel: {
         paddingTop: theme.spacing.unit,
-        height: '100%'
+        height: "100%",
     },
     contentWrapper: {
         paddingTop: theme.spacing.unit,
-        minWidth: 0
+        minWidth: 0,
     },
     content: {
         minWidth: 0,
@@ -140,8 +140,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
         paddingRight: theme.spacing.unit * 3,
         // Reserve vertical space for app bar + MainContentBar
         minHeight: `calc(100vh - ${theme.spacing.unit * 16}px)`,
-        display: 'flex',
-    }
+        display: "flex",
+    },
 });
 
 interface WorkbenchDataProps {
@@ -156,85 +156,213 @@ type WorkbenchPanelProps = WithStyles<CssRules> & WorkbenchDataProps;
 const defaultSplitterSize = 90;
 
 const getSplitterInitialSize = () => {
-    const splitterSize = localStorage.getItem('splitterSize');
+    const splitterSize = localStorage.getItem("splitterSize");
     return splitterSize ? Number(splitterSize) : defaultSplitterSize;
 };
 
-const saveSplitterSize = (size: number) => localStorage.setItem('splitterSize', size.toString());
+const saveSplitterSize = (size: number) => localStorage.setItem("splitterSize", size.toString());
 
-let routes = <>
-    <Route path={Routes.PROJECTS} component={ProjectPanel} />
-    <Route path={Routes.COLLECTIONS} component={CollectionPanel} />
-    <Route path={Routes.FAVORITES} component={FavoritePanel} />
-    <Route path={Routes.ALL_PROCESSES} component={AllProcessesPanel} />
-    <Route path={Routes.PROCESSES} component={ProcessPanel} />
-    <Route path={Routes.TRASH} component={TrashPanel} />
-    <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
-    <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
-    <Route path={Routes.REGISTEREDWORKFLOW} component={RegisteredWorkflowPanel} />
-    <Route path={Routes.WORKFLOWS} component={WorkflowPanel} />
-    <Route path={Routes.SEARCH_RESULTS} component={SearchResultsPanel} />
-    <Route path={Routes.VIRTUAL_MACHINES_USER} component={VirtualMachineUserPanel} />
-    <Route path={Routes.VIRTUAL_MACHINES_ADMIN} component={VirtualMachineAdminPanel} />
-    <Route path={Routes.REPOSITORIES} component={RepositoriesPanel} />
-    <Route path={Routes.SSH_KEYS_USER} component={SshKeyPanel} />
-    <Route path={Routes.SSH_KEYS_ADMIN} component={SshKeyAdminPanel} />
-    <Route path={Routes.SITE_MANAGER} component={SiteManagerPanel} />
-    <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
-    <Route path={Routes.USERS} component={UserPanel} />
-    <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
-    <Route path={Routes.MY_ACCOUNT} component={UserProfilePanel} />
-    <Route path={Routes.USER_PROFILE} component={UserProfilePanel} />
-    <Route path={Routes.GROUPS} component={GroupsPanel} />
-    <Route path={Routes.GROUP_DETAILS} component={GroupDetailsPanel} />
-    <Route path={Routes.LINKS} component={LinkPanel} />
-    <Route path={Routes.PUBLIC_FAVORITES} component={PublicFavoritePanel} />
-    <Route path={Routes.LINK_ACCOUNT} component={LinkAccountPanel} />
-    <Route path={Routes.COLLECTIONS_CONTENT_ADDRESS} component={CollectionsContentAddressPanel} />
-</>;
+let routes = (
+    <>
+        <Route
+            path={Routes.PROJECTS}
+            component={ProjectPanel}
+        />
+        <Route
+            path={Routes.COLLECTIONS}
+            component={CollectionPanel}
+        />
+        <Route
+            path={Routes.FAVORITES}
+            component={FavoritePanel}
+        />
+        <Route
+            path={Routes.ALL_PROCESSES}
+            component={AllProcessesPanel}
+        />
+        <Route
+            path={Routes.PROCESSES}
+            component={ProcessPanel}
+        />
+        <Route
+            path={Routes.TRASH}
+            component={TrashPanel}
+        />
+        <Route
+            path={Routes.SHARED_WITH_ME}
+            component={SharedWithMePanel}
+        />
+        <Route
+            path={Routes.RUN_PROCESS}
+            component={RunProcessPanel}
+        />
+        <Route
+            path={Routes.REGISTEREDWORKFLOW}
+            component={RegisteredWorkflowPanel}
+        />
+        <Route
+            path={Routes.WORKFLOWS}
+            component={WorkflowPanel}
+        />
+        <Route
+            path={Routes.SEARCH_RESULTS}
+            component={SearchResultsPanel}
+        />
+        <Route
+            path={Routes.VIRTUAL_MACHINES_USER}
+            component={VirtualMachineUserPanel}
+        />
+        <Route
+            path={Routes.VIRTUAL_MACHINES_ADMIN}
+            component={VirtualMachineAdminPanel}
+        />
+        <Route
+            path={Routes.REPOSITORIES}
+            component={RepositoriesPanel}
+        />
+        <Route
+            path={Routes.SSH_KEYS_USER}
+            component={SshKeyPanel}
+        />
+        <Route
+            path={Routes.SSH_KEYS_ADMIN}
+            component={SshKeyAdminPanel}
+        />
+        <Route
+            path={Routes.SITE_MANAGER}
+            component={SiteManagerPanel}
+        />
+        <Route
+            path={Routes.KEEP_SERVICES}
+            component={KeepServicePanel}
+        />
+        <Route
+            path={Routes.USERS}
+            component={UserPanel}
+        />
+        <Route
+            path={Routes.API_CLIENT_AUTHORIZATIONS}
+            component={ApiClientAuthorizationPanel}
+        />
+        <Route
+            path={Routes.MY_ACCOUNT}
+            component={UserProfilePanel}
+        />
+        <Route
+            path={Routes.USER_PROFILE}
+            component={UserProfilePanel}
+        />
+        <Route
+            path={Routes.GROUPS}
+            component={GroupsPanel}
+        />
+        <Route
+            path={Routes.GROUP_DETAILS}
+            component={GroupDetailsPanel}
+        />
+        <Route
+            path={Routes.LINKS}
+            component={LinkPanel}
+        />
+        <Route
+            path={Routes.PUBLIC_FAVORITES}
+            component={PublicFavoritePanel}
+        />
+        <Route
+            path={Routes.LINK_ACCOUNT}
+            component={LinkAccountPanel}
+        />
+        <Route
+            path={Routes.COLLECTIONS_CONTENT_ADDRESS}
+            component={CollectionsContentAddressPanel}
+        />
+    </>
+);
 
-const reduceRoutesFn: (a: React.ReactElement[],
-    b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
+const reduceRoutesFn: (a: React.ReactElement[], b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
 
-routes = React.createElement(React.Fragment, null, pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children)));
+routes = React.createElement(
+    React.Fragment,
+    null,
+    pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children))
+);
 
-const applyCollapsedState = (isCollapsed) => {
-    const rightPanel: Element = document.getElementsByClassName('layout-pane')[1]
-    const totalWidth: number = document.getElementsByClassName('splitter-layout')[0]?.clientWidth
-    const rightPanelExpandedWidth = ((totalWidth - COLLAPSE_ICON_SIZE)) / (totalWidth / 100)
+const applyCollapsedState = isCollapsed => {
+    const rightPanel: Element = document.getElementsByClassName("layout-pane")[1];
+    const totalWidth: number = document.getElementsByClassName("splitter-layout")[0]?.clientWidth;
+    const rightPanelExpandedWidth = (totalWidth - COLLAPSE_ICON_SIZE) / (totalWidth / 100);
     if (rightPanel) {
-        rightPanel.setAttribute('style', `width: ${isCollapsed ? rightPanelExpandedWidth : getSplitterInitialSize()}%`)
+        rightPanel.setAttribute("style", `width: ${isCollapsed ? rightPanelExpandedWidth : getSplitterInitialSize()}%`);
     }
-    const splitter = document.getElementsByClassName('layout-splitter')[0]
-    isCollapsed ? splitter?.classList.add('layout-splitter-disabled') : splitter?.classList.remove('layout-splitter-disabled')
-
-}
-
-export const WorkbenchPanel =
-    withStyles(styles)((props: WorkbenchPanelProps) => {
+    const splitter = document.getElementsByClassName("layout-splitter")[0];
+    isCollapsed ? splitter?.classList.add("layout-splitter-disabled") : splitter?.classList.remove("layout-splitter-disabled");
+};
 
-        //panel size will not scale automatically on window resize, so we do it manually
-        window.addEventListener('resize', () => applyCollapsedState(props.sidePanelIsCollapsed))
-        applyCollapsedState(props.sidePanelIsCollapsed)
+export const WorkbenchPanel = withStyles(styles)((props: WorkbenchPanelProps) => {
+    //panel size will not scale automatically on window resize, so we do it manually
+    window.addEventListener("resize", () => applyCollapsedState(props.sidePanelIsCollapsed));
+    applyCollapsedState(props.sidePanelIsCollapsed);
 
-        return <Grid container item xs className={props.classes.root}>
+    return (
+        <Grid
+            container
+            item
+            xs
+            className={props.classes.root}
+        >
             {props.sessionIdleTimeout > 0 && <AutoLogout />}
-            <Grid container item xs className={props.classes.container}>
-                <SplitterLayout customClassName={props.classes.splitter} percentage={true}
-                    primaryIndex={0} primaryMinSize={10}
-                    secondaryInitialSize={getSplitterInitialSize()} secondaryMinSize={40}
-                    onSecondaryPaneSizeChange={saveSplitterSize}>
-                    {props.isUserActive && props.isNotLinking && <Grid container item xs component='aside' direction='column' className={props.classes.asidePanel}>
-                        <SidePanel />
-                    </Grid>}
-                    <Grid container item xs component="main" direction="column" className={props.classes.contentWrapper}>
-                        <Grid item xs>
+            <Grid
+                container
+                item
+                xs
+                className={props.classes.container}
+            >
+                <SplitterLayout
+                    customClassName={props.classes.splitter}
+                    percentage={true}
+                    primaryIndex={0}
+                    primaryMinSize={10}
+                    secondaryInitialSize={getSplitterInitialSize()}
+                    secondaryMinSize={40}
+                    onSecondaryPaneSizeChange={saveSplitterSize}
+                >
+                    {props.isUserActive && props.isNotLinking && (
+                        <Grid
+                            container
+                            item
+                            xs
+                            component="aside"
+                            direction="column"
+                            className={props.classes.asidePanel}
+                        >
+                            <SidePanel />
+                        </Grid>
+                    )}
+                    <Grid
+                        container
+                        item
+                        xs
+                        component="main"
+                        direction="column"
+                        className={props.classes.contentWrapper}
+                    >
+                        <Grid
+                            item
+                            xs
+                        >
                             {props.isNotLinking && <MainContentBar />}
                         </Grid>
-                        <Grid item xs className={props.classes.content}>
+                        <Grid
+                            item
+                            xs
+                            className={props.classes.content}
+                        >
                             <Switch>
                                 {routes.props.children}
-                                <Route path={Routes.NO_MATCH} component={NotFoundPanel} />
+                                <Route
+                                    path={Routes.NO_MATCH}
+                                    component={NotFoundPanel}
+                                />
                             </Switch>
                         </Grid>
                     </Grid>
@@ -251,6 +379,7 @@ export const WorkbenchPanel =
             <ChangeWorkflowDialog />
             <ContextMenu />
             <CopyCollectionDialog />
+            <CopyMultiCollectionDialog />
             <CopyProcessDialog />
             <CreateCollectionDialog />
             <CreateProjectDialog />
@@ -307,5 +436,5 @@ export const WorkbenchPanel =
             <Banner />
             {React.createElement(React.Fragment, null, pluginConfig.dialogs)}
         </Grid>
-    }
     );
+});
index 39940ce5ac8eda7b9e8bfc3546fc7cb19bc8f58f..1b74b11f3fa665601fdf75d4aa9fc7b808623242 100644 (file)
@@ -2,21 +2,21 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { RootStore } from 'store/store';
-import { AuthService } from 'services/auth-service/auth-service';
-import { Config } from 'common/config';
-import { WebSocketService } from './websocket-service';
-import { ResourceEventMessage } from './resource-event-message';
-import { ResourceKind } from 'models/resource';
-import { loadProcess } from 'store/processes/processes-actions';
-import { getProcess, getSubprocesses } from 'store/processes/process';
-import { LogEventType } from 'models/log';
+import { RootStore } from "store/store";
+import { AuthService } from "services/auth-service/auth-service";
+import { Config } from "common/config";
+import { WebSocketService } from "./websocket-service";
+import { ResourceEventMessage } from "./resource-event-message";
+import { ResourceKind } from "models/resource";
+import { loadProcess } from "store/processes/processes-actions";
+import { getProcess, getSubprocesses } from "store/processes/process";
+import { LogEventType } from "models/log";
 import { subprocessPanelActions } from "store/subprocess-panel/subprocess-panel-actions";
-import { projectPanelActions } from "store/project-panel/project-panel-action";
-import { getProjectPanelCurrentUuid } from 'store/project-panel/project-panel-action';
-import { allProcessesPanelActions } from 'store/all-processes-panel/all-processes-panel-action';
-import { loadCollection } from 'store/workbench/workbench-actions';
-import { matchAllProcessesRoute, matchProjectRoute, matchProcessRoute } from 'routes/routes';
+import { projectPanelActions } from "store/project-panel/project-panel-action-bind";
+import { getProjectPanelCurrentUuid } from "store/project-panel/project-panel-action";
+import { allProcessesPanelActions } from "store/all-processes-panel/all-processes-panel-action";
+import { loadCollection } from "store/workbench/workbench-actions";
+import { matchAllProcessesRoute, matchProjectRoute, matchProcessRoute } from "routes/routes";
 
 export const initWebSocket = (config: Config, authService: AuthService, store: RootStore) => {
     if (config.websocketUrl) {
@@ -31,7 +31,7 @@ export const initWebSocket = (config: Config, authService: AuthService, store: R
 const messageListener = (store: RootStore) => (message: ResourceEventMessage) => {
     if (message.eventType === LogEventType.CREATE || message.eventType === LogEventType.UPDATE) {
         const state = store.getState();
-        const location = state.router.location ? state.router.location.pathname : '';
+        const location = state.router.location ? state.router.location.pathname : "";
         switch (message.objectKind) {
             case ResourceKind.COLLECTION:
                 const currentCollection = state.collectionPanel.item;
@@ -46,7 +46,7 @@ const messageListener = (store: RootStore) => (message: ResourceEventMessage) =>
                     }
                     const proc = getProcess(state.processPanel.containerRequestUuid)(state.resources);
                     if (proc && proc.container && proc.container.uuid === message.properties["new_attributes"]["requesting_container_uuid"]) {
-                        store.dispatch(subprocessPanelActions.REQUEST_ITEMS());
+                        store.dispatch(subprocessPanelActions.REQUEST_ITEMS(false, true));
                         return;
                     }
                 }
@@ -57,16 +57,16 @@ const messageListener = (store: RootStore) => (message: ResourceEventMessage) =>
                     const subproc = getSubprocesses(state.processPanel.containerRequestUuid)(state.resources);
                     for (const sb of subproc) {
                         if (sb.containerRequest.uuid === message.objectUuid || (sb.container && sb.container.uuid === message.objectUuid)) {
-                            store.dispatch(subprocessPanelActions.REQUEST_ITEMS());
+                            store.dispatch(subprocessPanelActions.REQUEST_ITEMS(false, true));
                             break;
                         }
                     }
                 }
                 if (matchAllProcessesRoute(location)) {
-                    store.dispatch(allProcessesPanelActions.REQUEST_ITEMS());
+                    store.dispatch(allProcessesPanelActions.REQUEST_ITEMS(false, true));
                 }
                 if (matchProjectRoute(location) && message.objectOwnerUuid === getProjectPanelCurrentUuid(state)) {
-                    store.dispatch(projectPanelActions.REQUEST_ITEMS());
+                    store.dispatch(projectPanelActions.REQUEST_ITEMS(false, true));
                 }
                 return;
             default: