16159: Merge branch 'master' into 16159-logout-request-with-token 16159-logout-request-with-token
authorLucas Di Pentima <lucas@di-pentima.com.ar>
Tue, 13 Apr 2021 20:29:24 +0000 (17:29 -0300)
committerLucas Di Pentima <lucas@di-pentima.com.ar>
Tue, 13 Apr 2021 20:29:24 +0000 (17:29 -0300)
Arvados-DCO-1.1-Signed-off-by: Lucas Di Pentima <lucas@di-pentima.com.ar>

60 files changed:
Makefile
README.md
cypress/integration/collection.spec.js
cypress/integration/side-panel.spec.js
src/common/labels.ts
src/common/plugintypes.ts [new file with mode: 0644]
src/common/url.test.ts [new file with mode: 0644]
src/common/url.ts
src/common/webdav.test.ts
src/common/webdav.ts
src/common/xml.ts
src/components/form-dialog/form-dialog.tsx
src/components/icon/icon.tsx
src/components/tree/tree.tsx
src/components/tree/virtual-tree.tsx
src/index.tsx
src/models/group.ts
src/models/link.ts
src/models/project.ts
src/plugins.tsx [new file with mode: 0644]
src/plugins/README.md [new file with mode: 0644]
src/plugins/blank/index.tsx [new file with mode: 0644]
src/plugins/example/exampleComponents.tsx [new file with mode: 0644]
src/plugins/example/index.tsx [new file with mode: 0644]
src/plugins/root-redirect/index.tsx [new file with mode: 0644]
src/routes/route-change-handlers.ts
src/services/collection-service/collection-service-files-response.test.ts [new file with mode: 0644]
src/services/collection-service/collection-service-files-response.ts
src/services/collection-service/collection-service.ts
src/services/project-service/project-service.test.ts
src/services/project-service/project-service.ts
src/store/breadcrumbs/breadcrumbs-actions.ts
src/store/context-menu/context-menu-actions.test.ts
src/store/context-menu/context-menu-actions.ts
src/store/groups-panel/groups-panel-middleware-service.ts
src/store/navigation/navigation-action.ts
src/store/resource-type-filters/resource-type-filters.test.ts
src/store/resource-type-filters/resource-type-filters.ts
src/store/side-panel-tree/side-panel-tree-actions.ts
src/store/side-panel/side-panel-action.ts
src/store/store.ts
src/store/tree-picker/tree-picker-actions.ts
src/store/workbench/workbench-actions.ts
src/views-components/context-menu/action-sets/process-resource-action-set.ts
src/views-components/context-menu/action-sets/project-action-set.test.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/context-menu.tsx
src/views-components/data-explorer/renderers.tsx
src/views-components/details-panel/project-details.tsx
src/views-components/form-fields/project-form-fields.tsx
src/views-components/main-app-bar/account-menu.tsx
src/views-components/main-app-bar/main-app-bar.tsx
src/views-components/main-content-bar/main-content-bar.tsx
src/views-components/project-properties-dialog/project-properties-dialog.tsx
src/views-components/sharing-dialog/participant-select.tsx
src/views-components/side-panel-button/side-panel-button.tsx
src/views-components/side-panel-tree/side-panel-tree.tsx
src/views/project-panel/project-panel.tsx
src/views/workbench/workbench.tsx

index de88cd3548cbc04be68900fa0b5ce8fecbb00c3c..6cf9c29dad2cda5b56d61603d20d87da8124bfef 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -25,7 +25,7 @@ DESCRIPTION=Arvados Workbench2 - Arvados is a free and open source platform for
 MAINTAINER=Arvados Package Maintainers <packaging@arvados.org>
 
 # DEST_DIR will have the build package copied.
-DEST_DIR=/var/www/arvados-workbench2/workbench2/
+DEST_DIR=/var/www/$(APP_NAME)/workbench2/
 
 # Debian package file
 DEB_FILE=$(APP_NAME)_$(VERSION)-$(ITERATION)_amd64.deb
index 55e96af3c14e2d764dac391a2c632137567e293d..f6a7e48587522f8dcf90592582dd47ca571b14e6 100644 (file)
--- a/README.md
+++ b/README.md
@@ -2,59 +2,60 @@
 [comment]: # ()
 [comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0)
 
-## Arvados Workbench 2
+# Arvados Workbench 2
 
-### Setup
-<pre>
-brew install yarn
+## Setup
+```
+npm install yarn
 yarn install
-</pre>
+```
+
 Install [redux-devtools-extension](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd)
 
-### Start project
-<code>yarn start</code>
+## Start project for development
+```
+yarn start
+```
 
-### Run unit tests
-<pre>
+## Run unit tests
+```
 make unit-tests
-</pre>
+```
 
-### Run end-to-end tests
+## Run end-to-end tests
 
-<pre>
+```
 make integration-tests
-</pre>
+```
 
-### Run end-to-end tests in a Docker container
+## Run end-to-end tests in a Docker container
 
-<pre>
+```
 make integration-tests-in-docker
-</pre>
+```
 
-### Run tests interactively in container
+## Run tests interactively in container
 
-<pre>
+```
 $ xhost +local:root
 $ ARVADOS_DIR=/path/to/arvados
 $ docker run -ti -v$PWD:$PWD -v$ARVADOS_DIR:/usr/src/arvados -w$PWD --env="DISPLAY" --volume="/tmp/.X11-unix:/tmp/.X11-unix:rw" workbench2-build /bin/bash
 (inside container)
 # yarn run cypress install
 # tools/run-integration-tests.sh -i -a /usr/src/arvados
-</pre>
+```
 
-### Production build
-<pre>
-yarn install
+## Production build
+```
 yarn build
-</pre>
+```
 
-### Package build
-<pre>
-make workbench2-build-image
-docker run -v$PWD:$PWD -w $PWD arvados/fpm make packages
-</pre>
+## Package build
+```
+make packages
+```
 
-### Build time configuration
+## Build time configuration
 You can customize project global variables using env variables. Default values are placed in the `.env` file.
 
 Example:
@@ -62,8 +63,9 @@ Example:
 REACT_APP_ARVADOS_CONFIG_URL=config.json yarn build
 ```
 
-### Run time configuration
-The app will fetch runtime configuration when starting. By default it will try to fetch `/config.json`. You can customize this url using build time configuration.
+## Run time configuration
+The app will fetch runtime configuration when starting. By default it will try to fetch `/config.json`.  In development mode, this can be found in the `public` directory.
+You can customize this url using build time configuration.
 
 Currently this configuration schema is supported:
 ```
@@ -74,19 +76,19 @@ Currently this configuration schema is supported:
 }
 ```
 
-#### API_HOST
+### API_HOST
 
 The Arvados base URL.
 
 The `REACT_APP_ARVADOS_API_HOST` environment variable can be used to set the default URL if the run time configuration is unreachable.
 
-#### VOCABULARY_URL
+### VOCABULARY_URL
 Local path, or any URL that allows cross-origin requests. See
 [Vocabulary JSON file example](public/vocabulary-example.json).
 
 To use the URL defined in the Arvados cluster configuration, remove the entire `VOCABULARY_URL` entry from the runtime configuration. Found in `/config.json` by default.
 
-### FILE_VIEWERS_CONFIG_URL
+## FILE_VIEWERS_CONFIG_URL
 Local path, or any URL that allows cross-origin requests. See:
 
 [File viewers config file example](public/file-viewers-example.json)
@@ -95,7 +97,14 @@ Local path, or any URL that allows cross-origin requests. See:
 
 To use the URL defined in the Arvados cluster configuration, remove the entire `FILE_VIEWERS_CONFIG_URL` entry from the runtime configuration. Found in `/config.json` by default.
 
-### Licensing
+## Plugin support
+
+Workbench supports plugins to add new functionality to the user
+interface.  For information about installing plugins, the provided
+example plugins, see [src/plugins/README.md](src/plugins/README.md).
+
+
+## Licensing
 
 Arvados is Free Software. See COPYING for information about Arvados Free
 Software licenses.
index 8db574ce9ff473201adc5b7e81f4e2002cc6a8f9..841197abcfdc5a0b130ce9759e7760bc93d48584 100644 (file)
@@ -2,68 +2,69 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-describe('Collection panel tests', function() {
+describe('Collection panel 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() {
+            .as('adminUser').then(function () {
                 adminUser = this.adminUser;
             }
-        );
+            );
         cy.getUser('collectionuser1', 'Collection', 'User', false, true)
-            .as('activeUser').then(function() {
+            .as('activeUser').then(function () {
                 activeUser = this.activeUser;
             }
-        );
+            );
     });
 
-    beforeEach(function() {
+    beforeEach(function () {
         cy.clearCookies();
         cy.clearLocalStorage();
     });
 
-    it('uses the property editor with vocabulary terms', function() {
+    it('uses the property editor 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"})
-        .as('testCollection').then(function() {
-            cy.loginAs(activeUser);
-            cy.doSearch(`${this.testCollection.uuid}`);
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        })
+            .as('testCollection').then(function () {
+                cy.loginAs(activeUser);
+                cy.doSearch(`${this.testCollection.uuid}`);
 
-            // 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=property-field-value]').within(() => {
-                    cy.get('input').type('Magenta');
+                // 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=property-field-value]').within(() => {
+                        cy.get('input').type('Magenta');
+                    });
+                    cy.root().submit();
                 });
-                cy.root().submit();
-            });
-            // Confirm proper vocabulary labels are displayed on the UI.
-            cy.get('[data-cy=collection-properties-panel]')
-                .should('contain', 'Color')
-                .and('contain', 'Magenta');
-            // Confirm proper vocabulary IDs were saved on the backend.
-            cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`)
-            .its('body').as('collection')
-            .then(function() {
-                expect(this.collection.properties).to.deep.equal(
-                    {IDTAGCOLORS: 'IDVALCOLORS3'});
+                // Confirm proper vocabulary labels are displayed on the UI.
+                cy.get('[data-cy=collection-properties-panel]')
+                    .should('contain', 'Color')
+                    .and('contain', 'Magenta');
+                // Confirm proper vocabulary IDs were saved on the backend.
+                cy.doRequest('GET', `/arvados/v1/collections/${this.testCollection.uuid}`)
+                    .its('body').as('collection')
+                    .then(function () {
+                        expect(this.collection.properties).to.deep.equal(
+                            { IDTAGCOLORS: 'IDVALCOLORS3' });
+                    });
             });
-        });
     });
 
-    it('shows collection by URL', function() {
+    it('shows collection by URL', function () {
         cy.loginAs(activeUser);
-        [true, false].map(function(isWritable) {
+        [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.
@@ -71,230 +72,262 @@ describe('Collection panel tests', function() {
             cy.createGroup(adminUser.token, {
                 name: 'Shared project',
                 group_class: 'project',
-            }).as('sharedGroup').then(function() {
+            }).as('sharedGroup').then(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',
                     owner_uuid: this.sharedGroup.uuid,
-                    properties: {someKey: 'someValue'},
-                    manifest_text: `. 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
-                    })
-                    cy.doSearch(`${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-properties-panel]')
-                        .should('contain', 'someKey')
-                        .and('contain', 'someValue')
-                        .and('not.contain', 'anotherKey')
-                        .and('not.contain', 'anotherValue')
-                    if (isWritable === true) {
-                        // Check that properties can be added.
-                        cy.get('[data-cy=resource-properties-form]').within(() => {
-                            cy.get('[data-cy=property-field-key]').within(() => {
-                                cy.get('input').type('anotherKey');
-                            });
-                            cy.get('[data-cy=property-field-value]').within(() => {
-                                cy.get('input').type('anotherValue');
-                            });
-                            cy.root().submit();
+                    properties: { someKey: 'someValue' },
+                    manifest_text: `. 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
                         })
+                        cy.doSearch(`${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-properties-panel]')
-                            .should('contain', 'anotherKey')
-                            .and('contain', 'anotherValue')
-                    } else {
-                        // Properties form shouldn't be displayed.
-                        cy.get('[data-cy=resource-properties-form]').should('not.exist');
-                    }
-                    // Check that the file listing show both read & write operations
-                    cy.get('[data-cy=collection-files-panel]').within(() => {
-                        cy.root().should('contain', fileName);
-                        if (isWritable) {
-                            cy.get('[data-cy=upload-button]')
-                                .should(`${isWritable ? '' : 'not.'}contain`, 'Upload data');
+                            .should('contain', 'someKey')
+                            .and('contain', 'someValue')
+                            .and('not.contain', 'anotherKey')
+                            .and('not.contain', 'anotherValue')
+                        if (isWritable === true) {
+                            // Check that properties can be added.
+                            cy.get('[data-cy=resource-properties-form]').within(() => {
+                                cy.get('[data-cy=property-field-key]').within(() => {
+                                    cy.get('input').type('anotherKey');
+                                });
+                                cy.get('[data-cy=property-field-value]').within(() => {
+                                    cy.get('input').type('anotherValue');
+                                });
+                                cy.root().submit();
+                            })
+                            cy.get('[data-cy=collection-properties-panel]')
+                                .should('contain', 'anotherKey')
+                                .and('contain', 'anotherValue')
+                        } else {
+                            // Properties form shouldn't be displayed.
+                            cy.get('[data-cy=resource-properties-form]').should('not.exist');
                         }
-                    });
-                    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
-                    // 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('contain', 'Download selected')
-                        .should(`${isWritable ? '' : 'not.'}contain`, 'Remove selected')
-                    cy.get('body').click(); // Collapse the menu
-                    // File item 'more options' button
-                    cy.get('[data-cy=file-item-options-btn')
-                        .click()
-                    cy.get('[data-cy=context-menu]')
-                        .should('contain', 'Download')
-                        .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
-                    cy.get('body').click(); // Collapse the menu
-                })
+                        // Check that the file listing show both read & write operations
+                        cy.get('[data-cy=collection-files-panel]').within(() => {
+                            cy.root().should('contain', fileName);
+                            if (isWritable) {
+                                cy.get('[data-cy=upload-button]')
+                                    .should(`${isWritable ? '' : 'not.'}contain`, 'Upload data');
+                            }
+                        });
+                        cy.get('[data-cy=collection-files-panel]')
+                            .contains(fileName).rightclick({ force: true });
+                        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
+                        // 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('contain', 'Download selected')
+                            .should(`${isWritable ? '' : 'not.'}contain`, 'Remove selected')
+                        cy.get('body').click(); // Collapse the menu
+                        // File item 'more options' button
+                        cy.get('[data-cy=file-item-options-btn')
+                            .click()
+                        cy.get('[data-cy=context-menu]')
+                            .should('contain', 'Download')
+                            .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
+                        cy.get('body').click(); // Collapse the menu
+                    })
             })
         })
     })
 
-    it('renames a file using valid names', function() {
+    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
         // 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"})
-        .as('testCollection').then(function() {
-            cy.loginAs(activeUser);
-            cy.doSearch(`${this.testCollection.uuid}`);
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        })
+            .as('testCollection').then(function () {
+                cy.loginAs(activeUser);
+                cy.doSearch(`${this.testCollection.uuid}`);
 
-            const nameTransitions = [
-                ['bar', '&'],
-                ['&', 'foo'],
-                ['foo', '&amp;'],
-                ['&amp;', 'I ❤️ ⛵️'],
-                ['I ❤️ ⛵️', '...']
-            ];
-            nameTransitions.forEach(([from, to]) => {
-                cy.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}${to}`);
-                    });
-                cy.get('[data-cy=form-submit-btn]').click();
-                cy.get('[data-cy=collection-files-panel]')
-                    .should('not.contain', `${from}`)
-                    .and('contain', `${to}`);
-            })
-        });
+                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',
+                    "G%C3%BCnter's%20file.pdf",
+                    'table%&?*2',
+                    'bar' // make sure we can go back to the original name as a last step
+                ];
+                eachPair(names, (from, to) => {
+                    cy.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}${to}`);
+                        });
+                    cy.get('[data-cy=form-submit-btn]').click();
+                    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"})
-        .as('testCollection').then(function() {
-            cy.loginAs(activeUser);
-            cy.doSearch(`${this.testCollection.uuid}`);
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        })
+            .as('testCollection').then(function () {
+                cy.loginAs(activeUser);
+                cy.doSearch(`${this.testCollection.uuid}`);
 
-            // Rename 'bar' to 'subdir/foo'
-            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}subdir/foo`);
-                });
-            cy.get('[data-cy=form-submit-btn]').click();
-            cy.get('[data-cy=collection-files-panel]')
-                .should('not.contain', 'bar')
-                .and('contain', 'subdir');
-            // Look for the "arrow icon" and expand the "subdir" directory.
-            cy.get('[data-cy=virtual-file-tree] > div > i').click();
-            // Rename 'subdir/foo' to 'baz'
-            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}baz`);
+                ['subdir', 'G%C3%BCnter\'s%20file', 'table%&?*2'].forEach((subdir) => {
+                    cy.get('[data-cy=collection-files-panel]')
+                        .contains('bar').rightclick({force: true});
+                    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('[data-cy=form-submit-btn]').click();
+                    cy.get('[data-cy=collection-files-panel]')
+                        .should('not.contain', 'bar')
+                        .and('contain', subdir);
+                    // Look for the "arrow icon" and expand the "subdir" directory.
+                    cy.get('[data-cy=virtual-file-tree] > div > i').click();
+                    // Rename 'subdir/foo' to 'foo'
+                    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('[data-cy=form-submit-btn]').click();
+                    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-submit-btn]').click();
-            cy.get('[data-cy=collection-files-panel]')
-                .should('contain', 'subdir') // empty dir kept
-                .and('contain', 'baz');
-        });
+            });
     });
 
