From: Peter Amstutz Date: Mon, 5 Apr 2021 14:32:46 +0000 (-0400) Subject: Merge branch '17426-plug-ins' refs #17426 X-Git-Tag: 2.1.2.1~3 X-Git-Url: https://git.arvados.org/arvados-workbench2.git/commitdiff_plain/31e84a9315728c2f58a26bf0e9e1d2b38326fb86?hp=0445c9903e02cf27b36c1b41ffbd76de81b9d2e8 Merge branch '17426-plug-ins' refs #17426 Arvados-DCO-1.1-Signed-off-by: Peter Amstutz --- diff --git a/cypress/integration/collection.spec.js b/cypress/integration/collection.spec.js index 8db574ce..841197ab 100644 --- a/cypress/integration/collection.spec.js +++ b/cypress/integration/collection.spec.js @@ -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', '&'], - ['&', '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', + '&', + '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'); diff --git a/cypress/integration/side-panel.spec.js b/cypress/integration/side-panel.spec.js index 309037ec..8100a0e4 100644 --- a/cypress/integration/side-panel.spec.js +++ b/cypress/integration/side-panel.spec.js @@ -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`); + }) + }) + +}) diff --git a/src/common/labels.ts b/src/common/labels.ts index c3c4fcd0..cfc2c52c 100644 --- a/src/common/labels.ts +++ b/src/common/labels.ts @@ -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/url.test.ts b/src/common/url.test.ts new file mode 100644 index 00000000..21bc518c --- /dev/null +++ b/src/common/url.test.ts @@ -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 diff --git a/src/common/url.ts b/src/common/url.ts index 9789b65e..185737ca 100644 --- a/src/common/url.ts +++ b/src/common/url.ts @@ -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; +}; diff --git a/src/common/webdav.test.ts b/src/common/webdav.test.ts index b928f82a..2ab106fc 100644 --- a/src/common/webdav.test.ts +++ b/src/common/webdav.test.ts @@ -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); }); diff --git a/src/common/webdav.ts b/src/common/webdav.ts index c4d8acae..758a5e18 100644 --- a/src/common/webdav.ts +++ b/src/common/webdav.ts @@ -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) diff --git a/src/common/xml.ts b/src/common/xml.ts index 3c6feb5d..e7db3aca 100644 --- a/src/common/xml.ts +++ b/src/common/xml.ts @@ -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) => { diff --git a/src/components/icon/icon.tsx b/src/components/icon/icon.tsx index 9eb60332..6bbacaf4 100644 --- a/src/components/icon/icon.tsx +++ b/src/components/icon/icon.tsx @@ -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) => ; export const ProcessIcon: IconType = (props) => ; export const ProjectIcon: IconType = (props) => ; +export const FilterGroupIcon: IconType = (props) => ; export const ProjectsIcon: IconType = (props) => ; export const ProvenanceGraphIcon: IconType = (props) => ; export const RemoveIcon: IconType = (props) => ; diff --git a/src/components/tree/tree.tsx b/src/components/tree/tree.tsx index 908ee28c..cf4d708d 100644 --- a/src/components/tree/tree.tsx +++ b/src/components/tree/tree.tsx @@ -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 ; }); @@ -228,7 +233,7 @@ const FlatTree = (props: FlatTreeProps) =>
- + {item.data.name} diff --git a/src/components/tree/virtual-tree.tsx b/src/components/tree/virtual-tree.tsx index 52867a83..5eb23ba1 100644 --- a/src/components/tree/virtual-tree.tsx +++ b/src/components/tree/virtual-tree.tsx @@ -188,7 +188,6 @@ export const VirtualTree = withStyles(styles)( class Component extends React.Component & WithStyles, {}> { render(): ReactElement { const { items, render } = this.props; - return {({ height, width }) => { return VirtualList(height, width, items || [], render, this.props); diff --git a/src/index.tsx b/src/index.tsx index 6f4d9dc2..43cfb5fb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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(); diff --git a/src/models/group.ts b/src/models/group.ts index e18c8ecb..365e9cce 100644 --- a/src/models/group.ts +++ b/src/models/group.ts @@ -15,5 +15,6 @@ export interface GroupResource extends TrashableResource { } export enum GroupClass { - PROJECT = "project" + PROJECT = 'project', + FILTER = 'filter', } diff --git a/src/models/project.ts b/src/models/project.ts index 8e101ce2..86ac04f6 100644 --- a/src/models/project.ts +++ b/src/models/project.ts @@ -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/services/collection-service/collection-service-files-response.test.ts b/src/services/collection-service/collection-service-files-response.test.ts new file mode 100644 index 00000000..074aa5ce --- /dev/null +++ b/src/services/collection-service/collection-service-files-response.test.ts @@ -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&%3F%2A2', 'table%&?*2', 'table%25&%3F%2A2', 'table%&?*2'], + ["G%C3%BCnter%27s%20file.pdf", "Günter's file.pdf", "G%C3%BCnter%27s%20file.pdf", "Günter's file.pdf"], + ['G%25C3%25BCnter%27s%2520file.pdf', 'G%C3%BCnter'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 = ` + + + + /c=xxxxx-zzzzz-vvvvvvvvvvvvvvv/ + + + + + + + + + + + + + + + + + Fri, 26 Mar 2021 14:44:08 GMT + + HTTP/1.1 200 OK + + + + /c=${collUUID}/${inputURL} + + + + application/pdf + + + + + + + + + + + ${inputDisplayName} + 3 + Fri, 26 Mar 2021 14:44:08 GMT + "166feb9c9110c008325a59" + + HTTP/1.1 200 OK + + + + `; + 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 diff --git a/src/services/collection-service/collection-service-files-response.ts b/src/services/collection-service/collection-service-files-response.ts index 5e6f7b83..325339d0 100644 --- a/src/services/collection-service/collection-service-files-response.ts +++ b/src/services/collection-service/collection-service-files-response.ts @@ -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 diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts index c46c3e27..9fd8a135 100644 --- a/src/services/collection-service/collection-service.ts +++ b/src/services/collection-service/collection-service.ts @@ -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 { expect(axiosInstance.get).toHaveBeenCalledWith("/groups", { params: { filters: "[" + new FilterBuilder() - .addEqual("group_class", "project") + .addIn("group_class", ["project", "filter"]) .getFilters() + "]", order: undefined } diff --git a/src/services/project-service/project-service.ts b/src/services/project-service/project-service.ts index 4ae91d4d..515571e7 100644 --- a/src/services/project-service/project-service.ts +++ b/src/services/project-service/project-service.ts @@ -20,7 +20,7 @@ export class ProjectService extends GroupsService { filters: joinFilters( args.filters || '', new FilterBuilder() - .addEqual("group_class", GroupClass.PROJECT) + .addIn('group_class', [GroupClass.PROJECT, GroupClass.FILTER]) .getFilters() ) }); diff --git a/src/store/context-menu/context-menu-actions.test.ts b/src/store/context-menu/context-menu-actions.test.ts index 2778568e..36976336 100644 --- a/src/store/context-menu/context-menu-actions.test.ts +++ b/src/store/context-menu/context-menu-actions.test.ts @@ -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(resourceUuidToContextMenuKind(resourceUuid as string)) + let menuKind: any; try { + menuKind = store.dispatch(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 +}); diff --git a/src/store/context-menu/context-menu-actions.ts b/src/store/context-menu/context-menu-actions.ts index 2982d052..1997b2a6 100644 --- a/src/store/context-menu/context-menu-actions.ts +++ b/src/store/context-menu/context-menu-actions.ts @@ -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({ @@ -201,19 +201,24 @@ export const openProcessContextMenu = (event: React.MouseEvent, 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(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(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: diff --git a/src/store/groups-panel/groups-panel-middleware-service.ts b/src/store/groups-panel/groups-panel-middleware-service.ts index f1576a23..8589c768 100644 --- a/src/store/groups-panel/groups-panel-middleware-service.ts +++ b/src/store/groups-panel/groups-panel-middleware-service.ts @@ -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 diff --git a/src/store/resource-type-filters/resource-type-filters.test.ts b/src/store/resource-type-filters/resource-type-filters.test.ts index 2f4d3cad..95d0349f 100644 --- a/src/store/resource-type-filters/resource-type-filters.test.ts +++ b/src/store/resource-type-filters/resource-type-filters.test.ts @@ -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"]`); + }); + }); diff --git a/src/store/resource-type-filters/resource-type-filters.ts b/src/store/resource-type-filters/resource-type-filters.ts index ef1198bc..26db4e9e 100644 --- a/src/store/resource-type-filters/resource-type-filters.ts +++ b/src/store/resource-type-filters/resource-type-filters.ts @@ -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(), - 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) => { + 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) => 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) => 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 +}; diff --git a/src/store/side-panel-tree/side-panel-tree-actions.ts b/src/store/side-panel-tree/side-panel-tree-actions.ts index dd0f5e68..6152b99f 100644 --- a/src/store/side-panel-tree/side-panel-tree-actions.ts +++ b/src/store/side-panel-tree/side-panel-tree-actions.ts @@ -128,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() diff --git a/src/store/tree-picker/tree-picker-actions.ts b/src/store/tree-picker/tree-picker-actions.ts index d11f7527..5d12b419 100644 --- a/src/store/tree-picker/tree-picker-actions.ts +++ b/src/store/tree-picker/tree-picker-actions.ts @@ -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(receiveTreePickerData({ 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, diff --git a/src/views-components/context-menu/action-sets/process-resource-action-set.ts b/src/views-components/context-menu/action-sets/process-resource-action-set.ts index 8cab9bfd..73a65a2d 100644 --- a/src/views-components/context-menu/action-sets/process-resource-action-set.ts +++ b/src/views-components/context-menu/action-sets/process-resource-action-set.ts @@ -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(openProcessUpdateDialog(resource)); - } - }, - { - icon: ShareIcon, - name: "Share", - execute: (dispatch, { uuid }) => { - dispatch(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(openMoveProcessDialog(resource)); - } - }, { icon: CopyIcon, name: "Copy to project", @@ -58,6 +37,31 @@ export const processResourceActionSet: ContextMenuActionSet = [[ dispatch(toggleDetailsPanel()); } }, +]]; + +export const processResourceActionSet: ContextMenuActionSet = [[ + ...readOnlyProcessResourceActionSet.reduce((prev, next) => prev.concat(next), []), + { + icon: RenameIcon, + name: "Edit process", + execute: (dispatch, resource) => { + dispatch(openProcessUpdateDialog(resource)); + } + }, + { + icon: ShareIcon, + name: "Share", + execute: (dispatch, { uuid }) => { + dispatch(openSharingDialog(uuid)); + } + }, + { + icon: MoveToIcon, + name: "Move to", + execute: (dispatch, resource) => { + dispatch(openMoveProcessDialog(resource)); + } + }, { name: "Remove", icon: RemoveIcon, diff --git a/src/views-components/context-menu/action-sets/project-action-set.test.ts b/src/views-components/context-menu/action-sets/project-action-set.test.ts index fd328221..1932194c 100644 --- a/src/views-components/context-menu/action-sets/project-action-set.test.ts +++ b/src/views-components/context-menu/action-sets/project-action-set.test.ts @@ -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)); + }) + }); +}); diff --git a/src/views-components/context-menu/action-sets/project-action-set.ts b/src/views-components/context-menu/action-sets/project-action-set.ts index 57ba0ea3..800f57d9 100644 --- a/src/views-components/context-menu/action-sets/project-action-set.ts +++ b/src/views-components/context-menu/action-sets/project-action-set.ts @@ -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(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(openProjectCreateDialog(resource.uuid)); + } + }, + ] +]; diff --git a/src/views-components/context-menu/action-sets/project-admin-action-set.ts b/src/views-components/context-menu/action-sets/project-admin-action-set.ts index a3a8ce79..982a7883 100644 --- a/src/views-components/context-menu/action-sets/project-admin-action-set.ts +++ b/src/views-components/context-menu/action-sets/project-admin-action-set.ts @@ -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(togglePublicFavorite(resource)).then(() => { + dispatch(publicFavoritePanelActions.REQUEST_ITEMS()); + }); + } + } +]]; diff --git a/src/views-components/context-menu/context-menu.tsx b/src/views-components/context-menu/context-menu.tsx index 219913cd..ee87d71a 100644 --- a/src/views-components/context-menu/context-menu.tsx +++ b/src/views-components/context-menu/context-menu.tsx @@ -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", diff --git a/src/views-components/data-explorer/renderers.tsx b/src/views-components/data-explorer/renderers.tsx index 6cf29fae..93abb15e 100644 --- a/src/views-components/data-explorer/renderers.tsx +++ b/src/views-components/data-explorer/renderers.tsx @@ -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) => @@ -59,6 +60,9 @@ export const ResourceName = connect( const renderIcon = (item: GroupContentsResource) => { switch (item.kind) { case ResourceKind.PROJECT: + if (item.groupClass === GroupClass.FILTER) { + return ; + } return ; case ResourceKind.COLLECTION: if (item.uuid === item.currentVersionUuid) { @@ -464,16 +468,16 @@ export const ResourceOwnerWithName = ; }); -const renderType = (type: string) => +const renderType = (type: string, subtype: string) => - {resourceLabel(type)} + {resourceLabel(type, subtype)} ; export const ResourceType = connect( (state: RootState, props: { uuid: string }) => { const resource = getResource(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(props.uuid)(state.resources) }; diff --git a/src/views-components/details-panel/project-details.tsx b/src/views-components/details-panel/project-details.tsx index 61797373..3feb4a7e 100644 --- a/src/views-components/details-panel/project-details.tsx +++ b/src/views-components/details-panel/project-details.tsx @@ -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 { getIcon(className?: string) { + if (this.item.groupClass === GroupClass.FILTER) { + return ; + } return ; } @@ -59,12 +63,12 @@ type ProjectDetailsComponentProps = ProjectDetailsComponentDataProps & ProjectDe const ProjectDetailsComponent = connect(null, mapDispatchToProps)( withStyles(styles)( ({ classes, project, onClick }: ProjectDetailsComponentProps) =>
- + } /> - + {project.description ? -
- -
+ {project.groupClass !== GroupClass.FILTER ? +
+ +
+ : '' + }
{ Object.keys(project.properties).map(k => diff --git a/src/views-components/sharing-dialog/participant-select.tsx b/src/views-components/sharing-dialog/participant-select.tsx index ea3775e9..0a61926e 100644 --- a/src/views-components/sharing-dialog/participant-select.tsx +++ b/src/views-components/sharing-dialog/participant-select.tsx @@ -134,7 +134,7 @@ export const ParticipantSelect = connect()( const userItems: ListResults = 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(); diff --git a/src/views-components/side-panel-button/side-panel-button.tsx b/src/views-components/side-panel-button/side-panel-button.tsx index 151cfb68..fb5ea11f 100644 --- a/src/views-components/side-panel-button/side-panel-button.tsx +++ b/src/views-components/side-panel-button/side-panel-button.tsx @@ -15,7 +15,7 @@ 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'; @@ -90,7 +90,8 @@ export const SidePanelButton = withStyles(styles)( const currentProject = getResource(currentItemId)(resources); if (currentProject && currentProject.writableBy.indexOf(currentUserUUID || '') >= 0 && - !isProjectTrashed(currentProject, resources)) { + !isProjectTrashed(currentProject, resources) && + currentProject.groupClass !== GroupClass.FILTER) { enabled = true; } } diff --git a/src/views-components/side-panel-tree/side-panel-tree.tsx b/src/views-components/side-panel-tree/side-panel-tree.tsx index e0d9777d..4c6f01a1 100644 --- a/src/views-components/side-panel-tree/side-panel-tree.tsx +++ b/src/views-components/side-panel-tree/side-panel-tree.tsx @@ -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) => { const getProjectPickerIcon = (item: TreeItem) => typeof item.data === 'string' ? getSidePanelIcon(item.data) - : ProjectIcon; + : (item.data && item.data.groupClass === GroupClass.FILTER) + ? FilterGroupIcon + : ProjectIcon; const getSidePanelIcon = (category: string) => { switch (category) { diff --git a/src/views/project-panel/project-panel.tsx b/src/views/project-panel/project-panel.tsx index 47dbd9b0..35a7f9c1 100644 --- a/src/views/project-panel/project-panel.tsx +++ b/src/views/project-panel/project-panel.tsx @@ -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, resourceUuid: string) => { const { resources } = this.props; const resource = getResource(resourceUuid)(resources); - const menuKind = this.props.dispatch(resourceUuidToContextMenuKind(resourceUuid)); + // When viewing the contents of a filter group, all contents should be treated as read only. + let readonly = false; + const project = getResource(this.props.currentItemId)(resources); + if (project && project.groupClass === GroupClass.FILTER) { + readonly = true; + } + + const menuKind = this.props.dispatch(resourceUuidToContextMenuKind(resourceUuid, readonly)); if (menuKind && resource) { this.props.dispatch(openContextMenu(event, { name: resource.name,