-    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"})
-        .as('testCollection').then(function() {
-            cy.loginAs(activeUser);
-            cy.doSearch(`${this.testCollection.uuid}`);
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:bar\n"
+        })
+            .as('testCollection').then(function () {
+                cy.loginAs(activeUser);
+                cy.doSearch(`${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']
-            ]
-            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')
-                    .within(() => {
-                        cy.get('input').type(`{selectall}{backspace}${name}`);
-                    });
-                cy.get('[data-cy=form-dialog]')
-                    .should('contain', 'Rename')
-                    .within(() => {
-                        cy.contains(`${errMsg}`);
-                    });
-                cy.get('[data-cy=form-cancel-btn]').click();
-            })
-        });
+                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']
+                ]
+                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')
+                        .within(() => {
+                            cy.get('input').type(`{selectall}{backspace}${name}`);
+                        });
+                    cy.get('[data-cy=form-dialog]')
+                        .should('contain', 'Rename')
+                        .within(() => {
+                            cy.contains(`${errMsg}`);
+                        });
+                    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 = '';
@@ -303,50 +336,51 @@ describe('Collection panel tests', function() {
             filters: `[["name", "=", "${colName}"]]`,
             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, {
             filters: `[["name", "=", "${colName}"]]`,
             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;
-                }
-            });
-            // Check the old version displays as what it is.
-            cy.loginAs(activeUser)
-            cy.doSearch(`${oldVersionUuid}`);
+            .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;
+                    }
+                });
+                // Check the old version displays as what it is.
+                cy.loginAs(activeUser)
+                cy.doSearch(`${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('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
@@ -355,125 +389,126 @@ 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"})
-        .as('collection').then(function() {
-            // Visit collection, check basic information
-            cy.loginAs(activeUser)
-            cy.doSearch(`${this.collection.uuid}`);
+            manifest_text: ". 37b51d194a7513e45b56f6524f2d51f2+3 0:3:foo 0:3:bar\n"
+        })
+            .as('collection').then(function () {
+                // Visit collection, check basic information
+                cy.loginAs(activeUser)
+                cy.doSearch(`${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');
+                // 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');
 
-            // 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')
-                .within(() => {
-                    // Version 1: 6 bytes in size
-                    cy.get('[data-cy=collection-version-browser-select-1]')
-                        .should('contain', '1').and('contain', '6 B');
-                    // 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');
-                    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');
+                // 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')
+                    .within(() => {
+                        // Version 1: 6 bytes in size
+                        cy.get('[data-cy=collection-version-browser-select-1]')
+                            .should('contain', '1').and('contain', '6 B');
+                        // 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');
+                        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');
 
-            // 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
+                // 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
 
-            // 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');
+                // 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');
 
-            // 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
+                // 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
 
-            // 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')
-                .within(() => {
-                    // appends some text
-                    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');
+                // 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')
+                    .within(() => {
+                        // appends some text
+                        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');
 
-            // Check context menus on version browser
-            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();
+                // Check context menus on version browser
+                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();
 
-            // Restore first version
-            cy.get('[data-cy=collection-version-browser]').within(() => {
-                cy.get('[data-cy=collection-version-browser-select-1]').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-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('creates new collection on home project', function() {
+    it('creates new collection on home project', function () {
         cy.loginAs(activeUser);
         cy.doSearch(`${activeUser.user.uuid}`);
         cy.get('[data-cy=breadcrumb-first]').should('contain', 'Projects');
index 309037ec58a9536112f62133cb2b46fa84936f69..8100a0e48135be15563a2a9e5f6d2eef6b26633b 100644 (file)
@@ -36,7 +36,7 @@ describe('Side panel tests', function() {
             .and('not.be.disabled');
     })
 
-    it('disables or enables the +NEW side panel button on depending on project permissions', function() {
+    it('disables or enables the +NEW side panel button depending on project permissions', function() {
         cy.loginAs(activeUser);
         [true, false].map(function(isWritable) {
             cy.createGroup(adminUser.token, {
@@ -75,4 +75,22 @@ describe('Side panel tests', function() {
                 .and('be.disabled');
         })
     })
-})
\ No newline at end of file
+
+    it('disables the +NEW side panel button when viewing filter group', function() {
+        cy.loginAs(adminUser);
+        cy.createGroup(adminUser.token, {
+            name: `my-favorite-filter-group`,
+            group_class: 'filter',
+            properties: {filters: []},
+        }).as('myFavoriteFilterGroup').then(function (myFavoriteFilterGroup) {
+            cy.contains('Refresh').click();
+            cy.doSearch(`${myFavoriteFilterGroup.uuid}`);
+            cy.get('[data-cy=breadcrumb-last]').should('contain', 'my-favorite-filter-group');
+
+            cy.get('[data-cy=side-panel-button]')
+                    .should('exist')
+                    .and(`be.disabled`);
+        })
+    })
+
+})
index c3c4fcd02733ac465bb2a4b27f63503a14bac9ee..cfc2c52c799ce96b2ef03647c8e0ba3d41d20613 100644 (file)
@@ -4,11 +4,14 @@
 
 import { ResourceKind } from "~/models/resource";
 
-export const resourceLabel = (type: string) => {
+export const resourceLabel = (type: string, subtype = '') => {
     switch (type) {
         case ResourceKind.COLLECTION:
             return "Data collection";
         case ResourceKind.PROJECT:
+            if (subtype === "filter") {
+                return "Filter group";
+            }
             return "Project";
         case ResourceKind.PROCESS:
             return "Process";
diff --git a/src/common/plugintypes.ts b/src/common/plugintypes.ts
new file mode 100644 (file)
index 0000000..a87b0a8
--- /dev/null
@@ -0,0 +1,178 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { Dispatch, Middleware } from 'redux';
+import { RootStore, RootState } from '~/store/store';
+import { ResourcesState } from '~/store/resources/resources';
+import { Location } from 'history';
+import { ServiceRepository } from "~/services/services";
+
+export type ElementListReducer = (startingList: React.ReactElement[], itemClass?: string) => React.ReactElement[];
+export type CategoriesListReducer = (startingList: string[]) => string[];
+export type NavigateMatcher = (dispatch: Dispatch, getState: () => RootState, uuid: string) => boolean;
+export type LocationChangeMatcher = (store: RootStore, pathname: string) => boolean;
+export type EnableNew = (location: Location, currentItemId: string, currentUserUUID: string | undefined, resources: ResourcesState) => boolean;
+export type MiddlewareListReducer = (startingList: Middleware[], services: ServiceRepository) => Middleware[];
+
+/* Workbench Plugin API
+
+   Code to your plugin should go into a subdirectory of '~/plugins'.
+
+   Your plugin should implement a "register" function, which will be
+   called with an object with the PluginConfig interface described
+   below.  The register function may make in-place modifications to
+   the pluginConfig object, but to preserve composability, it is
+   strongly advised this should be limited to push()ing new values
+   onto the various lists of hooks.
+
+   To enable a plugin, edit 'plugins.tsx', import the register
+   function exported by the plugin, and add a call to the register
+   function following the examples in the comments.  Then, build a new
+   Workbench package that includes the plugin.
+
+   Be aware that because plugins heavily leverage workbench, and in
+   fact must be compiled together, they are considered "derived works"
+   and so _must_ be license-compatible with AGPL-3.0.
+
+ */
+
+export interface PluginConfig {
+
+    /* During initialization, each
+     * function in the callback list will be called with the list of
+     * react - router "Route" components that will be used select what should
+     * be displayed in the central panel based on the navigation bar.
+     *
+     * The callback function may add, edit, or remove items from this list,
+     * and return a new list of components, which will be passed to the next
+     * function in `centerPanelList`.
+     *
+     * The hooks are applied in `views/workbench/workbench.tsx`.
+     *  */
+    centerPanelList: ElementListReducer[];
+
+    /* During initialization, each
+     * function in the callback list will be called with the list of strings
+     * that are the top-level categories in the left hand navigation tree.
+     *
+     * The callback function may add, edit, or remove items from this list,
+     * and return a new list of strings, which will be passed to the next
+     * function in `sidePanelCategories`.
+     *
+     * The hooks are applied in `store/side-panel-tree/side-panel-tree-actions.ts`.
+     *  */
+    sidePanelCategories: CategoriesListReducer[];
+
+    /* This is a list of additional dialog box components.
+     * Dialogs are components that are wrapped using the "withDialog()" method.
+     *
+     * These are added to the list in `views/workbench/workbench.tsx`.
+     *  */
+    dialogs: React.ReactElement[];
+
+    /* This is a list of additional navigation matchers.
+     * These are callbacks that are called by the navigateTo(uuid) method to
+     * set the path in the navigation bar to display the desired resource.
+     * Each handler should return "true" if the uuid was handled and "false or "undefined" if not.
+     *
+     * These are used in `store/navigation/navigation-action.tsx`.
+     *  */
+    navigateToHandlers: NavigateMatcher[];
+
+    /* This is a list of additional location change matchers.
+     * These are callbacks called when the URL in the navigation bar changes
+     * (this could be in response to "navigateTo()" or due to the user
+     * entering/changing the URL directly).
+     *
+     * The Route components in centerPanelList should
+     * automatically change in response to navigation.  The
+     * purpose of these handlers is trigger additional loading,
+     * such as fetching the object contents that will be
+     * displayed.
+     *
+     * Each handler should return "true" if the path was handled and "false or "undefined" if not.
+     *
+     * These are used in `routes/route-change-handlers.ts`.
+     */
+    locationChangeHandlers: LocationChangeMatcher[];
+
+    /* Replace the left side of the app bar.  Normally, this displays
+     * the site banner.
+     *
+     * Note: unlike most of the other hooks, this is not composable.
+     * This completely replaces that section of the app bar.  Multiple
+     * plugins setting this value will conflict.
+     *
+     * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+     */
+    appBarLeft?: React.ReactElement;
+
+    /* Replace the middle part of the app bar.  Normally, this displays
+     * the search bar.
+     *
+     * Note: unlike most of the other hooks, this is not composable.
+     * This completely replaces that section of the app bar.  Multiple
+     * plugins setting this value will conflict.
+     *
+     * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+     */
+    appBarMiddle?: React.ReactElement;
+
+    /* Replace the right part of the app bar.  Normally, this displays
+     * the admin menu and help menu.
+     * (Note: the user menu can be customized separately using accountMenuList)
+     *
+     * Note: unlike most of the other hooks, this is not composable.
+     * This completely replaces that section of the app bar.  Multiple
+     * plugins setting this value will conflict.
+     *
+     * Used in 'views-components/main-app-bar/main-app-bar.tsx'
+     */
+    appBarRight?: React.ReactElement;
+
+    /* During initialization, each
+     * function in the callback list will be called with the menu items that
+     * will appear in the "user account" menu.
+     *
+     * The callback function may add, edit, or remove items from this list,
+     * and return a new list of menu items, which will be passed to the next
+     * function in `accountMenuList`.
+     *
+     * The hooks are applied in 'views-components/main-app-bar/account-menu.tsx'.
+     *  */
+    accountMenuList: ElementListReducer[];
+
+    /* Each function in this list is called to determine if the the "NEW" button
+     * should be enabled or disabled.  If any function returns "true", the button
+     * (and corresponding drop-down menu) will be enabled.
+     *
+     * The hooks are applied in 'views-components/side-panel-button/side-panel-button.tsx'.
+     *  */
+    enableNewButtonMatchers: EnableNew[];
+
+    /* During initialization, each
+     * function in the callback list will be called with the menu items that
+     * will appear in the "NEW" dropdown menu.
+     *
+     * The callback function may add, edit, or remove items from this list,
+     * and return a new list of menu items, which will be passed to the next
+     * function in `newButtonMenuList`.
+     *
+     * The hooks are applied in 'views-components/side-panel-button/side-panel-button.tsx'.
+     *  */
+    newButtonMenuList: ElementListReducer[];
+
+    /* Add Middlewares to the Redux store.
+     *
+     * Middlewares intercept redux actions before they get to the reducer, and
+     * may produce side effects.  For example, the REQUEST_ITEMS action is intercepted by a middleware to
+     * trigger a load of data table contents.
+     *
+     * https://redux.js.org/tutorials/fundamentals/part-4-store#middleware
+     *
+     * Used in 'store/store.ts'
+     *  */
+    middlewares: MiddlewareListReducer[];
+}
diff --git a/src/common/url.test.ts b/src/common/url.test.ts
new file mode 100644 (file)
index 0000000..21bc518
--- /dev/null
@@ -0,0 +1,57 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { customDecodeURI, customEncodeURI } from './url';
+
+describe('url', () => {
+    describe('customDecodeURI', () => {
+        it('should decode encoded URI', () => {
+            // given
+            const path = 'test%23test%2Ftest';
+            const expectedResult = 'test#test%2Ftest';
+
+            // when
+            const result = customDecodeURI(path);
+
+            // then
+            expect(result).toEqual(expectedResult);
+        });
+
+        it('ignores non parsable URI and return its original form', () => {
+            // given
+            const path = 'test/path/with%wrong/sign';
+
+            // when
+            const result = customDecodeURI(path);
+
+            // then
+            expect(result).toEqual(path);
+        });
+    });
+
+    describe('customEncodeURI', () => {
+        it('should encode URI', () => {
+            // given
+            const path = 'test#test/test';
+            const expectedResult = 'test%23test/test';
+
+            // when
+            const result = customEncodeURI(path);
+
+            // then
+            expect(result).toEqual(expectedResult);
+        });
+
+        it('ignores non encodable URI and return its original form', () => {
+            // given
+            const path = 22;
+
+            // when
+            const result = customEncodeURI(path as any);
+
+            // then
+            expect(result).toEqual(path);
+        });
+    });
+});
\ No newline at end of file
index 9789b65effb6b47d6ed14c0589cb39b2b032c2a4..185737cac331d2a6a5eece01e8f99f293f76c230 100644 (file)
@@ -1,3 +1,7 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
 export function getUrlParameter(search: string, name: string) {
     const safeName = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]');
     const regex = new RegExp('[\\?&]' + safeName + '=([^&#]*)');
@@ -13,3 +17,19 @@ export function normalizeURLPath(url: string) {
     }
     return u.toString();
 }
+
+export const customEncodeURI = (path: string) => {
+    try {
+        return path.split('/').map(encodeURIComponent).join('/');
+    } catch(e) {}
+
+    return path;
+};
+
+export const customDecodeURI = (path: string) => {
+    try {
+        return path.split('%2F').map(decodeURIComponent).join('%2F');
+    } catch(e) {}
+
+    return path;
+};
index b928f82a0ff9e48a2968e335b9d8528ca0b09bf3..2ab106fcd3cb90a8710de2c0c5662cb8b53b78c5 100644 (file)
@@ -2,6 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { customEncodeURI } from "./url";
 import { WebDAV } from "./webdav";
 
 describe('WebDAV', () => {
@@ -54,70 +55,72 @@ describe('WebDAV', () => {
     it('COPY', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
+        webdav.defaults.baseURL = 'http://base';
         const promise = webdav.copy('foo', 'foo-copy');
         load();
         const request = await promise;
-        expect(open).toHaveBeenCalledWith('COPY', 'foo');
-        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'foo-copy');
+        expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('COPY - adds baseURL with trailing slash to Destination header', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'base/';
+        webdav.defaults.baseURL = 'http://base';
         const promise = webdav.copy('foo', 'foo-copy');
         load();
         const request = await promise;
-        expect(open).toHaveBeenCalledWith('COPY', 'base/foo');
-        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'base/foo-copy');
+        expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('COPY - adds baseURL without trailing slash to Destination header', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'base';
+        webdav.defaults.baseURL = 'http://base';
         const promise = webdav.copy('foo', 'foo-copy');
         load();
         const request = await promise;
-        expect(open).toHaveBeenCalledWith('COPY', 'base/foo');
-        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'base/foo-copy');
+        expect(open).toHaveBeenCalledWith('COPY', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-copy');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('MOVE', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
+        webdav.defaults.baseURL = 'http://base';
         const promise = webdav.move('foo', 'foo-moved');
         load();
         const request = await promise;
-        expect(open).toHaveBeenCalledWith('MOVE', 'foo');
-        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'foo-moved');
+        expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('MOVE - adds baseURL with trailing slash to Destination header', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'base/';
+        webdav.defaults.baseURL = 'http://base';
         const promise = webdav.move('foo', 'foo-moved');
         load();
         const request = await promise;
-        expect(open).toHaveBeenCalledWith('MOVE', 'base/foo');
-        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'base/foo-moved');
+        expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
     it('MOVE - adds baseURL without trailing slash to Destination header', async () => {
         const { open, setRequestHeader, load, createRequest } = mockCreateRequest();
         const webdav = new WebDAV(undefined, createRequest);
-        webdav.defaults.baseURL = 'base';
+        webdav.defaults.baseURL = 'http://base';
         const promise = webdav.move('foo', 'foo-moved');
         load();
         const request = await promise;
-        expect(open).toHaveBeenCalledWith('MOVE', 'base/foo');
-        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'base/foo-moved');
+        expect(open).toHaveBeenCalledWith('MOVE', 'http://base/foo');
+        expect(setRequestHeader).toHaveBeenCalledWith('Destination', 'http://base/foo-moved');
         expect(request).toBeInstanceOf(XMLHttpRequest);
     });
 
index c4d8acaea2e8a58f090b21d6b4b8dc48a31c0e49..758a5e18e1e9ad44015b9a802d0e532e70ba3d76 100644 (file)
@@ -2,6 +2,8 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { customEncodeURI } from "./url";
+
 export class WebDAV {
 
     defaults: WebDAVDefaults = {
@@ -75,7 +77,8 @@ export class WebDAV {
             r.open(config.method,
                 `${this.defaults.baseURL
                     ? this.defaults.baseURL+'/'
-                    : ''}${encodeURI(config.url)}`);
+                    : ''}${customEncodeURI(config.url)}`);
+
             const headers = { ...this.defaults.headers, ...config.headers };
             Object
                 .keys(headers)
index 3c6feb5dce8836a92a08074ba32a768af4ec84fc..e7db3acad8d904b58b4ae0c2061023eaade227b4 100644 (file)
@@ -2,9 +2,19 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-export const getTagValue = (document: Document | Element, tagName: string, defaultValue: string) => {
+import { customDecodeURI } from "./url";
+
+export const getTagValue = (document: Document | Element, tagName: string, defaultValue: string, skipDecoding: boolean = false) => {
     const [el] = Array.from(document.getElementsByTagName(tagName));
-    return decodeURI(el ? htmlDecode(el.innerHTML) : defaultValue);
+    const URI = el ? htmlDecode(el.innerHTML) : defaultValue;
+
+    if (!skipDecoding) {
+        try {
+            return customDecodeURI(URI);
+        } catch(e) {}
+    }
+
+    return URI;
 };
 
 const htmlDecode = (input: string) => {
index 8c847ca48e77acb5ba33670d1cf0e8da4dc64200..6970a38af8595087344d0ed4e5b52071c578f99a 100644 (file)
@@ -42,6 +42,7 @@ interface DialogProjectDataProps {
     dialogTitle: string;
     formFields: React.ComponentType<InjectedFormProps<any> & WithDialogProps<any>>;
     submitLabel?: string;
+    enableWhenPristine?: boolean;
 }
 
 type DialogProjectProps = DialogProjectDataProps & WithDialogProps<{}> & InjectedFormProps<any> & WithStyles<CssRules>;
@@ -76,7 +77,7 @@ export const FormDialog = withStyles(styles)((props: DialogProjectProps) =>
                     onClick={props.handleSubmit}
                     className={props.classes.lastButton}
                     color="primary"
-                    disabled={props.invalid || props.submitting || props.pristine}
+                    disabled={props.invalid || props.submitting || (props.pristine && !props.enableWhenPristine)}
                     variant="contained">
                     {props.submitLabel || 'Submit'}
                     {props.submitting && <CircularProgress size={20} className={props.classes.progressIndicator} />}
@@ -85,5 +86,3 @@ export const FormDialog = withStyles(styles)((props: DialogProjectProps) =>
         </form>
     </Dialog>
 );
-
-
index 9eb60332e7261e0ba75f40bc62334432d988c4ff..6bbacaf4bebf6f368778e968ad275a550e960b19 100644 (file)
@@ -28,6 +28,7 @@ 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';
@@ -126,6 +127,7 @@ export const PaginationLeftArrowIcon: IconType = (props) => <ChevronLeft {...pro
 export const PaginationRightArrowIcon: IconType = (props) => <ChevronRight {...props} />;
 export const ProcessIcon: IconType = (props) => <BubbleChart {...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} />;
index 908ee28ca8b24a139c21ef76358a6cbeaa723c5b..cf4d708daaabab31eddd615baac1b82a298b88a7 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { List, ListItem, ListItemIcon, Checkbox, Radio, Collapse } from "@material-ui/core";
 import { StyleRulesCallback, withStyles, WithStyles } from '@material-ui/core/styles';
-import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon } from '~/components/icon/icon';
+import { CollectionIcon, DefaultIcon, DirectoryIcon, FileIcon, ProjectIcon, FilterGroupIcon } from '~/components/icon/icon';
 import { ReactElement } from "react";
 import CircularProgress from '@material-ui/core/CircularProgress';
 import classnames from "classnames";
@@ -13,6 +13,7 @@ import classnames from "classnames";
 import { ArvadosTheme } from '~/common/custom-theme';
 import { SidePanelRightArrowIcon } from '../icon/icon';
 import { ResourceKind } from '~/models/resource';
+import { GroupClass } from '~/models/group';
 
 type CssRules = 'list'
     | 'listItem'
@@ -162,31 +163,35 @@ const FLAT_TREE_ACTIONS = {
     toggleActive: 'TOGGLE_ACTIVE',
 };
 
-const ItemIcon = React.memo(({type, kind, active, classes}: any) => {
+const ItemIcon = React.memo(({type, kind, active, groupClass, classes}: any) => {
     let Icon = ProjectIcon;
 
-        if (type) {
-            switch (type) {
-                case 'directory':
-                    Icon = DirectoryIcon;
-                    break;
-                case 'file':
-                    Icon = FileIcon;
-                    break;
-                default:
-                    Icon = DefaultIcon;
-            }
+    if (groupClass === GroupClass.FILTER) {
+        Icon = FilterGroupIcon;
+    }
+
+    if (type) {
+        switch (type) {
+            case 'directory':
+                Icon = DirectoryIcon;
+                break;
+            case 'file':
+                Icon = FileIcon;
+                break;
+            default:
+                Icon = DefaultIcon;
         }
+    }
 
-        if (kind) {
-            switch(kind) {
-                case ResourceKind.COLLECTION:
-                    Icon = CollectionIcon;
-                    break;
-                default:
-                    break;
-            }
+    if (kind) {
+        switch(kind) {
+            case ResourceKind.COLLECTION:
+                Icon = CollectionIcon;
+                break;
+            default:
+                break;
         }
+    }
 
     return <Icon className={classnames({ [classes.active]: active }, classes.childItemIcon)} />;
 });
@@ -228,7 +233,7 @@ const FlatTree = (props: FlatTreeProps) =>
                     </i>
                     <div data-action={FLAT_TREE_ACTIONS.toggleActive} className={props.classes.renderContainer}>
                         <span style={{ display: 'flex', alignItems: 'center' }}>
-                            <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} classes={props.classes} />
+                            <ItemIcon type={item.data.type} active={item.active} kind={item.data.kind} groupClass={item.data.kind === ResourceKind.GROUP ? item.data.groupClass : ''} classes={props.classes} />
                             <span style={{ fontSize: '0.875rem' }}>
                                 {item.data.name}
                             </span>
index 52867a836cdab2b971ad00198f45f950af51c659..5eb23ba194dd6bbb27feac0a356bcd9a29641ac5 100644 (file)
@@ -188,7 +188,6 @@ export const VirtualTree = withStyles(styles)(
     class Component<T> extends React.Component<TreeProps<T> & WithStyles<CssRules>, {}> {
         render(): ReactElement<any> {
             const { items, render } = this.props;
-
             return <AutoSizer>
                 {({ height, width }) => {
                     return VirtualList(height, width, items || [], render, this.props);
index b32066a46c69c37a9185bece05294319b6453fbc..43cfb5fb03e31513e7689561760a4246ec3a35d6 100644 (file)
@@ -21,7 +21,7 @@ import { CustomTheme } from '~/common/custom-theme';
 import { fetchConfig } from '~/common/config';
 import { addMenuActionSet, ContextMenuKind } from '~/views-components/context-menu/context-menu';
 import { rootProjectActionSet } from "~/views-components/context-menu/action-sets/root-project-action-set";
-import { projectActionSet, readOnlyProjectActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
+import { filterGroupActionSet, projectActionSet, readOnlyProjectActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
 import { resourceActionSet } from '~/views-components/context-menu/action-sets/resource-action-set';
 import { favoriteActionSet } from "~/views-components/context-menu/action-sets/favorite-action-set";
 import { collectionFilesActionSet, readOnlyCollectionFilesActionSet } from '~/views-components/context-menu/action-sets/collection-files-action-set';
@@ -37,7 +37,7 @@ 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 } from '~/views-components/context-menu/action-sets/process-resource-action-set';
+import { processResourceActionSet, 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';
@@ -58,7 +58,7 @@ import { groupMemberActionSet } from '~/views-components/context-menu/action-set
 import { linkActionSet } from '~/views-components/context-menu/action-sets/link-action-set';
 import { loadFileViewersConfig } from '~/store/file-viewers/file-viewers-actions';
 import { processResourceAdminActionSet } from '~/views-components/context-menu/action-sets/process-resource-admin-action-set';
-import { projectAdminActionSet } from '~/views-components/context-menu/action-sets/project-admin-action-set';
+import { filterGroupAdminActionSet, projectAdminActionSet } from '~/views-components/context-menu/action-sets/project-admin-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';
@@ -68,6 +68,7 @@ console.log(`Starting arvados [${getBuildInfo()}]`);
 addMenuActionSet(ContextMenuKind.ROOT_PROJECT, rootProjectActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT, projectActionSet);
 addMenuActionSet(ContextMenuKind.READONLY_PROJECT, readOnlyProjectActionSet);
+addMenuActionSet(ContextMenuKind.FILTER_GROUP, filterGroupActionSet);
 addMenuActionSet(ContextMenuKind.RESOURCE, resourceActionSet);
 addMenuActionSet(ContextMenuKind.FAVORITE, favoriteActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_FILES, collectionFilesActionSet);
@@ -81,6 +82,7 @@ addMenuActionSet(ContextMenuKind.OLD_VERSION_COLLECTION, oldCollectionVersionAct
 addMenuActionSet(ContextMenuKind.TRASHED_COLLECTION, trashedCollectionActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS, processActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_RESOURCE, processResourceActionSet);
+addMenuActionSet(ContextMenuKind.READONLY_PROCESS_RESOURCE, readOnlyProcessResourceActionSet);
 addMenuActionSet(ContextMenuKind.TRASH, trashActionSet);
 addMenuActionSet(ContextMenuKind.REPOSITORY, repositoryActionSet);
 addMenuActionSet(ContextMenuKind.SSH_KEY, sshKeyActionSet);
@@ -95,6 +97,7 @@ addMenuActionSet(ContextMenuKind.GROUP_MEMBER, groupMemberActionSet);
 addMenuActionSet(ContextMenuKind.COLLECTION_ADMIN, collectionAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROCESS_ADMIN, processResourceAdminActionSet);
 addMenuActionSet(ContextMenuKind.PROJECT_ADMIN, projectAdminActionSet);
+addMenuActionSet(ContextMenuKind.FILTER_GROUP_ADMIN, filterGroupAdminActionSet);
 
 storeRedirects();
 
@@ -119,7 +122,8 @@ fetchConfig()
                                 ? error.errors[0]
                                 : error.message}`,
                             kind: SnackbarKind.ERROR,
-                            hideDuration: 8000})
+                            hideDuration: 8000
+                        })
                         );
                     }
                 }
index e18c8ecbb96c6b67652ee51f2245ba022eaddd17..365e9ccebb9fc22341da3589f2dc993287d304c7 100644 (file)
@@ -15,5 +15,6 @@ export interface GroupResource extends TrashableResource {
 }
 
 export enum GroupClass {
-    PROJECT = "project"
+    PROJECT = 'project',
+    FILTER  = 'filter',
 }
index 785d531cf7d609fec3af696d16c3fbb9028753a4..1c82fe58502a3ae8fe1ac18ee6e1350bd895749b 100644 (file)
@@ -2,7 +2,6 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { TagProperty } from "~/models/tag";
 import { Resource, ResourceKind } from '~/models/resource';
 
 export interface LinkResource extends Resource {
@@ -12,7 +11,7 @@ export interface LinkResource extends Resource {
     tailKind: string;
     linkClass: string;
     name: string;
-    properties: TagProperty;
+    properties: any;
     kind: ResourceKind.LINK;
 }
 
@@ -21,4 +20,4 @@ export enum LinkClass {
     TAG = 'tag',
     PERMISSION = 'permission',
     PRESET = 'preset',
-}
\ No newline at end of file
+}
index 8e101ce29ffeaac99cb7c2073aae96b8b79ac9e8..86ac04f6dd58222d869bd29980ed03715f0adba7 100644 (file)
@@ -5,7 +5,7 @@
 import { GroupClass, GroupResource } from "./group";
 
 export interface ProjectResource extends GroupResource {
-    groupClass: GroupClass.PROJECT;
+    groupClass: GroupClass.PROJECT | GroupClass.FILTER;
 }
 
 export const getProjectUrl = (uuid: string) => {
diff --git a/src/plugins.tsx b/src/plugins.tsx
new file mode 100644 (file)
index 0000000..73811e5
--- /dev/null
@@ -0,0 +1,30 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PluginConfig } from '~/common/plugintypes';
+
+export const pluginConfig: PluginConfig = {
+    centerPanelList: [],
+    sidePanelCategories: [],
+    dialogs: [],
+    navigateToHandlers: [],
+    locationChangeHandlers: [],
+    appBarLeft: undefined,
+    appBarMiddle: undefined,
+    appBarRight: undefined,
+    accountMenuList: [],
+    enableNewButtonMatchers: [],
+    newButtonMenuList: [],
+    middlewares: []
+};
+
+// Starting here, import and register your Workbench 2 plugins. //
+
+// import { register as blankUIPluginRegister } from '~/plugins/blank/index';
+// import { register as examplePluginRegister } from '~/plugins/example/index';
+// import { register as rootRedirectRegister } from '~/plugins/root-redirect/index';
+
+// blankUIPluginRegister(pluginConfig);
+// examplePluginRegister(pluginConfig);
+// rootRedirectRegister(pluginConfig, exampleRoutePath);
diff --git a/src/plugins/README.md b/src/plugins/README.md
new file mode 100644 (file)
index 0000000..931590c
--- /dev/null
@@ -0,0 +1,65 @@
+[comment]: # (Copyright © The Arvados Authors. All rights reserved.)
+[comment]: # ()
+[comment]: # (SPDX-License-Identifier: CC-BY-SA-3.0)
+
+# Plugin support
+
+Workbench supports plugins to add new functionality to the user
+interface.  It is also possible to remove the majority of standard UI
+elements and replace them with your own, enabling you to use workbench
+as a basis for developing essentially new applications for Arvados.
+
+## Installing plugins
+
+1. Check out the source of your plugin into a directory under `arvados-workbench2/src/plugins`
+
+2. Register the plugin by editing `arvados-workbench2/src/plugins/plugins.tsx`.
+It will look something like this:
+
+```
+import { register as examplePluginRegister } from '~/plugins/example/index';
+examplePluginRegister(pluginConfig);
+```
+
+3. Rebuild Workbench 2
+
+For testing/development: `yarn start`
+
+For production: `APP_NAME=arvados-workbench2-with-custom-plugins make packages`
+
+Set `APP_NAME=` to whatever you like, but it is important to name it
+differently from the standard `arvados-workbench2` to avoid confusion.
+
+## Existing plugins
+
+### example
+
+This is an example plugin showing how to add a new navigation tree
+item, displaying a new center panel, as well as adding account menu
+and "New" menu items, and showing how to use SET_PROPERTY and
+getProperty() for state.
+
+### blank
+
+This deletes all of the existing user interface.  If you want the
+application to only display your plugin's UI elements and none of the
+standard elements, you would load and register this first.
+
+### root-redirect
+
+This helper takes a path when registered.  It tweaks the navigation
+behavior so that the default starting location when the application
+loads will be the path you provide, instead of "Projects".
+
+### sample-tracker
+
+This is a a new set of user interface screens that assist with
+clinical sample tracking and analysis.  It is intended as a demo of
+how a real-world application can built using the Workbench 2
+plug-in interface.  It can be found at
+https://github.com/arvados/sample-tracker .
+
+## Developing plugins
+
+For information about the plugin API, see
+[../common/plugintypes.ts](src/common/plugintypes.ts).
diff --git a/src/plugins/blank/index.tsx b/src/plugins/blank/index.tsx
new file mode 100644 (file)
index 0000000..0074c02
--- /dev/null
@@ -0,0 +1,22 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Example plugin.
+
+import { PluginConfig } from '~/common/plugintypes';
+import * as React from 'react';
+
+export const register = (pluginConfig: PluginConfig) => {
+
+    pluginConfig.centerPanelList.push((elms) => []);
+
+    pluginConfig.sidePanelCategories.push((cats: string[]): string[] => []);
+
+    pluginConfig.accountMenuList.push((elms) => []);
+    pluginConfig.newButtonMenuList.push((elms) => []);
+
+    pluginConfig.appBarLeft = <span />;
+    pluginConfig.appBarMiddle = <span />;
+    pluginConfig.appBarRight = <span />;
+};
diff --git a/src/plugins/example/exampleComponents.tsx b/src/plugins/example/exampleComponents.tsx
new file mode 100644 (file)
index 0000000..de2be4e
--- /dev/null
@@ -0,0 +1,131 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import * as React from 'react';
+import { WithDialogProps } from '~/store/dialog/with-dialog';
+import { ServiceRepository } from "~/services/services";
+import { Dispatch } from "redux";
+import { RootState } from '~/store/store';
+import { initialize } from 'redux-form';
+import { dialogActions } from "~/store/dialog/dialog-actions";
+import { reduxForm, InjectedFormProps, Field, reset, startSubmit } from 'redux-form';
+import { TextField } from "~/components/text-field/text-field";
+import { FormDialog } from '~/components/form-dialog/form-dialog';
+import { withDialog } from "~/store/dialog/with-dialog";
+import { compose } from "redux";
+import { propertiesActions } from "~/store/properties/properties-actions";
+import { DispatchProp, connect } from 'react-redux';
+import { MenuItem } from "@material-ui/core";
+import { Card, CardContent, Typography } from "@material-ui/core";
+
+// This is the name of the dialog box.  It in store actions that
+// open/close the dialog box.
+export const EXAMPLE_DIALOG_FORM_NAME = "exampleFormName";
+
+// This is the name of the property that will be used to store the
+// "pressed" count
+export const propertyKey = "Example_menu_item_pressed_count";
+
+// The model backing the form.
+export interface ExampleFormDialogData {
+    pressedCount: number | string;  // Supposed to start as a number but TextField seems to turn this into a string, unfortunately.
+}
+
+// The actual component with the editing fields.  Enables editing
+// the 'pressedCount' field.
+const ExampleEditFields = () => <span>
+    <Field
+        name='pressedCount'
+        component={TextField}
+        type="number"
+    />
+</span>;
+
+// Callback for when the form is submitted.
+const submitEditedPressedCount = (data: ExampleFormDialogData) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(startSubmit(EXAMPLE_DIALOG_FORM_NAME));
+        dispatch(propertiesActions.SET_PROPERTY({
+            key: propertyKey, value: parseInt(data.pressedCount as string, 10)
+        }));
+        dispatch(dialogActions.CLOSE_DIALOG({ id: EXAMPLE_DIALOG_FORM_NAME }));
+        dispatch(reset(EXAMPLE_DIALOG_FORM_NAME));
+    };
+
+// Props for the dialog component
+type DialogExampleProps = WithDialogProps<{ updating: boolean }> & InjectedFormProps<ExampleFormDialogData>;
+
+// This is the component that renders the dialog.
+const DialogExample = (props: DialogExampleProps) =>
+    <FormDialog
+        dialogTitle="Edit pressed count"
+        formFields={ExampleEditFields}
+        submitLabel="Update pressed count"
+        {...props}
+    />;
+
+// This ties it all together, withDialog() determines if the dialog is
+// visible based on state, and reduxForm manages the values of the
+// dialog's fields.
+export const ExampleDialog = compose(
+    withDialog(EXAMPLE_DIALOG_FORM_NAME),
+    reduxForm<ExampleFormDialogData>({
+        form: EXAMPLE_DIALOG_FORM_NAME,
+        onSubmit: (data, dispatch) => {
+            dispatch(submitEditedPressedCount(data));
+        }
+    })
+)(DialogExample);
+
+
+// Callback, dispatches an action to set the value of property
+// "Example_menu_item_pressed_count"
+const incrementPressedCount = (dispatch: Dispatch, pressedCount: number) => {
+    dispatch(propertiesActions.SET_PROPERTY({ key: propertyKey, value: pressedCount + 1 }));
+};
+
+// Callback, dispatches actions required to initialize and open the
+// dialog box.
+export const openExampleDialog = (pressedCount: number) =>
+    (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
+        dispatch(initialize(EXAMPLE_DIALOG_FORM_NAME, { pressedCount }));
+        dispatch(dialogActions.OPEN_DIALOG({
+            id: EXAMPLE_DIALOG_FORM_NAME, data: {}
+        }));
+    };
+
+// Props definition used for menu items.
+interface ExampleProps {
+    pressedCount: number;
+    className?: string;
+}
+
+// Called to get the props from the redux state for several of the
+// following components.
+// Gets the value of the property "Example_menu_item_pressed_count"
+// from the state and puts it in 'pressedCount'
+const exampleMapStateToProps = (state: RootState) => ({ pressedCount: state.properties[propertyKey] || 0 });
+
+// Define component for the menu item that incremens the count each time it is pressed.
+export const ExampleMenuComponent = connect(exampleMapStateToProps)(
+    ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp<any>) =>
+        <MenuItem className={className} onClick={() => incrementPressedCount(dispatch, pressedCount)}>Example menu item</MenuItem >
+);
+
+// Define component for the menu item that opens the dialog box that lets you edit the count directly.
+export const ExampleDialogMenuComponent = connect(exampleMapStateToProps)(
+    ({ pressedCount, dispatch, className }: ExampleProps & DispatchProp<any>) =>
+        <MenuItem className={className} onClick={() => dispatch(openExampleDialog(pressedCount))}>Open example dialog</MenuItem >
+);
+
+// The central panel.  Displays the "pressed" count.
+export const ExamplePluginMainPanel = connect(exampleMapStateToProps)(
+    ({ pressedCount }: ExampleProps) =>
+        <Card>
+            <CardContent>
+                <Typography>
+                    This is a example main panel plugin.  The example menu item has been pressed {pressedCount} times.
+               </Typography>
+            </CardContent>
+        </Card>);
diff --git a/src/plugins/example/index.tsx b/src/plugins/example/index.tsx
new file mode 100644 (file)
index 0000000..f4bb27b
--- /dev/null
@@ -0,0 +1,85 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+// Example workbench plugin.  The entry point is the "register" method.
+
+import { PluginConfig } from '~/common/plugintypes';
+import * as React from 'react';
+import { Dispatch } from 'redux';
+import { RootState } from '~/store/store';
+import { push } from "react-router-redux";
+import { Route, matchPath } from "react-router";
+import { RootStore } from '~/store/store';
+import { activateSidePanelTreeItem } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { setSidePanelBreadcrumbs } from '~/store/breadcrumbs/breadcrumbs-actions';
+import { Location } from 'history';
+import { handleFirstTimeLoad } from '~/store/workbench/workbench-actions';
+import {
+    ExampleDialog,
+    ExamplePluginMainPanel,
+    ExampleMenuComponent,
+    ExampleDialogMenuComponent
+} from './exampleComponents';
+
+const categoryName = "Plugin Example";
+export const routePath = "/examplePlugin";
+
+export const register = (pluginConfig: PluginConfig) => {
+
+    // Add this component to the main panel.  When the app navigates
+    // to '/examplePlugin' it will render ExamplePluginMainPanel.
+    pluginConfig.centerPanelList.push((elms) => {
+        elms.push(<Route path={routePath} component={ExamplePluginMainPanel} />);
+        return elms;
+    });
+
+    // Add ExampleDialogMenuComponent to the upper-right user account menu
+    pluginConfig.accountMenuList.push((elms, menuItemClass) => {
+        elms.push(<ExampleDialogMenuComponent className={menuItemClass} />);
+        return elms;
+    });
+
+    // Add ExampleMenuComponent to the "New" button dropdown.
+    pluginConfig.newButtonMenuList.push((elms, menuItemClass) => {
+        elms.push(<ExampleMenuComponent className={menuItemClass} />);
+        return elms;
+    });
+
+    // Add a hook so that when the 'Plugin Example' entry in the left
+    // hand tree view is clicked, which calls navigateTo('Plugin Example'),
+    // it will be implemented by navigating to '/examplePlugin'
+    pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => {
+        if (uuid === categoryName) {
+            dispatch(push(routePath));
+            return true;
+        }
+        return false;
+    });
+
+    // Adds 'Plugin Example' to the left hand tree view.
+    pluginConfig.sidePanelCategories.push((cats: string[]): string[] => { cats.push(categoryName); return cats; });
+
+    // When the location changes to '/examplePlugin', make sure
+    // 'Plugin Example' in the left hand tree view is selected, and
+    // make sure the breadcrumbs are updated.
+    pluginConfig.locationChangeHandlers.push((store: RootStore, pathname: string): boolean => {
+        if (matchPath(pathname, { path: routePath, exact: true })) {
+            store.dispatch(handleFirstTimeLoad(
+                (dispatch: Dispatch) => {
+                    dispatch<any>(activateSidePanelTreeItem(categoryName));
+                    dispatch<any>(setSidePanelBreadcrumbs(categoryName));
+                }));
+            return true;
+        }
+        return false;
+    });
+
+    // The "New" button can enabled or disabled based on the current
+    // context or selection.  This adds a new callback to that will
+    // enable the "New" button when the location is '/examplePlugin'
+    pluginConfig.enableNewButtonMatchers.push((location: Location) => (!!matchPath(location.pathname, { path: routePath, exact: true })));
+
+    // Add the example dialog box to the list of dialog box controls.
+    pluginConfig.dialogs.push(<ExampleDialog />);
+};
diff --git a/src/plugins/root-redirect/index.tsx b/src/plugins/root-redirect/index.tsx
new file mode 100644 (file)
index 0000000..13eeb94
--- /dev/null
@@ -0,0 +1,20 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { PluginConfig } from '~/common/plugintypes';
+import { Dispatch } from 'redux';
+import { RootState } from '~/store/store';
+import { SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
+import { push } from "react-router-redux";
+
+export const register = (pluginConfig: PluginConfig, redirect: string) => {
+
+    pluginConfig.navigateToHandlers.push((dispatch: Dispatch, getState: () => RootState, uuid: string) => {
+        if (uuid === SidePanelTreeCategory.PROJECTS) {
+            dispatch(push(redirect));
+            return true;
+        }
+        return false;
+    });
+};
index 400ddc88541eee668482b37abfdb77957fbdbbeb..8a66e4207f09cab88b4b05bb633c726e1f67d89d 100644 (file)
@@ -10,6 +10,7 @@ import { navigateToRootProject } from '~/store/navigation/navigation-action';
 import { dialogActions } from '~/store/dialog/dialog-actions';
 import { contextMenuActions } from '~/store/context-menu/context-menu-actions';
 import { searchBarActions } from '~/store/search-bar/search-bar-actions';
+import { pluginConfig } from '~/plugins';
 
 export const addRouteChangeHandlers = (history: History, store: RootStore) => {
     const handler = handleLocationChange(store);
@@ -53,6 +54,12 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     store.dispatch(contextMenuActions.CLOSE_CONTEXT_MENU());
     store.dispatch(searchBarActions.CLOSE_SEARCH_VIEW());
 
+    for (const locChangeFn of pluginConfig.locationChangeHandlers) {
+        if (locChangeFn(store, pathname)) {
+            return;
+        }
+    }
+
     if (projectMatch) {
         store.dispatch(WorkbenchActions.loadProject(projectMatch.params.id));
     } else if (collectionMatch) {
@@ -112,4 +119,4 @@ const handleLocationChange = (store: RootStore) => ({ pathname }: Location) => {
     } else if (allProcessesMatch) {
         store.dispatch(WorkbenchActions.loadAllProcesses());
     }
-};
\ No newline at end of file
+};
diff --git a/src/services/collection-service/collection-service-files-response.test.ts b/src/services/collection-service/collection-service-files-response.test.ts
new file mode 100644 (file)
index 0000000..074aa5c
--- /dev/null
@@ -0,0 +1,103 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { CollectionFile } from '~/models/collection-file';
+import { getFileFullPath, extractFilesData } from './collection-service-files-response';
+
+describe('collection-service-files-response', () => {
+
+    describe('extractFilesData', () => {
+        it('should correctly decode URLs & file names', () => {
+            const testCases = [
+                // input URL, input display name, expected URL, expected name
+                ['table%201%202%203', 'table 1 2 3', 'table%201%202%203', 'table 1 2 3'],
+                ['table%25&amp;%3F%2A2', 'table%&amp;?*2', 'table%25&%3F%2A2', 'table%&?*2'],
+                ["G%C3%BCnter%27s%20file.pdf", "Günter&#39;s file.pdf", "G%C3%BCnter%27s%20file.pdf", "Günter's file.pdf"],
+                ['G%25C3%25BCnter%27s%2520file.pdf', 'G%C3%BCnter&#39;s%20file.pdf', "G%25C3%25BCnter%27s%2520file.pdf", "G%C3%BCnter's%20file.pdf"]
+            ];
+
+            testCases.forEach(([inputURL, inputDisplayName, expectedURL, expectedName]) => {
+                // given
+                const collUUID = 'xxxxx-zzzzz-vvvvvvvvvvvvvvv';
+                const xmlData = `
+                <?xml version="1.0" encoding="UTF-8"?>
+                <D:multistatus xmlns:D="DAV:">
+                    <D:response>
+                        <D:href>/c=xxxxx-zzzzz-vvvvvvvvvvvvvvv/</D:href>
+                        <D:propstat>
+                            <D:prop>
+                                <D:resourcetype>
+                                    <D:collection xmlns:D="DAV:"/>
+                                </D:resourcetype>
+                                <D:supportedlock>
+                                    <D:lockentry xmlns:D="DAV:">
+                                        <D:lockscope>
+                                            <D:exclusive/>
+                                        </D:lockscope>
+                                        <D:locktype>
+                                            <D:write/>
+                                        </D:locktype>
+                                    </D:lockentry>
+                                </D:supportedlock>
+                                <D:displayname></D:displayname>
+                                <D:getlastmodified>Fri, 26 Mar 2021 14:44:08 GMT</D:getlastmodified>
+                            </D:prop>
+                            <D:status>HTTP/1.1 200 OK</D:status>
+                        </D:propstat>
+                    </D:response>
+                    <D:response>
+                        <D:href>/c=${collUUID}/${inputURL}</D:href>
+                        <D:propstat>
+                            <D:prop>
+                                <D:resourcetype></D:resourcetype>
+                                <D:getcontenttype>application/pdf</D:getcontenttype>
+                                <D:supportedlock>
+                                    <D:lockentry xmlns:D="DAV:">
+                                        <D:lockscope>
+                                            <D:exclusive/>
+                                        </D:lockscope>
+                                        <D:locktype>
+                                            <D:write/>
+                                        </D:locktype>
+                                    </D:lockentry>
+                                </D:supportedlock>
+                                <D:displayname>${inputDisplayName}</D:displayname>
+                                <D:getcontentlength>3</D:getcontentlength>
+                                <D:getlastmodified>Fri, 26 Mar 2021 14:44:08 GMT</D:getlastmodified>
+                                <D:getetag>"166feb9c9110c008325a59"</D:getetag>
+                            </D:prop>
+                            <D:status>HTTP/1.1 200 OK</D:status>
+                        </D:propstat>
+                    </D:response>
+                </D:multistatus>
+                `;
+                const parser = new DOMParser();
+                const xmlDoc = parser.parseFromString(xmlData, "text/xml");
+
+                // when
+                const result = extractFilesData(xmlDoc);
+
+                // then
+                expect(result).toEqual([{ id: `${collUUID}/${expectedName}`, name: expectedName, path: "", size: 3, type: "file", url: `/c=${collUUID}/${expectedURL}` }]);
+            });
+        });
+    });
+
+    describe('getFileFullPath', () => {
+        it('should encode weird names', async () => {
+            // given
+            const file = {
+                name: '#test',
+                path: 'http://localhost',
+            } as CollectionFile;
+
+            // when
+            const result = getFileFullPath(file);
+
+            // then
+            expect(result).toBe('http://localhost/#test');
+        });
+
+    });
+});
\ No newline at end of file
index 5e6f7b83f0ecf7628546ce5101dd75c7e52f6629..325339d00bb803d6e8aebbd19e8926f3953b56e0 100644 (file)
@@ -25,33 +25,39 @@ export const extractFilesData = (document: Document) => {
         .from(document.getElementsByTagName('D:response'))
         .slice(1) // omit first element which is collection itself
         .map(element => {
-            const name = getTagValue(element, 'D:displayname', '');
-            const size = parseInt(getTagValue(element, 'D:getcontentlength', '0'), 10);
-            const url = getTagValue(element, 'D:href', '');
-            const nameSuffix = `/${name || ''}`;
+            const name = getTagValue(element, 'D:displayname', '', true); // skip decoding as value should be already decoded
+            const size = parseInt(getTagValue(element, 'D:getcontentlength', '0', true), 10);
+            const url = getTagValue(element, 'D:href', '', true);
             const collectionUuidMatch = collectionUrlPrefix.exec(url);
             const collectionUuid = collectionUuidMatch ? collectionUuidMatch.pop() : '';
-            const directory = url
+            const pathArray = url.split(`/`);
+            if (!pathArray.pop()) {
+                pathArray.pop();
+            }
+            const directory = pathArray.join('/')
                 .replace(collectionUrlPrefix, '')
-                .replace(nameSuffix, '');
+                .replace(/\/\//g, '/');
 
             const parentPath = directory.replace(/\/$/, '');
             const data = {
                 url,
                 id: [
                     collectionUuid ? collectionUuid : '',
-                    directory ? parentPath : '',
+                    directory ? unescape(parentPath) : '',
                     '/' + name
                 ].join(''),
                 name,
-                path: parentPath,
+                path: unescape(parentPath),
             };
 
-            return getTagValue(element, 'D:resourcetype', '')
+            const result = getTagValue(element, 'D:resourcetype', '')
                 ? createCollectionDirectory(data)
                 : createCollectionFile({ ...data, size });
+
+            return result;
         });
 };
 
-export const getFileFullPath = ({ name, path }: CollectionFile | CollectionDirectory) =>
-    `${path}/${name}`;
+export const getFileFullPath = ({ name, path }: CollectionFile | CollectionDirectory) => {
+    return `${path}/${name}`;
+};
\ No newline at end of file
index c46c3e27764470f1c214cc4ff82200d612b6a8c4..9fd8a13511ff850b26f9a40af01336616c2340e2 100644 (file)
@@ -10,6 +10,7 @@ import { AuthService } from "../auth-service/auth-service";
 import { extractFilesData } from "./collection-service-files-response";
 import { TrashableResourceService } from "~/services/common-service/trashable-resource-service";
 import { ApiActions } from "~/services/api/api-actions";
+import { customEncodeURI } from "~/common/url";
 
 export type UploadProgress = (fileId: number, loaded: number, total: number, currentTime: number) => void;
 
@@ -78,7 +79,7 @@ export class CollectionService extends TrashableResourceService<CollectionResour
     async moveFile(collectionUuid: string, oldPath: string, newPath: string) {
         await this.webdavClient.move(
             `c=${collectionUuid}${oldPath}`,
-            `c=${collectionUuid}${encodeURI(newPath)}`
+            `c=${collectionUuid}/${customEncodeURI(newPath)}`
         );
         await this.update(collectionUuid, { preserveVersion: true });
     }
index 12eae0fec00be34f3399fc8280db8ea4a7d95681..3634b8cba60a3fc84621b4f12ef87c56ad9b53b6 100644 (file)
@@ -31,7 +31,7 @@ describe("CommonResourceService", () => {
         expect(axiosInstance.get).toHaveBeenCalledWith("/groups", {
             params: {
                 filters: "[" + new FilterBuilder()
-                    .addEqual("group_class", "project")
+                    .addIn("group_class", ["project", "filter"])
                     .getFilters() + "]",
                 order: undefined
             }
index 4ae91d4d088fe1c113e75d4191142719e39be618..515571e7d2a04113199530543eda3312d0dcd16b 100644 (file)
@@ -20,7 +20,7 @@ export class ProjectService extends GroupsService<ProjectResource> {
             filters: joinFilters(
                 args.filters || '',
                 new FilterBuilder()
-                    .addEqual("group_class", GroupClass.PROJECT)
+                    .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
                     .getFilters()
             )
         });
index 8803cfba7390a7d6141c9cad92a9c70d58a8fc23..b2857b69ad613c728653a9f6e87b4a0818bc8df2 100644 (file)
@@ -65,7 +65,7 @@ export const setSharedWithMeBreadcrumbs = (uuid: string) =>
 export const setTrashBreadcrumbs = (uuid: string) =>
     setCategoryBreadcrumbs(uuid, SidePanelTreeCategory.TRASH);
 
-export const setCategoryBreadcrumbs = (uuid: string, category: SidePanelTreeCategory) =>
+export const setCategoryBreadcrumbs = (uuid: string, category: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         const ancestors = await services.ancestorsService.ancestors(uuid, '');
         dispatch(updateResources(ancestors));
index 2778568e7681d1073a7f627e70bf9444b8aaee21..36976336a8f7d1409f18db7ea3a228fbbaa8920e 100644 (file)
@@ -6,6 +6,8 @@ import { ContextMenuKind } from '~/views-components/context-menu/context-menu';
 import { resourceUuidToContextMenuKind } from './context-menu-actions';
 import configureStore from 'redux-mock-store';
 import thunk from 'redux-thunk';
+import { PROJECT_PANEL_CURRENT_UUID } from '../project-panel/project-panel-action';
+import { GroupClass } from '~/models/group';
 
 describe('context-menu-actions', () => {
     describe('resourceUuidToContextMenuKind', () => {
@@ -16,70 +18,80 @@ describe('context-menu-actions', () => {
         const headCollectionUuid = 'zzzzz-4zz18-aaaaaaaaaaaaaaa';
         const oldCollectionUuid = 'zzzzz-4zz18-aaaaaaaaaaaaaab';
         const projectUuid = 'zzzzz-j7d0g-ccccccccccccccc';
+        const filterGroupUuid = 'zzzzz-j7d0g-ccccccccccccccd';
         const linkUuid = 'zzzzz-o0j2j-0123456789abcde';
         const containerRequestUuid = 'zzzzz-xvhdp-0123456789abcde';
 
         it('should return the correct menu kind', () => {
             const cases = [
-                // resourceUuid, isAdminUser, isEditable, isTrashed, expected
-                [headCollectionUuid, false, true, true, ContextMenuKind.TRASHED_COLLECTION],
-                [headCollectionUuid, false, true, false, ContextMenuKind.COLLECTION],
-                [headCollectionUuid, false, false, true, ContextMenuKind.READONLY_COLLECTION],
-                [headCollectionUuid, false, false, false, ContextMenuKind.READONLY_COLLECTION],
-                [headCollectionUuid, true, true, true, ContextMenuKind.TRASHED_COLLECTION],
-                [headCollectionUuid, true, true, false, ContextMenuKind.COLLECTION_ADMIN],
-                [headCollectionUuid, true, false, true, ContextMenuKind.TRASHED_COLLECTION],
-                [headCollectionUuid, true, false, false, ContextMenuKind.COLLECTION_ADMIN],
+                // resourceUuid, isAdminUser, isEditable, isTrashed, forceReadonly, expected
+                [headCollectionUuid, false, true, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, false, true, false, false, ContextMenuKind.COLLECTION],
+                [headCollectionUuid, false, true, false, true, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, false, false, true, false, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, false, false, false, false, ContextMenuKind.READONLY_COLLECTION],
+                [headCollectionUuid, true, true, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, true, true, false, false, ContextMenuKind.COLLECTION_ADMIN],
+                [headCollectionUuid, true, false, true, false, ContextMenuKind.TRASHED_COLLECTION],
+                [headCollectionUuid, true, false, false, false, ContextMenuKind.COLLECTION_ADMIN],
+                [headCollectionUuid, true, false, false, true, ContextMenuKind.READONLY_COLLECTION],
 
-                [oldCollectionUuid, false, true, true, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, false, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, false, false, true, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, false, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, true, true, true, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, true, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, true, false, true, ContextMenuKind.OLD_VERSION_COLLECTION],
-                [oldCollectionUuid, true, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, true, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, true, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, false, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, false, false, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, true, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, true, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, false, true, false, ContextMenuKind.OLD_VERSION_COLLECTION],
+                [oldCollectionUuid, true, false, false, false, ContextMenuKind.OLD_VERSION_COLLECTION],
 
                 // FIXME: WB2 doesn't currently have context menu for trashed projects
-                // [projectUuid, false, true, true, ContextMenuKind.TRASHED_PROJECT],
-                [projectUuid, false, true, false, ContextMenuKind.PROJECT],
-                [projectUuid, false, false, true, ContextMenuKind.READONLY_PROJECT],
-                [projectUuid, false, false, false, ContextMenuKind.READONLY_PROJECT],
-                // [projectUuid, true, true, true, ContextMenuKind.TRASHED_PROJECT],
-                [projectUuid, true, true, false, ContextMenuKind.PROJECT_ADMIN],
-                // [projectUuid, true, false, true, ContextMenuKind.TRASHED_PROJECT],
-                [projectUuid, true, false, false, ContextMenuKind.PROJECT_ADMIN],
+                // [projectUuid, false, true, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, false, true, false, false, ContextMenuKind.PROJECT],
+                [projectUuid, false, true, false, true, ContextMenuKind.READONLY_PROJECT],
+                [projectUuid, false, false, true, false, ContextMenuKind.READONLY_PROJECT],
+                [projectUuid, false, false, false, false, ContextMenuKind.READONLY_PROJECT],
+                // [projectUuid, true, true, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, true, true, false, false, ContextMenuKind.PROJECT_ADMIN],
+                // [projectUuid, true, false, true, false, ContextMenuKind.TRASHED_PROJECT],
+                [projectUuid, true, false, false, false, ContextMenuKind.PROJECT_ADMIN],
+                [projectUuid, true, false, false, true, ContextMenuKind.READONLY_PROJECT],
 
-                [linkUuid, false, true, true, ContextMenuKind.LINK],
-                [linkUuid, false, true, false, ContextMenuKind.LINK],
-                [linkUuid, false, false, true, ContextMenuKind.LINK],
-                [linkUuid, false, false, false, ContextMenuKind.LINK],
-                [linkUuid, true, true, true, ContextMenuKind.LINK],
-                [linkUuid, true, true, false, ContextMenuKind.LINK],
-                [linkUuid, true, false, true, ContextMenuKind.LINK],
-                [linkUuid, true, false, false, ContextMenuKind.LINK],
+                [linkUuid, false, true, true, false, ContextMenuKind.LINK],
+                [linkUuid, false, true, false, false, ContextMenuKind.LINK],
+                [linkUuid, false, false, true, false, ContextMenuKind.LINK],
+                [linkUuid, false, false, false, false, ContextMenuKind.LINK],
+                [linkUuid, true, true, true, false, ContextMenuKind.LINK],
+                [linkUuid, true, true, false, false, ContextMenuKind.LINK],
+                [linkUuid, true, false, true, false, ContextMenuKind.LINK],
+                [linkUuid, true, false, false, false, ContextMenuKind.LINK],
 
-                [userUuid, false, true, true, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, false, true, false, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, false, false, true, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, false, false, false, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, true, true, true, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, true, true, false, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, true, false, true, ContextMenuKind.ROOT_PROJECT],
-                [userUuid, true, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, true, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, true, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, false, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, false, false, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, true, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, true, false, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, false, true, false, ContextMenuKind.ROOT_PROJECT],
+                [userUuid, true, false, false, false, ContextMenuKind.ROOT_PROJECT],
 
-                [containerRequestUuid, false, true, true, ContextMenuKind.PROCESS_RESOURCE],
-                [containerRequestUuid, false, true, false, ContextMenuKind.PROCESS_RESOURCE],
-                [containerRequestUuid, false, false, true, ContextMenuKind.PROCESS_RESOURCE],
-                [containerRequestUuid, false, false, false, ContextMenuKind.PROCESS_RESOURCE],
-                [containerRequestUuid, true, true, true, ContextMenuKind.PROCESS_ADMIN],
-                [containerRequestUuid, true, true, false, ContextMenuKind.PROCESS_ADMIN],
-                [containerRequestUuid, true, false, true, ContextMenuKind.PROCESS_ADMIN],
-                [containerRequestUuid, true, false, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, false, true, true, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, true, false, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, true, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, false, false, ContextMenuKind.PROCESS_RESOURCE],
+                [containerRequestUuid, false, false, false, true, ContextMenuKind.READONLY_PROCESS_RESOURCE],
+                [containerRequestUuid, true, true, true, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, true, false, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, true, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, false, false, ContextMenuKind.PROCESS_ADMIN],
+                [containerRequestUuid, true, false, false, true, ContextMenuKind.READONLY_PROCESS_RESOURCE],
             ]
 
-            cases.forEach(([resourceUuid, isAdminUser, isEditable, isTrashed, expected]) => {
+            cases.forEach(([resourceUuid, isAdminUser, isEditable, isTrashed, forceReadonly, expected]) => {
                 const initialState = {
+                    properties: {
+                        [PROJECT_PANEL_CURRENT_UUID]: projectUuid,
+                    },
                     resources: {
                         [headCollectionUuid]: {
                             uuid: headCollectionUuid,
@@ -91,12 +103,18 @@ describe('context-menu-actions', () => {
                             uuid: oldCollectionUuid,
                             currentVersionUuid: headCollectionUuid,
                             isTrashed: isTrashed,
-
                         },
                         [projectUuid]: {
                             uuid: projectUuid,
                             ownerUuid: isEditable ? userUuid : otherUserUuid,
                             writableBy: isEditable ? [userUuid] : [otherUserUuid],
+                            groupClass: GroupClass.PROJECT,
+                        },
+                        [filterGroupUuid]: {
+                            uuid: filterGroupUuid,
+                            ownerUuid: isEditable ? userUuid : otherUserUuid,
+                            writableBy: isEditable ? [userUuid] : [otherUserUuid],
+                            groupClass: GroupClass.FILTER,
                         },
                         [linkUuid]: {
                             uuid: linkUuid,
@@ -118,13 +136,14 @@ describe('context-menu-actions', () => {
                 };
                 const store = mockStore(initialState);
 
-                const menuKind = store.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid as string))
+                let menuKind: any;
                 try {
+                    menuKind = store.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid as string, forceReadonly as boolean))
                     expect(menuKind).toBe(expected);
                 } catch (err) {
-                    throw new Error(`menuKind for resource ${JSON.stringify(initialState.resources[resourceUuid as string])} expected to be ${expected} but got ${menuKind}.`);
+                    throw new Error(`menuKind for resource ${JSON.stringify(initialState.resources[resourceUuid as string])} forceReadonly: ${forceReadonly} expected to be ${expected} but got ${menuKind}.`);
                 }
             });
         });
     });
-});
\ No newline at end of file
+});
index 225538859a743a690e2da15143fe600d8e786fe6..1997b2a64894b3aba3baa7c3666ea394dd93614c 100644 (file)
@@ -18,7 +18,7 @@ import { VirtualMachinesResource } from '~/models/virtual-machines';
 import { KeepServiceResource } from '~/models/keep-services';
 import { ProcessResource } from '~/models/process';
 import { CollectionResource } from '~/models/collection';
-import { GroupResource } from '~/models/group';
+import { GroupClass, GroupResource } from '~/models/group';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
 
 export const contextMenuActions = unionize({
@@ -34,7 +34,7 @@ export type ContextMenuResource = {
     ownerUuid: string;
     description?: string;
     kind: ResourceKind,
-    menuKind: ContextMenuKind;
+    menuKind: ContextMenuKind | string;
     isTrashed?: boolean;
     isEditable?: boolean;
     outputUuid?: string;
@@ -167,7 +167,7 @@ export const openProjectContextMenu = (event: React.MouseEvent<HTMLElement>, res
                 kind: res.kind,
                 menuKind,
                 ownerUuid: res.ownerUuid,
-                isTrashed: ('isTrashed' in res) ? res.isTrashed: false,
+                isTrashed: ('isTrashed' in res) ? res.isTrashed : false,
             }));
         }
     };
@@ -201,19 +201,24 @@ export const openProcessContextMenu = (event: React.MouseEvent<HTMLElement>, pro
         }
     };
 
-export const resourceUuidToContextMenuKind = (uuid: string) =>
+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 isEditable = isAdminUser || (resource || {} as EditableResource).isEditable;
+
+        const isEditable = (isAdminUser || (resource || {} as EditableResource).isEditable) && !readonly;
         switch (kind) {
             case ResourceKind.PROJECT:
-                return !isAdminUser
-                    ? isEditable
-                        ? ContextMenuKind.PROJECT
-                        : ContextMenuKind.READONLY_PROJECT
-                    : ContextMenuKind.PROJECT_ADMIN;
+                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;
             case ResourceKind.COLLECTION:
                 const c = getResource<CollectionResource>(uuid)(getState().resources);
                 if (c === undefined) { return; }
@@ -223,15 +228,17 @@ export const resourceUuidToContextMenuKind = (uuid: string) =>
                     ? ContextMenuKind.OLD_VERSION_COLLECTION
                     : (isTrashed && isEditable)
                         ? ContextMenuKind.TRASHED_COLLECTION
-                        : isAdminUser
+                        : (isAdminUser && !readonly)
                             ? ContextMenuKind.COLLECTION_ADMIN
                             : isEditable
                                 ? ContextMenuKind.COLLECTION
                                 : ContextMenuKind.READONLY_COLLECTION;
             case ResourceKind.PROCESS:
-                return !isAdminUser
-                    ? ContextMenuKind.PROCESS_RESOURCE
-                    : ContextMenuKind.PROCESS_ADMIN;
+                return (isAdminUser && !readonly)
+                    ? ContextMenuKind.PROCESS_ADMIN
+                    : readonly
+                        ? ContextMenuKind.READONLY_PROCESS_RESOURCE
+                        : ContextMenuKind.PROCESS_RESOURCE;
             case ResourceKind.USER:
                 return ContextMenuKind.ROOT_PROJECT;
             case ResourceKind.LINK:
index f1576a23bdffdf26c43538ba151b6f3b1b278619..8589c7687efe496142f97ea16334651777ab6d83 100644 (file)
@@ -36,7 +36,7 @@ export class GroupsPanelMiddlewareService extends DataExplorerMiddlewareService
                     order.addOrder(direction, 'name');
                 }
                 const filters = new FilterBuilder()
-                    .addNotIn('group_class', [GroupClass.PROJECT])
+                    .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
                     .addILike('name', dataExplorer.searchValue)
                     .getFilters();
                 const response = await this.services.groupsService
index d663ae37167142a2a075e2244412d9e8fd66a396..7b55f897e2d2b83461b13aa3a69a2dbb54be007e 100644 (file)
@@ -10,9 +10,25 @@ import { Routes, getProcessLogUrl, getGroupUrl, getNavUrl } from '~/routes/route
 import { RootState } from '~/store/store';
 import { ServiceRepository } from '~/services/services';
 import { GROUPS_PANEL_LABEL } from '~/store/breadcrumbs/breadcrumbs-actions';
+import { pluginConfig } from '~/plugins';
+import { snackbarActions, SnackbarKind } from '~/store/snackbar/snackbar-actions';
+
+const navigationNotAvailable = (id: string) =>
+    snackbarActions.OPEN_SNACKBAR({
+        message: `${id} not available`,
+        hideDuration: 3000,
+        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:
@@ -27,6 +43,12 @@ export const navigateTo = (uuid: string) =>
         }
 
         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;
@@ -49,8 +71,11 @@ export const navigateTo = (uuid: string) =>
                 dispatch(navigateToAllProcesses);
                 return;
         }
+
+        dispatch(navigationNotAvailable(uuid));
     };
 
+
 export const navigateToNotFound = push(Routes.NO_MATCH);
 
 export const navigateToRoot = push(Routes.ROOT);
@@ -78,10 +103,7 @@ export const pushOrGoto = (url: string): AnyAction => {
 export const navigateToProcessLogs = compose(push, getProcessLogUrl);
 
 export const navigateToRootProject = (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
-    const usr = getState().auth.user;
-    if (usr) {
-        dispatch<any>(navigateTo(usr.uuid));
-    }
+    navigateTo(SidePanelTreeCategory.PROJECTS)(dispatch, getState);
 };
 
 export const navigateToSharedWithMe = push(Routes.SHARED_WITH_ME);
index 2f4d3cad524fd3c04f99d147b28ffc4be31d7070..95d0349f11c3a37987972f23a295004f8a676e38 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { getInitialResourceTypeFilters, serializeResourceTypeFilters, ObjectTypeFilter, CollectionTypeFilter, ProcessTypeFilter } from './resource-type-filters';
+import { getInitialResourceTypeFilters, serializeResourceTypeFilters, ObjectTypeFilter, CollectionTypeFilter, ProcessTypeFilter, GroupTypeFilter } from './resource-type-filters';
 import { ResourceKind } from '~/models/resource';
 import { deselectNode } from '~/models/tree';
 import { pipe } from 'lodash/fp';
@@ -73,4 +73,43 @@ describe("serializeResourceTypeFilters", () => {
         expect(serializedFilters)
             .toEqual(`["uuid","is_a",["${ResourceKind.PROCESS}"]],["container_requests.requesting_container_uuid","!=",null]`);
     });
+
+    it("should serialize all project types", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(ObjectTypeFilter.COLLECTION),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]]`);
+    });
+
+    it("should serialize filter groups", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(GroupTypeFilter.PROJECT)
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(ObjectTypeFilter.COLLECTION),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]],["groups.group_class","=","filter"]`);
+    });
+
+    it("should serialize projects (normal)", () => {
+        const filters = pipe(
+            () => getInitialResourceTypeFilters(),
+            deselectNode(GroupTypeFilter.FILTER_GROUP)
+            deselectNode(ObjectTypeFilter.PROCESS),
+            deselectNode(ObjectTypeFilter.COLLECTION),
+        )();
+
+        const serializedFilters = serializeResourceTypeFilters(filters);
+        expect(serializedFilters)
+            .toEqual(`["uuid","is_a",["${ResourceKind.GROUP}"]],["groups.group_class","=","project"]`);
+    });
+
 });
index ef1198bc65d862554b4b655fa617a902ca9e6584..26db4e9e1c9ed1027adec8c03eb9735feecb42ad 100644 (file)
@@ -25,7 +25,12 @@ export enum ProcessStatusFilter {
 export enum ObjectTypeFilter {
     PROJECT = 'Project',
     PROCESS = 'Process',
-    COLLECTION = 'Data Collection',
+    COLLECTION = 'Data collection',
+}
+
+export enum GroupTypeFilter {
+    PROJECT = 'Project (normal)',
+    FILTER_GROUP = 'Filter group',
 }
 
 export enum CollectionTypeFilter {
@@ -62,7 +67,11 @@ export const getSimpleObjectTypeFilters = pipe(
 // causing compile issues.
 export const getInitialResourceTypeFilters = pipe(
     (): DataTableFilters => createTree<DataTableFilterItem>(),
-    initFilter(ObjectTypeFilter.PROJECT),
+    pipe(
+        initFilter(ObjectTypeFilter.PROJECT),
+        initFilter(GroupTypeFilter.PROJECT, ObjectTypeFilter.PROJECT),
+        initFilter(GroupTypeFilter.FILTER_GROUP, ObjectTypeFilter.PROJECT),
+    ),
     pipe(
         initFilter(ObjectTypeFilter.PROCESS),
         initFilter(ProcessTypeFilter.MAIN_PROCESS, ObjectTypeFilter.PROCESS),
@@ -124,10 +133,14 @@ const objectTypeToResourceKind = (type: ObjectTypeFilter) => {
 };
 
 const serializeObjectTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => {
+    const groupFilters = getMatchingFilters(values(GroupTypeFilter), selectedFilters);
     const collectionFilters = getMatchingFilters(values(CollectionTypeFilter), selectedFilters);
     const processFilters = getMatchingFilters(values(ProcessTypeFilter), selectedFilters);
     const typeFilters = pipe(
         () => new Set(getMatchingFilters(values(ObjectTypeFilter), selectedFilters)),
+        set => groupFilters.length > 0
+            ? set.add(ObjectTypeFilter.PROJECT)
+            : set,
         set => collectionFilters.length > 0
             ? set.add(ObjectTypeFilter.COLLECTION)
             : set,
@@ -182,6 +195,30 @@ const buildCollectionTypeFilters = ({ fb, filters }: { fb: FilterBuilder, filter
     }
 };
 
+const serializeGroupTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
+    () => getMatchingFilters(values(GroupTypeFilter), selectedFilters),
+    filters => filters,
+    mappedFilters => ({
+        fb: buildGroupTypeFilters({ fb, filters: mappedFilters, use_prefix: true }),
+        selectedFilters
+    })
+)();
+
+const GROUP_TYPES = values(GroupTypeFilter);
+
+const buildGroupTypeFilters = ({ fb, filters, use_prefix }: { fb: FilterBuilder, filters: string[], use_prefix: boolean }) => {
+    switch (true) {
+        case filters.length === 0 || filters.length === GROUP_TYPES.length:
+            return fb;
+        case includes(GroupTypeFilter.PROJECT, filters):
+            return fb.addEqual('groups.group_class', 'project');
+        case includes(GroupTypeFilter.FILTER_GROUP, filters):
+            return fb.addEqual('groups.group_class', 'filter');
+        default:
+            return fb;
+    }
+};
+
 const serializeProcessTypeFilters = ({ fb, selectedFilters }: ReturnType<typeof createFiltersBuilder>) => pipe(
     () => getMatchingFilters(values(ProcessTypeFilter), selectedFilters),
     filters => filters,
@@ -210,6 +247,7 @@ const buildProcessTypeFilters = ({ fb, filters, use_prefix }: { fb: FilterBuilde
 export const serializeResourceTypeFilters = pipe(
     createFiltersBuilder,
     serializeObjectTypeFilters,
+    serializeGroupTypeFilters,
     serializeCollectionTypeFilters,
     serializeProcessTypeFilters,
     ({ fb }) => fb.getFilters(),
@@ -260,4 +298,4 @@ export const buildProcessStatusFilters = ( fb:FilterBuilder, activeStatusFilter:
         }
     }
     return fb;
-};
\ No newline at end of file
+};
index ff506103db6ce3ecf23e4e3d0fadbd26d8d5385b..6152b99f1edde80cae53d1d3dc8d2d0bf8b8170c 100644 (file)
@@ -16,6 +16,8 @@ import { OrderBuilder } from '~/services/api/order-builder';
 import { ResourceKind } from '~/models/resource';
 import { GroupContentsResourcePrefix } from '~/services/groups-service/groups-service';
 import { GroupClass } from '~/models/group';
+import { CategoriesListReducer } from '~/common/plugintypes';
+import { pluginConfig } from '~/plugins';
 
 export enum SidePanelTreeCategory {
     PROJECTS = 'Projects',
@@ -44,34 +46,48 @@ export const getSidePanelTreeBranch = (uuid: string) => (treePicker: TreePicker)
     return [];
 };
 
-const SIDE_PANEL_CATEGORIES = [
+let SIDE_PANEL_CATEGORIES: string[] = [
+    SidePanelTreeCategory.PROJECTS,
+    SidePanelTreeCategory.SHARED_WITH_ME,
     SidePanelTreeCategory.PUBLIC_FAVORITES,
     SidePanelTreeCategory.FAVORITES,
     SidePanelTreeCategory.WORKFLOWS,
     SidePanelTreeCategory.ALL_PROCESSES,
-    SidePanelTreeCategory.TRASH,
+    SidePanelTreeCategory.TRASH
 ];
 
+const reduceCatsFn: (a: string[],
+    b: CategoriesListReducer) => string[] = (a, b) => b(a);
+
+SIDE_PANEL_CATEGORIES = pluginConfig.sidePanelCategories.reduce(reduceCatsFn, SIDE_PANEL_CATEGORIES);
+
 export const isSidePanelTreeCategory = (id: string) => SIDE_PANEL_CATEGORIES.some(category => category === id);
 
+
 export const initSidePanelTree = () =>
     (dispatch: Dispatch, getState: () => RootState, { authService }: ServiceRepository) => {
         const rootProjectUuid = getUserUuid(getState());
         if (!rootProjectUuid) { return; }
-        const nodes = SIDE_PANEL_CATEGORIES.map(id => initTreeNode({ id, value: id }));
-        const projectsNode = initTreeNode({ id: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS });
-        const sharedNode = initTreeNode({ id: SidePanelTreeCategory.SHARED_WITH_ME, value: SidePanelTreeCategory.SHARED_WITH_ME });
+        const nodes = SIDE_PANEL_CATEGORIES.map(id => {
+            if (id === SidePanelTreeCategory.PROJECTS) {
+                return initTreeNode({ id: rootProjectUuid, value: SidePanelTreeCategory.PROJECTS });
+            } else {
+                return initTreeNode({ id, value: id });
+            }
+        });
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
             id: '',
             pickerId: SIDE_PANEL_TREE,
-            nodes: [projectsNode, sharedNode, ...nodes]
+            nodes
         }));
         SIDE_PANEL_CATEGORIES.forEach(category => {
-            dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
-                id: category,
-                pickerId: SIDE_PANEL_TREE,
-                nodes: []
-            }));
+            if (category !== SidePanelTreeCategory.PROJECTS && category !== SidePanelTreeCategory.SHARED_WITH_ME) {
+                dispatch(treePickerActions.LOAD_TREE_PICKER_NODE_SUCCESS({
+                    id: category,
+                    pickerId: SIDE_PANEL_TREE,
+                    nodes: []
+                }));
+            }
         });
     };
 
@@ -112,7 +128,7 @@ const loadSharedRoot = async (dispatch: Dispatch, getState: () => RootState, ser
     const params = {
         filters: `[${new FilterBuilder()
             .addIsA('uuid', ResourceKind.PROJECT)
-            .addEqual('group_class', GroupClass.PROJECT)
+            .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
             .addDistinct('uuid', getState().auth.config.uuidPrefix + '-j7d0g-publicfavorites')
             .getFilters()}]`,
         order: new OrderBuilder<ProjectResource>()
index 6279aaea307e1e1d2783228672d73a919cb32cfb..28320f9661d5fb5819938f958f264012dd81110e 100644 (file)
@@ -3,41 +3,9 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { Dispatch } from 'redux';
-import { isSidePanelTreeCategory, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
-import { navigateToFavorites, navigateTo, navigateToTrash, navigateToSharedWithMe, navigateToWorkflows, navigateToPublicFavorites, navigateToAllProcesses } from '~/store/navigation/navigation-action';
-import {snackbarActions, SnackbarKind} from '~/store/snackbar/snackbar-actions';
+import { navigateTo } from '~/store/navigation/navigation-action';
 
 export const navigateFromSidePanel = (id: string) =>
     (dispatch: Dispatch) => {
-        if (isSidePanelTreeCategory(id)) {
-            dispatch<any>(getSidePanelTreeCategoryAction(id));
-        } else {
-            dispatch<any>(navigateTo(id));
-        }
+        dispatch<any>(navigateTo(id));
     };
-
-const getSidePanelTreeCategoryAction = (id: string) => {
-    switch (id) {
-        case SidePanelTreeCategory.FAVORITES:
-            return navigateToFavorites;
-        case SidePanelTreeCategory.PUBLIC_FAVORITES:
-            return navigateToPublicFavorites;
-        case SidePanelTreeCategory.TRASH:
-            return navigateToTrash;
-        case SidePanelTreeCategory.SHARED_WITH_ME:
-            return navigateToSharedWithMe;
-        case SidePanelTreeCategory.WORKFLOWS:
-            return navigateToWorkflows;
-        case SidePanelTreeCategory.ALL_PROCESSES:
-            return navigateToAllProcesses;
-        default:
-            return sidePanelTreeCategoryNotAvailable(id);
-    }
-};
-
-const sidePanelTreeCategoryNotAvailable = (id: string) =>
-    snackbarActions.OPEN_SNACKBAR({
-        message: `${id} not available`,
-        hideDuration: 3000,
-        kind: SnackbarKind.ERROR
-    });
index 517368aa43badea0d26c3bd6dbb54701257e0572..f236d02948b617885e97dbea89b7ffa237026d16 100644 (file)
@@ -70,6 +70,8 @@ import { SubprocessMiddlewareService } from '~/store/subprocess-panel/subprocess
 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';
 
 const composeEnhancers =
     (process.env.NODE_ENV === 'development' &&
@@ -142,7 +144,7 @@ export function configureStore(history: History, services: ServiceRepository, co
         return next(action);
     };
 
-    const middlewares: Middleware[] = [
+    let middlewares: Middleware[] = [
         routerMiddleware(history),
         thunkMiddleware.withExtraArgument(services),
         authMiddleware(services),
@@ -164,6 +166,11 @@ export function configureStore(history: History, services: ServiceRepository, co
         subprocessMiddleware,
     ];
 
+    const reduceMiddlewaresFn: (a: Middleware[],
+        b: MiddlewareListReducer) => Middleware[] = (a, b) => b(a, services);
+
+    middlewares = pluginConfig.middlewares.reduce(reduceMiddlewaresFn, middlewares);
+
     const enhancer = composeEnhancers(applyMiddleware(redirectToMiddleware, ...middlewares));
     return createStore(rootReducer, enhancer);
 }
index d11f7527b4e0d7d18a1e6e9d1a0ecbddfae67e35..5d12b419ebe898e2666131e9d5bb85b4adcb98b0 100644 (file)
@@ -21,7 +21,7 @@ import { mapTree } from '../../models/tree';
 import { LinkResource, LinkClass } from "~/models/link";
 import { mapTreeValues } from "~/models/tree";
 import { sortFilesTree } from "~/services/collection-service/collection-service-files-response";
-import { GroupResource } from "~/models/group";
+import { GroupClass, GroupResource } from "~/models/group";
 
 export const treePickerActions = unionize({
     LOAD_TREE_PICKER_NODE: ofType<{ id: string, pickerId: string }>(),
@@ -101,11 +101,12 @@ interface LoadProjectParams {
     pickerId: string;
     includeCollections?: boolean;
     includeFiles?: boolean;
+    includeFilterGroups?: boolean;
     loadShared?: boolean;
 }
 export const loadProject = (params: LoadProjectParams) =>
     async (dispatch: Dispatch, _: () => RootState, services: ServiceRepository) => {
-        const { id, pickerId, includeCollections = false, includeFiles = false, loadShared = false } = params;
+        const { id, pickerId, includeCollections = false, includeFiles = false, includeFilterGroups = false, loadShared = false } = params;
 
         dispatch(treePickerActions.LOAD_TREE_PICKER_NODE({ id, pickerId }));
 
@@ -121,7 +122,12 @@ export const loadProject = (params: LoadProjectParams) =>
         dispatch<any>(receiveTreePickerData<GroupContentsResource>({
             id,
             pickerId,
-            data: items,
+            data: items.filter((item) => {
+                    if (!includeFilterGroups && (item as GroupResource).groupClass && (item as GroupResource).groupClass === GroupClass.FILTER) {
+                        return false;
+                    }
+                    return true;
+                }),
             extractNodeData: item => ({
                 id: item.uuid,
                 value: item,
index 09aad2bd581b2e62d8cc23fc8be674e4f1f1d7b2..8ea19a145d9126aff5c39d967aa7b8f5673a04d4 100644 (file)
@@ -33,7 +33,7 @@ import {
     setSidePanelBreadcrumbs,
     setTrashBreadcrumbs
 } from '~/store/breadcrumbs/breadcrumbs-actions';
-import { navigateTo } from '~/store/navigation/navigation-action';
+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';
@@ -110,7 +110,7 @@ export const isWorkbenchLoading = (state: RootState) => {
     return progress ? progress.working : false;
 };
 
-const handleFirstTimeLoad = (action: any) =>
+export const handleFirstTimeLoad = (action: any) =>
     async (dispatch: Dispatch<any>, getState: () => RootState) => {
         try {
             await dispatch(action);
@@ -153,7 +153,7 @@ export const loadWorkbench = () =>
             if (router.location) {
                 const match = matchRootRoute(router.location.pathname);
                 if (match) {
-                    dispatch<any>(navigateTo(user.uuid));
+                    dispatch<any>(navigateToRootProject);
                 }
             }
         } else {
index 8cab9bfd5171b39f1171def4376cfa2e9dd15df5..73a65a2d417f6006050f9e82939c73418367a27c 100644 (file)
@@ -14,21 +14,7 @@ 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';
 
-export const processResourceActionSet: ContextMenuActionSet = [[
-    {
-        icon: RenameIcon,
-        name: "Edit process",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openProcessUpdateDialog(resource));
-        }
-    },
-    {
-        icon: ShareIcon,
-        name: "Share",
-        execute: (dispatch, { uuid }) => {
-            dispatch<any>(openSharingDialog(uuid));
-        }
-    },
+export const readOnlyProcessResourceActionSet: ContextMenuActionSet = [[
     {
         component: ToggleFavoriteAction,
         execute: (dispatch, resource) => {
@@ -37,13 +23,6 @@ export const processResourceActionSet: ContextMenuActionSet = [[
             });
         }
     },
-    {
-        icon: MoveToIcon,
-        name: "Move to",
-        execute: (dispatch, resource) => {
-            dispatch<any>(openMoveProcessDialog(resource));
-        }
-    },
     {
         icon: CopyIcon,
         name: "Copy to project",
@@ -58,6 +37,31 @@ export const processResourceActionSet: ContextMenuActionSet = [[
             dispatch<any>(toggleDetailsPanel());
         }
     },
+]];
+
+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,
index fd328221a8a9ffca94e818b26f70cbdc8dc6d56a..1932194ca4b0c694a7dff83b964d76753ef7ae55 100644 (file)
@@ -2,11 +2,12 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import { projectActionSet, readOnlyProjectActionSet } from "./project-action-set";
+import { filterGroupActionSet, projectActionSet, readOnlyProjectActionSet } from "./project-action-set";
 
 describe('project-action-set', () => {
     const flattProjectActionSet = projectActionSet.reduce((prev, next) => prev.concat(next), []);
     const flattReadOnlyProjectActionSet = readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []);
+    const flattFilterGroupActionSet = filterGroupActionSet.reduce((prev, next) => prev.concat(next), []);
 
     describe('projectActionSet', () => {
         it('should not be empty', () => {
@@ -33,4 +34,17 @@ describe('project-action-set', () => {
                 .not.toEqual(expect.arrayContaining(flattProjectActionSet));
         })
     });
-});
\ No newline at end of file
+
+    describe('filterGroupActionSet', () => {
+        it('should not be empty', () => {
+            // then
+            expect(flattFilterGroupActionSet.length).toBeGreaterThan(0);
+        });
+
+        it('should not contain projectActionSet items', () => {
+            // then
+            expect(flattFilterGroupActionSet)
+                .not.toEqual(expect.arrayContaining(flattProjectActionSet));
+        })
+    });
+});
index 57ba0ea3f1fcbcf98c9a763878ff6655a8a77a57..800f57d9f5ff13874d07b918c491a1ece4250c38 100644 (file)
@@ -66,16 +66,9 @@ export const readOnlyProjectActionSet: ContextMenuActionSet = [[
     },
 ]];
 
-export const projectActionSet: ContextMenuActionSet = [
+export const filterGroupActionSet: ContextMenuActionSet = [
     [
         ...readOnlyProjectActionSet.reduce((prev, next) => prev.concat(next), []),
-        {
-            icon: NewProjectIcon,
-            name: "New project",
-            execute: (dispatch, resource) => {
-                dispatch<any>(openProjectCreateDialog(resource.uuid));
-            }
-        },
         {
             icon: RenameIcon,
             name: "Edit project",
@@ -106,3 +99,16 @@ export const projectActionSet: ContextMenuActionSet = [
         },
     ]
 ];
+
+export const projectActionSet: ContextMenuActionSet = [
+    [
+        ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
+        {
+            icon: NewProjectIcon,
+            name: "New project",
+            execute: (dispatch, resource) => {
+                dispatch<any>(openProjectCreateDialog(resource.uuid));
+            }
+        },
+    ]
+];
index a3a8ce79e9ffd897c91dcb3751660abff71d4d54..982a78832740f942e89b377a879558b28ff096ac 100644 (file)
@@ -7,7 +7,7 @@ import { TogglePublicFavoriteAction } from "~/views-components/context-menu/acti
 import { togglePublicFavorite } from "~/store/public-favorites/public-favorites-actions";
 import { publicFavoritePanelActions } from "~/store/public-favorites-panel/public-favorites-action";
 
-import { projectActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
+import { projectActionSet, filterGroupActionSet } from "~/views-components/context-menu/action-sets/project-action-set";
 
 export const projectAdminActionSet: ContextMenuActionSet = [[
     ...projectActionSet.reduce((prev, next) => prev.concat(next), []),
@@ -21,3 +21,16 @@ export const projectAdminActionSet: ContextMenuActionSet = [[
         }
     }
 ]];
+
+export const filterGroupAdminActionSet: ContextMenuActionSet = [[
+    ...filterGroupActionSet.reduce((prev, next) => prev.concat(next), []),
+    {
+        component: TogglePublicFavoriteAction,
+        name: 'TogglePublicFavoriteAction',
+        execute: (dispatch, resource) => {
+            dispatch<any>(togglePublicFavorite(resource)).then(() => {
+                dispatch<any>(publicFavoritePanelActions.REQUEST_ITEMS());
+            });
+        }
+    }
+]];
index 219913cdd13ce4a2549779159a8ab8f62a4be9c7..ee87d71a37d84da65a8f0594cf1cfe3130a97fc3 100644 (file)
@@ -67,8 +67,10 @@ export enum ContextMenuKind {
     API_CLIENT_AUTHORIZATION = "ApiClientAuthorization",
     ROOT_PROJECT = "RootProject",
     PROJECT = "Project",
+    FILTER_GROUP = "FilterGroup",
     READONLY_PROJECT = 'ReadOnlyProject',
     PROJECT_ADMIN = "ProjectAdmin",
+    FILTER_GROUP_ADMIN = "FilterGroupAdmin",
     RESOURCE = "Resource",
     FAVORITE = "Favorite",
     TRASH = "Trash",
@@ -85,6 +87,7 @@ export enum ContextMenuKind {
     PROCESS = "Process",
     PROCESS_ADMIN = 'ProcessAdmin',
     PROCESS_RESOURCE = 'ProcessResource',
+    READONLY_PROCESS_RESOURCE = 'ReadOnlyProcessResource',
     PROCESS_LOGS = "ProcessLogs",
     REPOSITORY = "Repository",
     SSH_KEY = "SshKey",
index 6cf29faecf444683540de031966273298fc19314..93abb15e237ddc73424271f26762cbab3d6471bb 100644 (file)
@@ -6,7 +6,7 @@ import * as React from 'react';
 import { Grid, Typography, withStyles, Tooltip, IconButton, Checkbox } from '@material-ui/core';
 import { FavoriteStar, PublicFavoriteStar } from '../favorite-star/favorite-star';
 import { ResourceKind, TrashableResource } from '~/models/resource';
-import { ProjectIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon } from '~/components/icon/icon';
+import { ProjectIcon, FilterGroupIcon, CollectionIcon, ProcessIcon, DefaultIcon, ShareIcon, CollectionOldVersionIcon, WorkflowIcon } from '~/components/icon/icon';
 import { formatDate, formatFileSize, formatTime } from '~/common/formatters';
 import { resourceLabel } from '~/common/labels';
 import { connect, DispatchProp } from 'react-redux';
@@ -28,6 +28,7 @@ import { withResourceData } from '~/views-components/data-explorer/with-resource
 import { CollectionResource } from '~/models/collection';
 import { IllegalNamingWarning } from '~/components/warning/warning';
 import { loadResource } from '~/store/resources/resources-actions';
+import { GroupClass } from '~/models/group';
 
 const renderName = (dispatch: Dispatch, item: GroupContentsResource) =>
     <Grid container alignItems="center" wrap="nowrap" spacing={16}>
@@ -59,6 +60,9 @@ export const ResourceName = connect(
 const renderIcon = (item: GroupContentsResource) => {
     switch (item.kind) {
         case ResourceKind.PROJECT:
+            if (item.groupClass === GroupClass.FILTER) {
+                return <FilterGroupIcon />;
+            }
             return <ProjectIcon />;
         case ResourceKind.COLLECTION:
             if (item.uuid === item.currentVersionUuid) {
@@ -464,16 +468,16 @@ export const ResourceOwnerWithName =
             </Typography>;
         });
 
-const renderType = (type: string) =>
+const renderType = (type: string, subtype: string) =>
     <Typography noWrap>
-        {resourceLabel(type)}
+        {resourceLabel(type, subtype)}
     </Typography>;
 
 export const ResourceType = connect(
     (state: RootState, props: { uuid: string }) => {
         const resource = getResource<GroupContentsResource>(props.uuid)(state.resources);
-        return { type: resource ? resource.kind : '' };
-    })((props: { type: string }) => renderType(props.type));
+        return { type: resource ? resource.kind : '', subtype: resource && resource.kind === ResourceKind.GROUP ? resource.groupClass : '' };
+    })((props: { type: string, subtype: string }) => renderType(props.type, props.subtype));
 
 export const ResourceStatus = connect((state: RootState, props: { uuid: string }) => {
     return { resource: getResource<GroupContentsResource>(props.uuid)(state.resources) };
index 61797373b69a51ddb8dcd8039cf5180610ed5749..3feb4a7eb6e35d0a78d0e417301d2775e68d1672 100644 (file)
@@ -5,7 +5,7 @@
 import * as React from 'react';
 import { connect } from 'react-redux';
 import { openProjectPropertiesDialog } from '~/store/details-panel/details-panel-action';
-import { ProjectIcon, RenameIcon } from '~/components/icon/icon';
+import { ProjectIcon, RenameIcon, FilterGroupIcon } from '~/components/icon/icon';
 import { ProjectResource } from '~/models/project';
 import { formatDate } from '~/common/formatters';
 import { ResourceKind } from '~/models/resource';
@@ -18,9 +18,13 @@ import { ArvadosTheme } from '~/common/custom-theme';
 import { Dispatch } from 'redux';
 import { getPropertyChip } from '../resource-properties-form/property-chip';
 import { ResourceOwnerWithName } from '../data-explorer/renderers';
+import { GroupClass } from "~/models/group";
 
 export class ProjectDetails extends DetailsData<ProjectResource> {
     getIcon(className?: string) {
+        if (this.item.groupClass === GroupClass.FILTER) {
+            return <FilterGroupIcon className={className} />;
+        }
         return <ProjectIcon className={className} />;
     }
 
@@ -59,12 +63,12 @@ type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDe
 const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
     withStyles(styles)(
         ({ classes, project, onClick }: ProjectDetailsComponentProps) => <div>
-            <DetailsAttribute label='Type' value={resourceLabel(ResourceKind.PROJECT)} />
+            <DetailsAttribute label='Type' value={project.groupClass === GroupClass.FILTER ? 'Filter group' : resourceLabel(ResourceKind.PROJECT)} />
             <DetailsAttribute label='Owner' linkToUuid={project.ownerUuid}
                 uuidEnhancer={(uuid: string) => <ResourceOwnerWithName uuid={uuid} />} />
             <DetailsAttribute label='Last modified' value={formatDate(project.modifiedAt)} />
             <DetailsAttribute label='Created at' value={formatDate(project.createdAt)} />
-            <DetailsAttribute label='Project UUID' linkToUuid={project.uuid} value={project.uuid} />
+            <DetailsAttribute label='UUID' linkToUuid={project.uuid} value={project.uuid} />
             <DetailsAttribute label='Description'>
                 {project.description ?
                     <RichTextEditorLink
@@ -75,9 +79,12 @@ const ProjectDetailsComponent = connect(null, mapDispatchToProps)(
                 }
             </DetailsAttribute>
             <DetailsAttribute label='Properties'>
-                <div onClick={onClick}>
-                    <RenameIcon className={classes.editIcon} />
-                </div>
+                {project.groupClass !== GroupClass.FILTER ?
+                    <div onClick={onClick}>
+                        <RenameIcon className={classes.editIcon} />
+                    </div>
+                    : ''
+                }
             </DetailsAttribute>
             {
                 Object.keys(project.properties).map(k =>
index dc1e1612aaa92112f9fa4a4f8d4cb7672358c7be..3f576ab180afe9e41547118b8fd7b5e5544776dd 100644 (file)
@@ -11,6 +11,7 @@ import { RootState } from "~/store/store";
 
 interface ProjectNameFieldProps {
     validate: Validator[];
+    label?: string;
 }
 
 // Validation behavior depends on the value of ForwardSlashNameSubstitution.
@@ -32,7 +33,7 @@ export const ProjectNameField = connect(
             name='name'
             component={TextField}
             validate={props.validate}
-            label="Project Name"
+            label={props.label || "Project Name"}
             autoFocus={true} /></span>
     );
 
index ea3a2dd932409efce0b9ea3849180705dbcd4026..7892b8a77487f032ce503beb8081ac28378c5dff 100644 (file)
@@ -20,6 +20,8 @@ import {
     navigateToLinkAccount
 } from '~/store/navigation/navigation-action';
 import { openUserVirtualMachines } from "~/store/virtual-machines/virtual-machines-actions";
+import { pluginConfig } from '~/plugins';
+import { ElementListReducer } from '~/common/plugintypes';
 
 interface AccountMenuProps {
     user?: User;
@@ -57,38 +59,47 @@ const styles: StyleRulesCallback<CssRules> = () => ({
 });
 
 export const AccountMenuComponent =
-    ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp<any> & WithStyles<CssRules>) =>
-        user
-        ? <DropdownMenu
-            icon={<UserPanelIcon />}
-            id="account-menu"
-            title="Account Management"
-            key={currentRoute}>
-            <MenuItem disabled>
-                {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`}
-            </MenuItem>
-            {user.isActive ? <>
-                <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
-                {!user.isAdmin && <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>}
-                <MenuItem onClick={() => {
-                    dispatch<any>(getNewExtraToken(true));
-                    dispatch(openTokenDialog);
-                }}>Get API token</MenuItem>
-                <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
-                <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
-                <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
-                <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
-            </> : null}
+    ({ user, dispatch, currentRoute, workbenchURL, apiToken, localCluster, classes }: AccountMenuProps & DispatchProp<any> & WithStyles<CssRules>) => {
+        let accountMenuItems = <>
+            <MenuItem onClick={() => dispatch(openUserVirtualMachines())}>Virtual Machines</MenuItem>
+            <MenuItem onClick={() => dispatch(openRepositoriesPanel())}>Repositories</MenuItem>
+            <MenuItem onClick={() => {
+                dispatch<any>(getNewExtraToken(true));
+                dispatch(openTokenDialog);
+            }}>Get API token</MenuItem>
+            <MenuItem onClick={() => dispatch(navigateToSshKeysUser)}>Ssh Keys</MenuItem>
+            <MenuItem onClick={() => dispatch(navigateToSiteManager)}>Site Manager</MenuItem>
+            <MenuItem onClick={() => dispatch(navigateToMyAccount)}>My account</MenuItem>
+            <MenuItem onClick={() => dispatch(navigateToLinkAccount)}>Link account</MenuItem>
             <MenuItem>
                 <a href={`${workbenchURL.replace(/\/$/, "")}/${wb1URL(currentRoute)}?api_token=${apiToken}`}
                     className={classes.link}>
                     Switch to Workbench v1</a></MenuItem>
-            <Divider />
-            <MenuItem data-cy="logout-menuitem"
-                onClick={() => dispatch(authActions.LOGOUT({deleteLinkData: true}))}>
-                Logout
-            </MenuItem>
-        </DropdownMenu>
-        : null;
+        </>;
 
-export const AccountMenu = withStyles(styles)( connect(mapStateToProps)(AccountMenuComponent) );
+        const reduceItemsFn: (a: React.ReactElement[],
+            b: ElementListReducer) => React.ReactElement[] = (a, b) => b(a);
+
+        accountMenuItems = React.createElement(React.Fragment, null,
+            pluginConfig.accountMenuList.reduce(reduceItemsFn, React.Children.toArray(accountMenuItems.props.children)));
+
+        return user
+            ? <DropdownMenu
+                icon={<UserPanelIcon />}
+                id="account-menu"
+                title="Account Management"
+                key={currentRoute}>
+                <MenuItem disabled>
+                    {getUserDisplayName(user)} {user.uuid.substr(0, 5) !== localCluster && `(${user.uuid.substr(0, 5)})`}
+                </MenuItem>
+                {user.isActive && accountMenuItems}
+                <Divider />
+                <MenuItem data-cy="logout-menuitem"
+                    onClick={() => dispatch(authActions.LOGOUT({ deleteLinkData: true }))}>
+                    Logout
+                </MenuItem>
+            </DropdownMenu>
+            : null;
+    };
+
+export const AccountMenu = withStyles(styles)(connect(mapStateToProps)(AccountMenuComponent));
index ce1cab4ca01b0b985554d0134ad6dcb3a5ef48b2..44cbe20dd6df2f96b1c32d71bdbb72cdb7805afb 100644 (file)
@@ -14,6 +14,7 @@ import { AccountMenu } from "~/views-components/main-app-bar/account-menu";
 import { HelpMenu } from '~/views-components/main-app-bar/help-menu';
 import { ReactNode } from "react";
 import { AdminMenu } from "~/views-components/main-app-bar/admin-menu";
+import { pluginConfig } from '~/plugins';
 
 type CssRules = 'toolbar' | 'link';
 
@@ -42,20 +43,20 @@ export const MainAppBar = withStyles(styles)(
         return <AppBar position="absolute">
             <Toolbar className={props.classes.toolbar}>
                 <Grid container justify="space-between">
-                    <Grid container item xs={3} direction="column" justify="center">
+                    {pluginConfig.appBarLeft || <Grid container item xs={3} direction="column" justify="center">
                         <Typography variant='h6' color="inherit" noWrap>
                             <Link to={Routes.ROOT} className={props.classes.link}>
                                 <span dangerouslySetInnerHTML={{ __html: props.siteBanner }} /> ({props.uuidPrefix})
-                            </Link>
+                </Link>
                         </Typography>
                         <Typography variant="caption" color="inherit">{props.buildInfo}</Typography>
-                    </Grid>
+                    </Grid>}
                     <Grid
                         item
                         xs={6}
                         container
                         alignItems="center">
-                        {props.user && props.user.isActive && <SearchBar />}
+                        {pluginConfig.appBarMiddle || (props.user && props.user.isActive && <SearchBar />)}
                     </Grid>
                     <Grid
                         item
@@ -64,14 +65,17 @@ export const MainAppBar = withStyles(styles)(
                         alignItems="center"
                         justify="flex-end"
                         wrap="nowrap">
-                        {props.user
-                            ? <>
-                                <NotificationsMenu />
-                                <AccountMenu />
-                                {props.user.isAdmin && <AdminMenu />}
-                                <HelpMenu />
-                            </>
-                            : <HelpMenu />}
+                        {props.user ? <>
+                            <NotificationsMenu />
+                            <AccountMenu />
+                            {pluginConfig.appBarRight ||
+                                <>
+                                    {props.user.isAdmin && <AdminMenu />}
+                                    <HelpMenu />
+                                </>}
+                        </> :
+                            pluginConfig.appBarRight || <HelpMenu />
+                        }
                     </Grid>
                 </Grid>
             </Toolbar>
index cad73a3a8e4b8cee12b6eba1155b80a5fa619990..60adab66c01d01097ff88532fdb97432694b8f2d 100644 (file)
@@ -30,13 +30,27 @@ interface MainContentBarProps {
 
 const isButtonVisible = ({ router }: RootState) => {
     const pathname = router.location ? router.location.pathname : '';
-    return !Routes.matchWorkflowRoute(pathname) && !Routes.matchUserVirtualMachineRoute(pathname) &&
-        !Routes.matchAdminVirtualMachineRoute(pathname) && !Routes.matchRepositoriesRoute(pathname) &&
-        !Routes.matchSshKeysAdminRoute(pathname) && !Routes.matchSshKeysUserRoute(pathname) &&
-        !Routes.matchSiteManagerRoute(pathname) &&
-        !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname) &&
-        !Routes.matchApiClientAuthorizationsRoute(pathname) && !Routes.matchUsersRoute(pathname) &&
-        !Routes.matchMyAccountRoute(pathname) && !Routes.matchLinksRoute(pathname);
+    return Routes.matchCollectionsContentAddressRoute(pathname) ||
+        Routes.matchPublicFavoritesRoute(pathname) ||
+        Routes.matchGroupDetailsRoute(pathname) ||
+        Routes.matchGroupsRoute(pathname) ||
+        Routes.matchUsersRoute(pathname) ||
+        Routes.matchSearchResultsRoute(pathname) ||
+        Routes.matchSharedWithMeRoute(pathname) ||
+        Routes.matchProcessRoute(pathname) ||
+        Routes.matchCollectionRoute(pathname) ||
+        Routes.matchProjectRoute(pathname) ||
+        Routes.matchAllProcessesRoute(pathname) ||
+        Routes.matchTrashRoute(pathname) ||
+        Routes.matchFavoritesRoute(pathname);
+
+    /* return !Routes.matchWorkflowRoute(pathname) && !Routes.matchUserVirtualMachineRoute(pathname) &&
+     *     !Routes.matchAdminVirtualMachineRoute(pathname) && !Routes.matchRepositoriesRoute(pathname) &&
+     *     !Routes.matchSshKeysAdminRoute(pathname) && !Routes.matchSshKeysUserRoute(pathname) &&
+     *     !Routes.matchSiteManagerRoute(pathname) &&
+     *     !Routes.matchKeepServicesRoute(pathname) && !Routes.matchComputeNodesRoute(pathname) &&
+     *     !Routes.matchApiClientAuthorizationsRoute(pathname) && !Routes.matchUsersRoute(pathname) &&
+     *     !Routes.matchMyAccountRoute(pathname) && !Routes.matchLinksRoute(pathname); */
 };
 
 export const MainContentBar =
index e1874d9548fe557f91bb7253a4efca839f6c064f..c2982b3d8b412fdc1867132e81e78229da47a531 100644 (file)
@@ -40,42 +40,42 @@ const mapDispatchToProps = (dispatch: Dispatch): ProjectPropertiesDialogActionPr
     handleDelete: (key: string, value: string) => () => dispatch<any>(deleteProjectProperty(key, value)),
 });
 
-type ProjectPropertiesDialogProps =  ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
+type ProjectPropertiesDialogProps = ProjectPropertiesDialogDataProps & ProjectPropertiesDialogActionProps & WithDialogProps<{}> & WithStyles<CssRules>;
 
 export const ProjectPropertiesDialog = connect(mapStateToProps, mapDispatchToProps)(
     withStyles(styles)(
-    withDialog(PROJECT_PROPERTIES_DIALOG_NAME)(
-        ({ classes, open, closeDialog, handleDelete, project }: ProjectPropertiesDialogProps) =>
-            <Dialog open={open}
-                onClose={closeDialog}
-                fullWidth
-                maxWidth='sm'>
-                <DialogTitle>Properties</DialogTitle>
-                <DialogContent>
-                    <ProjectPropertiesForm />
-                    {project && project.properties &&
-                        Object.keys(project.properties).map(k =>
-                            Array.isArray(project.properties[k])
-                            ? project.properties[k].map((v: string) =>
-                                getPropertyChip(
-                                    k, v,
-                                    handleDelete(k, v),
-                                    classes.tag))
-                            : getPropertyChip(
-                                k, project.properties[k],
-                                handleDelete(k, project.properties[k]),
-                                classes.tag)
-                        )
-                    }
-                </DialogContent>
-                <DialogActions>
-                    <Button
-                        variant='text'
-                        color='primary'
-                        onClick={closeDialog}>
-                        Close
+        withDialog(PROJECT_PROPERTIES_DIALOG_NAME)(
+            ({ classes, open, closeDialog, handleDelete, project }: ProjectPropertiesDialogProps) =>
+                <Dialog open={open}
+                    onClose={closeDialog}
+                    fullWidth
+                    maxWidth='sm'>
+                    <DialogTitle>Properties</DialogTitle>
+                    <DialogContent>
+                        <ProjectPropertiesForm />
+                        {project && project.properties &&
+                            Object.keys(project.properties).map(k =>
+                                Array.isArray(project.properties[k])
+                                    ? project.properties[k].map((v: string) =>
+                                        getPropertyChip(
+                                            k, v,
+                                            handleDelete(k, v),
+                                            classes.tag))
+                                    : getPropertyChip(
+                                        k, project.properties[k],
+                                        handleDelete(k, project.properties[k]),
+                                        classes.tag)
+                            )
+                        }
+                    </DialogContent>
+                    <DialogActions>
+                        <Button
+                            variant='text'
+                            color='primary'
+                            onClick={closeDialog}>
+                            Close
                     </Button>
-                </DialogActions>
-            </Dialog>
-    )
-));
\ No newline at end of file
+                    </DialogActions>
+                </Dialog>
+        )
+    ));
index ea3775e93ad652a8463b95b3e28656e74a8f03de..0a61926e21ddc581fba0ed662f237fc2d3fd971a 100644 (file)
@@ -134,7 +134,7 @@ export const ParticipantSelect = connect()(
             const userItems: ListResults<any> = await userService.list({ filters: filterUsers, limit, count: "none" });
 
             const filterGroups = new FilterBuilder()
-                .addNotIn('group_class', [GroupClass.PROJECT])
+                .addNotIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER])
                 .addILike('name', value)
                 .getFilters();
 
index 3ca2f0d66e95d4cc552c54a70ca27f4644063d96..fb5ea11f5f0710afd51fd766c40b82691487ffc4 100644 (file)
@@ -15,9 +15,12 @@ import { navigateToRunProcess } from '~/store/navigation/navigation-action';
 import { runProcessPanelActions } from '~/store/run-process-panel/run-process-panel-actions';
 import { getUserUuid } from '~/common/getuser';
 import { matchProjectRoute } from '~/routes/routes';
-import { GroupResource } from '~/models/group';
+import { GroupClass, GroupResource } from '~/models/group';
 import { ResourcesState, getResource } from '~/store/resources/resources';
 import { extractUuidKind, ResourceKind } from '~/models/resource';
+import { pluginConfig } from '~/plugins';
+import { ElementListReducer } from '~/common/plugintypes';
+import { Location } from 'history';
 
 type CssRules = 'button' | 'menuItem' | 'icon';
 
@@ -37,7 +40,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
 });
 
 interface SidePanelDataProps {
-    location: any;
+    location: Location;
     currentItemId: string;
     resources: ResourcesState;
     currentUserUUID: string | undefined;
@@ -87,10 +90,36 @@ export const SidePanelButton = withStyles(styles)(
                     const currentProject = getResource<GroupResource>(currentItemId)(resources);
                     if (currentProject &&
                         currentProject.writableBy.indexOf(currentUserUUID || '') >= 0 &&
-                        !isProjectTrashed(currentProject, resources)) {
+                        !isProjectTrashed(currentProject, resources) &&
+                        currentProject.groupClass !== GroupClass.FILTER) {
                         enabled = true;
                     }
                 }
+
+                for (const enableFn of pluginConfig.enableNewButtonMatchers) {
+                    if (enableFn(location, currentItemId, currentUserUUID, resources)) {
+                        enabled = true;
+                    }
+                }
+
+                let menuItems = <>
+                    <MenuItem data-cy='side-panel-new-collection' className={classes.menuItem} onClick={this.handleNewCollectionClick}>
+                        <CollectionIcon className={classes.icon} /> New collection
+                    </MenuItem>
+                    <MenuItem data-cy='side-panel-run-process' className={classes.menuItem} onClick={this.handleRunProcessClick}>
+                        <ProcessIcon className={classes.icon} /> Run a process
+                    </MenuItem>
+                    <MenuItem data-cy='side-panel-new-project' className={classes.menuItem} onClick={this.handleNewProjectClick}>
+                        <ProjectIcon className={classes.icon} /> New project
+                    </MenuItem>
+                </>;
+
+                const reduceItemsFn: (a: React.ReactElement[], b: ElementListReducer) => React.ReactElement[] =
+                    (a, b) => b(a, classes.menuItem);
+
+                menuItems = React.createElement(React.Fragment, null,
+                    pluginConfig.newButtonMenuList.reduce(reduceItemsFn, React.Children.toArray(menuItems.props.children)));
+
                 return <Toolbar>
                     <Grid container>
                         <Grid container item xs alignItems="center" justify="flex-start">
@@ -109,15 +138,7 @@ export const SidePanelButton = withStyles(styles)(
                                 onClose={this.handleClose}
                                 onClick={this.handleClose}
                                 transformOrigin={transformOrigin}>
-                                <MenuItem data-cy='side-panel-new-collection' className={classes.menuItem} onClick={this.handleNewCollectionClick}>
-                                    <CollectionIcon className={classes.icon} /> New collection
-                                </MenuItem>
-                                <MenuItem data-cy='side-panel-run-process' className={classes.menuItem} onClick={this.handleRunProcessClick}>
-                                    <ProcessIcon className={classes.icon} /> Run a process
-                                </MenuItem>
-                                <MenuItem data-cy='side-panel-new-project' className={classes.menuItem} onClick={this.handleNewProjectClick}>
-                                    <ProjectIcon className={classes.icon} /> New project
-                                </MenuItem>
+                                {menuItems}
                             </Menu>
                         </Grid>
                     </Grid>
@@ -150,4 +171,4 @@ export const SidePanelButton = withStyles(styles)(
             }
         }
     )
-);
\ No newline at end of file
+);
index e0d9777da006aa317f9684eba4492ffd5934ff48..4c6f01a146c910885de60244924529aaed575f59 100644 (file)
@@ -9,13 +9,15 @@ import { TreePicker, TreePickerProps } from "../tree-picker/tree-picker";
 import { TreeItem } from "~/components/tree/tree";
 import { ProjectResource } from "~/models/project";
 import { ListItemTextIcon } from "~/components/list-item-text-icon/list-item-text-icon";
-import { ProcessIcon, ProjectIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon } from '~/components/icon/icon';
+import { ProcessIcon, ProjectIcon, FilterGroupIcon, FavoriteIcon, ProjectsIcon, ShareMeIcon, TrashIcon, PublicFavoriteIcon } from '~/components/icon/icon';
 import { WorkflowIcon } from '~/components/icon/icon';
 import { activateSidePanelTreeItem, toggleSidePanelTreeItemCollapse, SIDE_PANEL_TREE, SidePanelTreeCategory } from '~/store/side-panel-tree/side-panel-tree-actions';
 import { openSidePanelContextMenu } from '~/store/context-menu/context-menu-actions';
 import { noop } from 'lodash';
 import { ResourceKind } from "~/models/resource";
 import { IllegalNamingWarning } from "~/components/warning/warning";
+import { GroupClass } from "~/models/group";
+
 export interface SidePanelTreeProps {
     onItemActivation: (id: string) => void;
     sidePanelProgress?: boolean;
@@ -58,7 +60,9 @@ const renderSidePanelItem = (item: TreeItem<ProjectResource>) => {
 const getProjectPickerIcon = (item: TreeItem<ProjectResource | string>) =>
     typeof item.data === 'string'
         ? getSidePanelIcon(item.data)
-        : ProjectIcon;
+        : (item.data && item.data.groupClass === GroupClass.FILTER)
+            ? FilterGroupIcon
+            : ProjectIcon;
 
 const getSidePanelIcon = (category: string) => {
     switch (category) {
index 47dbd9b062b665f0c92f08a2d0079bb443833a0f..35a7f9c16068e87296db695cf0b63f0e0fbae722 100644 (file)
@@ -44,6 +44,7 @@ import {
     getInitialProcessStatusFilters
 } from '~/store/resource-type-filters/resource-type-filters';
 import { GroupContentsResource } from '~/services/groups-service/groups-service';
+import { GroupClass, GroupResource } from '~/models/group';
 
 type CssRules = 'root' | "button";
 
@@ -167,7 +168,14 @@ export const ProjectPanel = withStyles(styles)(
             handleContextMenu = (event: React.MouseEvent<HTMLElement>, resourceUuid: string) => {
                 const { resources } = this.props;
                 const resource = getResource<GroupContentsResource>(resourceUuid)(resources);
-                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid));
+                // When viewing the contents of a filter group, all contents should be treated as read only.
+                let readonly = false;
+                const project = getResource<GroupResource>(this.props.currentItemId)(resources);
+                if (project && project.groupClass === GroupClass.FILTER) {
+                    readonly = true;
+                }
+
+                const menuKind = this.props.dispatch<any>(resourceUuidToContextMenuKind(resourceUuid, readonly));
                 if (menuKind && resource) {
                     this.props.dispatch<any>(openContextMenu(event, {
                         name: resource.name,
index 9c2a7df8ffd547c7ab8f15f2dc62c875cb91087f..78ec3c87cb24bce04e5a7387963409d94db074df 100644 (file)
@@ -101,6 +101,8 @@ 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';
 
 type CssRules = 'root' | 'container' | 'splitter' | 'asidePanel' | 'contentWrapper' | 'content';
 
@@ -149,6 +151,42 @@ const getSplitterInitialSize = () => {
 
 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.PROCESS_LOGS} component={ProcessLogPanel} />
+    <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
+    <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
+    <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={SshKeyPanel} />
+    <Route path={Routes.SITE_MANAGER} component={SiteManagerPanel} />
+    <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
+    <Route path={Routes.USERS} component={UserPanel} />
+    <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
+    <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
+    <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
+    <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);
+
+routes = React.createElement(React.Fragment, null, pluginConfig.centerPanelList.reduce(reduceRoutesFn, React.Children.toArray(routes.props.children)));
+
 export const WorkbenchPanel =
     withStyles(styles)((props: WorkbenchPanelProps) =>
         <Grid container item xs className={props.classes.root}>
@@ -167,34 +205,7 @@ export const WorkbenchPanel =
                         </Grid>
                         <Grid item xs className={props.classes.content}>
                             <Switch>
-                                <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.PROCESS_LOGS} component={ProcessLogPanel} />
-                                <Route path={Routes.SHARED_WITH_ME} component={SharedWithMePanel} />
-                                <Route path={Routes.RUN_PROCESS} component={RunProcessPanel} />
-                                <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={SshKeyPanel} />
-                                <Route path={Routes.SITE_MANAGER} component={SiteManagerPanel} />
-                                <Route path={Routes.KEEP_SERVICES} component={KeepServicePanel} />
-                                <Route path={Routes.USERS} component={UserPanel} />
-                                <Route path={Routes.COMPUTE_NODES} component={ComputeNodePanel} />
-                                <Route path={Routes.API_CLIENT_AUTHORIZATIONS} component={ApiClientAuthorizationPanel} />
-                                <Route path={Routes.MY_ACCOUNT} component={MyAccountPanel} />
-                                <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} />
+                                {routes}
                                 <Route path={Routes.NO_MATCH} component={NotFoundPanel} />
                             </Switch>
                         </Grid>
@@ -264,5 +275,6 @@ export const WorkbenchPanel =
             <VirtualMachineAttributesDialog />
             <FedLogin />
             <WebDavS3InfoDialog />
+            {React.createElement(React.Fragment, null, pluginConfig.dialogs)}
         </Grid>
     );